diff --git a/app/src/search/data_source.rs b/app/src/search/data_source.rs index 832c93e7..2cfe968e 100644 --- a/app/src/search/data_source.rs +++ b/app/src/search/data_source.rs @@ -458,6 +458,10 @@ impl QueryResult { self.item.accessibility_help_message() } + pub fn detail_data(&self) -> Option { + self.item.detail_data() + } + /// Returns an optional deduplication key for this item from the [`SearchItem`]. pub fn dedup_key(&self) -> Option { self.item.dedup_key() diff --git a/app/src/search/item.rs b/app/src/search/item.rs index 3bdf3064..0525638d 100644 --- a/app/src/search/item.rs +++ b/app/src/search/item.rs @@ -1,11 +1,19 @@ use ordered_float::OrderedFloat; use warp_core::ui::theme::Fill; +use warpui::fonts::FamilyId; use warpui::{Action, AppContext, Element}; use crate::appearance::Appearance; use super::result_renderer::ItemHighlightState; +#[derive(Clone)] +pub struct SearchItemDetail { + pub title: String, + pub description: Option, + pub title_font_family: FamilyId, +} + /// Location where icon should be rendered relative to the [`SearchItem`]. pub enum IconLocation { /// Icon should be centered within the element. @@ -109,4 +117,8 @@ pub trait SearchItem: Send + Sync { fn tooltip(&self) -> Option { None } + + fn detail_data(&self) -> Option { + None + } } diff --git a/app/src/terminal/input/slash_commands/cloud_mode_v2_view.rs b/app/src/terminal/input/slash_commands/cloud_mode_v2_view.rs index 5db6654f..ec37f3b7 100644 --- a/app/src/terminal/input/slash_commands/cloud_mode_v2_view.rs +++ b/app/src/terminal/input/slash_commands/cloud_mode_v2_view.rs @@ -1,13 +1,15 @@ use std::collections::{HashMap, HashSet}; use std::sync::LazyLock; +use pathfinder_geometry::vector::vec2f; use warp_core::ui::appearance::Appearance; use warp_core::ui::theme::Fill; use warpui::elements::{ - Border, ClippedScrollStateHandle, ClippedScrollable, ConstrainedBox, Container, CornerRadius, - CrossAxisAlignment, DispatchEventResult, DropShadow, EventHandler, Flex, Hoverable, - MainAxisSize, MouseInBehavior, MouseStateHandle, ParentElement, Radius, SavePosition, - ScrollTarget, ScrollToPositionMode, ScrollbarWidth, Text, + Border, ChildAnchor, Clipped, ClippedScrollStateHandle, ClippedScrollable, ConstrainedBox, + Container, CornerRadius, CrossAxisAlignment, DispatchEventResult, DropShadow, EventHandler, + Flex, Hoverable, MainAxisSize, MouseInBehavior, MouseStateHandle, OffsetPositioning, + ParentElement, PositionedElementAnchor, PositionedElementOffsetBounds, Radius, SavePosition, + ScrollTarget, ScrollToPositionMode, ScrollbarWidth, Stack, Text, }; use warpui::platform::Cursor; use warpui::{ @@ -15,6 +17,7 @@ use warpui::{ }; use crate::search::data_source::QueryFilter; +use crate::search::item::SearchItemDetail; use crate::search::mixer::{AddAsyncSourceOptions, SearchMixer, SearchMixerEvent}; use crate::search::result_renderer::{QueryResultRenderer, QueryResultRendererStyles}; use crate::terminal::input::buffer_model::{InputBufferModel, InputBufferUpdateEvent}; @@ -55,10 +58,40 @@ const DIVIDER_HEIGHT: f32 = 1.; const DIVIDER_VERTICAL_PADDING: f32 = 0.; +const SIDECAR_WIDTH: f32 = MENU_WIDTH; + +const SIDECAR_MAX_HEIGHT: f32 = 240.; + +const SIDECAR_GAP: f32 = 2.; + +const SIDECAR_DESCRIPTION_FONT_SIZE: f32 = 12.; + +const SIDECAR_TITLE_TO_DESCRIPTION_GAP: f32 = 4.; + +const NAME_DESCRIPTION_GAP_PX: f32 = 8.; + fn row_position_id(visible_idx: usize) -> String { format!("cloud_mode_v2_slash_row_{visible_idx}") } +fn item_is_truncated_in_row(detail: &SearchItemDetail, app: &AppContext) -> bool { + let appearance = Appearance::as_ref(app); + let font_size = inline_styles::font_size(appearance); + let font_cache = app.font_cache(); + let name_em = font_cache.em_width(detail.title_font_family, font_size); + let name_px = name_em * detail.title.chars().count() as f32; + let row_chrome_px = MENU_HORIZONTAL_PADDING * 2. + ICON_SIZE + inline_styles::ICON_MARGIN; + let available = MENU_WIDTH - row_chrome_px; + match &detail.description { + Some(description) => { + let description_em = font_cache.em_width(appearance.ui_font_family(), font_size); + let description_px = description_em * description.chars().count() as f32; + (name_px + NAME_DESCRIPTION_GAP_PX + description_px) > available + } + None => name_px > available, + } +} + static QUERY_RESULT_RENDERER_STYLES: LazyLock = LazyLock::new(|| QueryResultRendererStyles { result_item_height_fn: |appearance| appearance.monospace_font_size() + 8., @@ -822,46 +855,121 @@ impl CloudModeV2SlashCommandView { .with_vertical_padding(ROW_VERTICAL_PADDING) .finish() } -} - -impl Entity for CloudModeV2SlashCommandView { - type Event = SlashCommandsEvent; -} - -impl TypedActionView for CloudModeV2SlashCommandView { - type Action = CloudModeV2SlashCommandAction; - fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext) { - match action { - CloudModeV2SlashCommandAction::Accept { - item, - cmd_or_ctrl_enter, + fn selected_detail_data(&self) -> Option { + match &self.menu_state { + MenuState::NoSearchActive { + sections, + expanded_sections, + selected_idx, + .. } => { - self.emit_selection(item, *cmd_or_ctrl_enter, ctx); - } - CloudModeV2SlashCommandAction::HoverIdx(idx) => { - let idx = *idx; - match &self.menu_state { - MenuState::NoSearchActive { .. } => self.set_browsing_selection(idx, ctx), - MenuState::SearchActive { .. } => self.set_search_selection(idx, ctx), + let idx = (*selected_idx)?; + let row = browsing_rows(sections, expanded_sections) + .get(idx) + .copied()?; + match row { + NoSearchActiveRow::Item { section, item_idx } => { + let rendered = sections.iter().find(|s| s.section == section)?; + let renderer = rendered.items.get(item_idx)?; + renderer.search_result.detail_data() + } + _ => None, } } - CloudModeV2SlashCommandAction::ToggleSection(section) => { - self.toggle_section(*section, ctx); - } - CloudModeV2SlashCommandAction::Dismiss => { - self.dismiss(ctx); + MenuState::SearchActive { + results, + selected_idx, + } => { + let idx = (*selected_idx)?; + let renderer = results.get(idx)?; + renderer.search_result.detail_data() } } } -} -impl View for CloudModeV2SlashCommandView { - fn ui_name() -> &'static str { - "CloudModeV2SlashCommandView" + fn selected_visible_idx(&self) -> Option { + match &self.menu_state { + MenuState::NoSearchActive { selected_idx, .. } => *selected_idx, + MenuState::SearchActive { selected_idx, .. } => *selected_idx, + } } - fn render(&self, app: &AppContext) -> Box { + fn render_sidecar_if_eligible(&self, app: &AppContext) -> Option<(String, Box)> { + let detail = self.selected_detail_data()?; + if !item_is_truncated_in_row(&detail, app) { + return None; + } + let visible_idx = self.selected_visible_idx()?; + Some(( + row_position_id(visible_idx), + self.render_sidecar_panel(&detail, app), + )) + } + + fn render_sidecar_panel( + &self, + detail: &SearchItemDetail, + app: &AppContext, + ) -> Box { + let appearance = Appearance::as_ref(app); + let theme = appearance.theme(); + let menu_bg = inline_styles::menu_background_color(app); + let primary = inline_styles::primary_text_color(theme, menu_bg.into()); + let secondary = inline_styles::secondary_text_color(theme, menu_bg.into()); + + let title = Text::new_inline( + detail.title.clone(), + detail.title_font_family, + inline_styles::font_size(appearance), + ) + .with_color(primary.into()) + .finish(); + + let mut column = Flex::column() + .with_cross_axis_alignment(CrossAxisAlignment::Start) + .with_main_axis_size(MainAxisSize::Min) + .with_child(title); + + if let Some(description_text) = detail.description.clone() { + let description = Text::new( + description_text, + appearance.ui_font_family(), + SIDECAR_DESCRIPTION_FONT_SIZE, + ) + .with_color(secondary.into()) + .finish(); + column = column.with_child( + Container::new(description) + .with_margin_top(SIDECAR_TITLE_TO_DESCRIPTION_GAP) + .finish(), + ); + } + + Container::new( + ConstrainedBox::new( + Clipped::new( + Container::new(column.finish()) + .with_horizontal_padding(MENU_HORIZONTAL_PADDING) + .with_vertical_padding(ROW_VERTICAL_PADDING) + .finish(), + ) + .finish(), + ) + .with_max_width(SIDECAR_WIDTH) + .with_max_height(SIDECAR_MAX_HEIGHT) + .finish(), + ) + .with_background(Fill::Solid(menu_bg)) + .with_border(Border::all(1.).with_border_fill(Fill::Solid(theme.outline().into_solid()))) + .with_corner_radius(CornerRadius::with_all(Radius::Pixels(MENU_CORNER_RADIUS))) + .with_padding_top(MENU_VERTICAL_PADDING) + .with_padding_bottom(MENU_VERTICAL_PADDING) + .with_drop_shadow(DropShadow::default()) + .finish() + } + + fn render_menu_panel(&self, app: &AppContext) -> Box { let appearance = Appearance::as_ref(app); let theme = appearance.theme(); let menu_bg = inline_styles::menu_background_color(app); @@ -925,6 +1033,64 @@ impl View for CloudModeV2SlashCommandView { } } +impl Entity for CloudModeV2SlashCommandView { + type Event = SlashCommandsEvent; +} + +impl TypedActionView for CloudModeV2SlashCommandView { + type Action = CloudModeV2SlashCommandAction; + + fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext) { + match action { + CloudModeV2SlashCommandAction::Accept { + item, + cmd_or_ctrl_enter, + } => { + self.emit_selection(item, *cmd_or_ctrl_enter, ctx); + } + CloudModeV2SlashCommandAction::HoverIdx(idx) => { + let idx = *idx; + match &self.menu_state { + MenuState::NoSearchActive { .. } => self.set_browsing_selection(idx, ctx), + MenuState::SearchActive { .. } => self.set_search_selection(idx, ctx), + } + } + CloudModeV2SlashCommandAction::ToggleSection(section) => { + self.toggle_section(*section, ctx); + } + CloudModeV2SlashCommandAction::Dismiss => { + self.dismiss(ctx); + } + } + } +} + +impl View for CloudModeV2SlashCommandView { + fn ui_name() -> &'static str { + "CloudModeV2SlashCommandView" + } + + fn render(&self, app: &AppContext) -> Box { + let menu_panel = self.render_menu_panel(app); + let Some((row_position_id, sidecar)) = self.render_sidecar_if_eligible(app) else { + return menu_panel; + }; + let mut stack = Stack::new(); + stack.add_child(menu_panel); + stack.add_positioned_overlay_child( + sidecar, + OffsetPositioning::offset_from_save_position_element( + row_position_id, + vec2f(SIDECAR_GAP, 0.), + PositionedElementOffsetBounds::WindowByPosition, + PositionedElementAnchor::BottomRight, + ChildAnchor::BottomLeft, + ), + ); + stack.finish() + } +} + fn render_section_header(section: Section, app: &AppContext) -> Box { let appearance = Appearance::as_ref(app); let theme = appearance.theme(); diff --git a/app/src/terminal/input/slash_commands/search_item.rs b/app/src/terminal/input/slash_commands/search_item.rs index 5bddad1c..f5850dad 100644 --- a/app/src/terminal/input/slash_commands/search_item.rs +++ b/app/src/terminal/input/slash_commands/search_item.rs @@ -7,6 +7,7 @@ use warpui::prelude::{ConstrainedBox, Container, CrossAxisAlignment, Empty, Flex use warpui::{AppContext, Element, SingletonEntity}; use crate::ai::blocklist::agent_view::shortcuts::render_keystroke_with_color_overrides; +use crate::search::item::SearchItemDetail; use crate::search::slash_command_menu::static_commands::commands::COMMAND_REGISTRY; use crate::search::{ItemHighlightState, SearchItem}; use crate::terminal::input::inline_menu::styles as inline_styles; @@ -177,4 +178,12 @@ impl SearchItem for InlineItem { fn accessibility_label(&self) -> String { format!("{:?}", self.action) } + + fn detail_data(&self) -> Option { + Some(SearchItemDetail { + title: self.name.clone(), + description: self.description.clone(), + title_font_family: self.font_family, + }) + } }