diff --git a/.gitignore b/.gitignore index 61fd30a..36df223 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -.build/ +.build build/ .DS_Store *.xcodeproj diff --git a/AGENTS.md b/AGENTS.md index a0e3fcf..55c3698 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,14 @@ make bundle # release build + assemble build/OpenOSK.app via scripts/bundle.s make clean ``` +Every target honours `SCRATCH_PATH` (default `.build`), SwiftPM's scratch +directory. + +- `.build/debug/openosk --snapshot ` renders the keyboard in every theme + and each settings pane to PNGs without opening a window — look at these after + any visual change. It reads the real user preferences, and the System + theme's desktop blur cannot be captured off-screen (it falls back to the + flat background color). - `.build/debug/openosk --smoke-test` starts the UI, prints `SMOKE_TEST_OK`, and exits 0 — use this to verify the app still launches without interacting with it. - On machines with only Command Line Tools (no Xcode), XCTest is unavailable and @@ -48,15 +56,27 @@ Two targets plus tests: - `AppProfiles.swift` — per-app overrides (layout, forced terminal mode) from `app-profiles.json` - `Preferences.swift` — UserDefaults-backed; posts - `Preferences.didChangeNotification` on every set + `Preferences.didChangeNotification` for real changes only, carrying the + changed `Preferences.Key` (`Preferences.changedKey(in:)`); also stores + panel positions by panel id - **`OpenOSK`** (executable, AppKit): - `KeyboardPanel.swift` — borderless `.nonactivatingPanel`, `canBecomeKey = false`, assistive-tech window level; this is what keeps focus in the target app - - `KeyboardView.swift` — manual frame layout (no Auto Layout in the panel); - `KeyView` handles press visuals, autorepeat, dwell (hover-to-press with - progress pie) and long-press detection; `SuggestionBarView` with dwell - buttons; current-text bar at the top. Character/macro keys fire on mouse-up - (enables long-press), specials/modifiers on mouse-down (enables autorepeat). + - `KeyboardView.swift` — metrics and the container view: manual frame layout + (no Auto Layout in the panel), current-text bar, blur backdrop for themes + with `usesVibrancy` (click-through, so background drags still move the panel) + - `KeyView.swift` — press visuals, autorepeat, dwell (progress pie), + long-press detection, accessibility button. Character/macro keys fire on + mouse-up (enables long-press), specials/modifiers on mouse-down (enables + autorepeat). + - `SuggestionBarView.swift` — themed suggestion chips (click, dwell, scan, + accessibility); suggestions that do not fit are dropped, best first + - `DwellTimer.swift` — hover countdown shared by keys and chips + - `KeyPresentation.swift` — spoken names and SF Symbols for special, + modifier and media keys + - `Theme.swift` — palettes; text on the panel background uses `panelText`, + text on key caps `text`/`secondaryText` + - `Snapshot.swift` — the `--snapshot` renderer - `KeyboardController.swift` — central coordinator: modifier latching (off → latched → locked), terminal-mode detection via frontmost app bundle ID, suggestion routing (words vs. shell), learning, typing aids, inactivity fade, @@ -65,11 +85,12 @@ Two targets plus tests: dismissed via local+global mouse monitors - `FocusWatcher.swift` — AXObserver on the frontmost app's focused UI element; drives "show keyboard when editing text" - - `ScanController.swift` — scanning (switch access): two-level row→key scan; + - `ScanController.swift` — scanning (switch access): two-level row→key scan + over the groups its `groupsProvider` returns (visible keyboard + open panels); the switch key is consumed via a global CGEvent tap that ignores events carrying `KeyInjector.injectionSignature` - `PanelsController.swift` — floating custom panels (layout JSON reused); - open state persisted in preferences; panels route key presses into + open state and positions persisted in preferences; panels route key presses into `KeyboardController.handleKey` - `ProfileEditorController.swift` / `PanelEditorController.swift` — table-based editors writing app-profiles.json and user panel JSON; they @@ -83,6 +104,18 @@ Two targets plus tests: ## Conventions & gotchas +- On a Synology Drive (File Provider) checkout `swift test` fails at the + codesign step ("resource fork, Finder information, or similar detritus not + allowed"). Build off the volume with `SCRATCH_PATH`, which the Makefile and + `scripts/bundle.sh` pass to SwiftPM as `--scratch-path`: + `make test SCRATCH_PATH=~/Entwicklung/Caches/OpenOSK/.build` (or export it). + The binary is then at `$SCRATCH_PATH/debug/openosk`. A `.build` symlink does + not hold on Synology Drive — the client restores the server's directory and + renames the symlink away. +- `KeyboardController.preferencesChanged` switches exhaustively over + `Preferences.Key`: a new preference must be given a case there, and only + preferences that change the view tree may trigger `rebuildKeyboardView()`. + - Resources are accessed via `Bundle.module` under the `Resources/` subdirectory (the package uses `.copy("Resources")`, so subpaths are preserved). The app bundle script copies `OpenOSK_OpenOSKCore.bundle` into `Contents/Resources/`. diff --git a/CLAUDE.md b/CLAUDE.md index d90580f..f5ff799 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,6 +14,8 @@ Swift + AppKit via SwiftPM (no Xcode project). Menu bar accessory app. - `make build` / `make run` / `make test` / `make bundle` / `make clean` - `.build/debug/openosk --smoke-test` — headless-ish launch check, prints `SMOKE_TEST_OK` and exits 0. Use after UI changes. +- `.build/debug/openosk --snapshot ` — renders every theme and settings + pane to PNGs; read them after visual changes. - Tests use **Swift Testing** (`import Testing`), not XCTest — XCTest is not available with Command Line Tools. `make test` adds the required search-path flags automatically. diff --git a/Makefile b/Makefile index a6d813d..1a4a873 100644 --- a/Makefile +++ b/Makefile @@ -5,6 +5,14 @@ # Swift Testing outside the default search paths. With an Xcode toolchain # selected, a plain `swift test` works. +# Where SwiftPM keeps build products. Point it at a local directory when the +# checkout lives on a synced or network volume (Synology Drive, iCloud, SMB): +# codesign rejects the extended attributes such volumes attach, which fails +# `make test`. Example: make test SCRATCH_PATH=~/Library/Caches/OpenOSK +SCRATCH_PATH ?= .build +export SCRATCH_PATH +SWIFT_FLAGS := --scratch-path "$(SCRATCH_PATH)" + DEVELOPER_DIR := $(shell xcode-select -p 2>/dev/null) CLT_FRAMEWORKS := /Library/Developer/CommandLineTools/Library/Developer/Frameworks CLT_TESTING_LIB := /Library/Developer/CommandLineTools/Library/Developer/usr/lib @@ -16,24 +24,24 @@ TEST_FLAGS := -Xswiftc -F -Xswiftc $(CLT_FRAMEWORKS) \ .PHONY: build release run test bundle clean build: - swift build + swift build $(SWIFT_FLAGS) release: - swift build -c release + swift build -c release $(SWIFT_FLAGS) run: build - .build/debug/openosk + "$(SCRATCH_PATH)/debug/openosk" test: @if echo "$(DEVELOPER_DIR)" | grep -q CommandLineTools && [ -d "$(CLT_FRAMEWORKS)" ]; then \ - swift test $(TEST_FLAGS); \ + swift test $(SWIFT_FLAGS) $(TEST_FLAGS); \ else \ - swift test; \ + swift test $(SWIFT_FLAGS); \ fi bundle: release scripts/bundle.sh clean: - swift package clean + swift package $(SWIFT_FLAGS) clean rm -rf build diff --git a/README.md b/README.md index 4391265..d301513 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,10 @@ offers, such as shell command completion for terminal work. - **Scanning input (switch access)**: key rows are highlighted sequentially; a single switch (Space/Return/F13–F15, consumed globally) first picks the row, then the key — for users who cannot operate a pointer at all. Optional - two-switch mode: one key advances the scan manually, the other selects + two-switch mode: one key advances the scan manually, the other selects. + Open custom panels are part of the scan cycle +- **Works with VoiceOver and Voice Control**: every key and suggestion is an + accessibility button with a spoken name ("Delete", "Volume Up") - **Custom panels**: floating button collections in the spirit of Apple's Panel Editor — Git, Editing, and System (volume/brightness/media) panels ship as examples; add your own JSON to `~/Library/Application Support/OpenOSK/Panels/`. @@ -58,7 +61,8 @@ offers, such as shell command completion for terminal work. `~/Library/Application Support/OpenOSK/Layouts/` - **Adjustable**: key size, opacity, **themes** (System, High Contrast, Dark, Light), optional key click sound, per-feature toggles; fades when idle; - remembers its position across launches and displays + the keyboard and every panel remember their position across launches and + displays - Optionally **shows itself automatically** when a text field gains focus (Accessibility API observer) - Runs as a menu bar app (no Dock icon); UI in English and German @@ -81,7 +85,8 @@ make bundle # create build/OpenOSK.app for /Applications Or directly with SwiftPM: `swift build`, `.build/debug/openosk`. -CLI flags: `--version`, `--help`, `--smoke-test` (start, verify initialization, exit). +CLI flags: `--version`, `--help`, `--smoke-test` (start, verify initialization, exit), +`--snapshot DIR` (render the keyboard in every theme and the settings panes to PNGs). > Tip: install the bundled app (`make bundle`, then copy `build/OpenOSK.app` to > `/Applications`) so macOS remembers the Accessibility permission across rebuilds. diff --git a/Sources/OpenOSK/AppDelegate.swift b/Sources/OpenOSK/AppDelegate.swift index cf6b5f3..ab359e7 100644 --- a/Sources/OpenOSK/AppDelegate.swift +++ b/Sources/OpenOSK/AppDelegate.swift @@ -20,7 +20,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate { func applicationDidFinishLaunching(_ notification: Notification) { keyboardController = KeyboardController(injector: injector, resolver: resolver) texterController = TexterController(injector: injector, resolver: resolver) - settingsController = SettingsController(keyboardController: keyboardController) + settingsController = SettingsController( + onClearLearnedWords: { [weak self] in self?.keyboardController.clearLearnedWords() }) statusBarController = StatusBarController(appDelegate: self) keyboardController.showPanel() diff --git a/Sources/OpenOSK/DwellTimer.swift b/Sources/OpenOSK/DwellTimer.swift new file mode 100644 index 0000000..8bc4d77 --- /dev/null +++ b/Sources/OpenOSK/DwellTimer.swift @@ -0,0 +1,44 @@ +#if canImport(AppKit) +import AppKit + +/// Dwell (hover-to-press) configuration shared by key and suggestion views. +struct DwellConfiguration { + var enabled = false + var time: TimeInterval = 0.9 +} + +/// Counts a hover up to the dwell time, reporting progress for the on-key +/// indicator. One instance per hoverable view. +final class DwellTimer { + /// Progress in 0..<1 while counting, 0 once cancelled or completed. + var onProgress: ((CGFloat) -> Void)? + var onComplete: (() -> Void)? + + private var ticker: Timer? + + func start(duration: TimeInterval) { + cancel() + let start = Date() + let ticker = Timer(timeInterval: 1.0 / 30.0, repeats: true) { [weak self] _ in + guard let self else { return } + let progress = CGFloat(Date().timeIntervalSince(start) / duration) + if progress >= 1 { + self.cancel() + self.onComplete?() + } else { + self.onProgress?(progress) + } + } + // Common modes: the panel is dragged and menus are open while hovering. + RunLoop.main.add(ticker, forMode: .common) + self.ticker = ticker + } + + func cancel() { + guard ticker != nil else { return } + ticker?.invalidate() + ticker = nil + onProgress?(0) + } +} +#endif diff --git a/Sources/OpenOSK/KeyPresentation.swift b/Sources/OpenOSK/KeyPresentation.swift new file mode 100644 index 0000000..b93c428 --- /dev/null +++ b/Sources/OpenOSK/KeyPresentation.swift @@ -0,0 +1,66 @@ +#if canImport(AppKit) +import Foundation +import OpenOSKCore + +/// How keys are named to assistive technology (VoiceOver, Voice Control) and +/// which SF Symbol stands in for glyphs the system font renders as emoji. +extension SpecialKey { + var spokenName: String { + switch self { + case .escape: return L("Escape") + case .tab: return L("Tab") + case .delete: return L("Delete") + case .forwardDelete: return L("Forward Delete") + case .return: return L("Return") + case .space: return L("Space") + case .left: return L("Left Arrow") + case .right: return L("Right Arrow") + case .up: return L("Up Arrow") + case .down: return L("Down Arrow") + case .home: return L("Home") + case .end: return L("End") + case .pageUp: return L("Page Up") + case .pageDown: return L("Page Down") + } + } +} + +extension Modifier { + var spokenName: String { + switch self { + case .shift: return L("Shift") + case .control: return L("Control") + case .option: return L("Option") + case .command: return L("Command") + } + } +} + +extension MediaKey { + var spokenName: String { + switch self { + case .volumeUp: return L("Volume Up") + case .volumeDown: return L("Volume Down") + case .mute: return L("Mute") + case .brightnessUp: return L("Brightness Up") + case .brightnessDown: return L("Brightness Down") + case .playPause: return L("Play/Pause") + case .next: return L("Next Track") + case .previous: return L("Previous Track") + } + } + + var symbolName: String { + switch self { + case .volumeUp: return "speaker.wave.3.fill" + case .volumeDown: return "speaker.wave.1.fill" + case .mute: return "speaker.slash.fill" + case .brightnessUp: return "sun.max.fill" + case .brightnessDown: return "sun.min.fill" + case .playPause: return "playpause.fill" + case .next: return "forward.fill" + case .previous: return "backward.fill" + } + } +} +#endif diff --git a/Sources/OpenOSK/KeyView.swift b/Sources/OpenOSK/KeyView.swift new file mode 100644 index 0000000..6390edf --- /dev/null +++ b/Sources/OpenOSK/KeyView.swift @@ -0,0 +1,346 @@ +#if canImport(AppKit) +import AppKit +import OpenOSKCore + +enum ModifierVisualState { + case off + case latched + case locked +} + +final class KeyView: NSView { + let key: Key + var displayText = "" + var secondaryText: String? + /// SF Symbol shown instead of the text label. + var imageName: String? { + didSet { if imageName != oldValue { symbolCache = [:] } } + } + /// What VoiceOver and Voice Control call this key. + var spokenLabel = "" + var modifierState: ModifierVisualState = .off { + didSet { needsDisplay = true } + } + var fontSize: CGFloat = 16 + var secondaryFontSize: CGFloat = 9 + var cornerRadius: CGFloat = 7 + var dwell = DwellConfiguration() + var theme = Theme.system + + var onPress: ((Key) -> Void)? + /// Long-press hook for character keys; returns true if it was handled + /// (e.g. a variant popup was shown), which suppresses the normal press. + var onLongPress: ((Key, KeyView) -> Bool)? + /// Highlight driven by scanning (switch access) mode. + var isScanHighlighted = false { + didSet { needsDisplay = true } + } + + private var isPressedVisual = false { + didSet { needsDisplay = true } + } + private var isHovered = false { + didSet { needsDisplay = true } + } + private var repeatTimer: Timer? + private var longPressTimer: Timer? + private var longPressTriggered = false + + private let dwellTimer = DwellTimer() + private var dwellProgress: CGFloat = 0 { + didSet { needsDisplay = true } + } + private var dwellCompleted = false + private var symbolCache: [Bool: NSImage] = [:] + + init(key: Key) { + self.key = key + super.init(frame: .zero) + dwellTimer.onProgress = { [weak self] in self?.dwellProgress = $0 } + dwellTimer.onComplete = { [weak self] in + self?.dwellCompleted = true + self?.triggerPress() + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } + + override var mouseDownCanMoveWindow: Bool { false } + + /// Programmatic activation: scanning, dwell and assistive technology. + func triggerPress() { + isPressedVisual = true + DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) { [weak self] in + self?.isPressedVisual = false + } + onPress?(key) + } + + private var autorepeats: Bool { + switch key.kind { + case .special(let special): return special.autorepeats + case .media(let media): return media.autorepeats + default: return false + } + } + + /// Character and macro keys fire on release (enables long-press variants + /// and slide-away cancel-free behavior); specials/modifiers fire on press + /// so autorepeat and latching feel immediate. + private var firesOnMouseUp: Bool { + switch key.kind { + case .character, .macro: return true + default: return false + } + } + + private var supportsLongPress: Bool { + guard case .character = key.kind, let base = key.base else { return false } + return !CharacterVariants.variants(for: base, shifted: false).isEmpty + || !CharacterVariants.variants(for: base, shifted: true).isEmpty + } + + override func updateTrackingAreas() { + super.updateTrackingAreas() + trackingAreas.forEach(removeTrackingArea) + addTrackingArea(NSTrackingArea( + rect: bounds, + options: [.mouseEnteredAndExited, .activeAlways], + owner: self + )) + } + + override func viewDidChangeEffectiveAppearance() { + super.viewDidChangeEffectiveAppearance() + symbolCache = [:] + needsDisplay = true + } + + // MARK: Accessibility + + // The panel never becomes key, but assistive technology reads and presses + // its elements through the accessibility API regardless of focus. + override func isAccessibilityElement() -> Bool { true } + override func accessibilityRole() -> NSAccessibility.Role? { .button } + override func accessibilityLabel() -> String? { spokenLabel } + + override func accessibilityPerformPress() -> Bool { + triggerPress() + return true + } + + // MARK: Mouse handling + + override func mouseEntered(with event: NSEvent) { + isHovered = true + if dwell.enabled, !dwellCompleted { + dwellTimer.start(duration: dwell.time) + } + } + + override func mouseExited(with event: NSEvent) { + isHovered = false + dwellTimer.cancel() + dwellCompleted = false + } + + override func mouseDown(with event: NSEvent) { + dwellTimer.cancel() + isPressedVisual = true + longPressTriggered = false + + if firesOnMouseUp { + if supportsLongPress { + let timer = Timer(timeInterval: 0.45, repeats: false) { [weak self] _ in + guard let self else { return } + self.longPressTriggered = self.onLongPress?(self.key, self) ?? false + if self.longPressTriggered { + self.isPressedVisual = false + } + } + RunLoop.current.add(timer, forMode: .common) + longPressTimer = timer + } + } else { + onPress?(key) + if autorepeats { + let timer = Timer(timeInterval: 0.35, repeats: false) { [weak self] _ in + self?.startRepeating() + } + RunLoop.current.add(timer, forMode: .common) + repeatTimer = timer + } + } + } + + override func mouseUp(with event: NSEvent) { + isPressedVisual = false + stopRepeating() + longPressTimer?.invalidate() + longPressTimer = nil + + if firesOnMouseUp, !longPressTriggered { + onPress?(key) + } + longPressTriggered = false + } + + // MARK: Autorepeat + + private func startRepeating() { + stopRepeating() + let timer = Timer(timeInterval: 0.06, repeats: true) { [weak self] _ in + guard let self else { return } + self.onPress?(self.key) + } + RunLoop.current.add(timer, forMode: .common) + repeatTimer = timer + } + + private func stopRepeating() { + repeatTimer?.invalidate() + repeatTimer = nil + } + + // MARK: Drawing + + private var isCharacter: Bool { + if case .character = key.kind { return true } + return false + } + + private var fillColor: NSColor { + if isPressedVisual { return theme.pressed } + var fill: NSColor + switch modifierState { + case .off: + fill = isCharacter ? theme.keyFill : theme.specialFill + if isHovered { + fill = fill.blended(withFraction: 0.15, of: theme.pressed) ?? fill + } + // Blended rather than translucent: over a black panel a see-through + // accent turns muddy. + case .latched: + fill = theme.specialFill.blended(withFraction: 0.4, of: theme.pressed) ?? theme.pressed + case .locked: + fill = theme.specialFill.blended(withFraction: 0.85, of: theme.pressed) ?? theme.pressed + } + if isScanHighlighted { + fill = fill.blended(withFraction: 0.3, of: theme.scanHighlight) ?? fill + } + return fill + } + + override func draw(_ dirtyRect: NSRect) { + // One point at the bottom is left for the lip that lifts the key cap + // off the panel. + var cap = bounds.insetBy(dx: 0.5, dy: 0.5) + cap.origin.y += 1 + cap.size.height -= 1 + + if !isPressedVisual { + let lip = NSBezierPath( + roundedRect: cap.offsetBy(dx: 0, dy: -1), + xRadius: cornerRadius, yRadius: cornerRadius) + theme.border.withAlphaComponent(0.6).setFill() + lip.fill() + } + + let path = NSBezierPath(roundedRect: cap, xRadius: cornerRadius, yRadius: cornerRadius) + fillColor.setFill() + path.fill() + theme.border.setStroke() + path.lineWidth = 1 + path.stroke() + + if isScanHighlighted { + strokeInnerBorder(in: cap, color: theme.scanHighlight, width: 3) + } else if modifierState != .off { + strokeInnerBorder(in: cap, color: theme.pressed, width: 2) + } + + let textColor: NSColor = isPressedVisual ? theme.pressedText : theme.text + let center = NSPoint(x: cap.midX, y: cap.midY) + + if let symbol = symbolImage(pressed: isPressedVisual) { + let size = symbol.size + symbol.draw(in: NSRect( + x: center.x - size.width / 2, + y: center.y - size.height / 2, + width: size.width, + height: size.height + )) + } else if !displayText.isEmpty { + let attributes: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: fontSize, weight: isCharacter ? .regular : .medium), + .foregroundColor: textColor, + ] + let size = displayText.size(withAttributes: attributes) + displayText.draw( + at: NSPoint(x: center.x - size.width / 2, y: center.y - size.height / 2), + withAttributes: attributes) + } + + if let secondaryText, !secondaryText.isEmpty { + let attributes: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: secondaryFontSize), + .foregroundColor: isPressedVisual + ? theme.pressedText.withAlphaComponent(0.8) + : theme.secondaryText, + ] + let size = secondaryText.size(withAttributes: attributes) + let inset = cornerRadius * 0.7 + secondaryText.draw( + at: NSPoint(x: cap.maxX - size.width - inset, y: cap.maxY - size.height - inset / 2), + withAttributes: attributes) + } + + if dwellProgress > 0 { + drawDwellProgress(center: center) + } + } + + private func strokeInnerBorder(in rect: NSRect, color: NSColor, width: CGFloat) { + let inset = width / 2 + let border = NSBezierPath( + roundedRect: rect.insetBy(dx: inset, dy: inset), + xRadius: max(0, cornerRadius - inset), yRadius: max(0, cornerRadius - inset)) + color.setStroke() + border.lineWidth = width + border.stroke() + } + + /// The key's SF Symbol in the current text color. Rendered once per + /// pressed state; redrawing happens on every hover and dwell tick. + private func symbolImage(pressed: Bool) -> NSImage? { + guard let imageName else { return nil } + if let cached = symbolCache[pressed] { return cached } + let color = pressed ? theme.pressedText : theme.text + let configuration = NSImage.SymbolConfiguration(pointSize: fontSize * 1.15, weight: .medium) + .applying(NSImage.SymbolConfiguration(paletteColors: [color])) + guard let image = NSImage(systemSymbolName: imageName, accessibilityDescription: nil)? + .withSymbolConfiguration(configuration) + else { return nil } + symbolCache[pressed] = image + return image + } + + /// Pie-style dwell progress indicator in the key's center. + private func drawDwellProgress(center: NSPoint) { + let radius = min(bounds.width, bounds.height) / 4.5 + let pie = NSBezierPath() + pie.move(to: center) + pie.appendArc( + withCenter: center, + radius: radius, + startAngle: 90, + endAngle: 90 - 360 * dwellProgress, + clockwise: true + ) + pie.close() + theme.pressed.withAlphaComponent(0.55).setFill() + pie.fill() + } +} +#endif diff --git a/Sources/OpenOSK/KeyboardController.swift b/Sources/OpenOSK/KeyboardController.swift index 4c2e516..ed2e11f 100644 --- a/Sources/OpenOSK/KeyboardController.swift +++ b/Sources/OpenOSK/KeyboardController.swift @@ -73,6 +73,8 @@ final class KeyboardController: NSObject { private var modifierStates: [Modifier: ModifierState] = [:] private var terminalMode = false private var didPositionPanel = false + /// Id under which the main keyboard's position is remembered. + private static let keyboardPanelID = "keyboard" private var panelWasAutoShown = false private var inactivityTimer: Timer? private var activeProfile: AppProfile? @@ -100,6 +102,13 @@ final class KeyboardController: NSObject { rebuildKeyboardView() observeEnvironment() refreshTerminalMode() + scanController.groupsProvider = { [weak self] in + guard let self else { return [] } + // Hidden keys must not be scanned: the user could not see them. + let keyboard = self.isPanelVisible ? self.keyboardView.scanGroups : [] + return keyboard + self.panels.scanGroups + } + panels.onChange = { [weak self] in self?.scanController.reload() } applyScanningState() focusWatcher.onFocusChange = { [weak self] info in @@ -124,13 +133,6 @@ final class KeyboardController: NSObject { object: nil ) - NotificationCenter.default.addObserver( - self, - selector: #selector(panelDidMove), - name: NSWindow.didMoveNotification, - object: panel - ) - // Discover bare commands on $PATH for terminal completion. DispatchQueue.global(qos: .utility).async { [weak self] in let names = ShellCompleter.executableNames( @@ -156,7 +158,6 @@ final class KeyboardController: NSObject { private func applyScanningState() { if preferences.scanningEnabled { scanController.interval = preferences.scanInterval - scanController.attach(to: keyboardView) let advanceKey = preferences.scanAdvanceKey scanController.start( switchKey: preferences.scanSwitchKey, @@ -183,32 +184,29 @@ final class KeyboardController: NSObject { } } - /// Remembers the panel position across launches (multi-display safe: the - /// saved origin is only used if it is still on a visible screen). - @objc private func panelDidMove(_ notification: Notification) { - guard didPositionPanel else { return } - UserDefaults.standard.set( - [panel.frame.origin.x, panel.frame.origin.y], forKey: "panelOrigin") - } - // MARK: - Panel var isPanelVisible: Bool { panel.isVisible } func showPanel(automatically: Bool = false) { if !didPositionPanel { - positionAtBottomCenter() + if !panel.restoreOrigin(forID: Self.keyboardPanelID) { + positionAtBottomCenter() + } + panel.trackOrigin(as: Self.keyboardPanelID) didPositionPanel = true } panelWasAutoShown = automatically panel.orderFrontRegardless() noteActivity() + scanController.reload() } func hidePanel() { variantPopup.dismiss() panelWasAutoShown = false panel.orderOut(nil) + scanController.reload() } func togglePanel() { @@ -217,19 +215,12 @@ final class KeyboardController: NSObject { func clearLearnedWords() { learnedStore.clear() + predictor.clearLearned() bigrams.clear() + updateSuggestions() } private func positionAtBottomCenter() { - if let saved = UserDefaults.standard.array(forKey: "panelOrigin") as? [Double], - saved.count == 2 { - let origin = NSPoint(x: saved[0], y: saved[1]) - let restoredFrame = NSRect(origin: origin, size: panel.frame.size) - if NSScreen.screens.contains(where: { $0.visibleFrame.intersects(restoredFrame) }) { - panel.setFrameOrigin(origin) - return - } - } guard let screen = NSScreen.main else { return } let frame = panel.frame let visible = screen.visibleFrame @@ -274,7 +265,7 @@ final class KeyboardController: NSObject { refreshKeyCaps() updateSuggestions() noteActivity() - scanController.attach(to: view) + scanController.reload() } // MARK: - Inactivity fade @@ -349,12 +340,37 @@ final class KeyboardController: NSObject { } } + /// Reacts to the one preference that changed; rebuilding the views for + /// every change made sliders stutter and dropped hover and scan state. @objc private func preferencesChanged(_ notification: Notification) { - applyEffectiveLayout() - rebuildKeyboardView() - applyScanningState() - loadSystemDictionaryIfEnabled() - panels.rebuildOpenPanels() + guard let key = Preferences.changedKey(in: notification) else { return } + switch key { + case .layoutID: + if applyEffectiveLayout() { + rebuildKeyboardView() + } + case .scale, .themeID, .dwellEnabled, .dwellTime: + rebuildKeyboardView() + panels.rebuildOpenPanels() + case .showCurrentText: + rebuildKeyboardView() + case .opacity: + panels.setOpacity(CGFloat(preferences.opacity)) + // Fades the keyboard itself to the new opacity. + noteActivity() + case .inactivityFadeEnabled, .inactivityFadeDelay: + noteActivity() + case .scanningEnabled, .scanInterval, .scanSwitchKey, .scanAdvanceKey: + applyScanningState() + case .systemDictionaryEnabled: + loadSystemDictionaryIfEnabled() + case .predictionsEnabled, .terminalCompletionsEnabled: + updateSuggestions() + case .learningEnabled, .texterPasteMode, .wordlistLanguages, .autoCapitalization, + .autoSpacing, .autoShowOnTextFocus, .openPanelIDs, .keyClickSound, .panelOrigins: + // Read at the point of use. + break + } } @objc private func inputSourceChanged(_ notification: Notification) { @@ -475,7 +491,8 @@ final class KeyboardController: NSObject { variantPopup.show( variants: variants, relativeTo: keyView, - metrics: keyboardView.metrics + metrics: keyboardView.metrics, + theme: keyboardView.theme ) { [weak self] variant in guard let self else { return } self.injector.typeText(variant) @@ -586,7 +603,7 @@ final class KeyboardController: NSObject { let injector = self.injector let resolver = self.resolver - DispatchQueue.global(qos: .userInitiated).async { + DispatchQueue.global(qos: .userInitiated).async { [weak self] in for step in macro.steps { if let delay = step.delayMs, delay > 0 { usleep(useconds_t(delay) * 1000) @@ -609,12 +626,12 @@ final class KeyboardController: NSObject { DispatchQueue.main.async { Self.open(target) } } if let panelID = step.panel { - DispatchQueue.main.async { [weak self] in + DispatchQueue.main.async { self?.panels.toggle(panelID: panelID) } } if let phrase = step.say { - DispatchQueue.main.async { [weak self] in + DispatchQueue.main.async { self?.speech.speak(AVSpeechUtterance(string: phrase)) } } diff --git a/Sources/OpenOSK/KeyboardPanel.swift b/Sources/OpenOSK/KeyboardPanel.swift index 6041d54..dc947c6 100644 --- a/Sources/OpenOSK/KeyboardPanel.swift +++ b/Sources/OpenOSK/KeyboardPanel.swift @@ -1,9 +1,12 @@ #if canImport(AppKit) import AppKit +import OpenOSKCore /// Borderless floating panel that never takes key or main status, so the /// focused application keeps receiving our synthetic keystrokes. final class KeyboardPanel: NSPanel { + private var originID: String? + init(contentRect: NSRect) { super.init( contentRect: contentRect, @@ -23,6 +26,35 @@ final class KeyboardPanel: NSPanel { becomesKeyOnlyIfNeeded = true } + /// Moves the panel to where the user last left it. Fails if it was never + /// moved, or if that spot is no longer on any screen (display unplugged). + func restoreOrigin(forID id: String) -> Bool { + guard let saved = Preferences.shared.panelOrigin(forID: id) else { return false } + let origin = NSPoint(x: saved.x, y: saved.y) + let restored = NSRect(origin: origin, size: frame.size) + guard NSScreen.screens.contains(where: { $0.visibleFrame.intersects(restored) }) else { + return false + } + setFrameOrigin(origin) + return true + } + + /// From now on, remembers the panel's position under `id`. Call after the + /// initial placement so a default position is never stored as a choice. + func trackOrigin(as id: String) { + guard originID == nil else { return } + originID = id + NotificationCenter.default.addObserver( + self, selector: #selector(originDidChange), + name: NSWindow.didMoveNotification, object: self) + } + + @objc private func originDidChange(_ notification: Notification) { + guard let originID else { return } + Preferences.shared.setPanelOrigin( + x: Double(frame.origin.x), y: Double(frame.origin.y), forID: originID) + } + override var canBecomeKey: Bool { false } override var canBecomeMain: Bool { false } } diff --git a/Sources/OpenOSK/KeyboardView.swift b/Sources/OpenOSK/KeyboardView.swift index 681723e..2718cba 100644 --- a/Sources/OpenOSK/KeyboardView.swift +++ b/Sources/OpenOSK/KeyboardView.swift @@ -11,7 +11,9 @@ struct KeyboardMetrics { var unit: CGFloat { 46 * scale } var keyHeight: CGFloat { 46 * scale } var gap: CGFloat { 5 * scale } - var padding: CGFloat { 8 * scale } + var padding: CGFloat { 10 * scale } + var keyCornerRadius: CGFloat { 7 * scale } + var panelCornerRadius: CGFloat { 14 * scale } var currentTextHeight: CGFloat { showsCurrentText ? 24 * scale : 0 } var suggestionHeight: CGFloat { showsSuggestionBar ? 34 * scale : 0 } var keyFontSize: CGFloat { 16 * scale } @@ -30,425 +32,28 @@ struct KeyboardMetrics { } } -/// Dwell (hover-to-press) configuration shared by key and suggestion views. -struct DwellConfiguration { - var enabled = false - var time: TimeInterval = 0.9 -} - -enum ModifierVisualState { - case off - case latched - case locked -} - -// MARK: - Key view - -final class KeyView: NSView { - let key: Key - var displayText = "" - var secondaryText: String? - /// SF Symbol shown instead of the text label. - var imageName: String? - var modifierState: ModifierVisualState = .off { - didSet { needsDisplay = true } - } - var fontSize: CGFloat = 16 - var secondaryFontSize: CGFloat = 9 - var dwell = DwellConfiguration() - var theme = Theme.system - - var onPress: ((Key) -> Void)? - /// Long-press hook for character keys; returns true if it was handled - /// (e.g. a variant popup was shown), which suppresses the normal press. - var onLongPress: ((Key, KeyView) -> Bool)? - /// Highlight driven by scanning (switch access) mode. - var isScanHighlighted = false { - didSet { needsDisplay = true } - } - - /// Programmatic activation, used by scanning mode. - func triggerPress() { - isPressedVisual = true - DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) { [weak self] in - self?.isPressedVisual = false - } - onPress?(key) - } - - private var isPressedVisual = false { - didSet { needsDisplay = true } - } - private var isHovered = false { - didSet { needsDisplay = true } - } - private var repeatTimer: Timer? - private var longPressTimer: Timer? - private var longPressTriggered = false - - private var dwellTicker: Timer? - private var dwellStart: Date? - private var dwellProgress: CGFloat = 0 { - didSet { needsDisplay = true } - } - private var dwellCompleted = false - - init(key: Key) { - self.key = key - super.init(frame: .zero) - } - - @available(*, unavailable) - required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } - - override var mouseDownCanMoveWindow: Bool { false } +// MARK: - Panel backdrop - private var autorepeats: Bool { - switch key.kind { - case .special(let special): return special.autorepeats - case .media(let media): return media.autorepeats - default: return false - } - } +// Both backdrop views hand their clicks to the keyboard view underneath, so +// dragging the panel by its background keeps working. - /// Character and macro keys fire on release (enables long-press variants - /// and slide-away cancel-free behavior); specials/modifiers fire on press - /// so autorepeat and latching feel immediate. - private var firesOnMouseUp: Bool { - switch key.kind { - case .character, .macro: return true - default: return false - } - } - - private var supportsLongPress: Bool { - guard case .character = key.kind, let base = key.base else { return false } - return !CharacterVariants.variants(for: base, shifted: false).isEmpty - || !CharacterVariants.variants(for: base, shifted: true).isEmpty - } - - override func updateTrackingAreas() { - super.updateTrackingAreas() - trackingAreas.forEach(removeTrackingArea) - addTrackingArea(NSTrackingArea( - rect: bounds, - options: [.mouseEnteredAndExited, .activeAlways], - owner: self - )) - } - - // MARK: Mouse handling - - override func mouseEntered(with event: NSEvent) { - isHovered = true - if dwell.enabled, !dwellCompleted { - startDwell() - } - } - - override func mouseExited(with event: NSEvent) { - isHovered = false - cancelDwell() - dwellCompleted = false - } - - override func mouseDown(with event: NSEvent) { - cancelDwell() - isPressedVisual = true - longPressTriggered = false - - if firesOnMouseUp { - if supportsLongPress { - let timer = Timer(timeInterval: 0.45, repeats: false) { [weak self] _ in - guard let self else { return } - self.longPressTriggered = self.onLongPress?(self.key, self) ?? false - if self.longPressTriggered { - self.isPressedVisual = false - } - } - RunLoop.current.add(timer, forMode: .common) - longPressTimer = timer - } - } else { - onPress?(key) - if autorepeats { - let timer = Timer(timeInterval: 0.35, repeats: false) { [weak self] _ in - self?.startRepeating() - } - RunLoop.current.add(timer, forMode: .common) - repeatTimer = timer - } - } - } - - override func mouseUp(with event: NSEvent) { - isPressedVisual = false - stopRepeating() - longPressTimer?.invalidate() - longPressTimer = nil - - if firesOnMouseUp, !longPressTriggered { - onPress?(key) - } - longPressTriggered = false - } - - // MARK: Autorepeat - - private func startRepeating() { - stopRepeating() - let timer = Timer(timeInterval: 0.06, repeats: true) { [weak self] _ in - guard let self else { return } - self.onPress?(self.key) - } - RunLoop.current.add(timer, forMode: .common) - repeatTimer = timer - } - - private func stopRepeating() { - repeatTimer?.invalidate() - repeatTimer = nil - } - - // MARK: Dwell - - private func startDwell() { - cancelDwell() - dwellStart = Date() - let ticker = Timer(timeInterval: 1.0 / 30.0, repeats: true) { [weak self] _ in - guard let self, let start = self.dwellStart else { return } - let progress = CGFloat(Date().timeIntervalSince(start) / self.dwell.time) - if progress >= 1 { - self.completeDwell() - } else { - self.dwellProgress = progress - } - } - RunLoop.current.add(ticker, forMode: .common) - dwellTicker = ticker - } - - private func completeDwell() { - cancelDwell() - dwellCompleted = true - isPressedVisual = true - DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) { [weak self] in - self?.isPressedVisual = false - } - onPress?(key) - } +private final class PassthroughEffectView: NSVisualEffectView { + override func hitTest(_ point: NSPoint) -> NSView? { nil } +} - private func cancelDwell() { - dwellTicker?.invalidate() - dwellTicker = nil - dwellStart = nil - dwellProgress = 0 - } +private final class PanelOutlineView: NSView { + var color = NSColor.separatorColor + var cornerRadius: CGFloat = 14 - // MARK: Drawing + override func hitTest(_ point: NSPoint) -> NSView? { nil } override func draw(_ dirtyRect: NSRect) { - let rect = bounds.insetBy(dx: 0.5, dy: 0.5) - let path = NSBezierPath(roundedRect: rect, xRadius: 7, yRadius: 7) - - let isCharacter: Bool - if case .character = key.kind { isCharacter = true } else { isCharacter = false } - - var fill: NSColor - if isPressedVisual { - fill = theme.pressed - } else { - switch modifierState { - case .off: - fill = isCharacter ? theme.keyFill : theme.specialFill - if isHovered { - fill = fill.blended(withFraction: 0.15, of: theme.pressed) ?? fill - } - case .latched: - fill = theme.pressed.withAlphaComponent(0.35) - case .locked: - fill = theme.pressed.withAlphaComponent(0.7) - } - } - fill.setFill() - path.fill() - - NSColor.separatorColor.setStroke() + let path = NSBezierPath( + roundedRect: bounds.insetBy(dx: 0.5, dy: 0.5), + xRadius: cornerRadius, yRadius: cornerRadius) + color.setStroke() path.lineWidth = 1 path.stroke() - - if modifierState == .latched || modifierState == .locked { - let border = NSBezierPath(roundedRect: rect.insetBy(dx: 1, dy: 1), xRadius: 6, yRadius: 6) - theme.pressed.setStroke() - border.lineWidth = 2 - border.stroke() - } - - if isScanHighlighted { - let border = NSBezierPath( - roundedRect: rect.insetBy(dx: 1.5, dy: 1.5), xRadius: 6, yRadius: 6) - NSColor.systemOrange.setStroke() - border.lineWidth = 3 - border.stroke() - } - - let textColor: NSColor = isPressedVisual ? theme.pressedText : theme.text - - if let imageName, - let symbol = NSImage(systemSymbolName: imageName, accessibilityDescription: displayText) { - let configured = symbol.withSymbolConfiguration( - NSImage.SymbolConfiguration(pointSize: fontSize * 1.15, weight: .medium) - ) ?? symbol - let tinted = NSImage(size: configured.size, flipped: false) { rect in - configured.draw(in: rect) - textColor.set() - rect.fill(using: .sourceAtop) - return true - } - let size = tinted.size - tinted.draw(in: NSRect( - x: bounds.midX - size.width / 2, - y: bounds.midY - size.height / 2, - width: size.width, - height: size.height - )) - } else if !displayText.isEmpty { - let attributes: [NSAttributedString.Key: Any] = [ - .font: NSFont.systemFont(ofSize: fontSize, weight: isCharacter ? .regular : .medium), - .foregroundColor: textColor, - ] - let size = displayText.size(withAttributes: attributes) - let origin = NSPoint( - x: bounds.midX - size.width / 2, - y: bounds.midY - size.height / 2 - ) - displayText.draw(at: origin, withAttributes: attributes) - } - - if let secondaryText, !secondaryText.isEmpty { - let attributes: [NSAttributedString.Key: Any] = [ - .font: NSFont.systemFont(ofSize: secondaryFontSize), - .foregroundColor: isPressedVisual - ? theme.pressedText.withAlphaComponent(0.8) - : theme.secondaryText, - ] - let size = secondaryText.size(withAttributes: attributes) - let origin = NSPoint( - x: bounds.maxX - size.width - 5, - y: bounds.maxY - size.height - 3 - ) - secondaryText.draw(at: origin, withAttributes: attributes) - } - - if dwellProgress > 0 { - drawDwellProgress() - } - } - - /// Pie-style dwell progress indicator in the key's center. - private func drawDwellProgress() { - let radius = min(bounds.width, bounds.height) / 4.5 - let center = NSPoint(x: bounds.midX, y: bounds.midY) - let pie = NSBezierPath() - pie.move(to: center) - pie.appendArc( - withCenter: center, - radius: radius, - startAngle: 90, - endAngle: 90 - 360 * dwellProgress, - clockwise: true - ) - pie.close() - theme.pressed.withAlphaComponent(0.55).setFill() - pie.fill() - } -} - -// MARK: - Suggestion bar - -/// Button that supports dwell selection. -final class SuggestionButton: NSButton { - var dwell = DwellConfiguration() - private var dwellTimer: Timer? - - var isScanHighlighted = false { - didSet { - wantsLayer = true - layer?.borderWidth = isScanHighlighted ? 3 : 0 - layer?.borderColor = NSColor.systemOrange.cgColor - layer?.cornerRadius = 6 - } - } - - override var mouseDownCanMoveWindow: Bool { false } - - override func updateTrackingAreas() { - super.updateTrackingAreas() - trackingAreas.forEach(removeTrackingArea) - addTrackingArea(NSTrackingArea( - rect: bounds, - options: [.mouseEnteredAndExited, .activeAlways], - owner: self - )) - } - - override func mouseEntered(with event: NSEvent) { - guard dwell.enabled else { return } - let timer = Timer(timeInterval: dwell.time, repeats: false) { [weak self] _ in - self?.performClick(nil) - } - RunLoop.current.add(timer, forMode: .common) - dwellTimer = timer - } - - override func mouseExited(with event: NSEvent) { - dwellTimer?.invalidate() - dwellTimer = nil - } -} - -final class SuggestionBarView: NSView { - var onSelect: ((String) -> Void)? - var fontSize: CGFloat = 13 - var dwell = DwellConfiguration() - - private var buttons: [SuggestionButton] = [] - - var scanItems: [SuggestionButton] { buttons } - - override var mouseDownCanMoveWindow: Bool { true } - - func setSuggestions(_ suggestions: [String]) { - buttons.forEach { $0.removeFromSuperview() } - buttons = suggestions.map { suggestion in - let button = SuggestionButton(title: suggestion, target: self, action: #selector(selected(_:))) - button.bezelStyle = .rounded - button.controlSize = .large - button.font = .systemFont(ofSize: fontSize) - button.dwell = dwell - addSubview(button) - return button - } - needsLayout = true - layoutButtons() - } - - @objc private func selected(_ sender: NSButton) { - onSelect?(sender.title) - } - - override func layout() { - super.layout() - layoutButtons() - } - - private func layoutButtons() { - var x: CGFloat = 0 - for button in buttons { - let width = button.intrinsicContentSize.width + 8 - button.frame = NSRect(x: x, y: 0, width: width, height: bounds.height) - x += width + 6 - } } } @@ -479,6 +84,20 @@ final class KeyboardView: NSView { return groups } + /// Rounded-rect mask for the blur view; stretches from its cap insets. + private static func maskImage(cornerRadius: CGFloat) -> NSImage { + let edge = cornerRadius * 2 + 1 + let image = NSImage(size: NSSize(width: edge, height: edge), flipped: false) { rect in + NSColor.black.setFill() + NSBezierPath(roundedRect: rect, xRadius: cornerRadius, yRadius: cornerRadius).fill() + return true + } + image.capInsets = NSEdgeInsets( + top: cornerRadius, left: cornerRadius, bottom: cornerRadius, right: cornerRadius) + image.resizingMode = .stretch + return image + } + let theme: Theme init( @@ -493,10 +112,29 @@ final class KeyboardView: NSView { super.init(frame: NSRect(origin: .zero, size: metrics.size(for: layout))) wantsLayer = true + if theme.usesVibrancy { + let blur = PassthroughEffectView(frame: bounds) + blur.autoresizingMask = [.width, .height] + blur.material = .popover + blur.blendingMode = .behindWindow + // The panel never becomes key; without this the blur would + // render in its washed-out inactive state. + blur.state = .active + blur.maskImage = Self.maskImage(cornerRadius: metrics.panelCornerRadius) + addSubview(blur) + + // The blur covers whatever this view draws itself, outline included. + let outline = PanelOutlineView(frame: bounds) + outline.autoresizingMask = [.width, .height] + outline.color = theme.border + outline.cornerRadius = metrics.panelCornerRadius + addSubview(outline) + } + if metrics.showsCurrentText { currentTextLabel.font = .monospacedSystemFont( ofSize: metrics.currentTextFontSize, weight: .regular) - currentTextLabel.textColor = theme.secondaryText + currentTextLabel.textColor = theme.panelText currentTextLabel.lineBreakMode = .byTruncatingHead currentTextLabel.alignment = .left addSubview(currentTextLabel) @@ -504,7 +142,9 @@ final class KeyboardView: NSView { if metrics.showsSuggestionBar { suggestionBar.fontSize = metrics.suggestionFontSize + suggestionBar.spacing = metrics.gap suggestionBar.dwell = dwell + suggestionBar.theme = theme addSubview(suggestionBar) } @@ -514,6 +154,7 @@ final class KeyboardView: NSView { let view = KeyView(key: key) view.fontSize = metrics.keyFontSize view.secondaryFontSize = metrics.secondaryFontSize + view.cornerRadius = metrics.keyCornerRadius view.dwell = dwell view.theme = theme view.onPress = { [weak self] key in self?.onKeyPress?(key) } @@ -552,10 +193,13 @@ final class KeyboardView: NSView { override func mouseExited(with event: NSEvent) { onHoverChange?(false) } override func draw(_ dirtyRect: NSRect) { - let path = NSBezierPath(roundedRect: bounds, xRadius: 14, yRadius: 14) + guard !theme.usesVibrancy else { return } + let radius = metrics.panelCornerRadius + let path = NSBezierPath( + roundedRect: bounds.insetBy(dx: 0.5, dy: 0.5), xRadius: radius, yRadius: radius) theme.background.setFill() path.fill() - NSColor.separatorColor.setStroke() + theme.border.setStroke() path.lineWidth = 1 path.stroke() } @@ -575,6 +219,7 @@ final class KeyboardView: NSView { switch key.kind { case .character: view.displayText = key.label ?? key.output(shifted: shifted, alted: alted) ?? "" + view.spokenLabel = view.displayText if !shifted, !alted, let shiftOutput = key.shift, shiftOutput != key.base?.uppercased() { view.secondaryText = shiftOutput @@ -584,24 +229,33 @@ final class KeyboardView: NSView { view.modifierState = .off case .special(let special): view.displayText = key.label ?? special.symbol + view.spokenLabel = special.spokenName view.secondaryText = nil view.modifierState = .off case .modifier(let modifier): view.displayText = key.label ?? modifier.symbol + view.spokenLabel = modifier.spokenName view.secondaryText = nil view.modifierState = modifierStates[modifier] ?? .off case .macro: view.displayText = key.label ?? key.text.map { String($0.prefix(6)) } ?? "◆" + view.spokenLabel = key.label ?? key.text ?? L("Macro") view.secondaryText = nil view.modifierState = .off case .media(let media): view.displayText = key.label ?? media.symbol + view.spokenLabel = media.spokenName view.secondaryText = nil view.modifierState = .off case nil: break } - view.imageName = key.image + if case .media(let media) = key.kind, key.label == nil { + // The text fallbacks are emoji, which ignore the theme. + view.imageName = key.image ?? media.symbolName + } else { + view.imageName = key.image + } view.needsDisplay = true } } diff --git a/Sources/OpenOSK/PanelsController.swift b/Sources/OpenOSK/PanelsController.swift index 62863a4..21f69cc 100644 --- a/Sources/OpenOSK/PanelsController.swift +++ b/Sources/OpenOSK/PanelsController.swift @@ -8,8 +8,8 @@ final class PanelsController { private unowned let keyboardController: KeyboardController private let preferences = Preferences.shared private var openPanels: [String: KeyboardPanel] = [:] - /// Suppresses the rebuild triggered by our own openPanelIDs persistence. - private var suppressNextRebuild = false + /// Called when a panel opened, closed or was rebuilt (scanning follows). + var onChange: (() -> Void)? init(keyboardController: KeyboardController) { self.keyboardController = keyboardController @@ -17,6 +17,13 @@ final class PanelsController { var openPanelIDs: Set { Set(openPanels.keys) } + /// Scan groups of all open panels, in a stable order. + var scanGroups: [[NSView]] { + openPanels.keys.sorted().flatMap { id in + (openPanels[id]?.contentView as? KeyboardView)?.scanGroups ?? [] + } + } + func availablePanels() -> [KeyboardLayout] { LayoutStore.allPanels() } @@ -28,6 +35,7 @@ final class PanelsController { open(panel) } } + onChange?() } func toggle(panelID: String) { @@ -37,6 +45,7 @@ final class PanelsController { open(panel) } persistOpenState() + onChange?() } func closeAll() { @@ -44,6 +53,7 @@ final class PanelsController { close(panelID: id) } persistOpenState() + onChange?() } private func open(_ layout: KeyboardLayout) { @@ -68,7 +78,10 @@ final class PanelsController { let window = KeyboardPanel(contentRect: NSRect(origin: .zero, size: size)) window.contentView = view window.setContentSize(size) - position(window, stackIndex: openPanels.count) + if !window.restoreOrigin(forID: layout.id) { + position(window, stackIndex: openPanels.count) + } + window.trackOrigin(as: layout.id) window.alphaValue = CGFloat(preferences.opacity) window.orderFrontRegardless() @@ -94,12 +107,12 @@ final class PanelsController { )) } - /// Rebuilds open panels (after scale/opacity/dwell changes). + func setOpacity(_ opacity: CGFloat) { + openPanels.values.forEach { $0.alphaValue = opacity } + } + + /// Rebuilds open panels (after scale, theme, dwell or panel edits). func rebuildOpenPanels() { - if suppressNextRebuild { - suppressNextRebuild = false - return - } let ids = Array(openPanels.keys) for id in ids { close(panelID: id) @@ -109,10 +122,10 @@ final class PanelsController { open(panel) } } + onChange?() } private func persistOpenState() { - suppressNextRebuild = true preferences.openPanelIDs = Array(openPanels.keys).sorted() } } diff --git a/Sources/OpenOSK/Resources/de.lproj/Localizable.strings b/Sources/OpenOSK/Resources/de.lproj/Localizable.strings index 8391bdc..347b267 100644 --- a/Sources/OpenOSK/Resources/de.lproj/Localizable.strings +++ b/Sources/OpenOSK/Resources/de.lproj/Localizable.strings @@ -16,7 +16,6 @@ "Insert" = "Einfügen"; /* Settings */ -"OpenOSK Settings" = "OpenOSK-Einstellungen"; "Layout:" = "Layout:"; "Key size:" = "Tastengröße:"; "Opacity:" = "Deckkraft:"; @@ -83,3 +82,42 @@ "(kept as defined in JSON)" = "(wie im JSON definiert)"; "Saved panels override bundled ones with the same id." = "Gespeicherte Panels überschreiben mitgelieferte mit derselben ID."; "My Panel" = "Mein Panel"; + +/* v0.6 */ +"General" = "Allgemein"; +"Typing" = "Eingabe"; +"Access" = "Zugang"; +"Predictions:" = "Vorschläge:"; +"Typing aids:" = "Tipphilfen:"; +"Dwell:" = "Verweilen:"; +"Scanning:" = "Scanning:"; +"Panels:" = "Panels:"; +"Per app:" = "Pro App:"; +"Clear all learned words?" = "Alle gelernten Wörter löschen?"; +"Predictions fall back to the bundled word lists. This cannot be undone." = "Vorschläge stützen sich danach wieder auf die mitgelieferten Wortlisten. Das lässt sich nicht widerrufen."; +"Cancel" = "Abbrechen"; +"Escape" = "Escape"; +"Tab" = "Tabulator"; +"Delete" = "Löschen"; +"Forward Delete" = "Vorwärts löschen"; +"Left Arrow" = "Pfeil links"; +"Right Arrow" = "Pfeil rechts"; +"Up Arrow" = "Pfeil hoch"; +"Down Arrow" = "Pfeil runter"; +"Home" = "Pos1"; +"End" = "Ende"; +"Page Up" = "Bild hoch"; +"Page Down" = "Bild runter"; +"Shift" = "Umschalt"; +"Control" = "Control"; +"Option" = "Option"; +"Command" = "Befehl"; +"Volume Up" = "Lauter"; +"Volume Down" = "Leiser"; +"Mute" = "Ton aus"; +"Brightness Up" = "Heller"; +"Brightness Down" = "Dunkler"; +"Play/Pause" = "Wiedergabe/Pause"; +"Next Track" = "Nächster Titel"; +"Previous Track" = "Vorheriger Titel"; +"Macro" = "Makro"; diff --git a/Sources/OpenOSK/Resources/en.lproj/Localizable.strings b/Sources/OpenOSK/Resources/en.lproj/Localizable.strings index ccfbf15..d28e5da 100644 --- a/Sources/OpenOSK/Resources/en.lproj/Localizable.strings +++ b/Sources/OpenOSK/Resources/en.lproj/Localizable.strings @@ -16,7 +16,6 @@ "Insert" = "Insert"; /* Settings */ -"OpenOSK Settings" = "OpenOSK Settings"; "Layout:" = "Layout:"; "Key size:" = "Key size:"; "Opacity:" = "Opacity:"; @@ -83,3 +82,42 @@ "(kept as defined in JSON)" = "(kept as defined in JSON)"; "Saved panels override bundled ones with the same id." = "Saved panels override bundled ones with the same id."; "My Panel" = "My Panel"; + +/* v0.6 */ +"General" = "General"; +"Typing" = "Typing"; +"Access" = "Access"; +"Predictions:" = "Predictions:"; +"Typing aids:" = "Typing aids:"; +"Dwell:" = "Dwell:"; +"Scanning:" = "Scanning:"; +"Panels:" = "Panels:"; +"Per app:" = "Per app:"; +"Clear all learned words?" = "Clear all learned words?"; +"Predictions fall back to the bundled word lists. This cannot be undone." = "Predictions fall back to the bundled word lists. This cannot be undone."; +"Cancel" = "Cancel"; +"Escape" = "Escape"; +"Tab" = "Tab"; +"Delete" = "Delete"; +"Forward Delete" = "Forward Delete"; +"Left Arrow" = "Left Arrow"; +"Right Arrow" = "Right Arrow"; +"Up Arrow" = "Up Arrow"; +"Down Arrow" = "Down Arrow"; +"Home" = "Home"; +"End" = "End"; +"Page Up" = "Page Up"; +"Page Down" = "Page Down"; +"Shift" = "Shift"; +"Control" = "Control"; +"Option" = "Option"; +"Command" = "Command"; +"Volume Up" = "Volume Up"; +"Volume Down" = "Volume Down"; +"Mute" = "Mute"; +"Brightness Up" = "Brightness Up"; +"Brightness Down" = "Brightness Down"; +"Play/Pause" = "Play/Pause"; +"Next Track" = "Next Track"; +"Previous Track" = "Previous Track"; +"Macro" = "Macro"; diff --git a/Sources/OpenOSK/ScanController.swift b/Sources/OpenOSK/ScanController.swift index 478463e..9e5ee46 100644 --- a/Sources/OpenOSK/ScanController.swift +++ b/Sources/OpenOSK/ScanController.swift @@ -24,7 +24,8 @@ final class ScanController { "f15": 113, ] - private weak var keyboardView: KeyboardView? + /// Supplies the groups to scan: the keyboard's rows plus open panels. + var groupsProvider: (() -> [[NSView]])? private var level: Level = .groups private var index = 0 private var timer: Timer? @@ -41,8 +42,8 @@ final class ScanController { private(set) var isActive = false - func attach(to view: KeyboardView?) { - keyboardView = view + /// The scanned views were replaced or shown/hidden: start over. + func reload() { if isActive { restartCycle() } @@ -76,7 +77,7 @@ final class ScanController { // MARK: - Cycle private var groups: [[NSView]] { - keyboardView?.scanGroups ?? [] + groupsProvider?() ?? [] } private func scheduleTimer() { @@ -138,8 +139,8 @@ final class ScanController { private func activate(_ view: NSView) { if let keyView = view as? KeyView { keyView.triggerPress() - } else if let button = view as? NSButton { - button.performClick(nil) + } else if let chip = view as? SuggestionChipView { + chip.select() } } @@ -175,8 +176,8 @@ final class ScanController { private func setHighlight(_ view: NSView, _ value: Bool) { if let keyView = view as? KeyView { keyView.isScanHighlighted = value - } else if let button = view as? SuggestionButton { - button.isScanHighlighted = value + } else if let chip = view as? SuggestionChipView { + chip.isScanHighlighted = value } } } diff --git a/Sources/OpenOSK/SettingsController.swift b/Sources/OpenOSK/SettingsController.swift index fbf4720..0a69047 100644 --- a/Sources/OpenOSK/SettingsController.swift +++ b/Sources/OpenOSK/SettingsController.swift @@ -2,16 +2,29 @@ import AppKit import OpenOSKCore +/// Settings window: toolbar tabs (General, Typing, Access, Panels), each a +/// grid of controls bound to `Preferences` by key path. final class SettingsController: NSObject { private let preferences = Preferences.shared - private unowned let keyboardController: KeyboardController + private let onClearLearnedWords: () -> Void private var window: NSWindow? private var layoutPopup: NSPopUpButton! private var layouts: [KeyboardLayout] = [] + /// Controls that only make sense while their switch is on. + private var dependents: [(control: NSControl, isEnabled: () -> Bool)] = [] - init(keyboardController: KeyboardController) { - self.keyboardController = keyboardController + private static let paneWidth: CGFloat = 460 + private static let scanKeys: [(id: String, title: String)] = [ + ("space", L("Space")), + ("return", L("Return")), + ("f13", "F13"), + ("f14", "F14"), + ("f15", "F15"), + ] + + init(onClearLearnedWords: @escaping () -> Void) { + self.onClearLearnedWords = onClearLearnedWords super.init() } @@ -24,277 +37,244 @@ final class SettingsController: NSObject { window?.makeKeyAndOrderFront(nil) } + // MARK: - Window + + /// The settings panes in tab order. Also rendered by `--snapshot`. + func makeTabs() -> [NSTabViewItem] { + let tabs = [ + tab(L("General"), symbol: "gearshape", rows: generalRows()), + tab(L("Typing"), symbol: "character.cursor.ibeam", rows: typingRows()), + tab(L("Access"), symbol: "accessibility", rows: accessRows()), + tab(L("Panels"), symbol: "rectangle.3.group", rows: panelRows()), + ] + reloadLayouts() + refreshDependents() + return tabs + } + private func buildWindow() { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 480, height: 480), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - window.title = L("OpenOSK Settings") + let tabs = SettingsTabViewController() + tabs.tabStyle = .toolbar + makeTabs().forEach(tabs.addTabViewItem) + + // No title of its own: a toolbar-style tab controller titles the + // window after the selected tab. + let window = NSWindow(contentViewController: tabs) + window.styleMask = [.titled, .closable] window.isReleasedWhenClosed = false window.center() + self.window = window + } - layoutPopup = NSPopUpButton() - layoutPopup.target = self - layoutPopup.action = #selector(layoutSelected) - - let scaleSlider = NSSlider( - value: preferences.scale, minValue: 0.7, maxValue: 1.6, - target: self, action: #selector(scaleChanged) - ) - let opacitySlider = NSSlider( - value: preferences.opacity, minValue: 0.35, maxValue: 1.0, - target: self, action: #selector(opacityChanged) - ) - let dwellTimeSlider = NSSlider( - value: preferences.dwellTime, minValue: 0.3, maxValue: 2.5, - target: self, action: #selector(dwellTimeChanged) - ) - - let themePopup = NSPopUpButton() - themePopup.addItems(withTitles: Theme.all.map(\.name)) - if let index = Theme.all.firstIndex(where: { $0.id == preferences.themeID }) { - themePopup.selectItem(at: index) - } - themePopup.target = self - themePopup.action = #selector(themeSelected) - - let advanceKeyPopup = NSPopUpButton() - advanceKeyPopup.addItems(withTitles: [L("None")] + Self.switchKeys.map(\.title)) - let advanceOptions = ["none"] + Self.switchKeys.map(\.id) - if let index = advanceOptions.firstIndex(of: preferences.scanAdvanceKey) { - advanceKeyPopup.selectItem(at: index) - } - advanceKeyPopup.target = self - advanceKeyPopup.action = #selector(advanceKeySelected) - - let profilesButton = NSButton( - title: L("App Profiles…"), target: self, action: #selector(editProfiles)) - let panelsButton = NSButton( - title: L("Panel Editor…"), target: self, action: #selector(editPanels)) - let panelsFolderButton = NSButton( - title: L("Open Panels Folder…"), target: self, action: #selector(openPanelsFolder)) - - let scanIntervalSlider = NSSlider( - value: preferences.scanInterval, minValue: 0.5, maxValue: 3.0, - target: self, action: #selector(scanIntervalChanged) - ) - let switchKeyPopup = NSPopUpButton() - switchKeyPopup.addItems(withTitles: Self.switchKeys.map { $0.title }) - if let index = Self.switchKeys.firstIndex(where: { $0.id == preferences.scanSwitchKey }) { - switchKeyPopup.selectItem(at: index) - } - switchKeyPopup.target = self - switchKeyPopup.action = #selector(switchKeySelected) - - let clearButton = NSButton( - title: L("Clear Learned Words"), - target: self, - action: #selector(clearLearned) - ) - - let grid = NSGridView(views: [ - [label(L("Layout:")), layoutPopup], - [label(L("Theme:")), themePopup], - [label(L("Key size:")), scaleSlider], - [label(L("Opacity:")), opacitySlider], - [NSGridCell.emptyContentView, checkbox( - L("Show word predictions"), - selector: #selector(predictionsToggled), - state: preferences.predictionsEnabled)], - [NSGridCell.emptyContentView, checkbox( - L("Learn words from my typing"), - selector: #selector(learningToggled), - state: preferences.learningEnabled)], - [NSGridCell.emptyContentView, checkbox( - L("Complete shell commands in terminals"), - selector: #selector(terminalToggled), - state: preferences.terminalCompletionsEnabled)], - [NSGridCell.emptyContentView, checkbox( - L("Auto-capitalize after sentence end"), - selector: #selector(autoCapToggled), - state: preferences.autoCapitalization)], - [NSGridCell.emptyContentView, checkbox( - L("Double-space inserts a period"), - selector: #selector(autoSpacingToggled), - state: preferences.autoSpacing)], - [NSGridCell.emptyContentView, checkbox( - L("Show current text on the keyboard"), - selector: #selector(currentTextToggled), - state: preferences.showCurrentText)], - [NSGridCell.emptyContentView, checkbox( - L("Use system dictionary for predictions"), - selector: #selector(systemDictionaryToggled), - state: preferences.systemDictionaryEnabled)], - [NSGridCell.emptyContentView, checkbox( - L("Key click sound"), - selector: #selector(keyClickToggled), - state: preferences.keyClickSound)], - [NSGridCell.emptyContentView, checkbox( - L("Fade keyboard when inactive"), - selector: #selector(fadeToggled), - state: preferences.inactivityFadeEnabled)], - [NSGridCell.emptyContentView, checkbox( - L("Show keyboard when editing text"), - selector: #selector(autoShowToggled), - state: preferences.autoShowOnTextFocus)], - [NSGridCell.emptyContentView, checkbox( - L("Dwell input (hover to press)"), - selector: #selector(dwellToggled), - state: preferences.dwellEnabled)], - [label(L("Dwell time:")), dwellTimeSlider], - [NSGridCell.emptyContentView, checkbox( - L("Scanning (switch access)"), - selector: #selector(scanningToggled), - state: preferences.scanningEnabled)], - [label(L("Scan interval:")), scanIntervalSlider], - [label(L("Switch key:")), switchKeyPopup], - [label(L("Advance key:")), advanceKeyPopup], - [NSGridCell.emptyContentView, profilesButton], - [NSGridCell.emptyContentView, panelsButton], - [NSGridCell.emptyContentView, panelsFolderButton], - [NSGridCell.emptyContentView, clearButton], - ]) + private func tab(_ title: String, symbol: String, rows: [[NSView]]) -> NSTabViewItem { + let grid = NSGridView(views: rows) grid.translatesAutoresizingMaskIntoConstraints = false grid.rowSpacing = 10 grid.columnSpacing = 12 grid.column(at: 0).xPlacement = .trailing + grid.rowAlignment = .firstBaseline - let content = NSView() - content.addSubview(grid) + let pane = NSView() + pane.addSubview(grid) NSLayoutConstraint.activate([ - grid.topAnchor.constraint(equalTo: content.topAnchor, constant: 20), - grid.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 20), - grid.trailingAnchor.constraint(lessThanOrEqualTo: content.trailingAnchor, constant: -20), - grid.bottomAnchor.constraint(lessThanOrEqualTo: content.bottomAnchor, constant: -20), - scaleSlider.widthAnchor.constraint(greaterThanOrEqualToConstant: 240), + pane.widthAnchor.constraint(equalToConstant: Self.paneWidth), + grid.topAnchor.constraint(equalTo: pane.topAnchor, constant: 20), + grid.centerXAnchor.constraint(equalTo: pane.centerXAnchor), + grid.leadingAnchor.constraint(greaterThanOrEqualTo: pane.leadingAnchor, constant: 20), + grid.bottomAnchor.constraint(equalTo: pane.bottomAnchor, constant: -20), ]) - window.contentView = content - self.window = window + let controller = NSViewController() + controller.view = pane + controller.title = title + let item = NSTabViewItem(viewController: controller) + item.label = title + item.image = NSImage(systemSymbolName: symbol, accessibilityDescription: title) + return item } - private func label(_ text: String) -> NSTextField { - NSTextField(labelWithString: text) - } + // MARK: - Panes - private func checkbox(_ title: String, selector: Selector, state: Bool) -> NSButton { - let button = NSButton(checkboxWithTitle: title, target: self, action: selector) - button.state = state ? .on : .off - return button - } + private func generalRows() -> [[NSView]] { + layoutPopup = NSPopUpButton() + layoutPopup.target = self + layoutPopup.action = #selector(layoutSelected) - private func reloadLayouts() { - layouts = LayoutStore.allLayouts() - layoutPopup.removeAllItems() - layoutPopup.addItems(withTitles: layouts.map(\.name)) - if let index = layouts.firstIndex(where: { $0.id == preferences.layoutID }) { - layoutPopup.selectItem(at: index) - } - } + let themePopup = popup( + titles: Theme.all.map(\.name), ids: Theme.all.map(\.id), keyPath: \.themeID) - // MARK: - Actions + // Every distinct key size rebuilds the keyboard, so it applies on release. + let scale = slider(\.scale, range: 0.7...1.6, format: Self.percent, continuous: false) + let opacity = slider(\.opacity, range: 0.35...1.0, format: Self.percent) - @objc private func layoutSelected(_ sender: NSPopUpButton) { - let index = sender.indexOfSelectedItem - guard layouts.indices.contains(index) else { return } - preferences.layoutID = layouts[index].id + return [ + [label(L("Layout:")), layoutPopup], + [label(L("Theme:")), themePopup], + [label(L("Key size:")), scale], + [label(L("Opacity:")), opacity], + [spacer(), checkbox(L("Show current text on the keyboard"), \.showCurrentText)], + [spacer(), checkbox(L("Fade keyboard when inactive"), \.inactivityFadeEnabled)], + [spacer(), checkbox(L("Show keyboard when editing text"), \.autoShowOnTextFocus)], + [spacer(), checkbox(L("Key click sound"), \.keyClickSound)], + ] } - @objc private func scaleChanged(_ sender: NSSlider) { - preferences.scale = sender.doubleValue + private func typingRows() -> [[NSView]] { + let clearButton = NSButton( + title: L("Clear Learned Words"), target: self, action: #selector(clearLearned)) + return [ + [label(L("Predictions:")), checkbox(L("Show word predictions"), \.predictionsEnabled)], + [spacer(), checkbox(L("Learn words from my typing"), \.learningEnabled)], + [spacer(), checkbox(L("Use system dictionary for predictions"), \.systemDictionaryEnabled)], + [spacer(), checkbox(L("Complete shell commands in terminals"), \.terminalCompletionsEnabled)], + [spacer(), clearButton], + [label(L("Typing aids:")), checkbox(L("Auto-capitalize after sentence end"), \.autoCapitalization)], + [spacer(), checkbox(L("Double-space inserts a period"), \.autoSpacing)], + ] } - @objc private func opacityChanged(_ sender: NSSlider) { - preferences.opacity = sender.doubleValue - } + private func accessRows() -> [[NSView]] { + let dwellTime = slider(\.dwellTime, range: 0.3...2.5, format: Self.seconds) + let scanInterval = slider(\.scanInterval, range: 0.5...3.0, format: Self.seconds) + let switchKey = popup( + titles: Self.scanKeys.map(\.title), ids: Self.scanKeys.map(\.id), + keyPath: \.scanSwitchKey) + let advanceKey = popup( + titles: [L("None")] + Self.scanKeys.map(\.title), + ids: ["none"] + Self.scanKeys.map(\.id), + keyPath: \.scanAdvanceKey) + + let preferences = self.preferences + dependents.append((dwellTime.slider, { preferences.dwellEnabled })) + for control in [scanInterval.slider, switchKey, advanceKey] as [NSControl] { + dependents.append((control, { preferences.scanningEnabled })) + } - @objc private func dwellTimeChanged(_ sender: NSSlider) { - preferences.dwellTime = sender.doubleValue + return [ + [label(L("Dwell:")), checkbox(L("Dwell input (hover to press)"), \.dwellEnabled)], + [label(L("Dwell time:")), dwellTime], + [label(L("Scanning:")), checkbox(L("Scanning (switch access)"), \.scanningEnabled)], + [label(L("Scan interval:")), scanInterval], + [label(L("Switch key:")), switchKey], + [label(L("Advance key:")), advanceKey], + ] } - @objc private func predictionsToggled(_ sender: NSButton) { - preferences.predictionsEnabled = sender.state == .on + private func panelRows() -> [[NSView]] { + [ + [label(L("Panels:")), NSButton( + title: L("Panel Editor…"), target: self, action: #selector(editPanels))], + [spacer(), NSButton( + title: L("Open Panels Folder…"), target: self, action: #selector(openPanelsFolder))], + [label(L("Per app:")), NSButton( + title: L("App Profiles…"), target: self, action: #selector(editProfiles))], + ] } - @objc private func learningToggled(_ sender: NSButton) { - preferences.learningEnabled = sender.state == .on - } + // MARK: - Control factories - @objc private func terminalToggled(_ sender: NSButton) { - preferences.terminalCompletionsEnabled = sender.state == .on + private static func percent(_ value: Double) -> String { + "\(Int((value * 100).rounded())) %" } - @objc private func autoCapToggled(_ sender: NSButton) { - preferences.autoCapitalization = sender.state == .on + private static func seconds(_ value: Double) -> String { + String(format: "%.1f s", locale: .current, value) } - @objc private func autoSpacingToggled(_ sender: NSButton) { - preferences.autoSpacing = sender.state == .on + private func label(_ text: String) -> NSTextField { + NSTextField(labelWithString: text) } - @objc private func currentTextToggled(_ sender: NSButton) { - preferences.showCurrentText = sender.state == .on + private func spacer() -> NSView { + NSGridCell.emptyContentView } - @objc private func fadeToggled(_ sender: NSButton) { - preferences.inactivityFadeEnabled = sender.state == .on + private func checkbox( + _ title: String, _ keyPath: ReferenceWritableKeyPath + ) -> NSButton { + let button = PreferenceCheckbox(checkboxWithTitle: title, target: self, action: #selector(checkboxToggled)) + button.keyPath = keyPath + button.state = preferences[keyPath: keyPath] ? .on : .off + return button } - @objc private func autoShowToggled(_ sender: NSButton) { - preferences.autoShowOnTextFocus = sender.state == .on + private func slider( + _ keyPath: ReferenceWritableKeyPath, + range: ClosedRange, + format: @escaping (Double) -> String, + continuous: Bool = true + ) -> PreferenceSliderRow { + let row = PreferenceSliderRow( + value: preferences[keyPath: keyPath], range: range, format: format) + row.appliesOnRelease = !continuous + row.onChange = { [preferences] value in preferences[keyPath: keyPath] = value } + return row } - @objc private func dwellToggled(_ sender: NSButton) { - preferences.dwellEnabled = sender.state == .on + private func popup( + titles: [String], ids: [String], + keyPath: ReferenceWritableKeyPath + ) -> NSPopUpButton { + let popup = PreferencePopup() + popup.addItems(withTitles: titles) + popup.ids = ids + popup.keyPath = keyPath + if let index = ids.firstIndex(of: preferences[keyPath: keyPath]) { + popup.selectItem(at: index) + } + popup.target = self + popup.action = #selector(popupSelected) + return popup } - @objc private func clearLearned() { - keyboardController.clearLearnedWords() + private func refreshDependents() { + for dependent in dependents { + dependent.control.isEnabled = dependent.isEnabled() + } } - private static let switchKeys: [(id: String, title: String)] = [ - ("space", L("Space")), - ("return", L("Return")), - ("f13", "F13"), - ("f14", "F14"), - ("f15", "F15"), - ] - - @objc private func scanningToggled(_ sender: NSButton) { - preferences.scanningEnabled = sender.state == .on + private func reloadLayouts() { + layouts = LayoutStore.allLayouts() + layoutPopup.removeAllItems() + layoutPopup.addItems(withTitles: layouts.map(\.name)) + if let index = layouts.firstIndex(where: { $0.id == preferences.layoutID }) { + layoutPopup.selectItem(at: index) + } } - @objc private func scanIntervalChanged(_ sender: NSSlider) { - preferences.scanInterval = sender.doubleValue - } + // MARK: - Actions - @objc private func switchKeySelected(_ sender: NSPopUpButton) { - let index = sender.indexOfSelectedItem - guard Self.switchKeys.indices.contains(index) else { return } - preferences.scanSwitchKey = Self.switchKeys[index].id + @objc private func checkboxToggled(_ sender: PreferenceCheckbox) { + guard let keyPath = sender.keyPath else { return } + preferences[keyPath: keyPath] = sender.state == .on + refreshDependents() } - @objc private func themeSelected(_ sender: NSPopUpButton) { + @objc private func popupSelected(_ sender: PreferencePopup) { let index = sender.indexOfSelectedItem - guard Theme.all.indices.contains(index) else { return } - preferences.themeID = Theme.all[index].id + guard let keyPath = sender.keyPath, sender.ids.indices.contains(index) else { return } + preferences[keyPath: keyPath] = sender.ids[index] } - @objc private func advanceKeySelected(_ sender: NSPopUpButton) { - let options = ["none"] + Self.switchKeys.map(\.id) + @objc private func layoutSelected(_ sender: NSPopUpButton) { let index = sender.indexOfSelectedItem - guard options.indices.contains(index) else { return } - preferences.scanAdvanceKey = options[index] - } - - @objc private func systemDictionaryToggled(_ sender: NSButton) { - preferences.systemDictionaryEnabled = sender.state == .on + guard layouts.indices.contains(index) else { return } + preferences.layoutID = layouts[index].id } - @objc private func keyClickToggled(_ sender: NSButton) { - preferences.keyClickSound = sender.state == .on + @objc private func clearLearned() { + let alert = NSAlert() + alert.messageText = L("Clear all learned words?") + alert.informativeText = L("Predictions fall back to the bundled word lists. This cannot be undone.") + alert.addButton(withTitle: L("Clear Learned Words")) + alert.addButton(withTitle: L("Cancel")) + alert.buttons.first?.hasDestructiveAction = true + guard let window else { return } + alert.beginSheetModal(for: window) { [weak self] response in + if response == .alertFirstButtonReturn { + self?.onClearLearnedWords() + } + } } @objc private func editProfiles() { @@ -311,4 +291,78 @@ final class SettingsController: NSObject { NSWorkspace.shared.open(url) } } + +// MARK: - Bound controls + +private final class PreferenceCheckbox: NSButton { + var keyPath: ReferenceWritableKeyPath? +} + +private final class PreferencePopup: NSPopUpButton { + var keyPath: ReferenceWritableKeyPath? + /// Stored value per menu item, parallel to the item titles. + var ids: [String] = [] +} + +/// A slider with its current value spelled out next to it. +private final class PreferenceSliderRow: NSStackView { + let slider: NSSlider + var onChange: ((Double) -> Void)? + /// Report the value only once the drag ends; the label still follows it. + var appliesOnRelease = false + + private let valueLabel = NSTextField(labelWithString: "") + private let format: (Double) -> String + + init(value: Double, range: ClosedRange, format: @escaping (Double) -> String) { + self.format = format + slider = NSSlider( + value: value, minValue: range.lowerBound, maxValue: range.upperBound, + target: nil, action: nil) + super.init(frame: .zero) + slider.target = self + slider.action = #selector(changed) + + valueLabel.font = .monospacedDigitSystemFont(ofSize: NSFont.systemFontSize, weight: .regular) + valueLabel.textColor = .secondaryLabelColor + valueLabel.alignment = .right + valueLabel.stringValue = format(value) + + orientation = .horizontal + spacing = 8 + addArrangedSubview(slider) + addArrangedSubview(valueLabel) + NSLayoutConstraint.activate([ + slider.widthAnchor.constraint(equalToConstant: 220), + valueLabel.widthAnchor.constraint(equalToConstant: 48), + ]) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } + + @objc private func changed() { + valueLabel.stringValue = format(slider.doubleValue) + let isDragging = NSApp.currentEvent.map { + $0.type == .leftMouseDown || $0.type == .leftMouseDragged + } ?? false + if !(appliesOnRelease && isDragging) { + onChange?(slider.doubleValue) + } + } +} + +/// Resizes the window to the selected pane, like System Settings-era +/// preference windows do. +private final class SettingsTabViewController: NSTabViewController { + override func tabView(_ tabView: NSTabView, didSelect tabViewItem: NSTabViewItem?) { + super.tabView(tabView, didSelect: tabViewItem) + guard let window = view.window, let pane = tabViewItem?.view else { return } + let content = window.frameRect(forContentRect: NSRect(origin: .zero, size: pane.fittingSize)) + var frame = window.frame + frame.origin.y += frame.height - content.height + frame.size = content.size + window.setFrame(frame, display: true, animate: true) + } +} #endif diff --git a/Sources/OpenOSK/Snapshot.swift b/Sources/OpenOSK/Snapshot.swift new file mode 100644 index 0000000..6fef5e0 --- /dev/null +++ b/Sources/OpenOSK/Snapshot.swift @@ -0,0 +1,56 @@ +#if canImport(AppKit) +import AppKit +import OpenOSKCore + +/// `--snapshot `: renders the keyboard once per theme, and every settings +/// pane, into PNG files without showing a window. For checking visual changes and for README +/// screenshots. The System theme's desktop blur cannot be captured +/// off-screen, so its snapshot falls back to the theme's background color. +enum Snapshot { + static func write(to directory: URL) throws { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + guard let layout = LayoutStore.layout(id: Preferences.shared.layoutID) + ?? LayoutStore.bundledLayouts().first + else { return } + + for theme in Theme.all { + var flat = theme + flat.usesVibrancy = false + let view = KeyboardView( + layout: layout, + metrics: KeyboardMetrics(scale: 1), + dwell: DwellConfiguration(), + theme: flat + ) + view.setCurrentText("OpenOSK types this te") + view.suggestionBar.setSuggestions(["text", "test", "terminal", "team"]) + view.updateKeyCaps(shifted: false, alted: false, modifierStates: [.shift: .latched]) + view.keyRows.first?.first?.isScanHighlighted = true + + try write(view, to: directory.appendingPathComponent("keyboard-\(theme.id).png")) + } + + let settings = SettingsController(onClearLearnedWords: {}) + for (index, tab) in settings.makeTabs().enumerated() { + guard let pane = tab.view else { continue } + // Panes are transparent; a window normally supplies the backdrop. + let backdrop = NSBox(frame: NSRect(origin: .zero, size: pane.fittingSize)) + backdrop.boxType = .custom + backdrop.borderWidth = 0 + backdrop.fillColor = .windowBackgroundColor + backdrop.contentViewMargins = .zero + backdrop.appearance = NSAppearance(named: .aqua) + pane.frame = backdrop.bounds + backdrop.addSubview(pane) + backdrop.layoutSubtreeIfNeeded() + try write(backdrop, to: directory.appendingPathComponent("settings-\(index + 1).png")) + } + } + + private static func write(_ view: NSView, to url: URL) throws { + guard let bitmap = view.bitmapImageRepForCachingDisplay(in: view.bounds) else { return } + view.cacheDisplay(in: view.bounds, to: bitmap) + try bitmap.representation(using: .png, properties: [:])?.write(to: url) + } +} +#endif diff --git a/Sources/OpenOSK/SuggestionBarView.swift b/Sources/OpenOSK/SuggestionBarView.swift new file mode 100644 index 0000000..ca3cdce --- /dev/null +++ b/Sources/OpenOSK/SuggestionBarView.swift @@ -0,0 +1,170 @@ +#if canImport(AppKit) +import AppKit + +/// One suggestion, drawn in the keyboard's theme. Pressable by click, dwell, +/// scanning and assistive technology. +final class SuggestionChipView: NSView { + let title: String + var onSelect: ((String) -> Void)? + var isScanHighlighted = false { + didSet { needsDisplay = true } + } + + private let theme: Theme + private let font: NSFont + private let dwell: DwellConfiguration + private let dwellTimer = DwellTimer() + private var dwellProgress: CGFloat = 0 { + didSet { needsDisplay = true } + } + private var isHovered = false { + didSet { needsDisplay = true } + } + private var isPressed = false { + didSet { needsDisplay = true } + } + + init(title: String, theme: Theme, font: NSFont, dwell: DwellConfiguration) { + self.title = title + self.theme = theme + self.font = font + self.dwell = dwell + super.init(frame: .zero) + dwellTimer.onProgress = { [weak self] in self?.dwellProgress = $0 } + dwellTimer.onComplete = { [weak self] in self?.select() } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } + + override var mouseDownCanMoveWindow: Bool { false } + + /// Width the chip needs for its title at the given height. + func fittingWidth(height: CGFloat) -> CGFloat { + ceil(title.size(withAttributes: [.font: font]).width) + height * 0.9 + } + + func select() { + onSelect?(title) + } + + override func updateTrackingAreas() { + super.updateTrackingAreas() + trackingAreas.forEach(removeTrackingArea) + addTrackingArea(NSTrackingArea( + rect: bounds, + options: [.mouseEnteredAndExited, .activeAlways], + owner: self + )) + } + + override func mouseEntered(with event: NSEvent) { + isHovered = true + if dwell.enabled { + dwellTimer.start(duration: dwell.time) + } + } + + override func mouseExited(with event: NSEvent) { + isHovered = false + dwellTimer.cancel() + } + + override func mouseDown(with event: NSEvent) { + dwellTimer.cancel() + isPressed = true + } + + override func mouseUp(with event: NSEvent) { + isPressed = false + if bounds.contains(convert(event.locationInWindow, from: nil)) { + select() + } + } + + override func isAccessibilityElement() -> Bool { true } + override func accessibilityRole() -> NSAccessibility.Role? { .button } + override func accessibilityLabel() -> String? { title } + + override func accessibilityPerformPress() -> Bool { + select() + return true + } + + override func draw(_ dirtyRect: NSRect) { + let rect = bounds.insetBy(dx: 0.5, dy: 0.5) + let radius = rect.height / 2 + let pill = NSBezierPath(roundedRect: rect, xRadius: radius, yRadius: radius) + + var fill = isPressed ? theme.pressed : theme.specialFill + if isHovered, !isPressed { + fill = fill.blended(withFraction: 0.15, of: theme.pressed) ?? fill + } + if isScanHighlighted { + fill = fill.blended(withFraction: 0.3, of: theme.scanHighlight) ?? fill + } + fill.setFill() + pill.fill() + + if dwellProgress > 0 { + // Fills from the leading edge; a centered pie would hide the word. + NSGraphicsContext.saveGraphicsState() + pill.addClip() + theme.pressed.withAlphaComponent(0.4).setFill() + NSRect(x: rect.minX, y: rect.minY, width: rect.width * dwellProgress, height: rect.height) + .fill(using: .sourceOver) + NSGraphicsContext.restoreGraphicsState() + } + + (isScanHighlighted ? theme.scanHighlight : theme.border).setStroke() + pill.lineWidth = isScanHighlighted ? 3 : 1 + pill.stroke() + + let attributes: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: isPressed ? theme.pressedText : theme.text, + ] + let size = title.size(withAttributes: attributes) + title.draw( + at: NSPoint(x: bounds.midX - size.width / 2, y: bounds.midY - size.height / 2), + withAttributes: attributes) + } +} + +final class SuggestionBarView: NSView { + var onSelect: ((String) -> Void)? + var fontSize: CGFloat = 13 + var spacing: CGFloat = 6 + var dwell = DwellConfiguration() + var theme = Theme.system + + private var chips: [SuggestionChipView] = [] + + /// The chips currently on screen, for scanning. + var scanItems: [SuggestionChipView] { chips } + + override var mouseDownCanMoveWindow: Bool { true } + + func setSuggestions(_ suggestions: [String]) { + guard suggestions != chips.map(\.title) else { return } + chips.forEach { $0.removeFromSuperview() } + chips = [] + + let font = NSFont.systemFont(ofSize: fontSize, weight: .medium) + let height = bounds.height - 2 + var x: CGFloat = 0 + for suggestion in suggestions { + let chip = SuggestionChipView(title: suggestion, theme: theme, font: font, dwell: dwell) + let width = chip.fittingWidth(height: height) + // Suggestions are ordered best-first, so whatever no longer fits + // is dropped rather than clipped mid-word. + guard x + width <= bounds.width else { break } + chip.frame = NSRect(x: x, y: 1, width: width, height: height) + chip.onSelect = { [weak self] title in self?.onSelect?(title) } + addSubview(chip) + chips.append(chip) + x += width + spacing + } + } +} +#endif diff --git a/Sources/OpenOSK/Theme.swift b/Sources/OpenOSK/Theme.swift index c00fb72..c4a2599 100644 --- a/Sources/OpenOSK/Theme.swift +++ b/Sources/OpenOSK/Theme.swift @@ -14,6 +14,15 @@ struct Theme { let secondaryText: NSColor let pressed: NSColor let pressedText: NSColor + /// Text drawn directly on the panel background (current-text bar); + /// `secondaryText` sits on key caps and may vanish against the panel. + let panelText: NSColor + /// Outline of keys and of the panel itself. + let border: NSColor + /// Marks the group or key the scan is currently on. + let scanHighlight: NSColor + /// Blur the desktop behind the panel instead of filling `background`. + var usesVibrancy = false var name: String { L(nameKey) } @@ -26,7 +35,11 @@ struct Theme { text: .labelColor, secondaryText: .secondaryLabelColor, pressed: .controlAccentColor, - pressedText: .white + pressedText: .white, + panelText: .secondaryLabelColor, + border: .separatorColor, + scanHighlight: .systemOrange, + usesVibrancy: true ) static let highContrast = Theme( @@ -38,7 +51,11 @@ struct Theme { text: .black, secondaryText: NSColor(white: 0.25, alpha: 1), pressed: .systemYellow, - pressedText: .black + pressedText: .black, + panelText: .white, + border: .white, + // Orange on white keys is ~2:1; blue keeps the scan visible. + scanHighlight: NSColor(red: 0, green: 0.35, blue: 1, alpha: 1) ) static let dark = Theme( @@ -50,7 +67,10 @@ struct Theme { text: .white, secondaryText: NSColor(white: 0.65, alpha: 1), pressed: .systemBlue, - pressedText: .white + pressedText: .white, + panelText: NSColor(white: 0.7, alpha: 1), + border: NSColor(white: 0.32, alpha: 1), + scanHighlight: .systemOrange ) static let light = Theme( @@ -62,7 +82,10 @@ struct Theme { text: .black, secondaryText: NSColor(white: 0.4, alpha: 1), pressed: .systemBlue, - pressedText: .white + pressedText: .white, + panelText: NSColor(white: 0.35, alpha: 1), + border: NSColor(white: 0.78, alpha: 1), + scanHighlight: .systemOrange ) static let all: [Theme] = [.system, .highContrast, .dark, .light] diff --git a/Sources/OpenOSK/VariantPopup.swift b/Sources/OpenOSK/VariantPopup.swift index ee82d2d..78516e5 100644 --- a/Sources/OpenOSK/VariantPopup.swift +++ b/Sources/OpenOSK/VariantPopup.swift @@ -15,6 +15,7 @@ final class VariantPopup { variants: [String], relativeTo keyView: KeyView, metrics: KeyboardMetrics, + theme: Theme, onSelect: @escaping (String) -> Void ) { dismiss() @@ -29,12 +30,17 @@ final class VariantPopup { let panel = KeyboardPanel(contentRect: NSRect(origin: .zero, size: size)) let content = PopupBackgroundView(frame: NSRect(origin: .zero, size: size)) + content.theme = theme + content.cornerRadius = metrics.keyCornerRadius + pad var x = pad for variant in variants { let view = KeyView(key: Key(base: variant)) view.displayText = variant + view.spokenLabel = variant view.fontSize = metrics.keyFontSize + view.cornerRadius = metrics.keyCornerRadius + view.theme = theme view.onPress = { [weak self] _ in onSelect(variant) self?.dismiss() @@ -97,11 +103,15 @@ final class VariantPopup { } private final class PopupBackgroundView: NSView { + var theme = Theme.system + var cornerRadius: CGFloat = 10 + override func draw(_ dirtyRect: NSRect) { - let path = NSBezierPath(roundedRect: bounds, xRadius: 10, yRadius: 10) - NSColor.windowBackgroundColor.setFill() + let path = NSBezierPath( + roundedRect: bounds.insetBy(dx: 0.5, dy: 0.5), xRadius: cornerRadius, yRadius: cornerRadius) + theme.background.setFill() path.fill() - NSColor.separatorColor.setStroke() + theme.border.setStroke() path.lineWidth = 1 path.stroke() } diff --git a/Sources/OpenOSK/main.swift b/Sources/OpenOSK/main.swift index 874d2ae..b3a6680 100644 --- a/Sources/OpenOSK/main.swift +++ b/Sources/OpenOSK/main.swift @@ -3,7 +3,7 @@ import AppKit #endif import Foundation -let appVersion = "0.5.0" +let appVersion = "0.6.0" let arguments = CommandLine.arguments if arguments.contains("--version") { @@ -21,6 +21,7 @@ if arguments.contains("--help") { --version Print version and exit --help Show this help and exit --smoke-test Start the UI, verify it initializes, then exit + --snapshot DIR Render the keyboard in every theme to PNGs in DIR OpenOSK runs as a menu bar application. It needs Accessibility access (System Settings → Privacy & Security → Accessibility) to send @@ -31,6 +32,20 @@ if arguments.contains("--help") { } #if canImport(AppKit) +if let flag = arguments.firstIndex(of: "--snapshot") { + guard arguments.indices.contains(flag + 1) else { + FileHandle.standardError.write(Data("openosk: --snapshot needs a directory\n".utf8)) + exit(2) + } + do { + try Snapshot.write(to: URL(fileURLWithPath: arguments[flag + 1])) + exit(0) + } catch { + FileHandle.standardError.write(Data("openosk: \(error.localizedDescription)\n".utf8)) + exit(1) + } +} + let app = NSApplication.shared let delegate = AppDelegate() app.delegate = delegate diff --git a/Sources/OpenOSKCore/Preferences.swift b/Sources/OpenOSKCore/Preferences.swift index 83b5f84..c8763ae 100644 --- a/Sources/OpenOSKCore/Preferences.swift +++ b/Sources/OpenOSKCore/Preferences.swift @@ -10,202 +10,241 @@ public final class Preferences { public init(defaults: UserDefaults = .standard) { self.defaults = defaults defaults.register(defaults: [ - Keys.layoutID: "qwertz-de", - Keys.scale: 1.0, - Keys.opacity: 0.97, - Keys.predictionsEnabled: true, - Keys.learningEnabled: true, - Keys.terminalCompletionsEnabled: true, - Keys.texterPasteMode: true, - Keys.wordlistLanguages: ["de", "en"], - Keys.autoCapitalization: true, - Keys.autoSpacing: true, - Keys.showCurrentText: true, - Keys.dwellEnabled: false, - Keys.dwellTime: 0.9, - Keys.inactivityFadeEnabled: true, - Keys.inactivityFadeDelay: 10.0, - Keys.autoShowOnTextFocus: false, - Keys.scanningEnabled: false, - Keys.scanInterval: 1.2, - Keys.scanSwitchKey: "space", - Keys.openPanelIDs: [String](), - Keys.themeID: "system", - Keys.keyClickSound: false, - Keys.systemDictionaryEnabled: false, - Keys.scanAdvanceKey: "none", + Key.layoutID.rawValue: "qwertz-de", + Key.scale.rawValue: 1.0, + Key.opacity.rawValue: 0.97, + Key.predictionsEnabled.rawValue: true, + Key.learningEnabled.rawValue: true, + Key.terminalCompletionsEnabled.rawValue: true, + Key.texterPasteMode.rawValue: true, + Key.wordlistLanguages.rawValue: ["de", "en"], + Key.autoCapitalization.rawValue: true, + Key.autoSpacing.rawValue: true, + Key.showCurrentText.rawValue: true, + Key.dwellEnabled.rawValue: false, + Key.dwellTime.rawValue: 0.9, + Key.inactivityFadeEnabled.rawValue: true, + Key.inactivityFadeDelay.rawValue: 10.0, + Key.autoShowOnTextFocus.rawValue: false, + Key.scanningEnabled.rawValue: false, + Key.scanInterval.rawValue: 1.2, + Key.scanSwitchKey.rawValue: "space", + Key.openPanelIDs.rawValue: [String](), + Key.themeID.rawValue: "system", + Key.keyClickSound.rawValue: false, + Key.systemDictionaryEnabled.rawValue: false, + Key.scanAdvanceKey.rawValue: "none", ]) } - private enum Keys { - static let layoutID = "layoutID" - static let scale = "keyboardScale" - static let opacity = "keyboardOpacity" - static let predictionsEnabled = "predictionsEnabled" - static let learningEnabled = "learningEnabled" - static let terminalCompletionsEnabled = "terminalCompletionsEnabled" - static let texterPasteMode = "texterPasteMode" - static let wordlistLanguages = "wordlistLanguages" - static let autoCapitalization = "autoCapitalization" - static let autoSpacing = "autoSpacing" - static let showCurrentText = "showCurrentText" - static let dwellEnabled = "dwellEnabled" - static let dwellTime = "dwellTime" - static let inactivityFadeEnabled = "inactivityFadeEnabled" - static let inactivityFadeDelay = "inactivityFadeDelay" - static let autoShowOnTextFocus = "autoShowOnTextFocus" - static let scanningEnabled = "scanningEnabled" - static let scanInterval = "scanInterval" - static let scanSwitchKey = "scanSwitchKey" - static let openPanelIDs = "openPanelIDs" - static let themeID = "themeID" - static let keyClickSound = "keyClickSound" - static let systemDictionaryEnabled = "systemDictionaryEnabled" - static let scanAdvanceKey = "scanAdvanceKey" + /// Every stored preference; the raw value is the `UserDefaults` key. + public enum Key: String, CaseIterable { + case layoutID + case scale = "keyboardScale" + case opacity = "keyboardOpacity" + case predictionsEnabled + case learningEnabled + case terminalCompletionsEnabled + case texterPasteMode + case wordlistLanguages + case autoCapitalization + case autoSpacing + case showCurrentText + case dwellEnabled + case dwellTime + case inactivityFadeEnabled + case inactivityFadeDelay + case autoShowOnTextFocus + case scanningEnabled + case scanInterval + case scanSwitchKey + case openPanelIDs + case themeID + case keyClickSound + case systemDictionaryEnabled + case scanAdvanceKey + case panelOrigins } public var layoutID: String { - get { defaults.string(forKey: Keys.layoutID) ?? "qwertz-de" } - set { set(newValue, forKey: Keys.layoutID) } + get { defaults.string(forKey: Key.layoutID.rawValue) ?? "qwertz-de" } + set { set(newValue, forKey: .layoutID) } } /// Key size multiplier (1.0 = standard). public var scale: Double { - get { defaults.double(forKey: Keys.scale) } - set { set(newValue, forKey: Keys.scale) } + get { defaults.double(forKey: Key.scale.rawValue) } + set { set(newValue, forKey: .scale) } } public var opacity: Double { - get { defaults.double(forKey: Keys.opacity) } - set { set(newValue, forKey: Keys.opacity) } + get { defaults.double(forKey: Key.opacity.rawValue) } + set { set(newValue, forKey: .opacity) } } public var predictionsEnabled: Bool { - get { defaults.bool(forKey: Keys.predictionsEnabled) } - set { set(newValue, forKey: Keys.predictionsEnabled) } + get { defaults.bool(forKey: Key.predictionsEnabled.rawValue) } + set { set(newValue, forKey: .predictionsEnabled) } } public var learningEnabled: Bool { - get { defaults.bool(forKey: Keys.learningEnabled) } - set { set(newValue, forKey: Keys.learningEnabled) } + get { defaults.bool(forKey: Key.learningEnabled.rawValue) } + set { set(newValue, forKey: .learningEnabled) } } public var terminalCompletionsEnabled: Bool { - get { defaults.bool(forKey: Keys.terminalCompletionsEnabled) } - set { set(newValue, forKey: Keys.terminalCompletionsEnabled) } + get { defaults.bool(forKey: Key.terminalCompletionsEnabled.rawValue) } + set { set(newValue, forKey: .terminalCompletionsEnabled) } } /// Whether the Texter inserts via clipboard + ⌘V instead of typing. public var texterPasteMode: Bool { - get { defaults.bool(forKey: Keys.texterPasteMode) } - set { set(newValue, forKey: Keys.texterPasteMode) } + get { defaults.bool(forKey: Key.texterPasteMode.rawValue) } + set { set(newValue, forKey: .texterPasteMode) } } public var wordlistLanguages: [String] { - get { defaults.stringArray(forKey: Keys.wordlistLanguages) ?? ["de", "en"] } - set { set(newValue, forKey: Keys.wordlistLanguages) } + get { defaults.stringArray(forKey: Key.wordlistLanguages.rawValue) ?? ["de", "en"] } + set { set(newValue, forKey: .wordlistLanguages) } } /// Capitalize the first letter after sentence-ending punctuation. public var autoCapitalization: Bool { - get { defaults.bool(forKey: Keys.autoCapitalization) } - set { set(newValue, forKey: Keys.autoCapitalization) } + get { defaults.bool(forKey: Key.autoCapitalization.rawValue) } + set { set(newValue, forKey: .autoCapitalization) } } /// Double-space inserts ". ". public var autoSpacing: Bool { - get { defaults.bool(forKey: Keys.autoSpacing) } - set { set(newValue, forKey: Keys.autoSpacing) } + get { defaults.bool(forKey: Key.autoSpacing.rawValue) } + set { set(newValue, forKey: .autoSpacing) } } /// Show the current-text bar on the keyboard. public var showCurrentText: Bool { - get { defaults.bool(forKey: Keys.showCurrentText) } - set { set(newValue, forKey: Keys.showCurrentText) } + get { defaults.bool(forKey: Key.showCurrentText.rawValue) } + set { set(newValue, forKey: .showCurrentText) } } /// Hovering a key presses it after `dwellTime`. public var dwellEnabled: Bool { - get { defaults.bool(forKey: Keys.dwellEnabled) } - set { set(newValue, forKey: Keys.dwellEnabled) } + get { defaults.bool(forKey: Key.dwellEnabled.rawValue) } + set { set(newValue, forKey: .dwellEnabled) } } /// Dwell duration in seconds. public var dwellTime: Double { - get { defaults.double(forKey: Keys.dwellTime) } - set { set(newValue, forKey: Keys.dwellTime) } + get { defaults.double(forKey: Key.dwellTime.rawValue) } + set { set(newValue, forKey: .dwellTime) } } /// Fade the keyboard after a period without interaction. public var inactivityFadeEnabled: Bool { - get { defaults.bool(forKey: Keys.inactivityFadeEnabled) } - set { set(newValue, forKey: Keys.inactivityFadeEnabled) } + get { defaults.bool(forKey: Key.inactivityFadeEnabled.rawValue) } + set { set(newValue, forKey: .inactivityFadeEnabled) } } /// Seconds of inactivity before the keyboard fades. public var inactivityFadeDelay: Double { - get { defaults.double(forKey: Keys.inactivityFadeDelay) } - set { set(newValue, forKey: Keys.inactivityFadeDelay) } + get { defaults.double(forKey: Key.inactivityFadeDelay.rawValue) } + set { set(newValue, forKey: .inactivityFadeDelay) } } /// Show the keyboard automatically when a text field gains focus. public var autoShowOnTextFocus: Bool { - get { defaults.bool(forKey: Keys.autoShowOnTextFocus) } - set { set(newValue, forKey: Keys.autoShowOnTextFocus) } + get { defaults.bool(forKey: Key.autoShowOnTextFocus.rawValue) } + set { set(newValue, forKey: .autoShowOnTextFocus) } } /// Scanning (switch access) input mode. public var scanningEnabled: Bool { - get { defaults.bool(forKey: Keys.scanningEnabled) } - set { set(newValue, forKey: Keys.scanningEnabled) } + get { defaults.bool(forKey: Key.scanningEnabled.rawValue) } + set { set(newValue, forKey: .scanningEnabled) } } /// Seconds between scan highlight steps. public var scanInterval: Double { - get { defaults.double(forKey: Keys.scanInterval) } - set { set(newValue, forKey: Keys.scanInterval) } + get { defaults.double(forKey: Key.scanInterval.rawValue) } + set { set(newValue, forKey: .scanInterval) } } /// Hardware key acting as the scan switch: "space", "return", "f13"–"f15". public var scanSwitchKey: String { - get { defaults.string(forKey: Keys.scanSwitchKey) ?? "space" } - set { set(newValue, forKey: Keys.scanSwitchKey) } + get { defaults.string(forKey: Key.scanSwitchKey.rawValue) ?? "space" } + set { set(newValue, forKey: .scanSwitchKey) } } /// Custom panels that are currently open (restored on launch). public var openPanelIDs: [String] { - get { defaults.stringArray(forKey: Keys.openPanelIDs) ?? [] } - set { set(newValue, forKey: Keys.openPanelIDs) } + get { defaults.stringArray(forKey: Key.openPanelIDs.rawValue) ?? [] } + set { set(newValue, forKey: .openPanelIDs) } } /// Visual theme id ("system", "high-contrast", "dark", "light"). public var themeID: String { - get { defaults.string(forKey: Keys.themeID) ?? "system" } - set { set(newValue, forKey: Keys.themeID) } + get { defaults.string(forKey: Key.themeID.rawValue) ?? "system" } + set { set(newValue, forKey: .themeID) } } /// Play a click sound on key press. public var keyClickSound: Bool { - get { defaults.bool(forKey: Keys.keyClickSound) } - set { set(newValue, forKey: Keys.keyClickSound) } + get { defaults.bool(forKey: Key.keyClickSound.rawValue) } + set { set(newValue, forKey: .keyClickSound) } } /// Merge /usr/share/dict/words as low-priority prediction fallback. public var systemDictionaryEnabled: Bool { - get { defaults.bool(forKey: Keys.systemDictionaryEnabled) } - set { set(newValue, forKey: Keys.systemDictionaryEnabled) } + get { defaults.bool(forKey: Key.systemDictionaryEnabled.rawValue) } + set { set(newValue, forKey: .systemDictionaryEnabled) } } /// Optional second switch that advances the scan manually ("none" /// disables it; otherwise same key names as `scanSwitchKey`). With an /// advance key set, the automatic scan timer is disabled. public var scanAdvanceKey: String { - get { defaults.string(forKey: Keys.scanAdvanceKey) ?? "none" } - set { set(newValue, forKey: Keys.scanAdvanceKey) } + get { defaults.string(forKey: Key.scanAdvanceKey.rawValue) ?? "none" } + set { set(newValue, forKey: .scanAdvanceKey) } } - private func set(_ value: Any, forKey key: String) { - defaults.set(value, forKey: key) - NotificationCenter.default.post(name: Self.didChangeNotification, object: self) + // MARK: - Panel positions + + /// Last position of a floating panel, keyed by panel id ("keyboard" for + /// the main keyboard). Nil until the user has moved that panel. + public func panelOrigin(forID id: String) -> (x: Double, y: Double)? { + // Up to v0.5 only the keyboard's position was stored, under its own key. + let legacy = id == "keyboard" ? defaults.array(forKey: "panelOrigin") as? [Double] : nil + guard let stored = panelOrigins[id] ?? legacy, stored.count == 2 else { return nil } + return (stored[0], stored[1]) + } + + public func setPanelOrigin(x: Double, y: Double, forID id: String) { + var origins = panelOrigins + origins[id] = [x, y] + set(origins, forKey: .panelOrigins) + } + + private var panelOrigins: [String: [Double]] { + defaults.dictionary(forKey: Key.panelOrigins.rawValue) as? [String: [Double]] ?? [:] + } + + // MARK: - Change notification + + /// The preference a `didChangeNotification` was posted for. + public static func changedKey(in notification: Notification) -> Key? { + notification.userInfo?[changedKeyUserInfoKey] as? Key + } + + private static let changedKeyUserInfoKey = "key" + + private func set(_ value: Value, forKey key: Key) { + // Sliders report every intermediate position and menus re-select the + // current item; observers rebuild UI, so only real changes are posted. + if defaults.object(forKey: key.rawValue) as? Value == value { return } + defaults.set(value, forKey: key.rawValue) + NotificationCenter.default.post( + name: Self.didChangeNotification, + object: self, + userInfo: [Self.changedKeyUserInfoKey: key] + ) } } diff --git a/Sources/OpenOSKCore/Resources/Panels/system.json b/Sources/OpenOSKCore/Resources/Panels/system.json index c77162e..d9e2e7d 100644 --- a/Sources/OpenOSKCore/Resources/Panels/system.json +++ b/Sources/OpenOSKCore/Resources/Panels/system.json @@ -13,8 +13,8 @@ { "media": "previous" }, { "media": "playPause" }, { "media": "next" }, - { "label": "🔒", "macro": { "steps": [ { "shortcut": "ctrl+cmd+q" } ] } }, - { "label": "📸", "macro": { "steps": [ { "shortcut": "cmd+shift+4" } ] } } + { "label": "Lock Screen", "image": "lock.fill", "macro": { "steps": [ { "shortcut": "ctrl+cmd+q" } ] } }, + { "label": "Screenshot", "image": "camera.viewfinder", "macro": { "steps": [ { "shortcut": "cmd+shift+4" } ] } } ] ] } diff --git a/Sources/OpenOSKCore/WordPredictor.swift b/Sources/OpenOSKCore/WordPredictor.swift index 52f23f7..c830477 100644 --- a/Sources/OpenOSKCore/WordPredictor.swift +++ b/Sources/OpenOSKCore/WordPredictor.swift @@ -7,12 +7,17 @@ public final class WordPredictor { var children: [Character: Node] = [:] /// Position in the base word list; lower = more frequent. var rank: Int? - /// How often the user typed this word. - var learnedCount = 0 + /// Lowest rank anywhere below (and including) this node. Lets the + /// search walk straight to the most frequent words instead of + /// exploring the subtree, which for short prefixes is most of the trie. + var bestRank = Int.max } private let root = Node() private var nextRank = 0 + /// How often the user typed each word. Kept beside the trie: the set is + /// small, and it has to be cleared and ranked independently of list order. + private var learnedCounts: [String: Int] = [:] /// Large sorted word list used as a low-priority fallback (kept out of /// the trie to save memory; e.g. /usr/share/dict/words). private var fallbackWords: [String] = [] @@ -25,12 +30,29 @@ public final class WordPredictor { for word in words { let normalized = word.trimmingCharacters(in: .whitespaces) guard !normalized.isEmpty, !normalized.hasPrefix("#") else { continue } - let node = node(for: normalized.lowercased(), createIfMissing: true)! - if node.rank == nil { - node.rank = nextRank - nextRank += 1 + insert(normalized.lowercased()) + } + } + + private func insert(_ word: String) { + var path = [root] + for character in word { + let parent = path[path.count - 1] + if let child = parent.children[character] { + path.append(child) + } else { + let child = Node() + parent.children[character] = child + path.append(child) } } + guard let node = path.last, node.rank == nil else { return } + node.rank = nextRank + // Ranks only grow, so a node's best rank is settled by its first word. + for ancestor in path where ancestor.bestRank == Int.max { + ancestor.bestRank = nextRank + } + nextRank += 1 } /// Loads bundled word lists, e.g. `["de", "en"]`. @@ -69,8 +91,12 @@ public final class WordPredictor { public func learn(_ word: String, count: Int = 1) { let normalized = word.lowercased() guard normalized.count >= 2 else { return } - let node = node(for: normalized, createIfMissing: true)! - node.learnedCount += count + learnedCounts[normalized, default: 0] += count + } + + /// Forgets everything learned; the base word lists stay loaded. + public func clearLearned() { + learnedCounts = [:] } // MARK: - Prediction @@ -80,15 +106,24 @@ public final class WordPredictor { public func suggestions(forPrefix prefix: String, limit: Int = 5) -> [String] { guard prefix.count >= 1 else { return [] } let lowered = prefix.lowercased() - guard let start = node(for: lowered, createIfMissing: false) else { return [] } - var results: [(word: String, learned: Int, rank: Int)] = [] - collect(from: start, prefix: lowered, into: &results, budget: 500) + let learnedMatches = learnedCounts.filter { $0.key.hasPrefix(lowered) } + var results: [(word: String, learned: Int, rank: Int)] = learnedMatches.map { + ($0.key, $0.value, node(for: $0.key)?.rank ?? Int.max) + } + if let start = node(for: lowered) { + // Learned words may displace list words, so fetch enough of both. + let ranked = mostFrequent(from: start, prefix: lowered, count: limit + learnedMatches.count) + results += ranked + .filter { learnedMatches[$0.word] == nil } + .map { ($0.word, 0, $0.rank) } + } results.sort { if $0.learned != $1.learned { return $0.learned > $1.learned } if $0.rank != $1.rank { return $0.rank < $1.rank } - return $0.word.count < $1.word.count + if $0.word.count != $1.word.count { return $0.word.count < $1.word.count } + return $0.word < $1.word } var suggestions = results @@ -132,23 +167,42 @@ public final class WordPredictor { return matches } - private func collect( - from node: Node, - prefix: String, - into results: inout [(word: String, learned: Int, rank: Int)], - budget: Int - ) { - var remaining = budget - var stack: [(Node, String)] = [(node, prefix)] - while let (current, word) = stack.popLast(), remaining > 0 { - remaining -= 1 - if current.rank != nil || current.learnedCount > 0 { - results.append((word, current.learnedCount, current.rank ?? Int.max)) + /// Best-first walk ordered by `bestRank`: yields the `count` most + /// frequent list words below `node`, most frequent first. + private func mostFrequent( + from node: Node, prefix: String, count: Int + ) -> [(word: String, rank: Int)] { + enum Entry { + case subtree(Node, String) + case word(String, Int) + } + func priority(_ entry: Entry) -> Int { + switch entry { + case .subtree(let node, _): return node.bestRank + case .word(_, let rank): return rank } - for (character, child) in current.children { - stack.append((child, word + String(character))) + } + + var found: [(word: String, rank: Int)] = [] + var frontier: [Entry] = [.subtree(node, prefix)] + // The frontier holds a handful of entries per visited level, so a + // linear minimum scan is cheaper than maintaining a heap. + while found.count < count, + let next = frontier.indices.min(by: { priority(frontier[$0]) < priority(frontier[$1]) }), + priority(frontier[next]) != Int.max { + switch frontier.remove(at: next) { + case .word(let word, let rank): + found.append((word, rank)) + case .subtree(let node, let word): + if let rank = node.rank { + frontier.append(.word(word, rank)) + } + for (character, child) in node.children where child.bestRank != Int.max { + frontier.append(.subtree(child, word + String(character))) + } } } + return found } private func applyCapitalization(of prefix: String, to word: String) -> String { @@ -162,18 +216,11 @@ public final class WordPredictor { return word } - private func node(for word: String, createIfMissing: Bool) -> Node? { + private func node(for word: String) -> Node? { var current = root for character in word { - if let child = current.children[character] { - current = child - } else if createIfMissing { - let child = Node() - current.children[character] = child - current = child - } else { - return nil - } + guard let child = current.children[character] else { return nil } + current = child } return current } diff --git a/TODO.md b/TODO.md index 7692e00..33714c9 100644 --- a/TODO.md +++ b/TODO.md @@ -64,9 +64,29 @@ run on Linux; CI runs swift test on both OSes - [x] CONTRIBUTING.md -## v0.6 candidates - -- [ ] Scanning across open panels +## Done (v0.6) + +- [x] Scanning across open panels +- [x] Keys and suggestions exposed to VoiceOver / Voice Control (role, spoken + name, press action) +- [x] Themed suggestion chips with dwell progress; variant popup follows the theme +- [x] System theme blurs the desktop behind the panel; key corner radii scale + with key size; media keys use SF Symbols instead of emoji +- [x] High Contrast: readable current-text bar, solid latched-modifier color +- [x] Settings window with toolbar tabs, slider value labels, dependent + controls disabled with their switch, confirmation before clearing + learned words +- [x] Custom panels remember their position; rebuilding no longer resets it +- [x] Preference changes carry the changed key: no full keyboard rebuild per + slider tick +- [x] Word prediction is deterministic for short prefixes (best-first search + by rank); "Clear learned words" also clears the in-memory counts +- [x] `--snapshot DIR` renders themes and settings panes to PNGs + +## v0.7 candidates + +- [ ] Variant popup for dwell and scanning users (long-press needs a held + mouse button today) - [ ] Larger frequency-ordered word lists (de/en) - [ ] Macro editor for multi-step macros (editor currently maps single-step actions; complex macros remain JSON) diff --git a/Tests/OpenOSKCoreTests/PreferencesTests.swift b/Tests/OpenOSKCoreTests/PreferencesTests.swift new file mode 100644 index 0000000..6bf5a14 --- /dev/null +++ b/Tests/OpenOSKCoreTests/PreferencesTests.swift @@ -0,0 +1,69 @@ +import Foundation +import Testing + +@testable import OpenOSKCore + +@Suite struct PreferencesTests { + private func makePreferences() -> Preferences { + let suite = "OpenOSKTests-\(UUID().uuidString)" + return Preferences(defaults: UserDefaults(suiteName: suite)!) + } + + /// Collects the keys `preferences` announces. The sender is matched here + /// rather than through the observer's `object:` filter, which on Linux + /// never matches a sender that is not an `NSObject`; other suites post + /// the same notification from their own instances in parallel. + private final class ChangeLog: @unchecked Sendable { + private(set) var keys: [Preferences.Key] = [] + private var observer: NSObjectProtocol? + + init(of preferences: Preferences) { + observer = NotificationCenter.default.addObserver( + forName: Preferences.didChangeNotification, object: nil, queue: nil + ) { [weak self, weak preferences] notification in + guard (notification.object as AnyObject?) === preferences, + let key = Preferences.changedKey(in: notification) + else { return } + self?.keys.append(key) + } + } + + deinit { + if let observer { NotificationCenter.default.removeObserver(observer) } + } + } + + @Test func changeNotificationNamesTheChangedKey() { + let preferences = makePreferences() + let log = ChangeLog(of: preferences) + + preferences.opacity = 0.5 + preferences.themeID = "dark" + + #expect(log.keys == [.opacity, .themeID]) + } + + @Test func unchangedValuesDoNotNotify() { + let preferences = makePreferences() + preferences.scale = 1.2 + let log = ChangeLog(of: preferences) + + preferences.scale = 1.2 + #expect(log.keys.isEmpty) + + // Guards against passing only because nothing is ever logged. + preferences.scale = 1.3 + #expect(log.keys == [.scale]) + } + + @Test func panelOriginRoundTrips() { + let preferences = makePreferences() + #expect(preferences.panelOrigin(forID: "keyboard") == nil) + + preferences.setPanelOrigin(x: 12.5, y: 80, forID: "keyboard") + + let origin = preferences.panelOrigin(forID: "keyboard") + #expect(origin?.x == 12.5) + #expect(origin?.y == 80) + } +} diff --git a/Tests/OpenOSKCoreTests/WordPredictorTests.swift b/Tests/OpenOSKCoreTests/WordPredictorTests.swift index 40461c0..b6ed166 100644 --- a/Tests/OpenOSKCoreTests/WordPredictorTests.swift +++ b/Tests/OpenOSKCoreTests/WordPredictorTests.swift @@ -48,4 +48,27 @@ import Testing #expect(!predictor.suggestions(forPrefix: "th").isEmpty) #expect(!predictor.suggestions(forPrefix: "un").isEmpty) } + + /// A one-letter prefix spans far more trie nodes than any search budget; + /// the most frequent words must still win, on every run. + @Test func frequentWordsWinInLargeSubtrees() { + let predictor = WordPredictor() + var words = ["sehr", "sie", "sind"] + for index in 0..<3000 { + words.append("s" + String(index, radix: 36) + "zzzzzzzz") + } + predictor.load(words: words) + + #expect(predictor.suggestions(forPrefix: "s", limit: 3) == ["sehr", "sie", "sind"]) + } + + @Test func clearLearnedForgetsLearnedWords() { + let predictor = WordPredictor() + predictor.load(words: ["hello", "help"]) + predictor.learn("helios", count: 4) + predictor.learn("help", count: 2) + predictor.clearLearned() + + #expect(predictor.suggestions(forPrefix: "hel") == ["hello", "help"]) + } } diff --git a/scripts/bundle.sh b/scripts/bundle.sh index b2289c0..569d144 100755 --- a/scripts/bundle.sh +++ b/scripts/bundle.sh @@ -9,15 +9,18 @@ cd "$(dirname "$0")/.." VERSION="$(grep -m1 'let appVersion' Sources/OpenOSK/main.swift | sed 's/.*"\(.*\)"/\1/')" APP="build/OpenOSK.app" -swift build -c release +# Same variable as the Makefile: build products may live off the checkout. +SCRATCH="${SCRATCH_PATH:-.build}" +swift build -c release --scratch-path "$SCRATCH" +BIN="$(swift build -c release --scratch-path "$SCRATCH" --show-bin-path)" rm -rf "$APP" mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" -cp .build/release/openosk "$APP/Contents/MacOS/OpenOSK" -cp -R .build/release/OpenOSK_OpenOSKCore.bundle "$APP/Contents/Resources/" -if [ -d .build/release/OpenOSK_OpenOSK.bundle ]; then - cp -R .build/release/OpenOSK_OpenOSK.bundle "$APP/Contents/Resources/" +cp "$BIN/openosk" "$APP/Contents/MacOS/OpenOSK" +cp -R "$BIN/OpenOSK_OpenOSKCore.bundle" "$APP/Contents/Resources/" +if [ -d "$BIN/OpenOSK_OpenOSK.bundle" ]; then + cp -R "$BIN/OpenOSK_OpenOSK.bundle" "$APP/Contents/Resources/" fi if [ -f Assets/OpenOSK.icns ]; then cp Assets/OpenOSK.icns "$APP/Contents/Resources/"