feat: user-editable keyboard shortcuts - #174
Open
lenisko wants to merge 7 commits into
Open
Conversation
Adds a Settings → Actions pane that rebinds every keyboard shortcut in FlowVision (~90 actions, ~120 chord slots). Defaults are bit-identical to the previous hardcoded dispatcher. Architecture - ShortcutAction: enum of every rebindable behavior. - ShortcutDef: single-source metadata table (category, scope, English name, default chords, firesDuringSearch, isSystemConventional, hasLockedPrimary). Built once at first access, cached. - KeyChord: layout-independent (keyCode, modifiers); character is display-only and ignored by hash/eq so the same physical key matches across layouts. - ShortcutStore: bindings + reverse-index, persisted to UserDefaults under shortcutBindings.v2. Posts .shortcutsChanged on mutation. v1 (single-chord) blobs auto-migrate; legacy key only dropped after a successful v2 write. - ShortcutRecorderView: NSView capturing a chord via first-responder. Bare Esc clears; right-click → Clear. - KeyShortcutManager (rewritten): window gate → non-rebindable text input → text-system fallthrough → 0.1s debounce → non-rebindable navigation → reverse-index chord lookup gated by scope + state. - AppDelegate.syncMenuShortcuts: walks main menu, applies current keyEquivalents to items whose identifier matches an action rawValue. Bare-letter and Fn-only chords skip menu sync to avoid AppKit hijacking text input. Scope model - browserOnly fires only when \!isInLargeView. - viewerOnly fires only in viewer. - both fires always. - Cross-scope collisions are intentional (W = browser up + viewer zoom-in, A/D = browser nav + viewer prev/next, F = sidebar + mirror, etc.). Conflict handling - Same-scope duplicate → "Shortcut conflict — reassign?" with one-pass strip of the blocker. - macOS HIG convention chord on non-system-conventional action → override confirm. - Locked primary default of another action (currently only Cmd+Q for quitApp) → hard reject, drop pending slot. - hasLockedPrimary actions render the primary recorder disabled; extras remain bindable. Non-rebindable surfaces (by design) - Quick-search letter input. - Cmd+ACVXZ + Home/End in OCR / rename text fields (delegated to the text system). - F2 / Enter rename, Tab focus swap, outline + grid arrow nav (context-dynamic, not keystroke-driven). Adding a new action: case in enum, case in buildDef, case in dispatch, optional storyboard identifier. Compiler enforces all of them via exhaustive switches.
Author
|
Hey there. This PR adds ability to change/add new keyboard shortcuts. It might need some expert eyes, changes itself are working, though I haven't tested all shortcuts. |
Captures launch-from-file optimization plan: instrumentation stage 1, six fix units stage 2 behind fastStartupEnabled flag.
Eight tasks: instrumentation, baseline capture, batched defaults, AppDelegate slimming, viewDidLoad pruning, optional storyboard split, launch-from-file fast path, FFmpeg warm + linker audit.
Owner
|
Hi. Thank you very much for your work! Custom key bindings have indeed been a long‑missing feature, and the reason is that a perfect implementation requires considering many factors, such as:
Regarding this pull request, there are still a few questions:
|
Shortcuts matched only on physical NSEvent.keyCode, so default +/-/= zoom bindings fired by US-ANSI key position. On ISO layouts where those symbols move (Norwegian + sits on the US - key) or sit behind AltGr/Option (Nordic = is Option+0), the wrong action triggered or none at all. Add a semantic fallback that matches on the glyph the key actually produced, taking precedence over the physical keyCode for printable keys (the reported Norwegian + key is physically bound to zoom-out). US/ANSI and Dvorak/Colemak are unaffected since glyph and position agree; special keys and control combos keep physical matching. - ShortcutMatching: pure, unit-tested matching helpers (no AppKit dep) - ShortcutStore: semantic index + lookup alongside the physical one - KeyChord.init: record the produced glyph so re-recorded Option/AltGr symbols stay symmetric with dispatch - dispatcher: semantic-first, deduped candidate ordering
The v1 single-chord wire format was never emitted — it landed in the same unreleased commit as v2 (after the 1.7.2 tag) and nothing ever wrote it (persist() only writes v2). The read/migration path was speculative dead code. Remove WireV1, EntryV1, applyV1, the legacy key, and the load() fallback branch.
Review follow-up.
- Comments claimed the semantic index was consulted only on a physical
miss and "never overrides" the keyCode match ("no-op on US/ANSI"). The
dispatch is semantic-first and does override; on US it now also resolves
shifted symbols (Shift+= -> '+' -> '=' -> zoom-in) the keyCode path
missed. Rewrite the comments to describe the actual precedence.
- producedChars now reuses recordedCharacter so dispatch and record share
one printable/Option-aware guard instead of producedChars keying only on
emptiness (which would have passed control codes through to lookup).
Author
|
Hey, thanks for interest! Been looking for fitting image viewer on Mac for years, had it hard as pervious IrfanView user which was doing a great job on Windows.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds a Settings → Actions pane that lets users rebind every keyboard shortcut in FlowVision (~90 actions, ~120 chord slots). Defaults are bit-identical to the previous hardcoded
dispatcher; existing muscle memory continues to work without any user action.
Why
The original
KeyShortcut.swiftwas a 1000-line dispatcher with chords baked into the logic. Users couldn't rebind, RTL handling was scattered, and adding a new action meant editingeight places. This PR splits dispatch (what to do) from binding (which key does it), routes everything through a single chord-lookup table, and exposes that table to the user.
Architecture
ShortcutAction(enum)ShortcutDef(struct)KeyChord(struct)(keyCode, modifiers)—characteris display metadata only, hash/eq ignore it.ShortcutStore(singleton)UserDefaults["shortcutBindings.v2"]. Posts.shortcutsChangedon mutation.ShortcutRecorderView(NSView)keyDowncapture. Bare Esc clears; right-click → Clear.ActionsSettingsViewController[recorder] [+] [↺]plus[recorder] [−]rows for extra chords.ViewController.KeyShortcutManagerAppDelegate.syncMenuShortcutsmainMenu, applieskeyEquivalentfrom store to any item whoseidentifiermatches an actionrawValue. Re-runs on.shortcutsChanged.Dispatch pipeline (
KeyShortcutManager)isKeyEventEnabled.NSTextselectors).findClosestItemarrows. Gated byisKeyEventEnabled.ShortcutStore.reverseIndex[chord]→ first action whose state gate (firesDuringSearchvsisKeyEventEnabled) and scope gate (browserOnly/viewerOnly/both) both pass.Scope model
Cross-scope chord collisions are intentional and load-bearing:
W= browser dir up (scope: browserOnly) AND viewer zoom-in (scope: viewerOnly)S= browser dir down AND viewer zoom-outA/D= browser dir left/right AND viewer prev/nextF= sidebar toggle (browser) AND mirror horizontal (viewer)Same-scope collisions trigger the conflict dialog at bind time.
Persistence — wire format
v2 (
shortcutBindings.v2):{ "version": 2, "entries": [{ "action": "viewerZoomIn", "chords": [...] }, ...] }defaultChords.chords: []→ user explicitly wiped this action (defaults won't sneak back).shortcutBindings.v1key is only deleted after a successful v2 write — failed migrations retry on next launch.Conflict / system-reserved handling
quitApp)hasLockedPrimary(currently onlyquitApp)Storyboard wiring
Menu items that should reflect the live binding gain an
identifier="<actionRawValue>"attribute.syncMenuShortcutsthen walks the main menu and applies the current chord'skeyEquivalent. Bare-letter and Fn-only chords are skipped so AppKit doesn't hijack text-input events through the menu — those keys route exclusively through the
NSEventlocal monitor.What stays non-rebindable
By design — these are context-dynamic, not keystroke-driven:
viewerClose/browserDeselect.How to add a new action
Documented inline at the top of
ShortcutAction.swift:Compiler-enforced exhaustiveness means a missing metadata entry or dispatch case is a build error, not a runtime surprise.
Compatibility / verification
EDIT_FEATURE_ENABLEDgate, Cmd+ForwardDelete no-prompt delete, Tab focus while OCR/renamemodal open — all verified.
Files
New
FlowVision/Sources/Shortcuts/KeyChord.swiftFlowVision/Sources/Shortcuts/ShortcutAction.swiftFlowVision/Sources/Shortcuts/ShortcutStore.swiftFlowVision/Sources/Shortcuts/ShortcutRecorderView.swiftFlowVision/Sources/SettingsViews/ActionsSettingsViewController.swiftModified
FlowVision/Sources/ViewControllerExtension/KeyShortcut.swift— rewritten as dispatcher.FlowVision/Sources/AppDelegate.swift— load store + menu sync.FlowVision/Resources/Base.lproj/Main.storyboard—identifier=on menu items that should auto-sync.