Compare commits

...
2 Commits
Author SHA1 Message Date
Michael ShimelesandClaude Opus 5.5 2bedb60151 fix(app): palette review fixes
- Typed command lines run as typed, so paths and titles keep their case.
- Rank after the text field updates, so Enter in the same frame as typing
  or a paste runs the new search.
- Recent picks show their live row (current marker, shortcut hint) and
  deleted themes and saved layouts drop out.
- Keep the card inside short windows; paint it on the dim layer once its
  height is known.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 20:30:55 -04:00
Michael ShimelesandClaude Opus 5.5 361c4b1eb0 feat(app): Cmd+K command palette
A centred, theme-matched overlay (Cmd+K on macOS, Ctrl+K elsewhere) that
fuzzy-searches every function code, tickers, new panels, layout presets and
saved layouts, themes, Settings tabs and workspace actions. Rows show their
shortcut; recent picks come first and persist in palette.json. Arrows, Tab,
Enter and Esc drive it; unmatched input runs as a command line.

The model (items, ranking, recents) lives in app/src/palette.rs and the
overlay in app/src/app/palette_view.rs. CommandPalette is a new rebindable
action, so it appears in the hotkey table.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 20:30:55 -04:00
7 changed files with 980 additions and 4 deletions

No files matched your search

+6
View File
@@ -154,6 +154,11 @@ gets the native title bar with traffic lights, window vibrancy behind the sideba
read-only or disabled, are marked read-only and cannot preview. An optional maximum order value
refuses large orders. The sample portfolio simulates fills locally. See
[Trading](#trading-opt-in).
- **Command palette** — ⌘K (Ctrl+K on Linux and Windows) opens a centred, theme-matched palette
that fuzzy-searches every function code (`PORT`, `POS`, `ORD`, `OMON`, `THEME`, …), tickers
(load the quote and chart), new panels, layout presets and saved layouts, themes, Settings tabs
and workspace actions (move the sidebar, open Brokerage settings). Each row shows its shortcut;
recent picks come first. ↑↓ select, ↵ open, Esc close.
- **Hotkeys and preferences** — every hotkey is rebindable from a native shortcut table in
Settings › Hotkeys and Help (click a binding, press the new keys); defaults for new charts and
order books; everything persists in `prefs.json`.
@@ -254,6 +259,7 @@ Settings › Hotkeys. On Linux and Windows ⌘ is Ctrl and ⌥ is Alt.
| Keys | Action |
|---|---|
| ⌘K (Ctrl+K) | command palette: fuzzy search over commands, tickers, panels, layouts, themes, settings and actions |
| F1 · F2 · F3 · F4 · F5 · F6 · F12 | help · Congress feed · politicians · alerts · watchlist · refresh filings · frame timing |
| F7 · F8 · F9 | brokerage portfolio · positions · orders |
| Tab · ⇧Tab | next · previous pane |
+18
View File
@@ -8,6 +8,7 @@ mod broker;
mod chrome;
mod hotkeys_view;
mod options_view;
mod palette_view;
mod panels;
mod portfolio_view;
mod settings_view;
@@ -504,6 +505,8 @@ pub struct TerminalApp {
broker: broker::BrokerState,
/// Order ticket and trading dialogs.
trading: trading::TradingState,
/// ⌘K command palette.
palette: crate::palette::Palette,
}
/// Resolve a run of words (`BTCUSD CRNCY AAPL US EQUITY MSFT`) into market keys.
@@ -629,6 +632,7 @@ impl TerminalApp {
vibrancy: false,
chrome: None,
last_chrome_poll: None,
palette: crate::palette::Palette::load(&data_dir().join("palette.json")),
};
let mut doc = None;
if let Some(s) = &settings {
@@ -1122,6 +1126,18 @@ impl TerminalApp {
}
return;
}
// ⌘K (Ctrl+K) opens and closes the palette from anywhere, even from a text field.
if let Some(&(key, m)) =
presses.iter().find(|(k, m)| self.prefs.hotkeys.action_for(*k, *m) == Some(prefs::Action::CommandPalette))
{
ctx.input_mut(|i| i.consume_key(m, key));
self.palette.toggle();
return;
}
// While it is open, the palette owns the keyboard.
if self.palette.open {
return;
}
let cmd_id = egui::Id::new("command-line");
let focused = ctx.memory(|m| m.focused());
let cmd_focused = focused == Some(cmd_id);
@@ -1247,6 +1263,7 @@ impl TerminalApp {
self.status = format!("Saved layout {:?}", self.layout_name);
}
A::Settings => self.apply(Function::Settings),
A::CommandPalette => self.palette.toggle(),
A::DrawTrendline => self.with_chart(|c, _| c.start_tool(Tool::Trendline)),
A::DrawHorizontal => self.with_chart(|c, _| c.start_tool(Tool::Horizontal)),
A::DrawVertical => self.with_chart(|c, _| c.start_tool(Tool::Vertical)),
@@ -1435,6 +1452,7 @@ impl eframe::App for TerminalApp {
);
self.hotkeys(&ctx);
self.command_palette(&ctx);
let mut sidebar_cmds = Vec::new();
if sb_w >= 1.0 {
// The sidebar slides in from its edge.
+547
View File
@@ -0,0 +1,547 @@
//! Command palette overlay (⌘K, Ctrl+K elsewhere): the live catalogue of items built
//! from the app's state, the centred search card, and running the picked item.
use egui::{Color32, Id, Key, Modifiers, Rect, Sense, Stroke, StrokeKind, Ui, UiBuilder, pos2, vec2};
use super::TerminalApp;
use super::settings_view::SettingsTab;
use crate::command::{self, Function};
use crate::layout::Layout;
use crate::palette::{Item, Kind, Target};
use crate::prefs::Action;
use crate::theme::{self, dp, mono, muted, text, ui_font};
use crate::widgets;
const INPUT_ID: &str = "command-palette-input";
const ROW_H: f32 = 34.0;
const VISIBLE_ROWS: usize = 10;
/// Function codes offered by the palette: code, description (empty uses the command
/// line's own hint), extra search words, and the action whose shortcut is shown.
const COMMANDS: &[(&str, &str, &str, Option<Action>)] = &[
("PORT", "", "portfolio prtu brokerage holdings value", Some(Action::Portfolio)),
("ACCT", "", "accounts balances brokerage", None),
("POS", "", "positions holdings brokerage", Some(Action::Positions)),
("ORD", "", "orders blotter brokerage", Some(Action::Orders)),
("ACTV", "", "activity transactions dividends brokerage", None),
("TRADE", "", "order ticket buy sell emsx", None),
("PORT REFRESH", "", "brokerage sync reload", None),
("PORT CONNECT", "", "brokerage link snaptrade", None),
("OMON", "", "options chain calls puts greeks", Some(Action::NewOptions)),
("CONG", "Congressional trades feed", "congress disclosures", Some(Action::Congress)),
("POL", "Politicians", "congress members profile", Some(Action::Politicians)),
("ALRT", "", "alerts tracked filings", Some(Action::Alerts)),
("REFRESH", "", "congress disclosures sync", Some(Action::Refresh)),
("WL", "", "watchlist monitor", Some(Action::Watchlist)),
("N", "", "news headlines", None),
("OB", "", "order book depth dom", None),
("WEI", "", "world equity indices markets", None),
("GC", "", "yield curve treasury rates", None),
("SECF", "Security search", "search find", None),
("LAYOUTS", "", "saved layouts workspaces", None),
("LAYOUT NEW", "Start a new saved layout", "workspace", None),
("SPLIT", "", "split right pane", Some(Action::SplitRight)),
("SPLIT DOWN", "", "split below pane", Some(Action::SplitDown)),
("POP", "", "pop out window undock", Some(Action::PopOut)),
("CLOSE", "", "close pane", Some(Action::ClosePane)),
("LINK OFF", "", "unlink colour group", None),
("THEME NEXT", "Cycle to the next theme", "appearance", Some(Action::NextTheme)),
("THEME LIGHT", "Use the light theme", "appearance", None),
("THEME DARK", "Use the dark theme", "appearance", None),
("THEME SYSTEM", "Follow the OS appearance", "appearance auto", None),
("THEME COMPACT", "Compact density", "appearance spacing", None),
("THEME COMFORTABLE", "Comfortable density", "appearance spacing", None),
("SIDEBAR LEFT", "Sidebar on the left", "dock side", None),
("SIDEBAR RIGHT", "Sidebar on the right", "dock side", None),
("PREFS", "", "settings preferences config", Some(Action::Settings)),
("HELP", "", "help functions reference", Some(Action::Help)),
("CANDLE", "", "chart type candlesticks", None),
("BAR", "", "chart type ohlc", None),
("LINE", "", "chart type", None),
("AREA", "", "chart type mountain", None),
("LOG", "", "log scale chart", Some(Action::LogScale)),
("RSI", "", "indicator oscillator", None),
("MACD", "", "indicator oscillator", None),
("BB", "", "bollinger bands indicator", None),
("SMA 50", "", "moving average indicator", None),
("SMA 200", "", "moving average indicator", None),
("EMA 20", "", "moving average indicator", None),
("VWAP", "", "indicator volume weighted", None),
("CLEAR", "", "indicators remove", None),
("CMP", "", "compare comparison remove", None),
("TL", "", "trendline draw", Some(Action::DrawTrendline)),
("HL", "", "horizontal line draw", Some(Action::DrawHorizontal)),
("VL", "", "vertical line draw", Some(Action::DrawVertical)),
("FIB", "", "fibonacci retracement draw", Some(Action::DrawFib)),
("MAGNET", "", "snap ohlc drawings", Some(Action::Magnet)),
("CLEAR DRAW", "", "drawings remove", None),
("1", "", "interval timeframe minute", None),
("5", "", "interval timeframe minutes", None),
("15", "", "interval timeframe minutes", None),
("1H", "", "interval timeframe hour", None),
("4H", "", "interval timeframe hours", None),
("1D", "", "interval timeframe daily", None),
("1W", "", "interval timeframe weekly", None),
("1M", "", "interval timeframe monthly", None),
("RANGE 5D", "", "quick range history", None),
("RANGE 1M", "", "quick range history month", None),
("RANGE 3M", "", "quick range history months", None),
("RANGE 6M", "", "quick range history months", None),
("YTD", "", "quick range year to date", None),
("RANGE 1Y", "", "quick range history year", None),
("RANGE 5Y", "", "quick range history years", None),
("RANGE ALL", "", "quick range full history", None),
];
/// Workspace actions run through their hotkey handler, with extra search words.
const ACTIONS: &[(Action, &str)] = &[
(Action::ToggleSidebar, "show hide sidebar"),
(Action::SwapSidebarSide, "swap flip sidebar side left right"),
(Action::NextPane, "focus cycle"),
(Action::PrevPane, "focus cycle"),
(Action::NewTab, "chart tab"),
(Action::SaveLayout, "layout workspace"),
(Action::ResetView, "chart zoom fit"),
(Action::FrameTiming, "fps performance perf"),
];
fn settings_blurb(tab: SettingsTab) -> &'static str {
match tab {
SettingsTab::Appearance => "Theme, light and dark choices, sidebar, vibrancy",
SettingsTab::Themes => "Edit, duplicate, import and export themes",
SettingsTab::Hotkeys => "Rebind keyboard shortcuts",
SettingsTab::Panels => "Defaults for new charts and order books",
SettingsTab::Layouts => "Saved layouts and presets",
SettingsTab::Brokerage => "SnapTrade keys and connected brokerages",
SettingsTab::Trading => "Enable trading, order limits and confirmations",
}
}
/// The static part of the catalogue: commands, panels, actions, presets and settings tabs.
pub(super) fn static_items() -> Vec<Item> {
let mut v = Vec::new();
for &(code, desc, words, hotkey) in COMMANDS {
let subtitle = if desc.is_empty() { command::hint(code) } else { desc.to_string() };
let mut it = Item::new(Kind::Command, code, subtitle, Target::Run(code.into())).keywords(words);
it.hotkey = hotkey;
v.push(it);
}
for a in Action::ALL {
if let Some(spec) = a.panel_spec() {
let it = Item::new(Kind::Panel, a.label(), format!("NEW {spec}"), Target::Run(format!("NEW {spec}")));
v.push(it.keywords("panel pane add open").hotkey(a));
}
}
v.push(
Item::new(Kind::Panel, "New Portfolio pane", "NEW PORT", Target::Run("NEW PORT".into()))
.keywords("panel pane brokerage"),
);
v.push(
Item::new(
Kind::Action,
"Open Brokerage settings",
"BROKER · SnapTrade keys and connections",
Target::Run("BROKER".into()),
)
.keywords("snaptrade keys connect broker"),
);
for &(a, words) in ACTIONS {
v.push(Item::new(Kind::Action, a.label(), a.category(), Target::Hotkey(a)).keywords(words).hotkey(a));
}
for l in Layout::ALL {
let n = l.slots();
v.push(
Item::new(
Kind::Layout,
format!("Grid preset {}", l.label()),
format!("{n} pane{} · LAYOUT {}", if n == 1 { "" } else { "s" }, l.label()),
Target::Run(format!("LAYOUT {}", l.label())),
)
.keywords("grid arrange panes"),
);
}
for tab in SettingsTab::ALL {
v.push(
Item::new(
Kind::Settings,
format!("Settings › {}", tab.label()),
settings_blurb(tab),
Target::Settings(tab.label().into()),
)
.keywords("settings preferences"),
);
}
v
}
fn ticker_item(key: &str) -> Item {
let name = tr_core::Security::parse(key).and_then(|s| s.name()).unwrap_or("");
Item::new(
Kind::Ticker,
key,
if name.is_empty() { "Load in the focused chart" } else { name },
Target::Run(key.into()),
)
.keywords(name)
}
fn kind_color(k: Kind) -> Color32 {
match k {
Kind::Command => theme::amber(),
Kind::Ticker => theme::cyan(),
Kind::Panel => theme::accent(),
Kind::Layout => theme::series(2),
Kind::Theme => theme::series(4),
Kind::Settings => theme::muted(),
Kind::Action => theme::up(),
}
}
impl TerminalApp {
/// Every item the palette can find right now, including tickers for the query.
pub(super) fn palette_items(&self) -> Vec<Item> {
let mut v = static_items();
for (i, name) in self.layout_names.iter().enumerate() {
let current = name.eq_ignore_ascii_case(&self.layout_name);
let mut it = Item::new(
Kind::Layout,
name.clone(),
if current { "Saved layout · current" } else { "Saved layout" },
Target::SwitchLayout(name.clone()),
)
.keywords("layout workspace switch");
it.hotkey = Action::ALL.into_iter().find(|a| a.layout_slot() == Some(i));
v.push(it);
}
for name in self.theme_choices() {
let mut sub = match crate::themes::find(&self.themes, &name) {
_ if name.eq_ignore_ascii_case(crate::themes::SYSTEM) => "Follow the OS appearance".to_string(),
Some(t) if t.dark => "Dark theme".into(),
Some(_) => "Light theme".into(),
None => "Theme".into(),
};
if name.eq_ignore_ascii_case(&self.prefs.appearance.theme) {
sub.push_str(" · current");
}
v.push(Item::new(Kind::Theme, name.clone(), sub, Target::Theme(name)).keywords("theme appearance colours"));
}
for key in &self.watchlist {
v.push(ticker_item(key).keywords("watchlist"));
}
let q = self.palette.query.trim();
if !q.is_empty() {
for l in tr_core::catalog::search(q, 12) {
v.push(ticker_item(&l.key()));
}
// Whatever the command line would do with the query, offered as-is.
match command::parse(q) {
Some(command::Command::Symbol(key)) => v.push(ticker_item(&key)),
// Kept as typed: arguments such as paths and titles are case-sensitive.
Some(_) if !v.iter().any(|it| matches!(&it.target, Target::Run(l) if l.eq_ignore_ascii_case(q))) => {
v.push(Item::new(Kind::Command, q, command::hint(q), Target::Run(q.into())));
}
Some(_) => {}
None => {}
}
}
v
}
/// Run a picked item.
fn run_palette_target(&mut self, t: Target) {
match t {
Target::Run(line) => self.execute(&line),
Target::Hotkey(a) => self.run_hotkey(a),
Target::Settings(label) => {
if let Some(tab) = SettingsTab::ALL.into_iter().find(|t| t.label() == label) {
self.settings_tab = tab;
}
self.apply(Function::Settings);
}
Target::SwitchLayout(name) => self.switch_layout(&name),
Target::Theme(name) => {
if !self.select_theme(&name) {
self.status = format!("No theme {name:?}");
}
}
}
}
/// Keyboard handling, the overlay, and running the pick. Runs before the panes so
/// arrows, Enter and Esc go to the palette while it is open.
pub(super) fn command_palette(&mut self, ctx: &egui::Context) {
let input_id = Id::new(INPUT_ID);
if !self.palette.open {
// Give the keyboard back once closed (by ⌘K, a pick or Esc).
if ctx.memory(|m| m.has_focus(input_id)) {
ctx.memory_mut(|m| m.surrender_focus(input_id));
}
return;
}
let (up, down, enter, esc) = ctx.input_mut(|i| {
let up = i.consume_key(Modifiers::NONE, Key::ArrowUp) || i.consume_key(Modifiers::SHIFT, Key::Tab);
let down = i.consume_key(Modifiers::NONE, Key::ArrowDown) || i.consume_key(Modifiers::NONE, Key::Tab);
let enter = i.consume_key(Modifiers::NONE, Key::Enter);
let esc = i.consume_key(Modifiers::NONE, Key::Escape);
(up, down, enter, esc)
});
let keyed = up || down;
let screen = ctx.content_rect();
let mut picked = None;
let mut close = esc;
let results = egui::Area::new(Id::new("command-palette"))
.order(egui::Order::Foreground)
.fixed_pos(screen.min)
.show(ctx, |ui| {
let (_, bg) = ui.allocate_exact_size(screen.size(), Sense::click());
let w = (screen.width() - dp(40.0)).clamp(320.0, 660.0);
let top = screen.top() + (screen.height() * 0.14).clamp(dp(16.0), dp(160.0));
let left = screen.center().x - w / 2.0;
let pad = vec2(dp(10.0), dp(6.0));
let field_h = dp(46.0);
let footer_h = dp(28.0);
let field = Rect::from_min_size(pos2(left, top) + pad, vec2(w - 2.0 * pad.x, field_h));
// The query first, so results (and Enter) see this frame's typing.
let edit = egui::TextEdit::singleline(&mut self.palette.query)
.id(input_id)
.font(ui_font(16.0))
.frame(egui::Frame::NONE)
.text_color(text())
.hint_text("Search commands, tickers, panels, layouts, themes, settings…")
.desired_width(field.width() - dp(36.0));
let field_rect = Rect::from_min_max(pos2(field.left() + dp(28.0), field.top()), field.max);
let resp = ui
.scope_builder(
UiBuilder::new().max_rect(field_rect).layout(egui::Layout::left_to_right(egui::Align::Center)),
|ui| ui.add(edit),
)
.inner;
if self.palette.just_opened || !resp.has_focus() {
resp.request_focus();
self.palette.just_opened = false;
}
if resp.changed() {
self.palette.selected = 0;
}
let items = self.palette_items();
let results = self.palette.results(&items);
let recents = if self.palette.query.trim().is_empty() {
crate::palette::live_recents(&items, &self.palette.recents).len()
} else {
0
};
if up {
self.palette.step(-1, results.len());
}
if down {
self.palette.step(1, results.len());
}
self.palette.selected = self.palette.selected.min(results.len().saturating_sub(1));
if enter {
picked = Some(self.palette.selected);
}
// Size the list to the results, but keep the card inside the window.
let rows = results.len().clamp(1, VISIBLE_ROWS);
let headers = if recents > 0 { 2.0 * dp(22.0) } else { 0.0 };
let room = screen.bottom() - dp(12.0) - field.bottom() - footer_h - 2.0 * pad.y;
let list_h =
(rows as f32 * dp(ROW_H) + headers).min(VISIBLE_ROWS as f32 * dp(ROW_H)).min(room).max(dp(ROW_H));
let card =
Rect::from_min_size(pos2(left, top), vec2(w, field_h + list_h + footer_h + 2.0 * pad.y + dp(4.0)));
if bg.clicked() && bg.interact_pointer_pos().is_some_and(|p| !card.contains(p)) {
close = true;
}
// Dim everything beneath, then the card, on a layer above the panes and
// below this one.
let under =
ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Middle, Id::new("command-palette-dim")));
let dark = theme::palette().dark;
under.rect_filled(screen, 0.0, Color32::from_black_alpha(if dark { 120 } else { 70 }));
widgets::card_on(&under, card, 1.0);
let inner = card.shrink2(pad);
widgets::draw_icon(
ui.painter(),
Rect::from_center_size(pos2(field.left() + dp(12.0), field.center().y), vec2(16.0, 16.0)),
widgets::Icon::Search,
muted(),
);
ui.painter().hline(inner.x_range(), field.bottom(), Stroke::new(1.0, theme::border()));
let list =
Rect::from_min_size(pos2(inner.left(), field.bottom() + dp(4.0)), vec2(inner.width(), list_h));
let mut list_ui = ui.new_child(UiBuilder::new().max_rect(list).id_salt("palette-list"));
egui::ScrollArea::vertical().max_height(list_h).auto_shrink([false, true]).show(&mut list_ui, |ui| {
ui.spacing_mut().item_spacing.y = 0.0;
if results.is_empty() {
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), dp(ROW_H)), Sense::hover());
ui.painter().text(
pos2(r.left() + dp(10.0), r.center().y),
egui::Align2::LEFT_CENTER,
"No matches. Enter runs it as a command if it parses.",
ui_font(13.0),
muted(),
);
}
for (i, it) in results.iter().enumerate() {
if recents > 0 && (i == 0 || i == recents) {
section_header(ui, if i == 0 { "Recent" } else { "All" });
}
if self.palette_row(ui, it, i == self.palette.selected, keyed) {
picked = Some(i);
}
}
});
let foot = Rect::from_min_max(
pos2(inner.left(), inner.bottom() - footer_h),
pos2(inner.right(), inner.bottom()),
);
let p = ui.painter();
p.hline(inner.x_range(), foot.top(), Stroke::new(1.0, theme::border()));
p.text(
pos2(foot.left() + dp(4.0), foot.center().y),
egui::Align2::LEFT_CENTER,
"↑↓ select ↵ open esc close",
mono(11.0),
muted(),
);
p.text(
pos2(foot.right() - dp(4.0), foot.center().y),
egui::Align2::RIGHT_CENTER,
format!("{} result{}", results.len(), if results.len() == 1 { "" } else { "s" }),
mono(11.0),
muted(),
);
results
})
.inner;
if let Some(item) = picked.and_then(|i| results.get(i)).cloned() {
self.palette.close();
self.palette.record(&item);
self.run_palette_target(item.target);
} else if picked.is_some() && !self.palette.query.trim().is_empty() {
// Nothing matched: behave like the command line.
let line = self.palette.query.trim().to_string();
self.palette.close();
self.execute(&line);
} else if close {
self.palette.close();
}
if !self.palette.open {
ctx.memory_mut(|m| m.surrender_focus(input_id));
}
}
/// One result row: kind badge, title, subtitle and shortcut caps. Returns whether it
/// was clicked.
fn palette_row(&self, ui: &mut Ui, it: &Item, selected: bool, keyed: bool) -> bool {
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), dp(ROW_H)), Sense::click());
if selected && keyed {
resp.scroll_to_me(None);
}
widgets::hover_wash(ui, r, &resp, selected);
let p = ui.painter();
let cy = r.center().y;
// Kind badge.
let kc = kind_color(it.kind);
let badge_g = p.layout_no_wrap(it.kind.label().to_string(), mono(9.5), kc);
let badge = Rect::from_min_size(pos2(r.left() + dp(8.0), cy - 9.0), vec2(dp(64.0), 18.0));
p.rect_filled(badge, 4.0, kc.gamma_multiply(0.14));
p.rect_stroke(badge, 4.0, Stroke::new(1.0, kc.gamma_multiply(0.5)), StrokeKind::Inside);
p.galley(pos2(badge.center().x - badge_g.size().x / 2.0, cy - badge_g.size().y / 2.0), badge_g, kc);
// Shortcut hint on the right.
let mut right = r.right() - dp(8.0);
if let Some(chord) = it.hotkey.and_then(|a| self.prefs.hotkeys.chord(a)) {
right -= super::hotkeys_view::paint_key_caps(p, right, cy, &chord.tokens()) + dp(10.0);
}
// Title then subtitle, clipped before the shortcut.
let x = badge.right() + dp(12.0);
let clip = Rect::from_min_max(pos2(x, r.top()), pos2(right.max(x), r.bottom()));
let p = p.with_clip_rect(clip.intersect(p.clip_rect()));
let title_font = if it.kind == Kind::Command { mono(13.0) } else { ui_font(13.5) };
let title = p.layout_no_wrap(it.title.clone(), title_font, text());
let tw = title.size().x;
p.galley(pos2(x, cy - title.size().y / 2.0), title, text());
if !it.subtitle.is_empty() {
p.text(pos2(x + tw + dp(10.0), cy), egui::Align2::LEFT_CENTER, &it.subtitle, ui_font(12.0), muted());
}
resp.clicked()
}
}
fn section_header(ui: &mut Ui, label: &str) {
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), dp(22.0)), Sense::hover());
ui.painter().text(
pos2(r.left() + dp(8.0), r.center().y + 2.0),
egui::Align2::LEFT_CENTER,
label.to_uppercase(),
mono(10.0),
muted(),
);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::palette::rank;
#[test]
fn every_palette_command_parses() {
for &(code, ..) in COMMANDS {
assert!(command::parse(code).is_some(), "{code} does not parse");
}
for it in static_items() {
if let Target::Run(line) = &it.target {
assert!(command::parse(line).is_some(), "{line} does not parse");
}
}
}
#[test]
fn catalogue_covers_commands_panels_actions_and_settings() {
let items = static_items();
let has = |kind: Kind, title: &str| items.iter().any(|i| i.kind == kind && i.title == title);
for code in ["PORT", "POS", "ORD", "OMON", "THEME NEXT", "CONG", "WEI", "GC", "HELP"] {
assert!(has(Kind::Command, code), "{code}");
}
assert!(has(Kind::Panel, "New Options chain pane"));
assert!(has(Kind::Action, "Move sidebar to the other side"));
assert!(has(Kind::Action, "Open Brokerage settings"));
assert!(has(Kind::Layout, "Grid preset 4"));
for tab in SettingsTab::ALL {
assert!(has(Kind::Settings, &format!("Settings › {}", tab.label())));
}
assert!(
items.iter().all(|i| i.target != Target::Hotkey(Action::CommandPalette)),
"the palette does not list itself"
);
// Function codes and their shortcuts.
let pos = items.iter().find(|i| i.title == "POS").unwrap();
assert_eq!(pos.hotkey, Some(Action::Positions));
assert!(pos.subtitle.contains("positions"), "{}", pos.subtitle);
}
#[test]
fn typical_queries_find_their_item() {
let items = static_items();
let top = |q: &str| rank(&items, &[], q).first().map(|i| i.title.clone()).unwrap_or_default();
assert_eq!(top("omon"), "OMON");
assert_eq!(top("port"), "PORT");
assert_eq!(top("swap sidebar"), "Move sidebar to the other side");
assert_eq!(top("hotkeys"), "Settings › Hotkeys");
assert_eq!(top("theme"), "THEME NEXT");
let best2 = |q: &str| rank(&items, &[], q).into_iter().take(2).map(|i| i.title).collect::<Vec<_>>();
let b = best2("brokerage settings");
assert!(b.contains(&"Open Brokerage settings".to_string()) && b.contains(&"Settings › Brokerage".to_string()));
let b = best2("yield curve");
assert!(b.contains(&"GC".to_string()), "{b:?}");
}
}
+2 -2
View File
@@ -24,7 +24,7 @@ pub enum SettingsTab {
}
impl SettingsTab {
const ALL: [SettingsTab; 7] = [
pub(super) const ALL: [SettingsTab; 7] = [
SettingsTab::Appearance,
SettingsTab::Themes,
SettingsTab::Hotkeys,
@@ -34,7 +34,7 @@ impl SettingsTab {
SettingsTab::Trading,
];
fn label(self) -> &'static str {
pub(super) fn label(self) -> &'static str {
match self {
SettingsTab::Appearance => "Appearance",
SettingsTab::Themes => "Themes",
+1
View File
@@ -10,6 +10,7 @@ mod dock;
mod layout;
mod layouts;
mod markets_view;
mod palette;
mod prefs;
mod theme;
mod themes;
+397
View File
@@ -0,0 +1,397 @@
//! Command palette (⌘K, Ctrl+K elsewhere): fuzzy search over every command, ticker,
//! panel, layout, theme, settings tab and workspace action.
//!
//! This module is the model: items, ranking and the persisted list of recent picks. The
//! overlay and the catalogue of live items (themes, saved layouts, watchlist) live in
//! `app/palette_view.rs`.
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::prefs::Action;
/// How many recent picks are remembered.
pub const MAX_RECENTS: usize = 10;
/// Rows shown for a query (the full list is still searchable).
pub const MAX_RESULTS: usize = 60;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Kind {
Command,
Ticker,
Panel,
Layout,
Theme,
Settings,
Action,
}
impl Kind {
pub fn label(self) -> &'static str {
match self {
Kind::Command => "CMD",
Kind::Ticker => "TICKER",
Kind::Panel => "PANEL",
Kind::Layout => "LAYOUT",
Kind::Theme => "THEME",
Kind::Settings => "SETTINGS",
Kind::Action => "ACTION",
}
}
}
/// What picking an item does.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum Target {
/// Run a command line, exactly as if typed and sent with <GO>.
Run(String),
/// Run a rebindable action.
Hotkey(Action),
/// Open Settings on the tab with this label.
Settings(String),
/// Switch to a saved layout by name.
SwitchLayout(String),
/// Select a theme (or System) by exact name.
Theme(String),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Item {
pub kind: Kind,
pub title: String,
pub subtitle: String,
/// Extra words that match but are not shown (aliases, full names).
#[serde(default)]
pub keywords: String,
/// The action whose binding is shown as the shortcut hint.
#[serde(default)]
pub hotkey: Option<Action>,
pub target: Target,
}
impl Item {
pub fn new(kind: Kind, title: impl Into<String>, subtitle: impl Into<String>, target: Target) -> Self {
Self { kind, title: title.into(), subtitle: subtitle.into(), keywords: String::new(), hotkey: None, target }
}
pub fn keywords(mut self, k: impl Into<String>) -> Self {
self.keywords = k.into();
self
}
pub fn hotkey(mut self, a: Action) -> Self {
self.hotkey = Some(a);
self
}
/// Two items are the same pick when they do the same thing.
pub fn same(&self, other: &Item) -> bool {
self.target == other.target
}
}
/// Fuzzy subsequence score of `needle` in `hay` (case-insensitive), or `None` if some
/// character is missing. Rewards matches at word starts, runs of consecutive characters
/// and an exact or prefix match; penalises gaps.
pub fn fuzzy(needle: &str, hay: &str) -> Option<i32> {
let n: Vec<char> = needle.chars().flat_map(char::to_lowercase).collect();
if n.is_empty() {
return Some(0);
}
let h: Vec<char> = hay.chars().collect();
let lower: Vec<char> = h.iter().map(|c| c.to_lowercase().next().unwrap_or(*c)).collect();
let mut score = 0;
let mut ni = 0;
let mut prev: Option<usize> = None;
for (i, &c) in lower.iter().enumerate() {
if ni == n.len() {
break;
}
if c != n[ni] {
continue;
}
let word_start = i == 0
|| !h[i - 1].is_alphanumeric()
|| (h[i].is_uppercase() && h[i - 1].is_lowercase())
|| (h[i].is_ascii_digit() && !h[i - 1].is_ascii_digit());
score += 1;
if word_start {
score += 8;
}
match prev {
Some(p) if p + 1 == i => score += 6,
Some(p) => score -= ((i - p - 1) as i32).min(5),
None => score -= (i as i32).min(8),
}
prev = Some(i);
ni += 1;
}
if ni < n.len() {
return None;
}
let hay_l: String = lower.iter().collect();
let needle_l: String = n.iter().collect();
if hay_l == needle_l {
score += 60;
} else if hay_l.starts_with(&needle_l) {
score += 30;
}
Some(score)
}
/// Score an item for a query: every whitespace-separated word must match the title,
/// keywords or subtitle (title weighs most).
pub fn score(item: &Item, query: &str) -> Option<i32> {
let mut total = 0;
for word in query.split_whitespace() {
let best = [
fuzzy(word, &item.title).map(|s| s * 3),
fuzzy(word, &item.keywords).map(|s| s * 2),
fuzzy(word, &item.subtitle),
]
.into_iter()
.flatten()
.max()?;
total += best;
}
// A whole-query hit on the title (multi-word commands like `SMA 50`).
if query.split_whitespace().nth(1).is_some()
&& let Some(s) = fuzzy(query.trim(), &item.title)
{
total = total.max(s * 3) + 20;
}
Some(total)
}
/// Palette state: open flag, query, selection and recent picks.
#[derive(Debug, Default)]
pub struct Palette {
pub open: bool,
pub query: String,
pub selected: usize,
pub recents: Vec<Item>,
/// Set when opened, to focus the search field on the next frame.
pub just_opened: bool,
path: Option<PathBuf>,
}
impl Palette {
/// Load recent picks from `path` (missing or unreadable files start empty).
pub fn load(path: &Path) -> Self {
let recents = std::fs::read(path).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default();
Self { recents, path: Some(path.to_path_buf()), ..Default::default() }
}
pub fn open(&mut self) {
self.open = true;
self.just_opened = true;
self.query.clear();
self.selected = 0;
}
pub fn close(&mut self) {
self.open = false;
}
pub fn toggle(&mut self) {
if self.open { self.close() } else { self.open() }
}
/// Move the selection by `delta` rows, wrapping around `len`.
pub fn step(&mut self, delta: i32, len: usize) {
if len == 0 {
self.selected = 0;
return;
}
self.selected = (self.selected.min(len - 1) as i32 + delta).rem_euclid(len as i32) as usize;
}
/// Remember a pick (most recent first) and persist the list.
pub fn record(&mut self, item: &Item) {
self.recents.retain(|r| !r.same(item));
self.recents.insert(0, item.clone());
self.recents.truncate(MAX_RECENTS);
if let Some(path) = &self.path
&& let Ok(json) = serde_json::to_vec_pretty(&self.recents)
&& let Err(e) = crate::layouts::write_atomic(path, &json)
{
tracing::warn!("saving palette recents: {e}");
}
}
/// Ranked results for the current query over `items`.
pub fn results(&self, items: &[Item]) -> Vec<Item> {
rank(items, &self.recents, &self.query)
}
}
/// Recent picks refreshed from the live catalogue: a pick still offered shows its current
/// row (markers, shortcut hints); saved layouts and themes that no longer exist are
/// dropped. Other picks (a searched ticker, a typed command) are kept as recorded.
pub fn live_recents(items: &[Item], recents: &[Item]) -> Vec<Item> {
recents
.iter()
.filter_map(|r| match items.iter().find(|it| it.same(r)) {
Some(live) => Some(live.clone()),
None if matches!(r.target, Target::SwitchLayout(_) | Target::Theme(_)) => None,
None => Some(r.clone()),
})
.collect()
}
/// Rank `items` for `query`. With no query: recent picks first, then everything in
/// catalogue order. With a query: best fuzzy matches, recent picks boosted, ties kept
/// in catalogue order.
pub fn rank(items: &[Item], recents: &[Item], query: &str) -> Vec<Item> {
let recents = live_recents(items, recents);
let recents = recents.as_slice();
let recent_rank = |it: &Item| recents.iter().position(|r| r.same(it));
if query.trim().is_empty() {
let mut out: Vec<Item> = recents.to_vec();
out.extend(items.iter().filter(|it| recent_rank(it).is_none()).cloned());
return out;
}
// Recent picks that are no longer in the catalogue (a searched ticker) still match.
let pool = items.iter().chain(recents.iter().filter(|r| !items.iter().any(|it| it.same(r))));
let mut scored: Vec<(i32, usize, &Item)> = pool
.enumerate()
.filter_map(|(i, it)| {
let s = score(it, query)?;
let boost = recent_rank(it).map_or(0, |r| 40 - 3 * r as i32);
Some((s + boost, i, it))
})
.collect();
scored.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
let mut out: Vec<Item> = Vec::new();
for (_, _, it) in scored {
if !out.iter().any(|o| o.same(it)) {
out.push(it.clone());
}
if out.len() == MAX_RESULTS {
break;
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn cmd(code: &str, desc: &str) -> Item {
Item::new(Kind::Command, code, desc, Target::Run(code.into()))
}
fn catalogue() -> Vec<Item> {
vec![
cmd("PORT", "Brokerage portfolio"),
cmd("POS", "Brokerage positions"),
cmd("ORD", "Brokerage orders"),
cmd("OMON", "Option chain").keywords("options chain"),
cmd("SMA 50", "Toggle SMA 50"),
Item::new(Kind::Theme, "Nord", "Dark theme", Target::Run("THEME Nord".into())),
Item::new(Kind::Settings, "Settings › Brokerage", "SnapTrade keys", Target::Settings("Brokerage".into())),
Item::new(Kind::Action, "Move sidebar to the other side", "", Target::Hotkey(Action::SwapSidebarSide)),
Item::new(Kind::Ticker, "AAPL US Equity", "Apple Inc", Target::Run("AAPL US Equity".into()))
.keywords("Apple Inc"),
]
}
fn titles(v: &[Item]) -> Vec<&str> {
v.iter().map(|i| i.title.as_str()).collect()
}
#[test]
fn fuzzy_matches_subsequences_and_prefers_word_starts() {
assert!(fuzzy("omn", "OMON").is_some());
assert!(fuzzy("xyz", "OMON").is_none());
assert_eq!(fuzzy("", "anything"), Some(0));
// Exact beats prefix beats scattered.
let exact = fuzzy("pos", "POS").unwrap();
let prefix = fuzzy("pos", "POSITIONS").unwrap();
let scattered = fuzzy("pos", "PORT LIST S").unwrap();
assert!(exact > prefix && prefix > scattered, "{exact} {prefix} {scattered}");
// Word-start initials match well: "mss" → "Move Sidebar to the other Side".
assert!(fuzzy("mss", "Move sidebar to the other side").is_some());
assert!(fuzzy("brk", "Settings › Brokerage").unwrap() > fuzzy("brk", "abcbrrrk").unwrap());
}
#[test]
fn query_ranks_best_match_first() {
let items = catalogue();
assert_eq!(titles(&rank(&items, &[], "pos"))[0], "POS");
assert_eq!(titles(&rank(&items, &[], "ord"))[0], "ORD");
assert_eq!(titles(&rank(&items, &[], "omon"))[0], "OMON");
assert_eq!(titles(&rank(&items, &[], "options"))[0], "OMON", "keywords match");
assert_eq!(titles(&rank(&items, &[], "apple"))[0], "AAPL US Equity");
assert_eq!(titles(&rank(&items, &[], "brokerage set"))[0], "Settings › Brokerage", "all words must match");
assert_eq!(titles(&rank(&items, &[], "sma 50"))[0], "SMA 50");
assert!(rank(&items, &[], "qqqzzz").is_empty());
}
#[test]
fn recents_come_first_and_boost_matches() {
let items = catalogue();
let nord = items.iter().find(|i| i.title == "Nord").unwrap().clone();
let ord = items.iter().find(|i| i.title == "ORD").unwrap().clone();
let recents = vec![nord.clone(), ord.clone()];
let empty = rank(&items, &recents, "");
assert_eq!(titles(&empty)[..2], ["Nord", "ORD"]);
assert_eq!(empty.len(), items.len(), "recents are not listed twice");
// "or" matches ORD best; Nord is recent and also matches, so it moves up too.
let r = rank(&items, &recents, "nor");
assert_eq!(titles(&r)[0], "Nord");
// A recent pick no longer in the catalogue (a searched ticker) is still found.
let msft = Item::new(Kind::Ticker, "MSFT US Equity", "Microsoft", Target::Run("MSFT US Equity".into()));
let r = rank(&items, std::slice::from_ref(&msft), "msft");
assert_eq!(titles(&r), ["MSFT US Equity"]);
}
#[test]
fn recents_follow_the_live_catalogue() {
let theme = |name: &str, sub: &str| Item::new(Kind::Theme, name, sub, Target::Theme(name.into()));
let layout = |name: &str| Item::new(Kind::Layout, name, "Saved layout", Target::SwitchLayout(name.into()));
let ticker = Item::new(Kind::Ticker, "MSFT US Equity", "Microsoft", Target::Run("MSFT US Equity".into()));
let recents =
vec![theme("Nord", "Dark theme · current"), layout("Desk"), theme("Gone", "Dark theme"), ticker.clone()];
let live = vec![theme("Nord", "Dark theme"), layout("Desk").hotkey(Action::Layout2)];
let r = live_recents(&live, &recents);
assert_eq!(titles(&r), ["Nord", "Desk", "MSFT US Equity"], "deleted themes and layouts drop out");
assert_eq!(r[0].subtitle, "Dark theme", "the current marker follows the live row");
assert_eq!(r[1].hotkey, Some(Action::Layout2), "shortcut hints follow the live row");
assert_eq!(titles(&rank(&live, &recents, "")), ["Nord", "Desk", "MSFT US Equity"]);
assert!(rank(&live, &recents, "gone").is_empty());
}
#[test]
fn selection_wraps_and_recents_persist() {
let mut p = Palette::default();
p.open();
assert!(p.open && p.just_opened && p.query.is_empty());
p.step(-1, 3);
assert_eq!(p.selected, 2);
p.step(1, 3);
assert_eq!(p.selected, 0);
p.step(1, 0);
assert_eq!(p.selected, 0);
p.toggle();
assert!(!p.open);
let dir = std::env::temp_dir().join(format!("tr-palette-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("palette.json");
let mut p = Palette::load(&path);
assert!(p.recents.is_empty());
let items = catalogue();
for it in items.iter().chain(items.iter()) {
p.record(it);
}
p.record(&items[0]);
assert_eq!(p.recents.len(), items.len().min(MAX_RECENTS));
assert_eq!(p.recents[0].title, "PORT", "re-picking moves an item to the front");
let back = Palette::load(&path);
assert_eq!(back.recents, p.recents);
std::fs::remove_dir_all(&dir).unwrap();
}
}
+9 -2
View File
@@ -231,6 +231,7 @@ pub enum Action {
PopOut,
SaveLayout,
Settings,
CommandPalette,
NewChart,
NewWatchlist,
NewNews,
@@ -261,7 +262,7 @@ pub enum Action {
}
impl Action {
pub const ALL: [Action; 49] = [
pub const ALL: [Action; 50] = [
Action::Help,
Action::Congress,
Action::Politicians,
@@ -284,6 +285,7 @@ impl Action {
Action::PopOut,
Action::SaveLayout,
Action::Settings,
Action::CommandPalette,
Action::NewChart,
Action::NewWatchlist,
Action::NewNews,
@@ -337,6 +339,7 @@ impl Action {
Action::PopOut => "Pop out pane".into(),
Action::SaveLayout => "Save layout".into(),
Action::Settings => "Settings".into(),
Action::CommandPalette => "Command palette".into(),
Action::NewChart => "New Chart pane".into(),
Action::NewWatchlist => "New Watchlist pane".into(),
Action::NewNews => "New News pane".into(),
@@ -375,7 +378,8 @@ impl Action {
| A::NewTab
| A::ClosePane
| A::PopOut
| A::Settings => "Workspace",
| A::Settings
| A::CommandPalette => "Workspace",
a if a.layout_slot().is_some() || a == A::SaveLayout => "Layouts",
A::DrawTrendline
| A::DrawHorizontal
@@ -568,6 +572,8 @@ impl Default for Keymap {
bind(Action::PopOut, "Cmd+Shift+P");
bind(Action::SaveLayout, "Cmd+S");
bind(Action::Settings, "Cmd+Comma");
// ⌘K on macOS, Ctrl+K elsewhere (`Cmd` is the platform command key).
bind(Action::CommandPalette, "Cmd+K");
bind(Action::DrawTrendline, "Alt+T");
bind(Action::DrawHorizontal, "Alt+H");
bind(Action::DrawVertical, "Alt+V");
@@ -763,6 +769,7 @@ mod tests {
assert_eq!(k.action_for(Key::Num1, Modifiers::COMMAND), Some(Action::NewChart));
assert_eq!(k.action_for(Key::Num0, Modifiers::COMMAND), Some(Action::NewSearch));
assert_eq!(k.action_for(Key::Num3, Modifiers::ALT), Some(Action::Layout3));
assert_eq!(k.action_for(Key::K, Modifiers::COMMAND), Some(Action::CommandPalette));
// Every action has a default binding, and no two share one.
let chords: Vec<Chord> = Action::ALL.iter().filter_map(|a| k.chord(*a)).collect();
assert_eq!(chords.len(), Action::ALL.len());