diff --git a/crates/ely_app/src/shell/chrome/command_match.rs b/crates/ely_app/src/shell/chrome/command_match.rs index 193ed73..923217a 100644 --- a/crates/ely_app/src/shell/chrome/command_match.rs +++ b/crates/ely_app/src/shell/chrome/command_match.rs @@ -1,8 +1,94 @@ use ely_browser_core::BrowserSnapshot; -use ely_domain::{BookmarkEntry, BrowserTab, HistoryEntry}; +use ely_domain::{BookmarkEntry, BrowserTab, HistoryEntry, TabId, UrlText}; +use gpui_component::IconName; pub(crate) const RESULT_LIMIT: usize = 4; +pub(crate) struct CommandActionEntry { + pub title: &'static str, + pub hint: &'static str, + pub icon: IconName, + pub route: &'static str, + pub keys: &'static str, +} + +pub(crate) const COMMAND_ACTIONS: &[CommandActionEntry] = &[ + CommandActionEntry { + title: "Switch workspace", + hint: "Cycle to the next space", + icon: IconName::LayoutDashboard, + route: "ely://settings/spaces", + keys: "⌘⇧W", + }, + CommandActionEntry { + title: "Open Settings", + hint: "Appearance, sync, plugins…", + icon: IconName::Settings, + route: "ely://settings", + keys: "⌘,", + }, + CommandActionEntry { + title: "Open Plugins", + hint: "Marketplace and installed plugins", + icon: IconName::Asterisk, + route: "ely://plugins", + keys: "", + }, + CommandActionEntry { + title: "Open Bookmarks", + hint: "Manage your saved pages", + icon: IconName::BookOpen, + route: "ely://bookmarks", + keys: "", + }, +]; + +pub(crate) fn matching_actions(needle: &str) -> Vec<&'static CommandActionEntry> { + COMMAND_ACTIONS + .iter() + .filter(|action| { + needle.is_empty() + || action.title.to_lowercase().contains(needle) + || action.hint.to_lowercase().contains(needle) + }) + .collect() +} + +#[derive(Clone, Debug)] +pub(crate) enum CommandSelection { + SelectTab(TabId), + OpenUrl(UrlText), + OpenRoute(&'static str), +} + +pub(crate) fn visible_command_rows( + snapshot: &BrowserSnapshot, + needle: &str, +) -> Vec { + let mut rows = Vec::new(); + rows.extend( + matching_tabs(snapshot, needle) + .iter() + .map(|tab| CommandSelection::SelectTab(tab.id().clone())), + ); + rows.extend( + matching_history(snapshot, needle) + .iter() + .map(|entry| CommandSelection::OpenUrl(entry.url().clone())), + ); + rows.extend( + matching_bookmarks(snapshot, needle) + .iter() + .map(|bookmark| CommandSelection::OpenUrl(bookmark.url().clone())), + ); + rows.extend( + matching_actions(needle) + .iter() + .map(|action| CommandSelection::OpenRoute(action.route)), + ); + rows +} + pub(crate) fn matching_tabs<'a>( snapshot: &'a BrowserSnapshot, needle: &str, diff --git a/crates/ely_app/src/shell/chrome/command_overlay.rs b/crates/ely_app/src/shell/chrome/command_overlay.rs index 726d557..8088561 100644 --- a/crates/ely_app/src/shell/chrome/command_overlay.rs +++ b/crates/ely_app/src/shell/chrome/command_overlay.rs @@ -3,20 +3,22 @@ use ely_design_system::colors; use ely_domain::{BookmarkEntry, BrowserTab, HistoryEntry}; use gpui::{ AnyElement, BoxShadow, Context, FontWeight, InteractiveElement, IntoElement, ParentElement, - SharedString, StatefulInteractiveElement, Styled, div, hsla, point, px, rgb, rgba, + SharedString, StatefulInteractiveElement, Styled, div, hsla, point, + prelude::FluentBuilder, px, rgb, rgba, }; use gpui_component::IconName; use crate::shell::ElyShell; use crate::shell::chrome::command_footer::{render_command_footer, render_kbd}; use crate::shell::chrome::command_match::{ - matching_bookmarks, matching_history, matching_tabs, + CommandActionEntry, matching_actions, matching_bookmarks, matching_history, matching_tabs, }; use crate::shell::chrome::render_glyph_for; const COMMAND_PREFIX: &str = ">"; pub(crate) fn render_command_overlay( + shell: &ElyShell, snapshot: &BrowserSnapshot, cx: &mut Context, ) -> Option { @@ -25,13 +27,15 @@ pub(crate) fn render_command_overlay( return None; } let needle = query[COMMAND_PREFIX.len()..].trim().to_lowercase(); + let selected_index = shell.command_selected_index; - Some(render_overlay(snapshot, &needle, cx)) + Some(render_overlay(snapshot, &needle, selected_index, cx)) } fn render_overlay( snapshot: &BrowserSnapshot, needle: &str, + selected_index: usize, cx: &mut Context, ) -> AnyElement { div() @@ -42,13 +46,14 @@ fn render_overlay( .flex_col() .items_center() .pt(px(80.0)) - .child(render_panel(snapshot, needle, cx)) + .child(render_panel(snapshot, needle, selected_index, cx)) .into_any_element() } fn render_panel( snapshot: &BrowserSnapshot, needle: &str, + selected_index: usize, cx: &mut Context, ) -> AnyElement { let query_label = if needle.is_empty() { @@ -67,7 +72,7 @@ fn render_panel( .flex() .flex_col() .child(render_header(query_label.clone(), needle.is_empty())) - .child(render_results(snapshot, needle, cx)) + .child(render_results(snapshot, needle, selected_index, cx)) .child(render_command_footer()) .into_any_element() } @@ -112,6 +117,7 @@ fn render_header(query_label: String, is_empty: bool) -> AnyElement { fn render_results( snapshot: &BrowserSnapshot, needle: &str, + selected_index: usize, cx: &mut Context, ) -> AnyElement { let tabs = matching_tabs(snapshot, needle); @@ -119,21 +125,37 @@ fn render_results( let bookmarks = matching_bookmarks(snapshot, needle); let actions = matching_actions(needle); + let mut offset = 0usize; let mut sections: Vec = Vec::new(); if !tabs.is_empty() { - sections.push(render_section("Open tabs", render_tab_rows(tabs, cx))); + let count = tabs.len(); + sections.push(render_section( + "Open tabs", + render_tab_rows(tabs, offset, selected_index, cx), + )); + offset += count; } if !history.is_empty() { - sections.push(render_section("History", render_history_rows(history, cx))); + let count = history.len(); + sections.push(render_section( + "History", + render_history_rows(history, offset, selected_index, cx), + )); + offset += count; } if !bookmarks.is_empty() { + let count = bookmarks.len(); sections.push(render_section( "Bookmarks", - render_bookmark_rows(bookmarks, cx), + render_bookmark_rows(bookmarks, offset, selected_index, cx), )); + offset += count; } if !actions.is_empty() { - sections.push(render_section("Actions", render_action_rows(actions, cx))); + sections.push(render_section( + "Actions", + render_action_rows(actions, offset, selected_index, cx), + )); } if sections.is_empty() { @@ -170,7 +192,12 @@ fn render_section(label: &'static str, body: AnyElement) -> AnyElement { .into_any_element() } -fn render_tab_rows(tabs: Vec<&BrowserTab>, cx: &mut Context) -> AnyElement { +fn render_tab_rows( + tabs: Vec<&BrowserTab>, + offset: usize, + selected_index: usize, + cx: &mut Context, +) -> AnyElement { div() .flex() .flex_col() @@ -182,6 +209,7 @@ fn render_tab_rows(tabs: Vec<&BrowserTab>, cx: &mut Context) -> AnyEle .clone() .unwrap_or_else(|| tab.display_url()); let initial = title.chars().next().unwrap_or('?').to_string(); + let is_selected = offset + index == selected_index; render_row_with_glyph( CommandRowContent { @@ -189,6 +217,7 @@ fn render_tab_rows(tabs: Vec<&BrowserTab>, cx: &mut Context) -> AnyEle title, hint: Some(host_label), keys: None, + selected: is_selected, }, host.as_deref(), &initial, @@ -204,6 +233,8 @@ fn render_tab_rows(tabs: Vec<&BrowserTab>, cx: &mut Context) -> AnyEle fn render_history_rows( entries: Vec<&HistoryEntry>, + offset: usize, + selected_index: usize, cx: &mut Context, ) -> AnyElement { div() @@ -217,6 +248,7 @@ fn render_history_rows( .clone() .unwrap_or_else(|| entry.url().as_str().to_string()); let initial = title.chars().next().unwrap_or('?').to_string(); + let is_selected = offset + index == selected_index; render_row_with_glyph( CommandRowContent { @@ -224,6 +256,7 @@ fn render_history_rows( title, hint: Some(display), keys: None, + selected: is_selected, }, host.as_deref(), &initial, @@ -237,58 +270,10 @@ fn render_history_rows( .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>, + actions: Vec<&'static CommandActionEntry>, + offset: usize, + selected_index: usize, cx: &mut Context, ) -> AnyElement { div() @@ -302,6 +287,7 @@ fn render_action_rows( Some(action.keys.to_string()) }; let icon = action.icon.clone(); + let is_selected = offset + index == selected_index; render_row( CommandRowContent { @@ -309,6 +295,7 @@ fn render_action_rows( title: action.title.to_string(), hint: Some(action.hint.to_string()), keys, + selected: is_selected, }, icon, cx, @@ -326,6 +313,7 @@ struct CommandRowContent { title: String, hint: Option, keys: Option, + selected: bool, } fn render_row( @@ -373,19 +361,34 @@ fn render_row_inner( where F: Fn(&mut ElyShell, &mut gpui::Window, &mut Context) + 'static, { - let CommandRowContent { id, title, hint, keys } = content; + let CommandRowContent { id, title, hint, keys, selected } = content; + let bg = if selected { ROW_SELECTED_BG } else { 0x00000000 }; div() .id(SharedString::from(id)) + .relative() .flex() .items_center() .gap(px(12.0)) .px(px(16.0)) .py(px(8.0)) + .bg(rgba(bg)) .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))) + .when(selected, |el| { + el.child( + div() + .absolute() + .left_0() + .top(px(6.0)) + .bottom(px(6.0)) + .w(px(2.0)) + .rounded(px(2.0)) + .bg(rgb(colors::ACCENT)), + ) + }) .child(leading) .child( div() @@ -431,6 +434,8 @@ fn render_empty_state() -> AnyElement { fn render_bookmark_rows( entries: Vec<&BookmarkEntry>, + offset: usize, + selected_index: usize, cx: &mut Context, ) -> AnyElement { div() @@ -444,6 +449,7 @@ fn render_bookmark_rows( .clone() .unwrap_or_else(|| bookmark.url().as_str().to_string()); let initial = title.chars().next().unwrap_or('?').to_string(); + let is_selected = offset + index == selected_index; render_row_with_glyph( CommandRowContent { @@ -451,6 +457,7 @@ fn render_bookmark_rows( title, hint: Some(display), keys: None, + selected: is_selected, }, host.as_deref(), &initial, @@ -467,6 +474,7 @@ fn render_bookmark_rows( const PANEL_BG: u32 = 0xfffffff5; const BACKDROP_BG: u32 = 0x140f0a3d; const ROW_HOVER_BG: u32 = 0xc9644214; +const ROW_SELECTED_BG: u32 = 0xc964421f; const ROW_ICON_BG: u32 = 0xffffffd9; const BADGE_BG: u32 = 0x281e140f; diff --git a/crates/ely_app/src/shell/mod.rs b/crates/ely_app/src/shell/mod.rs index f9bb479..82e8911 100644 --- a/crates/ely_app/src/shell/mod.rs +++ b/crates/ely_app/src/shell/mod.rs @@ -67,6 +67,7 @@ pub struct ElyShell { pub(crate) translucency_slider: Entity, pub(crate) workspace_picker_open: bool, pub(crate) sidebar_hover_expanded: bool, + pub(crate) command_selected_index: usize, download_action_error: Option, download_clear_confirmation: bool, download_security_confirmation: Option, @@ -179,6 +180,7 @@ impl ElyShell { translucency_slider, workspace_picker_open: false, sidebar_hover_expanded: false, + command_selected_index: 0, download_action_error: None, download_clear_confirmation: false, download_security_confirmation: None, diff --git a/crates/ely_app/src/shell/navigation.rs b/crates/ely_app/src/shell/navigation.rs index 309cca8..72cf538 100644 --- a/crates/ely_app/src/shell/navigation.rs +++ b/crates/ely_app/src/shell/navigation.rs @@ -81,6 +81,73 @@ impl ElyShell { self.command_input.update(cx, |input, cx| { input.set_value("", window, cx); }); + self.command_selected_index = 0; cx.notify(); } + + pub(crate) fn command_select_next(&mut self, total_rows: usize, cx: &mut Context) { + if total_rows == 0 { + self.command_selected_index = 0; + return; + } + let next = (self.command_selected_index + 1) % total_rows; + if next != self.command_selected_index { + self.command_selected_index = next; + cx.notify(); + } + } + + pub(crate) fn command_select_prev(&mut self, total_rows: usize, cx: &mut Context) { + if total_rows == 0 { + self.command_selected_index = 0; + return; + } + let prev = if self.command_selected_index == 0 { + total_rows - 1 + } else { + self.command_selected_index - 1 + }; + if prev != self.command_selected_index { + self.command_selected_index = prev; + cx.notify(); + } + } + + pub(crate) fn activate_selected_command( + &mut self, + window: &mut Window, + cx: &mut Context, + ) { + let snapshot = match &self.state { + ShellState::Ready(core) => match core.snapshot() { + Ok(snapshot) => snapshot, + Err(_) => return, + }, + ShellState::StartupError(_) => return, + }; + let needle_owned = snapshot.command_query.as_str(); + let Some(stripped) = needle_owned.strip_prefix('>') else { + return; + }; + let needle = stripped.trim().to_lowercase(); + let rows = crate::shell::chrome::command_match::visible_command_rows( + &snapshot, &needle, + ); + if rows.is_empty() { + return; + } + let index = self.command_selected_index.min(rows.len() - 1); + match rows[index].clone() { + crate::shell::chrome::command_match::CommandSelection::SelectTab(tab_id) => { + self.select_tab(&tab_id, window, cx); + } + crate::shell::chrome::command_match::CommandSelection::OpenUrl(url) => { + self.open_internal_tab(url.as_str(), window, cx); + } + crate::shell::chrome::command_match::CommandSelection::OpenRoute(route) => { + self.open_internal_tab(route, window, cx); + } + } + self.dismiss_command_mode(window, cx); + } } diff --git a/crates/ely_app/src/shell/render.rs b/crates/ely_app/src/shell/render.rs index 4ea3666..c02da52 100644 --- a/crates/ely_app/src/shell/render.rs +++ b/crates/ely_app/src/shell/render.rs @@ -2,11 +2,12 @@ use ely_browser_core::BrowserSnapshot; use ely_design_system::{colors, spacing}; use ely_domain::{BrowserTab, DEFAULT_SIDEBAR_WIDTH_PX, HIDDEN_SIDEBAR_WIDTH_PX}; use gpui::{ - AnyElement, Context, InteractiveElement, IntoElement, MouseMoveEvent, ParentElement, Render, - SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::FluentBuilder, px, - rgb, rgba, + AnyElement, Context, InteractiveElement, IntoElement, KeyDownEvent, MouseMoveEvent, + ParentElement, Render, SharedString, StatefulInteractiveElement, Styled, Window, div, + prelude::FluentBuilder, px, rgb, rgba, }; +use super::chrome::command_match::visible_command_rows; use super::chrome::{ panel_bg, panel_shadow, render_command_overlay, render_topbar as render_topbar_chrome, render_wallpaper, @@ -67,6 +68,7 @@ impl ElyShell { .on_action(cx.listener(Self::on_zoom_in)) .on_action(cx.listener(Self::on_zoom_out)) .on_mouse_move(cx.listener(Self::on_window_mouse_move)) + .capture_key_down(cx.listener(Self::on_command_overlay_key_down)) .text_color(rgb(colors::INK)) .child(render_wallpaper(snapshot.appearance.wallpaper())) .child( @@ -88,7 +90,7 @@ impl ElyShell { .when(hover_expanded, |el| { el.child(self.render_hidden_sidebar_overlay(&snapshot, cx)) }) - .children(render_command_overlay(&snapshot, cx)) + .children(render_command_overlay(self, &snapshot, cx)) .into_any_element() } @@ -167,6 +169,40 @@ impl ElyShell { self.render_expanded_sidebar(snapshot, sidebar_width, cx) } + fn on_command_overlay_key_down( + &mut self, + event: &KeyDownEvent, + window: &mut Window, + cx: &mut Context, + ) { + let key = event.keystroke.key.as_str(); + if !matches!(key, "up" | "down" | "enter") { + return; + } + + let total_rows = match &self.state { + ShellState::Ready(core) => match core.snapshot() { + Ok(snapshot) => { + let Some(stripped) = snapshot.command_query.strip_prefix('>') else { + return; + }; + let needle = stripped.trim().to_lowercase(); + visible_command_rows(&snapshot, &needle).len() + } + Err(_) => return, + }, + ShellState::StartupError(_) => return, + }; + + match key { + "up" => self.command_select_prev(total_rows, cx), + "down" => self.command_select_next(total_rows, cx), + "enter" => self.activate_selected_command(window, cx), + _ => {} + } + cx.stop_propagation(); + } + fn on_window_mouse_move( &mut self, event: &MouseMoveEvent,