//! Overlay menu for the code review diff selector: pinned search input and //! a filtered list of label-only rows with a left check slot. use std::cmp; use fuzzy_match::{match_indices_case_insensitive, FuzzyMatchResult}; use galaxy_core::ui::theme::Fill; use galaxy_editor::editor::NavigationKey; use galaxyui::{ color::ColorU, elements::{ Border, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Dismiss, DispatchEventResult, DropShadow, Element, Empty, EventHandler, Flex, Highlight, MainAxisSize, MouseInBehavior, ParentElement, Radius, ScrollStateHandle, Scrollable, ScrollableElement, ScrollbarWidth, Text, UniformList, UniformListState, }, fonts::{Properties, Weight}, id, keymap::FixedBinding, ui_components::components::{Coords, UiComponent, UiComponentStyles}, AppContext, Entity, FocusContext, SingletonEntity as _, TypedActionView, View, ViewContext, ViewHandle, }; use crate::{ appearance::Appearance, code_review::{diff_selector::DiffTarget, diff_state::DiffMode}, editor::{ EditorOptions, EditorView, Event as EditorEvent, PropagateAndNoOpNavigationKeys, TextOptions, }, ui_components::icons::Icon, }; const MENU_WIDTH: f32 = 280.; const MENU_MAX_LIST_HEIGHT: f32 = 200.; const MENU_CORNER_RADIUS: f32 = 6.; const ROW_HORIZONTAL_PADDING: f32 = 14.; const ROW_VERTICAL_PADDING: f32 = 5.; const LIST_BOTTOM_PADDING: f32 = 9.; const SEARCH_INPUT_HORIZONTAL_PADDING: f32 = 8.; const CHECK_GAP: f32 = 8.; const CHECK_SLOT_SIZE: f32 = 12.; #[derive(Clone, Debug)] pub enum CodeReviewDiffMenuEvent { Select(DiffMode), Close, } #[derive(Clone, Debug, PartialEq)] pub enum CodeReviewDiffMenuAction { ClickRow { index: usize }, HoverRow { index: usize }, SelectUp, SelectDown, SelectEnter, Close, } pub fn init(app: &mut AppContext) { app.register_fixed_bindings([ FixedBinding::new( "up", CodeReviewDiffMenuAction::SelectUp, id!(CodeReviewDiffMenu::ui_name()), ), FixedBinding::new( "down", CodeReviewDiffMenuAction::SelectDown, id!(CodeReviewDiffMenu::ui_name()), ), FixedBinding::new( "enter", CodeReviewDiffMenuAction::SelectEnter, id!(CodeReviewDiffMenu::ui_name()), ), FixedBinding::new( "escape", CodeReviewDiffMenuAction::Close, id!(CodeReviewDiffMenu::ui_name()), ), ]); } pub struct CodeReviewDiffMenu { targets: Vec, /// (target index, optional match result) pairs for rows that pass the /// current filter. Original target order is preserved; the match result /// carries indices for bolding matched characters in the label. filtered: Vec<(usize, Option)>, /// Index into `filtered` of the keyboard-focused row. selected_index: Option, search_input: ViewHandle, search_query: String, list_state: UniformListState, scroll_state: ScrollStateHandle, } impl CodeReviewDiffMenu { pub fn new(ctx: &mut ViewContext) -> Self { let search_input = ctx.add_typed_action_view(|ctx| { let appearance = Appearance::handle(ctx).as_ref(ctx); let ui_font_family = appearance.ui_font_family(); let mut text_options = TextOptions::ui_font_size(appearance); text_options.font_family_override = Some(ui_font_family); 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); editor.set_placeholder_text("Search diff sets or branches to compare…", ctx); editor }); ctx.subscribe_to_view(&search_input, |menu, _, event, ctx| match event { EditorEvent::Edited(_) => { let new_query = menu .search_input .read(ctx, |editor, ctx| editor.buffer_text(ctx).to_string()); if new_query != menu.search_query { menu.set_search_query(new_query, ctx); } } EditorEvent::Escape => menu.emit_close(ctx), EditorEvent::Navigate(NavigationKey::Up) => menu.select_prev(ctx), EditorEvent::Navigate(NavigationKey::Down) => menu.select_next(ctx), EditorEvent::Enter => menu.select_enter(ctx), _ => {} }); Self { targets: Vec::new(), filtered: Vec::new(), selected_index: None, search_input, search_query: String::new(), list_state: Default::default(), scroll_state: Default::default(), } } /// Replace the row set and reset filter/scroll to the top. pub fn set_targets(&mut self, targets: Vec, ctx: &mut ViewContext) { self.targets = targets; self.refresh_filtered(); self.scroll_list_to_top(); ctx.notify(); } /// Reset the menu to a fresh state: empty query, selection/scroll back /// to the top, and rows re-computed against the current targets. pub fn reset(&mut self, ctx: &mut ViewContext) { self.search_query.clear(); self.search_input.update(ctx, |editor, ctx| { editor.clear_buffer(ctx); }); self.refresh_filtered(); self.scroll_list_to_top(); ctx.notify(); } fn set_search_query(&mut self, query: String, ctx: &mut ViewContext) { self.search_query = query; self.refresh_filtered(); self.scroll_list_to_top(); ctx.notify(); } fn scroll_list_to_top(&mut self) { if !self.filtered.is_empty() { self.list_state.scroll_to(0); } } fn refresh_filtered(&mut self) { if self.search_query.is_empty() { self.filtered = (0..self.targets.len()).map(|i| (i, None)).collect(); } else { // Use fuzzy matching for membership only; preserve original order // so the list never reorders under the user as they type. self.filtered = self .targets .iter() .enumerate() .filter_map(|(i, target)| { match_indices_case_insensitive(&target.label, &self.search_query) .map(|m| (i, Some(m))) }) .collect(); } self.selected_index = if self.filtered.is_empty() { None } else { Some(0) }; } fn select_prev(&mut self, ctx: &mut ViewContext) { let Some(current) = self.selected_index else { return; }; if current == 0 { return; } self.selected_index = Some(current - 1); self.list_state.scroll_to(current - 1); ctx.notify(); } fn select_next(&mut self, ctx: &mut ViewContext) { if self.filtered.is_empty() { return; } let next = match self.selected_index { Some(i) if i + 1 < self.filtered.len() => i + 1, Some(i) => i, None => 0, }; self.selected_index = Some(next); self.list_state.scroll_to(next); ctx.notify(); } fn select_enter(&mut self, ctx: &mut ViewContext) { let Some(selected) = self.selected_index else { return; }; self.select_filtered_index(selected, ctx); } fn select_filtered_index(&mut self, filtered_index: usize, ctx: &mut ViewContext) { let Some((target_index, _)) = self.filtered.get(filtered_index).cloned() else { return; }; let Some(target) = self.targets.get(target_index) else { return; }; let mode = target.mode.clone(); ctx.emit(CodeReviewDiffMenuEvent::Select(mode)); ctx.notify(); } fn emit_close(&mut self, ctx: &mut ViewContext) { ctx.emit(CodeReviewDiffMenuEvent::Close); ctx.notify(); } fn render_search_input(&self, appearance: &Appearance) -> Box { let theme = appearance.theme(); let search_input = appearance .ui_builder() .text_input(self.search_input.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(MENU_WIDTH - (SEARCH_INPUT_HORIZONTAL_PADDING * 2.)), padding: Some(Coords::uniform(4.)), ..Default::default() }) .build() .finish(); // Search bar inherits the menu card surface; a hairline bottom // border separates it from the row list below. Container::new(search_input) .with_horizontal_padding(SEARCH_INPUT_HORIZONTAL_PADDING) .with_vertical_padding(2.) .with_border(Border::bottom(1.0).with_border_color(theme.outline().into())) .finish() } fn render_empty_state(&self, appearance: &Appearance) -> Box { let theme = appearance.theme(); Container::new( Text::new( "No matches", appearance.ui_font_family(), appearance.ui_font_size(), ) .with_color(theme.sub_text_color(theme.surface_2()).into_solid()) .finish(), ) .with_horizontal_padding(ROW_HORIZONTAL_PADDING) .with_vertical_padding(ROW_VERTICAL_PADDING * 2.0) .finish() } fn render_rows(&self, ctx: &AppContext) -> Box { if self.filtered.is_empty() { if !self.search_query.is_empty() { return self.render_empty_state(Appearance::as_ref(ctx)); } return Empty::new().finish(); } let appearance = Appearance::as_ref(ctx); let theme = appearance.theme(); let selected = self.selected_index; let filtered_snapshot: Vec<(DiffTarget, Option)> = self .filtered .iter() .filter_map(|(i, m)| self.targets.get(*i).cloned().map(|t| (t, m.clone()))) .collect(); let filtered_len = filtered_snapshot.len(); let list = UniformList::new( self.list_state.clone(), filtered_len, move |mut range, app| { let appearance = Appearance::as_ref(app); let theme = appearance.theme(); range.end = cmp::min(range.end, filtered_len); range .map(|row_index| { let (target, match_result) = &filtered_snapshot[row_index]; let is_focused = selected == Some(row_index); let font_size = appearance.ui_font_size(); let (text_color, bg) = if is_focused { let bg = theme.accent(); (theme.main_text_color(bg).into_solid(), Some(bg)) } else { (theme.main_text_color(theme.surface_2()).into_solid(), None) }; let check_slot: Box = if target.is_selected { ConstrainedBox::new( Icon::Check .to_galaxyui_icon(Fill::Solid(text_color)) .finish(), ) .with_width(CHECK_SLOT_SIZE) .with_height(CHECK_SLOT_SIZE) .finish() } else { ConstrainedBox::new(Empty::new().finish()) .with_width(CHECK_SLOT_SIZE) .with_height(CHECK_SLOT_SIZE) .finish() }; let label_text = Text::new_inline( target.label.clone(), appearance.ui_font_family(), font_size, ) .with_color(text_color); let label = match match_result { Some(m) => label_text.with_single_highlight( Highlight::new() .with_properties(Properties::default().weight(Weight::Bold)) .with_foreground_color(text_color), m.matched_indices.clone(), ), None => label_text, } .finish(); let row = Flex::row() .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_main_axis_size(MainAxisSize::Max) .with_child( Container::new(check_slot) .with_margin_right(CHECK_GAP) .finish(), ) .with_child(label) .finish(); let mut container = Container::new(row) .with_horizontal_padding(ROW_HORIZONTAL_PADDING) .with_vertical_padding(ROW_VERTICAL_PADDING); if let Some(bg) = bg { container = container.with_background(bg); } EventHandler::new(container.finish()) .on_left_mouse_down(move |ctx, _, _| { ctx.dispatch_typed_action(CodeReviewDiffMenuAction::ClickRow { index: row_index, }); DispatchEventResult::StopPropagation }) .on_mouse_in( move |ctx, _, _| { ctx.dispatch_typed_action(CodeReviewDiffMenuAction::HoverRow { index: row_index, }); ctx.notify(); DispatchEventResult::StopPropagation }, Some(MouseInBehavior { fire_on_synthetic_events: false, fire_when_covered: false, }), ) .finish() }) .collect::>() .into_iter() }, ); let scrollable = Scrollable::vertical( self.scroll_state.clone(), list.finish_scrollable(), ScrollbarWidth::None, theme.nonactive_ui_detail().into(), theme.active_ui_detail().into(), galaxyui::elements::Fill::None, ) .with_padding_end(0.) .with_padding_start(0.); ConstrainedBox::new(scrollable.finish()) .with_width(MENU_WIDTH) .with_max_height(MENU_MAX_LIST_HEIGHT) .finish() } } impl Entity for CodeReviewDiffMenu { type Event = CodeReviewDiffMenuEvent; } impl TypedActionView for CodeReviewDiffMenu { type Action = CodeReviewDiffMenuAction; fn handle_action(&mut self, action: &CodeReviewDiffMenuAction, ctx: &mut ViewContext) { match action { CodeReviewDiffMenuAction::ClickRow { index } => { // Sync keyboard selection with the clicked row. if *index < self.filtered.len() { self.selected_index = Some(*index); self.select_filtered_index(*index, ctx); } } CodeReviewDiffMenuAction::HoverRow { index } => { if *index < self.filtered.len() && self.selected_index != Some(*index) { self.selected_index = Some(*index); ctx.notify(); } } CodeReviewDiffMenuAction::SelectUp => self.select_prev(ctx), CodeReviewDiffMenuAction::SelectDown => self.select_next(ctx), CodeReviewDiffMenuAction::SelectEnter => self.select_enter(ctx), CodeReviewDiffMenuAction::Close => self.emit_close(ctx), } } } impl View for CodeReviewDiffMenu { fn ui_name() -> &'static str { "CodeReviewDiffMenu" } fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext) { if focus_ctx.is_self_focused() { ctx.focus(&self.search_input); } } fn render(&self, app: &AppContext) -> Box { let appearance = Appearance::as_ref(app); let theme = appearance.theme(); let mut column = Flex::column().with_child(self.render_search_input(appearance)); if !self.targets.is_empty() || !self.search_query.is_empty() { column.add_child( Container::new(self.render_rows(app)) .with_padding_bottom(LIST_BOTTOM_PADDING) .finish(), ); } let menu_card = ConstrainedBox::new( Container::new(column.finish()) .with_background(theme.surface_2()) .with_corner_radius(CornerRadius::with_all(Radius::Pixels(MENU_CORNER_RADIUS))) .with_drop_shadow(DropShadow::default()) .finish(), ) .with_width(MENU_WIDTH) .finish(); Dismiss::new(menu_card) .on_dismiss(|ctx, _app| { ctx.dispatch_typed_action(CodeReviewDiffMenuAction::Close); }) .prevent_interaction_with_other_elements() .finish() } }