Skip to content

UI and UX improvements#1

Closed
plyght wants to merge 3 commits intopeterp:mainfrom
plyght:main
Closed

UI and UX improvements#1
plyght wants to merge 3 commits intopeterp:mainfrom
plyght:main

Conversation

@plyght
Copy link
Copy Markdown
Contributor

@plyght plyght commented Apr 27, 2026

This pull request introduces several significant enhancements and refactorings to the cmdcmd app, focusing on configurability, user experience, and maintainability. The main improvements include the addition of a built-in settings window, expanded configuration options (including minimal and display modes), improved onboarding and permission handling, and technical upgrades such as event taps for more robust input handling. Below are the most important changes grouped by theme:

Configuration and User Experience Enhancements:

  • Added new configuration options: minimalMode (for a lightweight, privacy-friendly UI), displayMode (to control Dock/menu-bar/hidden appearance), vimBindings, and letterJump. The config system is now more robust and includes a built-in settings window for immediate visual changes, while trigger edits still require restart. (README.md, Config.swift, Overlay.swift) [1] [2] [3] [4] [5] [6] [7]
  • Updated onboarding and permissions flow: minimal mode is now the default and does not require Screen Recording permission, improving first-launch experience. (README.md)
  • Enhanced documentation to reflect new settings, configuration options, and file structure. (README.md)

Technical Improvements:

  • Introduced event taps in both CmdChord and Overlay for more reliable detection of key events, improving the robustness of global hotkey and overlay interactions. (CmdChord.swift, Overlay.swift) [1] [2] [3] [4] [5] [6]
  • Added new window animation helpers for smooth fade-in and fade-out transitions, enhancing UI polish. (NSWindowAnimations.swift)

Key Binding and Customization:

  • Added support for optional Vim-style key bindings (h, j, k, l), controlled by the new vimBindings config option. (Keymap.swift, Config.swift, Overlay.swift) [1] [2] [3] [4]

Build and Packaging:

  • Added .brisk.toml for Brisk build configuration, specifying app metadata, dependencies, build targets, and signing. (.brisk.toml)
  • Updated Swift package settings to parse as a library for improved build compatibility. (Package.swift)

These changes collectively make the app more flexible, easier to configure, and more user-friendly, while also improving technical reliability and maintainability.

Copilot AI review requested due to automatic review settings April 27, 2026 18:40
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR enhances the cmdcmd macOS app’s configurability and UX by adding an in-app Settings window, introducing new config options (minimal mode, display mode, vim bindings, letter jump), and improving input handling robustness via event taps.

Changes:

  • Added a SwiftUI-based Settings window and new configuration fields persisted to config.json.
  • Implemented minimal/icon-only overlay mode, letter-jump navigation, and usage-based ordering.
  • Refactored overlay/key handling (NSPanel + event taps) and added window fade animations.

Reviewed changes

Copilot reviewed 11 out of 12 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
Sources/cmdcmd/main.swift Introduces @main entrypoint, Settings integration, and display mode application.
Sources/cmdcmd/Tile.swift Adds minimal-mode rendering (icons) and supports tiles without SCWindow.
Sources/cmdcmd/SettingsWindow.swift New SwiftUI Settings UI + model that saves config changes.
Sources/cmdcmd/OverlayView.swift Adds letter input routing and refactors key handling into reusable handlers.
Sources/cmdcmd/Overlay.swift Adds minimal-mode overlay path, usage-based ordering, and key event tap handling.
Sources/cmdcmd/NSWindowAnimations.swift Adds reusable fade-in/out window animation helpers.
Sources/cmdcmd/Keymap.swift Adds optional vim-style default bindings controlled by config.
Sources/cmdcmd/Config.swift Expands config schema (minimal/display/vim/letterJump) and adds save().
Sources/cmdcmd/CmdChord.swift Adds a session event tap to improve cmd-cmd chord detection robustness.
README.md Updates documentation for new settings, modes, and configuration options.
Package.swift Adds -parse-as-library to support the new @main structure.
.pi/semantic-grep.sqlite Adds a SQLite artifact file (appears tool-generated).
.brisk.toml Adds Brisk build configuration for packaging/signing metadata.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 87 to +94
deinit {
if let o = workspaceObserver {
NotificationCenter.default.removeObserver(o)
}
if let o = appActivationObserver {
NSWorkspace.shared.notificationCenter.removeObserver(o)
}
}
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overlay’s deinit removes observers but doesn’t stop the activity timer or tear down the key event tap/run-loop source. If an Overlay instance were ever deallocated while visible (or if shutdown paths change), this could leave a tap/source installed longer than intended.

Suggestion: call stopActivityTimer() and stopKeyEventTap() (and nil out tap/source) from deinit as a defensive cleanup.

Copilot uses AI. Check for mistakes.
Comment on lines +37 to +55
private let trigger: String?
@Published var status: String = ""
var onSave: ((Config) -> Void)?

init(config: Config) {
animations = config.animations
minimalMode = config.minimalMode
displayMode = config.displayMode
vimBindings = config.vimBindings
letterJump = config.letterJump
trigger = config.trigger
}

func save() {
var config = Config.default
config.animations = animations
config.minimalMode = minimalMode
config.displayMode = displayMode
config.trigger = trigger
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SettingsModel.save() rebuilds the file from Config.default and never carries over the existing bindings (or any future config fields). Opening Settings and toggling any switch will overwrite the user’s custom bindings in config.json.

Suggestion: keep a stored Config instance (initialized from the passed-in config) and only mutate the fields controlled by Settings, or explicitly preserve bindings from the initial config when saving.

Suggested change
private let trigger: String?
@Published var status: String = ""
var onSave: ((Config) -> Void)?
init(config: Config) {
animations = config.animations
minimalMode = config.minimalMode
displayMode = config.displayMode
vimBindings = config.vimBindings
letterJump = config.letterJump
trigger = config.trigger
}
func save() {
var config = Config.default
config.animations = animations
config.minimalMode = minimalMode
config.displayMode = displayMode
config.trigger = trigger
private var config: Config
@Published var status: String = ""
var onSave: ((Config) -> Void)?
init(config: Config) {
self.config = config
animations = config.animations
minimalMode = config.minimalMode
displayMode = config.displayMode
vimBindings = config.vimBindings
letterJump = config.letterJump
}
func save() {
config.animations = animations
config.minimalMode = minimalMode
config.displayMode = displayMode

Copilot uses AI. Check for mistakes.
Comment on lines 362 to +366
let saved = savedOrder
let ordered: [Tile]
if saved.isEmpty {
ordered = mcTiles
} else {
let presentIDs = Set(mcTiles.map { CGWindowID($0.scWindow.windowID) })
let knownInOrder = saved.filter { presentIDs.contains($0) }
let knownIDs = Set(knownInOrder)
let known = knownInOrder.compactMap { wid in mcTiles.first(where: { CGWindowID($0.scWindow.windowID) == wid }) }
let unknown = mcTiles.filter { !knownIDs.contains(CGWindowID($0.scWindow.windowID)) }
ordered = known + unknown
}
savedOrder = ordered.map { CGWindowID($0.scWindow.windowID) }
let usage = Self.usageOrder
let savedRanks = Dictionary(uniqueKeysWithValues: saved.enumerated().map { ($0.element, $0.offset) })
let usageRanks = Dictionary(uniqueKeysWithValues: usage.enumerated().map { ($0.element, $0.offset) })
let ordered = mcTiles.sorted { a, b in
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dictionary(uniqueKeysWithValues:) will trap at runtime if the input contains duplicate keys. Both saved (from UserDefaults tile order) and usage (from UserDefaults app usage order) can plausibly contain duplicates (e.g., after manual defaults edits or data corruption), which would crash when opening the overlay.

Suggestion: build rank maps in a way that tolerates duplicates (e.g., iterate and only set the first-seen rank, or dedupe the arrays before creating the dictionary).

Copilot uses AI. Check for mistakes.
Task {
for t in toStop { await t.stop() }
for t in toStop {
t.layer.removeFromSuperlayer()
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In hide(), the Task removes each tile’s CALayer from its superlayer. That Task is not confined to the main thread/actor, but Core Animation layer tree mutations are expected to be performed on the main thread; doing this off-main can lead to race conditions or crashes.

Suggestion: perform removeFromSuperlayer() on the main actor (e.g., wrap in MainActor.run / DispatchQueue.main.async) and keep the async t.stop() work off-main if desired.

Suggested change
t.layer.removeFromSuperlayer()
await MainActor.run {
t.layer.removeFromSuperlayer()
}

Copilot uses AI. Check for mistakes.
Comment on lines +952 to +969
let container = NSView(frame: NSRect(origin: .zero, size: frame.size))
container.autoresizingMask = [.width, .height]
container.wantsLayer = true
container.layer?.backgroundColor = config.minimalMode ? NSColor.clear.cgColor : NSColor.black.withAlphaComponent(0.18).cgColor
if config.minimalMode {
let blur = NSVisualEffectView(frame: container.bounds)
blur.autoresizingMask = [.width, .height]
blur.material = .hudWindow
blur.blendingMode = .withinWindow
blur.state = .active
blur.wantsLayer = true
blur.layer?.cornerRadius = 25
blur.layer?.cornerCurve = .continuous
blur.layer?.masksToBounds = true
blur.layer?.borderWidth = 1
blur.layer?.borderColor = NSColor.white.withAlphaComponent(0.18).cgColor
container.addSubview(blur)
}
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

makeWindow bakes config.minimalMode into the window chrome (container background + optional blur view). After the window is created once, toggling minimalMode via Settings updates self.config, but the existing window’s container/blur isn’t updated or rebuilt, so the UI may not actually reflect the new mode until restart or until the window is recreated.

Suggestion: when minimalMode changes, recreate the overlay window (or update the container view hierarchy/background) so the mode switch is visually consistent.

Copilot uses AI. Check for mistakes.
@peterp
Copy link
Copy Markdown
Owner

peterp commented Apr 28, 2026

This is really awesome, will rebase and see how this runs.

@peterp peterp mentioned this pull request Apr 28, 2026
2 tasks
peterp added a commit that referenced this pull request Apr 28, 2026
* Add NSWindow fade animation helpers

Adds reusable fadeInAndUp / fadeOutAndDown extensions on NSWindow for
smooth window transitions. No callers yet — landed standalone so future
window UI (settings, status-item-driven panels) can adopt them.

Lifted from #1 by @plyght.

Co-Authored-By: plyght <plyght@peril.lol>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Wire window fade into overlay show/hide

When animations are on, the overlay now fades in on show and fades out
on hide using the new NSWindow helpers. Distance is 0 (alpha-only) since
the overlay is fullscreen and any slide reveals desktop edges.

Hide defers orderOut and the layer-clear into the fade completion so the
fade isn't on an already-emptied window.

Co-Authored-By: plyght <plyght@peril.lol>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: plyght <plyght@peril.lol>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@peterp
Copy link
Copy Markdown
Owner

peterp commented Apr 28, 2026

Hey @plyght — thanks for this PR. To make it easier to test the moving parts in isolation, I split your changes into six smaller PRs and shipped each one separately:

  1. Add NSWindow fade animation helpers #4NSWindow fade-in/out helpers (with the overlay show/hide wired up)
  2. Add built-in Settings window #5 — Built-in Settings window (Dock-menu / status-item / re-open handler) + comment-preserving in-place patcher for config.json
  3. Add display-mode setting (dock / menu bar / hidden) #6displayMode setting (dock / menu-bar / hidden) with NSStatusItem
  4. Add first-letter app-jump in the overlay #7 — First-letter app-jump in the overlay (gated behind ⌃ to avoid colliding with wasd movement)
  5. Order tiles by recent app usage (opt-in) #8 — Tile ordering by recent app usage (opt-in)
  6. Add a session event tap as a fallback for cmd-cmd chord detection #9CGEventTap fallback for chord detection

A few intentional differences from your original PR for the local context:

  • Minimal mode / icon HUD: skipped. It changes the app's identity (live-preview switcher → icon switcher) and flipped the default. Happy to revisit as a separate opt-in PR if you'd like to push it.
  • Default values: kept defaults so existing users don't see a behavior change. letterJump is on but ⌃-gated; usageOrdering is off; displayMode defaults to dock.
  • Hidden mode: uses .accessory instead of .prohibited so single-instance relaunch and applicationShouldHandleReopen keep working.
  • Letter-jump: gated by ⌃ + letter to avoid eating wasd movement.
  • CGEventTap: ported only the chord tap (passive .listenOnly, async dispatch). Skipped the in-overlay tap since the overlay window already becomes key and the existing NSResponder path handles it.
  • .brisk.toml / .pi/semantic-grep.sqlite: not included.
  • Settings save: rewrote save to patch config.json in place instead of full re-encode, so your hand-written comments / key order / formatting survive a UI save.

You're co-credited on every commit (Co-Authored-By: plyght <plyght@peril.lol>) and called out in each PR description. Closing this PR is fine on your end — leaving it open in case you want to chime in on anything above.

Cheers!

@peterp peterp closed this Apr 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants