diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 802749d..1a52adb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,7 +54,7 @@ jobs: libvulkan-dev \ libfontconfig1-dev libfreetype6-dev \ libssl-dev \ - libgtk-3-dev libayatana-appindicator3-dev + libgtk-3-dev libayatana-appindicator3-dev libxdo-dev - run: cargo build - run: cargo build --features tray - run: cargo clippy --all-targets --features tray -- -D warnings diff --git a/README.md b/README.md index 4fcdac2..8876ccd 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ You need a stable **Rust** toolchain (`rustup`) and: ```bash sudo apt install build-essential pkg-config libxcb1-dev libxkbcommon-dev \ libxkbcommon-x11-dev libwayland-dev libvulkan-dev libfontconfig1-dev \ - libfreetype6-dev libssl-dev # + libgtk-3-dev libayatana-appindicator3-dev for --features tray + libfreetype6-dev libssl-dev # + libgtk-3-dev libayatana-appindicator3-dev libxdo-dev for --features tray ``` > Same code on both. CI builds macOS **and** Linux on every push β€” see [Platforms](#platforms). @@ -51,6 +51,7 @@ You need a stable **Rust** toolchain (`rustup`) and: | | Feature | Where | |---|---|---| | πŸͺŸ | Native window + custom transparent title bar (traffic lights on macOS, window controls on Linux) | `main.rs`, `shell.rs` | +| ⌘ | **Command palette** (⌘K) β€” fuzzy search, grouped, keyboard-first, recents | `command_palette.rs` | | 🎨 | Dark/light theme + a live **accent picker** (6 colors) | `theme.rs` | | βš™οΈ | **Settings page** with preferences saved as JSON in the OS config dir | `settings.rs`, `settings_view.rs` | | ⌨️ | **Keyboard shortcuts** β†’ actions β†’ menu items | `main.rs`, `shell.rs` | @@ -77,6 +78,35 @@ Preferences live at `~/Library/Application Support//settings.json`. T ~40 lines of `serde` + the `directories` crate β€” see [LEARNINGS Β§3](docs/LEARNINGS.md#settings) for how it compares to `confy` and Zed's settings system. +## Command palette (⌘K) + +Deck command palette + +Press **⌘K** (Ctrl K) for a Superhuman/Linear-style launcher: a floating, top-anchored panel with +**fuzzy search**, commands **grouped by category**, a **Recent** group, **live shortcut chips**, and +full keyboard control (`↑↓` move, `↡` run, `esc` close). It's built on the same searchable-list +primitive (`List` + `ListDelegate`) that powers Zed's own palette, so the search box, navigation and +selection come for free β€” the whole feature is one heavily-commented file you own: +`src/command_palette.rs` (registry, fuzzy matcher, delegate, overlay, and its tests). + +**Adding a command is one line.** Edit the `commands()` registry at the top of the file. If it maps +to an action you already have, just point at it β€” the palette dispatches the *same* action as the +hotkey and the menu bar, so the three can never drift, and the trailing shortcut chip is derived +**live from your keymap** (no hand-syncing labels): + +```rust +Command { + id: "home", title: "Go Home", icon: IconName::ArrowLeft, + category: Category::Navigation, keywords: &["back", "welcome"], + run: Run::Action(|| Box::new(GoBack)), +} +``` + +The fuzzy matcher is a compact, dependency-free subsequence scorer (rewards prefixes, word +boundaries and consecutive runs; highlights the matched characters) β€” small enough to read and +tweak. See [LEARNINGS Β§16](docs/LEARNINGS.md#command-palette) for the load-bearing details (why a +custom overlay over a dialog, how commands are run via the list's event, and the gotchas). + ## Menu-bar / tray-first apps (`--features tray`) ``` @@ -135,6 +165,7 @@ Point it at the Anthropic API (Claude), a local model, or your own runtime β€” D | Shortcut | Action | |---|---| +| `⌘K` | Command palette | | `⌘N` | New (fires the `NewItem` action) | | `⌘,` | Open Settings | | `⌘[` | Back | @@ -155,6 +186,7 @@ deck/ β”œβ”€β”€ src/ β”‚ β”œβ”€β”€ main.rs bootstrap: window, menus, shortcuts, theme, settings β”‚ β”œβ”€β”€ shell.rs root view: routing (Welcome/Settings) + app state +β”‚ β”œβ”€β”€ command_palette.rs the ⌘K palette: command registry + fuzzy search + overlay β”‚ β”œβ”€β”€ welcome.rs the home page (replace me) β”‚ β”œβ”€β”€ settings.rs the persisted Settings struct (serde + config dir) β”‚ β”œβ”€β”€ settings_view.rs the settings page UI diff --git a/docs/LEARNINGS.md b/docs/LEARNINGS.md index fdbe547..d240feb 100644 --- a/docs/LEARNINGS.md +++ b/docs/LEARNINGS.md @@ -407,6 +407,9 @@ text, tooltip, tree`. Sharp edges that bite forkers: 8. Tray icon: create on the main thread in `app.run` and **keep the handle alive** (Β§8). 9. `KeyBinding::new` panics on bad strings; the `actions!` namespace is a bare ident. 10. No `Modal` (use `Dialog`); `Input` is stateful (`InputState` entity); no bundled font. +11. `List` selection is owned by `ListState` β€” never set `.selected()` in `render_item` (Β§16). +12. `.searchable(true)` lives on `ListState`, not the `List` element; `render_initial` *replaces* the + list on an empty query (Β§16). --- @@ -422,5 +425,63 @@ text, tooltip, tree`. Sharp edges that bite forkers: - **`cargo bundle` block + a single `icon.png`** β†’ rejected a Zed-style signing/notarizing script. - **Tray as an opt-in feature, window stays GPUI** β†’ rejected any second rendering system; the status item is a native image, nothing more. +- **Command palette = one file on `List`/`ListDelegate`, commands dispatch real actions** β†’ rejected a + separate registry crate, a bespoke modal, and a heavyweight background fuzzy matcher (Β§16). - **A few small files, ~700 lines** β†’ rejected a sprawling, over-engineered monorepo. Read it in ten minutes, then start deleting. + +--- + +## 16. The command palette β€” `List` + `ListDelegate` {#command-palette} + +The ⌘K palette (`src/command_palette.rs`) is one file. It leans on the same primitive Zed's own +command palette uses: a **searchable list driven by a delegate**. In gpui-component that's +`ListState` + the `ListDelegate` trait (Zed calls it `Picker` / `PickerDelegate`). You get the +search input, virtualized scrolling, `↑↓` navigation, `↡`/`esc` handling and selection styling for +free; you supply the *rows*, the *match*, and the *chrome*. + +**The shape.** A flat `commands()` registry at the top of the file (the one edit surface) β†’ a +`PaletteDelegate` that filters those into grouped `sections` β†’ the `List` renders them. A command +runs by **dispatching a real `gpui` action** (`Run::Action(|| Box::new(OpenSettings))`), so a palette +entry, its hotkey, and its menu item all funnel through the *same* `Shell` handler and can never +drift. The trailing shortcut chip is derived **live from the keymap** via +`Kbd::binding_for_action(&*action, None, window)` β€” change a `KeyBinding` in `main.rs` and the chip +follows. + +**Running a command across the view boundary.** The delegate can't reach `Shell` (its methods only +get `&mut Context>`). So `confirm()` just stashes the chosen command in a `pending` +field, and `Shell` β€” subscribed to the list via `cx.subscribe_in(&palette, window, …)` β€” drains it on +`ListEvent::Confirm`, **closes the palette first**, then dispatches (so the action lands on `Shell`'s +focus tree, not the palette's). `ListEvent::Cancel` (esc) just closes. This event bridge is the same +pattern the kit uses internally. + +**The fuzzy matcher** is ~40 lines, no deps: a subsequence scorer that rewards matches at the start, +at word boundaries (after a space/`-`/`_`) and camelCase humps, and consecutive runs, while penalizing +gaps and length β€” and returns the matched **byte ranges** so the title can highlight them with +`StyledText::with_highlights`. It runs synchronously: for dozens of commands that's microseconds, so +(unlike Zed) there's no background-threaded matcher or char-bag prefilter. If you grow to thousands of +candidates, that's where you'd add them. + +**Custom overlay, not `Dialog`.** The panel is a child the `Shell` renders when open β€” a scrim +(`background.opacity(0.55)`, correct in both modes) + a `popover`-surfaced panel, anchored near the +top (the Superhuman signature, vs. centered). `gpui-component`'s `Dialog` has title/footer chrome and +its layer only paints if your root view calls `Root::render_dialog_layer` itself β€” a child overlay is +less wiring and gives full control of position and motion. + +### Gotchas that cost real time + +- **Don't set `.selected()` in `render_item`** β€” `ListState` tracks the selected index and applies + the selection style to whatever item you return. The index you store in the delegate is only there + so `confirm()` knows what was chosen. +- **`.searchable(true)` is a `ListState` method, not a `List`-element method.** Build it on the state + (`ListState::new(delegate, …).searchable(true)`); the `List` element only takes `.search_placeholder(…)`. +- **`render_initial` *replaces* the list when the query is empty.** If you want a navigable empty-state + (recents + all commands), leave `render_initial` as the default `None` and populate the sections in + `perform_search("")` instead. +- **Unique element ids for duplicated rows.** A command can appear in "Recent" *and* its category, so + the row id is `(cmd.id, section)` β€” a bare `cmd.id` would collide and misbehave. +- **Animate opacity, not offset.** The panel sits inside a centering flex (not absolutely + positioned), so the entrance fades with `.with_animation(.., |el, t| el.opacity(t))`; animating + `.top()` there would be shaky. +- **Pre-select on open.** A fresh list with an empty query has nothing selected, and `↡` only fires + when something is selected β€” so `open_palette` calls `set_selected_index(first_row)` before focusing. diff --git a/docs/screenshot-palette.png b/docs/screenshot-palette.png new file mode 100644 index 0000000..b7a947c Binary files /dev/null and b/docs/screenshot-palette.png differ diff --git a/src/command_palette.rs b/src/command_palette.rs new file mode 100644 index 0000000..781a0ed --- /dev/null +++ b/src/command_palette.rs @@ -0,0 +1,816 @@ +//! Command palette β€” the ⌘K (Ctrl K) launcher, Superhuman / Linear style. +//! +//! A floating, top-anchored panel over a soft scrim: type to fuzzy-search every +//! command, ↑↓ to move, ↡ to run, esc to close. It's built on gpui-component's +//! `List` + `ListDelegate` (the same searchable-list primitive Zed's own palette +//! uses), so the search box, keyboard navigation, scrolling and selection styling +//! all come for free β€” this file only supplies the *commands*, the *fuzzy match*, +//! and the *chrome*. +//! +//! ## Add a command (the whole story) +//! +//! Add one line to `commands()` below. If it maps to an action you already have +//! (the common case), point `run` at it and you're done β€” the palette dispatches +//! the *same* action as the hotkey and the menu bar, so behaviour can never drift, +//! and the trailing shortcut chip is derived live from your keymap: +//! +//! ```ignore +//! Command { id: "home", title: "Go Home", icon: IconName::ArrowLeft, +//! category: Category::Navigation, keywords: &["back", "welcome"], +//! run: Run::Action(|| Box::new(GoBack)) }, +//! ``` +//! +//! For a palette-only verb with no standing action, use the `Run::Accent`-style +//! escape hatch (or add your own `Run` variant + a match arm in `run_command`). +//! +//! ## How it's wired (see `shell.rs` + `main.rs`) +//! +//! `main.rs` declares the `TogglePalette` action and binds `secondary-k` to it. +//! `Shell` owns the open/closed state, builds a fresh `ListState` each time the +//! palette opens (so the query always starts empty), and renders the overlay as +//! its last child. Because the delegate can't reach `Shell`, running a command is +//! bridged through the list's `ListEvent`: the delegate stashes the chosen command +//! in `pending`, and `Shell::on_palette_event` drains it, closes the palette, then +//! dispatches (see the `impl Shell` block at the bottom of this file). +//! +//! ## Extending +//! +//! - **A verb with no action / with data.** Add a `Run` variant (e.g. +//! `Run::OpenUrl(&'static str)`) and a match arm in `Shell::run_command`. Keep the +//! `Run` match in `render_item` (the shortcut-chip derivation) in sync too. +//! - **A new group.** Add a `Category` variant and list it in `Category::ORDER`. +//! - **Persistent recents.** Recents are session-only by design (no disk I/O on the +//! hot path). To survive restarts: add `recents: Vec` to `Settings`, load it +//! in `Shell::new`, and call `self.settings.save()` from `record_recent` β€” the same +//! pattern `settings.rs` already uses for `display_name`. + +use std::ops::Range; +use std::time::Duration; + +use gpui::{ + div, ease_out_quint, hsla, px, Animation, AnimationExt, AppContext, Context, Entity, + FontWeight, HighlightStyle, InteractiveElement, IntoElement, MouseButton, ParentElement, + Styled, StyledText, Task, Window, +}; +use gpui_component::{ + h_flex, + kbd::Kbd, + list::{List, ListDelegate, ListEvent, ListItem, ListState}, + v_flex, ActiveTheme, Icon, IconName, IndexPath, +}; + +use crate::shell::Shell; +use crate::theme::Accent; +use crate::{GoBack, NewItem, OpenSettings, Quit, TogglePalette, ToggleTheme}; + +// =========================================================================== +// 1. The command registry β€” THE place you edit to add/remove commands. +// =========================================================================== + +/// A group heading in the palette. The order here is the order on screen. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum Category { + Navigation, + Create, + Appearance, + App, +} + +impl Category { + /// Sections render in this order; empty groups are dropped automatically. + const ORDER: [Category; 4] = [ + Category::Navigation, + Category::Create, + Category::Appearance, + Category::App, + ]; + + fn label(self) -> &'static str { + match self { + Category::Navigation => "Navigation", + Category::Create => "Create", + Category::Appearance => "Appearance", + Category::App => "App", + } + } +} + +/// What running a command does. +#[derive(Clone, Copy)] +pub enum Run { + /// Dispatch a real gpui action. This funnels through the *same* handler as + /// the keyboard shortcut and the native menu item, so the three can never + /// disagree β€” and a command that reuses an existing action needs no new code. + Action(fn() -> Box), + /// Set the brand accent. An example of a palette-only verb that has no + /// standing action/hotkey β€” handled directly in `Shell` (see `run_command`). + Accent(Accent), +} + +/// One palette entry. (`Clone`, not `Copy`, because `IconName` isn't `Copy`.) +#[derive(Clone)] +pub struct Command { + /// Stable id β€” used as the recents key and part of the row's element id. + pub id: &'static str, + /// The label you see (also what fuzzy-search matches + highlights). + pub title: &'static str, + /// Leading glyph (Lucide; see `IconName`). + pub icon: IconName, + /// Which section it lives in. + pub category: Category, + /// Extra search aliases that don't appear in the title (e.g. "prefs"). + pub keywords: &'static [&'static str], + /// What it does. + pub run: Run, +} + +/// The command set Deck ships with. **Add your commands here.** Everything maps +/// to an action `Shell`/`main.rs` already handles, except the six accent verbs, +/// which show off the `Run::Accent` escape hatch. +pub fn commands() -> Vec { + let mut cmds = vec![ + Command { + id: "settings", + title: "Open Settings", + icon: IconName::Settings, + category: Category::Navigation, + keywords: &["preferences", "prefs", "config"], + run: Run::Action(|| Box::new(OpenSettings)), + }, + Command { + id: "home", + title: "Go Home", + icon: IconName::ArrowLeft, + category: Category::Navigation, + keywords: &["back", "welcome"], + run: Run::Action(|| Box::new(GoBack)), + }, + Command { + id: "new", + title: "New Item", + icon: IconName::Plus, + category: Category::Create, + keywords: &["add", "create"], + run: Run::Action(|| Box::new(NewItem)), + }, + Command { + id: "theme", + title: "Toggle Theme", + icon: IconName::Sun, + category: Category::Appearance, + keywords: &["dark", "light", "mode", "appearance"], + run: Run::Action(|| Box::new(ToggleTheme)), + }, + ]; + + // Six accent commands, generated from `Accent::ALL` (mirrors settings_view.rs). + cmds.extend(Accent::ALL.iter().map(|&accent| { + let (id, title) = accent_meta(accent); + Command { + id, + title, + icon: IconName::Palette, + category: Category::Appearance, + keywords: &["color", "accent", "brand"], + run: Run::Accent(accent), + } + })); + + cmds.push(Command { + id: "quit", + title: "Quit", + icon: IconName::Close, + category: Category::App, + keywords: &["exit", "close"], + run: Run::Action(|| Box::new(Quit)), + }); + + cmds +} + +/// `(stable id, palette title)` for an accent β€” kept here so `theme.rs` stays +/// purely about colour. +fn accent_meta(accent: Accent) -> (&'static str, &'static str) { + match accent { + Accent::Indigo => ("accent-indigo", "Accent Β· Indigo"), + Accent::Blue => ("accent-blue", "Accent Β· Blue"), + Accent::Violet => ("accent-violet", "Accent Β· Violet"), + Accent::Emerald => ("accent-emerald", "Accent Β· Emerald"), + Accent::Amber => ("accent-amber", "Accent Β· Amber"), + Accent::Rose => ("accent-rose", "Accent Β· Rose"), + } +} + +// =========================================================================== +// 2. The fuzzy matcher. +// =========================================================================== +// +// A compact subsequence scorer β€” every query char must appear in order, and we +// reward matches at the start, at word boundaries (after a space / - / _) and +// camelCase humps, and consecutive runs, while penalising gaps and length. It +// returns the matched byte ranges so we can highlight them. This is a small, +// readable cousin of Zed's `fuzzy` crate; for a palette of dozens of commands +// it runs synchronously in microseconds β€” no background threads needed. + +/// Fuzzy-match `query` against `haystack`. `None` if not a subsequence; otherwise +/// `Some((score, matched byte ranges))`, higher score = better. +fn fuzzy(query: &str, haystack: &str) -> Option<(i32, Vec>)> { + if query.is_empty() { + return Some((0, Vec::new())); + } + + let needles: Vec = query.chars().flat_map(char::to_lowercase).collect(); + let mut ni = 0usize; + let mut ranges: Vec> = Vec::with_capacity(needles.len()); + let mut score = 0i32; + // Two cursors so multi-byte UTF-8 is handled correctly: `byte` is the real + // string offset we hand back for highlight ranges; `char_ix` counts logical + // characters, which is what adjacency/gap scoring should reason about. + let mut prev_match: i64 = -2; + let mut prev: Option = None; + + for (char_ix, (byte, ch)) in (0i64..).zip(haystack.char_indices()) { + if ni == needles.len() { + break; + } + let lc = ch.to_lowercase().next().unwrap_or(ch); + if lc == needles[ni] { + if char_ix == 0 { + score += 15; // start of string + } else if matches!(prev, Some(p) if p == ' ' || p == '-' || p == '_' || p == '/') { + score += 12; // word boundary + } else if matches!(prev, Some(p) if p.is_lowercase()) && ch.is_uppercase() { + score += 9; // camelCase hump + } + if char_ix == prev_match + 1 { + score += 6; // consecutive run + } else { + score -= ((char_ix - prev_match - 1).min(6)) as i32; // gap penalty (capped) + } + ranges.push(byte..byte + ch.len_utf8()); + prev_match = char_ix; + ni += 1; + } + prev = Some(ch); + } + + if ni == needles.len() { + score -= haystack.chars().count() as i32 / 4; // mild preference for shorter labels + Some((score, ranges)) + } else { + None + } +} + +/// Score a command: a title match (which carries highlight ranges) wins; failing +/// that, a keyword match counts but ranks a little lower and highlights nothing. +fn score_command(query: &str, cmd: &Command) -> Option<(i32, Vec>)> { + if let Some(hit) = fuzzy(query, cmd.title) { + return Some(hit); + } + let mut best: Option = None; + for kw in cmd.keywords { + if let Some((s, _)) = fuzzy(query, kw) { + best = Some(best.map_or(s, |b| b.max(s))); + } + } + best.map(|s| (s - 4, Vec::new())) +} + +// =========================================================================== +// 3. The list delegate β€” feeds rows to gpui-component's `List`. +// =========================================================================== + +/// A matched row: a command plus the byte ranges of its title to highlight. +#[derive(Clone)] +struct Hit { + cmd: Command, + ranges: Vec>, +} + +impl Hit { + /// A row with no highlighting (used for the empty-query / recents view). + fn plain(cmd: Command) -> Self { + Hit { + cmd, + ranges: Vec::new(), + } + } +} + +/// A visible group: a heading and its rows. +struct Section { + title: &'static str, + rows: Vec, +} + +/// The data + filtering behind the palette. The `List` owns the search input, +/// keyboard navigation and selection; this just answers "what rows, in what +/// groups?" and remembers which command was confirmed. +pub struct PaletteDelegate { + all: Vec, + /// Snapshot of recently-run ids, passed in by `Shell` when the palette opens. + recents: Vec<&'static str>, + sections: Vec
, + /// The currently highlighted row; the `List` keeps this in sync. Read in + /// `confirm` to know what to run. + selected: Option, + /// Set by `confirm`, drained by `Shell::on_palette_event`. + pending: Option<(Run, &'static str)>, +} + +impl PaletteDelegate { + pub fn new(recents: Vec<&'static str>) -> Self { + let mut this = Self { + all: commands(), + recents, + sections: Vec::new(), + selected: None, + pending: None, + }; + this.rebuild(""); + this + } + + fn resolve(&self, id: &str) -> Option { + self.all.iter().find(|c| c.id == id).cloned() + } + + /// The first selectable row, used to pre-select something on open. + pub fn first_row(&self) -> Option { + self.sections + .iter() + .position(|s| !s.rows.is_empty()) + .map(|s| IndexPath::new(0).section(s)) + } + + /// Take the command confirmed by the user, if any. + pub fn take_pending(&mut self) -> Option<(Run, &'static str)> { + self.pending.take() + } + + /// Recompute the visible sections for `query`. + fn rebuild(&mut self, query: &str) { + let query = query.trim(); + self.sections.clear(); + + if query.is_empty() { + // Empty query: a "Recent" group, then every *other* command grouped by + // category. Recent commands lift out of their group (Raycast/Spotlight + // style) so nothing appears twice. + let recent: Vec = self + .recents + .iter() + .filter_map(|id| self.resolve(id)) + .collect(); + if !recent.is_empty() { + self.sections.push(Section { + title: "Recent", + rows: recent.iter().map(|cmd| Hit::plain(cmd.clone())).collect(), + }); + } + for cat in Category::ORDER { + let rows: Vec = self + .all + .iter() + .filter(|c| c.category == cat && !recent.iter().any(|r| r.id == c.id)) + .map(|cmd| Hit::plain(cmd.clone())) + .collect(); + if !rows.is_empty() { + self.sections.push(Section { + title: cat.label(), + rows, + }); + } + } + } else { + // Non-empty query: each category's matches, best score first. + for cat in Category::ORDER { + let mut scored: Vec<(i32, Hit)> = self + .all + .iter() + .filter(|c| c.category == cat) + .filter_map(|cmd| { + score_command(query, cmd).map(|(s, ranges)| { + ( + s, + Hit { + cmd: cmd.clone(), + ranges, + }, + ) + }) + }) + .collect(); + scored.sort_by_key(|entry| std::cmp::Reverse(entry.0)); + if !scored.is_empty() { + self.sections.push(Section { + title: cat.label(), + rows: scored.into_iter().map(|(_, hit)| hit).collect(), + }); + } + } + } + } +} + +impl ListDelegate for PaletteDelegate { + type Item = ListItem; + + fn perform_search( + &mut self, + query: &str, + _: &mut Window, + _: &mut Context>, + ) -> Task<()> { + self.rebuild(query); + Task::ready(()) + } + + fn sections_count(&self, _: &gpui::App) -> usize { + self.sections.len() + } + + fn items_count(&self, section: usize, _: &gpui::App) -> usize { + self.sections.get(section).map_or(0, |s| s.rows.len()) + } + + fn render_section_header( + &mut self, + section: usize, + _: &mut Window, + cx: &mut Context>, + ) -> Option { + let title = self.sections.get(section)?.title; + Some( + div() + .px_3() + .pt_3() + .pb_1() + .text_xs() + .font_weight(FontWeight::SEMIBOLD) + .text_color(cx.theme().muted_foreground) + .child(title.to_uppercase()), + ) + } + + fn render_item( + &mut self, + ix: IndexPath, + window: &mut Window, + cx: &mut Context>, + ) -> Option { + let hit = self.sections.get(ix.section)?.rows.get(ix.row)?; + let cmd = &hit.cmd; + let ranges = hit.ranges.clone(); + // The List applies the selected *background*; we only use this to brighten + // the leading icon on the selected row (it stays quiet on the rest). + let is_selected = self.selected == Some(ix); + + // Derive the shortcut chip live from the keymap, so it always mirrors the + // real binding in main.rs (None = global context, which is how Deck binds). + let kbd = match cmd.run { + Run::Action(make) => Kbd::binding_for_action(&*make(), None, window), + Run::Accent(_) => None, + }; + + let foreground = cx.theme().foreground; + let muted = cx.theme().muted_foreground; + let accent = cx.theme().primary; + let icon_color = if is_selected { foreground } else { muted }; + let title = cmd.title; + + // The title, with matched characters tinted + bolded. `flex_1().truncate()` + // keeps long titles from shoving the shortcut chip off the row. + let label = { + let base = div().flex_1().truncate().text_sm().text_color(foreground); + if ranges.is_empty() { + base.child(title) + } else { + let highlights = ranges.into_iter().map(move |r| { + ( + r, + HighlightStyle { + color: Some(accent), + font_weight: Some(FontWeight::SEMIBOLD), + ..Default::default() + }, + ) + }); + base.child(StyledText::new(title).with_highlights(highlights)) + } + }; + + // NOTE: do NOT call `.selected(..)` here β€” the `List` applies selection + // styling itself from the index it tracks. The id pairs the command with + // its section so the same command can appear in "Recent" and its category + // without colliding. + Some( + ListItem::new((cmd.id, ix.section)) + .suffix(move |_, _| match kbd.clone() { + Some(k) => k.into_any_element(), + None => div().into_any_element(), + }) + .child( + h_flex() + .w_full() + .gap_3() + .items_center() + .child(Icon::new(cmd.icon.clone()).size_4().text_color(icon_color)) + .child(label), + ), + ) + } + + fn render_empty( + &mut self, + _: &mut Window, + cx: &mut Context>, + ) -> impl IntoElement { + v_flex() + .py_8() + .gap_2() + .items_center() + .child( + Icon::new(IconName::Search) + .size_6() + .text_color(cx.theme().muted_foreground.opacity(0.5)), + ) + .child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child("No matching commands"), + ) + } + + fn set_selected_index( + &mut self, + ix: Option, + _: &mut Window, + _: &mut Context>, + ) { + self.selected = ix; + } + + fn confirm(&mut self, _: bool, _: &mut Window, _: &mut Context>) { + // The List only calls this when something is selected, but fall back to + // the first row defensively. + let ix = self.selected.or_else(|| self.first_row()); + if let Some(ix) = ix { + if let Some(hit) = self + .sections + .get(ix.section) + .and_then(|s| s.rows.get(ix.row)) + { + self.pending = Some((hit.cmd.run, hit.cmd.id)); + } + } + } +} + +// =========================================================================== +// 4. Shell integration β€” open/close, render the overlay, run commands. +// (These are `Shell` methods, split into this module like welcome.rs / +// settings_view.rs do; the fields live in `shell.rs`.) +// =========================================================================== + +/// How far the panel floats from the top of the window. +const TOP_OFFSET: f32 = 112.0; +const PANEL_WIDTH: f32 = 620.0; + +impl Shell { + /// ⌘K β€” toggle the palette. Bound in `main.rs`, wired in `shell.rs`'s render. + pub fn on_palette_toggle( + &mut self, + _: &TogglePalette, + window: &mut Window, + cx: &mut Context, + ) { + if self.palette.is_some() { + self.close_palette(window, cx); + } else { + self.open_palette(window, cx); + } + } + + /// Build a fresh palette (so the query always starts empty), pre-select the + /// first row, and focus the search input. + fn open_palette(&mut self, window: &mut Window, cx: &mut Context) { + let recents = self.recents.iter().copied().collect::>(); + let palette = + cx.new(|cx| ListState::new(PaletteDelegate::new(recents), window, cx).searchable(true)); + self.palette_sub = Some(cx.subscribe_in(&palette, window, Self::on_palette_event)); + palette.update(cx, |state, cx| { + let first = state.delegate().first_row(); + state.set_selected_index(first, window, cx); + state.focus(window, cx); + }); + self.palette = Some(palette); + cx.notify(); + } + + /// Close the palette and return focus to the shell so global hotkeys work. + pub fn close_palette(&mut self, window: &mut Window, cx: &mut Context) { + self.palette = None; + self.palette_sub = None; + window.focus(&self.focus_handle); + cx.notify(); + } + + /// Bridge the list's events back to the shell: run on confirm, close on cancel. + pub fn on_palette_event( + &mut self, + state: &Entity>, + event: &ListEvent, + window: &mut Window, + cx: &mut Context, + ) { + match event { + ListEvent::Confirm(_) => { + let chosen = state.update(cx, |s, _| s.delegate_mut().take_pending()); + // Close first, so the dispatched action lands on the shell's focus + // tree (not the palette's). + self.close_palette(window, cx); + if let Some((run, id)) = chosen { + self.record_recent(id); + self.run_command(run, window, cx); + } + } + ListEvent::Cancel => self.close_palette(window, cx), + ListEvent::Select(_) => {} + } + } + + fn record_recent(&mut self, id: &'static str) { + self.recents.retain(|r| *r != id); + self.recents.push_front(id); + self.recents.truncate(8); + } + + fn run_command(&mut self, run: Run, window: &mut Window, cx: &mut Context) { + match run { + Run::Action(make) => window.dispatch_action(make(), cx), + Run::Accent(accent) => self.set_accent(accent, cx), + } + } + + /// The overlay: a scrim with the floating panel, anchored near the top. + pub fn render_palette( + &self, + palette: &Entity>, + cx: &mut Context, + ) -> impl IntoElement { + let border = cx.theme().border; + + div() + .id("palette-scrim") + .absolute() + .inset_0() + .flex() + .flex_col() + .items_center() + .justify_start() + .pt(px(TOP_OFFSET)) + // A soft dark scrim that *darkens* behind the panel in BOTH modes (a + // theme `background` tint would wash out white in light mode). Kept a + // local constant β€” a scrim is conventionally mode-independent. + .bg(hsla(0.0, 0.0, 0.0, 0.45)) + // Click outside the panel to dismiss. + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, _, window, cx| this.close_palette(window, cx)), + ) + .child( + v_flex() + .w(px(PANEL_WIDTH)) + .max_w_full() + .max_h(px(440.0)) + .rounded_xl() + .border_1() + .border_color(border) + .bg(cx.theme().popover) + .shadow_lg() + .overflow_hidden() + // Swallow clicks inside the panel so they don't hit the scrim. + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .child( + List::new(palette) + .search_placeholder("Search commands…") + .max_h(px(360.0)), + ) + .child(self.render_palette_footer(cx)) + // Subtle, fast entrance β€” opacity only (the panel isn't + // absolutely positioned, so animating offset would be shaky). + .with_animation( + "palette-in", + Animation::new(Duration::from_millis(120)).with_easing(ease_out_quint()), + |el, t| el.opacity(t), + ), + ) + } + + /// The whisper-thin hint bar at the bottom of the panel. Delete this method's + /// call in `render_palette` if you want it even quieter. + fn render_palette_footer(&self, cx: &mut Context) -> impl IntoElement { + let border = cx.theme().border; + let muted = cx.theme().muted_foreground; + let surface = cx.theme().secondary; + + let chip = move |key: &str| { + div() + .px_1() + .rounded_sm() + .border_1() + .border_color(border) + .bg(surface) + .text_color(muted) + .child(key.to_string()) + }; + let hint = move |keys: Vec<&'static str>, label: &'static str| { + h_flex() + .gap_1() + .items_center() + .children(keys.into_iter().map(chip)) + .child(div().child(label)) + }; + + h_flex() + .w_full() + .px_3() + .py_1p5() + .gap_3() + .items_center() + .border_t_1() + .border_color(border) + .text_xs() + .text_color(muted) + .child(hint(vec!["↑", "↓"], "Navigate")) + .child(hint(vec!["↡"], "Open")) + .child(hint(vec!["esc"], "Close")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn score(q: &str, hay: &str) -> Option { + fuzzy(q, hay).map(|(s, _)| s) + } + + #[test] + fn matches_are_subsequences() { + assert!(score("os", "Open Settings").is_some()); // initials + assert!(score("settings", "Open Settings").is_some()); + assert!(score("", "anything").is_some()); // empty query matches + assert!(score("xyz", "Open Settings").is_none()); // not a subsequence + assert!(score("ngs", "Open Settings").is_some()); // mid-word run + } + + #[test] + fn case_insensitive() { + assert!(score("OPEN", "open settings").is_some()); + assert!(score("open", "OPEN SETTINGS").is_some()); + } + + #[test] + fn word_boundary_beats_scatter() { + // "os" as two word-initials should outscore the same letters mid-word. + let initials = score("os", "Open Settings").unwrap(); + let scattered = score("os", "Booomerang Stew").unwrap(); + assert!(initials > scattered, "{initials} !> {scattered}"); + } + + #[test] + fn prefix_and_consecutive_win() { + // A contiguous prefix run outscores the same letters scattered mid-word. + let prefix = score("set", "Settings").unwrap(); + let scattered = score("set", "Basement").unwrap(); // s..e....t, all mid-word + assert!(prefix > scattered, "{prefix} !> {scattered}"); + + // Word-initials are also a strong signal β€” on par with a prefix run. + let initials = score("os", "Open Settings").unwrap(); + let midword = score("os", "Chaos Theory").unwrap(); // 'o','s' inside "Chaos" + assert!(initials > midword, "{initials} !> {midword}"); + } + + #[test] + fn highlight_ranges_point_at_matched_chars() { + let (_, ranges) = fuzzy("os", "Open Settings").unwrap(); + // O at byte 0, S at byte 5. + let matched: String = ranges.iter().map(|r| &"Open Settings"[r.clone()]).collect(); + assert_eq!(matched, "OS"); + } + + #[test] + fn keyword_match_included_without_title_highlight() { + let settings = commands().into_iter().find(|c| c.id == "settings").unwrap(); + // "prefs" only matches the keyword, not the title β€” included, no highlight. + let (_, ranges) = score_command("prefs", &settings).unwrap(); + assert!(ranges.is_empty()); + // "set" matches the title β€” included, with highlight ranges. + let (_, ranges) = score_command("set", &settings).unwrap(); + assert!(!ranges.is_empty()); + } +} diff --git a/src/main.rs b/src/main.rs index 52d16db..d75257d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,6 +7,7 @@ //! Fork checklist: rename the crate in `Cargo.toml`, change `APP_NAME` and the //! bundle identifier, swap `assets/icon.png`, then start editing the views. +mod command_palette; mod settings; mod settings_view; mod shell; @@ -31,7 +32,15 @@ pub const APP_NAME: &str = "Deck"; // to, hang a menu item off of, and handle in a view or globally. Add your own here. gpui::actions!( deck, - [Quit, About, OpenSettings, ToggleTheme, NewItem, GoBack] + [ + Quit, + About, + OpenSettings, + ToggleTheme, + NewItem, + GoBack, + TogglePalette + ] ); fn main() { @@ -57,6 +66,7 @@ fn main() { KeyBinding::new("secondary-,", OpenSettings, None), KeyBinding::new("secondary-shift-d", ToggleTheme, None), KeyBinding::new("secondary-[", GoBack, None), + KeyBinding::new("secondary-k", TogglePalette, None), ]); // 4. Global action handlers. View-local actions (NewItem, OpenSettings, @@ -96,7 +106,11 @@ fn main() { }, Menu { name: "View".into(), - items: vec![MenuItem::action("Toggle Light / Dark", ToggleTheme)], + items: vec![ + MenuItem::action("Command Palette…", TogglePalette), + MenuItem::separator(), + MenuItem::action("Toggle Light / Dark", ToggleTheme), + ], }, ]); diff --git a/src/shell.rs b/src/shell.rs index 7e603a6..1c867d9 100644 --- a/src/shell.rs +++ b/src/shell.rs @@ -4,17 +4,21 @@ //! `settings_view.rs` as `impl Shell` methods (Rust lets you split an inherent //! impl across modules), so this file stays focused on state + navigation. +use std::collections::VecDeque; + use gpui::{ div, App, AppContext, Context, Entity, FocusHandle, Focusable, FontWeight, InteractiveElement, - IntoElement, ParentElement, Render, Styled, Window, + IntoElement, ParentElement, Render, Styled, Subscription, Window, }; use gpui_component::{ button::{Button, ButtonVariants}, h_flex, input::{InputEvent, InputState}, + list::ListState, v_flex, ActiveTheme, IconName, TitleBar, }; +use crate::command_palette::PaletteDelegate; use crate::settings::{Settings, ThemeModePref}; use crate::theme::{self, Accent}; use crate::{GoBack, NewItem, OpenSettings, ToggleTheme, APP_NAME}; @@ -31,6 +35,13 @@ pub struct Shell { pub settings: Settings, pub name_input: Entity, pub created: usize, + /// The command palette's list, while it's open (rebuilt fresh each open so the + /// query always starts empty). `None` = closed. See `command_palette.rs`. + pub palette: Option>>, + /// Keeps the palette's event subscription alive while it's open. + pub palette_sub: Option, + /// Recently-run command ids, most-recent first β€” the palette's "Recent" group. + pub recents: VecDeque<&'static str>, } impl Shell { @@ -59,6 +70,10 @@ impl Shell { settings, name_input, created: 0, + palette: None, + palette_sub: None, + // Seed a few sensible "recents" so the palette is never blank on first open. + recents: ["settings", "new", "theme"].into_iter().collect(), } } @@ -191,16 +206,24 @@ impl Render for Shell { Route::Welcome => self.render_welcome(cx).into_any_element(), Route::Settings => self.render_settings(window, cx).into_any_element(), }; + // The command palette overlay (when open), painted over everything else. + let palette = self + .palette + .clone() + .map(|p| self.render_palette(&p, cx).into_any_element()); v_flex() .size_full() + .relative() .bg(background) .track_focus(&self.focus_handle) .on_action(cx.listener(Self::on_new_item)) .on_action(cx.listener(Self::on_open_settings)) .on_action(cx.listener(Self::on_go_back)) .on_action(cx.listener(Self::on_toggle_theme)) + .on_action(cx.listener(Self::on_palette_toggle)) .child(title_bar) .child(content) + .children(palette) } }