use galaxy_editor::editor::NavigationKey; use galaxyui::elements::{ ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Flex, MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement, Radius, Wrap, WrapFill, }; use galaxyui::fonts::FamilyId; use galaxyui::ui_components::components::{UiComponent, UiComponentStyles}; use galaxyui::{ AppContext, Element, Entity, FocusContext, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle, }; use pathfinder_color::ColorU; use crate::appearance::Appearance; use crate::editor::{ EditorView, Event, InteractionState, PropagateAndNoOpNavigationKeys, SingleLineEditorOptions, TextOptions, }; use crate::themes::theme::Fill; pub struct WordBlockEditorView { editor_view: ViewHandle, separators: Vec, list_of_words: Vec, word_validator: Box bool>, /// TODO(seikun): chip max width is necessary for now as the current implementation of /// wrap does not have a way of limiting max length of children elements along its /// alignment axis. chip_max_width: f32, layout: WordBlockLayout, style_fn: Box WordBlockEditorStyles>, } #[derive(Debug, Clone, Copy, Default)] pub enum WordBlockLayout { /// Word chips are shown above the text editor. #[default] Vertical, /// Word chips are shown alongside the text editor. Horizontal { editor_min_width: f32 }, } pub struct WordBlockEditorStyles { pub font_family: FamilyId, pub editor_font_color: ColorU, pub background: Fill, pub valid_word_styles: WordBlockStyles, pub invalid_word_styles: WordBlockStyles, } pub struct WordBlockStyles { pub font_color: ColorU, pub background: Fill, } struct Word { text: String, mouse_state_handle: MouseStateHandle, } pub struct ChipEditorState { pub is_valid: bool, // track whether list of words is valid (based on validator) pub is_empty: bool, // track whether there are any elements in the list of words so far pub num_chips: usize, // track the # of chipped words } // Wrapper class to editor view that will separate word chips into visual blocks delimited // by a user specified `separator` char. Can additionally pass in a validator to mark // invalid words in an error color. impl WordBlockEditorView { pub fn new( ctx: &mut ViewContext, placeholder: &str, ui_font_size: f32, separators: Vec, chip_max_width: f32, word_validator: Box bool>, ) -> Self { let editor_view = ctx.add_typed_action_view(|ctx| { let options = SingleLineEditorOptions { text: TextOptions { font_size_override: Some(ui_font_size), ..Default::default() }, ..Default::default() }; EditorView::single_line(options, ctx) }); editor_view.update(ctx, |editor, ctx| { editor.clear_buffer_and_reset_undo_stack(ctx); editor.set_placeholder_text(placeholder, ctx); }); ctx.subscribe_to_view(&editor_view, |me, _, event, ctx| { me.handle_editor_view_event(event, ctx) }); Self { editor_view, separators, list_of_words: Vec::new(), word_validator, chip_max_width, layout: WordBlockLayout::default(), style_fn: Box::new(WordBlockEditorStyles::default_styles), } } // Return list of words that fail the validator check, includes text in the buffer pub fn get_list_of_invalid_words(&self, ctx: &AppContext) -> Vec { let mut words: Vec = self .list_of_words .iter() .filter(|word| !(self.word_validator)(&word.text)) .map(|word| word.text.clone()) .collect(); let buffer_text = self.editor_view.as_ref(ctx).buffer_text(ctx); if !buffer_text.is_empty() && !(self.word_validator)(&buffer_text) { words.push(buffer_text); } words } // Return list of words that are chipped + text in the buffer as a separate word pub fn get_list_of_words(&self, ctx: &AppContext) -> Vec { let mut words: Vec = self .list_of_words .iter() .map(|word| word.text.clone()) .collect(); let buffer_text = self.editor_view.as_ref(ctx).buffer_text(ctx); if !buffer_text.is_empty() { words.push(buffer_text); } words } pub fn num_chips(&self) -> usize { self.list_of_words.len() } pub fn with_validator( &mut self, ctx: &mut ViewContext, word_validator: Box bool>, ) { self.word_validator = word_validator; ctx.emit(WordBlockEditorViewEvent::WordListValidityChanged); ctx.notify(); } pub fn with_layout(mut self, layout: WordBlockLayout) -> Self { // No need for ctx.notify - because this takes `self`, it can only be called when adding // the view. self.layout = layout; self } pub fn with_styles( mut self, ctx: &mut ViewContext, styles: impl Fn(&AppContext) -> WordBlockEditorStyles + 'static, ) -> Self { let initial_styles = styles(ctx); self.editor_view.update(ctx, |editor, ctx| { editor.set_font_family(initial_styles.font_family, ctx); }); self.style_fn = Box::new(styles); self } /// Set the word input's [PropagateAndNoOpNavigationKeys] behavior. This allows navigation in /// form-like views that include a [WordBlockEditorView]. pub fn set_propagate_navigation_keys( &mut self, propagate_navigation_keys: PropagateAndNoOpNavigationKeys, ctx: &mut ViewContext, ) { self.editor_view.update(ctx, |editor, _| { editor.set_propagate_vertical_navigation_keys(propagate_navigation_keys); }) } pub fn clear_list_of_words(&mut self, ctx: &mut ViewContext) { self.list_of_words = Vec::new(); self.editor_view.update(ctx, |editor, ctx| { editor.clear_buffer_and_reset_undo_stack(ctx); }); ctx.notify(); } pub fn add_word(&mut self, word: &String, ctx: &mut ViewContext) { self.list_of_words.push(Word { text: word.to_owned(), mouse_state_handle: Default::default(), }); ctx.emit(WordBlockEditorViewEvent::WordListValidityChanged); ctx.notify(); } pub fn set_editor_buffer_text(&mut self, word: &str, ctx: &mut ViewContext) { self.editor_view.update(ctx, |editor, ctx| { editor.set_buffer_text(word, ctx); }); ctx.notify(); } /// Forwards the interaction state to the inner editor view. pub fn set_interaction_state(&mut self, state: InteractionState, ctx: &mut ViewContext) { self.editor_view.update(ctx, |editor, ctx| { editor.set_interaction_state(state, ctx); ctx.notify(); }); } fn delete_word(&mut self, index: usize, ctx: &mut ViewContext) { self.list_of_words.remove(index); ctx.emit(WordBlockEditorViewEvent::WordListValidityChanged); ctx.notify(); } fn handle_editor_view_event(&mut self, event: &Event, ctx: &mut ViewContext) { match event { Event::Edited(_) => { let buffer_text = self.editor_view.as_ref(ctx).buffer_text(ctx); // there's words to be parsed only if there's a separator character if buffer_text.chars().any(|c| self.separators.contains(&c)) { let mut parts: Vec<&str> = buffer_text .split(|c| self.separators.contains(&c)) .map(|s| s.trim()) .filter(|s| !s.is_empty()) .collect(); if !parts.is_empty() { // if text does not end in a separator, keep last part in buffer let remaining_buffer_text = if !self.separators.iter().any(|&c| buffer_text.ends_with(c)) { parts.pop() } else { None }; for part in parts { self.list_of_words.push(Word { text: part.into(), mouse_state_handle: Default::default(), }); } self.editor_view.update(ctx, |editor, ctx| { editor.clear_buffer_and_reset_undo_stack(ctx); if let Some(text) = remaining_buffer_text { editor.set_buffer_text(text, ctx); } }); } } ctx.emit(WordBlockEditorViewEvent::WordListValidityChanged); ctx.notify(); } Event::BackspaceOnEmptyBuffer => { if !self.list_of_words.is_empty() { self.list_of_words.pop(); ctx.emit(WordBlockEditorViewEvent::WordListValidityChanged); ctx.notify(); } } Event::Escape => ctx.emit(WordBlockEditorViewEvent::Escape), Event::Enter => ctx.emit(WordBlockEditorViewEvent::Enter), Event::Navigate(key) => ctx.emit(WordBlockEditorViewEvent::Navigate(*key)), _ => (), } } } #[derive(Debug, Clone)] pub enum WordBlockEditorViewAction { DeleteWord { index: usize }, } impl TypedActionView for WordBlockEditorView { type Action = WordBlockEditorViewAction; fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext) { match action { WordBlockEditorViewAction::DeleteWord { index } => self.delete_word(*index, ctx), } } } #[derive(Clone)] pub enum WordBlockEditorViewEvent { // Emitted whenever anything happens that can affect validity of all the words in the // list: // 1. words in list are removed or added // 2. validator changes WordListValidityChanged, Escape, Enter, Navigate(NavigationKey), } impl Entity for WordBlockEditorView { type Event = WordBlockEditorViewEvent; } impl View for WordBlockEditorView { fn ui_name() -> &'static str { "WordBlockEditorView" } fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext) { if focus_ctx.is_self_focused() { ctx.focus(&self.editor_view); ctx.notify(); } } fn render(&self, app: &AppContext) -> Box { let appearance = Appearance::as_ref(app); let styles = (self.style_fn)(app); // Add existing words. let word_chips = self.list_of_words.iter().enumerate().map(|(idx, word)| { let is_word_valid = (self.word_validator)(&word.text); let (background_color, text_color) = if is_word_valid { ( styles.valid_word_styles.background, styles.valid_word_styles.font_color, ) } else { ( styles.invalid_word_styles.background, styles.invalid_word_styles.font_color, ) }; let mut word_and_button = Flex::row() .with_main_axis_alignment(MainAxisAlignment::SpaceBetween) .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_main_axis_size(MainAxisSize::Min); // Add word text word_and_button.add_child( ConstrainedBox::new( appearance .ui_builder() .span(word.text.clone()) .with_style(UiComponentStyles { font_color: Some(text_color), font_family_id: Some(styles.font_family), ..Default::default() }) .build() .finish(), ) .with_max_width(self.chip_max_width) .finish(), ); // Add action button word_and_button.add_child( Container::new( appearance .ui_builder() .close_button(16., word.mouse_state_handle.clone()) .build() .on_click(move |ctx, _, _| { ctx.dispatch_typed_action(WordBlockEditorViewAction::DeleteWord { index: idx, }) }) .finish(), ) .with_margin_left(5.) .finish(), ); Container::new(word_and_button.finish()) .with_padding_left(5.) .with_vertical_padding(4.) .with_padding_right(4.) .with_horizontal_margin(3.) .with_background(background_color) .with_corner_radius(CornerRadius::with_all(Radius::Pixels(3.))) .finish() }); let editor = Container::new( appearance .ui_builder() .text_input(self.editor_view.clone()) .with_style(UiComponentStyles { background: Some(styles.background.into()), font_color: Some(styles.editor_font_color), border_width: Some(0.), ..Default::default() }) .build() .finish(), ) .with_corner_radius(CornerRadius::with_all(Radius::Pixels(3.))) .finish(); if self.list_of_words.is_empty() { editor } else { let wrapping_section = Wrap::row() .with_run_spacing(10.) .with_main_axis_alignment(MainAxisAlignment::Start) .with_main_axis_size(MainAxisSize::Min) .with_children(word_chips); match self.layout { WordBlockLayout::Vertical => { let wrapping_section_container = Container::new(wrapping_section.finish()) .with_margin_bottom(4.) .with_margin_top(3.) .with_horizontal_margin(4.) .finish(); Flex::column() .with_children([wrapping_section_container, editor]) .finish() } WordBlockLayout::Horizontal { editor_min_width } => { let wrapping_section = wrapping_section .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_child(WrapFill::new(editor_min_width, editor).finish()) .finish(); Container::new(wrapping_section) .with_vertical_padding(6.) .with_horizontal_padding(4.) .finish() } } } } } impl WordBlockEditorStyles { fn default_styles(app: &AppContext) -> WordBlockEditorStyles { let appearance = Appearance::as_ref(app); let text_color = appearance .theme() .main_text_color(appearance.theme().background()) .into_solid(); WordBlockEditorStyles { font_family: appearance.monospace_font_family(), editor_font_color: text_color, background: appearance.theme().background(), valid_word_styles: WordBlockStyles { font_color: text_color, background: appearance.theme().nonactive_ui_detail(), }, invalid_word_styles: WordBlockStyles { font_color: text_color, background: Fill::from(appearance.theme().ui_error_color()).with_opacity(30), }, } } }