use crate::appearance::Appearance; use crate::ui_components::blended_colors; use galaxyui::{ elements::{ Border, Container, CornerRadius, CrossAxisAlignment, Expanded, Flex, Hoverable, MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement, Radius, }, keymap::{macros::*, FixedBinding, Keystroke}, platform::Cursor, ui_components::{ components::{Coords, UiComponent, UiComponentStyles}, keyboard_shortcut::KeyboardShortcut, text::Span, }, AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, }; type ItemLabelFn = Box Span>; const HAS_ITEMS: &str = "HasItems"; pub fn init(app: &mut AppContext) { let context = id!(ToggleableItemsView::<()>::ui_name()) & id!(HAS_ITEMS); app.register_fixed_bindings([ FixedBinding::new("enter", ToggleableItemsAction::Submit, context.clone()), FixedBinding::new( "numpadenter", ToggleableItemsAction::Submit, context.clone(), ), FixedBinding::new("up", ToggleableItemsAction::ArrowUp, context.clone()), FixedBinding::new("down", ToggleableItemsAction::ArrowDown, context.clone()), FixedBinding::new( "cmdorctrl-enter", ToggleableItemsAction::ToggleFocused, context, ), ]); } /// Builder for configuring how individual items should be displayed and selected. /// /// # Type Parameters /// - `T`: The data type for each item pub struct ToggleableItemBuilder { label_fn: ItemLabelFn, is_selected_fn: Box bool>, } impl ToggleableItemBuilder { pub fn new( label_fn: impl Fn(&T, &AppContext) -> Span + 'static, is_selected_fn: impl Fn(&T) -> bool + 'static, ) -> Self { Self { label_fn: Box::new(label_fn), is_selected_fn: Box::new(is_selected_fn), } } } /// Internal action type for interacting with an item list. #[derive(Debug, Clone, Copy)] pub enum ToggleableItemsAction { ToggleItem(usize), ToggleFocused, ArrowUp, ArrowDown, Submit, } /// A generic view for displaying multiple items with checkboxes. /// /// # Type Parameters /// - `T`: The data type for each item pub struct ToggleableItemsView { items: Vec, selected_states: Vec, label_fn: ItemLabelFn, checkbox_mouse_states: Vec, row_mouse_states: Vec, selected_item_index: usize, } impl ToggleableItemsView { pub fn new(items: Vec, builder: ToggleableItemBuilder) -> Self { let count = items.len(); let selected_states = items .iter() .map(|item| (builder.is_selected_fn)(item)) .collect(); Self { items, selected_states, label_fn: builder.label_fn, checkbox_mouse_states: (0..count).map(|_| MouseStateHandle::default()).collect(), row_mouse_states: (0..count).map(|_| MouseStateHandle::default()).collect(), selected_item_index: 0, } } /// Get the currently selected items. pub fn get_selected_items(&self) -> impl Iterator + '_ { self.items .iter() .zip(self.selected_states.iter()) .filter_map(|(item, selected)| selected.then_some(item)) } } pub enum ToggleableItemsEvent { SelectionChanged, SubmitRequested, } impl Entity for ToggleableItemsView { type Event = ToggleableItemsEvent; } impl View for ToggleableItemsView { fn ui_name() -> &'static str { "ToggleableItemsView" } fn keymap_context(&self, _app: &AppContext) -> galaxyui::keymap::Context { let mut context = Self::default_keymap_context(); if !self.items.is_empty() { context.set.insert(HAS_ITEMS); } context } fn render(&self, ctx: &AppContext) -> Box { let appearance = Appearance::as_ref(ctx); let theme = appearance.theme(); let border_color = blended_colors::neutral_4(theme); let mut outer_container = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch); let mut checkboxes_column = Flex::column() .with_cross_axis_alignment(CrossAxisAlignment::Stretch) .with_spacing(4.); // Render checkboxes for each item for (index, item) in self.items.iter().enumerate() { let is_selected = self.selected_states[index]; let is_focused = index == self.selected_item_index; let label = (self.label_fn)(item, ctx); let checkbox = appearance .ui_builder() .checkbox(self.checkbox_mouse_states[index].clone(), None) .check(is_selected) .with_label(label) .build() .finish(); let row_inner: Box = if is_focused { let toggle_keystroke = if cfg!(target_os = "macos") { Keystroke::parse("cmd-enter").expect("can parse cmd-enter") } else { Keystroke::parse("ctrl-enter").expect("can parse ctrl-enter") }; let hint_styles = UiComponentStyles { font_family_id: Some(appearance.ui_font_family()), font_size: Some(appearance.monospace_font_size() - 2.), font_color: Some(blended_colors::text_sub(theme, theme.surface_1())), ..Default::default() }; let shortcut = KeyboardShortcut::new(&toggle_keystroke, hint_styles) .text_only() .build() .finish(); let hint_text = Span::new( "to toggle selection", UiComponentStyles { margin: Some(Coords::default().left(6.)), ..hint_styles }, ) .build() .finish(); let hint = Flex::row() .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_child(shortcut) .with_child(hint_text) .finish(); Flex::row() .with_main_axis_alignment(MainAxisAlignment::SpaceBetween) .with_main_axis_size(MainAxisSize::Max) .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_child(Expanded::new(1., checkbox).finish()) .with_child(hint) .finish() } else { checkbox }; // Make the entire row clickable (not just the checkbox control) by wrapping the // padded/bordered container in a separate Hoverable click target. // Use accent border color when focused to show keyboard focus. let row_border_color = if is_focused { theme.accent().into() } else { border_color }; let row = Hoverable::new(self.row_mouse_states[index].clone(), |_| { Container::new(row_inner) .with_horizontal_padding(12.) .with_vertical_padding(8.) .with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.))) .with_border(Border::all(1.).with_border_fill(row_border_color)) .finish() }) .with_cursor(Cursor::PointingHand) .on_click(move |ctx, _, _| { ctx.dispatch_typed_action(ToggleableItemsAction::ToggleItem(index)); }) .finish(); checkboxes_column.add_child(row); } outer_container.add_child(checkboxes_column.finish()); outer_container.finish() } } impl TypedActionView for ToggleableItemsView { type Action = ToggleableItemsAction; fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext) { match action { ToggleableItemsAction::ToggleItem(index) => { if let Some(selected) = self.selected_states.get_mut(*index) { *selected = !*selected; ctx.emit(ToggleableItemsEvent::SelectionChanged); ctx.notify(); } } ToggleableItemsAction::ToggleFocused => { if let Some(selected) = self.selected_states.get_mut(self.selected_item_index) { *selected = !*selected; ctx.emit(ToggleableItemsEvent::SelectionChanged); ctx.notify(); } } ToggleableItemsAction::ArrowUp => { if !self.items.is_empty() { self.selected_item_index = (self.selected_item_index + self.items.len() - 1) % self.items.len(); ctx.notify(); } } ToggleableItemsAction::ArrowDown => { if !self.items.is_empty() { self.selected_item_index = (self.selected_item_index + 1) % self.items.len(); ctx.notify(); } } ToggleableItemsAction::Submit => { ctx.emit(ToggleableItemsEvent::SubmitRequested); } } } }