input: Add to support search and replace. (#1252)

<img width="806" height="765" alt="image"
src="https://github.com/user-attachments/assets/5e2a7144-f814-4874-b9c1-89ebf5d942ec"
/>

- Close #1212 to support search for Input.
- Fix #1235 to support scroll to cursor when go to line.
This commit is contained in:
Jason Lee 2025-09-17 19:02:00 +08:00 committed by GitHub
parent e01b139995
commit 8a3ef51ea1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 883 additions and 156 deletions

1
Cargo.lock generated
View file

@ -3206,6 +3206,7 @@ dependencies = [
name = "gpui-component"
version = "0.1.0"
dependencies = [
"aho-corasick",
"anyhow",
"chrono",
"enum-iterator",

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-case-sensitive-icon lucide-case-sensitive"><path d="m2 16 4.039-9.69a.5.5 0 0 1 .923 0L11 16"/><path d="M22 9v7"/><path d="M3.304 13h6.392"/><circle cx="18.5" cy="12.5" r="3.5"/></svg>

After

Width:  |  Height:  |  Size: 387 B

16
assets/icons/replace.svg Normal file
View file

@ -0,0 +1,16 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-replace-icon lucide-replace"
><path d="M14 4a2 2 0 0 1 2-2" /><path d="M16 10a2 2 0 0 1-2-2" /><path
d="M20 2a2 2 0 0 1 2 2"
/><path d="M22 8a2 2 0 0 1-2 2" /><path d="m3 7 3 3 3-3" /><path
d="M6 10V5a3 3 0 0 1 3-3h1"
/><rect x="2" y="14" width="8" height="8" rx="2" /></svg>

After

Width:  |  Height:  |  Size: 542 B

View file

@ -137,11 +137,7 @@ impl CompletionProvider for ExampleLspStore {
_: &mut Window,
cx: &mut Context<InputState>,
) -> Task<Result<Vec<CompletionResponse>>> {
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![]));
}

View file

@ -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),
),

View file

@ -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| {

View file

@ -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 }

View file

@ -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

View file

@ -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",

View file

@ -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<usize>,
last_layout: &LastLayout,
bounds: &mut Bounds<Pixels>,
_: &mut Window,
cx: &mut App,
) -> Option<Path<Pixels>> {
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<Pixels>,
cx: &mut App,
) -> Vec<(Path<Pixels>, 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<Pixels>,
cx: &mut App,
) -> Option<Path<Pixels>> {
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<usize>,
selection_path: Option<Path<Pixels>>,
search_match_paths: Vec<(Path<Pixels>, bool)>,
bounds: Bounds<Pixels>,
}
@ -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.

View file

@ -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;

View file

@ -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<AhoCorasick>,
pub(super) matched_ranges: Rc<Vec<Range<usize>>>,
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<Range<usize>> {
self.matched_ranges.get(self.current_match_ix + 1).cloned()
}
}
impl Iterator for SearchMatcher {
type Item = Range<usize>;
fn next(&mut self) -> Option<Self::Item> {
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<Self::Item> {
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<InputState>,
search_input: Entity<InputState>,
replace_input: Entity<InputState>,
case_insensitive: bool,
replace_mode: bool,
matcher: SearchMatcher,
open: bool,
_subscriptions: Vec<Subscription>,
}
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<Self>,
) {
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<InputState>, window: &mut Window, cx: &mut App) -> Entity<Self> {
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>,
) {
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<Self>) {
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>) {
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>) {
self.prev(window, cx);
}
fn on_action_next(&mut self, _: &Enter, window: &mut Window, cx: &mut Context<Self>) {
self.next(window, cx);
}
fn on_action_escape(&mut self, _: &Escape, window: &mut Window, cx: &mut Context<Self>) {
self.hide(window, cx);
}
fn on_action_tab(&mut self, _: &IndentInline, window: &mut Window, cx: &mut Context<Self>) {
self.text_state.focus_handle(cx).focus(window);
}
fn update_text_selection(&mut self, cx: &mut Context<Self>) {
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<Self>) {
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<Self>) {
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<Self>) {
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<Self>) {
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<Self>) -> 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);
}
}

View file

@ -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<usize>,
/// 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<usize>,
/// The last layout lines (Only have visible lines).
pub(super) lines: Rc<SmallVec<[WrappedLine; 1]>>,
/// 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<Entity<SearchPanel>>,
pub(super) searchable: bool,
/// Range for save the selected word, use to keep word range when drag move.
pub(super) selected_word_range: Option<Selection>,
pub(super) selection_reversed: bool,
/// The marked range is the temporary insert text on IME typing.
pub(super) marked_range: Option<Selection>,
pub(super) ime_marked_range: Option<Selection>,
pub(super) last_layout: Option<LastLayout>,
pub(super) last_cursor: Option<usize>,
/// 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>,
) {
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<Self>) {
@ -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<Self>) {
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<Self>) {
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<Self>,
) -> Option<Range<usize>> {
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>) {
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) {

View file

@ -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<DefiniteLength>,
input_state: &Entity<InputState>,
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
}
})
}
}

View file

@ -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.),
}
}
}

View file

@ -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",