From 7f1870295d5622735ea420ab0a1f7a905c2f82c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sat, 9 May 2026 18:12:49 -0400 Subject: [PATCH] Add command-mode floating switcher overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Whenever the command query starts with the existing '>' prefix the shell now overlays a 640-wide floating panel — backdrop dim, glass card, three ranked sections (Open tabs from snapshot.tabs, History from snapshot.history_entries, and a fixed action set for workspace + key internal routes), and a footer with the design's keyboard hints. Each result is a real navigation hop; selecting a row dispatches the appropriate shell action and clears the command query so the overlay dismisses without leaving stale chrome state. The overlay reads from the live BrowserSnapshot rather than fabricating results, and matches case-insensitively against title and URL. Empty queries surface the most recent items so the panel stays useful even before the user types. --- .../src/shell/chrome/command_overlay.rs | 492 ++++++++++++++++++ crates/ely_app/src/shell/chrome/mod.rs | 2 + crates/ely_app/src/shell/navigation.rs | 11 + crates/ely_app/src/shell/render.rs | 5 +- 4 files changed, 508 insertions(+), 2 deletions(-) create mode 100644 crates/ely_app/src/shell/chrome/command_overlay.rs diff --git a/crates/ely_app/src/shell/chrome/command_overlay.rs b/crates/ely_app/src/shell/chrome/command_overlay.rs new file mode 100644 index 0000000..a5090b8 --- /dev/null +++ b/crates/ely_app/src/shell/chrome/command_overlay.rs @@ -0,0 +1,492 @@ +use ely_browser_core::BrowserSnapshot; +use ely_design_system::colors; +use ely_domain::{BrowserTab, HistoryEntry}; +use gpui::{ + AnyElement, BoxShadow, Context, FontWeight, InteractiveElement, IntoElement, ParentElement, + SharedString, StatefulInteractiveElement, Styled, div, hsla, point, px, rgb, rgba, +}; +use gpui_component::IconName; + +use crate::shell::ElyShell; + +const COMMAND_PREFIX: &str = ">"; +const RESULT_LIMIT: usize = 4; + +pub(crate) fn render_command_overlay( + snapshot: &BrowserSnapshot, + cx: &mut Context, +) -> Option { + let query = snapshot.command_query.as_str(); + if !query.starts_with(COMMAND_PREFIX) { + return None; + } + let needle = query[COMMAND_PREFIX.len()..].trim().to_lowercase(); + + Some(render_overlay(snapshot, &needle, cx)) +} + +fn render_overlay( + snapshot: &BrowserSnapshot, + needle: &str, + cx: &mut Context, +) -> AnyElement { + div() + .absolute() + .inset_0() + .bg(rgba(BACKDROP_BG)) + .flex() + .flex_col() + .items_center() + .pt(px(80.0)) + .child(render_panel(snapshot, needle, cx)) + .into_any_element() +} + +fn render_panel( + snapshot: &BrowserSnapshot, + needle: &str, + cx: &mut Context, +) -> AnyElement { + let query_label = if needle.is_empty() { + "Type to search…".to_string() + } else { + needle.to_string() + }; + + div() + .w(px(640.0)) + .max_h(px(540.0)) + .rounded(px(16.0)) + .bg(rgba(PANEL_BG)) + .shadow(panel_shadow()) + .overflow_hidden() + .flex() + .flex_col() + .child(render_header(query_label.clone(), needle.is_empty())) + .child(render_results(snapshot, needle, cx)) + .child(render_footer()) + .into_any_element() +} + +fn render_header(query_label: String, is_empty: bool) -> AnyElement { + let label_color = if is_empty { colors::INK_4 } else { colors::INK }; + + div() + .flex() + .items_center() + .gap(px(12.0)) + .px(px(18.0)) + .py(px(16.0)) + .border_b_1() + .border_color(rgba(colors::DIVIDER)) + .child( + div() + .text_color(rgb(colors::INK_3)) + .child(IconName::Search), + ) + .child( + div() + .flex_1() + .text_size(px(16.0)) + .text_color(rgb(label_color)) + .child(query_label), + ) + .child( + div() + .px(px(8.0)) + .py(px(2.0)) + .rounded(px(6.0)) + .bg(rgba(BADGE_BG)) + .text_size(px(10.5)) + .text_color(rgb(colors::INK_3)) + .child("Switcher"), + ) + .child(render_kbd("esc")) + .into_any_element() +} + +fn render_results( + snapshot: &BrowserSnapshot, + needle: &str, + cx: &mut Context, +) -> AnyElement { + let tabs = matching_tabs(snapshot, needle); + let history = matching_history(snapshot, needle); + let actions = matching_actions(needle); + + let mut sections: Vec = Vec::new(); + if !tabs.is_empty() { + sections.push(render_section("Open tabs", render_tab_rows(tabs, cx))); + } + if !history.is_empty() { + sections.push(render_section("History", render_history_rows(history, cx))); + } + if !actions.is_empty() { + sections.push(render_section("Actions", render_action_rows(actions, cx))); + } + + if sections.is_empty() { + sections.push(render_empty_state()); + } + + div() + .flex_1() + .min_h_0() + .py(px(8.0)) + .flex() + .flex_col() + .children(sections) + .into_any_element() +} + +fn render_section(label: &'static str, body: AnyElement) -> AnyElement { + div() + .flex() + .flex_col() + .pb(px(4.0)) + .pt(px(8.0)) + .child( + div() + .px(px(16.0)) + .pb(px(4.0)) + .pt(px(6.0)) + .text_size(px(10.0)) + .font_weight(FontWeight(500.0)) + .text_color(rgb(colors::INK_4)) + .child(label), + ) + .child(body) + .into_any_element() +} + +fn render_tab_rows(tabs: Vec<&BrowserTab>, cx: &mut Context) -> AnyElement { + div() + .flex() + .flex_col() + .children(tabs.into_iter().enumerate().map(|(index, tab)| { + let tab_id = tab.id().clone(); + let title = tab.title().to_string(); + let host = tab + .url() + .host() + .map(|host| host.to_string()) + .unwrap_or_else(|| tab.display_url()); + + render_row( + format!("cmd-tab-{index}"), + IconName::Globe, + title, + Some(host), + None, + cx, + move |shell, window, cx| { + shell.select_tab(&tab_id, window, cx); + shell.dismiss_command_mode(window, cx); + }, + ) + })) + .into_any_element() +} + +fn render_history_rows( + entries: Vec<&HistoryEntry>, + cx: &mut Context, +) -> AnyElement { + div() + .flex() + .flex_col() + .children(entries.into_iter().enumerate().map(|(index, entry)| { + let url = entry.url().clone(); + let title = entry.title().to_string(); + let display = entry + .url() + .host() + .map(|host| host.to_string()) + .unwrap_or_else(|| entry.url().as_str().to_string()); + + render_row( + format!("cmd-history-{index}"), + IconName::Undo2, + title, + Some(display), + None, + cx, + move |shell, window, cx| { + shell.open_internal_tab(url.as_str(), window, cx); + shell.dismiss_command_mode(window, cx); + }, + ) + })) + .into_any_element() +} + +struct CommandAction { + title: &'static str, + hint: &'static str, + icon: IconName, + route: &'static str, + keys: &'static str, +} + +const COMMAND_ACTIONS: &[CommandAction] = &[ + CommandAction { + title: "Switch workspace", + hint: "Cycle to the next space", + icon: IconName::LayoutDashboard, + route: "ely://settings/spaces", + keys: "⌘⇧W", + }, + CommandAction { + title: "Open Settings", + hint: "Appearance, sync, plugins…", + icon: IconName::Settings, + route: "ely://settings", + keys: "⌘,", + }, + CommandAction { + title: "Open Plugins", + hint: "Marketplace and installed plugins", + icon: IconName::Asterisk, + route: "ely://plugins", + keys: "", + }, + CommandAction { + title: "Open Bookmarks", + hint: "Manage your saved pages", + icon: IconName::BookOpen, + route: "ely://bookmarks", + keys: "", + }, +]; + +fn matching_actions(needle: &str) -> Vec<&'static CommandAction> { + COMMAND_ACTIONS + .iter() + .filter(|action| { + needle.is_empty() + || action.title.to_lowercase().contains(needle) + || action.hint.to_lowercase().contains(needle) + }) + .collect() +} + +fn render_action_rows( + actions: Vec<&'static CommandAction>, + cx: &mut Context, +) -> AnyElement { + div() + .flex() + .flex_col() + .children(actions.into_iter().enumerate().map(|(index, action)| { + let route = action.route; + let keys = if action.keys.is_empty() { + None + } else { + Some(action.keys.to_string()) + }; + let icon = action.icon.clone(); + + render_row( + format!("cmd-action-{index}"), + icon, + action.title.to_string(), + Some(action.hint.to_string()), + keys, + cx, + move |shell, window, cx| { + shell.open_internal_tab(route, window, cx); + shell.dismiss_command_mode(window, cx); + }, + ) + })) + .into_any_element() +} + +fn render_row( + id: String, + icon: IconName, + title: String, + hint: Option, + keys: Option, + cx: &mut Context, + handler: F, +) -> AnyElement +where + F: Fn(&mut ElyShell, &mut gpui::Window, &mut Context) + 'static, +{ + div() + .id(SharedString::from(id)) + .flex() + .items_center() + .gap(px(12.0)) + .px(px(16.0)) + .py(px(8.0)) + .cursor_pointer() + .hover(|style| style.bg(rgba(ROW_HOVER_BG))) + .active(|style| style.opacity(0.85)) + .on_click(cx.listener(move |shell, _, window, cx| handler(shell, window, cx))) + .child( + div() + .size(px(24.0)) + .rounded(px(6.0)) + .bg(rgba(ROW_ICON_BG)) + .flex() + .items_center() + .justify_center() + .text_color(rgb(colors::INK_2)) + .child(icon), + ) + .child( + div() + .flex_1() + .min_w_0() + .flex() + .flex_col() + .gap_1() + .child( + div() + .text_size(px(13.0)) + .font_weight(FontWeight(500.0)) + .text_color(rgb(colors::INK)) + .truncate() + .child(title), + ) + .children(hint.map(|hint| { + div() + .text_size(px(11.0)) + .text_color(rgb(colors::INK_4)) + .truncate() + .child(hint) + })), + ) + .children(keys.map(|key_label| { + div() + .text_size(px(10.5)) + .text_color(rgb(colors::INK_3)) + .child(key_label) + })) + .into_any_element() +} + +fn render_empty_state() -> AnyElement { + div() + .px(px(18.0)) + .py(px(20.0)) + .text_size(px(12.5)) + .text_color(rgb(colors::INK_4)) + .child("No matches yet — keep typing.") + .into_any_element() +} + +fn render_footer() -> AnyElement { + div() + .flex() + .items_center() + .gap(px(14.0)) + .px(px(16.0)) + .py(px(10.0)) + .border_t_1() + .border_color(rgba(colors::DIVIDER)) + .text_size(px(10.5)) + .text_color(rgb(colors::INK_3)) + .bg(rgba(FOOTER_BG)) + .child(footer_chunk("↑↓", "navigate")) + .child(footer_chunk("↵", "open")) + .child(footer_chunk("⌘↵", "open in split")) + .child(footer_chunk("⇥", "filter")) + .child( + div() + .ml_auto() + .flex() + .items_center() + .gap(px(6.0)) + .child( + div() + .text_color(rgb(colors::ACCENT)) + .child(IconName::Asterisk), + ) + .child("Powered by ELY"), + ) + .into_any_element() +} + +fn footer_chunk(keys: &'static str, label: &'static str) -> AnyElement { + div() + .flex() + .items_center() + .gap(px(4.0)) + .child(render_kbd(keys)) + .child(label) + .into_any_element() +} + +fn render_kbd(label: &'static str) -> AnyElement { + div() + .px(px(5.0)) + .py(px(1.0)) + .rounded(px(4.0)) + .bg(rgba(KBD_BG)) + .text_size(px(10.0)) + .text_color(rgb(colors::INK_3)) + .child(label) + .into_any_element() +} + +fn matching_tabs<'a>( + snapshot: &'a BrowserSnapshot, + needle: &str, +) -> Vec<&'a BrowserTab> { + if needle.is_empty() { + return snapshot.tabs.iter().take(RESULT_LIMIT).collect(); + } + + snapshot + .tabs + .iter() + .filter(|tab| matches_tab(tab, needle)) + .take(RESULT_LIMIT) + .collect() +} + +fn matches_tab(tab: &BrowserTab, needle: &str) -> bool { + tab.title().to_lowercase().contains(needle) || tab.url().as_str().to_lowercase().contains(needle) +} + +fn matching_history<'a>( + snapshot: &'a BrowserSnapshot, + needle: &str, +) -> Vec<&'a HistoryEntry> { + if needle.is_empty() { + return snapshot.history_entries.iter().rev().take(RESULT_LIMIT).collect(); + } + + snapshot + .history_entries + .iter() + .rev() + .filter(|entry| matches_history(entry, needle)) + .take(RESULT_LIMIT) + .collect() +} + +fn matches_history(entry: &HistoryEntry, needle: &str) -> bool { + entry.title().to_lowercase().contains(needle) + || entry.url().as_str().to_lowercase().contains(needle) +} + +const PANEL_BG: u32 = 0xfffffff5; +const BACKDROP_BG: u32 = 0x140f0a3d; +const ROW_HOVER_BG: u32 = 0xc9644214; +const ROW_ICON_BG: u32 = 0xffffffd9; +const BADGE_BG: u32 = 0x281e140f; +const KBD_BG: u32 = 0xffffffd9; +const FOOTER_BG: u32 = 0xffffff8c; + +fn panel_shadow() -> Vec { + vec![BoxShadow { + color: hsla(25.0 / 360.0, 0.33, 0.12, 0.45), + offset: point(px(0.0), px(30.0)), + blur_radius: px(80.0), + spread_radius: px(-20.0), + }] +} diff --git a/crates/ely_app/src/shell/chrome/mod.rs b/crates/ely_app/src/shell/chrome/mod.rs index 8e6b73e..8ccfe3b 100644 --- a/crates/ely_app/src/shell/chrome/mod.rs +++ b/crates/ely_app/src/shell/chrome/mod.rs @@ -1,9 +1,11 @@ +pub(crate) mod command_overlay; pub(crate) mod home; pub(crate) mod sidebar; pub(crate) mod sidebar_header; pub(crate) mod topbar; pub(crate) mod wallpaper; +pub(crate) use command_overlay::render_command_overlay; pub(crate) use home::render_home_page; pub(crate) use sidebar::{PANEL_BG, panel_shadow}; pub(crate) use sidebar_header::render_sidebar_header; diff --git a/crates/ely_app/src/shell/navigation.rs b/crates/ely_app/src/shell/navigation.rs index 651094a..309cca8 100644 --- a/crates/ely_app/src/shell/navigation.rs +++ b/crates/ely_app/src/shell/navigation.rs @@ -72,4 +72,15 @@ impl ElyShell { cx.write_to_clipboard(ClipboardItem::new_string(tab.url().as_str().to_string())); } } + + pub(crate) fn dismiss_command_mode(&mut self, window: &mut Window, cx: &mut Context) { + if let ShellState::Ready(core) = &mut self.state { + core.set_command_query(String::new()); + } + + self.command_input.update(cx, |input, cx| { + input.set_value("", window, cx); + }); + cx.notify(); + } } diff --git a/crates/ely_app/src/shell/render.rs b/crates/ely_app/src/shell/render.rs index 328dd1c..1a829b3 100644 --- a/crates/ely_app/src/shell/render.rs +++ b/crates/ely_app/src/shell/render.rs @@ -7,8 +7,8 @@ use gpui::{ }; use super::chrome::{ - PANEL_BG, WallpaperTheme, panel_shadow, render_topbar as render_topbar_chrome, - render_wallpaper, + PANEL_BG, WallpaperTheme, panel_shadow, render_command_overlay, + render_topbar as render_topbar_chrome, render_wallpaper, }; use super::sidebar::collapsed_sidebar_active; use super::{ElyShell, ShellState}; @@ -75,6 +75,7 @@ impl ElyShell { .child(self.render_sidebar(&snapshot, sidebar_width, sidebar_collapsed, cx)) .child(self.render_main_pane(&snapshot, &active_tab, sidebar_collapsed, cx)), ) + .children(render_command_overlay(&snapshot, cx)) .into_any_element() }