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() }