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/"