diff --git a/packages/alphatab/src/NotationSettings.ts b/packages/alphatab/src/NotationSettings.ts index 51514e260..fcb6f41b1 100644 --- a/packages/alphatab/src/NotationSettings.ts +++ b/packages/alphatab/src/NotationSettings.ts @@ -372,7 +372,17 @@ export enum NotationElement { /** * The slurs shown on bend effects within the score staff. */ - ScoreBendSlur = 55 + ScoreBendSlur = 55, + + /** + * The hammer-on pull-off text shown on slurs. + */ + EffectHammerOnPullOffText = 56, + + /** + * The slide text shown on slurs. + */ + EffectSlideText = 57 } /** diff --git a/packages/alphatab/src/RenderingResources.ts b/packages/alphatab/src/RenderingResources.ts index df711ecd7..beb2338d0 100644 --- a/packages/alphatab/src/RenderingResources.ts +++ b/packages/alphatab/src/RenderingResources.ts @@ -53,7 +53,9 @@ export class RenderingResources { [NotationElement.RepeatCount, new Font(RenderingResources._sansFont, 11, FontStyle.Plain)], [NotationElement.BarNumber, new Font(RenderingResources._sansFont, 11, FontStyle.Plain)], [NotationElement.ScoreBendSlur, new Font(RenderingResources._sansFont, 11, FontStyle.Plain)], - [NotationElement.EffectAlternateEndings, new Font(RenderingResources._serifFont, 15, FontStyle.Plain)] + [NotationElement.EffectAlternateEndings, new Font(RenderingResources._serifFont, 15, FontStyle.Plain)], + [NotationElement.EffectHammerOnPullOffText, RenderingResources._effectFont], + [NotationElement.EffectSlideText, RenderingResources._effectFont] ]); /** @@ -381,8 +383,16 @@ export class RenderingResources { break; } + return this.getFontForNotationElement(notationElement); + } + + /** + * @internal + * @param element + */ + public getFontForNotationElement(notationElement: NotationElement): Font { return this.elementFonts.has(notationElement) ? this.elementFonts.get(notationElement)! - : RenderingResources.defaultFonts.get(NotationElement.ScoreWords)!; + : RenderingResources.defaultFonts.get(notationElement)!; } } diff --git a/packages/alphatab/src/model/Beat.ts b/packages/alphatab/src/model/Beat.ts index 42fdc428f..a0cbf8df0 100644 --- a/packages/alphatab/src/model/Beat.ts +++ b/packages/alphatab/src/model/Beat.ts @@ -13,6 +13,7 @@ import { GraceType } from '@coderline/alphatab/model/GraceType'; import { Note } from '@coderline/alphatab/model/Note'; import { Ottavia } from '@coderline/alphatab/model/Ottavia'; import { PickStroke } from '@coderline/alphatab/model/PickStroke'; +import type { Slur } from '@coderline/alphatab/model/Slur'; import { TupletGroup } from '@coderline/alphatab/model/TupletGroup'; import { VibratoType } from '@coderline/alphatab/model/VibratoType'; import type { Voice } from '@coderline/alphatab/model/Voice'; @@ -687,6 +688,24 @@ export class Beat { */ public effectSlurDestination: Beat | null = null; + /** + * Convenience accessor for the {@link Slur} of this beat. Returns + * the effect slur of whichever note in this beat owns it (the + * chain-origin note populated during `Note.finish()`), or `null` + * when no note in the beat is an effect-slur origin. + * @clone_ignore + * @json_ignore + * @internal + */ + public get effectSlur(): Slur | null { + for (const n of this.notes) { + if (n.effectSlur !== null) { + return n.effectSlur; + } + } + return null; + } + /** * Gets or sets how the beaming should be done for this beat. */ diff --git a/packages/alphatab/src/model/Note.ts b/packages/alphatab/src/model/Note.ts index 1a55a49a6..df63ee78e 100644 --- a/packages/alphatab/src/model/Note.ts +++ b/packages/alphatab/src/model/Note.ts @@ -11,6 +11,8 @@ import { NoteAccidentalMode } from '@coderline/alphatab/model/NoteAccidentalMode import { Ottavia } from '@coderline/alphatab/model/Ottavia'; import { SlideInType } from '@coderline/alphatab/model/SlideInType'; import { SlideOutType } from '@coderline/alphatab/model/SlideOutType'; +import { Slur } from '@coderline/alphatab/model/Slur'; +import { SlurSegmentKind } from '@coderline/alphatab/model/SlurSegmentKind'; import type { Staff } from '@coderline/alphatab/model/Staff'; import { VibratoType } from '@coderline/alphatab/model/VibratoType'; import { NotationMode } from '@coderline/alphatab/NotationSettings'; @@ -604,6 +606,17 @@ export class Note { */ public effectSlurDestination: Note | null = null; + /** + * The {@link Slur} object whose origin is this note. Populated by + * `finish()`; non-null only on the chain-origin note of an effect + * slur. Carries the inner articulation segments used by the + * renderer to paint H/P/sl. labels along the arc. + * @clone_ignore + * @json_ignore + * @internal + */ + public effectSlur: Slur | null = null; + /** * The ornament applied on the note. */ @@ -906,21 +919,50 @@ export class Note { break; } let effectSlurDestination: Note | null = null; + let effectSlurSegmentKind: SlurSegmentKind | null = null; if (this.isHammerPullOrigin && this.hammerPullDestination) { effectSlurDestination = this.hammerPullDestination; + effectSlurSegmentKind = SlurSegmentKind.HammerPull; } else if (this.slideOutType === SlideOutType.Legato && this.slideTarget) { effectSlurDestination = this.slideTarget; + effectSlurSegmentKind = SlurSegmentKind.LegatoSlide; } if (effectSlurDestination) { this.hasEffectSlur = true; if (this.effectSlurOrigin && this.beat.pickStroke === PickStroke.None) { - this.effectSlurOrigin.effectSlurDestination = effectSlurDestination; - this.effectSlurOrigin.effectSlurDestination.effectSlurOrigin = this.effectSlurOrigin; + const chainOrigin = this.effectSlurOrigin; + chainOrigin.effectSlurDestination = effectSlurDestination; + effectSlurDestination.effectSlurOrigin = chainOrigin; this.effectSlurOrigin = null; + + if (effectSlurSegmentKind !== null && chainOrigin.effectSlur !== null) { + chainOrigin.effectSlur.destinationNote = effectSlurDestination; + chainOrigin.effectSlur.segments.push({ + fromNote: this, + toNote: effectSlurDestination, + kind: effectSlurSegmentKind, + text: null + }); + } } else { this.isEffectSlurOrigin = true; this.effectSlurDestination = effectSlurDestination; - this.effectSlurDestination.effectSlurOrigin = this; + effectSlurDestination.effectSlurOrigin = this; + + // Always allocate a fresh Slur — finish() may run twice (worker re-finish); + // overwriting unconditionally keeps the derivation idempotent. + const slur = new Slur(); + slur.originNote = this; + slur.destinationNote = effectSlurDestination; + if (effectSlurSegmentKind !== null) { + slur.segments.push({ + fromNote: this, + toNote: effectSlurDestination, + kind: effectSlurSegmentKind, + text: null + }); + } + this.effectSlur = slur; } } // try to detect what kind of bend was used and cleans unneeded points if required diff --git a/packages/alphatab/src/model/Slur.ts b/packages/alphatab/src/model/Slur.ts new file mode 100644 index 000000000..8a42f15be --- /dev/null +++ b/packages/alphatab/src/model/Slur.ts @@ -0,0 +1,19 @@ +import type { Note } from '@coderline/alphatab/model/Note'; +import type { SlurSegment } from '@coderline/alphatab/model/SlurSegment'; + +/** + * A slur arc spanning two notes, optionally with inner articulation + * segments. Corresponds conceptually to a MusicXML `` element + * plus the technique spans inside it. + * + * For this PR only effect slurs (hammer-pull + legato-slide chains) + * are derived in `Note.finish()`. Phrase and legato slurs may join + * this type in a future PR; a discriminator will be added at that + * point. + * @internal + */ +export class Slur { + public originNote!: Note; + public destinationNote!: Note; + public segments: SlurSegment[] = []; +} diff --git a/packages/alphatab/src/model/SlurSegment.ts b/packages/alphatab/src/model/SlurSegment.ts new file mode 100644 index 000000000..453908df3 --- /dev/null +++ b/packages/alphatab/src/model/SlurSegment.ts @@ -0,0 +1,22 @@ +import type { Note } from '@coderline/alphatab/model/Note'; +import type { SlurSegmentKind } from '@coderline/alphatab/model/SlurSegmentKind'; + +/** + * One inner articulation span inside a {@link Slur}. Corresponds + * conceptually to a MusicXML `` / `` / `` + * start-stop pair nested inside the surrounding `` element. + * @record + * @internal + */ +export interface SlurSegment { + fromNote: Note; + toNote: Note; + kind: SlurSegmentKind; + /** + * Optional explicit label preserved from an external source (e.g. a + * future importer that reads MusicXML element text content). + * When null, the renderer derives the label from `kind` and note + * context — H vs P by fret/realValue comparison, "sl." for slides. + */ + text: string | null; +} diff --git a/packages/alphatab/src/model/SlurSegmentKind.ts b/packages/alphatab/src/model/SlurSegmentKind.ts new file mode 100644 index 000000000..ce58149a1 --- /dev/null +++ b/packages/alphatab/src/model/SlurSegmentKind.ts @@ -0,0 +1,13 @@ +/** + * Articulation kind for an inner span of a {@link Slur}. + * + * Drives the renderer's font selection (which {@link NotationElement} to + * use) and the default label text when {@link SlurSegment.text} is null. + * `Note.finish()` classifies the kind once when building the slur; the + * renderer never re-derives it. + * @internal + */ +export enum SlurSegmentKind { + HammerPull = 0, + LegatoSlide = 1 +} diff --git a/packages/alphatab/src/rendering/glyphs/ScoreSlurGlyph.ts b/packages/alphatab/src/rendering/glyphs/ScoreSlurGlyph.ts index 8735bd156..4e09d2e11 100644 --- a/packages/alphatab/src/rendering/glyphs/ScoreSlurGlyph.ts +++ b/packages/alphatab/src/rendering/glyphs/ScoreSlurGlyph.ts @@ -2,16 +2,36 @@ import { GraceType } from '@coderline/alphatab/model/GraceType'; import { NoteXPosition, NoteYPosition } from '@coderline/alphatab/rendering/BarRendererBase'; import { BeatXPosition } from '@coderline/alphatab/rendering/BeatXPosition'; import { ScoreTieGlyph } from '@coderline/alphatab/rendering/glyphs/ScoreTieGlyph'; +import { TieGlyphLabels, type TieGlyphLabel } from '@coderline/alphatab/rendering/glyphs/TieGlyphLabel'; import { BeamDirection } from '@coderline/alphatab/rendering/utils/BeamDirection'; /** * @internal */ export class ScoreSlurGlyph extends ScoreTieGlyph { + private _labels: TieGlyphLabel[] | null = null; + public override getTieHeight(startX: number, _startY: number, endX: number, _endY: number): number { return (Math.log2(endX - startX + 1) * this.renderer.settings.notation.slurHeight) / 2; } + protected override getSlurLabels(): TieGlyphLabel[] | null { + if (this._labels === null) { + this._labels = []; + const slur = this.startNote.beat.effectSlur; + if (slur !== null) { + const notationSettings = this.renderer.settings.notation; + for (const s of slur.segments) { + const label = TieGlyphLabels.build(s, s.toNote.realValue >= s.fromNote.realValue); + if (notationSettings.isNotationElementVisible(label.element)) { + this._labels.push(label); + } + } + } + } + return this._labels.length > 0 ? this._labels : null; + } + protected override calculateStartX(): number { return ( this.renderer.x + diff --git a/packages/alphatab/src/rendering/glyphs/TabSlurGlyph.ts b/packages/alphatab/src/rendering/glyphs/TabSlurGlyph.ts index 97ba66030..380eeaf13 100644 --- a/packages/alphatab/src/rendering/glyphs/TabSlurGlyph.ts +++ b/packages/alphatab/src/rendering/glyphs/TabSlurGlyph.ts @@ -1,5 +1,6 @@ import type { Note } from '@coderline/alphatab/model/Note'; import { TabTieGlyph } from '@coderline/alphatab/rendering/glyphs/TabTieGlyph'; +import { TieGlyphLabels, type TieGlyphLabel } from '@coderline/alphatab/rendering/glyphs/TieGlyphLabel'; import { BeamDirection } from '@coderline/alphatab/rendering/utils/BeamDirection'; /** @@ -7,8 +8,9 @@ import { BeamDirection } from '@coderline/alphatab/rendering/utils/BeamDirection */ export class TabSlurGlyph extends TabTieGlyph { private _forSlide: boolean; + private _labels: TieGlyphLabel[] | null = null; - public constructor(slurEffectId: string, startNote: Note, endNote: Note, forSlide: boolean, forEnd:boolean) { + public constructor(slurEffectId: string, startNote: Note, endNote: Note, forSlide: boolean, forEnd: boolean) { super(slurEffectId, startNote, endNote, forEnd); this._forSlide = forSlide; } @@ -17,6 +19,23 @@ export class TabSlurGlyph extends TabTieGlyph { return (Math.log(endX - startX + 1) * this.renderer.settings.notation.slurHeight) / 2; } + protected override getSlurLabels(): TieGlyphLabel[] | null { + if (this._labels === null) { + this._labels = []; + const slur = this.startNote.effectSlur; + if (slur !== null) { + const notationSettings = this.renderer.settings.notation; + for (const s of slur.segments) { + const label = TieGlyphLabels.build(s, s.toNote.fret >= s.fromNote.fret); + if (notationSettings.isNotationElementVisible(label.element)) { + this._labels.push(label); + } + } + } + } + return this._labels.length > 0 ? this._labels : null; + } + public tryExpand(startNote: Note, endNote: Note, forSlide: boolean, forEnd: boolean): boolean { // same type required if (this._forSlide !== forSlide) { @@ -42,6 +61,7 @@ export class TabSlurGlyph extends TabTieGlyph { case BeamDirection.Up: if (startNote.realValue > this.startNote.realValue) { this.startNote = startNote; + this._labels = null; // invalidate cache — labels live on startNote } if (endNote.realValue > this.endNote.realValue) { this.endNote = endNote; @@ -50,6 +70,7 @@ export class TabSlurGlyph extends TabTieGlyph { case BeamDirection.Down: if (startNote.realValue < this.startNote.realValue) { this.startNote = startNote; + this._labels = null; } if (endNote.realValue < this.endNote.realValue) { this.endNote = endNote; diff --git a/packages/alphatab/src/rendering/glyphs/TieGlyph.ts b/packages/alphatab/src/rendering/glyphs/TieGlyph.ts index 5b96249a0..f19a036eb 100644 --- a/packages/alphatab/src/rendering/glyphs/TieGlyph.ts +++ b/packages/alphatab/src/rendering/glyphs/TieGlyph.ts @@ -1,7 +1,8 @@ import type { Note } from '@coderline/alphatab/model/Note'; -import type { ICanvas } from '@coderline/alphatab/platform/ICanvas'; +import { TextAlign, TextBaseline, type ICanvas } from '@coderline/alphatab/platform/ICanvas'; import { type BarRendererBase, NoteXPosition, NoteYPosition } from '@coderline/alphatab/rendering/BarRendererBase'; import { Glyph } from '@coderline/alphatab/rendering/glyphs/Glyph'; +import type { ResolvedTieGlyphLabel, TieGlyphLabel } from '@coderline/alphatab/rendering/glyphs/TieGlyphLabel'; import type { LineBarRenderer } from '@coderline/alphatab/rendering/LineBarRenderer'; import { BeamDirection } from '@coderline/alphatab/rendering/utils/BeamDirection'; import { Bounds } from '@coderline/alphatab/rendering/utils/Bounds'; @@ -38,6 +39,12 @@ export abstract class TieGlyph extends Glyph implements ITieGlyph { private _tieHeight: number = 0; private _boundingBox?: Bounds; private _shouldPaint: boolean = false; + // Resolved per-label paint state. Lazily grown; re-layouts mutate + // existing entries in place and update `_resolvedLabelCount` to + // signal how many of them are valid this pass. + private _resolvedLabels: ResolvedTieGlyphLabel[] = []; + private _resolvedLabelCount: number = 0; + private _labelBaselineOffset: number = 0; public get checkForOverflow() { return this._shouldPaint && this._boundingBox !== undefined; @@ -119,7 +126,14 @@ export abstract class TieGlyph extends Glyph implements ITieGlyph { this._boundingBox = undefined; this.y = Math.min(this._startY, this._endY); + const down = this.tieDirection === BeamDirection.Down; let tieBoundingBox: Bounds; + // Bezier control points for the tie. Computed once and reused + // for both the bounding box (via _calculateActualTieHeightFromCps) + // and label-apex sampling further below — avoids a redundant + // call to _computeBezierControlPoints (and its 14-element array + // allocation) per labeled slur per layout. + let cps: number[] = []; if (this.shouldDrawBendSlur()) { this._tieHeight = 0; tieBoundingBox = TieGlyph.calculateBendSlurHeight( @@ -127,25 +141,100 @@ export abstract class TieGlyph extends Glyph implements ITieGlyph { this._startY, this._endX, this._endY, - this.tieDirection === BeamDirection.Down, + down, this.renderer.smuflMetrics.tieHeight ); } else { this._tieHeight = this.getTieHeight(this._startX, this._startY, this._endX, this._endY); - - tieBoundingBox = TieGlyph.calculateActualTieHeight( + const tieThickness = this.renderer.smuflMetrics.tieMidpointThickness; + cps = TieGlyph._computeBezierControlPoints( 1, this._startX, this._startY, this._endX, this._endY, - this.tieDirection === BeamDirection.Down, + down, this._tieHeight, - this.renderer.smuflMetrics.tieMidpointThickness + tieThickness + ); + tieBoundingBox = TieGlyph._calculateActualTieHeightFromCps( + cps, + this._startX, + this._startY, + this._endX, + this._endY, + down, + tieThickness ); } this._boundingBox = tieBoundingBox; + this._resolvedLabelCount = 0; + const labels = this.getSlurLabels(); + if (labels !== null && labels.length > 0 && this.shouldPaintLabels()) { + const res = this.renderer.settings.display.resources; + const padding = this.renderer.smuflMetrics.oneStaffSpace * 0.25; + let maxTextHeight = 0; + + // Single Y line for all labels — the outer arc apex. + // Painted offset adds `padding` on the outward side, so + // every label sits the same fixed distance from its arc. + const labelLineY = cps.length > 0 + ? 0.125 * cps[7] + 0.375 * cps[9] + 0.375 * cps[11] + 0.125 * cps[13] + : (this._startY + this._endY) / 2; + + for (const label of labels) { + const fromX = this.resolveLabelAnchorX(label.fromNote); + const toX = this.resolveLabelAnchorX(label.toNote); + if (fromX === null || toX === null) { + continue; + } + const midX = (fromX + toX) / 2; + if (midX < this._startX || midX > this._endX) { + continue; + } + + // Per-element font.size as an upper bound on glyph + // height — avoids per-label measureText calls. All H/P + // and sl. labels use the same _effectFont, so this is + // typically computed once. + const font = res.getFontForNotationElement(label.element); + if (font.size > maxTextHeight) { + maxTextHeight = font.size; + } + + // grow cache lazily; mutate existing slot in place otherwise + let slot: ResolvedTieGlyphLabel; + if (this._resolvedLabelCount < this._resolvedLabels.length) { + slot = this._resolvedLabels[this._resolvedLabelCount]; + slot.x = midX; + slot.y = labelLineY; + slot.text = label.text; + slot.element = label.element; + } else { + slot = { + x: midX, + y: labelLineY, + text: label.text, + element: label.element + }; + this._resolvedLabels.push(slot); + } + this._resolvedLabelCount++; + } + + if (this._resolvedLabelCount > 0) { + // canvas.textBaseline is 'hanging' (TextBaseline.Top), so + // fillText positions `y` at the glyph's top edge. + if (this.tieDirection === BeamDirection.Up) { + tieBoundingBox.y -= maxTextHeight + padding; + this._labelBaselineOffset = -(maxTextHeight + padding); + } else { + this._labelBaselineOffset = padding; + } + tieBoundingBox.h += maxTextHeight + padding; + } + } this.height = tieBoundingBox.h; @@ -165,6 +254,8 @@ export abstract class TieGlyph extends Glyph implements ITieGlyph { return; } + const isDown = this.tieDirection === BeamDirection.Down; + if (this.shouldDrawBendSlur()) { TieGlyph.drawBendSlur( canvas, @@ -172,7 +263,7 @@ export abstract class TieGlyph extends Glyph implements ITieGlyph { cy + this._startY, cx + this._endX, cy + this._endY, - this.tieDirection === BeamDirection.Down, + isDown, this.renderer.smuflMetrics.tieHeight ); } else { @@ -183,11 +274,79 @@ export abstract class TieGlyph extends Glyph implements ITieGlyph { cy + this._startY, cx + this._endX, cy + this._endY, - this.tieDirection === BeamDirection.Down, + isDown, this._tieHeight, this.renderer.smuflMetrics.tieMidpointThickness ); } + + if (this._resolvedLabelCount > 0) { + const ta = canvas.textAlign; + const tb = canvas.textBaseline; + canvas.textAlign = TextAlign.Center; + canvas.textBaseline = TextBaseline.Top; + const res = this.renderer.resources; + let lastElement = -1; + for (let i = 0; i < this._resolvedLabelCount; i++) { + const label = this._resolvedLabels[i]; + if (label.element !== lastElement) { + canvas.font = res.getFontForNotationElement(label.element); + lastElement = label.element; + } + canvas.fillText(label.text, cx + label.x, cy + label.y + this._labelBaselineOffset); + } + canvas.textAlign = ta; + canvas.textBaseline = tb; + } + } + + /** + * Returns the labels to paint along this slur, or `null` when there + * are none. Override in subclasses. + */ + protected getSlurLabels(): TieGlyphLabel[] | null { + return null; + } + + /** + * Whether label painting is enabled. Defaults to `true`. Subclasses + * may override to disable labels on the bend-slur path or other + * special cases. + */ + protected shouldPaintLabels(): boolean { + return !this.shouldDrawBendSlur(); + } + + /** + * Looks up the absolute X coordinate of an anchor note. Reuses + * the start/end bar renderers already resolved by the subclass + * (NoteTieGlyph) when the note's bar matches — most labels live + * in the slur's start or end bar, so this avoids the double Map + * lookup in `getRendererForBar` per label per layout. Returns + * `null` when the note's bar is not rendered on this glyph's + * staff (cross-system case). + */ + protected resolveLabelAnchorX(note: Note): number | null { + const bar = note.beat.voice.bar; + let renderer: LineBarRenderer | null = null; + const start = this.lookupStartBeatRenderer(); + if (start !== null && start.bar === bar) { + renderer = start; + } else { + const end = this.lookupEndBeatRenderer(); + if (end !== null && end.bar === bar) { + renderer = end; + } else { + renderer = this.renderer.scoreRenderer.layout!.getRendererForBar( + this.renderer.staff!.staffId, + bar + ) as LineBarRenderer | null; + } + } + if (renderer === null) { + return null; + } + return renderer.x + renderer.getNoteX(note, NoteXPosition.Center); } protected abstract shouldDrawBendSlur(): boolean; @@ -236,12 +395,27 @@ export abstract class TieGlyph extends Glyph implements ITieGlyph { size: number ): Bounds { const cp = TieGlyph._computeBezierControlPoints(scale, x1, y1, x2, y2, down, offset, size); + return TieGlyph._calculateActualTieHeightFromCps(cp, x1, y1, x2, y2, down, size); + } + + /** + * Derives the bounding box for a tie from already-computed control + * points. Splits the bbox math from cps generation so callers that + * need BOTH cps and bbox (e.g. multi-label slur layout) avoid a + * second call to `_computeBezierControlPoints`. + */ + private static _calculateActualTieHeightFromCps( + cp: number[], + x1: number, + y1: number, + x2: number, + y2: number, + down: boolean, + size: number + ): Bounds { if (cp.length === 0) { return new Bounds(x1, y1, x2 - x1, y2 - y1); } - - // For a musical tie/slur, the extrema occur predictably near the midpoint - // Evaluate at midpoint (t=0.5) and check endpoints const p0x = cp[0]; const p0y = cp[1]; const c1x = cp[2]; @@ -251,17 +425,14 @@ export abstract class TieGlyph extends Glyph implements ITieGlyph { const p1x = cp[6]; const p1y = cp[7]; - // Evaluate at t=0.5 for midpoint const midX = 0.125 * p0x + 0.375 * c1x + 0.375 * c2x + 0.125 * p1x; const midY = 0.125 * p0y + 0.375 * c1y + 0.375 * c2y + 0.125 * p1y; - // Bounds are simply min/max of start, end, and midpoint const xMin = Math.min(p0x, p1x, midX); const xMax = Math.max(p0x, p1x, midX); let yMin = Math.min(p0y, p1y, midY); let yMax = Math.max(p0y, p1y, midY); - // Account for thickness of the tie/slur if (down) { yMax += size; } else { @@ -360,6 +531,7 @@ export abstract class TieGlyph extends Glyph implements ITieGlyph { return [rotateX + rx, rotateY + ry]; } + public static paintTie( canvas: ICanvas, scale: number, diff --git a/packages/alphatab/src/rendering/glyphs/TieGlyphLabel.ts b/packages/alphatab/src/rendering/glyphs/TieGlyphLabel.ts new file mode 100644 index 000000000..8e6ca4359 --- /dev/null +++ b/packages/alphatab/src/rendering/glyphs/TieGlyphLabel.ts @@ -0,0 +1,63 @@ +import type { Note } from '@coderline/alphatab/model/Note'; +import type { SlurSegment } from '@coderline/alphatab/model/SlurSegment'; +import { SlurSegmentKind } from '@coderline/alphatab/model/SlurSegmentKind'; +import { NotationElement } from '@coderline/alphatab/NotationSettings'; + +/** + * One resolved label to paint along a tie/slur arc. Built once per + * slur glyph from the model-side {@link SlurSegment}s; consumed by + * `TieGlyph` during layout (X/Y resolution) and paint. + * @record + * @internal + */ +export interface TieGlyphLabel { + fromNote: Note; + toNote: Note; + text: string; + element: NotationElement; +} + +/** + * A label whose paint position has been resolved against the current + * layout. Stored on the glyph in a lazily-grown cache so re-layouts + * mutate existing entries instead of allocating. + * @record + * @internal + */ +export interface ResolvedTieGlyphLabel { + x: number; + y: number; + text: string; + element: NotationElement; +} + +/** + * Helpers for building `TieGlyphLabel` instances from model-side + * {@link SlurSegment}s. + * @internal + */ +export class TieGlyphLabels { + /** + * Builds a `TieGlyphLabel` for one segment of a slur. The + * `isAscending` flag selects between the H/P glyph for hammer-on + * vs. pull-off — score side passes a comparison on `realValue`, + * tab side passes a comparison on `fret`. + */ + public static build(s: SlurSegment, isAscending: boolean): TieGlyphLabel { + if (s.kind === SlurSegmentKind.LegatoSlide) { + return { + fromNote: s.fromNote, + toNote: s.toNote, + text: s.text !== null ? s.text : 'sl.', + element: NotationElement.EffectSlideText + }; + } + // HammerPull + return { + fromNote: s.fromNote, + toNote: s.toNote, + text: s.text !== null ? s.text : isAscending ? 'H' : 'P', + element: NotationElement.EffectHammerOnPullOffText + }; + } +} diff --git a/packages/alphatab/test-data/visual-tests/bounds-lookup/onnotes-beat.png b/packages/alphatab/test-data/visual-tests/bounds-lookup/onnotes-beat.png index 4e41112c2..1161cfd40 100644 Binary files a/packages/alphatab/test-data/visual-tests/bounds-lookup/onnotes-beat.png and b/packages/alphatab/test-data/visual-tests/bounds-lookup/onnotes-beat.png differ diff --git a/packages/alphatab/test-data/visual-tests/bounds-lookup/real-bar.png b/packages/alphatab/test-data/visual-tests/bounds-lookup/real-bar.png index fe88f0d8c..598ba0fa4 100644 Binary files a/packages/alphatab/test-data/visual-tests/bounds-lookup/real-bar.png and b/packages/alphatab/test-data/visual-tests/bounds-lookup/real-bar.png differ diff --git a/packages/alphatab/test-data/visual-tests/bounds-lookup/real-beat.png b/packages/alphatab/test-data/visual-tests/bounds-lookup/real-beat.png index 69881d535..49d2b3362 100644 Binary files a/packages/alphatab/test-data/visual-tests/bounds-lookup/real-beat.png and b/packages/alphatab/test-data/visual-tests/bounds-lookup/real-beat.png differ diff --git a/packages/alphatab/test-data/visual-tests/bounds-lookup/real-master.png b/packages/alphatab/test-data/visual-tests/bounds-lookup/real-master.png index e456e29f3..b89766cc2 100644 Binary files a/packages/alphatab/test-data/visual-tests/bounds-lookup/real-master.png and b/packages/alphatab/test-data/visual-tests/bounds-lookup/real-master.png differ diff --git a/packages/alphatab/test-data/visual-tests/bounds-lookup/real-note.png b/packages/alphatab/test-data/visual-tests/bounds-lookup/real-note.png index 38c956a70..10b41fb99 100644 Binary files a/packages/alphatab/test-data/visual-tests/bounds-lookup/real-note.png and b/packages/alphatab/test-data/visual-tests/bounds-lookup/real-note.png differ diff --git a/packages/alphatab/test-data/visual-tests/bounds-lookup/real-system.png b/packages/alphatab/test-data/visual-tests/bounds-lookup/real-system.png index 4ff1a70f7..2cb4c4dfa 100644 Binary files a/packages/alphatab/test-data/visual-tests/bounds-lookup/real-system.png and b/packages/alphatab/test-data/visual-tests/bounds-lookup/real-system.png differ diff --git a/packages/alphatab/test-data/visual-tests/bounds-lookup/visual-bar.png b/packages/alphatab/test-data/visual-tests/bounds-lookup/visual-bar.png index fe88f0d8c..598ba0fa4 100644 Binary files a/packages/alphatab/test-data/visual-tests/bounds-lookup/visual-bar.png and b/packages/alphatab/test-data/visual-tests/bounds-lookup/visual-bar.png differ diff --git a/packages/alphatab/test-data/visual-tests/bounds-lookup/visual-beat.png b/packages/alphatab/test-data/visual-tests/bounds-lookup/visual-beat.png index dddbc9514..f06e90c00 100644 Binary files a/packages/alphatab/test-data/visual-tests/bounds-lookup/visual-beat.png and b/packages/alphatab/test-data/visual-tests/bounds-lookup/visual-beat.png differ diff --git a/packages/alphatab/test-data/visual-tests/bounds-lookup/visual-master.png b/packages/alphatab/test-data/visual-tests/bounds-lookup/visual-master.png index 8818a4164..0f5d63e6c 100644 Binary files a/packages/alphatab/test-data/visual-tests/bounds-lookup/visual-master.png and b/packages/alphatab/test-data/visual-tests/bounds-lookup/visual-master.png differ diff --git a/packages/alphatab/test-data/visual-tests/bounds-lookup/visual-note.png b/packages/alphatab/test-data/visual-tests/bounds-lookup/visual-note.png index 38c956a70..10b41fb99 100644 Binary files a/packages/alphatab/test-data/visual-tests/bounds-lookup/visual-note.png and b/packages/alphatab/test-data/visual-tests/bounds-lookup/visual-note.png differ diff --git a/packages/alphatab/test-data/visual-tests/bounds-lookup/visual-system.png b/packages/alphatab/test-data/visual-tests/bounds-lookup/visual-system.png index 8e3d94e75..5578287ee 100644 Binary files a/packages/alphatab/test-data/visual-tests/bounds-lookup/visual-system.png and b/packages/alphatab/test-data/visual-tests/bounds-lookup/visual-system.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/chords.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/chords.png index c2ddb1693..45308b807 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/chords.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/chords.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-asc-then-desc.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-asc-then-desc.png new file mode 100644 index 000000000..def200d1b Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-asc-then-desc.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at1.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at1.png new file mode 100644 index 000000000..2fb19ff66 Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at1.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at10.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at10.png new file mode 100644 index 000000000..4a1813295 Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at10.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at11.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at11.png new file mode 100644 index 000000000..9a5255e5d Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at11.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at12.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at12.png new file mode 100644 index 000000000..d7cefbc39 Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at12.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at13.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at13.png new file mode 100644 index 000000000..2004ee2ae Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at13.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at14.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at14.png new file mode 100644 index 000000000..763a58feb Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at14.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at2.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at2.png new file mode 100644 index 000000000..a58d4c608 Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at2.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at3.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at3.png new file mode 100644 index 000000000..ac4426f03 Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at3.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at4.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at4.png new file mode 100644 index 000000000..1c9fb1120 Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at4.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at5.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at5.png new file mode 100644 index 000000000..8a2842216 Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at5.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at6.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at6.png new file mode 100644 index 000000000..98790d58f Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at6.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at7.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at7.png new file mode 100644 index 000000000..14d4491e7 Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at7.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at8.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at8.png new file mode 100644 index 000000000..fd2e50864 Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at8.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at9.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at9.png new file mode 100644 index 000000000..929a74d4c Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at9.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-chord-with-chain.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-chord-with-chain.png new file mode 100644 index 000000000..b64aecccd Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-chord-with-chain.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-labels-disabled.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-labels-disabled.png new file mode 100644 index 000000000..f13e35db6 Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-labels-disabled.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-mixed-h-slide.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-mixed-h-slide.png new file mode 100644 index 000000000..ae8d239e0 Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-mixed-h-slide.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-mixed-slide-h-p.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-mixed-slide-h-p.png new file mode 100644 index 000000000..0ac567bfe Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-mixed-slide-h-p.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-pull-off-chain.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-pull-off-chain.png new file mode 100644 index 000000000..8ad135117 Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-pull-off-chain.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-score-only.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-score-only.png new file mode 100644 index 000000000..d31249a11 Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-score-only.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-tab-only.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-tab-only.png new file mode 100644 index 000000000..c37ee2d66 Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-tab-only.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/slides.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/slides.png index 9cdf27e2c..945992e53 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/slides.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/slides.png differ diff --git a/packages/alphatab/test-data/visual-tests/guitar-tabs/rhythm-with-beams.png b/packages/alphatab/test-data/visual-tests/guitar-tabs/rhythm-with-beams.png index cec2bae48..d00d9e033 100644 Binary files a/packages/alphatab/test-data/visual-tests/guitar-tabs/rhythm-with-beams.png and b/packages/alphatab/test-data/visual-tests/guitar-tabs/rhythm-with-beams.png differ diff --git a/packages/alphatab/test-data/visual-tests/guitar-tabs/rhythm.png b/packages/alphatab/test-data/visual-tests/guitar-tabs/rhythm.png index fa37ac2fc..0431f90f4 100644 Binary files a/packages/alphatab/test-data/visual-tests/guitar-tabs/rhythm.png and b/packages/alphatab/test-data/visual-tests/guitar-tabs/rhythm.png differ diff --git a/packages/alphatab/test-data/visual-tests/guitar-tabs/string-variations.png b/packages/alphatab/test-data/visual-tests/guitar-tabs/string-variations.png index c0755bd4b..0861804be 100644 Binary files a/packages/alphatab/test-data/visual-tests/guitar-tabs/string-variations.png and b/packages/alphatab/test-data/visual-tests/guitar-tabs/string-variations.png differ diff --git a/packages/alphatab/test-data/visual-tests/layout/horizontal-layout.png b/packages/alphatab/test-data/visual-tests/layout/horizontal-layout.png index 7237389d2..c0e5e337c 100644 Binary files a/packages/alphatab/test-data/visual-tests/layout/horizontal-layout.png and b/packages/alphatab/test-data/visual-tests/layout/horizontal-layout.png differ diff --git a/packages/alphatab/test-data/visual-tests/layout/multi-track.png b/packages/alphatab/test-data/visual-tests/layout/multi-track.png index c81924394..bf211092d 100644 Binary files a/packages/alphatab/test-data/visual-tests/layout/multi-track.png and b/packages/alphatab/test-data/visual-tests/layout/multi-track.png differ diff --git a/packages/alphatab/test-data/visual-tests/layout/page-layout-5barsperrow.png b/packages/alphatab/test-data/visual-tests/layout/page-layout-5barsperrow.png index af8e89109..aea34f916 100644 Binary files a/packages/alphatab/test-data/visual-tests/layout/page-layout-5barsperrow.png and b/packages/alphatab/test-data/visual-tests/layout/page-layout-5barsperrow.png differ diff --git a/packages/alphatab/test-data/visual-tests/layout/page-layout-justify-last-row.png b/packages/alphatab/test-data/visual-tests/layout/page-layout-justify-last-row.png index 2df9adf3d..4e6195adf 100644 Binary files a/packages/alphatab/test-data/visual-tests/layout/page-layout-justify-last-row.png and b/packages/alphatab/test-data/visual-tests/layout/page-layout-justify-last-row.png differ diff --git a/packages/alphatab/test-data/visual-tests/layout/page-layout.png b/packages/alphatab/test-data/visual-tests/layout/page-layout.png index be0d4a460..b373a000c 100644 Binary files a/packages/alphatab/test-data/visual-tests/layout/page-layout.png and b/packages/alphatab/test-data/visual-tests/layout/page-layout.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/full-default-large.png b/packages/alphatab/test-data/visual-tests/notation-legend/full-default-large.png index 6724a9b6d..bbf9949be 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/full-default-large.png and b/packages/alphatab/test-data/visual-tests/notation-legend/full-default-large.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/full-default-small.png b/packages/alphatab/test-data/visual-tests/notation-legend/full-default-small.png index feb3c491b..57faec369 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/full-default-small.png and b/packages/alphatab/test-data/visual-tests/notation-legend/full-default-small.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/full-default.png b/packages/alphatab/test-data/visual-tests/notation-legend/full-default.png index fb4c9f547..7c49b5266 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/full-default.png and b/packages/alphatab/test-data/visual-tests/notation-legend/full-default.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/full-songbook.png b/packages/alphatab/test-data/visual-tests/notation-legend/full-songbook.png index cfe1f47cf..8ccb412b9 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/full-songbook.png and b/packages/alphatab/test-data/visual-tests/notation-legend/full-songbook.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/grace-default.png b/packages/alphatab/test-data/visual-tests/notation-legend/grace-default.png index 02a526402..e0d7c516e 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/grace-default.png and b/packages/alphatab/test-data/visual-tests/notation-legend/grace-default.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/grace-songbook.png b/packages/alphatab/test-data/visual-tests/notation-legend/grace-songbook.png index 2001b4833..43e6e3a02 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/grace-songbook.png and b/packages/alphatab/test-data/visual-tests/notation-legend/grace-songbook.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/hammer-default.png b/packages/alphatab/test-data/visual-tests/notation-legend/hammer-default.png index 4dd89f7bb..3e1c779b9 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/hammer-default.png and b/packages/alphatab/test-data/visual-tests/notation-legend/hammer-default.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/hammer-songbook.png b/packages/alphatab/test-data/visual-tests/notation-legend/hammer-songbook.png index 4dd89f7bb..3e1c779b9 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/hammer-songbook.png and b/packages/alphatab/test-data/visual-tests/notation-legend/hammer-songbook.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/mixed-default.png b/packages/alphatab/test-data/visual-tests/notation-legend/mixed-default.png index ca1644730..09b1aa47d 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/mixed-default.png and b/packages/alphatab/test-data/visual-tests/notation-legend/mixed-default.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/mixed-songbook.png b/packages/alphatab/test-data/visual-tests/notation-legend/mixed-songbook.png index 80504bcc6..f836570bf 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/mixed-songbook.png and b/packages/alphatab/test-data/visual-tests/notation-legend/mixed-songbook.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/resize-sequence-1300.png b/packages/alphatab/test-data/visual-tests/notation-legend/resize-sequence-1300.png index 6ec3e7089..5a3b437b2 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/resize-sequence-1300.png and b/packages/alphatab/test-data/visual-tests/notation-legend/resize-sequence-1300.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/resize-sequence-1500.png b/packages/alphatab/test-data/visual-tests/notation-legend/resize-sequence-1500.png index 649ff8e9c..6114a47d1 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/resize-sequence-1500.png and b/packages/alphatab/test-data/visual-tests/notation-legend/resize-sequence-1500.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/resize-sequence-500.png b/packages/alphatab/test-data/visual-tests/notation-legend/resize-sequence-500.png index bbe2ce253..d7cc33386 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/resize-sequence-500.png and b/packages/alphatab/test-data/visual-tests/notation-legend/resize-sequence-500.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/resize-sequence-800.png b/packages/alphatab/test-data/visual-tests/notation-legend/resize-sequence-800.png index 7281b935c..7d6c122e6 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/resize-sequence-800.png and b/packages/alphatab/test-data/visual-tests/notation-legend/resize-sequence-800.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/slides-default.png b/packages/alphatab/test-data/visual-tests/notation-legend/slides-default.png index 77366babd..8841ed27b 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/slides-default.png and b/packages/alphatab/test-data/visual-tests/notation-legend/slides-default.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/slides-songbook.png b/packages/alphatab/test-data/visual-tests/notation-legend/slides-songbook.png index 77366babd..8841ed27b 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/slides-songbook.png and b/packages/alphatab/test-data/visual-tests/notation-legend/slides-songbook.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/smufl-petaluma-1300.png b/packages/alphatab/test-data/visual-tests/notation-legend/smufl-petaluma-1300.png index aa37cbd87..ca949e06a 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/smufl-petaluma-1300.png and b/packages/alphatab/test-data/visual-tests/notation-legend/smufl-petaluma-1300.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/tap-riff-default.png b/packages/alphatab/test-data/visual-tests/notation-legend/tap-riff-default.png index 80e73d4d4..c1381ef61 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/tap-riff-default.png and b/packages/alphatab/test-data/visual-tests/notation-legend/tap-riff-default.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/tap-riff-songbook.png b/packages/alphatab/test-data/visual-tests/notation-legend/tap-riff-songbook.png index 80e73d4d4..c1381ef61 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/tap-riff-songbook.png and b/packages/alphatab/test-data/visual-tests/notation-legend/tap-riff-songbook.png differ diff --git a/packages/alphatab/test-data/visual-tests/special-notes/grace-notes-advanced-1300-2.png b/packages/alphatab/test-data/visual-tests/special-notes/grace-notes-advanced-1300-2.png index 5e4f2535c..fa4355ab3 100644 Binary files a/packages/alphatab/test-data/visual-tests/special-notes/grace-notes-advanced-1300-2.png and b/packages/alphatab/test-data/visual-tests/special-notes/grace-notes-advanced-1300-2.png differ diff --git a/packages/alphatab/test-data/visual-tests/special-notes/grace-notes-advanced-1300.png b/packages/alphatab/test-data/visual-tests/special-notes/grace-notes-advanced-1300.png index 4afdc155a..4699573ff 100644 Binary files a/packages/alphatab/test-data/visual-tests/special-notes/grace-notes-advanced-1300.png and b/packages/alphatab/test-data/visual-tests/special-notes/grace-notes-advanced-1300.png differ diff --git a/packages/alphatab/test-data/visual-tests/special-notes/grace-notes-advanced-800.png b/packages/alphatab/test-data/visual-tests/special-notes/grace-notes-advanced-800.png index bc788e0ca..19d4399ca 100644 Binary files a/packages/alphatab/test-data/visual-tests/special-notes/grace-notes-advanced-800.png and b/packages/alphatab/test-data/visual-tests/special-notes/grace-notes-advanced-800.png differ diff --git a/packages/alphatab/test-data/visual-tests/special-notes/grace-notes-advanced.png b/packages/alphatab/test-data/visual-tests/special-notes/grace-notes-advanced.png index 4afdc155a..4699573ff 100644 Binary files a/packages/alphatab/test-data/visual-tests/special-notes/grace-notes-advanced.png and b/packages/alphatab/test-data/visual-tests/special-notes/grace-notes-advanced.png differ diff --git a/packages/alphatab/test-data/visual-tests/special-tracks/numbered.png b/packages/alphatab/test-data/visual-tests/special-tracks/numbered.png index 3a0bd29dd..554fbd63e 100644 Binary files a/packages/alphatab/test-data/visual-tests/special-tracks/numbered.png and b/packages/alphatab/test-data/visual-tests/special-tracks/numbered.png differ diff --git a/packages/alphatab/test/visualTests/features/EffectsAndAnnotations.test.ts b/packages/alphatab/test/visualTests/features/EffectsAndAnnotations.test.ts index d35532d43..85224ac11 100644 --- a/packages/alphatab/test/visualTests/features/EffectsAndAnnotations.test.ts +++ b/packages/alphatab/test/visualTests/features/EffectsAndAnnotations.test.ts @@ -1,10 +1,11 @@ -import { describe, expect, it } from 'vitest'; import { ScoreLoader } from '@coderline/alphatab/importer/ScoreLoader'; import { LayoutMode } from '@coderline/alphatab/LayoutMode'; +import { NotationElement } from '@coderline/alphatab/NotationSettings'; import { BeatBarreEffectInfo } from '@coderline/alphatab/rendering/effects/BeatBarreEffectInfo'; import { Settings } from '@coderline/alphatab/Settings'; import { TestPlatform } from 'test/TestPlatform'; import { VisualTestHelper, VisualTestOptions, VisualTestRun } from 'test/visualTests/VisualTestHelper'; +import { describe, expect, it } from 'vitest'; describe('EffectsAndAnnotationsTests', () => { it('markers', async () => { @@ -573,4 +574,66 @@ describe('EffectsAndAnnotationsTests', () => { ); }); }); + + describe('hopo-arcs', () => { + async function test(test: string, tex: string) { + await VisualTestHelper.runVisualTestTex( + tex, + `test-data/visual-tests/effects-and-annotations/hopo-arcs-${test}.png` + ); + } + + it('at1', async () => await test('at1', ':4 5.3{h} 7.3 r r')); + it('at2', async () => await test('at2', ':4 7.3{h} 5.3 r r')); + it('at3', async () => await test('at3', ':4 5.3{h} 7.3 7.3{h} 5.3')); + it('at4', async () => await test('at4', ':4 5.3{h} 7.3 8.4{h} 5.4')); + it('at5', async () => await test('at5', ':4 5.3{h} 7.3{h} 5.3 r')); + it('at6', async () => await test('at6', ':8 5.3{h} 7.3{h} 5.3{h} 7.3 r r r r')); + it('at7', async () => await test('at7', ':4 5.3{sl} 7.3 r r')); + it('at8', async () => await test('at8', ':4 5.3 7.3 5.3 7.3')); + it('at9', async () => await test('at9', ':4 (5.3{h} 5.4) (7.3 7.4) r r')); + it('at10', async () => await test('at10', ':4 (5.3 5.4{h}) (7.3 7.4) r r')); + it('at11', async () => await test('at11', ':4 (5.3{h} 5.4{h}) (7.3 7.4) r r')); + it('at12', async () => await test('at12', ':4 (5.3{h} 7.4{h}) (7.3 5.4) r r')); + it('at13', async () => await test('at13', ':4 (5.3{h} 7.4{h}) (7.3{h} 5.4{h}) (5.3 7.4) r')); + it('at14', async () => await test('at14', ':4 5.3 {h} 7.3{h} 5.3 | 5.4 {h} 7.4{h} 5.4')); + + // Pure descending pull-off chain + it('pull-off-chain', async () => await test('pull-off-chain', ':4 9.3{h} 7.3{h} 5.3 r')); + + // Mixed hammer-on + legato slide in one chain — the core + // "combined effects" case that motivated the redesign. Both + // labels (H, sl.) appear above the single arc. + it('mixed-h-slide', async () => await test('mixed-h-slide', ':4 5.3{h} 7.3{sl} 9.3 r')); + it('mixed-slide-h-p', async () => await test('mixed-slide-h-p', ':4 5.3{sl} 7.3{h} 9.3{h} 7.3')); + + // Chain that swings up then down: H then P inside one arc + it('asc-then-desc', async () => await test('asc-then-desc', ':4 5.3{h} 7.3{h} 9.3{h} 7.3{h} 5.3')); + + // Three-note chord with H/P chain on the upper string + it('chord-with-chain', async () => + await test('chord-with-chain', ':4 (5.3{h} 5.4 5.5) (7.3{h} 7.4 7.5) (5.3 5.4 5.5) r')); + + // Score-only — confirms ScoreSlurGlyph paints labels even + // without the tab staff present. + it('score-only', async () => + await test('score-only', '\\track "T" \\staff {score} :4 5.3{h} 7.3{h} 5.3{sl} 7.3')); + + // Tab-only — confirms TabSlurGlyph paints labels in isolation. + it('tab-only', async () => + await test('tab-only', '\\track "T" \\staff {tabs} :4 5.3{h} 7.3{h} 5.3{sl} 7.3')); + + // Labels disabled via NotationSettings — arcs still render but + // without H/P/sl. text above them. + it('labels-disabled', async () => { + const settings = new Settings(); + settings.notation.elements.set(NotationElement.EffectHammerOnPullOffText, false); + settings.notation.elements.set(NotationElement.EffectSlideText, false); + await VisualTestHelper.runVisualTestTex( + ':4 5.3{h} 7.3{h} 5.3{sl} 7.3', + 'test-data/visual-tests/effects-and-annotations/hopo-arcs-labels-disabled.png', + settings + ); + }); + }); }); diff --git a/packages/playground/src/apps/TestResultsApp.ts b/packages/playground/src/apps/TestResultsApp.ts index 545a74c3b..ab83a9b2f 100644 --- a/packages/playground/src/apps/TestResultsApp.ts +++ b/packages/playground/src/apps/TestResultsApp.ts @@ -6,10 +6,17 @@ import { type Mountable, css, html, injectStyles, parseHtml } from '../util/Dom' injectStyles( 'TestResultsApp', css` + body { + justify-content: flex-start; + } + body > * { + overflow: visible; + } .at-test-results { padding: 1rem; font-family: 'Noto Sans', sans-serif; min-height: 100vh; + max-width: 90vw; } .at-test-results > h1 { margin-top: 0; } .at-test-results-toolbar { margin: 1rem 0; } @@ -26,12 +33,48 @@ injectStyles( margin: 0 0 8px 0; } .at-test-comparer { position: relative; } - .at-test-comparer .slider { + .at-test-comparer .slider-handle { position: absolute; - top: 30px; - right: 0; - left: 0; - width: 100%; + bottom: 0; + width: 40px; + transform: translateX(-50%); + cursor: ew-resize; + z-index: 10; + touch-action: none; + user-select: none; + } + .at-test-comparer .slider-handle::before { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: 50%; + width: 2px; + transform: translateX(-50%); + background: rgba(255, 255, 255, 0.9); + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.25), 0 0 4px rgba(0, 0, 0, 0.15); + pointer-events: none; + } + .at-test-comparer .slider-handle::after { + content: ''; + position: sticky; + top: calc(50vh - 20px); + display: block; + width: 40px; + height: 40px; + margin-top: var(--knob-margin-top, 0); + background-color: #fff; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M9 18L3 12l6-6M15 6l6 6-6 6' fill='none' stroke='%23555' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: center; + background-size: 22px; + border-radius: 50%; + border: 1.5px solid rgba(0, 0, 0, 0.15); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25), 0 0 0 1px rgba(0, 0, 0, 0.06); + pointer-events: none; + } + .at-test-comparer .slider-handle:hover::after { + box-shadow: 0 3px 12px rgba(0, 0, 0, 0.35), 0 0 0 1.5px rgba(0, 0, 0, 0.12); } .at-test-comparer .expected, .at-test-comparer .actual, @@ -63,9 +106,11 @@ injectStyles( body.hide-accepted .at-test-card.accepted { display: none; } .at-test-controls { - position: absolute; + position: sticky; top: 0; - left: 0; + z-index: 20; + background: #fff; + padding: 6px 0; display: flex; gap: 12px; align-items: center; @@ -199,9 +244,7 @@ export class TestResultsApp implements Mountable { this.listEl.replaceChildren(); this.currentResults = results; if (results.length === 0) { - const banner = parseHtml( - html`
No reported errors on visual tests.
` - ); + const banner = parseHtml(html`
No reported errors on visual tests.
`); this.listEl.appendChild(banner); this.updateRemaining(); return; @@ -216,15 +259,15 @@ export class TestResultsApp implements Mountable { const card = parseHtml(html`
${result.originalFile}
+
+ + +
expected
actual
diff
- -
- - -
+
`); @@ -232,7 +275,7 @@ export class TestResultsApp implements Mountable { const ex = comparer.querySelector('.expected')!; const ac = comparer.querySelector('.actual')!; const df = comparer.querySelector('.diff')!; - const slider = comparer.querySelector('.slider')!; + const handle = comparer.querySelector('.slider-handle')!; const exImg = ex.querySelector('img')!; const acImg = ac.querySelector('img')!; const dfImg = df.querySelector('img')!; @@ -245,26 +288,33 @@ export class TestResultsApp implements Mountable { const width = Math.max(exImg.width, acImg.width); const height = Math.max(exImg.height, acImg.height); - const controlsHeight = 60; comparer.style.width = `${width}px`; - comparer.style.height = `${height + controlsHeight}px`; + comparer.style.height = `${height}px`; ex.style.width = `${width}px`; ex.style.height = `${height}px`; - ex.style.top = `${controlsHeight}px`; ac.style.width = `${width / 2}px`; ac.style.height = `${height}px`; - ac.style.top = `${controlsHeight}px`; df.style.width = `${width}px`; df.style.height = `${height}px`; - df.style.top = `${controlsHeight}px`; - slider.oninput = () => { - ac.style.width = `${width * (1 - slider.valueAsNumber)}px`; - }; - comparer.querySelector('.diff-toggle')!.onchange = e => { + handle.style.left = `${width / 2}px`; + handle.style.setProperty('--knob-margin-top', `${height / 2 - 20}px`); + + handle.addEventListener('pointerdown', e => { + handle.setPointerCapture(e.pointerId); + e.preventDefault(); + }); + handle.addEventListener('pointermove', e => { + if (!e.buttons) { return; } + const rect = comparer.getBoundingClientRect(); + const x = Math.max(0, Math.min(e.clientX - rect.left, width)); + handle.style.left = `${x}px`; + ac.style.width = `${width - x}px`; + }); + card.querySelector('.diff-toggle')!.onchange = e => { df.style.display = (e.target as HTMLInputElement).checked ? 'block' : 'none'; }; - const acceptBtn = comparer.querySelector('.accept')!; + const acceptBtn = card.querySelector('.accept')!; acceptBtn.onclick = async () => { acceptBtn.disabled = true; acceptBtn.textContent = 'Accepting...';