Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.build/
.build
build/
.DS_Store
*.xcodeproj
Expand Down
49 changes: 41 additions & 8 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ make bundle # release build + assemble build/OpenOSK.app via scripts/bundle.s
make clean
```

Every target honours `SCRATCH_PATH` (default `.build`), SwiftPM's scratch
directory.

- `.build/debug/openosk --snapshot <dir>` 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
Expand Down Expand Up @@ -48,15 +56,27 @@ Two targets plus tests:
- `AppProfiles.swift` — per-app overrides (layout, forced terminal mode) from
`app-profiles.json`
- `Preferences.swift` — UserDefaults-backed; posts
`Preferences.didChangeNotification` on every set
`Preferences.didChangeNotification` for real changes only, carrying the
changed `Preferences.Key` (`Preferences.changedKey(in:)`); also stores
panel positions by panel id
- **`OpenOSK`** (executable, AppKit):
- `KeyboardPanel.swift` — borderless `.nonactivatingPanel`, `canBecomeKey = false`,
assistive-tech window level; this is what keeps focus in the target app
- `KeyboardView.swift` — manual frame layout (no Auto Layout in the panel);
`KeyView` handles press visuals, autorepeat, dwell (hover-to-press with
progress pie) and long-press detection; `SuggestionBarView` with dwell
buttons; current-text bar at the top. Character/macro keys fire on mouse-up
(enables long-press), specials/modifiers on mouse-down (enables autorepeat).
- `KeyboardView.swift` — metrics and the container view: manual frame layout
(no Auto Layout in the panel), current-text bar, blur backdrop for themes
with `usesVibrancy` (click-through, so background drags still move the panel)
- `KeyView.swift` — press visuals, autorepeat, dwell (progress pie),
long-press detection, accessibility button. Character/macro keys fire on
mouse-up (enables long-press), specials/modifiers on mouse-down (enables
autorepeat).
- `SuggestionBarView.swift` — themed suggestion chips (click, dwell, scan,
accessibility); suggestions that do not fit are dropped, best first
- `DwellTimer.swift` — hover countdown shared by keys and chips
- `KeyPresentation.swift` — spoken names and SF Symbols for special,
modifier and media keys
- `Theme.swift` — palettes; text on the panel background uses `panelText`,
text on key caps `text`/`secondaryText`
- `Snapshot.swift` — the `--snapshot` renderer
- `KeyboardController.swift` — central coordinator: modifier latching
(off → latched → locked), terminal-mode detection via frontmost app bundle ID,
suggestion routing (words vs. shell), learning, typing aids, inactivity fade,
Expand All @@ -65,11 +85,12 @@ Two targets plus tests:
dismissed via local+global mouse monitors
- `FocusWatcher.swift` — AXObserver on the frontmost app's focused UI element;
drives "show keyboard when editing text"
- `ScanController.swift` — scanning (switch access): two-level row→key scan;
- `ScanController.swift` — scanning (switch access): two-level row→key scan
over the groups its `groupsProvider` returns (visible keyboard + open panels);
the switch key is consumed via a global CGEvent tap that ignores events
carrying `KeyInjector.injectionSignature`
- `PanelsController.swift` — floating custom panels (layout JSON reused);
open state persisted in preferences; panels route key presses into
open state and positions persisted in preferences; panels route key presses into
`KeyboardController.handleKey`
- `ProfileEditorController.swift` / `PanelEditorController.swift` —
table-based editors writing app-profiles.json and user panel JSON; they
Expand All @@ -83,6 +104,18 @@ Two targets plus tests:

## Conventions & gotchas

- On a Synology Drive (File Provider) checkout `swift test` fails at the
codesign step ("resource fork, Finder information, or similar detritus not
allowed"). Build off the volume with `SCRATCH_PATH`, which the Makefile and
`scripts/bundle.sh` pass to SwiftPM as `--scratch-path`:
`make test SCRATCH_PATH=~/Entwicklung/Caches/OpenOSK/.build` (or export it).
The binary is then at `$SCRATCH_PATH/debug/openosk`. A `.build` symlink does
not hold on Synology Drive — the client restores the server's directory and
renames the symlink away.
- `KeyboardController.preferencesChanged` switches exhaustively over
`Preferences.Key`: a new preference must be given a case there, and only
preferences that change the view tree may trigger `rebuildKeyboardView()`.

- Resources are accessed via `Bundle.module` under the `Resources/` subdirectory
(the package uses `.copy("Resources")`, so subpaths are preserved). The app
bundle script copies `OpenOSK_OpenOSKCore.bundle` into `Contents/Resources/`.
Expand Down
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <dir>` — 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.
Expand Down
20 changes: 14 additions & 6 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/`.
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion Sources/OpenOSK/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
44 changes: 44 additions & 0 deletions Sources/OpenOSK/DwellTimer.swift
Original file line number Diff line number Diff line change
@@ -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
66 changes: 66 additions & 0 deletions Sources/OpenOSK/KeyPresentation.swift
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading