diff --git a/CHANGELOG.md b/CHANGELOG.md index 25a7b24..818fd9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ development artifact builds use `vMAJOR.MINOR.PATCH-dev.N`. - Added Leland glyph duration controls to the Notation window and main Notation track, with 4/5/6/7 shortcuts for eighth, quarter, half, and whole notes. - Added selectable Notation rest durations with persisted measure layouts and split-rest MusicXML export. - Added MusicXML export metadata, project tempo marking, and boxed bold Region labels. +- Fixed Notation duration button glyph alignment in the timeline and Notation window. - Fixed MusicXML export so harmony offsets are relative to the current notation position and exported notes include type values. - Added MusicXML export for Notation from the File menu and Notation window. - Added a collapsible Notation track in the main timeline, collapsed by default and saved per project. diff --git a/JammLab/Services/NotationSMuFL.swift b/JammLab/Services/NotationSMuFL.swift index cde6345..7b28180 100644 --- a/JammLab/Services/NotationSMuFL.swift +++ b/JammLab/Services/NotationSMuFL.swift @@ -137,4 +137,16 @@ enum NotationMusicFontRegistry { struct NotationSMuFLGlyphPath { var path: CGPath var bounds: CGRect + + func centeredTransform(in size: CGSize) -> CGAffineTransform { + // CoreText glyph paths use a y-up coordinate space; SwiftUI Canvas draws y-down. + CGAffineTransform( + a: 1, + b: 0, + c: 0, + d: -1, + tx: size.width / 2 - bounds.midX, + ty: size.height / 2 + bounds.midY + ) + } } diff --git a/JammLab/Views/Components/AppControls.swift b/JammLab/Views/Components/AppControls.swift index 7aa8f00..3683bd7 100644 --- a/JammLab/Views/Components/AppControls.swift +++ b/JammLab/Views/Components/AppControls.swift @@ -43,12 +43,10 @@ struct NotationDurationControl: View { return Button { denominator = option.denominator } label: { - Text(option.symbol.glyph) - .font(.custom( - NotationMusicFontRegistry.fontName, - size: AppTheme.ControlSize.notationDurationGlyphSize - )) - .foregroundStyle(iconColor(isSelected: isSelected)) + NotationDurationGlyphView( + symbol: option.symbol, + color: iconColor(isSelected: isSelected) + ) .frame( width: AppTheme.ControlSize.notationDurationButtonWidth, height: AppTheme.ControlSize.notationDurationControlHeight @@ -84,6 +82,34 @@ struct NotationDurationControl: View { } } +private struct NotationDurationGlyphView: View { + let symbol: NotationDurationControlSymbol + let color: Color + + var body: some View { + ZStack { + if let glyphPath = NotationMusicFontRegistry.glyphPath( + for: symbol, + fontSize: AppTheme.ControlSize.notationDurationGlyphSize + ) { + Canvas { context, size in + context.fill( + Path(glyphPath.path).applying(glyphPath.centeredTransform(in: size)), + with: .color(color) + ) + } + } else { + Text(symbol.glyph) + .font(.custom( + NotationMusicFontRegistry.fontName, + size: AppTheme.ControlSize.notationDurationGlyphSize + )) + .foregroundStyle(color) + } + } + } +} + private struct NotationDurationOption: Identifiable { let denominator: Int let durationName: String diff --git a/JammLabTests/NotationPrimitivesTests.swift b/JammLabTests/NotationPrimitivesTests.swift index 07c9946..1442509 100644 --- a/JammLabTests/NotationPrimitivesTests.swift +++ b/JammLabTests/NotationPrimitivesTests.swift @@ -145,6 +145,31 @@ final class NotationPrimitivesTests: XCTestCase { } } + func testLelandDurationControlGlyphPathsCenterInsideButtonFrame() throws { + let buttonSize = CGSize( + width: AppTheme.ControlSize.notationDurationButtonWidth, + height: AppTheme.ControlSize.notationDurationControlHeight + ) + + for symbol in [ + NotationDurationControlSymbol.whole, + .half, + .quarter, + .eighth + ] { + let glyphPath = try XCTUnwrap(NotationMusicFontRegistry.glyphPath( + for: symbol, + fontSize: AppTheme.ControlSize.notationDurationGlyphSize + )) + let centeredBounds = glyphPath.bounds.applying(glyphPath.centeredTransform(in: buttonSize)) + + XCTAssertEqual(centeredBounds.midX, buttonSize.width / 2, accuracy: 0.0001) + XCTAssertEqual(centeredBounds.midY, buttonSize.height / 2, accuracy: 0.0001) + XCTAssertLessThanOrEqual(centeredBounds.width, buttonSize.width) + XCTAssertLessThanOrEqual(centeredBounds.height, buttonSize.height) + } + } + func testWholeRestVisualCenterUsesStandardStaffPosition() { let y = NotationMeasureLayout.wholeRestVisualCenterY( staffTop: 10,