From cde7df9f5fc80d127eaeddde8ab4b7686db6c706 Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Thu, 9 Jul 2026 16:47:03 +0300 Subject: [PATCH] feat: add notation duration tooltips --- CHANGELOG.md | 2 + JammLab/Utilities/AppHotkey.swift | 16 ++++-- JammLab/Views/Components/AppControls.swift | 51 +++++++++++++++++-- ...tkeyNotationDurationEventFilterTests.swift | 23 +++++++++ .../AppHotkeyNotationMappingTests.swift | 45 ++++++++++++++++ JammLabTests/NotationPrimitivesTests.swift | 38 ++++++++++++++ 6 files changed, 166 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e07e2f0..890e6dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ development artifact builds use `vMAJOR.MINOR.PATCH-dev.N`. ## Unreleased +- Added clearer hover tooltips and numpad shortcuts for Notation duration buttons. + ## 1.1.0 ### New Features diff --git a/JammLab/Utilities/AppHotkey.swift b/JammLab/Utilities/AppHotkey.swift index 468bf7c..d4eebaa 100644 --- a/JammLab/Utilities/AppHotkey.swift +++ b/JammLab/Utilities/AppHotkey.swift @@ -77,13 +77,13 @@ enum AppHotkey: CaseIterable, Hashable { self = .setBeatOne case 8: self = .toggleClick - case 21: + case 21, 86: self = .setNotationDurationEighth - case 23: + case 23, 87: self = .setNotationDurationQuarter - case 22: + case 22, 88: self = .setNotationDurationHalf - case 26: + case 26, 89: self = .setNotationDurationWhole case 53: self = .clearNotationMeasureSelection @@ -235,4 +235,12 @@ enum AppHotkey: CaseIterable, Hashable { return nil } } + + static func notationDurationShortcutText(for denominator: Int) -> String? { + guard let hotkey = allCases.first(where: { + notationDurationHotkeys.contains($0) + && $0.notationDurationDenominator == denominator + }) else { return nil } + return "\(hotkey.key); Num\(hotkey.key)" + } } diff --git a/JammLab/Views/Components/AppControls.swift b/JammLab/Views/Components/AppControls.swift index 3683bd7..48a84ce 100644 --- a/JammLab/Views/Components/AppControls.swift +++ b/JammLab/Views/Components/AppControls.swift @@ -38,7 +38,7 @@ struct NotationDurationControl: View { private func durationButton(for option: NotationDurationOption) -> some View { let isSelected = NotationDuration.normalizedDenominator(denominator) == option.denominator - let helpText = "Set \(ControlHelpText.notationDuration.lowercased()) to \(option.durationName)" + let helpText = NotationDurationControlHelpText.tooltip(for: option.duration) return Button { denominator = option.denominator @@ -62,8 +62,9 @@ struct NotationDurationControl: View { } .buttonStyle(.plain) .disabled(!isEnabled) - .help(helpText) - .accessibilityLabel("Notation duration: \(option.durationName)") + .help(Text(helpText)) + .accessibilityLabel(NotationDurationControlHelpText.accessibilityLabel(for: option.duration)) + .accessibilityHint(NotationDurationControlHelpText.accessibilityHint(for: option.duration)) .accessibilityValue(isSelected ? "Selected" : "Not selected") .accessibilityAddTraits(isSelected ? [.isSelected] : []) } @@ -112,7 +113,7 @@ private struct NotationDurationGlyphView: View { private struct NotationDurationOption: Identifiable { let denominator: Int - let durationName: String + let duration: NotationDuration let symbol: NotationDurationControlSymbol var id: Int { denominator } @@ -124,7 +125,47 @@ private struct NotationDurationOption: Identifiable { } self.denominator = duration.denominator - self.durationName = duration.pluralDisplayName + self.duration = duration self.symbol = symbol } } + +enum NotationDurationControlHelpText { + static func tooltip(for duration: NotationDuration) -> String { + let title = title(for: duration) + let shortcut = AppHotkey.notationDurationShortcutText(for: duration.denominator) + let firstLine = shortcut.map { "\(title) (\($0))" } ?? title + return "\(firstLine)\nSet duration: \(lowercaseTitle(for: duration))" + } + + static func accessibilityLabel(for duration: NotationDuration) -> String { + "\(duration.displayName.capitalized) note duration" + } + + static func accessibilityHint(for duration: NotationDuration) -> String { + "Sets notation duration to \(duration.displayName) note" + } + + private static func title(for duration: NotationDuration) -> String { + "\(duration.displayName.capitalized) (\(traditionalName(for: duration))) note" + } + + private static func lowercaseTitle(for duration: NotationDuration) -> String { + "\(duration.displayName) (\(traditionalName(for: duration))) note" + } + + private static func traditionalName(for duration: NotationDuration) -> String { + switch duration.denominator { + case 1: + return "semibreve" + case 2: + return "minim" + case 4: + return "crotchet" + case 8: + return "quaver" + default: + return duration.displayName + } + } +} diff --git a/JammLabTests/AppHotkeyNotationDurationEventFilterTests.swift b/JammLabTests/AppHotkeyNotationDurationEventFilterTests.swift index feb6353..c1c31a0 100644 --- a/JammLabTests/AppHotkeyNotationDurationEventFilterTests.swift +++ b/JammLabTests/AppHotkeyNotationDurationEventFilterTests.swift @@ -38,6 +38,29 @@ final class AppHotkeyNotationDurationEventFilterTests: XCTestCase { ), .setNotationDurationEighth ) + + let numpadDurationEvent = try XCTUnwrap(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.numericPad], + timestamp: 0, + windowNumber: 42, + context: nil, + characters: "4", + charactersIgnoringModifiers: "4", + isARepeat: false, + keyCode: 86 + )) + XCTAssertEqual( + AppHotkeyEventFilter.hotkey( + for: numpadDurationEvent, + attachedWindowNumber: 42, + firstResponder: nil, + allowedHotkeys: AppHotkey.notationDurationHotkeys + ), + .setNotationDurationEighth + ) + XCTAssertNil( AppHotkeyEventFilter.hotkey( for: durationEvent, diff --git a/JammLabTests/AppHotkeyNotationMappingTests.swift b/JammLabTests/AppHotkeyNotationMappingTests.swift index 62f033d..0654c44 100644 --- a/JammLabTests/AppHotkeyNotationMappingTests.swift +++ b/JammLabTests/AppHotkeyNotationMappingTests.swift @@ -49,6 +49,51 @@ final class AppHotkeyNotationMappingTests: XCTestCase { XCTAssertTrue(AppHotkey.allCases.contains(.setNotationDurationWhole)) } + func testAppHotkeyRecognizesNotationDurationNumpadKeys() throws { + let expectations: [(key: String, keyCode: UInt16, hotkey: AppHotkey, denominator: Int)] = [ + ("4", 86, .setNotationDurationEighth, 8), + ("5", 87, .setNotationDurationQuarter, 4), + ("6", 88, .setNotationDurationHalf, 2), + ("7", 89, .setNotationDurationWhole, 1) + ] + + for expectation in expectations { + let event = try XCTUnwrap(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [], + timestamp: 0, + windowNumber: 0, + context: nil, + characters: expectation.key, + charactersIgnoringModifiers: expectation.key, + isARepeat: false, + keyCode: expectation.keyCode + )) + + XCTAssertEqual(AppHotkey(event: event), expectation.hotkey) + XCTAssertEqual(expectation.hotkey.notationDurationDenominator, expectation.denominator) + } + } + + func testAppHotkeyNotationDurationShortcutTextUsesPrimaryAndNumpadKeys() { + let expectations: [(denominator: Int, shortcutText: String)] = [ + (8, "4; Num4"), + (4, "5; Num5"), + (2, "6; Num6"), + (1, "7; Num7") + ] + + for expectation in expectations { + XCTAssertEqual( + AppHotkey.notationDurationShortcutText(for: expectation.denominator), + expectation.shortcutText + ) + } + + XCTAssertNil(AppHotkey.notationDurationShortcutText(for: 16)) + } + func testAppHotkeyRecognizesCmdKButNotOldHarmonyKeys() throws { let aEvent = try XCTUnwrap(NSEvent.keyEvent( with: .keyDown, diff --git a/JammLabTests/NotationPrimitivesTests.swift b/JammLabTests/NotationPrimitivesTests.swift index 1442509..2c68b2e 100644 --- a/JammLabTests/NotationPrimitivesTests.swift +++ b/JammLabTests/NotationPrimitivesTests.swift @@ -170,6 +170,44 @@ final class NotationPrimitivesTests: XCTestCase { } } + func testNotationDurationControlHelpTextIncludesNameAndShortcuts() { + let expectations: [(denominator: Int, tooltip: String)] = [ + ( + 1, + "Whole (semibreve) note (7; Num7)\nSet duration: whole (semibreve) note" + ), + ( + 2, + "Half (minim) note (6; Num6)\nSet duration: half (minim) note" + ), + ( + 4, + "Quarter (crotchet) note (5; Num5)\nSet duration: quarter (crotchet) note" + ), + ( + 8, + "Eighth (quaver) note (4; Num4)\nSet duration: eighth (quaver) note" + ) + ] + + for expectation in expectations { + XCTAssertEqual( + NotationDurationControlHelpText.tooltip(for: NotationDuration(denominator: expectation.denominator)), + expectation.tooltip + ) + } + + let quarter = NotationDuration(denominator: 4) + XCTAssertEqual( + NotationDurationControlHelpText.accessibilityLabel(for: quarter), + "Quarter note duration" + ) + XCTAssertEqual( + NotationDurationControlHelpText.accessibilityHint(for: quarter), + "Sets notation duration to quarter note" + ) + } + func testWholeRestVisualCenterUsesStandardStaffPosition() { let y = NotationMeasureLayout.wholeRestVisualCenterY( staffTop: 10,