Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

slab/menuband: localization scaffold + hover-link chips + tighter voice pill

- Localization.swift adds plug-and-play L("key") with English + Spanish tables; popover rebuilds on language change.
- HoverLinkButton paints a real link/chip (idle + hover fill/border, pointing-hand cursor) for the Why-this-Keymap and brand badges.
- Music-note pill in KeyboardIconRenderer now hugs the actual digits of voiceNumber instead of reserving a fixed 12pt pad.
- viewDidChangeEffectiveAppearance re-resolves the popover background so light/dark toggles repaint cleanly.
- Bundles keymaps-social-software-26-arxiv.pdf as a resource for the Why-this-Keymap chip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

+546 -90
+57 -28
slab/menuband/Sources/MenuBand/AppDelegate.swift
··· 286 286 // first click pops it instantly. With `animates = false` the 287 287 // open/close has no transition — it's a snap, much more "playable" 288 288 // for quickly toggling between the menubar piano and the picker. 289 + installPopoverVC() 290 + // .applicationDefined: never auto-close. We manage closing manually 291 + // so clicking a menubar piano key (which would normally count as 292 + // "outside" the popover under .transient) doesn't dismiss the 293 + // popover while the user is playing. 294 + popover.behavior = .applicationDefined 295 + popover.animates = false 296 + 297 + // Language change → rebuild the popover with the new translations. 298 + // Cheaper than walking every label with a setter, and means future 299 + // strings just have to live in `Localization.tables` to participate. 300 + NotificationCenter.default.addObserver( 301 + forName: Localization.didChange, 302 + object: nil, queue: .main 303 + ) { [weak self] _ in 304 + self?.rebuildPopoverForLanguageChange() 305 + } 306 + 307 + startAdaptiveLayoutChecks() 308 + startShiftStateMonitors() 309 + startVisualizerAnimation() 310 + 311 + // Retint the bundle's Finder icon to the user's accent color. 312 + // Stored as an xattr on the bundle folder, so the signed payload 313 + // isn't modified. Refreshed whenever the accent changes. 314 + IconTinter.applyTintedIcon() 315 + NotificationCenter.default.addObserver( 316 + forName: NSColor.systemColorsDidChangeNotification, 317 + object: nil, queue: .main 318 + ) { _ in 319 + IconTinter.applyTintedIcon() 320 + } 321 + } 322 + 323 + // MARK: - Popover lifecycle 324 + 325 + /// Build a fresh `MenuBandPopoverViewController`, hand it our callbacks, 326 + /// and install it as the popover's content. Called once at launch and 327 + /// again whenever the language flips so every label rebuilds against 328 + /// the new translation table without us having to track each one. 329 + private func installPopoverVC() { 289 330 let vc = MenuBandPopoverViewController() 290 331 vc.menuBand = menuBand 291 332 vc.popover = popover ··· 309 350 } 310 351 popoverVC = vc 311 352 popover.contentViewController = vc 312 - // .applicationDefined: never auto-close. We manage closing manually 313 - // so clicking a menubar piano key (which would normally count as 314 - // "outside" the popover under .transient) doesn't dismiss the 315 - // popover while the user is playing. 316 - popover.behavior = .applicationDefined 317 - popover.animates = false 318 353 _ = vc.view 319 - 320 - startAdaptiveLayoutChecks() 321 - startShiftStateMonitors() 322 - startVisualizerAnimation() 354 + } 323 355 324 - // Retint the bundle's Finder icon to the user's accent color. 325 - // Stored as an xattr on the bundle folder, so the signed payload 326 - // isn't modified. Refreshed whenever the accent changes. 327 - IconTinter.applyTintedIcon() 328 - NotificationCenter.default.addObserver( 329 - forName: NSColor.systemColorsDidChangeNotification, 330 - object: nil, queue: .main 331 - ) { _ in 332 - IconTinter.applyTintedIcon() 356 + /// Swap the popover's content for a freshly-built VC so every string 357 + /// re-reads from the current locale. Preserves whether the popover was 358 + /// open — re-shows it relative to the status item if so. 359 + private func rebuildPopoverForLanguageChange() { 360 + let wasShown = popover.isShown 361 + if wasShown { popover.performClose(nil) } 362 + installPopoverVC() 363 + popoverVC?.syncFromController() 364 + if wasShown, let button = statusItem.button { 365 + popover.show(relativeTo: button.bounds, 366 + of: button, 367 + preferredEdge: .minY) 333 368 } 334 369 } 335 370 ··· 730 765 731 766 private func alertNoMenuBarSpace() { 732 767 let alert = NSAlert() 733 - alert.messageText = "Menu Band can't fit in your menu bar" 734 - alert.informativeText = """ 735 - There's no room in your menu bar — even for the compact icon. \ 736 - Try quitting an app that puts items in the menu bar (slack, \ 737 - dropbox, etc.), or use Bartender / Hidden Bar to manage them. 738 - 739 - Menu Band will keep trying every few seconds. 740 - """ 768 + alert.messageText = L("alert.noMenuBarSpace.title") 769 + alert.informativeText = L("alert.noMenuBarSpace.body") 741 770 alert.alertStyle = .informational 742 - alert.addButton(withTitle: "OK") 771 + alert.addButton(withTitle: L("alert.ok")) 743 772 NSApp.activate(ignoringOtherApps: true) 744 773 alert.runModal() 745 774 }
+21 -4
slab/menuband/Sources/MenuBand/KeyboardIconRenderer.swift
··· 831 831 // any other status-bar item. 832 832 let iconBox = settingsIconRect 833 833 if hovered { 834 - // Pill encompasses the music note AND the voice-badge 835 - // pad as one target — hovering either part highlights 836 - // the same chip, so the user gets unified feedback. 834 + // Pill hugs the actual drawn content: just the music note 835 + // when voiceNumber is 0, expanding rightward only as far as 836 + // the voice-number digits actually flow when present. Avoids 837 + // a chunky 12pt of empty pad on the right when the user is 838 + // on the default Acoustic Grand (program 0). 839 + var pillRightExtra: CGFloat = 1 840 + if voiceNumber > 0 { 841 + let digitFont = NSFont.monospacedDigitSystemFont(ofSize: 7, weight: .heavy) 842 + let label = NSAttributedString(string: String(voiceNumber), attributes: [ 843 + .font: digitFont, .kern: -0.4, 844 + ]) 845 + let oneDigitW = NSAttributedString(string: "0", attributes: [ 846 + .font: digitFont, 847 + ]).size().width 848 + // First digit hugs iconBox's right edge (see digit-draw 849 + // logic below); additional digits flow rightward into 850 + // the badge pad. Pad just enough to cover them + a 2pt 851 + // breathing margin past the rightmost digit. 852 + pillRightExtra = max(1, label.size().width - oneDigitW + 2) 853 + } 837 854 let pill = NSRect( 838 855 x: iconBox.minX - 1, 839 856 y: iconBox.minY + 1, 840 - width: (iconBox.width + Self.voiceBadgeRightPad + 1), 857 + width: iconBox.width + pillRightExtra, 841 858 height: iconBox.height - 2 842 859 ) 843 860 let path = NSBezierPath(roundedRect: pill, xRadius: 4, yRadius: 4)
+206
slab/menuband/Sources/MenuBand/Localization.swift
··· 1 + import Foundation 2 + import AppKit 3 + 4 + // Plug-and-play interface localization for Menu Band. 5 + // 6 + // Strings used in the popover, menus, alerts, and any other user-visible 7 + // surface should pull through `L("key")` rather than hardcoding. Adding a 8 + // new language is a matter of dropping another dictionary into `tables` — 9 + // any unknown key falls back to English, then to the literal key, so a 10 + // half-translated language never produces a blank string. 11 + enum Localization { 12 + 13 + // MARK: - Languages 14 + 15 + struct Language { 16 + let code: String 17 + let label: String // native-language label for the picker 18 + let flag: String // emoji flag for the picker chip 19 + } 20 + 21 + static let supported: [Language] = [ 22 + Language(code: "en", label: "English", flag: "🇺🇸"), 23 + Language(code: "es", label: "Español", flag: "🇪🇸"), 24 + ] 25 + 26 + static let didChange = Notification.Name("MenuBandLanguageDidChange") 27 + private static let userDefaultsKey = "menuband.language" 28 + 29 + static var current: String { 30 + get { 31 + UserDefaults.standard.string(forKey: userDefaultsKey) 32 + ?? defaultLanguage() 33 + } 34 + set { 35 + guard supported.contains(where: { $0.code == newValue }) else { return } 36 + UserDefaults.standard.set(newValue, forKey: userDefaultsKey) 37 + NotificationCenter.default.post(name: didChange, object: nil) 38 + } 39 + } 40 + 41 + static func language(for code: String) -> Language { 42 + supported.first(where: { $0.code == code }) ?? supported[0] 43 + } 44 + 45 + /// Best-effort match of the OS preferred language to one of our supported 46 + /// codes. Falls back to English when the user's locale isn't represented. 47 + private static func defaultLanguage() -> String { 48 + let pref = (Locale.preferredLanguages.first ?? "en").lowercased() 49 + for lang in supported where pref.hasPrefix(lang.code) { 50 + return lang.code 51 + } 52 + return "en" 53 + } 54 + 55 + // MARK: - Lookup 56 + 57 + static func t(_ key: String) -> String { 58 + if let s = tables[current]?[key] { return s } 59 + if let s = tables["en"]?[key] { return s } 60 + return key 61 + } 62 + 63 + /// Format with positional `%@` substitutions, English-fallback aware. 64 + static func t(_ key: String, _ args: CVarArg...) -> String { 65 + let format = tables[current]?[key] ?? tables["en"]?[key] ?? key 66 + return String(format: format, arguments: args) 67 + } 68 + 69 + // MARK: - Tables 70 + // 71 + // Keep the keys in dotted-namespace form so callers self-document 72 + // (`popover.layout.label` vs. an ambiguous `layoutLabel`). When adding 73 + // a new key, add it to `en` first — the fallback path uses English, so a 74 + // missing translation in another language won't break the UI. 75 + static let tables: [String: [String: String]] = [ 76 + "en": en, 77 + "es": es, 78 + ] 79 + 80 + private static let en: [String: String] = [ 81 + // Popover — header / banner 82 + "popover.octave": "Octave", 83 + "popover.octave.down": "Octave down", 84 + "popover.octave.up": "Octave up", 85 + "popover.midi.label": "MIDI", 86 + "popover.update.button": "Open menuband.com", 87 + "popover.update.available": "Update available: %@", 88 + 89 + // Popover — layout block 90 + "popover.layout.label": "Keymap", 91 + "popover.layout.notepat": "Notepat.com", 92 + "popover.layout.ableton": "Ableton Computer Keyboard", 93 + "popover.layout.hint": "⌃⌥⌘P toggles last keystrokes mode", 94 + "popover.layout.why": "Why this Keymap?", 95 + "popover.layout.why.tooltip": "Open the Keymaps as Social Software paper", 96 + 97 + // Popover — shortcut rows 98 + "popover.shortcuts.label": "Key Shortcuts", 99 + "popover.shortcuts.focus": "Focus menu piano", 100 + "popover.shortcuts.floating": "Floating piano", 101 + "popover.shortcuts.show": "Show", 102 + "popover.shortcuts.focusButton": "Focus", 103 + "popover.shortcuts.press": "Press", 104 + "popover.shortcuts.pressKeys": "Press keys", 105 + "popover.shortcuts.use": "Use ⌘, ⌃, or ⌥", 106 + "popover.shortcuts.saved": "Saved %@", 107 + "popover.shortcuts.unavailable": "Shortcut unavailable", 108 + "popover.shortcuts.reserved": "⌃⌥⌘P is reserved", 109 + 110 + // Popover — palette helpers 111 + "popover.arrows.tooltip": "Arrow keys move the selection.", 112 + 113 + // Popover — about / footer 114 + "popover.about.lead": "Menu Band", 115 + "popover.about.body": " brings the built-in macOS instruments into the menu bar.", 116 + "popover.about.quit": "Quit Menu Band", 117 + "popover.about.crash.send": "Send crash reports", 118 + "popover.about.crash.sending": "Sending…", 119 + "popover.about.crash.sentAll": "Sent ✓", 120 + "popover.about.crash.sentSome": "Sent %@/%@ — retry", 121 + "popover.about.crash.sendOne": "Send 1 crash", 122 + "popover.about.crash.sendMany": "Send %@ crashes", 123 + 124 + // Popover — language switcher 125 + "popover.language.label": "Language", 126 + 127 + // Alerts 128 + "alert.noMenuBarSpace.title": "Menu Band can't fit in your menu bar", 129 + "alert.noMenuBarSpace.body": 130 + "There's no room in your menu bar — even for the compact icon. " + 131 + "Try quitting an app that puts items in the menu bar (slack, " + 132 + "dropbox, etc.), or use Bartender / Hidden Bar to manage them." + 133 + "\n\nMenu Band will keep trying every few seconds.", 134 + "alert.ok": "OK", 135 + ] 136 + 137 + private static let es: [String: String] = [ 138 + // Popover — header / banner 139 + "popover.octave": "Octava", 140 + "popover.octave.down": "Bajar octava", 141 + "popover.octave.up": "Subir octava", 142 + "popover.midi.label": "MIDI", 143 + "popover.update.button": "Abrir menuband.com", 144 + "popover.update.available": "Actualización disponible: %@", 145 + 146 + // Popover — layout block 147 + "popover.layout.label": "Mapa", 148 + "popover.layout.notepat": "Notepat.com", 149 + "popover.layout.ableton": "Teclado Ableton", 150 + "popover.layout.hint": "⌃⌥⌘P alterna el modo de captura", 151 + "popover.layout.why": "¿Por qué este teclado?", 152 + "popover.layout.why.tooltip": "Abrir el artículo Keymaps as Social Software", 153 + 154 + // Popover — shortcut rows 155 + "popover.shortcuts.label": "Atajos de teclado", 156 + "popover.shortcuts.focus": "Enfocar piano del menú", 157 + "popover.shortcuts.floating": "Piano flotante", 158 + "popover.shortcuts.show": "Mostrar", 159 + "popover.shortcuts.focusButton": "Enfocar", 160 + "popover.shortcuts.press": "Pulsar", 161 + "popover.shortcuts.pressKeys": "Pulsa teclas", 162 + "popover.shortcuts.use": "Usa ⌘, ⌃ o ⌥", 163 + "popover.shortcuts.saved": "Guardado %@", 164 + "popover.shortcuts.unavailable": "Atajo no disponible", 165 + "popover.shortcuts.reserved": "⌃⌥⌘P está reservado", 166 + 167 + // Popover — palette helpers 168 + "popover.arrows.tooltip": "Las flechas mueven la selección.", 169 + 170 + // Popover — about / footer 171 + "popover.about.lead": "Menu Band", 172 + "popover.about.body": 173 + " trae los instrumentos integrados de macOS a la barra de menús.", 174 + "popover.about.quit": "Salir de Menu Band", 175 + "popover.about.crash.send": "Enviar informes de fallos", 176 + "popover.about.crash.sending": "Enviando…", 177 + "popover.about.crash.sentAll": "Enviado ✓", 178 + "popover.about.crash.sentSome": "Enviados %@/%@ — reintentar", 179 + "popover.about.crash.sendOne": "Enviar 1 fallo", 180 + "popover.about.crash.sendMany": "Enviar %@ fallos", 181 + 182 + // Popover — language switcher 183 + "popover.language.label": "Idioma", 184 + 185 + // Alerts 186 + "alert.noMenuBarSpace.title": 187 + "Menu Band no cabe en tu barra de menús", 188 + "alert.noMenuBarSpace.body": 189 + "No hay espacio en la barra de menús — ni siquiera para el icono " + 190 + "compacto. Cierra una app que ocupe la barra (slack, dropbox, " + 191 + "etc.), o usa Bartender / Hidden Bar para gestionarlas." + 192 + "\n\nMenu Band seguirá intentándolo cada pocos segundos.", 193 + "alert.ok": "Aceptar", 194 + ] 195 + } 196 + 197 + /// Shorthand for `Localization.t(key)`. 198 + func L(_ key: String) -> String { Localization.t(key) } 199 + 200 + /// Shorthand for `Localization.t(key, args…)` — positional `%@` formatter. 201 + func L(_ key: String, _ args: CVarArg...) -> String { 202 + let format = Localization.tables[Localization.current]?[key] 203 + ?? Localization.tables["en"]?[key] 204 + ?? key 205 + return String(format: format, arguments: args) 206 + }
+262 -58
slab/menuband/Sources/MenuBand/MenuBandPopover.swift
··· 1 1 import AppKit 2 2 import Carbon 3 3 4 + /// NSButton subclass for chip-shaped link buttons — paints a layer-backed 5 + /// fill/border, swaps to a brighter "hover" pair when the cursor enters, 6 + /// and switches the cursor to a pointing hand. Used by the Why-this-Keymap 7 + /// chip and the Aesthetic.Computer / notepat.com brand badges so they all 8 + /// behave like real links instead of mute-looking buttons. 9 + final class HoverLinkButton: NSButton { 10 + var idleBackground: NSColor? 11 + var idleBorder: NSColor? 12 + var hoverBackground: NSColor? 13 + var hoverBorder: NSColor? 14 + private var trackingArea: NSTrackingArea? 15 + 16 + override func resetCursorRects() { 17 + super.resetCursorRects() 18 + addCursorRect(bounds, cursor: .pointingHand) 19 + } 20 + 21 + override func updateTrackingAreas() { 22 + super.updateTrackingAreas() 23 + if let ta = trackingArea { removeTrackingArea(ta) } 24 + let ta = NSTrackingArea( 25 + rect: bounds, 26 + options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect], 27 + owner: self, userInfo: nil 28 + ) 29 + addTrackingArea(ta) 30 + trackingArea = ta 31 + } 32 + 33 + override func mouseEntered(with event: NSEvent) { 34 + applyState(hovered: true) 35 + } 36 + 37 + override func mouseExited(with event: NSEvent) { 38 + applyState(hovered: false) 39 + } 40 + 41 + private func applyState(hovered: Bool) { 42 + let bg = hovered ? hoverBackground : idleBackground 43 + let bd = hovered ? hoverBorder : idleBorder 44 + layer?.backgroundColor = bg?.cgColor 45 + layer?.borderColor = bd?.cgColor 46 + } 47 + } 48 + 4 49 /// NSSegmentedControl subclass that reports the currently-hovered segment 5 50 /// (or nil when the cursor leaves). Used by the input-mode picker so 6 51 /// hovering a segment can preview that mode in the menubar piano without ··· 95 140 /// rest so the layout doesn't wobble when notes come and go. 96 141 private var heldNotesStack: NSStackView! 97 142 private var heldNotesContainer: NSView! 143 + /// Held so `viewDidChangeEffectiveAppearance` can repaint the 144 + /// layer-painted background when the user toggles light/dark 145 + /// mode mid-session — `NSColor.windowBackgroundColor.cgColor` 146 + /// is resolved once at loadView, so we have to re-resolve it on 147 + /// each appearance change. 148 + private weak var rootBackgroundView: NSView? 98 149 /// Chord-candidate cards live in their own row directly below the 99 150 /// visualizer bezel, mirroring the floating play palette so the 100 151 /// popover gets the same searchable chord readout. ··· 126 177 // effect view sampled the surrounding context and shifted appearance 127 178 // when focus moved between the menu bar and the popover. A flat 128 179 // background keeps the popover homogeneous in all states. 129 - let root = NSView() 180 + let root = MenuBandPopoverRootView() 130 181 root.wantsLayer = true 131 182 root.layer?.backgroundColor = NSColor.windowBackgroundColor.cgColor 132 183 root.translatesAutoresizingMaskIntoConstraints = false 184 + root.onAppearanceChange = { [weak self] in 185 + self?.handleEffectiveAppearanceChange() 186 + } 187 + rootBackgroundView = root 133 188 134 189 // Vertical stack of rows. Tight spacing + small edge insets so the 135 190 // popover hugs the 224 px instrument grid with no slack. ··· 191 246 192 247 let leftArrow = NSButton() 193 248 leftArrow.image = NSImage(systemSymbolName: "chevron.left", 194 - accessibilityDescription: "Octave down")? 249 + accessibilityDescription: L("popover.octave.down"))? 195 250 .withSymbolConfiguration(chevConfig) 196 251 leftArrow.isBordered = false 197 252 leftArrow.controlSize = .small ··· 202 257 203 258 let rightArrow = NSButton() 204 259 rightArrow.image = NSImage(systemSymbolName: "chevron.right", 205 - accessibilityDescription: "Octave up")? 260 + accessibilityDescription: L("popover.octave.up"))? 206 261 .withSymbolConfiguration(chevConfig) 207 262 rightArrow.isBordered = false 208 263 rightArrow.controlSize = .small ··· 217 272 // "octave" hint label sits to the right of the rightArrow so the 218 273 // number reads as scientific pitch notation (C4, C5, …) without 219 274 // taking much room. 220 - let octaveHint = NSTextField(labelWithString: "Octave") 275 + let octaveHint = NSTextField(labelWithString: L("popover.octave")) 221 276 octaveHint.font = NSFont.systemFont(ofSize: 9, weight: .regular) 222 277 octaveHint.textColor = .tertiaryLabelColor 223 278 ··· 240 295 midiSwitch = NSSwitch() 241 296 midiSwitch.target = self 242 297 midiSwitch.action = #selector(midiSwitchToggled(_:)) 243 - midiInlineLabel = NSTextField(labelWithString: "MIDI") 298 + midiInlineLabel = NSTextField(labelWithString: L("popover.midi.label")) 244 299 midiInlineLabel.font = NSFont.systemFont(ofSize: 10, weight: .semibold) 245 300 midiInlineLabel.textColor = .secondaryLabelColor 246 301 titleRow.addArrangedSubview(midiInlineLabel) ··· 266 321 updateLabel.lineBreakMode = .byWordWrapping 267 322 updateLabel.maximumNumberOfLines = 0 268 323 updateLabel.translatesAutoresizingMaskIntoConstraints = false 269 - let updateLink = NSButton(title: "Open menuband.com", 324 + let updateLink = NSButton(title: L("popover.update.button"), 270 325 target: self, 271 326 action: #selector(openMenuBandSite)) 272 327 updateLink.bezelStyle = .recessed ··· 298 353 // Layout block — built here, but appended to the stack 299 354 // *below* the palettePanel so the Layout choice reads as a 300 355 // configuration knob you reach for after picking a voice. 301 - let inputLabel = NSTextField(labelWithString: "Layout") 356 + let inputLabel = NSTextField(labelWithString: L("popover.layout.label")) 302 357 inputLabel.font = NSFont.systemFont(ofSize: 11, weight: .semibold) 303 358 inputLabel.textColor = .labelColor 304 359 ··· 309 364 shortcuts.spacing = 8 310 365 shortcuts.translatesAutoresizingMaskIntoConstraints = false 311 366 312 - let focusLabel = NSTextField(labelWithString: "Focus menu piano") 367 + let focusLabel = NSTextField(labelWithString: L("popover.shortcuts.focus")) 313 368 focusLabel.font = NSFont.systemFont(ofSize: 11) 314 369 focusLabel.textColor = .labelColor 315 370 shortcuts.addArrangedSubview(focusLabel) ··· 348 403 playPaletteRow.spacing = 6 349 404 playPaletteRow.translatesAutoresizingMaskIntoConstraints = false 350 405 351 - let playPaletteLabel = NSTextField(labelWithString: "Floating piano") 406 + let playPaletteLabel = NSTextField(labelWithString: L("popover.shortcuts.floating")) 352 407 playPaletteLabel.font = NSFont.systemFont(ofSize: 11) 353 408 playPaletteLabel.textColor = .labelColor 354 409 playPaletteLabel.lineBreakMode = .byTruncatingTail ··· 360 415 playPaletteRow.addArrangedSubview(playPaletteSpacer) 361 416 362 417 playPaletteToggleButton = NSButton( 363 - title: "Show", 418 + title: L("popover.shortcuts.show"), 364 419 target: self, 365 420 action: #selector(playPaletteToggleButtonClicked(_:)) 366 421 ) ··· 406 461 // (`NotepatFavicon.image`, lazily fetched + cached); Ableton 407 462 // gets the canonical logo we render programmatically. 408 463 let modeSpecs: [(label: String, image: NSImage?)] = [ 409 - ("Notepat.com", 464 + (L("popover.layout.notepat"), 410 465 NotepatFavicon.image 411 466 ?? NSImage(systemSymbolName: "keyboard", 412 - accessibilityDescription: "Notepat.com")? 467 + accessibilityDescription: L("popover.layout.notepat"))? 413 468 .withSymbolConfiguration(modeSymbolConfig)), 414 - ("Ableton Computer Keyboard", 469 + (L("popover.layout.ableton"), 415 470 AbletonLogo.image(height: 11)), 416 471 ] 417 472 modeButtons = [] ··· 443 498 ).isActive = true 444 499 445 500 let inputHint = NSTextField(labelWithString: 446 - "⌃⌥⌘P toggles last keystrokes mode") 501 + L("popover.layout.hint")) 447 502 inputHint.font = NSFont.systemFont(ofSize: 10) 448 503 inputHint.textColor = .secondaryLabelColor 504 + 505 + // "Why this Keymap?" — small chip-link to the keymaps paper. 506 + // Sits right under the layout-mode picker so the user has a 507 + // one-tap path from "what is this layout" to the writeup 508 + // explaining the chromatic notepat keymap, the Ableton M-mode 509 + // overlap, and why the popover defaults to notepat.com. 510 + let whyKeymapAttr = NSMutableAttributedString( 511 + string: L("popover.layout.why"), 512 + attributes: [ 513 + .font: NSFont.systemFont(ofSize: 11, weight: .semibold), 514 + .foregroundColor: NSColor.linkColor, 515 + ] 516 + ) 517 + let whyKeymapButton = MenuBandPopoverViewController.makeLinkButton( 518 + attr: whyKeymapAttr, 519 + target: self, 520 + action: #selector(openKeymapsPaper(_:)) 521 + ) 522 + whyKeymapButton.toolTip = L("popover.layout.why.tooltip") 523 + 449 524 // Layout label / mode buttons / hint are inserted into the 450 525 // popover stack farther down — see the `palettePanel` 451 526 // insertion point. 452 - let layoutBlock = (label: inputLabel, picker: modeStack, hint: inputHint) 527 + let layoutBlock = (label: inputLabel, picker: modeStack, 528 + hint: inputHint, link: whyKeymapButton) 453 529 454 - let shortcutLabel = NSTextField(labelWithString: "Key Shortcuts") 530 + let shortcutLabel = NSTextField(labelWithString: L("popover.shortcuts.label")) 455 531 shortcutLabel.font = NSFont.systemFont(ofSize: 11, weight: .semibold) 456 532 shortcutLabel.textColor = .labelColor 457 533 ··· 719 795 palettePanel.addSubview(keyboardDeck) 720 796 721 797 arrowsHint = ArrowKeysIndicator() 722 - arrowsHint.toolTip = "Arrow keys move the selection." 798 + arrowsHint.toolTip = L("popover.arrows.tooltip") 723 799 arrowsHint.translatesAutoresizingMaskIntoConstraints = false 724 800 arrowsHint.onClick = { [weak self] dir, isDown in 725 801 // Drive the same selection path the physical arrow keys ··· 731 807 // Strip below the grid: keyboard chassis wraps the QWERTY 732 808 // map on top and the arrow-keys cluster tucked into its 733 809 // bottom-right corner. Reads like a tiny laptop keyboard 734 - // glued to the base of the voice grid. Sized to hug the 735 - // qwerty (46h) + arrows (30h) + insets — no trackpad slab 736 - // anymore, so the strip can be tighter. 737 - let strip: CGFloat = 88 810 + // glued to the base of the voice grid. Tuned to hug the 811 + // qwerty (46h) + arrows (30h) with the qwerty/arrows gap 812 + // squeezed to 0 — no dead vertical band between rows. 813 + let strip: CGFloat = 82 738 814 qwertyMap = QwertyLayoutView() 739 815 qwertyMap.translatesAutoresizingMaskIntoConstraints = false 740 816 // Pointer-driven play: clicks/drags on caps route through the ··· 755 831 756 832 // Deck wraps the keys + trackpad. Spans the full panel 757 833 // width so the chassis reads as a real laptop deck under 758 - // the voice grid. 834 + // the voice grid. Sits 4 pt below the instrument palette 835 + // (was 6) so the chassis reads as flush-mounted to the 836 + // grid above instead of floating with a wide gutter. 759 837 keyboardDeck.leadingAnchor.constraint(equalTo: palettePanel.leadingAnchor), 760 838 keyboardDeck.trailingAnchor.constraint(equalTo: palettePanel.trailingAnchor), 761 - keyboardDeck.topAnchor.constraint(equalTo: instrumentList.bottomAnchor, constant: 6), 839 + keyboardDeck.topAnchor.constraint(equalTo: instrumentList.bottomAnchor, constant: 4), 762 840 keyboardDeck.bottomAnchor.constraint(equalTo: palettePanel.bottomAnchor), 763 841 764 842 // QWERTY map sits at the top of the chassis with a 765 843 // small inset from the deck's rounded edge. 766 844 qwertyMap.centerXAnchor.constraint(equalTo: keyboardDeck.centerXAnchor), 767 - qwertyMap.topAnchor.constraint(equalTo: keyboardDeck.topAnchor, constant: 6), 845 + qwertyMap.topAnchor.constraint(equalTo: keyboardDeck.topAnchor, constant: 4), 768 846 qwertyMap.widthAnchor.constraint(equalToConstant: QwertyLayoutView.intrinsicSize.width), 769 847 qwertyMap.heightAnchor.constraint(equalToConstant: QwertyLayoutView.intrinsicSize.height), 770 848 771 - // Arrow cluster nestles below the QWERTY rows on the 772 - // right edge — same inverted-T position a real laptop 773 - // would put it. 849 + // Arrow cluster nestles directly below the QWERTY rows on 850 + // the right edge — inverted-T position, no vertical gap so 851 + // the cluster reads as a continuation of the qwerty deck 852 + // instead of a stranded floating widget. 774 853 arrowsHint.trailingAnchor.constraint(equalTo: keyboardDeck.trailingAnchor, constant: -cornerInset), 775 - arrowsHint.topAnchor.constraint(equalTo: qwertyMap.bottomAnchor, constant: 2), 854 + arrowsHint.topAnchor.constraint(equalTo: qwertyMap.bottomAnchor, constant: 0), 776 855 ]) 777 856 stack.addArrangedSubview(palettePanel) 778 857 palettePanel.widthAnchor.constraint(equalToConstant: InstrumentListView.preferredWidth).isActive = true ··· 784 863 stack.addArrangedSubview(layoutBlock.label) 785 864 stack.addArrangedSubview(layoutBlock.picker) 786 865 stack.addArrangedSubview(layoutBlock.hint) 866 + stack.addArrangedSubview(layoutBlock.link) 787 867 stack.addArrangedSubview(shortcutLabel) 788 868 stack.addArrangedSubview(shortcuts) 789 869 stack.addArrangedSubview(focusShortcutStatusLabel) ··· 824 904 let aboutText = NSMutableAttributedString() 825 905 let bodyFont = NSFont.systemFont(ofSize: 10.5) 826 906 let boldFont = NSFont.systemFont(ofSize: 10.5, weight: .bold) 827 - aboutText.append(NSAttributedString(string: "Menu Band", 907 + aboutText.append(NSAttributedString(string: L("popover.about.lead"), 828 908 attributes: [.font: boldFont, .foregroundColor: NSColor.labelColor])) 829 909 aboutText.append(NSAttributedString( 830 - string: " brings the built-in macOS instruments into the menu bar.", 910 + string: L("popover.about.body"), 831 911 attributes: [.font: bodyFont, .foregroundColor: NSColor.secondaryLabelColor])) 832 912 aboutBody.attributedStringValue = aboutText 833 913 aboutBody.preferredMaxLayoutWidth = InstrumentListView.preferredWidth ··· 862 942 // bottom on multi-line crash hints. 863 943 crashStatusLabel = NSTextField(labelWithString: "") // legacy ivar — unused 864 944 crashHintLabel = NSTextField(labelWithString: "") // legacy ivar — unused 865 - crashSendButton = NSButton(title: "Send crash reports", 945 + crashSendButton = NSButton(title: L("popover.about.crash.send"), 866 946 target: self, 867 947 action: #selector(sendCrashLogs(_:))) 868 948 crashSendButton.bezelStyle = .recessed ··· 875 955 // Quit reads as its own action, not a list item under About. 876 956 stack.setCustomSpacing(10, after: aboutCrashRow) 877 957 958 + // Language switcher — compact flag-chip row, same pattern as the 959 + // kidlisp.com / help.aesthetic.computer pickers. The active language 960 + // is solid; the others are flat. Tapping a chip flips the locale and 961 + // posts `Localization.didChange`, which the AppDelegate observes to 962 + // rebuild the popover with translated strings. 963 + let langRow = NSStackView() 964 + langRow.orientation = .horizontal 965 + langRow.alignment = .centerY 966 + langRow.spacing = 6 967 + let langLabel = NSTextField(labelWithString: L("popover.language.label")) 968 + langLabel.font = NSFont.systemFont(ofSize: 10, weight: .semibold) 969 + langLabel.textColor = .secondaryLabelColor 970 + langRow.addArrangedSubview(langLabel) 971 + for lang in Localization.supported { 972 + let isActive = (lang.code == Localization.current) 973 + let attr = NSMutableAttributedString( 974 + string: "\(lang.flag) \(lang.label)", 975 + attributes: [ 976 + .font: NSFont.systemFont( 977 + ofSize: 11, 978 + weight: isActive ? .semibold : .regular), 979 + .foregroundColor: isActive 980 + ? NSColor.labelColor 981 + : NSColor.secondaryLabelColor, 982 + ] 983 + ) 984 + let accent = NSColor.controlAccentColor 985 + let chip = MenuBandPopoverViewController.makeLinkButton( 986 + attr: attr, 987 + target: self, 988 + action: #selector(languageChipClicked(_:)), 989 + background: isActive 990 + ? accent.withAlphaComponent(0.18) 991 + : NSColor.clear, 992 + border: isActive 993 + ? accent.withAlphaComponent(0.55) 994 + : NSColor.separatorColor.withAlphaComponent(0.5)) 995 + chip.identifier = NSUserInterfaceItemIdentifier( 996 + rawValue: "menuband.lang.\(lang.code)") 997 + chip.toolTip = lang.label 998 + langRow.addArrangedSubview(chip) 999 + } 1000 + let langSpacer = NSView() 1001 + langSpacer.setContentHuggingPriority(.defaultLow, for: .horizontal) 1002 + langRow.addArrangedSubview(langSpacer) 1003 + stack.addArrangedSubview(langRow) 1004 + langRow.widthAnchor.constraint(equalTo: stack.widthAnchor, 1005 + constant: -16).isActive = true 1006 + stack.setCustomSpacing(10, after: langRow) 1007 + 878 1008 // Quit — red bezel, white bold title. Bottom-right of the footer 879 1009 // row; crash-send button (when present) sits at the left of the 880 1010 // same row. ··· 886 1016 quit.target = self 887 1017 quit.action = #selector(quitApp) 888 1018 quit.attributedTitle = NSAttributedString( 889 - string: "Quit Menu Band", 1019 + string: L("popover.about.quit"), 890 1020 attributes: [ 891 1021 .foregroundColor: NSColor.white, 892 1022 .font: NSFont.systemFont(ofSize: 11, weight: .semibold), ··· 1157 1287 private func updateFocusShortcutControls(status: String? = nil) { 1158 1288 guard focusShortcutButton != nil else { return } 1159 1289 if isRecordingFocusShortcut { 1160 - focusShortcutButton.title = "Press keys" 1290 + focusShortcutButton.title = L("popover.shortcuts.pressKeys") 1161 1291 } else { 1162 1292 focusShortcutButton.title = MenuBandShortcutPreferences.focusShortcut.displayString 1163 1293 } ··· 1169 1299 stopPlayPaletteShortcutRecording(status: nil) 1170 1300 isRecordingFocusShortcut = true 1171 1301 onFocusShortcutRecordingChanged?(true) 1172 - updateFocusShortcutControls(status: "Use ⌘, ⌃, or ⌥") 1302 + updateFocusShortcutControls(status: L("popover.shortcuts.use")) 1173 1303 focusShortcutRecorderMonitor = NSEvent.addLocalMonitorForEvents( 1174 1304 matching: [.keyDown] 1175 1305 ) { [weak self] event in ··· 1199 1329 modifiers: MenuBandShortcut.carbonModifiers(from: event.modifierFlags) 1200 1330 ) 1201 1331 guard shortcut.isValidForRecording else { 1202 - updateFocusShortcutControls(status: "Use ⌘, ⌃, or ⌥") 1332 + updateFocusShortcutControls(status: L("popover.shortcuts.use")) 1203 1333 return 1204 1334 } 1205 1335 guard !shortcut.isReservedForTypeMode else { 1206 - updateFocusShortcutControls(status: "⌃⌥⌘P is reserved") 1336 + updateFocusShortcutControls(status: L("popover.shortcuts.reserved")) 1207 1337 return 1208 1338 } 1209 1339 let saved = onFocusShortcutChange?(shortcut) ?? false 1210 1340 stopFocusShortcutRecording( 1211 - status: saved ? "Saved \(shortcut.displayString)" : "Shortcut unavailable" 1341 + status: saved 1342 + ? L("popover.shortcuts.saved", shortcut.displayString) 1343 + : L("popover.shortcuts.unavailable") 1212 1344 ) 1213 1345 } 1214 1346 private func updatePlayPaletteShortcutControls(status: String? = nil) { 1215 1347 guard playPaletteShortcutButton != nil else { return } 1216 - playPaletteToggleButton.title = (isPlayPaletteShown?() ?? false) ? "Focus" : "Show" 1348 + playPaletteToggleButton.title = (isPlayPaletteShown?() ?? false) 1349 + ? L("popover.shortcuts.focusButton") 1350 + : L("popover.shortcuts.show") 1217 1351 if isRecordingPlayPaletteShortcut { 1218 - playPaletteShortcutButton.title = "Press" 1352 + playPaletteShortcutButton.title = L("popover.shortcuts.press") 1219 1353 } else { 1220 1354 playPaletteShortcutButton.title = MenuBandShortcutPreferences.playPaletteShortcut.displayString 1221 1355 } ··· 1257 1391 modifiers: MenuBandShortcut.carbonModifiers(from: event.modifierFlags) 1258 1392 ) 1259 1393 guard shortcut.isValidForRecording else { 1260 - updatePlayPaletteShortcutControls(status: "Use ⌘, ⌃, or ⌥") 1394 + updatePlayPaletteShortcutControls(status: L("popover.shortcuts.use")) 1261 1395 return 1262 1396 } 1263 1397 guard !shortcut.isReservedForTypeMode else { 1264 - updatePlayPaletteShortcutControls(status: "⌃⌥⌘P is reserved") 1398 + updatePlayPaletteShortcutControls(status: L("popover.shortcuts.reserved")) 1265 1399 return 1266 1400 } 1267 1401 let saved = onPlayPaletteShortcutChange?(shortcut) ?? false 1268 1402 stopPlayPaletteShortcutRecording( 1269 - status: saved ? "Saved \(shortcut.displayString)" : "Shortcut unavailable" 1403 + status: saved 1404 + ? L("popover.shortcuts.saved", shortcut.displayString) 1405 + : L("popover.shortcuts.unavailable") 1270 1406 ) 1271 1407 } 1272 1408 private func updateInstrumentReadout() { ··· 1346 1482 ) 1347 1483 } 1348 1484 1349 - // Appearance changes (light/dark toggle) refresh on next popover 1350 - // open via syncFromController — viewDidChangeEffectiveAppearance 1351 - // isn't on NSViewController in macOS so we don't try to hook it 1352 - // mid-session. 1485 + // Appearance changes (light/dark toggle) repaint live: the 1486 + // popover root + the visualizer bezel + the keyboard deck all 1487 + // hold layer-painted CGColors that were resolved once at 1488 + // loadView, so a system-wide light/dark flip wouldn't otherwise 1489 + // propagate to them. `viewDidChangeEffectiveAppearance` is 1490 + // declared on NSResponder but not on NSViewController in our 1491 + // SDK target — the popover's custom root view (which IS an 1492 + // NSView and does inherit the override) drives this callback 1493 + // for us. See `MenuBandPopoverRootView` below. 1494 + fileprivate func handleEffectiveAppearanceChange() { 1495 + rootBackgroundView?.layer?.backgroundColor = 1496 + NSColor.windowBackgroundColor.cgColor 1497 + applyAppearanceToVisualizer() 1498 + refreshHeldNotes() 1499 + updateInstrumentReadout() 1500 + } 1353 1501 1354 1502 /// Flip the LED bezel + visualizer between dark-mode (LED-on-black 1355 1503 /// glow) and light-mode (ink-on-paper) substrates so the meter ··· 1411 1559 return box 1412 1560 } 1413 1561 1414 - /// Badge-style link button — flat NSButton with a layer-painted 1562 + /// Badge-style link button — flat HoverLinkButton with a layer-painted 1415 1563 /// fill + optional border, so the per-link attributed title sits 1416 1564 /// inside a small chip. `bezelStyle = .inline` strips the system 1417 - /// chrome; the layer below provides the badge look. 1565 + /// chrome; the layer below provides the badge look. On hover the 1566 + /// background/border brighten and the cursor flips to pointing hand. 1418 1567 static func makeLinkButton(attr: NSAttributedString, 1419 1568 target: AnyObject, 1420 1569 action: Selector, 1421 1570 background: NSColor? = nil, 1422 1571 border: NSColor? = nil) -> NSButton { 1423 - let btn = NSButton() 1572 + let btn = HoverLinkButton() 1424 1573 btn.bezelStyle = .inline 1425 1574 btn.isBordered = false 1426 1575 btn.controlSize = .small ··· 1429 1578 btn.attributedTitle = attr 1430 1579 btn.wantsLayer = true 1431 1580 btn.layer?.cornerRadius = 5 1432 - if let bg = background { 1433 - btn.layer?.backgroundColor = bg.cgColor 1434 - } 1435 - if let bd = border { 1581 + let idleBg = background 1582 + ?? NSColor.controlAccentColor.withAlphaComponent(0.06) 1583 + let idleBd = border 1584 + let hoverBg = idleBg.withAlphaComponent( 1585 + min(1.0, idleBg.alphaComponent + 0.18)) 1586 + let hoverBd = idleBd?.withAlphaComponent( 1587 + min(1.0, (idleBd?.alphaComponent ?? 0.5) + 0.25)) 1588 + ?? NSColor.controlAccentColor.withAlphaComponent(0.55) 1589 + btn.idleBackground = idleBg 1590 + btn.idleBorder = idleBd 1591 + btn.hoverBackground = hoverBg 1592 + btn.hoverBorder = hoverBd 1593 + btn.layer?.backgroundColor = idleBg.cgColor 1594 + if let bd = idleBd { 1436 1595 btn.layer?.borderColor = bd.cgColor 1437 1596 btn.layer?.borderWidth = 1 1597 + } else { 1598 + btn.layer?.borderWidth = 0 1438 1599 } 1439 1600 return btn 1440 1601 } ··· 1515 1676 let n = CrashLogReader.recentLogs().count 1516 1677 crashSendButton.isHidden = (n == 0) 1517 1678 if n > 0 { 1518 - crashSendButton.title = n == 1 ? "Send 1 crash" : "Send \(n) crashes" 1679 + crashSendButton.title = n == 1 1680 + ? L("popover.about.crash.sendOne") 1681 + : L("popover.about.crash.sendMany", String(n)) 1519 1682 crashSendButton.isEnabled = true 1520 1683 } 1521 1684 } ··· 1530 1693 if UpdateChecker.isNewer(info.version, than: current) { 1531 1694 let notes = info.notes?.isEmpty == false ? " — \(info.notes!)" : "" 1532 1695 self.updateLabel.stringValue = 1533 - "Update available: \(info.version)\(notes)" 1696 + L("popover.update.available", "\(info.version)\(notes)") 1534 1697 self.updateBanner.isHidden = false 1535 1698 } else { 1536 1699 self.updateBanner.isHidden = true ··· 1544 1707 } 1545 1708 } 1546 1709 1710 + @objc private func languageChipClicked(_ sender: NSButton) { 1711 + guard let id = sender.identifier?.rawValue else { return } 1712 + let prefix = "menuband.lang." 1713 + guard id.hasPrefix(prefix) else { return } 1714 + let code = String(id.dropFirst(prefix.count)) 1715 + guard code != Localization.current else { return } 1716 + Localization.current = code 1717 + } 1718 + 1719 + @objc private func openKeymapsPaper(_ sender: NSButton) { 1720 + // Prefer the copy bundled inside the app — opens in Preview offline, 1721 + // no network round-trip — then fall back to the public hosted PDF if 1722 + // the bundled resource somehow goes missing. 1723 + if let url = Bundle.module.url( 1724 + forResource: "keymaps-social-software-26-arxiv", 1725 + withExtension: "pdf") 1726 + { 1727 + NSWorkspace.shared.open(url) 1728 + return 1729 + } 1730 + if let url = URL(string: 1731 + "https://papers.aesthetic.computer/keymaps-social-software-26-arxiv.pdf") { 1732 + NSWorkspace.shared.open(url) 1733 + } 1734 + } 1735 + 1547 1736 @objc private func sendCrashLogs(_ sender: NSButton) { 1548 1737 let logs = CrashLogReader.recentLogs() 1549 1738 guard !logs.isEmpty else { return } 1550 1739 sender.isEnabled = false 1551 - sender.title = "Sending…" 1740 + sender.title = L("popover.about.crash.sending") 1552 1741 1553 1742 let version = (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String) ?? "?" 1554 1743 var remaining = logs.count ··· 1560 1749 remaining -= 1 1561 1750 if remaining == 0 { 1562 1751 self.crashSendButton.title = ok == logs.count 1563 - ? "Sent ✓" 1564 - : "Sent \(ok)/\(logs.count) — retry" 1752 + ? L("popover.about.crash.sentAll") 1753 + : L("popover.about.crash.sentSome", 1754 + String(ok), String(logs.count)) 1565 1755 self.crashSendButton.isEnabled = ok != logs.count 1566 1756 } 1567 1757 } ··· 1805 1995 NSApp.terminate(nil) 1806 1996 } 1807 1997 } 1998 + 1999 + /// Custom NSView used as the popover root so we can repaint layer- 2000 + /// painted CGColors when the system flips light/dark mid-session. 2001 + /// NSView (via NSResponder) receives viewDidChangeEffectiveAppearance 2002 + /// callbacks; NSViewController in our SDK target does not, so the 2003 + /// root view forwards them through `onAppearanceChange` to the 2004 + /// popover view controller. 2005 + final class MenuBandPopoverRootView: NSView { 2006 + var onAppearanceChange: (() -> Void)? 2007 + override func viewDidChangeEffectiveAppearance() { 2008 + super.viewDidChangeEffectiveAppearance() 2009 + onAppearanceChange?() 2010 + } 2011 + }
slab/menuband/Sources/MenuBand/Resources/keymaps-social-software-26-arxiv.pdf

This is a binary file and will not be displayed.