mirror of
https://github.com/michaelshimeles/terminal.git
synced 2026-11-08 08:11:07 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bedb60151 | ||
|
|
361c4b1eb0 |
No files matched your search
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -10,6 +10,7 @@ mod dock;
|
||||
mod layout;
|
||||
mod layouts;
|
||||
mod markets_view;
|
||||
mod palette;
|
||||
mod prefs;
|
||||
mod theme;
|
||||
mod themes;
|
||||
|
||||
@@ -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
@@ -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());
|
||||
|
||||
Reference in new issue
Block a user