Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 12 additions & 4 deletions JammLab/Utilities/AppHotkey.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)"
}
}
51 changes: 46 additions & 5 deletions JammLab/Views/Components/AppControls.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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] : [])
}
Expand Down Expand Up @@ -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 }
Expand All @@ -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
}
}
}
23 changes: 23 additions & 0 deletions JammLabTests/AppHotkeyNotationDurationEventFilterTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
45 changes: 45 additions & 0 deletions JammLabTests/AppHotkeyNotationMappingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
38 changes: 38 additions & 0 deletions JammLabTests/NotationPrimitivesTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down