From 189191fea29ae368cca0dcbe57f3aaf3af7063e9 Mon Sep 17 00:00:00 2001 From: Christian Neff Date: Thu, 17 Sep 2026 23:01:19 +0200 Subject: [PATCH 1/8] fix(core): replace budgeted trie walk with best-first search by rank The prediction search stopped after 500 nodes of an unordered dictionary walk, so one- and two-letter prefixes missed frequent words and returned different results between runs. Nodes now carry the best rank of their subtree and the search follows it. Learned counts move out of the trie into a dictionary and get a clearLearned(), which "Clear learned words" needs to take effect before the next launch. Co-Authored-By: Claude Fable 5.1 --- Sources/OpenOSKCore/WordPredictor.swift | 119 ++++++++++++------ .../OpenOSKCoreTests/WordPredictorTests.swift | 23 ++++ 2 files changed, 106 insertions(+), 36 deletions(-) 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/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"]) + } } From 65db07e46d3fc2181d62fb6e3dcdad64c2756429 Mon Sep 17 00:00:00 2001 From: Christian Neff Date: Thu, 17 Sep 2026 23:01:25 +0200 Subject: [PATCH 2/8] feat(core): name the changed key in preference notifications Preference keys become the public Preferences.Key enum; the change notification carries the key and is skipped when the value did not change, so observers can react to one setting instead of rebuilding for all of them. Adds per-panel origin storage, reading the v0.5 keyboard position as a fallback. Co-Authored-By: Claude Fable 5.1 --- Sources/OpenOSKCore/Preferences.swift | 239 ++++++++++-------- Tests/OpenOSKCoreTests/PreferencesTests.swift | 54 ++++ 2 files changed, 193 insertions(+), 100 deletions(-) create mode 100644 Tests/OpenOSKCoreTests/PreferencesTests.swift 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/Tests/OpenOSKCoreTests/PreferencesTests.swift b/Tests/OpenOSKCoreTests/PreferencesTests.swift new file mode 100644 index 0000000..dc72919 --- /dev/null +++ b/Tests/OpenOSKCoreTests/PreferencesTests.swift @@ -0,0 +1,54 @@ +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)!) + } + + @Test func changeNotificationNamesTheChangedKey() { + let preferences = makePreferences() + var received: [Preferences.Key] = [] + let observer = NotificationCenter.default.addObserver( + forName: Preferences.didChangeNotification, object: preferences, queue: nil + ) { notification in + if let key = Preferences.changedKey(in: notification) { + received.append(key) + } + } + defer { NotificationCenter.default.removeObserver(observer) } + + preferences.opacity = 0.5 + preferences.themeID = "dark" + + #expect(received == [.opacity, .themeID]) + } + + @Test func unchangedValuesDoNotNotify() { + let preferences = makePreferences() + preferences.scale = 1.2 + var count = 0 + let observer = NotificationCenter.default.addObserver( + forName: Preferences.didChangeNotification, object: preferences, queue: nil + ) { _ in count += 1 } + defer { NotificationCenter.default.removeObserver(observer) } + + preferences.scale = 1.2 + + #expect(count == 0) + } + + @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) + } +} From 8caa5aab2571b31082cded92393be5158527dbef Mon Sep 17 00:00:00 2001 From: Christian Neff Date: Thu, 17 Sep 2026 23:01:25 +0200 Subject: [PATCH 3/8] chore: ignore .build as a file as well as a directory A .build symlinked off a File Provider mount is a file to git; the trailing slash only matched the directory. Co-Authored-By: Claude Fable 5.1 --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 61fd30a..36df223 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -.build/ +.build build/ .DS_Store *.xcodeproj From bed863d0ae73221e775d15f0064420fd4a1236c2 Mon Sep 17 00:00:00 2001 From: Christian Neff Date: Thu, 17 Sep 2026 23:01:53 +0200 Subject: [PATCH 4/8] feat: themed suggestion chips, accessible keys, panel scanning, tabbed settings Keyboard - Split KeyboardView.swift into KeyView, SuggestionBarView, DwellTimer and KeyPresentation. - Replace the NSButton suggestions with theme-drawn chips that show dwell progress and drop suggestions that do not fit. - Expose keys and chips as accessibility buttons with spoken names and a press action (VoiceOver, Voice Control). - Themes gain border, panelText, scanHighlight and usesVibrancy; the System theme blurs the desktop behind the panel. The High Contrast current-text bar used a key-cap color that was unreadable on black. - Key and panel corner radii scale with the key size; media keys and the System panel use SF Symbols instead of emoji; the variant popup follows the theme. Behaviour - KeyboardController reacts to the changed preference key instead of rebuilding the keyboard and all panels on every change. - ScanController scans the groups of a provider: the visible keyboard plus open panels. - Panels remember their position and keep it across rebuilds. - "Clear learned words" also clears the in-memory counts and asks for confirmation. Settings - Replace the single 25-row grid with toolbar tabs (General, Typing, Access, Panels), value labels on sliders, and dependent controls that disable with their switch. Key size applies on release. - SettingsController takes a closure instead of the KeyboardController. Tooling - Add --snapshot DIR, which renders every theme and settings pane to PNGs off-screen. Bump the version to 0.6.0. Co-Authored-By: Claude Fable 5.1 --- Sources/OpenOSK/AppDelegate.swift | 3 +- Sources/OpenOSK/DwellTimer.swift | 44 ++ Sources/OpenOSK/KeyPresentation.swift | 66 +++ Sources/OpenOSK/KeyView.swift | 346 +++++++++++++ Sources/OpenOSK/KeyboardController.swift | 88 ++-- Sources/OpenOSK/KeyboardPanel.swift | 32 ++ Sources/OpenOSK/KeyboardView.swift | 486 +++-------------- Sources/OpenOSK/PanelsController.swift | 31 +- .../Resources/de.lproj/Localizable.strings | 39 ++ .../Resources/en.lproj/Localizable.strings | 39 ++ Sources/OpenOSK/ScanController.swift | 17 +- Sources/OpenOSK/SettingsController.swift | 490 ++++++++++-------- Sources/OpenOSK/Snapshot.swift | 56 ++ Sources/OpenOSK/SuggestionBarView.swift | 170 ++++++ Sources/OpenOSK/Theme.swift | 31 +- Sources/OpenOSK/VariantPopup.swift | 16 +- Sources/OpenOSK/main.swift | 17 +- .../OpenOSKCore/Resources/Panels/system.json | 4 +- 18 files changed, 1276 insertions(+), 699 deletions(-) create mode 100644 Sources/OpenOSK/DwellTimer.swift create mode 100644 Sources/OpenOSK/KeyPresentation.swift create mode 100644 Sources/OpenOSK/KeyView.swift create mode 100644 Sources/OpenOSK/Snapshot.swift create mode 100644 Sources/OpenOSK/SuggestionBarView.swift 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..17577dd 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,36 @@ 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)) + 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 +490,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 +602,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 +625,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..59864c5 100644 --- a/Sources/OpenOSK/Resources/de.lproj/Localizable.strings +++ b/Sources/OpenOSK/Resources/de.lproj/Localizable.strings @@ -83,3 +83,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..664d495 100644 --- a/Sources/OpenOSK/Resources/en.lproj/Localizable.strings +++ b/Sources/OpenOSK/Resources/en.lproj/Localizable.strings @@ -83,3 +83,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..52eba08 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,242 @@ final class SettingsController: NSObject { window?.makeKeyAndOrderFront(nil) } - 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") - window.isReleasedWhenClosed = false - window.center() + // MARK: - Window - layoutPopup = NSPopUpButton() - layoutPopup.target = self - layoutPopup.action = #selector(layoutSelected) + /// 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 + } - 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) + private func buildWindow() { + let tabs = SettingsTabViewController() + tabs.tabStyle = .toolbar + makeTabs().forEach(tabs.addTabViewItem) - let clearButton = NSButton( - title: L("Clear Learned Words"), - target: self, - action: #selector(clearLearned) - ) + let window = NSWindow(contentViewController: tabs) + window.styleMask = [.titled, .closable] + window.isReleasedWhenClosed = false + window.center() + self.window = window + } - 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 +289,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/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" } ] } } ] ] } From bbad256211d0ad0023788a5b87ccdcd0b066cbbc Mon Sep 17 00:00:00 2001 From: Christian Neff Date: Thu, 17 Sep 2026 23:01:53 +0200 Subject: [PATCH 5/8] docs: document v0.6 in README, AGENTS.md, CLAUDE.md and TODO Co-Authored-By: Claude Fable 5.1 --- AGENTS.md | 42 ++++++++++++++++++++++++++++++++++-------- CLAUDE.md | 2 ++ README.md | 11 ++++++++--- TODO.md | 26 +++++++++++++++++++++++--- 4 files changed, 67 insertions(+), 14 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a0e3fcf..fac59db 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,11 @@ make bundle # release build + assemble build/OpenOSK.app via scripts/bundle.s make clean ``` +- `.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 +53,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 +82,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 +101,14 @@ 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"). Keep `.build` off the mount, e.g. as a symlink to a local + directory; `.gitignore` lists `.build` without a trailing slash for that. +- `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/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/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) From b6d78768bb45fbc49cc45c5c7af47b99b6ad74a0 Mon Sep 17 00:00:00 2001 From: Christian Neff Date: Thu, 17 Sep 2026 23:06:38 +0200 Subject: [PATCH 6/8] test(core): match the notification sender by identity instead of object: NotificationCenter's object: filter on Linux never matches a sender that is not an NSObject, so the keyed-change test saw no notifications there and the no-op test passed without observing anything. The tests now observe unfiltered and compare the sender themselves; the no-op test also asserts that a real change is logged. Co-Authored-By: Claude Fable 5.1 --- Tests/OpenOSKCoreTests/PreferencesTests.swift | 47 ++++++++++++------- 1 file changed, 31 insertions(+), 16 deletions(-) diff --git a/Tests/OpenOSKCoreTests/PreferencesTests.swift b/Tests/OpenOSKCoreTests/PreferencesTests.swift index dc72919..6bf5a14 100644 --- a/Tests/OpenOSKCoreTests/PreferencesTests.swift +++ b/Tests/OpenOSKCoreTests/PreferencesTests.swift @@ -9,36 +9,51 @@ import Testing return Preferences(defaults: UserDefaults(suiteName: suite)!) } - @Test func changeNotificationNamesTheChangedKey() { - let preferences = makePreferences() - var received: [Preferences.Key] = [] - let observer = NotificationCenter.default.addObserver( - forName: Preferences.didChangeNotification, object: preferences, queue: nil - ) { notification in - if let key = Preferences.changedKey(in: notification) { - received.append(key) + /// 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) } } - defer { NotificationCenter.default.removeObserver(observer) } + + 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(received == [.opacity, .themeID]) + #expect(log.keys == [.opacity, .themeID]) } @Test func unchangedValuesDoNotNotify() { let preferences = makePreferences() preferences.scale = 1.2 - var count = 0 - let observer = NotificationCenter.default.addObserver( - forName: Preferences.didChangeNotification, object: preferences, queue: nil - ) { _ in count += 1 } - defer { NotificationCenter.default.removeObserver(observer) } + let log = ChangeLog(of: preferences) preferences.scale = 1.2 + #expect(log.keys.isEmpty) - #expect(count == 0) + // Guards against passing only because nothing is ever logged. + preferences.scale = 1.3 + #expect(log.keys == [.scale]) } @Test func panelOriginRoundTrips() { From ad36e95ced80f330cf4403a4100ee42fa411f92c Mon Sep 17 00:00:00 2001 From: Christian Neff Date: Thu, 17 Sep 2026 23:16:10 +0200 Subject: [PATCH 7/8] build: add SCRATCH_PATH to the Makefile and bundle script Every make target and scripts/bundle.sh pass SCRATCH_PATH (default .build) to SwiftPM as --scratch-path; the bundle script resolves the product directory with --show-bin-path instead of assuming .build/release. On a synced or network volume codesign rejects the volume's extended attributes and `make test` fails; building into a local directory avoids that. AGENTS.md drops the .build symlink advice: Synology Drive restores the server's directory and renames the symlink away. Co-Authored-By: Claude Fable 5.1 --- AGENTS.md | 11 +++++++++-- Makefile | 20 ++++++++++++++------ scripts/bundle.sh | 13 ++++++++----- 3 files changed, 31 insertions(+), 13 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fac59db..55c3698 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,9 @@ 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 @@ -103,8 +106,12 @@ Two targets plus tests: - On a Synology Drive (File Provider) checkout `swift test` fails at the codesign step ("resource fork, Finder information, or similar detritus not - allowed"). Keep `.build` off the mount, e.g. as a symlink to a local - directory; `.gitignore` lists `.build` without a trailing slash for that. + 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()`. 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/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/" From 53beace312a664ed40a531c9889ab43456bcb2f4 Mon Sep 17 00:00:00 2001 From: Christian Neff Date: Thu, 17 Sep 2026 23:17:21 +0200 Subject: [PATCH 8/8] refactor: remove the unused "OpenOSK Settings" string The toolbar-style tab controller titles the settings window after the selected tab, so the string lost its only use. Comments now say so, and say that noteActivity() is what applies a new opacity to the keyboard. Co-Authored-By: Claude Fable 5.1 --- Sources/OpenOSK/KeyboardController.swift | 1 + Sources/OpenOSK/Resources/de.lproj/Localizable.strings | 1 - Sources/OpenOSK/Resources/en.lproj/Localizable.strings | 1 - Sources/OpenOSK/SettingsController.swift | 2 ++ 4 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Sources/OpenOSK/KeyboardController.swift b/Sources/OpenOSK/KeyboardController.swift index 17577dd..ed2e11f 100644 --- a/Sources/OpenOSK/KeyboardController.swift +++ b/Sources/OpenOSK/KeyboardController.swift @@ -356,6 +356,7 @@ final class KeyboardController: NSObject { rebuildKeyboardView() case .opacity: panels.setOpacity(CGFloat(preferences.opacity)) + // Fades the keyboard itself to the new opacity. noteActivity() case .inactivityFadeEnabled, .inactivityFadeDelay: noteActivity() diff --git a/Sources/OpenOSK/Resources/de.lproj/Localizable.strings b/Sources/OpenOSK/Resources/de.lproj/Localizable.strings index 59864c5..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:"; diff --git a/Sources/OpenOSK/Resources/en.lproj/Localizable.strings b/Sources/OpenOSK/Resources/en.lproj/Localizable.strings index 664d495..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:"; diff --git a/Sources/OpenOSK/SettingsController.swift b/Sources/OpenOSK/SettingsController.swift index 52eba08..0a69047 100644 --- a/Sources/OpenOSK/SettingsController.swift +++ b/Sources/OpenOSK/SettingsController.swift @@ -57,6 +57,8 @@ final class SettingsController: NSObject { 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