276 lines
9.4 KiB
Rust
276 lines
9.4 KiB
Rust
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<T> = Box<dyn Fn(&T, &AppContext) -> 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<T> {
|
|
label_fn: ItemLabelFn<T>,
|
|
is_selected_fn: Box<dyn Fn(&T) -> bool>,
|
|
}
|
|
|
|
impl<T> ToggleableItemBuilder<T> {
|
|
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<T> {
|
|
items: Vec<T>,
|
|
selected_states: Vec<bool>,
|
|
label_fn: ItemLabelFn<T>,
|
|
checkbox_mouse_states: Vec<MouseStateHandle>,
|
|
row_mouse_states: Vec<MouseStateHandle>,
|
|
selected_item_index: usize,
|
|
}
|
|
|
|
impl<T> ToggleableItemsView<T> {
|
|
pub fn new(items: Vec<T>, builder: ToggleableItemBuilder<T>) -> 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<Item = &T> + '_ {
|
|
self.items
|
|
.iter()
|
|
.zip(self.selected_states.iter())
|
|
.filter_map(|(item, selected)| selected.then_some(item))
|
|
}
|
|
}
|
|
|
|
pub enum ToggleableItemsEvent {
|
|
SelectionChanged,
|
|
SubmitRequested,
|
|
}
|
|
|
|
impl<T: 'static> Entity for ToggleableItemsView<T> {
|
|
type Event = ToggleableItemsEvent;
|
|
}
|
|
|
|
impl<T: 'static> View for ToggleableItemsView<T> {
|
|
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<dyn Element> {
|
|
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<dyn Element> = 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<T: 'static> TypedActionView for ToggleableItemsView<T> {
|
|
type Action = ToggleableItemsAction;
|
|
|
|
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
}
|