diff --git a/packages/alphatab/src/NotationSettings.ts b/packages/alphatab/src/NotationSettings.ts index 51514e260..669a00ed5 100644 --- a/packages/alphatab/src/NotationSettings.ts +++ b/packages/alphatab/src/NotationSettings.ts @@ -372,7 +372,12 @@ 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, } /** diff --git a/packages/alphatab/src/RenderingResources.ts b/packages/alphatab/src/RenderingResources.ts index df711ecd7..e8ba8edbf 100644 --- a/packages/alphatab/src/RenderingResources.ts +++ b/packages/alphatab/src/RenderingResources.ts @@ -53,7 +53,8 @@ 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] ]); /** @@ -381,8 +382,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/rendering/glyphs/TabBeatContainerGlyph.ts b/packages/alphatab/src/rendering/glyphs/TabBeatContainerGlyph.ts index 437899ea0..adba41f6e 100644 --- a/packages/alphatab/src/rendering/glyphs/TabBeatContainerGlyph.ts +++ b/packages/alphatab/src/rendering/glyphs/TabBeatContainerGlyph.ts @@ -56,8 +56,56 @@ export class TabBeatContainerGlyph extends BeatContainerGlyph { const tapSlur: TabTieGlyph = new TabTieGlyph(`tab.tie.leftHandTap.${n.id}`, n, n, false); this.addTie(tapSlur); } - // start effect slur on first beat - if (n.isEffectSlurOrigin && n.effectSlurDestination) { + // H/P arc start-side: create individual arc per hammer-pull pair + if (n.isHammerPullOrigin && n.hammerPullDestination) { + const dest = n.hammerPullDestination; + const slurText = dest.fret >= n.fret ? 'H' : 'P'; + let expanded: boolean = false; + for (const slur of this._effectSlurs) { + if (slur.tryExpand(n, dest, false, false, slurText)) { + expanded = true; + break; + } + } + if (!expanded) { + const effectSlur: TabSlurGlyph = new TabSlurGlyph( + `tab.slur.effect.${n.id}`, + n, + dest, + false, + false, + slurText + ); + this._effectSlurs.push(effectSlur); + this.addTie(effectSlur); + } + } + // H/P arc end-side: for cross-bar rendering + if (n.isHammerPullDestination && n.hammerPullOrigin) { + const origin = n.hammerPullOrigin; + const slurText = n.fret >= origin.fret ? 'H' : 'P'; + let expanded: boolean = false; + for (const slur of this._effectSlurs) { + if (slur.tryExpand(origin, n, false, true, slurText)) { + expanded = true; + break; + } + } + if (!expanded) { + const effectSlur: TabSlurGlyph = new TabSlurGlyph( + `tab.slur.effect.${origin.id}`, + origin, + n, + false, + true, + slurText + ); + this._effectSlurs.push(effectSlur); + this.addTie(effectSlur); + } + } + // start non-H/P effect slur (e.g. legato slide) + if (n.isEffectSlurOrigin && n.effectSlurDestination && !n.isHammerPullOrigin) { let expanded: boolean = false; for (const slur of this._effectSlurs) { if (slur.tryExpand(n, n.effectSlurDestination, false, false)) { @@ -77,8 +125,8 @@ export class TabBeatContainerGlyph extends BeatContainerGlyph { this.addTie(effectSlur); } } - // end effect slur on last beat - if (n.isEffectSlurDestination && n.effectSlurOrigin) { + // end non-H/P effect slur + if (n.isEffectSlurDestination && n.effectSlurOrigin && !n.isHammerPullDestination) { let expanded: boolean = false; for (const slur of this._effectSlurs) { if (slur.tryExpand(n.effectSlurOrigin, n, false, true)) { diff --git a/packages/alphatab/src/rendering/glyphs/TabSlurGlyph.ts b/packages/alphatab/src/rendering/glyphs/TabSlurGlyph.ts index 97ba66030..4c34d45c7 100644 --- a/packages/alphatab/src/rendering/glyphs/TabSlurGlyph.ts +++ b/packages/alphatab/src/rendering/glyphs/TabSlurGlyph.ts @@ -7,17 +7,27 @@ import { BeamDirection } from '@coderline/alphatab/rendering/utils/BeamDirection */ export class TabSlurGlyph extends TabTieGlyph { private _forSlide: boolean; + private readonly _slurText?: string; - public constructor(slurEffectId: string, startNote: Note, endNote: Note, forSlide: boolean, forEnd:boolean) { + public constructor(slurEffectId: string, startNote: Note, endNote: Note, forSlide: boolean, forEnd:boolean, slurText?: string) { super(slurEffectId, startNote, endNote, forEnd); this._forSlide = forSlide; + this._slurText = slurText; } public override getTieHeight(startX: number, _startY: number, endX: number, _endY: number): number { return (Math.log(endX - startX + 1) * this.renderer.settings.notation.slurHeight) / 2; } - public tryExpand(startNote: Note, endNote: Note, forSlide: boolean, forEnd: boolean): boolean { + protected override getSlurText(): string | undefined { + return this._slurText; + } + + public tryExpand(startNote: Note, endNote: Note, forSlide: boolean, forEnd: boolean, slurText?: string): boolean { + // same label required (when provided) + if (slurText !== undefined && this._slurText !== slurText) { + return false; + } // same type required if (this._forSlide !== forSlide) { return false; diff --git a/packages/alphatab/src/rendering/glyphs/TieGlyph.ts b/packages/alphatab/src/rendering/glyphs/TieGlyph.ts index 5b96249a0..fe002bc57 100644 --- a/packages/alphatab/src/rendering/glyphs/TieGlyph.ts +++ b/packages/alphatab/src/rendering/glyphs/TieGlyph.ts @@ -1,5 +1,6 @@ import type { Note } from '@coderline/alphatab/model/Note'; -import type { ICanvas } from '@coderline/alphatab/platform/ICanvas'; +import { NotationElement } from '@coderline/alphatab/NotationSettings'; +import { TextAlign, 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 { LineBarRenderer } from '@coderline/alphatab/rendering/LineBarRenderer'; @@ -33,6 +34,7 @@ export abstract class TieGlyph extends Glyph implements ITieGlyph { private _startX: number = 0; private _startY: number = 0; + private _slurTextY: number = 0; private _endX: number = 0; private _endY: number = 0; private _tieHeight: number = 0; @@ -146,6 +148,25 @@ export abstract class TieGlyph extends Glyph implements ITieGlyph { } this._boundingBox = tieBoundingBox; + const slurText = this.getSlurText(); + if (slurText) { + // text will be aligned on the arc with a slight padding + const c = this.renderer.scoreRenderer.canvas!; + const settings = this.renderer.settings; + const res = settings.display.resources; + c.font = res.getFontForNotationElement(NotationElement.EffectHammerOnPullOffText); + const textSize = c.measureText(slurText); + const padding = this.renderer.smuflMetrics.tieMidpointThickness; + + if (this.tieDirection === BeamDirection.Up) { + tieBoundingBox.y -= textSize.height + padding; + this._slurTextY = tieBoundingBox.y; + tieBoundingBox.h += textSize.height + padding; + } else { + this._slurTextY = tieBoundingBox.y + tieBoundingBox.h + padding; + tieBoundingBox.h += textSize.height + padding; + } + } this.height = tieBoundingBox.h; @@ -165,6 +186,8 @@ export abstract class TieGlyph extends Glyph implements ITieGlyph { return; } + const isDown = this.tieDirection === BeamDirection.Down; + if (this.shouldDrawBendSlur()) { TieGlyph.drawBendSlur( canvas, @@ -172,7 +195,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 +206,25 @@ 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 ); } + + const slurText = this.getSlurText(); + if (slurText) { + const ta = canvas.textAlign; + canvas.textAlign = TextAlign.Center; + canvas.font = this.renderer.resources.getFontForNotationElement(NotationElement.EffectHammerOnPullOffText); + const midX = cx + (this._startX + this._endX) / 2; + canvas.fillText(slurText, midX, cy + this._slurTextY); + canvas.textAlign = ta; + } + } + + protected getSlurText(): string | undefined { + return undefined; } protected abstract shouldDrawBendSlur(): boolean; diff --git a/packages/alphatab/test/visualTests/features/EffectsAndAnnotations.test.ts b/packages/alphatab/test/visualTests/features/EffectsAndAnnotations.test.ts index d35532d43..2354de30b 100644 --- a/packages/alphatab/test/visualTests/features/EffectsAndAnnotations.test.ts +++ b/packages/alphatab/test/visualTests/features/EffectsAndAnnotations.test.ts @@ -1,10 +1,10 @@ -import { describe, expect, it } from 'vitest'; import { ScoreLoader } from '@coderline/alphatab/importer/ScoreLoader'; import { LayoutMode } from '@coderline/alphatab/LayoutMode'; 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 +573,28 @@ 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')); + }); }); 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`