use std::{ops::Range, rc::Rc}; use gpui::{ App, ElementId, Entity, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div, prelude::*, }; use crate::{ buttons::{Button, ButtonVariant, IconButton, ToggleButton, ToggleItem}, forms::{Input, TextInput}, primitives::IconName, theme::{ActiveTheme, ControlSize, Elevation, Radius, TextSize}, }; /// How a search matches: case exactly, whole words only, the query as a regular expression. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct FindOptions { pub case: bool, pub word: bool, pub regex: bool, } /// Where `query` appears in `text` under `options`; a pattern that does not compile is an error to show. pub fn find_all( text: &str, query: &str, options: FindOptions, ) -> Result>, String> { if query.is_empty() { return Ok(Vec::new()); } let pattern = if options.regex { query.to_string() } else { regex::escape(query) }; let pattern = if options.word { format!(r"\b(?:{pattern})\b") } else { pattern }; let found = regex::RegexBuilder::new(&pattern) .case_insensitive(!options.case) .multi_line(true) .build() .map_err(|error| error.to_string())?; Ok(found .find_iter(text) .filter(|hit| !hit.is_empty()) .map(|hit| hit.range()) .collect()) } type OnOptions = Rc; type OnBool = Rc; type OnClose = Rc; /// A find box: the query, its options when the owner takes them, where the current match stands among all, steps between them, and a replace row that folds away when the owner offers one. #[derive(IntoElement)] pub struct FindWidget { id: ElementId, find: Entity, replace: Option>, options: FindOptions, matches: Result<(Option, usize), SharedString>, on_options: Option, on_step: Option, on_replace: Option, on_toggle_replace: Option, on_close: Option, } impl FindWidget { /// `find` holds the query; `current` is the chosen match among `total`. pub fn new( id: impl Into, find: &Entity, current: Option, total: usize, ) -> Self { Self { id: id.into(), find: find.clone(), replace: None, options: FindOptions::default(), matches: Ok((current, total)), on_options: None, on_step: None, on_replace: None, on_toggle_replace: None, on_close: None, } } /// The query failed to compile; its message shows in place of a count. pub fn error(mut self, message: impl Into) -> Self { self.matches = Err(message.into()); self } pub fn options(mut self, options: FindOptions) -> Self { self.options = options; self } /// Shows the replace row, its text in `field`. pub fn replace(mut self, field: &Entity) -> Self { self.replace = Some(field.clone()); self } pub fn on_options( mut self, handler: impl Fn(FindOptions, &mut Window, &mut App) + 'static, ) -> Self { self.on_options = Some(Rc::new(handler)); self } /// Gets `true` for the next match, `false` for the previous. pub fn on_step(mut self, handler: impl Fn(bool, &mut Window, &mut App) + 'static) -> Self { self.on_step = Some(Rc::new(handler)); self } /// Gets `true` to replace every match, `false` for the current one. pub fn on_replace(mut self, handler: impl Fn(bool, &mut Window, &mut App) + 'static) -> Self { self.on_replace = Some(Rc::new(handler)); self } /// Gets whether the replace row should show. pub fn on_toggle_replace( mut self, handler: impl Fn(bool, &mut Window, &mut App) + 'static, ) -> Self { self.on_toggle_replace = Some(Rc::new(handler)); self } pub fn on_close(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { self.on_close = Some(Rc::new(handler)); self } } impl RenderOnce for FindWidget { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { let theme = cx.theme(); let colors = theme.colors.clone(); let options = self.options; let toggle = |key: &'static str, icon: IconName, words: &'static str, on: bool, set: fn(&mut FindOptions, bool)| { let on_options = self.on_options.clone(); ToggleButton::new( (self.id.clone(), key), ToggleItem::new(key).icon(icon).tooltip(words), on, ) .size(ControlSize::Sm) .on_toggle(move |on, window, cx| { let mut next = options; set(&mut next, on); log::info!("find: {next:?}"); if let Some(on_options) = &on_options { on_options(next, window, cx); } }) }; let (count, missing) = match &self.matches { Ok((_, 0)) => ("No results".to_string(), true), Ok((Some(current), total)) => (format!("{} of {total}", current + 1), false), Ok((None, total)) => (format!("{total} found"), false), Err(message) => (message.to_string(), true), }; let found = matches!(self.matches, Ok((_, total)) if total > 0); let icon_button = |key: &'static str, icon: IconName, words: &'static str, enabled: bool, action: Option, value: bool| { IconButton::new((self.id.clone(), key), icon) .variant(ButtonVariant::Ghost) .size(ControlSize::Sm) .tooltip(words) .disabled(!enabled) .when_some(action, move |button, action| { button.on_click(move |_, window, cx| action(value, window, cx)) }) }; let showing = self.replace.is_some(); let row = || div().flex().items_center().gap_1(); div() .w(theme.label_width() * 2.75) .flex() .flex_col() .gap_1() .p_1() .rounded(theme.radius(Radius::Md)) .border_1() .border_color(colors.border) .bg(colors.overlay) .shadow(theme.elevation(Elevation::Floating)) .text_size(theme.text_size(TextSize::Xs)) .child( row() .children(self.on_toggle_replace.clone().map(|fold| { icon_button( "fold", if showing { IconName::ChevronDown } else { IconName::ChevronRight }, "Toggle replace", true, Some(fold), !showing, ) })) .child( div() .flex_1() .min_w_0() .child(Input::new(&self.find).size(ControlSize::Sm)), ) .when(self.on_options.is_some(), |row| { row.child(toggle( "case", IconName::CaseSensitive, "Match case", options.case, |next, on| next.case = on, )) .child(toggle( "word", IconName::WholeWord, "Whole word", options.word, |next, on| next.word = on, )) .child(toggle( "regex", IconName::Regex, "Regular expression", options.regex, |next, on| next.regex = on, )) }) .child( div() .w(theme.label_width() * 0.5) .text_center() .text_color(if missing { colors.danger } else { colors.fg_muted }) .child(count), ) .child(icon_button( "previous", IconName::ArrowUp, "Previous match", found, self.on_step.clone(), false, )) .child(icon_button( "next", IconName::ArrowDown, "Next match", found, self.on_step.clone(), true, )) .children(self.on_close.clone().map(|close| { IconButton::new((self.id.clone(), "close"), IconName::X) .variant(ButtonVariant::Ghost) .size(ControlSize::Sm) .tooltip("Close") .on_click(move |_, window, cx| close(window, cx)) })), ) .children(self.replace.as_ref().map(|field| { let (one, all) = (self.on_replace.clone(), self.on_replace.clone()); row() .when(self.on_toggle_replace.is_some(), |row| { row.pl(theme.control_height(ControlSize::Sm)) }) .child( div() .flex_1() .min_w_0() .child(Input::new(field).size(ControlSize::Sm)), ) .child( Button::new((self.id.clone(), "replace"), "Replace") .variant(ButtonVariant::Ghost) .size(ControlSize::Sm) .disabled(!found) .when_some(one, |button, one| { button.on_click(move |_, window, cx| one(false, window, cx)) }), ) .child( Button::new((self.id.clone(), "all"), "Replace all") .variant(ButtonVariant::Ghost) .size(ControlSize::Sm) .disabled(!found) .when_some(all, |button, all| { button.on_click(move |_, window, cx| all(true, window, cx)) }), ) })) } } #[cfg(test)] mod tests { use super::*; #[test] fn finds_by_case_word_and_pattern() { let text = "Let total = total_cost + TOTAL;"; let plain = FindOptions::default(); assert_eq!( find_all(text, "total", plain).map(|found| found.len()), Ok(3) ); let case = FindOptions { case: true, ..plain }; assert_eq!( find_all(text, "total", case).map(|found| found.len()), Ok(2) ); let word = FindOptions { word: true, ..plain }; assert_eq!(find_all(text, "total", word), Ok(vec![4..9, 25..30])); let pattern = FindOptions { regex: true, ..plain }; let word = 12..22; assert_eq!(find_all(text, r"total_\w+", pattern), Ok(vec![word])); assert!( find_all(text, "(", pattern).is_err(), "a broken pattern says so" ); assert_eq!( find_all(text, "(", plain), Ok(Vec::new()), "and plain text is escaped" ); } }