Add command-mode floating switcher overlay

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.
This commit is contained in:
2026-05-09 18:12:49 -04:00
parent 02760ffd10
commit 7f1870295d
4 changed files with 508 additions and 2 deletions
@@ -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<ElyShell>,
) -> Option<AnyElement> {
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<ElyShell>,
) -> 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<ElyShell>,
) -> 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<ElyShell>,
) -> AnyElement {
let tabs = matching_tabs(snapshot, needle);
let history = matching_history(snapshot, needle);
let actions = matching_actions(needle);
let mut sections: Vec<AnyElement> = 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<ElyShell>) -> 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<ElyShell>,
) -> 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<ElyShell>,
) -> 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<F>(
id: String,
icon: IconName,
title: String,
hint: Option<String>,
keys: Option<String>,
cx: &mut Context<ElyShell>,
handler: F,
) -> AnyElement
where
F: Fn(&mut ElyShell, &mut gpui::Window, &mut Context<ElyShell>) + '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<BoxShadow> {
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),
}]
}
+2
View File
@@ -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;
+11
View File
@@ -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<Self>) {
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();
}
}
+3 -2
View File
@@ -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()
}