Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
77f5c60
fix(rendering): do not count the first beat's pre-width twice in the …
Danielku15 Oct 8, 2026
d027b80
feat(rendering): keep a minimum padding between the content of adjace…
Danielku15 Oct 8, 2026
fbb9fb6
feat(rendering): keep a minimum padding between the last beat and the…
Danielku15 Oct 8, 2026
0bad2fd
style: format BarLayoutingInfo
Danielku15 Oct 8, 2026
8ee5f2b
fix(rendering): center multi-bar rests within their bar
Danielku15 Oct 8, 2026
bda5dd6
test: update reference images for the spacing minimum changes
Danielku15 Oct 8, 2026
ee3782e
fix(rendering): keep a minimum span for ties and slurs in squeezed bars
Danielku15 Oct 8, 2026
ac0f4fd
feat(rendering): warn when bars are compressed below their minimum width
Danielku15 Oct 8, 2026
15b6782
fix(rendering): draw hammer-on/pull-off and legato slide slurs outsid…
Danielku15 Oct 8, 2026
609e339
fix(rendering): make the compression warning work in the C# and Kotli…
Danielku15 Oct 8, 2026
08482ad
fix(rendering): follow note chains for effect slurs and place them at…
Danielku15 Oct 8, 2026
e68bd52
test: update hammer-on/pull-off reference images for the effect slur …
Danielku15 Oct 8, 2026
d0c0979
test: update notation legend reference images for the effect slur pla…
Danielku15 Oct 8, 2026
c079c26
fix(rendering): place tab staff line gaps at the current note position
Danielku15 Oct 8, 2026
a9c4b04
test: update resize sequence reference images for the tab staff line …
Danielku15 Oct 8, 2026
6cbb093
test: cover effect slurs on chords and in multi-voice bars
Danielku15 Oct 8, 2026
961b448
test: add reference images for the effect slur chord and multi-voice …
Danielku15 Oct 9, 2026
ad94f25
fix(rendering): draw one tab arc per side for hammer-ons, pull-offs a…
Danielku15 Oct 9, 2026
8654162
fix(rendering): make the tab effect slur grouping compile in Kotlin
Danielku15 Oct 9, 2026
83dfdc5
test: update reference images for the tab effect slur arcs
Danielku15 Oct 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions packages/alphatab/src/EngravingSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,8 @@ export class EngravingSettings {
this.preBeatGlyphSpacing = 0.6 * this.oneStaffSpace;
this.multiVoiceDisplacedNoteHeadSpacing = 0.2 * this.oneStaffSpace;
this.staffContentPadding = 1 * this.oneStaffSpace;
this.beatContentPadding = 0.5 * this.oneStaffSpace;
this.barlineContentPadding = 1 * this.oneStaffSpace;

this.tuningGlyphStringRowPadding = 0.2 * this.oneStaffSpace;
}
Expand Down Expand Up @@ -790,6 +792,23 @@ export class EngravingSettings {
*/
public staffContentPadding = 0;

/**
* The minimum horizontal padding between the content of two adjacent beats
* (e.g. a notehead and the accidental or grace notes of the following beat).
* Additional space is only added between beats where their content would come closer than this.
* @remarks
* Behind Bars: where space is limited, characters should not be closer than 1/2 stave-space and never collide.
*/
public beatContentPadding = 0;

/**
* The minimum horizontal padding between the content of the last beat in a bar and the bar line.
* Additional space is only added where the content would come closer than this.
* @remarks
* Behind Bars: stems must never come closer to a barline than one stave-space.
*/
public barlineContentPadding = 0;

/**
* Calculates the stem height for a note of the given duration.
* @param duration The duration to calculate the height respecting flag sizes.
Expand Down
2 changes: 2 additions & 0 deletions packages/alphatab/src/generated/EngravingSettingsCloner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ export class EngravingSettingsCloner {
clone.directionsScale = original.directionsScale;
clone.multiVoiceDisplacedNoteHeadSpacing = original.multiVoiceDisplacedNoteHeadSpacing;
clone.staffContentPadding = original.staffContentPadding;
clone.beatContentPadding = original.beatContentPadding;
clone.barlineContentPadding = original.barlineContentPadding;
clone.stemFlagHeight = new Map(original.stemFlagHeight);
return clone;
}
Expand Down
15 changes: 15 additions & 0 deletions packages/alphatab/src/generated/EngravingSettingsJson.ts
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,21 @@ export interface EngravingSettingsJson {
* Behind Bars: characters should not be closer than 1/2 stave-space and never collide.
*/
staffContentPadding?: number;
/**
* The minimum horizontal padding between the content of two adjacent beats
* (e.g. a notehead and the accidental or grace notes of the following beat).
* Additional space is only added between beats where their content would come closer than this.
* @remarks
* Behind Bars: where space is limited, characters should not be closer than 1/2 stave-space and never collide.
*/
beatContentPadding?: number;
/**
* The minimum horizontal padding between the content of the last beat in a bar and the bar line.
* Additional space is only added where the content would come closer than this.
* @remarks
* Behind Bars: stems must never come closer to a barline than one stave-space.
*/
barlineContentPadding?: number;
/**
* The space needed by flags on the stem-side from top to bottom to place.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,8 @@ export class EngravingSettingsSerializer {
o.set("directionsscale", obj.directionsScale);
o.set("multivoicedisplacednoteheadspacing", obj.multiVoiceDisplacedNoteHeadSpacing);
o.set("staffcontentpadding", obj.staffContentPadding);
o.set("beatcontentpadding", obj.beatContentPadding);
o.set("barlinecontentpadding", obj.barlineContentPadding);
{
const m = new Map<string, unknown>();
o.set("stemflagheight", m);
Expand Down Expand Up @@ -447,6 +449,12 @@ export class EngravingSettingsSerializer {
case "staffcontentpadding":
obj.staffContentPadding = v! as number;
return true;
case "beatcontentpadding":
obj.beatContentPadding = v! as number;
return true;
case "barlinecontentpadding":
obj.barlineContentPadding = v! as number;
return true;
case "stemflagheight":
obj.stemFlagHeight = new Map<Duration, number>();
JsonHelper.forEach(v, (v, k) => {
Expand Down
18 changes: 18 additions & 0 deletions packages/alphatab/src/rendering/BarRendererBase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ export enum NoteXPosition {
* @internal
*/
export class BarRendererBase {
private static readonly _compressionTolerance: number = 1;
private _preBeatGlyphs = new LeftToRightLayoutingGlyphGroup();
protected readonly voiceContainer = new MultiVoiceContainerGlyph();
private readonly _postBeatGlyphs = new LeftToRightLayoutingGlyphGroup();
Expand Down Expand Up @@ -374,6 +375,22 @@ export class BarRendererBase {
return this._preBeatGlyphs.width + this._postBeatGlyphs.width;
}

/**
* Reports this bar to the layout if the available width is below the minimum width
* its content needs without overlaps.
*/
private _reportCompression(containerWidth: number): void {
const info = this.layoutingInfo;
if (info.totalSpringConstant === -1) {
return;
}
// small tolerance for rounding differences in the system width distribution
const missingWidth = info.calculateVoiceWidth(info.minStretchForce) - containerWidth;
if (missingWidth > BarRendererBase._compressionTolerance) {
this.scoreRenderer.layout!.reportCompressedBar(this.bar.masterBar.index, missingWidth);
}
}

public scaleToWidth(width: number): void {
// preBeat and postBeat glyphs do not get resized
const containerWidth: number = width - this._preBeatGlyphs.width - this._postBeatGlyphs.width;
Expand All @@ -390,6 +407,7 @@ export class BarRendererBase {
}

this.voiceContainer.scaleToWidth(containerWidth);
this._reportCompression(containerWidth);

for (const v of this.helpers.beamHelpers) {
for (const h of v) {
Expand Down
8 changes: 8 additions & 0 deletions packages/alphatab/src/rendering/LineBarRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -636,6 +636,14 @@ export abstract class LineBarRenderer extends BarRendererBase {
return this.calculateBeamYWithDirection(h, x, this.getBeamDirection(h));
}

/**
* Gets the y position of the stem end of the given beat, respecting the beam or flag it belongs to.
*/
public getBeatStemEndY(beat: Beat): number {
const helper = this.helpers.getBeamingHelperForBeat(beat)!;
return this.calculateBeamY(helper, this.getBeatX(beat, BeatXPosition.Stem));
}

protected override createPreBeatGlyphs(): void {
super.createPreBeatGlyphs();
this.addPreBeatGlyph(new BarLineGlyph(false, this.bar.staff.track.score.stylesheet.extendBarLines));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,15 @@ export class MultiBarRestBeatContainerGlyph extends BeatContainerGlyphBase {

public override applyLayoutingInfo(_info: BarLayoutingInfo): void {}

public override scaleToWidth(beatWidth: number): void {
super.scaleToWidth(beatWidth);
// Behind Bars: the multiple-rest bar is centred within the bar
const g = this._glyph;
if (g) {
g.x = Math.max(0, (beatWidth - g.width) / 2);
}
}

public override buildBoundingsLookup(_barBounds: BarBounds, _cx: number, _cy: number): void {}

public override doLayout(): void {
Expand Down
36 changes: 17 additions & 19 deletions packages/alphatab/src/rendering/ScoreBeatContainerGlyph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,12 @@ import { FlagGlyph } from '@coderline/alphatab/rendering/glyphs/FlagGlyph';
import { ScoreBeatGlyph } from '@coderline/alphatab/rendering/glyphs/ScoreBeatGlyph';
import { ScoreBeatPreNotesGlyph } from '@coderline/alphatab/rendering/glyphs/ScoreBeatPreNotesGlyph';
import { ScoreBendGlyph } from '@coderline/alphatab/rendering/glyphs/ScoreBendGlyph';
import { ScoreEffectSlurGlyph } from '@coderline/alphatab/rendering/glyphs/ScoreEffectSlurGlyph';
import { ScoreLegatoGlyph } from '@coderline/alphatab/rendering/glyphs/ScoreLegatoGlyph';
import { ScoreSlideLineGlyph } from '@coderline/alphatab/rendering/glyphs/ScoreSlideLineGlyph';
import { ScoreSlurGlyph } from '@coderline/alphatab/rendering/glyphs/ScoreSlurGlyph';
import { ScoreTieGlyph } from '@coderline/alphatab/rendering/glyphs/ScoreTieGlyph';
import type { LineBarRenderer } from '@coderline/alphatab/rendering/LineBarRenderer';
import type { ScoreBarRenderer } from '@coderline/alphatab/rendering/ScoreBarRenderer';
import { BeamDirection } from '@coderline/alphatab/rendering/utils/BeamDirection';

/**
* @internal
Expand Down Expand Up @@ -133,26 +132,25 @@ export class ScoreBeatContainerGlyph extends BeatContainerGlyph {
const tie: ScoreSlurGlyph = new ScoreSlurGlyph(`score.slur.${n.slurOrigin!.id}`, n.slurOrigin!, n, true);
this.addTie(tie);
}
// effect slurs (hammer-on, pull-off, legato slide) are a single slur per chord
// start effect slur on first beat
if (!this._effectSlur && n.isEffectSlurOrigin && n.effectSlurDestination) {
const effectSlur = new ScoreSlurGlyph(`score.slur.effect.${n.beat.id}`, n, n.effectSlurDestination, false);
this._effectSlur = effectSlur;
this.addTie(effectSlur);
const beat = n.beat;
if (!this._effectSlur && n.isEffectSlurOrigin) {
const destination = ScoreEffectSlurGlyph.getDestinationBeat(beat);
if (destination) {
const effectSlur = new ScoreEffectSlurGlyph(`score.slur.effect.${beat.id}`, beat, destination, false);
this._effectSlur = effectSlur;
this.addTie(effectSlur);
}
}
// end effect slur on last beat
if (!this._effectEndSlur && n.beat.isEffectSlurDestination && n.beat.effectSlurOrigin) {
const direction = (this.renderer as LineBarRenderer).getBeatDirection(n.beat);
const startNote =
direction === BeamDirection.Up ? n.beat.effectSlurOrigin.minNote! : n.beat.effectSlurOrigin.maxNote!;
const endNote = direction === BeamDirection.Up ? n.beat.minNote! : n.beat.maxNote!;
const effectEndSlur = new ScoreSlurGlyph(
`score.slur.effect.${startNote.beat.id}`,
startNote,
endNote,
true
);
this._effectEndSlur = effectEndSlur;
this.addTie(effectEndSlur);
if (!this._effectEndSlur && n.effectSlurOrigin) {
const origin = n.effectSlurOrigin.beat;
if (ScoreEffectSlurGlyph.getDestinationBeat(origin) === beat) {
const effectEndSlur = new ScoreEffectSlurGlyph(`score.slur.effect.${origin.id}`, origin, beat, true);
this._effectEndSlur = effectEndSlur;
this.addTie(effectEndSlur);
}
}
if (n.hasBend) {
if (!this._bend) {
Expand Down
21 changes: 11 additions & 10 deletions packages/alphatab/src/rendering/TabBarRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,11 +87,12 @@ export class TabBarRenderer extends LineBarRenderer {
* `_gapBucketEnd[i]` is the exclusive end-offset into the parallel
* `_gapRelXAndWidth` / `_gapBeatRefs` arrays for line `i`. Stores the
* width-invariant payload; absolute x is projected at paint time via
* `beatGlyphsStart + bg.x + relativeX`.
* `beatGlyphsStart + bg.x + bg.onNotes.x + relativeX` (`onNotes.x` is not width-invariant:
* centered full-bar beats shift it on every scale pass).
*/
private _gapBucketEnd: Uint32Array | null = null;
private _gapRelXAndWidth: Float32Array | null = null;
private _gapBeatRefs: BeatContainerGlyphBase[] | null = null;
private _gapBeatRefs: TabBeatContainerGlyph[] | null = null;
private _gapCount: number = 0;

public override get showMultiBarRest(): boolean {
Expand Down Expand Up @@ -166,7 +167,7 @@ export class TabBarRenderer extends LineBarRenderer {
while (i >= bucketEnd[line]) {
line++;
}
const absX = base + beatRefs[i].x + relXAndWidth[i * 2];
const absX = base + beatRefs[i].x + beatRefs[i].onNotes.x + relXAndWidth[i * 2];
spaces[line].push(new Float32Array([absX, relXAndWidth[i * 2 + 1]]));
}
}
Expand Down Expand Up @@ -218,9 +219,9 @@ export class TabBarRenderer extends LineBarRenderer {
let lineX = 0;
if (end > cursor + 1) {
let inverted = false;
let prevX = base + beatRefs[cursor].x + relXAndWidth[cursor * 2];
let prevX = base + beatRefs[cursor].x + beatRefs[cursor].onNotes.x + relXAndWidth[cursor * 2];
for (let k = cursor + 1; k < end; k++) {
const xk = base + beatRefs[k].x + relXAndWidth[k * 2];
const xk = base + beatRefs[k].x + beatRefs[k].onNotes.x + relXAndWidth[k * 2];
if (xk < prevX) {
inverted = true;
break;
Expand All @@ -233,7 +234,7 @@ export class TabBarRenderer extends LineBarRenderer {
const ws = new Float32Array(segCount);
for (let k = 0; k < segCount; k++) {
const ki = cursor + k;
xs[k] = base + beatRefs[ki].x + relXAndWidth[ki * 2];
xs[k] = base + beatRefs[ki].x + beatRefs[ki].onNotes.x + relXAndWidth[ki * 2];
ws[k] = relXAndWidth[ki * 2 + 1];
}
const order = new Uint32Array(segCount);
Expand Down Expand Up @@ -264,7 +265,7 @@ export class TabBarRenderer extends LineBarRenderer {
}

for (let k = cursor; k < end; k++) {
const gx = base + beatRefs[k].x + relXAndWidth[k * 2];
const gx = base + beatRefs[k].x + beatRefs[k].onNotes.x + relXAndWidth[k * 2];
const gw = relXAndWidth[k * 2 + 1];
canvas.fillRect(cxLocal + lineX, cyLine, gx - lineX, thickness);
lineX = gx + gw;
Expand Down Expand Up @@ -319,15 +320,15 @@ export class TabBarRenderer extends LineBarRenderer {
}

const relXAndWidth = new Float32Array(total * 2);
const beatRefs: BeatContainerGlyphBase[] = new Array(total);
const beatRefs: TabBeatContainerGlyph[] = new Array(total);
const padding: number = this.smuflMetrics.staffLineThickness;

for (const voice of this.voiceContainer.beatGlyphs.values()) {
for (const bg of voice) {
const notes: TabBeatGlyph = (bg as TabBeatContainerGlyph).onNotes as TabBeatGlyph;
const noteNumbers: TabNoteChordGlyph | null = notes.noteNumbers;
if (noteNumbers) {
const relativeXBase = notes.x + noteNumbers.x - padding;
const relativeXBase = noteNumbers.x - padding;
const fullWidth = noteNumbers.width + padding * 2;
for (const [str, noteNumber] of noteNumbers.notesPerString) {
if (!noteNumber.isEmpty) {
Expand All @@ -336,7 +337,7 @@ export class TabBarRenderer extends LineBarRenderer {
const idx = fwdCursor[lineIdx]++;
relXAndWidth[idx * 2] = relativeXBase;
relXAndWidth[idx * 2 + 1] = fullWidth;
beatRefs[idx] = bg;
beatRefs[idx] = bg as TabBeatContainerGlyph;
}
}
}
Expand Down
18 changes: 15 additions & 3 deletions packages/alphatab/src/rendering/glyphs/MultiVoiceContainerGlyph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,9 +151,21 @@ export class MultiVoiceContainerGlyph extends Glyph {

if (i > 0) {
if (currentBeatGlyph.graceIndex === 0) {
// we place the grace beat directly after the previous one
// otherwise this causes flickers on resizing
currentBeatGlyph.x = beatGlyphs[i - 1].x + beatGlyphs[i - 1].width;
// we place the grace beat directly after the content of the previous one
// (its on-time position plus the post-beat size the spacing reserves for it).
// the previous beat width is not used as it is resized to this gap below
// which would cause flickers on resizing
const previous = beatGlyphs[i - 1];
const previousSpring = this.renderer.layoutingInfo.springs.get(
previous.absoluteDisplayStart
);
currentBeatGlyph.x =
previous.graceType === GraceType.None && previousSpring
? previous.x +
previous.onTimeX +
previousSpring.postSpringWidth +
this.renderer.layoutingInfo.beatContentPadding
: previous.x + previous.width;
} else {
// for the multiple grace glyphs we take the width of the grace rod
// this width setting is aligned with the positioning logic below
Expand Down
53 changes: 53 additions & 0 deletions packages/alphatab/src/rendering/glyphs/NumberedSlurGlyph.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,64 @@
import type { Note } from '@coderline/alphatab/model/Note';
import { TabSlurGlyph } from '@coderline/alphatab/rendering/glyphs/TabSlurGlyph';
import { TabTieGlyph } from '@coderline/alphatab/rendering/glyphs/TabTieGlyph';
import { BeamDirection } from '@coderline/alphatab/rendering/utils/BeamDirection';

/**
* @internal
*/
export class NumberedSlurGlyph extends TabSlurGlyph {
private _forSlide: boolean;

public constructor(slurEffectId: string, startNote: Note, endNote: Note, forSlide: boolean, forEnd: boolean) {
super(slurEffectId, startNote, endNote, forEnd);
this._forSlide = forSlide;
}

protected override calculateTieDirection(): BeamDirection {
return BeamDirection.Up;
}

public tryExpand(startNote: Note, endNote: Note, forSlide: boolean, forEnd: boolean): boolean {
// same type required
if (this._forSlide !== forSlide) {
return false;
}
// same start and endbeat
if (this.startNote.beat.id !== startNote.beat.id) {
return false;
}
if (this.endNote.beat.id !== endNote.beat.id) {
return false;
}
const isForEnd = this.renderer === this.lookupEndBeatRenderer();
if (isForEnd !== forEnd) {
return false;
}
// same draw direction
if (this.tieDirection !== TabTieGlyph.getBeamDirectionForNote(startNote)) {
return false;
}
// if we can expand, expand in correct direction
switch (this.tieDirection) {
case BeamDirection.Up:
if (startNote.realValue > this.startNote.realValue) {
this.startNote = startNote;
this.invalidateLabels(); // labels live on startNote
}
if (endNote.realValue > this.endNote.realValue) {
this.endNote = endNote;
}
break;
case BeamDirection.Down:
if (startNote.realValue < this.startNote.realValue) {
this.startNote = startNote;
this.invalidateLabels();
}
if (endNote.realValue < this.endNote.realValue) {
this.endNote = endNote;
}
break;
}
return true;
}
}
Loading
Loading