From 8a3ef51ea1532c4d3a207db25a6e21d8dda6707f Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Wed, 17 Sep 2025 19:02:00 +0800 Subject: [PATCH] input: Add to support search and replace. (#1252) image - Close #1212 to support search for Input. - Fix #1235 to support scroll to cursor when go to line. --- Cargo.lock | 1 + assets/icons/case-sensitive.svg | 1 + assets/icons/replace.svg | 16 + crates/story/examples/code-editor.rs | 6 +- crates/story/examples/markdown.rs | 2 + crates/story/src/textarea_story.rs | 28 +- crates/ui/Cargo.toml | 1 + crates/ui/locales/ui.yml | 11 + crates/ui/src/icon.rs | 6 +- crates/ui/src/input/element.rs | 149 +++++--- crates/ui/src/input/mod.rs | 1 + crates/ui/src/input/search.rs | 547 +++++++++++++++++++++++++++ crates/ui/src/input/state.rs | 105 +++-- crates/ui/src/input/text_input.rs | 154 +++++--- crates/ui/src/styled.rs | 8 +- themes/catppuccin.json | 3 +- 16 files changed, 883 insertions(+), 156 deletions(-) create mode 100644 assets/icons/case-sensitive.svg create mode 100644 assets/icons/replace.svg create mode 100644 crates/ui/src/input/search.rs diff --git a/Cargo.lock b/Cargo.lock index e442c3a9..83562a52 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3206,6 +3206,7 @@ dependencies = [ name = "gpui-component" version = "0.1.0" dependencies = [ + "aho-corasick", "anyhow", "chrono", "enum-iterator", diff --git a/assets/icons/case-sensitive.svg b/assets/icons/case-sensitive.svg new file mode 100644 index 00000000..648134fa --- /dev/null +++ b/assets/icons/case-sensitive.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/replace.svg b/assets/icons/replace.svg new file mode 100644 index 00000000..5d7b480c --- /dev/null +++ b/assets/icons/replace.svg @@ -0,0 +1,16 @@ + diff --git a/crates/story/examples/code-editor.rs b/crates/story/examples/code-editor.rs index f3c43cab..259914b3 100644 --- a/crates/story/examples/code-editor.rs +++ b/crates/story/examples/code-editor.rs @@ -137,11 +137,7 @@ impl CompletionProvider for ExampleLspStore { _: &mut Window, cx: &mut Context, ) -> Task>> { - let trigger_character = trigger - .trigger_character - .as_deref() - .unwrap_or("") - .to_string(); + let trigger_character = trigger.trigger_character.unwrap_or_default(); if trigger_character.is_empty() { return Task::ready(Ok(vec![])); } diff --git a/crates/story/examples/markdown.rs b/crates/story/examples/markdown.rs index d95608a9..b048fe3c 100644 --- a/crates/story/examples/markdown.rs +++ b/crates/story/examples/markdown.rs @@ -26,6 +26,7 @@ impl Example { tab_size: 2, ..Default::default() }) + .searchable(true) .placeholder("Enter your Markdown here...") .default_value(EXAMPLE) }); @@ -66,6 +67,7 @@ impl Render for Example { .child( TextInput::new(&self.input_state) .h_full() + .p_0() .appearance(false) .focus_bordered(false), ), diff --git a/crates/story/src/textarea_story.rs b/crates/story/src/textarea_story.rs index ea2dcfe7..49ab20a4 100644 --- a/crates/story/src/textarea_story.rs +++ b/crates/story/src/textarea_story.rs @@ -54,28 +54,30 @@ impl TextareaStory { InputState::new(window, cx) .multi_line() .rows(10) - .placeholder("Enter text here...").default_value( - unindent::unindent( - r#"Hello 世界,this is GPUI component. + .placeholder("Enter text here...") + .searchable(true) + .default_value( + unindent::unindent( + r#"Hello 世界,this is GPUI component. - The GPUI Component is a collection of UI components for GPUI framework, including. + The GPUI Component is a collection of UI components for GPUI framework, including. - Button, Input, Checkbox, Radio, Dropdown, Tab, and more... + Button, Input, Checkbox, Radio, Dropdown, Tab, and more... - Here is an application that is built by using GPUI Component. + Here is an application that is built by using GPUI Component. - > This application is still under development, not published yet. + > This application is still under development, not published yet. - ![image](https://github.com/user-attachments/assets/559a648d-19df-4b5a-b563-b78cc79c8894) + ![image](https://github.com/user-attachments/assets/559a648d-19df-4b5a-b563-b78cc79c8894) - ![image](https://github.com/user-attachments/assets/5e06ad5d-7ea0-43db-8d13-86a240da4c8d) + ![image](https://github.com/user-attachments/assets/5e06ad5d-7ea0-43db-8d13-86a240da4c8d) - ## Demo + ## Demo - If you want to see the demo, here is a some demo applications. - "#, + If you want to see the demo, here is a some demo applications. + "#, + ) ) - ) }); let textarea_auto_grow = cx.new(|cx| { diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml index 559dfff5..4cb2888d 100644 --- a/crates/ui/Cargo.toml +++ b/crates/ui/Cargo.toml @@ -91,6 +91,7 @@ chrono = "0.4.38" # Code Editor lsp-types.workspace = true +aho-corasick = "1.1.3" tree-sitter = "0.25.4" tree-sitter-json = "0.24.8" tree-sitter-bash = { version = "0.23.3", optional = true } diff --git a/crates/ui/locales/ui.yml b/crates/ui/locales/ui.yml index a802c835..4c03904c 100644 --- a/crates/ui/locales/ui.yml +++ b/crates/ui/locales/ui.yml @@ -155,3 +155,14 @@ List: zh-CN: 搜索... zh-HK: 搜索... it: Ricerca... +Input: + Replace: + en: Replace + zh-CN: 替换 + zh-HK: 替換 + it: Sostituisci + Replace All: + en: Replace All + zh-CN: 全部替换 + zh-HK: 全部替換 + it: Sostituisci tutto diff --git a/crates/ui/src/icon.rs b/crates/ui/src/icon.rs index b465f4aa..0e6e4b70 100644 --- a/crates/ui/src/icon.rs +++ b/crates/ui/src/icon.rs @@ -18,13 +18,14 @@ pub enum IconName { Bot, Building2, Calendar, + CaseSensitive, ChartPie, Check, ChevronDown, ChevronLeft, ChevronRight, - ChevronUp, ChevronsUpDown, + ChevronUp, CircleCheck, CircleUser, CircleX, @@ -68,6 +69,7 @@ pub enum IconName { PanelRightClose, PanelRightOpen, Plus, + Replace, ResizeCorner, Search, Settings, @@ -102,6 +104,7 @@ impl IconName { Self::Bot => "icons/bot.svg", Self::Building2 => "icons/building-2.svg", Self::Calendar => "icons/calendar.svg", + Self::CaseSensitive => "icons/case-sensitive.svg", Self::ChartPie => "icons/chart-pie.svg", Self::Check => "icons/check.svg", Self::ChevronDown => "icons/chevron-down.svg", @@ -152,6 +155,7 @@ impl IconName { Self::PanelRightClose => "icons/panel-right-close.svg", Self::PanelRightOpen => "icons/panel-right-open.svg", Self::Plus => "icons/plus.svg", + Self::Replace => "icons/replace.svg", Self::ResizeCorner => "icons/resize-corner.svg", Self::Search => "icons/search.svg", Self::Settings => "icons/settings.svg", diff --git a/crates/ui/src/input/element.rs b/crates/ui/src/input/element.rs index c525d27e..9a313d9f 100644 --- a/crates/ui/src/input/element.rs +++ b/crates/ui/src/input/element.rs @@ -11,7 +11,7 @@ use smallvec::SmallVec; use crate::{ input::{blink_cursor::CURSOR_WIDTH, RopeExt as _}, - ActiveTheme as _, Root, + ActiveTheme as _, Colorize, Root, }; use super::{mode::InputMode, InputState, LastLayout}; @@ -76,8 +76,8 @@ impl TextElement { let line_number_width = last_layout.line_number_width; let mut selected_range = state.selected_range; - if let Some(marked_range) = &state.marked_range { - selected_range = (marked_range.end..marked_range.end).into(); + if let Some(ime_marked_range) = &state.ime_marked_range { + selected_range = (ime_marked_range.end..ime_marked_range.end).into(); } let cursor = state.cursor(); @@ -230,35 +230,29 @@ impl TextElement { (cursor_bounds, scroll_offset, current_row) } - fn layout_selections( - &self, + fn layout_match_range( + range: Range, last_layout: &LastLayout, bounds: &mut Bounds, - _: &mut Window, - cx: &mut App, ) -> Option> { - let line_height = last_layout.line_height; - let visible_top = last_layout.visible_top; - let visible_start_offset = last_layout.visible_start_offset; - let lines = &last_layout.lines; - let line_number_width = last_layout.line_number_width; - - let state = self.state.read(cx); - let mut selected_range = state.selected_range; - if let Some(marked_range) = &state.marked_range { - if !marked_range.is_empty() { - selected_range = (marked_range.end..marked_range.end).into(); - } - } - if selected_range.is_empty() { + if range.is_empty() { return None; } - let (start_ix, end_ix) = if selected_range.start < selected_range.end { - (selected_range.start, selected_range.end) - } else { - (selected_range.end, selected_range.start) - }; + if range.start < last_layout.visible_range_offset.start + || range.end > last_layout.visible_range_offset.end + { + return None; + } + + let line_height = last_layout.line_height; + let visible_top = last_layout.visible_top; + let visible_start_offset = last_layout.visible_range_offset.start; + let lines = &last_layout.lines; + let line_number_width = last_layout.line_number_width; + + let start_ix = range.start; + let end_ix = range.end; let mut prev_lines_offset = visible_start_offset; let mut offset_y = visible_top; @@ -369,6 +363,62 @@ impl TextElement { builder.build().ok() } + fn layout_search_matches( + &self, + last_layout: &LastLayout, + bounds: &mut Bounds, + cx: &mut App, + ) -> Vec<(Path, bool)> { + let search_panel = self.state.read(cx).search_panel.clone(); + let Some((ranges, current_match_ix)) = search_panel.and_then(|panel| { + if let Some(matcher) = panel.read(cx).matcher() { + Some((matcher.matched_ranges.clone(), matcher.current_match_ix)) + } else { + None + } + }) else { + return vec![]; + }; + + let mut paths = Vec::new(); + for (index, range) in ranges.as_ref().iter().enumerate() { + if let Some(path) = Self::layout_match_range(range.clone(), last_layout, bounds) { + paths.push((path, current_match_ix == index)); + } + } + + paths + } + + fn layout_selections( + &self, + last_layout: &LastLayout, + bounds: &mut Bounds, + cx: &mut App, + ) -> Option> { + let state = self.state.read(cx); + let mut selected_range = state.selected_range; + if let Some(ime_marked_range) = &state.ime_marked_range { + if !ime_marked_range.is_empty() { + selected_range = (ime_marked_range.end..ime_marked_range.end).into(); + } + } + if selected_range.is_empty() { + return None; + } + + let (start_ix, end_ix) = if selected_range.start < selected_range.end { + (selected_range.start, selected_range.end) + } else { + (selected_range.end, selected_range.start) + }; + + let range = start_ix.max(last_layout.visible_range_offset.start) + ..end_ix.min(last_layout.visible_range_offset.end); + + Self::layout_match_range(range, &last_layout, bounds) + } + /// Calculate the visible range of lines in the viewport. /// /// Returns @@ -470,6 +520,7 @@ pub(super) struct PrepaintState { /// row index (zero based), no wrap, same line as the cursor. current_row: Option, selection_path: Option>, + search_match_paths: Vec<(Path, bool)>, bounds: Bounds, } @@ -604,10 +655,10 @@ impl Element for TextElement { // Calculate the width of the line numbers let empty_line_number = window.text_system().shape_line( - "+++++".into(), + "++++++".into(), font_size, &[TextRun { - len: 5, + len: 6, font: style.font(), color: gpui::black(), background_color: None, @@ -649,8 +700,10 @@ impl Element for TextElement { runs.extend(highlight_styles.iter().map(|(range, style)| { let mut run = text_style.clone().highlight(*style).to_run(range.len()); - if let Some(marked_range) = &state.marked_range { - if range.start >= marked_range.start && range.end <= marked_range.end { + if let Some(ime_marked_range) = &state.ime_marked_range { + if range.start >= ime_marked_range.start + && range.end <= ime_marked_range.end + { run.color = marked_run.color; run.strikethrough = marked_run.strikethrough; run.underline = marked_run.underline; @@ -664,20 +717,20 @@ impl Element for TextElement { } else { vec![run] } - } else if let Some(marked_range) = &state.marked_range { + } else if let Some(ime_marked_range) = &state.ime_marked_range { // IME marked text vec![ TextRun { - len: marked_range.start, + len: ime_marked_range.start, ..run.clone() }, TextRun { - len: marked_range.end - marked_range.start, + len: ime_marked_range.end - ime_marked_range.start, underline: marked_run.underline, ..run.clone() }, TextRun { - len: display_text.len() - marked_range.end, + len: display_text.len() - ime_marked_range.end, ..run.clone() }, ] @@ -689,7 +742,7 @@ impl Element for TextElement { }; let wrap_width = if multi_line && state.soft_wrap { - Some(bounds.size.width - line_number_width) + Some(bounds.size.width - line_number_width - RIGHT_MARGIN) } else { None }; @@ -706,8 +759,8 @@ impl Element for TextElement { .expect("failed to shape text"); // measure.end(); - let mut longest_line_width = px(0.); - if state.mode.is_multi_line() && lines.len() > 1 { + let mut longest_line_width = wrap_width.unwrap_or(px(0.)); + if state.mode.is_multi_line() && !state.soft_wrap && lines.len() > 1 { let longtest_line: SharedString = state .text .line(state.text.summary().longest_row as usize) @@ -750,7 +803,7 @@ impl Element for TextElement { let mut last_layout = LastLayout { visible_range, visible_top, - visible_start_offset, + visible_range_offset: visible_start_offset..visible_end_offset, line_height, wrap_width, line_number_width, @@ -793,7 +846,8 @@ impl Element for TextElement { self.layout_cursor(&last_layout, &mut bounds, window, cx); last_layout.cursor_bounds = cursor_bounds; - let selection_path = self.layout_selections(&last_layout, &mut bounds, window, cx); + let search_match_paths = self.layout_search_matches(&last_layout, &mut bounds, cx); + let selection_path = self.layout_selections(&last_layout, &mut bounds, cx); let state = self.state.read(cx); let line_numbers = if state.mode.line_number() { @@ -821,7 +875,7 @@ impl Element for TextElement { let ix = last_layout.visible_range.start + ix; let line_no = ix + 1; - let mut line_no_text = format!("{:>5}", line_no); + let mut line_no_text = format!("{:>6}", line_no); if !line.wrap_boundaries.is_empty() { line_no_text.push_str(&"\n ".repeat(line.wrap_boundaries.len())); } @@ -852,6 +906,7 @@ impl Element for TextElement { cursor_scroll_offset, current_row, selection_path, + search_match_paths, } } @@ -948,6 +1003,14 @@ impl Element for TextElement { // Paint selections if window.is_window_active() { + for (path, is_active) in prepaint.search_match_paths.iter() { + window.paint_path(path.clone(), cx.theme().selection.saturation(0.1)); + + if *is_active { + window.paint_path(path.clone(), cx.theme().selection); + } + } + if let Some(path) = prepaint.selection_path.take() { window.paint_path(path, cx.theme().selection); } @@ -986,11 +1049,7 @@ impl Element for TextElement { input_bounds.size.height, ), }, - cx.theme() - .highlight_theme - .style - .background - .unwrap_or(cx.theme().input), + cx.theme().background, )); // Each item is the normal lines. diff --git a/crates/ui/src/input/mod.rs b/crates/ui/src/input/mod.rs index cf7699ae..f89abe95 100644 --- a/crates/ui/src/input/mod.rs +++ b/crates/ui/src/input/mod.rs @@ -10,6 +10,7 @@ mod number_input; mod otp_input; mod popovers; mod rope_ext; +mod search; mod state; mod text_input; mod text_wrapper; diff --git a/crates/ui/src/input/search.rs b/crates/ui/src/input/search.rs new file mode 100644 index 00000000..8d4edafc --- /dev/null +++ b/crates/ui/src/input/search.rs @@ -0,0 +1,547 @@ +use aho_corasick::AhoCorasick; +use rust_i18n::t; +use std::{ops::Range, rc::Rc}; + +use gpui::{ + actions, div, prelude::FluentBuilder as _, App, AppContext as _, Context, Empty, Entity, + EntityInputHandler, FocusHandle, Focusable, Half, InteractiveElement as _, IntoElement, + KeyBinding, ParentElement as _, Render, Styled, Subscription, Window, +}; +use rope::Rope; + +use crate::{ + actions::SelectPrev, + button::{Button, ButtonVariants}, + h_flex, + input::{Enter, Escape, IndentInline, InputEvent, InputState, RopeExt, Search, TextInput}, + v_flex, ActiveTheme, IconName, Selectable, Sizable, +}; + +const KEY_CONTEXT: &'static str = "SearchPanel"; + +actions!(input, [Tab]); + +pub(super) fn init(cx: &mut App) { + cx.bind_keys(vec![KeyBinding::new( + "shift-enter", + SelectPrev, + Some(KEY_CONTEXT), + )]); +} + +#[derive(Debug, Clone)] +pub struct SearchMatcher { + text: Rope, + pub query: Option, + + pub(super) matched_ranges: Rc>>, + pub(super) current_match_ix: usize, + /// Is in replacing mode, if true, the next update will not reset the current match index. + replacing: bool, +} + +impl SearchMatcher { + pub fn new() -> Self { + Self { + text: "".into(), + query: None, + matched_ranges: Rc::new(Vec::new()), + current_match_ix: 0, + replacing: false, + } + } + + /// Update source text and re-match + pub(crate) fn update(&mut self, text: &Rope) { + if self.text.eq(text) { + return; + } + + self.text = text.clone(); + self.update_matches(); + } + + fn update_matches(&mut self) { + let mut new_ranges = Vec::new(); + if let Some(query) = &self.query { + let matches = query.stream_find_iter(self.text.bytes_in_range(0..self.text.len())); + + for query_match in matches.into_iter() { + let query_match = query_match.expect("query match for select all action"); + new_ranges.push(query_match.range()); + } + } + self.matched_ranges = Rc::new(new_ranges); + if !self.replacing { + self.current_match_ix = 0; + self.replacing = false; + } + } + + /// Update the search query and reset the current match index. + pub fn update_query(&mut self, query: &str, case_insensitive: bool) { + if query.len() > 0 { + self.query = Some( + AhoCorasick::builder() + .ascii_case_insensitive(case_insensitive) + .build(&[query.to_string()]) + .expect("failed to build AhoCorasick query in SearchMatcher"), + ); + } else { + self.query = None; + } + self.update_matches(); + } + + /// Returns the number of matches found. + #[allow(unused)] + #[inline] + fn len(&self) -> usize { + self.matched_ranges.len() + } + + fn peek(&self) -> Option> { + self.matched_ranges.get(self.current_match_ix + 1).cloned() + } +} + +impl Iterator for SearchMatcher { + type Item = Range; + + fn next(&mut self) -> Option { + if self.matched_ranges.is_empty() { + return None; + } + + if self.current_match_ix < self.matched_ranges.len().saturating_sub(1) { + self.current_match_ix += 1; + } else { + self.current_match_ix = 0; + } + + self.matched_ranges.get(self.current_match_ix).cloned() + } +} + +impl DoubleEndedIterator for SearchMatcher { + fn next_back(&mut self) -> Option { + if self.matched_ranges.is_empty() { + return None; + } + + if self.current_match_ix == 0 { + self.current_match_ix = self.matched_ranges.len(); + } + + self.current_match_ix -= 1; + let item = self.matched_ranges[self.current_match_ix].clone(); + + Some(item) + } +} + +pub(super) struct SearchPanel { + text_state: Entity, + search_input: Entity, + replace_input: Entity, + case_insensitive: bool, + replace_mode: bool, + matcher: SearchMatcher, + + open: bool, + _subscriptions: Vec, +} + +impl InputState { + /// Update the search matcher when text changes. + pub(super) fn update_search(&mut self, cx: &mut App) { + let Some(search_panel) = self.search_panel.as_ref() else { + return; + }; + + let text = self.text.clone(); + search_panel.update(cx, |this, _| { + this.matcher.update(&text); + }); + } + + pub(super) fn on_action_search( + &mut self, + _: &Search, + window: &mut Window, + cx: &mut Context, + ) { + if !self.searchable { + return; + } + + let search_panel = match self.search_panel.as_ref() { + Some(panel) => panel.clone(), + None => SearchPanel::new(cx.entity(), window, cx), + }; + + let text = self.text.clone(); + let text_state = cx.entity(); + let selected_text = self.selected_text(); + search_panel.update(cx, |this, cx| { + this.text_state = text_state; + this.matcher.update(&text); + this.show(&selected_text, window, cx); + }); + self.search_panel = Some(search_panel); + cx.notify(); + } +} + +impl SearchPanel { + pub fn new(text_state: Entity, window: &mut Window, cx: &mut App) -> Entity { + let search_input = cx.new(|cx| InputState::new(window, cx)); + let replace_input = cx.new(|cx| InputState::new(window, cx)); + + cx.new(|cx| { + let _subscriptions = vec![cx.subscribe( + &search_input, + |this: &mut Self, search_input, ev: &InputEvent, cx| { + // Handle search input changes + match ev { + InputEvent::Change => { + let value = search_input.read(cx).value(); + this.matcher + .update_query(value.as_str(), this.case_insensitive); + } + _ => {} + } + }, + )]; + + Self { + text_state, + search_input, + replace_input, + case_insensitive: true, + replace_mode: false, + matcher: SearchMatcher::new(), + open: true, + _subscriptions, + } + }) + } + + pub(super) fn show( + &mut self, + selected_text: &Rope, + window: &mut Window, + cx: &mut Context, + ) { + self.open = true; + self.search_input.read(cx).focus_handle.focus(window); + + self.search_input.update(cx, |this, cx| { + if selected_text.len() > 0 { + this.set_value(selected_text.to_string(), window, cx); + } + this.select_all(&super::SelectAll, window, cx); + }); + self.update_search(cx); + cx.notify(); + } + + fn update_search(&mut self, cx: &mut Context) { + let query = self.search_input.read(cx).value(); + self.matcher + .update_query(query.as_str(), self.case_insensitive); + self.update_text_selection(cx); + } + + pub(super) fn hide(&mut self, window: &mut Window, cx: &mut Context) { + self.open = false; + self.text_state.read(cx).focus_handle.focus(window); + cx.notify(); + } + + fn on_action_prev(&mut self, _: &SelectPrev, window: &mut Window, cx: &mut Context) { + self.prev(window, cx); + } + + fn on_action_next(&mut self, _: &Enter, window: &mut Window, cx: &mut Context) { + self.next(window, cx); + } + + fn on_action_escape(&mut self, _: &Escape, window: &mut Window, cx: &mut Context) { + self.hide(window, cx); + } + + fn on_action_tab(&mut self, _: &IndentInline, window: &mut Window, cx: &mut Context) { + self.text_state.focus_handle(cx).focus(window); + } + + fn update_text_selection(&mut self, cx: &mut Context) { + if let Some(range) = self + .matcher + .matched_ranges + .get(self.matcher.current_match_ix) + .cloned() + { + let state = self.text_state.clone(); + cx.spawn(async move |_, cx| { + _ = cx.update(|cx| { + state.update(cx, |state, cx| { + state.selected_range = range.into(); + cx.notify(); + }); + }); + }) + .detach(); + } + } + + fn prev(&mut self, _: &mut Window, cx: &mut Context) { + if let Some(range) = self.matcher.next_back() { + self.text_state.update(cx, |state, cx| { + state.scroll_to(range.start, cx); + }); + } + } + + fn next(&mut self, _: &mut Window, cx: &mut Context) { + if let Some(range) = self.matcher.next() { + self.text_state.update(cx, |state, cx| { + state.scroll_to(range.end, cx); + }); + } + } + + pub(super) fn matcher(&self) -> Option<&SearchMatcher> { + if !self.open { + return None; + } + + Some(&self.matcher) + } + + fn replace_next(&mut self, window: &mut Window, cx: &mut Context) { + let new_text = self.replace_input.read(cx).value(); + self.matcher.replacing = true; + if let Some(range) = self + .matcher + .matched_ranges + .get(self.matcher.current_match_ix) + .cloned() + { + let text_state = self.text_state.clone(); + + let next_range = self.matcher.peek().unwrap_or(range.clone()); + cx.spawn_in(window, async move |_, cx| { + cx.update(|window, cx| { + text_state.update(cx, |state, cx| { + let range_utf16 = state.range_to_utf16(&range); + state.scroll_to(next_range.end, cx); + state.replace_text_in_range( + Some(range_utf16), + new_text.as_str(), + window, + cx, + ); + }); + }) + }) + .detach(); + } + } + + fn replace_all(&mut self, window: &mut Window, cx: &mut Context) { + let new_text = self.replace_input.read(cx).value(); + self.matcher.replacing = true; + let ranges = self.matcher.matched_ranges.clone(); + if ranges.is_empty() { + return; + } + + let text_state = self.text_state.clone(); + cx.spawn_in(window, async move |_, cx| { + cx.update(|window, cx| { + text_state.update(cx, |state, cx| { + // Replace from the end to avoid messing up the ranges. + let mut rope = state.text.clone(); + for range in ranges.iter().rev() { + rope.replace(range.clone(), new_text.as_str()); + } + state.replace_text_in_range( + Some(0..state.text.len()), + &rope.to_string(), + window, + cx, + ); + state.scroll_to(0, cx); + }); + }) + }) + .detach(); + } +} + +impl Focusable for SearchPanel { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.search_input.read(cx).focus_handle.clone() + } +} + +impl Render for SearchPanel { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + if !self.open { + return Empty.into_any_element(); + } + + v_flex() + .id("search-panel") + .occlude() + .track_focus(&self.focus_handle(cx)) + .key_context(KEY_CONTEXT) + .on_action(cx.listener(Self::on_action_prev)) + .on_action(cx.listener(Self::on_action_next)) + .on_action(cx.listener(Self::on_action_escape)) + .on_action(cx.listener(Self::on_action_tab)) + .font_family(".SystemUIFont") + .items_center() + .py_2() + .px_3() + .w_full() + .gap_1() + .bg(cx.theme().popover) + .border_b_1() + .rounded(cx.theme().radius.half()) + .border_color(cx.theme().border) + .child( + h_flex() + .w_full() + .gap_2() + .child( + div().flex_1().gap_1().child( + TextInput::new(&self.search_input) + .focus_bordered(false) + .suffix( + Button::new("case-insensitive") + .selected(!self.case_insensitive) + .xsmall() + .compact() + .ghost() + .icon(IconName::CaseSensitive) + .on_click(cx.listener(|this, _, _, cx| { + this.case_insensitive = !this.case_insensitive; + this.update_search(cx); + cx.notify(); + })), + ) + .small() + .w_full() + .cleanable() + .shadow_none(), + ), + ) + .child( + Button::new("replace-mode") + .xsmall() + .ghost() + .icon(IconName::Replace) + .selected(self.replace_mode) + .on_click(cx.listener(|this, _, window, cx| { + this.replace_mode = !this.replace_mode; + this.replace_input.read(cx).focus_handle.focus(window); + cx.notify(); + })), + ) + .child( + Button::new("prev") + .xsmall() + .ghost() + .icon(IconName::ChevronLeft) + .on_click(cx.listener(|this, _, window, cx| { + this.prev(window, cx); + })), + ) + .child( + Button::new("next") + .xsmall() + .ghost() + .icon(IconName::ChevronRight) + .on_click(cx.listener(|this, _, window, cx| { + this.next(window, cx); + })), + ) + .child(div().w_5()) + .child( + Button::new("close") + .xsmall() + .ghost() + .icon(IconName::Close) + .on_click(cx.listener(|this, _, window, cx| { + this.on_action_escape(&Escape, window, cx); + })), + ), + ) + .when(self.replace_mode, |this| { + this.child( + h_flex() + .w_full() + .gap_2() + .child( + TextInput::new(&self.replace_input) + .focus_bordered(false) + .small() + .w_full() + .shadow_none(), + ) + .child( + Button::new("replace-one") + .small() + .label(t!("Input.Replace")) + .on_click(cx.listener(|this, _, window, cx| { + this.replace_next(window, cx); + })), + ) + .child( + Button::new("replace-all") + .small() + .label(t!("Input.Replace All")) + .on_click(cx.listener(|this, _, window, cx| { + this.replace_all(window, cx); + })), + ), + ) + }) + .into_any_element() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_search() { + let mut search = SearchMatcher::new(); + search.update(&Rope::from("Hello 世界 this is a Is test string.")); + search.update_query("Is", true); + + assert_eq!(search.len(), 3); + let mut matches = search.clone().into_iter(); + assert_eq!(matches.current_match_ix, 0); + assert_eq!(matches.next(), Some(18..20)); + assert_eq!(matches.next(), Some(23..25)); + assert_eq!(matches.current_match_ix, 2); + assert_eq!(matches.next(), Some(15..17)); + assert_eq!(matches.current_match_ix, 0); + assert_eq!(matches.next_back(), Some(23..25)); + assert_eq!(matches.current_match_ix, 2); + assert_eq!(matches.next_back(), Some(18..20)); + assert_eq!(matches.current_match_ix, 1); + assert_eq!(matches.next_back(), Some(15..17)); + assert_eq!(matches.current_match_ix, 0); + assert_eq!(matches.next_back(), Some(23..25)); + + search.update_query("IS", false); + assert_eq!(search.len(), 0); + assert_eq!(search.next(), None); + assert_eq!(search.next_back(), None); + } +} diff --git a/crates/ui/src/input/state.rs b/crates/ui/src/input/state.rs index dadedab6..3f908421 100644 --- a/crates/ui/src/input/state.rs +++ b/crates/ui/src/input/state.rs @@ -5,7 +5,7 @@ use anyhow::Result; use gpui::{ actions, div, point, prelude::FluentBuilder as _, px, Action, App, AppContext, Bounds, - ClipboardItem, Context, Entity, EntityInputHandler, EventEmitter, FocusHandle, Focusable, + ClipboardItem, Context, Entity, EntityInputHandler, EventEmitter, FocusHandle, Focusable, Half, InteractiveElement as _, IntoElement, KeyBinding, KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, Point, Render, ScrollHandle, ScrollWheelEvent, SharedString, Styled as _, Subscription, Task, UTF16Selection, Window, @@ -31,6 +31,7 @@ use super::{ }; use crate::input::{ popovers::{ContextMenu, DiagnosticPopover}, + search::{self, SearchPanel}, Position, }; use crate::input::{RopeExt as _, Selection}; @@ -90,6 +91,7 @@ actions!( MoveToNextWord, Escape, ToggleCodeActions, + Search, ] ); @@ -216,8 +218,13 @@ pub fn init(cx: &mut App) { KeyBinding::new("cmd-.", ToggleCodeActions, Some(CONTEXT)), #[cfg(not(target_os = "macos"))] KeyBinding::new("ctrl-.", ToggleCodeActions, Some(CONTEXT)), + #[cfg(target_os = "macos")] + KeyBinding::new("cmd-f", Search, Some(CONTEXT)), + #[cfg(not(target_os = "macos"))] + KeyBinding::new("ctrl-f", Search, Some(CONTEXT)), ]); + search::init(cx); number_input::init(cx); } @@ -227,8 +234,8 @@ pub(super) struct LastLayout { pub(super) visible_range: Range, /// The first visible line top position in scroll viewport. pub(super) visible_top: Pixels, - /// The start byte offset of the first visible line. - pub(super) visible_start_offset: usize, + /// The range of byte offset of the visible lines. + pub(super) visible_range_offset: Range, /// The last layout lines (Only have visible lines). pub(super) lines: Rc>, /// The line_height of text layout, this will change will InputElement painted. @@ -255,11 +262,13 @@ pub struct InputState { /// - "Hello 世界💝" = 16 /// - "💝" = 4 pub(super) selected_range: Selection, + pub(super) search_panel: Option>, + pub(super) searchable: bool, /// Range for save the selected word, use to keep word range when drag move. pub(super) selected_word_range: Option, pub(super) selection_reversed: bool, /// The marked range is the temporary insert text on IME typing. - pub(super) marked_range: Option, + pub(super) ime_marked_range: Option, pub(super) last_layout: Option, pub(super) last_cursor: Option, /// The input container bounds @@ -342,9 +351,11 @@ impl InputState { blink_cursor, history, selected_range: Selection::default(), + search_panel: None, + searchable: false, selected_word_range: None, selection_reversed: false, - marked_range: None, + ime_marked_range: None, input_bounds: Bounds::default(), selecting: false, disabled: false, @@ -425,6 +436,13 @@ impl InputState { code_action_providers: vec![], completion_provider: None, }; + self.searchable = true; + self + } + + /// Set this input is searchable, default is false (Default true for Code Editor). + pub fn searchable(mut self, searchable: bool) -> Self { + self.searchable = searchable; self } @@ -634,7 +652,7 @@ impl InputState { }; let line_height = last_layout.line_height; - let mut prev_lines_offset = last_layout.visible_start_offset; + let mut prev_lines_offset = last_layout.visible_range_offset.start; let mut y_offset = last_layout.visible_top; for (line_index, line) in last_layout.lines.iter().enumerate() { let local_offset = offset.saturating_sub(prev_lines_offset); @@ -1049,7 +1067,7 @@ impl InputState { cx: &mut Context, ) { self.move_to(0, window, cx); - self.select_to(self.text.len(), window, cx) + self.select_to(self.text.len(), window, cx); } pub(super) fn home(&mut self, _: &MoveHome, window: &mut Window, cx: &mut Context) { @@ -1549,7 +1567,7 @@ impl InputState { return; } - if self.marked_range.is_some() { + if self.ime_marked_range.is_some() { self.unmark_text(window, cx); } @@ -1577,9 +1595,9 @@ impl InputState { ) { // If there have IME marked range and is empty (Means pressed Esc to abort IME typing) // Clear the marked range. - if let Some(marked_range) = &self.marked_range { - if marked_range.len() == 0 { - self.marked_range = None; + if let Some(ime_marked_range) = &self.ime_marked_range { + if ime_marked_range.len() == 0 { + self.ime_marked_range = None; } } @@ -1674,6 +1692,41 @@ impl InputState { cx.notify(); } + pub(crate) fn scroll_to(&mut self, offset: usize, cx: &mut Context) { + let Some(last_layout) = self.last_layout.as_ref() else { + return; + }; + let Some(bounds) = self.last_bounds.as_ref() else { + return; + }; + + let mut scroll_offset = self.scroll_handle.offset(); + let line_height = last_layout.line_height; + + let point = self.text.offset_to_point(offset); + let row = point.row as usize; + + let mut row_offset_y = px(0.); + for (ix, wrap_line) in self.text_wrapper.lines.iter().enumerate() { + if ix == row { + break; + } + + row_offset_y += wrap_line.height(line_height); + } + + // Check if row_offset_y is out of the viewport + // If row offset is not in the viewport, scroll to make it visible + if row_offset_y < -scroll_offset.y { + // Scroll up + scroll_offset.y = -row_offset_y - line_height + bounds.size.height.half(); + } else if row_offset_y + line_height > -scroll_offset.y + bounds.size.height { + // Scroll down + scroll_offset.y = -(row_offset_y - bounds.size.height.half()); + } + self.update_scroll_offset(Some(scroll_offset), cx); + } + pub(super) fn show_character_palette( &mut self, _: &ShowCharacterPalette, @@ -1756,6 +1809,7 @@ impl InputState { fn move_to(&mut self, offset: usize, _: &mut Window, cx: &mut Context) { let offset = offset.clamp(0, self.text.len()); self.selected_range = (offset..offset).into(); + self.scroll_to(offset, cx); self.pause_blink_cursor(cx); self.update_preferred_column(); self.hide_context_menu(cx); @@ -1766,8 +1820,8 @@ impl InputState { /// /// The offset is the UTF-8 offset. pub fn cursor(&self) -> usize { - if let Some(marked_range) = &self.marked_range { - return marked_range.end; + if let Some(ime_marked_range) = &self.ime_marked_range { + return ime_marked_range.end; } if self.selection_reversed { @@ -1809,7 +1863,7 @@ impl InputState { // - included the scroll offset. let inner_position = position - bounds.origin - point(line_number_width, px(0.)); - let mut index = last_layout.visible_start_offset; + let mut index = last_layout.visible_range_offset.start; let mut y_offset = last_layout.visible_top; for (ix, line) in self .text_wrapper @@ -2183,6 +2237,10 @@ impl InputState { } } } + + pub(super) fn selected_text(&self) -> Rope { + self.text.slice(self.selected_range.into()) + } } impl EntityInputHandler for InputState { @@ -2215,12 +2273,12 @@ impl EntityInputHandler for InputState { _window: &mut Window, _cx: &mut Context, ) -> Option> { - self.marked_range + self.ime_marked_range .map(|range| self.range_to_utf16(&range.into())) } fn unmark_text(&mut self, _window: &mut Window, _cx: &mut Context) { - self.marked_range = None; + self.ime_marked_range = None; } /// Replace text in range. @@ -2243,7 +2301,7 @@ impl EntityInputHandler for InputState { let range = range_utf16 .as_ref() .map(|range_utf16| self.range_from_utf16(range_utf16)) - .or(self.marked_range.map(|range| range.into())) + .or(self.ime_marked_range.map(|range| range.into())) .unwrap_or(self.selected_range.into()); let old_text = self.text.clone(); @@ -2273,9 +2331,10 @@ impl EntityInputHandler for InputState { self.mode .update_highlighter(&range, &self.text, &new_text, true, cx); self.selected_range = (new_offset..new_offset).into(); - self.marked_range.take(); + self.ime_marked_range.take(); self.update_preferred_column(); self.update_scroll_offset(None, cx); + self.update_search(cx); self.mode.update_auto_grow(&self.text_wrapper); self.handle_completion_trigger(&range, &new_text, window, cx); cx.emit(InputEvent::Change); @@ -2298,7 +2357,7 @@ impl EntityInputHandler for InputState { let range = range_utf16 .as_ref() .map(|range_utf16| self.range_from_utf16(range_utf16)) - .or(self.marked_range.map(|range| range.into())) + .or(self.ime_marked_range.map(|range| range.into())) .unwrap_or(self.selected_range.into()); let old_text = self.text.clone(); @@ -2320,9 +2379,9 @@ impl EntityInputHandler for InputState { if new_text.is_empty() { // Cancel selection, when cancel IME input. self.selected_range = (range.start..range.start).into(); - self.marked_range = None; + self.ime_marked_range = None; } else { - self.marked_range = Some((range.start..range.start + new_text.len()).into()); + self.ime_marked_range = Some((range.start..range.start + new_text.len()).into()); self.selected_range = new_selected_range_utf16 .as_ref() .map(|range_utf16| self.range_from_utf16(range_utf16)) @@ -2352,7 +2411,7 @@ impl EntityInputHandler for InputState { let mut end_origin = None; let line_number_origin = point(line_number_width, px(0.)); let mut y_offset = last_layout.visible_top; - let mut index_offset = last_layout.visible_start_offset; + let mut index_offset = last_layout.visible_range_offset.start; for line in last_layout.lines.iter() { if start_origin.is_some() && end_origin.is_some() { @@ -2400,7 +2459,7 @@ impl EntityInputHandler for InputState { let last_layout = self.last_layout.as_ref()?; let line_height = last_layout.line_height; let line_point = self.last_bounds?.localize(&point)?; - let offset = last_layout.visible_start_offset; + let offset = last_layout.visible_range_offset.start; for line in last_layout.lines.iter() { if let Ok(utf8_index) = line.index_for_position(line_point, line_height) { diff --git a/crates/ui/src/input/text_input.rs b/crates/ui/src/input/text_input.rs index e5b42f87..d9720635 100644 --- a/crates/ui/src/input/text_input.rs +++ b/crates/ui/src/input/text_input.rs @@ -1,8 +1,8 @@ use gpui::prelude::FluentBuilder as _; use gpui::{ - div, px, relative, AnyElement, App, DefiniteLength, Entity, InteractiveElement as _, - IntoElement, IsZero, MouseButton, ParentElement as _, Rems, RenderOnce, StyleRefinement, - Styled, Window, + div, px, relative, AnyElement, App, DefiniteLength, Edges, EdgesRefinement, Entity, + InteractiveElement as _, IntoElement, IsZero, MouseButton, ParentElement as _, Pixels, Rems, + RenderOnce, StyleRefinement, Styled, Window, }; use crate::button::{Button, ButtonVariants as _}; @@ -10,8 +10,8 @@ use crate::indicator::Indicator; use crate::input::clear_button; use crate::input::element::{LINE_NUMBER_RIGHT_MARGIN, RIGHT_MARGIN}; use crate::scroll::Scrollbar; -use crate::ActiveTheme; use crate::{h_flex, StyledExt}; +use crate::{v_flex, ActiveTheme}; use crate::{IconName, Size}; use crate::{Sizable, StyleSized}; @@ -139,6 +139,76 @@ impl TextInput { } }) } + + /// This method must after the refine_style. + fn render_editor( + paddings: EdgesRefinement, + input_state: &Entity, + state: &InputState, + window: &Window, + _cx: &App, + ) -> impl IntoElement { + let base_size = window.text_style().font_size; + let rem_size = window.rem_size(); + + let paddings = Edges { + left: paddings + .left + .map(|v| v.to_pixels(base_size, rem_size)) + .unwrap_or(px(0.)), + right: paddings + .right + .map(|v| v.to_pixels(base_size, rem_size)) + .unwrap_or(px(0.)), + top: paddings + .top + .map(|v| v.to_pixels(base_size, rem_size)) + .unwrap_or(px(0.)), + bottom: paddings + .bottom + .map(|v| v.to_pixels(base_size, rem_size)) + .unwrap_or(px(0.)), + }; + + const MIN_SCROLL_PADDING: Pixels = px(2.0); + + v_flex() + .size_full() + .children(state.search_panel.clone()) + .child(div().flex_1().child(input_state.clone()).map(|this| { + if let Some(last_layout) = state.last_layout.as_ref() { + let left = if last_layout.line_number_width.is_zero() { + px(0.) + } else { + // Align left edge to the Line number. + paddings.left + last_layout.line_number_width - LINE_NUMBER_RIGHT_MARGIN + }; + + let scroll_size = gpui::Size { + width: state.scroll_size.width - left + paddings.right + RIGHT_MARGIN, + height: state.scroll_size.height, + }; + + let scrollbar = if !state.soft_wrap { + Scrollbar::both(&state.scroll_state, &state.scroll_handle) + } else { + Scrollbar::vertical(&state.scroll_state, &state.scroll_handle) + }; + + this.relative().child( + div() + .absolute() + .top(-paddings.top + MIN_SCROLL_PADDING) + .left(left) + .right(-paddings.right + MIN_SCROLL_PADDING) + .bottom(-paddings.bottom + MIN_SCROLL_PADDING) + .child(scrollbar.scroll_size(scroll_size)), + ) + } else { + this + } + })) + } } impl Styled for TextInput { @@ -233,6 +303,7 @@ impl RenderOnce for TextInput { .on_action(window.listener_for(&self.state, InputState::select_to_end)) .on_action(window.listener_for(&self.state, InputState::show_character_palette)) .on_action(window.listener_for(&self.state, InputState::copy)) + .on_action(window.listener_for(&self.state, InputState::on_action_search)) .on_key_down(window.listener_for(&self.state, InputState::on_key_down)) .on_mouse_down( MouseButton::Left, @@ -246,10 +317,12 @@ impl RenderOnce for TextInput { .on_scroll_wheel(window.listener_for(&self.state, InputState::on_scroll_wheel)) .size_full() .line_height(LINE_HEIGHT) + .input_px(self.size) .input_py(self.size) .input_h(self.size) .cursor_text() .text_size(font_size) + .items_center() .when(state.mode.is_multi_line(), |this| { this.h_auto() .when_some(self.height, |this, height| this.h(height)) @@ -266,11 +339,23 @@ impl RenderOnce for TextInput { }) }) }) - .input_px(self.size) .items_center() .gap(gap_x) + .refine_style(&self.style) .children(prefix) - .child(self.state.clone()) + .when(state.mode.is_multi_line(), |mut this| { + let paddings = this.style().padding.clone(); + this.child(Self::render_editor( + paddings, + &self.state, + &state, + window, + cx, + )) + }) + .when(!state.mode.is_multi_line(), |this| { + this.child(self.state.clone()) + }) .when(has_suffix, |this| { this.pr(self.size.input_px() / 2.).child( h_flex() @@ -297,62 +382,5 @@ impl RenderOnce for TextInput { .children(suffix), ) }) - .refine_style(&self.style) - .when(state.mode.is_multi_line(), |mut this| { - let paddings = this.style().padding.clone(); - let base_size = window.text_style().font_size; - let rem_size = window.rem_size(); - - let paddings = gpui::Edges { - left: paddings - .left - .map(|v| v.to_pixels(base_size, rem_size)) - .unwrap_or(px(0.)), - right: paddings - .right - .map(|v| v.to_pixels(base_size, rem_size)) - .unwrap_or(px(0.)), - top: paddings - .top - .map(|v| v.to_pixels(base_size, rem_size)) - .unwrap_or(px(0.)), - bottom: paddings - .bottom - .map(|v| v.to_pixels(base_size, rem_size)) - .unwrap_or(px(0.)), - }; - - if let Some(last_layout) = state.last_layout.as_ref() { - let left = if last_layout.line_number_width.is_zero() { - px(0.) - } else { - // Align left edge to the Line number. - paddings.left + last_layout.line_number_width - LINE_NUMBER_RIGHT_MARGIN - }; - - let scroll_size = gpui::Size { - width: state.scroll_size.width - left + paddings.right + RIGHT_MARGIN, - height: state.scroll_size.height, - }; - - let scrollbar = if !state.soft_wrap { - Scrollbar::both(&state.scroll_state, &state.scroll_handle) - } else { - Scrollbar::vertical(&state.scroll_state, &state.scroll_handle) - }; - - this.relative().child( - div() - .absolute() - .top_0() - .left(left) - .right_0() - .bottom_0() - .child(scrollbar.scroll_size(scroll_size)), - ) - } else { - this - } - }) } } diff --git a/crates/ui/src/styled.rs b/crates/ui/src/styled.rs index c364dc53..1fdd08a0 100644 --- a/crates/ui/src/styled.rs +++ b/crates/ui/src/styled.rs @@ -333,11 +333,11 @@ impl Size { pub fn input_py(&self) -> Pixels { match self { - Size::Large => px(16.), - Size::Medium => px(8.), - Size::Small => px(4.), + Size::Large => px(10.), + Size::Medium => px(5.), + Size::Small => px(2.), Size::XSmall => px(0.), - _ => px(4.), + _ => px(2.), } } } diff --git a/themes/catppuccin.json b/themes/catppuccin.json index ca026f49..b494484c 100644 --- a/themes/catppuccin.json +++ b/themes/catppuccin.json @@ -584,7 +584,6 @@ "editor.active_line.background": "#363a4f", "editor.line_number": "#b8c0e0", "editor.active_line_number": "#cad3f5", - "conflict": "#ed8796", "created": "#a6da95", "deleted": "#ed8796", @@ -730,7 +729,7 @@ "highlight": { "editor.foreground": "#cdd6f4", "editor.background": "#181825", - "editor.active_line.background": "#302d41", + "editor.active_line.background": "#222230AA", "editor.line_number": "#6c7086", "editor.active_line_number": "#cdd6f4", "conflict": "#f38ba8",