diff --git a/rust/fleet-plan-tree/src/main.rs b/rust/fleet-plan-tree/src/main.rs index 1452586..778ce3d 100644 --- a/rust/fleet-plan-tree/src/main.rs +++ b/rust/fleet-plan-tree/src/main.rs @@ -28,7 +28,9 @@ use fleet_ui::{ card::card, chip::{status_chip, ChipKind}, palette::*, - spotlight_overlay::{Spotlight, SpotlightItem, SpotlightState}, + spotlight_overlay::{ + shared_spotlight_filter, Spotlight, SpotlightState, SHARED_SPOTLIGHT_ITEMS, + }, }; use tuirealm::application::{Application, PollStrategy}; use tuirealm::command::{Cmd, CmdResult}; @@ -74,88 +76,6 @@ enum Overlay { Spotlight, } -const SPOTLIGHT_ITEMS: &[SpotlightItem<'static>] = &[ - SpotlightItem { - group: "PANE", - icon: "⊟", - title: "Horizontal split", - sub: "Split active pane top/bottom", - kbd: "h", - }, - SpotlightItem { - group: "PANE", - icon: "⊞", - title: "Vertical split", - sub: "Split active pane left/right", - kbd: "v", - }, - SpotlightItem { - group: "PANE", - icon: "⤢", - title: "Zoom pane", - sub: "Toggle full-screen for this pane", - kbd: "z", - }, - SpotlightItem { - group: "PANE", - icon: "⇄", - title: "Swap with marked pane", - sub: "Swap active and marked panes", - kbd: "s", - }, - SpotlightItem { - group: "SESSION", - icon: "⧉", - title: "Copy whole session", - sub: "Copy the current transcript", - kbd: "⇧C", - }, - SpotlightItem { - group: "SESSION", - icon: "☰", - title: "Queue message", - sub: "Send a message on next idle", - kbd: "↹", - }, - SpotlightItem { - group: "SESSION", - icon: "⌚", - title: "Search history…", - sub: "Search the current session", - kbd: "/", - }, - SpotlightItem { - group: "FLEET", - icon: "+", - title: "Spawn new codex worker", - sub: "Open another worker pane", - kbd: "Ctrl N", - }, - SpotlightItem { - group: "FLEET", - icon: "⎇", - title: "Switch worktree…", - sub: "Choose another branch/worktree", - kbd: "Ctrl B", - }, -]; - -fn spotlight_filter(query: &str) -> Vec<&'static SpotlightItem<'static>> { - if query.is_empty() { - return SPOTLIGHT_ITEMS.iter().collect(); - } - - let q = query.to_lowercase(); - SPOTLIGHT_ITEMS - .iter() - .filter(|it| { - it.title.to_lowercase().contains(&q) - || it.sub.to_lowercase().contains(&q) - || it.group.to_lowercase().contains(&q) - }) - .collect() -} - // ---------- The PlanView component ---------- struct PlanView { @@ -187,7 +107,7 @@ impl Default for PlanView { .checked_sub(RELOAD_GIT_EVERY) .unwrap_or_else(Instant::now), overlay: Overlay::None, - spotlight: Spotlight::new(SPOTLIGHT_ITEMS.to_vec()), + spotlight: Spotlight::new(SHARED_SPOTLIGHT_ITEMS.to_vec()), spotlight_state: SpotlightState::default(), props: Props::default(), }; @@ -398,7 +318,7 @@ impl AppComponent for PlanView { Event::Keyboard(KeyEvent { code: Key::Down, .. }) if self.overlay == Overlay::Spotlight => { - let max = spotlight_filter(&self.spotlight_state.query) + let max = shared_spotlight_filter(&self.spotlight_state.query) .len() .saturating_sub(1); self.spotlight_state.selected = (self.spotlight_state.selected + 1).min(max); @@ -1209,8 +1129,8 @@ mod spotlight_tests { #[test] fn spotlight_catalogue_matches_watcher_order() { - let hits = spotlight_filter(""); - assert_eq!(hits.len(), SPOTLIGHT_ITEMS.len()); + let hits = shared_spotlight_filter(""); + assert_eq!(hits.len(), SHARED_SPOTLIGHT_ITEMS.len()); assert_eq!(hits.first().unwrap().title, "Horizontal split"); assert_eq!(hits.last().unwrap().title, "Switch worktree…"); } diff --git a/rust/fleet-state/src/main.rs b/rust/fleet-state/src/main.rs index 917fb47..b23ef4b 100644 --- a/rust/fleet-state/src/main.rs +++ b/rust/fleet-state/src/main.rs @@ -28,7 +28,9 @@ use fleet_ui::{ chip::{status_chip, ChipKind}, palette::*, rail::{progress_rail, RailAxis}, - spotlight_overlay::{Spotlight, SpotlightItem, SpotlightState}, + spotlight_overlay::{ + shared_spotlight_filter, Spotlight, SpotlightState, SHARED_SPOTLIGHT_ITEMS, + }, }; use tuirealm::application::{Application, PollStrategy}; use tuirealm::command::{Cmd, CmdResult}; @@ -65,88 +67,6 @@ enum Overlay { Spotlight, } -const SPOTLIGHT_ITEMS: &[SpotlightItem<'static>] = &[ - SpotlightItem { - group: "PANE", - icon: "⊟", - title: "Horizontal split", - sub: "Split active pane top/bottom", - kbd: "h", - }, - SpotlightItem { - group: "PANE", - icon: "⊞", - title: "Vertical split", - sub: "Split active pane left/right", - kbd: "v", - }, - SpotlightItem { - group: "PANE", - icon: "⤢", - title: "Zoom pane", - sub: "Toggle full-screen for this pane", - kbd: "z", - }, - SpotlightItem { - group: "PANE", - icon: "⇄", - title: "Swap with marked pane", - sub: "Swap active and marked panes", - kbd: "s", - }, - SpotlightItem { - group: "SESSION", - icon: "⧉", - title: "Copy whole session", - sub: "Copy the current transcript", - kbd: "⇧C", - }, - SpotlightItem { - group: "SESSION", - icon: "☰", - title: "Queue message", - sub: "Send a message on next idle", - kbd: "↹", - }, - SpotlightItem { - group: "SESSION", - icon: "⌚", - title: "Search history…", - sub: "Search the current session", - kbd: "/", - }, - SpotlightItem { - group: "FLEET", - icon: "+", - title: "Spawn new codex worker", - sub: "Open another worker pane", - kbd: "Ctrl N", - }, - SpotlightItem { - group: "FLEET", - icon: "⎇", - title: "Switch worktree…", - sub: "Choose another branch/worktree", - kbd: "Ctrl B", - }, -]; - -fn spotlight_filter(query: &str) -> Vec<&'static SpotlightItem<'static>> { - if query.is_empty() { - return SPOTLIGHT_ITEMS.iter().collect(); - } - - let q = query.to_lowercase(); - SPOTLIGHT_ITEMS - .iter() - .filter(|it| { - it.title.to_lowercase().contains(&q) - || it.sub.to_lowercase().contains(&q) - || it.group.to_lowercase().contains(&q) - }) - .collect() -} - // ---------- The Fleet component ---------- /// tmux session + window the fleet's worker panes live in. Matches the @@ -174,7 +94,7 @@ impl Default for FleetView { rows: None, load_error: None, overlay: Overlay::None, - spotlight: Spotlight::new(SPOTLIGHT_ITEMS.to_vec()), + spotlight: Spotlight::new(SHARED_SPOTLIGHT_ITEMS.to_vec()), spotlight_state: SpotlightState::default(), props: Props::default(), }; @@ -402,7 +322,7 @@ impl AppComponent for FleetView { Event::Keyboard(KeyEvent { code: Key::Down, .. }) if self.overlay == Overlay::Spotlight => { - let max = spotlight_filter(&self.spotlight_state.query) + let max = shared_spotlight_filter(&self.spotlight_state.query) .len() .saturating_sub(1); self.spotlight_state.selected = (self.spotlight_state.selected + 1).min(max); diff --git a/rust/fleet-ui/src/overlay.rs b/rust/fleet-ui/src/overlay.rs index fbe6e4c..dcdc8a3 100644 --- a/rust/fleet-ui/src/overlay.rs +++ b/rust/fleet-ui/src/overlay.rs @@ -1,7 +1,8 @@ //! Centered modal overlay helpers. //! -//! Shared geometry/chrome primitives used by the overlay modules. Two -//! pieces: +//! Shared geometry/chrome primitives used by every overlay surface +//! (`spotlight_overlay`, `session_switcher_overlay`, `context_menu`, …). +//! Three pieces: //! //! 1. [`centered_overlay`] — geometry helper. Returns a `Rect` of //! `width × height` centred inside `area`, clamped so it never @@ -9,182 +10,26 @@ //! 2. [`render_overlay`] — paints a `Clear` to wipe whatever was beneath //! the popup, then draws a rounded [`crate::card::card`] block. The //! caller renders their own content inside `Block::inner(rect)`. +//! 3. [`card_shadow`] — paints the standard right-edge + bottom-edge +//! drop shadow band that overlays use to lift off the background. +//! +//! NOTE: the Spotlight overlay used to live here as well (a substring- +//! filter sibling of [`crate::spotlight_overlay`]). It was removed when +//! the two implementations were consolidated onto the fuzzy +//! [`crate::spotlight_overlay::Spotlight`]; see `SHARED_SPOTLIGHT_ITEMS` +//! there for the canonical command catalogue. use crate::card::card; -use crate::palette::*; use ratatui::Frame; use ratatui::{ layout::Rect, - style::{Color, Modifier, Style}, - text::{Line, Span}, - widgets::{Block, BorderType, Borders, Clear, Paragraph}, + style::{Color, Style}, + widgets::{Block, Clear}, }; pub mod context_menu; pub use context_menu::{ContextMenu, MenuItem, Section}; -/// One command row in the reusable Spotlight palette. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct SpotlightItem { - pub group: &'static str, - pub icon: &'static str, - pub title: &'static str, - pub sub: &'static str, - pub kbd: &'static str, -} - -impl SpotlightItem { - pub const fn new( - group: &'static str, - icon: &'static str, - title: &'static str, - sub: &'static str, - kbd: &'static str, - ) -> Self { - Self { - group, - icon, - title, - sub, - kbd, - } - } -} - -/// Interactive Spotlight palette state owned by the caller. -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct SpotlightState { - pub query: String, - pub selected: usize, - pub tick: u64, -} - -/// Return Spotlight items whose title or sub-line contains `query`. -pub fn filter<'a>(items: &'a [SpotlightItem], query: &str) -> Vec<&'a SpotlightItem> { - if query.is_empty() { - return items.iter().collect(); - } - - let query = query.to_lowercase(); - items - .iter() - .filter(|item| { - item.title.to_lowercase().contains(&query) || item.sub.to_lowercase().contains(&query) - }) - .collect() -} - -/// Reusable iOS-style Spotlight command palette overlay. -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct Spotlight; - -impl Spotlight { - pub const WIDTH: u16 = 78; - pub const HEIGHT: u16 = 42; - - pub fn new() -> Self { - Self - } - - pub fn render( - &self, - frame: &mut Frame, - area: Rect, - state: &SpotlightState, - items: &[SpotlightItem], - ) { - let filtered = filter(items, &state.query); - let total = filtered.len(); - let selected = if total == 0 { - 0 - } else { - state.selected.min(total - 1) - }; - - let rect = centered_overlay(area, Self::WIDTH, Self::HEIGHT); - card_shadow(frame, rect, area); - frame.render_widget(Clear, rect); - frame.render_widget(spotlight_glass_block(), rect); - - let inner = Rect { - x: rect.x + 2, - y: rect.y + 1, - width: rect.width.saturating_sub(4), - height: rect.height.saturating_sub(2), - }; - if inner.width == 0 || inner.height == 0 { - return; - } - - let mut y = inner.y + 1; - y = render_spotlight_search_row(frame, inner, y, state); - render_spotlight_hairline(frame, inner, y); - y += 2; - - if total == 0 { - render_spotlight_empty(frame, inner, y); - render_spotlight_footer(frame, inner, total); - return; - } - - frame.render_widget( - Paragraph::new(Line::from(Span::styled( - "TOP HIT", - Style::default() - .fg(IOS_FG_MUTED) - .add_modifier(Modifier::BOLD), - ))), - Rect { - x: inner.x, - y, - width: inner.width, - height: 1, - }, - ); - y += 1; - - y = render_spotlight_top_hit(frame, inner, y, filtered[0], selected == 0); - y += 1; - - let bottom_guard = inner.y + inner.height.saturating_sub(2); - let mut last_group: Option<&str> = None; - for (rank_index, item) in filtered.iter().enumerate().skip(1) { - if y + 3 > bottom_guard { - break; - } - if last_group != Some(item.group) { - if last_group.is_some() { - y += 1; - if y + 3 > bottom_guard { - break; - } - } - frame.render_widget( - Paragraph::new(Line::from(Span::styled( - format!(" {}", item.group), - Style::default() - .fg(IOS_FG_MUTED) - .add_modifier(Modifier::BOLD), - ))), - Rect { - x: inner.x, - y, - width: inner.width, - height: 1, - }, - ); - y += 1; - last_group = Some(item.group); - } - - render_spotlight_result(frame, inner, y, item, rank_index == selected); - y += 2; - } - - render_spotlight_footer(frame, inner, total); - } -} - /// Return a `Rect` of `width × height` centred inside `area`. If the /// requested size exceeds `area`, the result is clamped to `area` (top-left /// aligned in that degenerate case so nothing overflows the frame). @@ -254,307 +99,6 @@ pub fn card_shadow(frame: &mut Frame, card_rect: Rect, area: Rect) { } } -fn spotlight_glass_block() -> Block<'static> { - Block::default() - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .border_style(Style::default().fg(IOS_HAIRLINE_STRONG)) - .style(Style::default().bg(IOS_BG_SOLID).fg(IOS_FG)) -} - -fn render_spotlight_search_row( - frame: &mut Frame, - inner: Rect, - y: u16, - state: &SpotlightState, -) -> u16 { - let caret_on = (state.tick / 4) % 2 == 0; - let caret_char = if caret_on { "▏" } else { " " }; - let query_display = if state.query.is_empty() { - "type to filter…" - } else { - state.query.as_str() - }; - let query_style = if state.query.is_empty() { - Style::default().fg(IOS_FG_FAINT) - } else { - Style::default().fg(IOS_FG).add_modifier(Modifier::BOLD) - }; - let query_spans = vec![ - Span::styled("⌕ ", Style::default().fg(IOS_FG_MUTED)), - Span::styled(query_display.to_string(), query_style), - Span::styled( - caret_char, - Style::default().fg(IOS_TINT).add_modifier(Modifier::BOLD), - ), - ]; - let cmdk = " Ctrl K "; - let cmdk_w = text_width(cmdk); - frame.render_widget( - Paragraph::new(Line::from(query_spans)), - Rect { - x: inner.x, - y, - width: inner.width.saturating_sub(cmdk_w + 1), - height: 1, - }, - ); - frame.render_widget( - Paragraph::new(Line::from(Span::styled( - cmdk, - Style::default().fg(IOS_FG_FAINT).bg(IOS_CHIP_BG), - ))), - Rect { - x: inner.x + inner.width - cmdk_w, - y, - width: cmdk_w, - height: 1, - }, - ); - y + 1 -} - -fn render_spotlight_empty(frame: &mut Frame, inner: Rect, y: u16) { - let msg = "no matches"; - let msg_w = text_width(msg); - frame.render_widget( - Paragraph::new(Line::from(Span::styled( - msg, - Style::default() - .fg(IOS_FG_MUTED) - .add_modifier(Modifier::BOLD), - ))), - Rect { - x: inner.x + (inner.width.saturating_sub(msg_w)) / 2, - y: y + 3, - width: msg_w, - height: 1, - }, - ); -} - -fn render_spotlight_top_hit( - frame: &mut Frame, - inner: Rect, - y: u16, - item: &SpotlightItem, - active: bool, -) -> u16 { - let hit_bg = if active { - IOS_TINT - } else { - Color::Rgb(8, 80, 180) - }; - let hit_rect = Rect { - x: inner.x, - y, - width: inner.width, - height: 3, - }; - frame.render_widget( - Block::default().style(Style::default().bg(hit_bg)), - hit_rect, - ); - - let badge = format!(" tmux · {} ", item.kbd); - let badge_w = text_width(&badge); - let chevron = " › "; - let chevron_w = text_width(chevron); - frame.render_widget( - Paragraph::new(Line::from(vec![ - Span::styled(" ", Style::default().bg(hit_bg)), - Span::styled( - format!(" {} ", item.icon), - Style::default() - .fg(Color::Rgb(255, 255, 255)) - .bg(IOS_TINT_DARK) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - format!(" {}", item.title), - Style::default() - .fg(Color::Rgb(255, 255, 255)) - .bg(hit_bg) - .add_modifier(Modifier::BOLD), - ), - ])), - Rect { - x: hit_rect.x, - y: hit_rect.y + 1, - width: hit_rect.width.saturating_sub(badge_w + chevron_w + 1), - height: 1, - }, - ); - if hit_rect.width > badge_w + chevron_w + 1 { - frame.render_widget( - Paragraph::new(Line::from(Span::styled( - badge, - Style::default() - .fg(Color::Rgb(255, 255, 255)) - .bg(IOS_TINT_DARK) - .add_modifier(Modifier::BOLD), - ))), - Rect { - x: hit_rect.x + hit_rect.width - badge_w - chevron_w, - y: hit_rect.y + 1, - width: badge_w, - height: 1, - }, - ); - frame.render_widget( - Paragraph::new(Line::from(Span::styled( - chevron, - Style::default() - .fg(IOS_TINT_SUB) - .bg(hit_bg) - .add_modifier(Modifier::BOLD), - ))), - Rect { - x: hit_rect.x + hit_rect.width - chevron_w, - y: hit_rect.y + 1, - width: chevron_w, - height: 1, - }, - ); - } - frame.render_widget( - Paragraph::new(Line::from(Span::styled( - format!(" {}", item.sub), - Style::default().fg(IOS_TINT_SUB).bg(hit_bg), - ))), - Rect { - x: hit_rect.x, - y: hit_rect.y + 2, - width: hit_rect.width, - height: 1, - }, - ); - y + 3 -} - -fn render_spotlight_result( - frame: &mut Frame, - inner: Rect, - y: u16, - item: &SpotlightItem, - selected: bool, -) { - let row_bg = if selected { IOS_TINT_DARK } else { IOS_CARD_BG }; - let title_fg = if selected { - Color::Rgb(255, 255, 255) - } else { - IOS_FG - }; - let sub_fg = if selected { IOS_TINT_SUB } else { IOS_FG_MUTED }; - let item_rect = Rect { - x: inner.x, - y, - width: inner.width, - height: 2, - }; - frame.render_widget( - Block::default().style(Style::default().bg(row_bg)), - item_rect, - ); - - let kbd = format!(" {} ", item.kbd); - let kbd_w = text_width(&kbd); - frame.render_widget( - Paragraph::new(Line::from(vec![ - Span::styled(" ", Style::default().bg(row_bg)), - Span::styled( - format!(" {} ", item.icon), - Style::default() - .fg(title_fg) - .bg(IOS_ICON_CHIP) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - format!(" {}", item.title), - Style::default() - .fg(title_fg) - .bg(row_bg) - .add_modifier(Modifier::BOLD), - ), - ])), - Rect { - x: inner.x, - y, - width: inner.width.saturating_sub(kbd_w + 2), - height: 1, - }, - ); - if inner.width > kbd_w + 1 { - frame.render_widget( - Paragraph::new(Line::from(Span::styled( - kbd, - Style::default() - .fg(title_fg) - .bg(IOS_ICON_CHIP) - .add_modifier(Modifier::BOLD), - ))), - Rect { - x: inner.x + inner.width - kbd_w - 1, - y, - width: kbd_w, - height: 1, - }, - ); - } - frame.render_widget( - Paragraph::new(Line::from(Span::styled( - format!(" {}", item.sub), - Style::default().fg(sub_fg).bg(row_bg), - ))), - Rect { - x: inner.x, - y: y + 1, - width: inner.width, - height: 1, - }, - ); -} - -fn render_spotlight_footer(frame: &mut Frame, inner: Rect, total: usize) { - let y = inner.y + inner.height - 1; - let footer = Line::from(vec![ - Span::styled("↵", Style::default().fg(IOS_FG)), - Span::styled(" open · ", Style::default().fg(IOS_FG_MUTED)), - Span::styled("⌥↵", Style::default().fg(IOS_FG)), - Span::styled(" all panes · ", Style::default().fg(IOS_FG_MUTED)), - Span::styled("esc", Style::default().fg(IOS_FG)), - Span::styled(" cancel · ", Style::default().fg(IOS_FG_MUTED)), - Span::styled("✦", Style::default().fg(IOS_PURPLE)), - Span::styled(format!(" {total} items"), Style::default().fg(IOS_FG_MUTED)), - ]); - frame.render_widget( - Paragraph::new(footer), - Rect { - x: inner.x, - y, - width: inner.width, - height: 1, - }, - ); -} - -fn render_spotlight_hairline(frame: &mut Frame, inner: Rect, y: u16) { - let hairline = "─".repeat(inner.width as usize); - frame.render_widget( - Paragraph::new(Span::styled(hairline, Style::default().fg(IOS_HAIRLINE))), - Rect { - x: inner.x, - y, - width: inner.width, - height: 1, - }, - ); -} - -fn text_width(s: &str) -> u16 { - s.chars().count() as u16 -} - #[cfg(test)] mod tests { use super::*; diff --git a/rust/fleet-ui/src/spotlight_overlay.rs b/rust/fleet-ui/src/spotlight_overlay.rs index 21176ef..4e3a6f3 100644 --- a/rust/fleet-ui/src/spotlight_overlay.rs +++ b/rust/fleet-ui/src/spotlight_overlay.rs @@ -44,6 +44,95 @@ pub struct SpotlightState { pub tick: u64, } +/// Canonical Spotlight catalogue shared by every fleet binary +/// (`fleet-state`, `fleet-plan-tree`, `fleet-waves`). Previously each +/// binary kept its own near-identical `SPOTLIGHT_ITEMS` const; that +/// duplication is consolidated here. Three groups: PANE (split / zoom / +/// swap), SESSION (transcript / queue / history), FLEET (spawn / switch). +pub const SHARED_SPOTLIGHT_ITEMS: &[SpotlightItem<'static>] = &[ + SpotlightItem { + group: "PANE", + icon: "⊟", + title: "Horizontal split", + sub: "Split active pane top/bottom", + kbd: "h", + }, + SpotlightItem { + group: "PANE", + icon: "⊞", + title: "Vertical split", + sub: "Split active pane left/right", + kbd: "v", + }, + SpotlightItem { + group: "PANE", + icon: "⤢", + title: "Zoom pane", + sub: "Toggle full-screen for this pane", + kbd: "z", + }, + SpotlightItem { + group: "PANE", + icon: "⇄", + title: "Swap with marked pane", + sub: "Swap active and marked panes", + kbd: "s", + }, + SpotlightItem { + group: "SESSION", + icon: "⧉", + title: "Copy whole session", + sub: "Copy the current transcript", + kbd: "⇧C", + }, + SpotlightItem { + group: "SESSION", + icon: "☰", + title: "Queue message", + sub: "Send a message on next idle", + kbd: "↹", + }, + SpotlightItem { + group: "SESSION", + icon: "⌚", + title: "Search history…", + sub: "Search the current session", + kbd: "/", + }, + SpotlightItem { + group: "FLEET", + icon: "+", + title: "Spawn new codex worker", + sub: "Open another worker pane", + kbd: "Ctrl N", + }, + SpotlightItem { + group: "FLEET", + icon: "⎇", + title: "Switch worktree…", + sub: "Choose another branch/worktree", + kbd: "Ctrl B", + }, +]; + +/// Fuzzy-rank the canonical catalogue against `query`. Returns the items +/// in score-descending order; an empty query returns all items in catalogue +/// order. Mirrors the legacy per-binary `spotlight_filter` helper, but +/// powered by `SpotlightFilter` (skim fuzzy) instead of substring matching. +pub fn shared_spotlight_filter(query: &str) -> Vec<&'static SpotlightItem<'static>> { + if query.is_empty() { + return SHARED_SPOTLIGHT_ITEMS.iter().collect(); + } + let keys: Vec = SHARED_SPOTLIGHT_ITEMS + .iter() + .map(|item| format!("{} {} {}", item.group, item.title, item.sub)) + .collect(); + let hits = SpotlightFilter::default().rank(query, &keys, SHARED_SPOTLIGHT_ITEMS.len()); + hits.into_iter() + .map(|hit| &SHARED_SPOTLIGHT_ITEMS[hit.index]) + .collect() +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct Spotlight<'a> { pub items: Vec>, diff --git a/rust/fleet-ui/tests/overlay_spotlight.rs b/rust/fleet-ui/tests/overlay_spotlight.rs deleted file mode 100644 index 84807ca..0000000 --- a/rust/fleet-ui/tests/overlay_spotlight.rs +++ /dev/null @@ -1,97 +0,0 @@ -use fleet_ui::overlay::{filter, Spotlight, SpotlightItem, SpotlightState}; -use ratatui::{backend::TestBackend, Terminal}; - -fn poc_items() -> Vec { - vec![ - SpotlightItem::new( - "PANE", - "⊟", - "Horizontal split", - "Split active pane top/bottom", - "h", - ), - SpotlightItem::new( - "PANE", - "⊞", - "Vertical split", - "Split active pane left/right", - "v", - ), - SpotlightItem::new( - "PANE", - "⤢", - "Zoom pane", - "Toggle full-screen for this pane", - "z", - ), - SpotlightItem::new( - "PANE", - "⇄", - "Swap with marked pane", - "codex-ricsi-zazrifka ⇄ marked", - "s", - ), - SpotlightItem::new( - "SESSION · codex-admin-kollarrobert", - "⧉", - "Copy whole session", - "180 lines · transcript", - "⇧C", - ), - SpotlightItem::new( - "SESSION · codex-admin-kollarrobert", - "☰", - "Queue message", - "Send to agent on next idle", - "↹", - ), - SpotlightItem::new( - "SESSION · codex-admin-kollarrobert", - "⌚", - "Search history…", - "Across all 7 panes", - "/", - ), - SpotlightItem::new( - "FLEET", - "+", - "Spawn new codex worker", - "codex-fleet · new agent", - "Ctrl N", - ), - SpotlightItem::new( - "FLEET", - "⎇", - "Switch worktree…", - "codex-fleet-extract-p1…", - "Ctrl B", - ), - ] -} - -#[test] -fn spotlight_filter_is_case_insensitive_substring() { - let items = poc_items(); - let filtered = filter(&items, "SPLIT"); - assert_eq!(filtered.len(), 2); - assert_eq!(filtered[0].title, "Horizontal split"); - assert_eq!(filtered[1].title, "Vertical split"); -} - -#[test] -fn spotlight_default_render() { - let mut terminal = Terminal::new(TestBackend::new(100, 40)).unwrap(); - let spotlight = Spotlight::new(); - let state = SpotlightState { - query: "split".to_string(), - selected: 0, - tick: 0, - }; - let items = poc_items(); - - terminal - .draw(|frame| spotlight.render(frame, frame.area(), &state, &items)) - .unwrap(); - - insta::assert_snapshot!(format!("{}", terminal.backend())); -} diff --git a/rust/fleet-ui/tests/snapshots/overlay_spotlight__spotlight_default_render.snap b/rust/fleet-ui/tests/snapshots/overlay_spotlight__spotlight_default_render.snap deleted file mode 100644 index 2adcad4..0000000 --- a/rust/fleet-ui/tests/snapshots/overlay_spotlight__spotlight_default_render.snap +++ /dev/null @@ -1,44 +0,0 @@ ---- -source: fleet-ui/tests/overlay_spotlight.rs -expression: "format!(\"{}\", terminal.backend())" ---- -" ╭────────────────────────────────────────────────────────────────────────────╮ " -" │ │ " -" │ ⌕ split▏ Ctrl K │ " -" │ ────────────────────────────────────────────────────────────────────────── │ " -" │ │ " -" │ TOP HIT │ " -" │ │ " -" │ ⊟ Horizontal split tmux · h › │ " -" │ Split active pane top/bottom │ " -" │ │ " -" │ PANE │ " -" │ ⊞ Vertical split v │ " -" │ Split active pane left/right │ " -" │ │ " -" │ │ " -" │ │ " -" │ │ " -" │ │ " -" │ │ " -" │ │ " -" │ │ " -" │ │ " -" │ │ " -" │ │ " -" │ │ " -" │ │ " -" │ │ " -" │ │ " -" │ │ " -" │ │ " -" │ │ " -" │ │ " -" │ │ " -" │ │ " -" │ │ " -" │ │ " -" │ │ " -" │ │ " -" │ ↵ open · ⌥↵ all panes · esc cancel · ✦ 2 items │ " -" ╰────────────────────────────────────────────────────────────────────────────╯ " diff --git a/rust/fleet-waves/src/main.rs b/rust/fleet-waves/src/main.rs index 35bc3c1..d091042 100644 --- a/rust/fleet-waves/src/main.rs +++ b/rust/fleet-waves/src/main.rs @@ -41,8 +41,10 @@ use fleet_data::plan::Subtask; use fleet_data::toposort::waves; use fleet_ui::{ chip::{status_chip, ChipKind, CHIP_WIDTH}, - overlay::{filter as filter_spotlight_items, Spotlight, SpotlightItem, SpotlightState}, palette::*, + spotlight_overlay::{ + shared_spotlight_filter, Spotlight, SpotlightState, SHARED_SPOTLIGHT_ITEMS, + }, }; use tuirealm::application::{Application, PollStrategy}; use tuirealm::command::{Cmd, CmdResult}; @@ -77,83 +79,13 @@ enum Overlay { Spotlight, } -const SPOTLIGHT_ITEMS: &[SpotlightItem] = &[ - SpotlightItem::new( - "PANE", - "⊟", - "Horizontal split", - "Split active pane top/bottom", - "h", - ), - SpotlightItem::new( - "PANE", - "⊞", - "Vertical split", - "Split active pane left/right", - "v", - ), - SpotlightItem::new( - "PANE", - "⤢", - "Zoom pane", - "Toggle full-screen for this pane", - "z", - ), - SpotlightItem::new( - "PANE", - "⇄", - "Swap with marked pane", - "Swap active and marked panes", - "s", - ), - SpotlightItem::new( - "SESSION", - "⧉", - "Copy whole session", - "Copy the current transcript", - "⇧C", - ), - SpotlightItem::new( - "SESSION", - "☰", - "Queue message", - "Send a message on next idle", - "↹", - ), - SpotlightItem::new( - "SESSION", - "⌚", - "Search history…", - "Search the current session", - "/", - ), - SpotlightItem::new( - "FLEET", - "+", - "Spawn new codex worker", - "Open another worker pane", - "Ctrl N", - ), - SpotlightItem::new( - "FLEET", - "⎇", - "Switch worktree…", - "Choose another branch/worktree", - "Ctrl B", - ), -]; - -fn spotlight_filter(query: &str) -> Vec<&'static SpotlightItem> { - filter_spotlight_items(SPOTLIGHT_ITEMS, query) -} - struct App { plan: Option, props: Props, tick: u64, timeline: Box, overlay: Overlay, - spotlight: Spotlight, + spotlight: Spotlight<'static>, spotlight_state: SpotlightState, } @@ -185,7 +117,7 @@ impl App { tick: 0, timeline, overlay: Overlay::None, - spotlight: Spotlight::new(), + spotlight: Spotlight::new(SHARED_SPOTLIGHT_ITEMS.to_vec()), spotlight_state: SpotlightState::default(), } } @@ -270,7 +202,7 @@ impl AppComponent for App { Event::Keyboard(KeyEvent { code: Key::Down, .. }) if self.overlay == Overlay::Spotlight => { - let max = spotlight_filter(&self.spotlight_state.query) + let max = shared_spotlight_filter(&self.spotlight_state.query) .len() .saturating_sub(1); self.spotlight_state.selected = (self.spotlight_state.selected + 1).min(max); @@ -1234,8 +1166,7 @@ fn render(frame: &mut Frame, area: Rect, app: &mut App) { render_footer(frame, rows[3], app.plan.as_ref()); if app.overlay == Overlay::Spotlight { - app.spotlight - .render(frame, area, &app.spotlight_state, SPOTLIGHT_ITEMS); + app.spotlight.render(frame, area, &app.spotlight_state); } } @@ -1478,8 +1409,8 @@ mod tests { #[test] fn spotlight_catalogue_matches_fleet_dashboard_order() { - let hits = spotlight_filter(""); - assert_eq!(hits.len(), SPOTLIGHT_ITEMS.len()); + let hits = shared_spotlight_filter(""); + assert_eq!(hits.len(), SHARED_SPOTLIGHT_ITEMS.len()); assert_eq!(hits.first().unwrap().title, "Horizontal split"); assert_eq!(hits.last().unwrap().title, "Switch worktree…"); }