use std::cmp; use std::collections::HashMap; use std::fmt::Debug; use std::sync::Arc; use instant::Instant; use pathfinder_geometry::vector::vec2f; use crate::{ ai::cloud_environments::CloudAmbientAgentEnvironment, cloud_object::model::generic_string_model::StringModel, editor::{ EditorOptions, EditorView, Event as EditorEvent, PropagateAndNoOpNavigationKeys, TextOptions, }, server::ids::{ClientId, HashableId, ServerId, SyncId}, ui_components::icons::Icon, view_components::copyable_text_field::{ render_copyable_text_field, CopyButtonPlacement, CopyableTextFieldConfig, COPY_FEEDBACK_DURATION, }, }; use fuzzy_match::{match_indices_case_insensitive, FuzzyMatchResult}; use galaxy_core::ui::theme::color::internal_colors; use galaxy_core::ui::{appearance::Appearance, builder::MIN_FONT_SIZE, theme::Fill}; use galaxy_editor::editor::NavigationKey; use galaxyui::units::Pixels; use galaxyui::{ color::ColorU, elements::Highlight, fonts::{Properties, Weight}, ui_components::components::{Coords, UiComponentStyles}, }; use galaxyui::{ elements::{ Border, ChildAnchor, ChildView, ClippedScrollStateHandle, ClippedScrollable, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Dismiss, DispatchEventResult, DropShadow, Empty, EventHandler, Flex, Hoverable, MainAxisAlignment, MainAxisSize, MouseInBehavior, MouseStateHandle, OffsetPositioning, ParentElement, PositionedElementAnchor, PositionedElementOffsetBounds, Radius, SavePosition, ScrollStateHandle, Scrollable, ScrollableElement, ScrollbarWidth, Shrinkable, Stack, Text, UniformList, UniformListState, }, keymap::FixedBinding, ui_components::components::UiComponent, AppContext, Element, Entity, FocusContext, SingletonEntity as _, TypedActionView, View, ViewContext, ViewHandle, WindowId, }; use galaxyui::clipboard::ClipboardContent; use galaxyui::r#async::Timer; /// Trait for items that can be displayed in a generic menu pub trait GenericMenuItem: Debug + 'static { /// Enable downcasting to concrete types fn as_any(&self) -> &dyn std::any::Any; /// Display name for the menu item fn name(&self) -> String; /// Icon to display for the menu item (None for no icon) fn icon(&self, _app: &AppContext) -> Option; /// Data associated with this menu item action fn action_data(&self) -> String; /// Optional element to render on the right side of the menu item fn right_side_element(&self, _app: &AppContext) -> Option> { None } } #[derive(Debug, Clone)] pub struct FixedFooter { action_item: Arc, mouse_state: MouseStateHandle, } impl FixedFooter { pub fn new(action_item: Arc) -> Self { Self { action_item, mouse_state: Default::default(), } } } #[derive(Copy, Clone, Debug, PartialEq)] pub enum ChipMenuType { Directories, Branches, CodeReview, Environments, } const LABEL_HORIZONTAL_PADDING: f32 = 14.; const SEARCH_INPUT_HORIZONTAL_PADDING: f32 = 8.; const LABEL_VERTICAL_PADDING: f32 = 5.; const MENU_VERTICAL_PADDING: f32 = 9.; const MENU_WIDTH: f32 = 360.; // Environments menu sizing from Figma mock. const ENV_MENU_WIDTH: f32 = 321.; const ENV_MENU_MAX_HEIGHT: f32 = 200.; const ENV_MENU_VERTICAL_PADDING: f32 = 4.; const ENV_MENU_ITEM_HORIZONTAL_PADDING: f32 = 16.; const ENV_MENU_ITEM_VERTICAL_PADDING: f32 = 4.; const ENV_MENU_ICON_SIZE: f32 = 16.; const ENV_MENU_ICON_SLOT_SIZE: f32 = 16.; const ENV_MENU_ITEM_FONT_SIZE: f32 = 14.; const ENV_MENU_SEARCH_VERTICAL_PADDING: f32 = 4.; // Bottom padding under the search field. The model selector's bottom padding // is effectively `SEARCH_VERTICAL_PADDING (4) + MENU_CONTENT_VERTICAL_PADDING // (4) = 8` because its `Menu` wraps the pinned footer in another 4px of // content padding. We don't have that wrapper, so we bake the same 8px // directly into the footer container. const ENV_MENU_SEARCH_BOTTOM_PADDING: f32 = 8.; const ENV_MENU_SEARCH_FOOTER_TOP_MARGIN: f32 = 4.; // Environments sidecar sizing from Figma mock. const ENV_SIDE_CAR_WIDTH: f32 = 320.; const ENV_SIDE_CAR_HEIGHT: f32 = 108.; const ENV_SIDE_CAR_HORIZONTAL_GAP: f32 = 1.; const ENV_SIDE_CAR_PADDING: f32 = 12.; const ENV_SIDE_CAR_ROW_GAP: f32 = 8.; const ENV_SIDE_CAR_ICON_LABEL_GAP: f32 = 4.; const ENV_SIDE_CAR_ICON_SIZE: f32 = 12.; const ENV_SIDE_CAR_COPY_BUTTON_SIZE: f32 = 16.; const ENV_SIDE_CAR_OUTER_RADIUS: f32 = 6.; const ENV_SIDE_CAR_INNER_RADIUS: f32 = 4.; pub fn init(app: &mut AppContext) { use galaxyui::keymap::macros::*; app.register_fixed_bindings([ FixedBinding::new( "up", DisplayChipMenuAction::SelectUp, id!(DisplayChipMenu::ui_name()), ), FixedBinding::new( "down", DisplayChipMenuAction::SelectDown, id!(DisplayChipMenu::ui_name()), ), FixedBinding::new( "escape", DisplayChipMenuAction::Close, id!(DisplayChipMenu::ui_name()), ), FixedBinding::new( "enter", DisplayChipMenuAction::SelectEnter, id!(DisplayChipMenu::ui_name()), ), ]); } #[derive(Debug, Clone)] struct FilteredMenuItem { item: Arc, match_result: Option, } #[derive(Clone, Debug)] struct EnvironmentSidecarData { name: String, id: String, image: String, repos_text: String, } #[derive(Copy, Clone, Debug, PartialEq, Eq)] enum EnvironmentSidecarSide { Left, Right, } pub struct DisplayChipMenu { list_state: UniformListState, scroll_state: ScrollStateHandle, menu_items: Vec>, filtered_items: Vec, selected_index: usize, is_footer_selected: bool, fixed_footer: Option, search_input: Option>, search_query: String, chip_menu_type: ChipMenuType, // Environment sidecar state window_id: WindowId, env_sidecar_copy_id_mouse_state: MouseStateHandle, env_sidecar_copy_image_mouse_state: MouseStateHandle, env_sidecar_copy_feedback_times: HashMap, env_sidecar_scroll_state: ClippedScrollStateHandle, } #[derive(Debug, Clone)] pub enum DisplayChipMenuAction { SelectItem { index: usize }, Select { index: usize }, SelectUp, SelectDown, SelectEnter, SelectFixedFooterOption, CopyEnvironmentSidecarField { key: String, value: String }, Close, } impl DisplayChipMenu { fn menu_width(&self) -> f32 { match self.chip_menu_type { ChipMenuType::Environments => ENV_MENU_WIDTH, ChipMenuType::Directories | ChipMenuType::Branches | ChipMenuType::CodeReview => { MENU_WIDTH } } } fn menu_item_horizontal_padding(&self) -> f32 { match self.chip_menu_type { ChipMenuType::Environments => ENV_MENU_ITEM_HORIZONTAL_PADDING, ChipMenuType::Directories | ChipMenuType::Branches | ChipMenuType::CodeReview => { LABEL_HORIZONTAL_PADDING } } } fn menu_item_vertical_padding(&self) -> f32 { match self.chip_menu_type { ChipMenuType::Environments => ENV_MENU_ITEM_VERTICAL_PADDING, ChipMenuType::Directories | ChipMenuType::Branches | ChipMenuType::CodeReview => { LABEL_VERTICAL_PADDING } } } fn menu_vertical_padding(&self) -> f32 { match self.chip_menu_type { ChipMenuType::Environments => ENV_MENU_VERTICAL_PADDING, ChipMenuType::Directories | ChipMenuType::Branches | ChipMenuType::CodeReview => { MENU_VERTICAL_PADDING } } } fn figma_menu_drop_shadow() -> DropShadow { DropShadow::default() } pub fn new( menu_items: Vec, fixed_footer_option: Option, chip_menu_type: ChipMenuType, ctx: &mut ViewContext, ) -> Self { let search_input = match chip_menu_type { ChipMenuType::Directories | ChipMenuType::Branches | ChipMenuType::Environments => { Some(ctx.add_typed_action_view(|ctx| { let appearance = Appearance::handle(ctx).as_ref(ctx); let text_options = match chip_menu_type { ChipMenuType::Environments => { TextOptions::ui_text(Some(ENV_MENU_ITEM_FONT_SIZE), appearance) } ChipMenuType::Directories | ChipMenuType::Branches | ChipMenuType::CodeReview => { let ui_font_family = appearance.ui_font_family(); let mut options = TextOptions::ui_font_size(appearance); options.font_family_override = Some(ui_font_family); options } }; let options = EditorOptions { autogrow: false, soft_wrap: false, single_line: true, text: text_options, propagate_and_no_op_vertical_navigation_keys: PropagateAndNoOpNavigationKeys::Always, ..Default::default() }; let mut editor = EditorView::new(options, ctx); let placeholder_text = match chip_menu_type { ChipMenuType::Directories => "Search directories...", ChipMenuType::Branches => "Search branches...", ChipMenuType::Environments => "Search environments...", ChipMenuType::CodeReview => { unreachable!("search input should not be constructed") } }; editor.set_placeholder_text(placeholder_text, ctx); editor })) } ChipMenuType::CodeReview => None, }; // Subscribe to editor changes to update search query (only if search input exists) if let Some(ref search_input_handle) = search_input { ctx.subscribe_to_view( search_input_handle, |menu, _editor, event, ctx| match event { EditorEvent::Edited(_) => { if let Some(ref search_input) = menu.search_input { let new_query = search_input .read(ctx, |editor, ctx| editor.buffer_text(ctx).to_string()); if new_query != menu.search_query { menu.update_search_query(new_query, ctx); } } } EditorEvent::Escape => { menu.close(ctx); } EditorEvent::Navigate(NavigationKey::Up) => { menu.select_prev(ctx); } EditorEvent::Navigate(NavigationKey::Down) => { menu.select_next(ctx); } EditorEvent::Enter => { menu.select_enter(ctx); } _ => {} }, ); } let menu_items: Vec> = menu_items .into_iter() .map(|value| { let arc: Arc = Arc::new(value); arc }) .collect(); let filtered_items: Vec = menu_items .iter() .map(|item| FilteredMenuItem { item: item.clone(), match_result: None, }) .collect(); // Always start selection at the top (first item) for consistent behavior let initial_selected_index = 0; Self { list_state: Default::default(), scroll_state: Default::default(), menu_items, filtered_items, selected_index: initial_selected_index, fixed_footer: fixed_footer_option, is_footer_selected: false, search_input, search_query: String::new(), chip_menu_type, window_id: ctx.window_id(), env_sidecar_copy_id_mouse_state: Default::default(), env_sidecar_copy_image_mouse_state: Default::default(), env_sidecar_copy_feedback_times: HashMap::new(), env_sidecar_scroll_state: Default::default(), } } pub fn reset_selected_index(&mut self) { if self.filtered_items.is_empty() && self.fixed_footer.is_some() { self.is_footer_selected = true; return; } self.selected_index = 0; self.is_footer_selected = false; self.env_sidecar_scroll_state.scroll_to(Pixels::zero()); } /// Update the menu items and reset the selected index pub fn update_menu_items( &mut self, new_items: Vec, ctx: &mut ViewContext, ) { self.menu_items = new_items .into_iter() .map(|value| { let arc: Arc = Arc::new(value); arc }) .collect(); self.update_filtered_items(); self.reset_selected_index(); // Scroll to the selected item if !self.filtered_items.is_empty() { self.list_state.scroll_to(self.selected_index); } ctx.notify(); } fn update_filtered_items(&mut self) { if self.search_query.is_empty() { // No search query - show all items self.filtered_items = self .menu_items .iter() .map(|item| FilteredMenuItem { item: item.clone(), match_result: None, }) .collect(); } else { // Filter items based on search query self.filtered_items = self .menu_items .iter() .filter_map(|item| { let item_name = item.name(); match_indices_case_insensitive(&item_name, &self.search_query).map( |match_result| FilteredMenuItem { item: item.clone(), match_result: Some(match_result), }, ) }) .collect(); // Sort by match score (higher scores first) self.filtered_items.sort_by(|a, b| { let score_a = a.match_result.as_ref().map(|r| r.score).unwrap_or(0); let score_b = b.match_result.as_ref().map(|r| r.score).unwrap_or(0); score_b.cmp(&score_a) }); } } pub fn update_search_query(&mut self, query: String, ctx: &mut ViewContext) { self.search_query = query; self.update_filtered_items(); // Always start at the top after filtering for consistent behavior self.reset_selected_index(); if !self.filtered_items.is_empty() { self.list_state.scroll_to(self.selected_index); } ctx.notify(); } fn select_item(&mut self, item: Arc, ctx: &mut ViewContext) { ctx.emit(PromptDisplayMenuEvent::MenuAction(GenericMenuEvent { action_item: item.clone(), })); ctx.notify(); } fn select(&mut self, index: usize, ctx: &mut ViewContext) { if self.selected_index != index { self.selected_index = index; self.env_sidecar_scroll_state.scroll_to(Pixels::zero()); } ctx.notify(); } fn is_footer_selected(&self) -> bool { self.is_footer_selected || self .fixed_footer .as_ref() .is_some_and(|f| f.mouse_state.lock().is_ok_and(|state| state.is_hovered())) } fn select_prev(&mut self, ctx: &mut ViewContext) { if self.filtered_items.is_empty() { return; } let has_footer = self.fixed_footer.is_some(); if self.selected_index == 0 { if has_footer && !self.is_footer_selected() { self.is_footer_selected = true; } else { self.is_footer_selected = false; self.selected_index = self.filtered_items.len() - 1; self.env_sidecar_scroll_state.scroll_to(Pixels::zero()); } } else { self.is_footer_selected = false; self.selected_index -= 1; self.env_sidecar_scroll_state.scroll_to(Pixels::zero()); } self.list_state.scroll_to(self.selected_index); ctx.notify(); } fn select_next(&mut self, ctx: &mut ViewContext) { if self.filtered_items.is_empty() { return; } let has_footer = self.fixed_footer.is_some(); self.selected_index += 1; if self.is_footer_selected() { self.is_footer_selected = false; self.selected_index = 0; self.env_sidecar_scroll_state.scroll_to(Pixels::zero()); } else if self.selected_index >= self.filtered_items.len() { self.selected_index = 0; self.env_sidecar_scroll_state.scroll_to(Pixels::zero()); if has_footer && !self.is_footer_selected() { self.is_footer_selected = true; } } else { self.env_sidecar_scroll_state.scroll_to(Pixels::zero()); } self.list_state.scroll_to(self.selected_index); ctx.notify(); } fn select_enter(&mut self, ctx: &mut ViewContext) { if self.is_footer_selected() { self.select_fixed_footer_option(ctx); return; } if self.selected_index < self.filtered_items.len() { let item = self.filtered_items[self.selected_index].item.clone(); self.select_item(item, ctx); } } fn select_fixed_footer_option(&mut self, ctx: &mut ViewContext) { if let Some(footer_option) = &self.fixed_footer { ctx.emit(PromptDisplayMenuEvent::MenuAction(GenericMenuEvent { action_item: footer_option.action_item.clone(), })); ctx.notify(); } } fn close(&mut self, ctx: &mut ViewContext) { ctx.emit(PromptDisplayMenuEvent::CloseMenu); ctx.notify(); } fn should_show_environment_sidecar(&self) -> bool { self.chip_menu_type == ChipMenuType::Environments && !self.is_footer_selected() && self.selected_index < self.filtered_items.len() } fn parse_sync_id_lossy(s: &str) -> SyncId { if let Some(hashed) = ClientId::from_hash(s) { SyncId::ClientId(hashed) } else { SyncId::ServerId(ServerId::from_string_lossy(s)) } } fn environment_sidecar_data(&self, app: &AppContext) -> Option { if !self.should_show_environment_sidecar() { return None; } let item = self.filtered_items.get(self.selected_index)?.item.clone(); let sync_id = Self::parse_sync_id_lossy(&item.action_data()); let env = CloudAmbientAgentEnvironment::get_by_id(&sync_id, app)?; let repo_names = env .model() .string_model .github_repos .iter() .map(|repo| repo.repo.clone()) .collect::>(); let repos_text = if repo_names.is_empty() { "(none)".to_string() } else { repo_names.join(", ") }; Some(EnvironmentSidecarData { name: env.model().string_model.display_name(), id: env.id.to_string(), image: env.model().string_model.base_image.to_string(), repos_text, }) } fn environment_sidecar_anchor_id(&self) -> Option { if !self.should_show_environment_sidecar() { return None; } Some(format!("MenuPromptChip-{}", self.selected_index)) } fn environment_sidecar_side( &self, position_id: &str, app: &AppContext, ) -> EnvironmentSidecarSide { let Some(window) = app.windows().platform_window(self.window_id) else { return EnvironmentSidecarSide::Left; }; // Anchor is the currently selected/hovered row. let Some(anchor_rect) = app.element_position_by_id_at_last_frame(self.window_id, position_id) else { return EnvironmentSidecarSide::Left; }; let gap = ENV_SIDE_CAR_HORIZONTAL_GAP; let window_width = window.size().x(); // If sidecar is on the right of the anchor. let right_edge_if_on_right = anchor_rect.max_x() + gap + ENV_SIDE_CAR_WIDTH; let overflow_right = (right_edge_if_on_right - window_width).max(0.); // If sidecar is on the left of the anchor. let left_edge_if_on_left = anchor_rect.min_x() - gap - ENV_SIDE_CAR_WIDTH; let overflow_left = (0. - left_edge_if_on_left).max(0.); let would_overflow_right = overflow_right > 0.; let would_overflow_left = overflow_left > 0.; match (would_overflow_left, would_overflow_right) { (true, false) => EnvironmentSidecarSide::Right, (false, true) => EnvironmentSidecarSide::Left, (false, false) => EnvironmentSidecarSide::Left, (true, true) => { if overflow_left <= overflow_right { EnvironmentSidecarSide::Left } else { EnvironmentSidecarSide::Right } } } } fn environment_sidecar_positioning( &self, position_id: String, app: &AppContext, ) -> Option { // Ensure anchor rect exists in cache; otherwise positioning will be wrong. app.element_position_by_id_at_last_frame(self.window_id, &position_id)?; let side = self.environment_sidecar_side(&position_id, app); let offset_y = -ENV_MENU_VERTICAL_PADDING; Some(match side { EnvironmentSidecarSide::Right => OffsetPositioning::offset_from_save_position_element( position_id, vec2f(ENV_SIDE_CAR_HORIZONTAL_GAP, offset_y), PositionedElementOffsetBounds::WindowByPosition, PositionedElementAnchor::TopRight, ChildAnchor::TopLeft, ), EnvironmentSidecarSide::Left => OffsetPositioning::offset_from_save_position_element( position_id, vec2f(-ENV_SIDE_CAR_HORIZONTAL_GAP, offset_y), PositionedElementOffsetBounds::WindowByPosition, PositionedElementAnchor::TopLeft, ChildAnchor::TopRight, ), }) } fn environment_sidecar_overlay( &self, app: &AppContext, ) -> Option<(Box, OffsetPositioning)> { let data = self.environment_sidecar_data(app)?; let position_id = self.environment_sidecar_anchor_id()?; let positioning = self.environment_sidecar_positioning(position_id, app)?; Some((self.render_environment_sidecar(&data, app), positioning)) } fn render_environment_sidecar( &self, data: &EnvironmentSidecarData, app: &AppContext, ) -> Box { let appearance = Appearance::as_ref(app); let theme = appearance.theme(); let background = theme.surface_2(); let label_text_color = theme.sub_text_color(background).into_solid(); let main_text_color = theme.main_text_color(background).into_solid(); let label_font_size = 12.; let value_font_size = 12.; let id_key = format!("env-sidecar:{}:id", data.id); let image_key = format!("env-sidecar:{}:image", data.id); let icon = |icon: Icon| { ConstrainedBox::new(icon.to_galaxyui_icon(Fill::Solid(label_text_color)).finish()) .with_width(ENV_SIDE_CAR_ICON_SIZE) .with_height(ENV_SIDE_CAR_ICON_SIZE) .finish() }; let label_text = |text: &str| { Text::new_inline( text.to_string(), appearance.ui_font_family(), label_font_size, ) .with_color(label_text_color) .finish() }; let value_text = |text: String| { Text::new(text, appearance.ui_font_family(), value_font_size) .with_color(main_text_color) .with_selectable(true) .finish() }; let id_value = { let env_id = data.id.clone(); render_copyable_text_field( CopyableTextFieldConfig::new(env_id.clone()) .with_font_size(value_font_size) .with_text_color(main_text_color) .with_icon_size(ENV_SIDE_CAR_COPY_BUTTON_SIZE) .with_mouse_state(self.env_sidecar_copy_id_mouse_state.clone()) .with_last_copied_at(self.env_sidecar_copy_feedback_times.get(&id_key)) .with_wrap_text(true) .with_cross_axis_alignment(CrossAxisAlignment::Start) .with_copy_button_placement(CopyButtonPlacement::NextToText), move |ctx| { ctx.dispatch_typed_action(DisplayChipMenuAction::CopyEnvironmentSidecarField { key: id_key.clone(), value: env_id.clone(), }); }, app, ) }; let image_value = { let docker_image = data.image.clone(); render_copyable_text_field( CopyableTextFieldConfig::new(docker_image.clone()) .with_font_size(value_font_size) .with_text_color(main_text_color) .with_icon_size(ENV_SIDE_CAR_COPY_BUTTON_SIZE) .with_mouse_state(self.env_sidecar_copy_image_mouse_state.clone()) .with_last_copied_at(self.env_sidecar_copy_feedback_times.get(&image_key)) .with_wrap_text(true) .with_cross_axis_alignment(CrossAxisAlignment::Start) .with_copy_button_placement(CopyButtonPlacement::NextToText), move |ctx| { ctx.dispatch_typed_action(DisplayChipMenuAction::CopyEnvironmentSidecarField { key: image_key.clone(), value: docker_image.clone(), }); }, app, ) }; let row = |row_icon: Icon, label: &str, value: Box, is_last: bool| { let label_cluster = Flex::row() .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_child( Container::new(icon(row_icon)) .with_margin_right(ENV_SIDE_CAR_ICON_LABEL_GAP) .finish(), ) .with_child(label_text(label)) .finish(); let element = Flex::row() .with_main_axis_size(MainAxisSize::Max) .with_cross_axis_alignment(CrossAxisAlignment::Start) .with_child( Container::new(label_cluster) .with_margin_right(ENV_SIDE_CAR_ROW_GAP) .finish(), ) .with_child(Shrinkable::new(1., value).finish()) .finish(); if is_last { element } else { Container::new(element) .with_margin_bottom(ENV_SIDE_CAR_ROW_GAP) .finish() } }; let content = Flex::column() .with_cross_axis_alignment(CrossAxisAlignment::Start) .with_child(row( Icon::Globe4, "Name:", value_text(data.name.clone()), false, )) .with_child(row(Icon::Hash, "ID:", id_value, false)) .with_child(row(Icon::Docker, "Image:", image_value, false)) .with_child(row( Icon::Github, "Repos:", value_text(data.repos_text.clone()), true, )) .finish(); let scrollable_content = ClippedScrollable::vertical( self.env_sidecar_scroll_state.clone(), content, ScrollbarWidth::Auto, theme.nonactive_ui_detail().into(), theme.active_ui_detail().into(), // Leave the scrollbar gutter background transparent. galaxyui::elements::Fill::None, ) .with_padding_start(0.) .with_padding_end(0.) .with_overlayed_scrollbar() .finish(); let inner = Container::new(scrollable_content) .with_uniform_padding(ENV_SIDE_CAR_PADDING) .with_border( Border::all(1.).with_border_fill(Fill::Solid(internal_colors::neutral_2(theme))), ) .with_corner_radius(CornerRadius::with_all(Radius::Pixels( ENV_SIDE_CAR_INNER_RADIUS, ))) .finish(); let outer = Container::new(inner) .with_background(background) .with_border( Border::all(1.).with_border_fill(Fill::Solid(internal_colors::neutral_4(theme))), ) .with_corner_radius(CornerRadius::with_all(Radius::Pixels( ENV_SIDE_CAR_OUTER_RADIUS, ))) .with_drop_shadow(Self::figma_menu_drop_shadow()) .finish(); ConstrainedBox::new(outer) .with_width(ENV_SIDE_CAR_WIDTH) .with_min_height(ENV_SIDE_CAR_HEIGHT) .finish() } fn render_fixed_footer_option( &self, app: &AppContext, footer_option: &FixedFooter, ) -> Box { let appearance = Appearance::as_ref(app); let theme = appearance.theme(); let chip_menu_type = self.chip_menu_type; let (font_size, icon_size) = match chip_menu_type { ChipMenuType::Environments => (ENV_MENU_ITEM_FONT_SIZE, ENV_MENU_ICON_SIZE), ChipMenuType::Directories | ChipMenuType::Branches | ChipMenuType::CodeReview => { let font_size = appearance.ui_font_size(); (font_size, font_size * 0.8) } }; let item_horizontal_padding = self.menu_item_horizontal_padding(); let item_vertical_padding = self.menu_item_vertical_padding(); let is_footer_selected = self.is_footer_selected(); ConstrainedBox::new( Hoverable::new(footer_option.mouse_state.clone(), move |mouse_state| { let is_active = mouse_state.is_hovered() || is_footer_selected; let background_color = if is_active { match chip_menu_type { ChipMenuType::Environments => Some(internal_colors::fg_overlay_4(theme)), ChipMenuType::Directories | ChipMenuType::Branches | ChipMenuType::CodeReview => Some(theme.accent()), } } else { None }; let text_color = if is_active { match chip_menu_type { ChipMenuType::Environments => { theme.main_text_color(theme.surface_2()).into_solid() } ChipMenuType::Directories | ChipMenuType::Branches | ChipMenuType::CodeReview => { theme.main_text_color(theme.accent()).into_solid() } } } else { theme.sub_text_color(theme.surface_2()).into_solid() }; // Update icon and text colors based on hover state let mut updated_text = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center); // Add icon if it exists if let Some(icon) = footer_option.action_item.icon(app) { updated_text.add_child( Container::new( ConstrainedBox::new( icon.to_galaxyui_icon(Fill::Solid(text_color)).finish(), ) .with_height(icon_size) .with_width(icon_size) .finish(), ) .with_margin_right(8.) .finish(), ); } else { // Add spacing equivalent to icon + margin for alignment updated_text.add_child( ConstrainedBox::new(Empty::new().finish()) .with_width(icon_size + 8.) .finish(), ); } // Add the text element updated_text.add_child( Text::new_inline( footer_option.action_item.name(), appearance.ui_font_family(), font_size, ) .autosize_text(MIN_FONT_SIZE) .with_color(text_color) .finish(), ); let mut container = Container::new(updated_text.finish()) .with_horizontal_padding(item_horizontal_padding) .with_vertical_padding(item_vertical_padding) .with_border(Border::top(1.0)); if let Some(bg_color) = background_color { container = container.with_background(bg_color); } container.finish() }) .on_click(|ctx, _, _| { ctx.dispatch_typed_action(DisplayChipMenuAction::SelectFixedFooterOption); }) .finish(), ) .with_width(self.menu_width()) .finish() } fn render_env_search_footer( &self, search_input: &ViewHandle, app: &AppContext, ) -> Box { let theme = Appearance::as_ref(app).theme(); let divider_color = internal_colors::fg_overlay_2(theme); Container::new(ChildView::new(search_input).finish()) .with_margin_top(ENV_MENU_SEARCH_FOOTER_TOP_MARGIN) .with_horizontal_padding(ENV_MENU_ITEM_HORIZONTAL_PADDING) .with_padding_top(ENV_MENU_SEARCH_VERTICAL_PADDING) .with_padding_bottom(ENV_MENU_SEARCH_BOTTOM_PADDING) .with_border(Border::top(1.).with_border_fill(divider_color)) .finish() } fn render_items(&self, ctx: &AppContext) -> Box { let appearance = Appearance::as_ref(ctx); let theme = appearance.theme(); if self.filtered_items.is_empty() { // Show "No results" if search is active but no matches. if !self.search_query.is_empty() { // Match the model selector dropdown's no-results row exactly // for the environments menu (label, font size, paddings, and // text color); other chip menus keep their existing styling. let (label, font_size, horizontal_padding, vertical_padding, text_color) = match self.chip_menu_type { ChipMenuType::Environments => ( "No results", ENV_MENU_ITEM_FONT_SIZE, ENV_MENU_ITEM_HORIZONTAL_PADDING, ENV_MENU_ITEM_VERTICAL_PADDING, internal_colors::text_sub(theme, theme.surface_2()), ), ChipMenuType::Directories | ChipMenuType::Branches | ChipMenuType::CodeReview => ( "No results found", appearance.ui_font_size(), LABEL_HORIZONTAL_PADDING, LABEL_VERTICAL_PADDING * 2.0, theme.sub_text_color(theme.surface_2()).into_solid(), ), }; return Container::new( Text::new(label, appearance.ui_font_family(), font_size) .with_color(text_color) .finish(), ) .with_horizontal_padding(horizontal_padding) .with_vertical_padding(vertical_padding) .finish(); } return Empty::new().finish(); } let selected_index = self.selected_index; let filtered_items_length = self.filtered_items.len(); let filtered_items = self.filtered_items.clone(); let is_footer_hovered = self.is_footer_selected(); let menu_width = self.menu_width(); let item_horizontal_padding = self.menu_item_horizontal_padding(); let item_vertical_padding = self.menu_item_vertical_padding(); let chip_menu_type = self.chip_menu_type; let list = UniformList::new( self.list_state.clone(), filtered_items.len(), move |mut range, app| { let appearance = Appearance::as_ref(app); let theme = appearance.theme(); range.end = cmp::min(range.end, filtered_items.len()); range .map(|index| { let filtered_item = &filtered_items[index]; let item = &filtered_item.item; let display_text_str = item.name(); let is_selected = index == selected_index && !is_footer_hovered; let font_size = if matches!(chip_menu_type, ChipMenuType::Environments) { ENV_MENU_ITEM_FONT_SIZE } else { appearance.ui_font_size() }; let icon_size = font_size * 0.8; // Icon slightly smaller than text let (main_text, selected_background) = match chip_menu_type { ChipMenuType::Environments => ( theme.main_text_color(theme.surface_2()).into_solid(), is_selected.then_some(internal_colors::fg_overlay_4(theme)), ), ChipMenuType::Directories | ChipMenuType::Branches | ChipMenuType::CodeReview => { if is_selected { let bg = theme.accent(); (theme.main_text_color(bg).into_solid(), Some(bg)) } else { (theme.main_text_color(theme.surface_2()).into_solid(), None) } } }; // Create main container with SpaceBetween to float right elements to far right let mut main_container = Flex::row() .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_main_axis_size(MainAxisSize::Max); // Create left side container with icon and main text let mut left_side = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center); let icon_gap = 8.; if matches!(chip_menu_type, ChipMenuType::Environments) { if let Some(icon) = item.icon(app) { let icon_slot_size = ENV_MENU_ICON_SLOT_SIZE; let glyph_size = ENV_MENU_ICON_SIZE; let icon_glyph = ConstrainedBox::new( icon.to_galaxyui_icon(Fill::Solid(main_text)).finish(), ) .with_width(glyph_size) .with_height(glyph_size) .finish(); let icon_slot = ConstrainedBox::new( Flex::row() .with_main_axis_alignment(MainAxisAlignment::Center) .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_child(icon_glyph) .finish(), ) .with_width(icon_slot_size) .with_height(icon_slot_size) .finish(); left_side.add_child( Container::new(icon_slot) .with_margin_right(icon_gap) .finish(), ); } } else if let Some(icon) = item.icon(app) { left_side.add_child( Container::new( ConstrainedBox::new( icon.to_galaxyui_icon(Fill::Solid(main_text)).finish(), ) .with_height(icon_size) .with_width(icon_size) .finish(), ) .with_margin_right(icon_gap) .finish(), ); } else { // Add spacing equivalent to icon + margin for alignment left_side.add_child( ConstrainedBox::new(Empty::new().finish()) .with_width(icon_size + icon_gap) .finish(), ); } // Create main text with highlighting if there's a match result let display_text = if let Some(match_result) = &filtered_item.match_result { Text::new_inline( display_text_str, appearance.ui_font_family(), font_size, ) .autosize_text(MIN_FONT_SIZE) .with_color(main_text) .with_single_highlight( Highlight::new() .with_properties(Properties::default().weight(Weight::Bold)) .with_foreground_color(main_text), match_result.matched_indices.clone(), ) } else { Text::new_inline( display_text_str, appearance.ui_font_family(), font_size, ) .autosize_text(MIN_FONT_SIZE) .with_color(main_text) }; left_side.add_child(display_text.finish()); // Add left side to main container main_container.add_child(left_side.finish()); // Add right-side element if available, using SpaceBetween alignment if let Some(right_element) = item.right_side_element(app) { main_container = main_container .with_main_axis_alignment(MainAxisAlignment::SpaceBetween); main_container.add_child(right_element); } let mut container = Container::new(main_container.finish()) .with_horizontal_padding(item_horizontal_padding) .with_vertical_padding(item_vertical_padding); if !matches!(chip_menu_type, ChipMenuType::Environments) && (is_selected || index < filtered_items_length - 1) { container = container.with_border(Border::bottom(1.0)); } if let Some(bg) = selected_background { container = container.with_background(bg); } SavePosition::new( EventHandler::new(container.finish()) .on_left_mouse_down(move |ctx, _, _| { ctx.dispatch_typed_action(DisplayChipMenuAction::SelectItem { index, }); DispatchEventResult::StopPropagation }) .on_mouse_in( move |ctx, _, _| { ctx.dispatch_typed_action(DisplayChipMenuAction::Select { index, }); ctx.notify(); DispatchEventResult::StopPropagation }, Some(MouseInBehavior { fire_on_synthetic_events: false, fire_when_covered: false, }), ) .finish(), format!("MenuPromptChip-{index}").as_str(), ) .finish() }) .collect::>() .into_iter() }, ); let (scrollbar_width, max_height, overlayed_scrollbar) = match self.chip_menu_type { ChipMenuType::Environments => ( ScrollbarWidth::Auto, ENV_MENU_MAX_HEIGHT - (ENV_MENU_VERTICAL_PADDING * 2.0), true, ), ChipMenuType::Directories | ChipMenuType::Branches | ChipMenuType::CodeReview => { (ScrollbarWidth::None, 200., false) } }; let mut scrollable = Scrollable::vertical( self.scroll_state.clone(), list.finish_scrollable(), scrollbar_width, theme.nonactive_ui_detail().into(), theme.active_ui_detail().into(), galaxyui::elements::Fill::None, ) .with_padding_end(0.) .with_padding_start(0.); if overlayed_scrollbar { scrollable = scrollable.with_overlayed_scrollbar(); } // Return just the scrollable content area (no outer styling) ConstrainedBox::new(scrollable.finish()) .with_width(menu_width) .with_max_height(max_height) .finish() } } impl View for DisplayChipMenu { fn ui_name() -> &'static str { "DisplayMenu" } fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext) { if focus_ctx.is_self_focused() { if let Some(ref search_input) = self.search_input { ctx.focus(search_input); } } } fn render(&self, app: &AppContext) -> Box { let appearance = Appearance::as_ref(app); let theme = appearance.theme(); // Create vertical flex container for main content + search input + sticky fixed footer option let mut main_container = Flex::column(); let border_radius = Radius::Pixels(6.); match self.chip_menu_type { ChipMenuType::Environments => { if !self.menu_items.is_empty() { main_container.add_child( Container::new(self.render_items(app)) .with_padding_top(self.menu_vertical_padding()) .with_padding_bottom(self.menu_vertical_padding()) .finish(), ); } if let Some(ref footer_option) = self.fixed_footer { main_container.add_child(self.render_fixed_footer_option(app, footer_option)); } if let Some(ref search_input_handle) = self.search_input { main_container .add_child(self.render_env_search_footer(search_input_handle, app)); } } ChipMenuType::Directories | ChipMenuType::Branches | ChipMenuType::CodeReview => { if let Some(ref search_input_handle) = self.search_input { let search_input = appearance .ui_builder() .text_input(search_input_handle.clone()) .with_style(UiComponentStyles { background: Some(Fill::Solid(ColorU::new(0, 0, 0, 0)).into()), border_color: None, border_width: Some(0.), border_radius: None, width: Some(self.menu_width() - (SEARCH_INPUT_HORIZONTAL_PADDING * 2.)), padding: Some(Coords::uniform(4.)), ..Default::default() }) .build() .finish(); let search_input_container = Container::new(search_input) .with_horizontal_padding(SEARCH_INPUT_HORIZONTAL_PADDING) .with_vertical_padding(2.) .with_background(theme.surface_1()) .with_border(Border::all(1.0).with_border_color(theme.surface_2().into())) .with_corner_radius(CornerRadius::with_top(border_radius)) .finish(); main_container.add_child(search_input_container); } if let Some(ref footer_option) = self.fixed_footer { main_container.add_child(self.render_fixed_footer_option(app, footer_option)); } if !self.menu_items.is_empty() { main_container.add_child( Container::new(self.render_items(app)) .with_padding_bottom(self.menu_vertical_padding()) .finish(), ); } } } let menu_card = { let menu_container = Container::new(main_container.finish()) .with_background(theme.surface_2()) .with_corner_radius(CornerRadius::with_all(border_radius)); let menu_container = match self.chip_menu_type { ChipMenuType::Environments => menu_container .with_border( Border::all(1.) .with_border_fill(Fill::Solid(internal_colors::neutral_4(theme))), ) .with_drop_shadow(Self::figma_menu_drop_shadow()), ChipMenuType::Directories | ChipMenuType::Branches | ChipMenuType::CodeReview => { menu_container.with_drop_shadow(DropShadow::default()) } }; ConstrainedBox::new(menu_container.finish()) .with_width(self.menu_width()) .finish() }; let mut stack = Stack::new(); stack.add_child(menu_card); if self.should_show_environment_sidecar() { if let Some((sidecar, positioning)) = self.environment_sidecar_overlay(app) { stack.add_positioned_overlay_child(sidecar, positioning); } } Dismiss::new(stack.finish()) .on_dismiss(|ctx, _app| ctx.dispatch_typed_action(DisplayChipMenuAction::Close)) .prevent_interaction_with_other_elements() .finish() } } #[derive(Debug, Clone)] pub struct GenericMenuEvent { pub action_item: Arc, } pub enum PromptDisplayMenuEvent { MenuAction(GenericMenuEvent), CloseMenu, } impl Entity for DisplayChipMenu { type Event = PromptDisplayMenuEvent; } impl TypedActionView for DisplayChipMenu { type Action = DisplayChipMenuAction; fn handle_action(&mut self, action: &DisplayChipMenuAction, ctx: &mut ViewContext) { match action { DisplayChipMenuAction::SelectItem { index } => { if *index >= self.filtered_items.len() { return; } let item = self.filtered_items[*index].item.clone(); self.select_item(item, ctx) } DisplayChipMenuAction::Select { index } => self.select(*index, ctx), DisplayChipMenuAction::SelectUp => self.select_prev(ctx), DisplayChipMenuAction::SelectDown => self.select_next(ctx), DisplayChipMenuAction::SelectEnter => self.select_enter(ctx), DisplayChipMenuAction::SelectFixedFooterOption => self.select_fixed_footer_option(ctx), DisplayChipMenuAction::CopyEnvironmentSidecarField { key, value } => { ctx.clipboard() .write(ClipboardContent::plain_text(value.clone())); self.env_sidecar_copy_feedback_times .insert(key.clone(), Instant::now()); let duration = COPY_FEEDBACK_DURATION; ctx.spawn( async move { Timer::after(duration).await; }, move |me, _, ctx| { // Clean up old entries. me.env_sidecar_copy_feedback_times .retain(|_, time| time.elapsed() < duration); ctx.notify(); }, ); ctx.notify(); } DisplayChipMenuAction::Close => self.close(ctx), } } }