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" name = "gpui-component"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"aho-corasick",
"anyhow", "anyhow",
"chrono", "chrono",
"enum-iterator", "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, _: &mut Window,
cx: &mut Context<InputState>, cx: &mut Context<InputState>,
) -> Task<Result<Vec<CompletionResponse>>> { ) -> Task<Result<Vec<CompletionResponse>>> {
let trigger_character = trigger let trigger_character = trigger.trigger_character.unwrap_or_default();
.trigger_character
.as_deref()
.unwrap_or("")
.to_string();
if trigger_character.is_empty() { if trigger_character.is_empty() {
return Task::ready(Ok(vec![])); return Task::ready(Ok(vec![]));
} }

View file

@ -26,6 +26,7 @@ impl Example {
tab_size: 2, tab_size: 2,
..Default::default() ..Default::default()
}) })
.searchable(true)
.placeholder("Enter your Markdown here...") .placeholder("Enter your Markdown here...")
.default_value(EXAMPLE) .default_value(EXAMPLE)
}); });
@ -66,6 +67,7 @@ impl Render for Example {
.child( .child(
TextInput::new(&self.input_state) TextInput::new(&self.input_state)
.h_full() .h_full()
.p_0()
.appearance(false) .appearance(false)
.focus_bordered(false), .focus_bordered(false),
), ),

View file

@ -54,7 +54,9 @@ impl TextareaStory {
InputState::new(window, cx) InputState::new(window, cx)
.multi_line() .multi_line()
.rows(10) .rows(10)
.placeholder("Enter text here...").default_value( .placeholder("Enter text here...")
.searchable(true)
.default_value(
unindent::unindent( unindent::unindent(
r#"Hello 世界this is GPUI component. r#"Hello 世界this is GPUI component.

View file

@ -91,6 +91,7 @@ chrono = "0.4.38"
# Code Editor # Code Editor
lsp-types.workspace = true lsp-types.workspace = true
aho-corasick = "1.1.3"
tree-sitter = "0.25.4" tree-sitter = "0.25.4"
tree-sitter-json = "0.24.8" tree-sitter-json = "0.24.8"
tree-sitter-bash = { version = "0.23.3", optional = true } tree-sitter-bash = { version = "0.23.3", optional = true }

View file

@ -155,3 +155,14 @@ List:
zh-CN: 搜索... zh-CN: 搜索...
zh-HK: 搜索... zh-HK: 搜索...
it: Ricerca... 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, Bot,
Building2, Building2,
Calendar, Calendar,
CaseSensitive,
ChartPie, ChartPie,
Check, Check,
ChevronDown, ChevronDown,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
ChevronUp,
ChevronsUpDown, ChevronsUpDown,
ChevronUp,
CircleCheck, CircleCheck,
CircleUser, CircleUser,
CircleX, CircleX,
@ -68,6 +69,7 @@ pub enum IconName {
PanelRightClose, PanelRightClose,
PanelRightOpen, PanelRightOpen,
Plus, Plus,
Replace,
ResizeCorner, ResizeCorner,
Search, Search,
Settings, Settings,
@ -102,6 +104,7 @@ impl IconName {
Self::Bot => "icons/bot.svg", Self::Bot => "icons/bot.svg",
Self::Building2 => "icons/building-2.svg", Self::Building2 => "icons/building-2.svg",
Self::Calendar => "icons/calendar.svg", Self::Calendar => "icons/calendar.svg",
Self::CaseSensitive => "icons/case-sensitive.svg",
Self::ChartPie => "icons/chart-pie.svg", Self::ChartPie => "icons/chart-pie.svg",
Self::Check => "icons/check.svg", Self::Check => "icons/check.svg",
Self::ChevronDown => "icons/chevron-down.svg", Self::ChevronDown => "icons/chevron-down.svg",
@ -152,6 +155,7 @@ impl IconName {
Self::PanelRightClose => "icons/panel-right-close.svg", Self::PanelRightClose => "icons/panel-right-close.svg",
Self::PanelRightOpen => "icons/panel-right-open.svg", Self::PanelRightOpen => "icons/panel-right-open.svg",
Self::Plus => "icons/plus.svg", Self::Plus => "icons/plus.svg",
Self::Replace => "icons/replace.svg",
Self::ResizeCorner => "icons/resize-corner.svg", Self::ResizeCorner => "icons/resize-corner.svg",
Self::Search => "icons/search.svg", Self::Search => "icons/search.svg",
Self::Settings => "icons/settings.svg", Self::Settings => "icons/settings.svg",

View file

@ -11,7 +11,7 @@ use smallvec::SmallVec;
use crate::{ use crate::{
input::{blink_cursor::CURSOR_WIDTH, RopeExt as _}, input::{blink_cursor::CURSOR_WIDTH, RopeExt as _},
ActiveTheme as _, Root, ActiveTheme as _, Colorize, Root,
}; };
use super::{mode::InputMode, InputState, LastLayout}; use super::{mode::InputMode, InputState, LastLayout};
@ -76,8 +76,8 @@ impl TextElement {
let line_number_width = last_layout.line_number_width; let line_number_width = last_layout.line_number_width;
let mut selected_range = state.selected_range; let mut selected_range = state.selected_range;
if let Some(marked_range) = &state.marked_range { if let Some(ime_marked_range) = &state.ime_marked_range {
selected_range = (marked_range.end..marked_range.end).into(); selected_range = (ime_marked_range.end..ime_marked_range.end).into();
} }
let cursor = state.cursor(); let cursor = state.cursor();
@ -230,35 +230,29 @@ impl TextElement {
(cursor_bounds, scroll_offset, current_row) (cursor_bounds, scroll_offset, current_row)
} }
fn layout_selections( fn layout_match_range(
&self, range: Range<usize>,
last_layout: &LastLayout, last_layout: &LastLayout,
bounds: &mut Bounds<Pixels>, bounds: &mut Bounds<Pixels>,
_: &mut Window,
cx: &mut App,
) -> Option<Path<Pixels>> { ) -> Option<Path<Pixels>> {
let line_height = last_layout.line_height; if range.is_empty() {
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() {
return None; return None;
} }
let (start_ix, end_ix) = if selected_range.start < selected_range.end { if range.start < last_layout.visible_range_offset.start
(selected_range.start, selected_range.end) || range.end > last_layout.visible_range_offset.end
} else { {
(selected_range.end, selected_range.start) 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 prev_lines_offset = visible_start_offset;
let mut offset_y = visible_top; let mut offset_y = visible_top;
@ -369,6 +363,62 @@ impl TextElement {
builder.build().ok() 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. /// Calculate the visible range of lines in the viewport.
/// ///
/// Returns /// Returns
@ -470,6 +520,7 @@ pub(super) struct PrepaintState {
/// row index (zero based), no wrap, same line as the cursor. /// row index (zero based), no wrap, same line as the cursor.
current_row: Option<usize>, current_row: Option<usize>,
selection_path: Option<Path<Pixels>>, selection_path: Option<Path<Pixels>>,
search_match_paths: Vec<(Path<Pixels>, bool)>,
bounds: Bounds<Pixels>, bounds: Bounds<Pixels>,
} }
@ -604,10 +655,10 @@ impl Element for TextElement {
// Calculate the width of the line numbers // Calculate the width of the line numbers
let empty_line_number = window.text_system().shape_line( let empty_line_number = window.text_system().shape_line(
"+++++".into(), "++++++".into(),
font_size, font_size,
&[TextRun { &[TextRun {
len: 5, len: 6,
font: style.font(), font: style.font(),
color: gpui::black(), color: gpui::black(),
background_color: None, background_color: None,
@ -649,8 +700,10 @@ impl Element for TextElement {
runs.extend(highlight_styles.iter().map(|(range, style)| { runs.extend(highlight_styles.iter().map(|(range, style)| {
let mut run = text_style.clone().highlight(*style).to_run(range.len()); let mut run = text_style.clone().highlight(*style).to_run(range.len());
if let Some(marked_range) = &state.marked_range { if let Some(ime_marked_range) = &state.ime_marked_range {
if range.start >= marked_range.start && range.end <= marked_range.end { if range.start >= ime_marked_range.start
&& range.end <= ime_marked_range.end
{
run.color = marked_run.color; run.color = marked_run.color;
run.strikethrough = marked_run.strikethrough; run.strikethrough = marked_run.strikethrough;
run.underline = marked_run.underline; run.underline = marked_run.underline;
@ -664,20 +717,20 @@ impl Element for TextElement {
} else { } else {
vec![run] 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 // IME marked text
vec![ vec![
TextRun { TextRun {
len: marked_range.start, len: ime_marked_range.start,
..run.clone() ..run.clone()
}, },
TextRun { TextRun {
len: marked_range.end - marked_range.start, len: ime_marked_range.end - ime_marked_range.start,
underline: marked_run.underline, underline: marked_run.underline,
..run.clone() ..run.clone()
}, },
TextRun { TextRun {
len: display_text.len() - marked_range.end, len: display_text.len() - ime_marked_range.end,
..run.clone() ..run.clone()
}, },
] ]
@ -689,7 +742,7 @@ impl Element for TextElement {
}; };
let wrap_width = if multi_line && state.soft_wrap { 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 { } else {
None None
}; };
@ -706,8 +759,8 @@ impl Element for TextElement {
.expect("failed to shape text"); .expect("failed to shape text");
// measure.end(); // measure.end();
let mut longest_line_width = px(0.); let mut longest_line_width = wrap_width.unwrap_or(px(0.));
if state.mode.is_multi_line() && lines.len() > 1 { if state.mode.is_multi_line() && !state.soft_wrap && lines.len() > 1 {
let longtest_line: SharedString = state let longtest_line: SharedString = state
.text .text
.line(state.text.summary().longest_row as usize) .line(state.text.summary().longest_row as usize)
@ -750,7 +803,7 @@ impl Element for TextElement {
let mut last_layout = LastLayout { let mut last_layout = LastLayout {
visible_range, visible_range,
visible_top, visible_top,
visible_start_offset, visible_range_offset: visible_start_offset..visible_end_offset,
line_height, line_height,
wrap_width, wrap_width,
line_number_width, line_number_width,
@ -793,7 +846,8 @@ impl Element for TextElement {
self.layout_cursor(&last_layout, &mut bounds, window, cx); self.layout_cursor(&last_layout, &mut bounds, window, cx);
last_layout.cursor_bounds = cursor_bounds; 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 state = self.state.read(cx);
let line_numbers = if state.mode.line_number() { 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 ix = last_layout.visible_range.start + ix;
let line_no = ix + 1; 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() { if !line.wrap_boundaries.is_empty() {
line_no_text.push_str(&"\n ".repeat(line.wrap_boundaries.len())); line_no_text.push_str(&"\n ".repeat(line.wrap_boundaries.len()));
} }
@ -852,6 +906,7 @@ impl Element for TextElement {
cursor_scroll_offset, cursor_scroll_offset,
current_row, current_row,
selection_path, selection_path,
search_match_paths,
} }
} }
@ -948,6 +1003,14 @@ impl Element for TextElement {
// Paint selections // Paint selections
if window.is_window_active() { 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() { if let Some(path) = prepaint.selection_path.take() {
window.paint_path(path, cx.theme().selection); window.paint_path(path, cx.theme().selection);
} }
@ -986,11 +1049,7 @@ impl Element for TextElement {
input_bounds.size.height, input_bounds.size.height,
), ),
}, },
cx.theme() cx.theme().background,
.highlight_theme
.style
.background
.unwrap_or(cx.theme().input),
)); ));
// Each item is the normal lines. // Each item is the normal lines.

View file

@ -10,6 +10,7 @@ mod number_input;
mod otp_input; mod otp_input;
mod popovers; mod popovers;
mod rope_ext; mod rope_ext;
mod search;
mod state; mod state;
mod text_input; mod text_input;
mod text_wrapper; 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 anyhow::Result;
use gpui::{ use gpui::{
actions, div, point, prelude::FluentBuilder as _, px, Action, App, AppContext, Bounds, 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, InteractiveElement as _, IntoElement, KeyBinding, KeyDownEvent, MouseButton, MouseDownEvent,
MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, Point, Render, ScrollHandle, MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, Point, Render, ScrollHandle,
ScrollWheelEvent, SharedString, Styled as _, Subscription, Task, UTF16Selection, Window, ScrollWheelEvent, SharedString, Styled as _, Subscription, Task, UTF16Selection, Window,
@ -31,6 +31,7 @@ use super::{
}; };
use crate::input::{ use crate::input::{
popovers::{ContextMenu, DiagnosticPopover}, popovers::{ContextMenu, DiagnosticPopover},
search::{self, SearchPanel},
Position, Position,
}; };
use crate::input::{RopeExt as _, Selection}; use crate::input::{RopeExt as _, Selection};
@ -90,6 +91,7 @@ actions!(
MoveToNextWord, MoveToNextWord,
Escape, Escape,
ToggleCodeActions, ToggleCodeActions,
Search,
] ]
); );
@ -216,8 +218,13 @@ pub fn init(cx: &mut App) {
KeyBinding::new("cmd-.", ToggleCodeActions, Some(CONTEXT)), KeyBinding::new("cmd-.", ToggleCodeActions, Some(CONTEXT)),
#[cfg(not(target_os = "macos"))] #[cfg(not(target_os = "macos"))]
KeyBinding::new("ctrl-.", ToggleCodeActions, Some(CONTEXT)), 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); number_input::init(cx);
} }
@ -227,8 +234,8 @@ pub(super) struct LastLayout {
pub(super) visible_range: Range<usize>, pub(super) visible_range: Range<usize>,
/// The first visible line top position in scroll viewport. /// The first visible line top position in scroll viewport.
pub(super) visible_top: Pixels, pub(super) visible_top: Pixels,
/// The start byte offset of the first visible line. /// The range of byte offset of the visible lines.
pub(super) visible_start_offset: usize, pub(super) visible_range_offset: Range<usize>,
/// The last layout lines (Only have visible lines). /// The last layout lines (Only have visible lines).
pub(super) lines: Rc<SmallVec<[WrappedLine; 1]>>, pub(super) lines: Rc<SmallVec<[WrappedLine; 1]>>,
/// The line_height of text layout, this will change will InputElement painted. /// The line_height of text layout, this will change will InputElement painted.
@ -255,11 +262,13 @@ pub struct InputState {
/// - "Hello 世界💝" = 16 /// - "Hello 世界💝" = 16
/// - "💝" = 4 /// - "💝" = 4
pub(super) selected_range: Selection, 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. /// Range for save the selected word, use to keep word range when drag move.
pub(super) selected_word_range: Option<Selection>, pub(super) selected_word_range: Option<Selection>,
pub(super) selection_reversed: bool, pub(super) selection_reversed: bool,
/// The marked range is the temporary insert text on IME typing. /// 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_layout: Option<LastLayout>,
pub(super) last_cursor: Option<usize>, pub(super) last_cursor: Option<usize>,
/// The input container bounds /// The input container bounds
@ -342,9 +351,11 @@ impl InputState {
blink_cursor, blink_cursor,
history, history,
selected_range: Selection::default(), selected_range: Selection::default(),
search_panel: None,
searchable: false,
selected_word_range: None, selected_word_range: None,
selection_reversed: false, selection_reversed: false,
marked_range: None, ime_marked_range: None,
input_bounds: Bounds::default(), input_bounds: Bounds::default(),
selecting: false, selecting: false,
disabled: false, disabled: false,
@ -425,6 +436,13 @@ impl InputState {
code_action_providers: vec![], code_action_providers: vec![],
completion_provider: None, 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 self
} }
@ -634,7 +652,7 @@ impl InputState {
}; };
let line_height = last_layout.line_height; 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; let mut y_offset = last_layout.visible_top;
for (line_index, line) in last_layout.lines.iter().enumerate() { for (line_index, line) in last_layout.lines.iter().enumerate() {
let local_offset = offset.saturating_sub(prev_lines_offset); let local_offset = offset.saturating_sub(prev_lines_offset);
@ -1049,7 +1067,7 @@ impl InputState {
cx: &mut Context<Self>, cx: &mut Context<Self>,
) { ) {
self.move_to(0, window, cx); 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>) { pub(super) fn home(&mut self, _: &MoveHome, window: &mut Window, cx: &mut Context<Self>) {
@ -1549,7 +1567,7 @@ impl InputState {
return; return;
} }
if self.marked_range.is_some() { if self.ime_marked_range.is_some() {
self.unmark_text(window, cx); 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) // If there have IME marked range and is empty (Means pressed Esc to abort IME typing)
// Clear the marked range. // Clear the marked range.
if let Some(marked_range) = &self.marked_range { if let Some(ime_marked_range) = &self.ime_marked_range {
if marked_range.len() == 0 { if ime_marked_range.len() == 0 {
self.marked_range = None; self.ime_marked_range = None;
} }
} }
@ -1674,6 +1692,41 @@ impl InputState {
cx.notify(); 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( pub(super) fn show_character_palette(
&mut self, &mut self,
_: &ShowCharacterPalette, _: &ShowCharacterPalette,
@ -1756,6 +1809,7 @@ impl InputState {
fn move_to(&mut self, offset: usize, _: &mut Window, cx: &mut Context<Self>) { fn move_to(&mut self, offset: usize, _: &mut Window, cx: &mut Context<Self>) {
let offset = offset.clamp(0, self.text.len()); let offset = offset.clamp(0, self.text.len());
self.selected_range = (offset..offset).into(); self.selected_range = (offset..offset).into();
self.scroll_to(offset, cx);
self.pause_blink_cursor(cx); self.pause_blink_cursor(cx);
self.update_preferred_column(); self.update_preferred_column();
self.hide_context_menu(cx); self.hide_context_menu(cx);
@ -1766,8 +1820,8 @@ impl InputState {
/// ///
/// The offset is the UTF-8 offset. /// The offset is the UTF-8 offset.
pub fn cursor(&self) -> usize { pub fn cursor(&self) -> usize {
if let Some(marked_range) = &self.marked_range { if let Some(ime_marked_range) = &self.ime_marked_range {
return marked_range.end; return ime_marked_range.end;
} }
if self.selection_reversed { if self.selection_reversed {
@ -1809,7 +1863,7 @@ impl InputState {
// - included the scroll offset. // - included the scroll offset.
let inner_position = position - bounds.origin - point(line_number_width, px(0.)); 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; let mut y_offset = last_layout.visible_top;
for (ix, line) in self for (ix, line) in self
.text_wrapper .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 { impl EntityInputHandler for InputState {
@ -2215,12 +2273,12 @@ impl EntityInputHandler for InputState {
_window: &mut Window, _window: &mut Window,
_cx: &mut Context<Self>, _cx: &mut Context<Self>,
) -> Option<Range<usize>> { ) -> Option<Range<usize>> {
self.marked_range self.ime_marked_range
.map(|range| self.range_to_utf16(&range.into())) .map(|range| self.range_to_utf16(&range.into()))
} }
fn unmark_text(&mut self, _window: &mut Window, _cx: &mut Context<Self>) { 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. /// Replace text in range.
@ -2243,7 +2301,7 @@ impl EntityInputHandler for InputState {
let range = range_utf16 let range = range_utf16
.as_ref() .as_ref()
.map(|range_utf16| self.range_from_utf16(range_utf16)) .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()); .unwrap_or(self.selected_range.into());
let old_text = self.text.clone(); let old_text = self.text.clone();
@ -2273,9 +2331,10 @@ impl EntityInputHandler for InputState {
self.mode self.mode
.update_highlighter(&range, &self.text, &new_text, true, cx); .update_highlighter(&range, &self.text, &new_text, true, cx);
self.selected_range = (new_offset..new_offset).into(); self.selected_range = (new_offset..new_offset).into();
self.marked_range.take(); self.ime_marked_range.take();
self.update_preferred_column(); self.update_preferred_column();
self.update_scroll_offset(None, cx); self.update_scroll_offset(None, cx);
self.update_search(cx);
self.mode.update_auto_grow(&self.text_wrapper); self.mode.update_auto_grow(&self.text_wrapper);
self.handle_completion_trigger(&range, &new_text, window, cx); self.handle_completion_trigger(&range, &new_text, window, cx);
cx.emit(InputEvent::Change); cx.emit(InputEvent::Change);
@ -2298,7 +2357,7 @@ impl EntityInputHandler for InputState {
let range = range_utf16 let range = range_utf16
.as_ref() .as_ref()
.map(|range_utf16| self.range_from_utf16(range_utf16)) .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()); .unwrap_or(self.selected_range.into());
let old_text = self.text.clone(); let old_text = self.text.clone();
@ -2320,9 +2379,9 @@ impl EntityInputHandler for InputState {
if new_text.is_empty() { if new_text.is_empty() {
// Cancel selection, when cancel IME input. // Cancel selection, when cancel IME input.
self.selected_range = (range.start..range.start).into(); self.selected_range = (range.start..range.start).into();
self.marked_range = None; self.ime_marked_range = None;
} else { } 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 self.selected_range = new_selected_range_utf16
.as_ref() .as_ref()
.map(|range_utf16| self.range_from_utf16(range_utf16)) .map(|range_utf16| self.range_from_utf16(range_utf16))
@ -2352,7 +2411,7 @@ impl EntityInputHandler for InputState {
let mut end_origin = None; let mut end_origin = None;
let line_number_origin = point(line_number_width, px(0.)); let line_number_origin = point(line_number_width, px(0.));
let mut y_offset = last_layout.visible_top; 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() { for line in last_layout.lines.iter() {
if start_origin.is_some() && end_origin.is_some() { 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 last_layout = self.last_layout.as_ref()?;
let line_height = last_layout.line_height; let line_height = last_layout.line_height;
let line_point = self.last_bounds?.localize(&point)?; 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() { for line in last_layout.lines.iter() {
if let Ok(utf8_index) = line.index_for_position(line_point, line_height) { 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::prelude::FluentBuilder as _;
use gpui::{ use gpui::{
div, px, relative, AnyElement, App, DefiniteLength, Entity, InteractiveElement as _, div, px, relative, AnyElement, App, DefiniteLength, Edges, EdgesRefinement, Entity,
IntoElement, IsZero, MouseButton, ParentElement as _, Rems, RenderOnce, StyleRefinement, InteractiveElement as _, IntoElement, IsZero, MouseButton, ParentElement as _, Pixels, Rems,
Styled, Window, RenderOnce, StyleRefinement, Styled, Window,
}; };
use crate::button::{Button, ButtonVariants as _}; use crate::button::{Button, ButtonVariants as _};
@ -10,8 +10,8 @@ use crate::indicator::Indicator;
use crate::input::clear_button; use crate::input::clear_button;
use crate::input::element::{LINE_NUMBER_RIGHT_MARGIN, RIGHT_MARGIN}; use crate::input::element::{LINE_NUMBER_RIGHT_MARGIN, RIGHT_MARGIN};
use crate::scroll::Scrollbar; use crate::scroll::Scrollbar;
use crate::ActiveTheme;
use crate::{h_flex, StyledExt}; use crate::{h_flex, StyledExt};
use crate::{v_flex, ActiveTheme};
use crate::{IconName, Size}; use crate::{IconName, Size};
use crate::{Sizable, StyleSized}; 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 { 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::select_to_end))
.on_action(window.listener_for(&self.state, InputState::show_character_palette)) .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::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_key_down(window.listener_for(&self.state, InputState::on_key_down))
.on_mouse_down( .on_mouse_down(
MouseButton::Left, MouseButton::Left,
@ -246,10 +317,12 @@ impl RenderOnce for TextInput {
.on_scroll_wheel(window.listener_for(&self.state, InputState::on_scroll_wheel)) .on_scroll_wheel(window.listener_for(&self.state, InputState::on_scroll_wheel))
.size_full() .size_full()
.line_height(LINE_HEIGHT) .line_height(LINE_HEIGHT)
.input_px(self.size)
.input_py(self.size) .input_py(self.size)
.input_h(self.size) .input_h(self.size)
.cursor_text() .cursor_text()
.text_size(font_size) .text_size(font_size)
.items_center()
.when(state.mode.is_multi_line(), |this| { .when(state.mode.is_multi_line(), |this| {
this.h_auto() this.h_auto()
.when_some(self.height, |this, height| this.h(height)) .when_some(self.height, |this, height| this.h(height))
@ -266,11 +339,23 @@ impl RenderOnce for TextInput {
}) })
}) })
}) })
.input_px(self.size)
.items_center() .items_center()
.gap(gap_x) .gap(gap_x)
.refine_style(&self.style)
.children(prefix) .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| { .when(has_suffix, |this| {
this.pr(self.size.input_px() / 2.).child( this.pr(self.size.input_px() / 2.).child(
h_flex() h_flex()
@ -297,62 +382,5 @@ impl RenderOnce for TextInput {
.children(suffix), .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 { pub fn input_py(&self) -> Pixels {
match self { match self {
Size::Large => px(16.), Size::Large => px(10.),
Size::Medium => px(8.), Size::Medium => px(5.),
Size::Small => px(4.), Size::Small => px(2.),
Size::XSmall => px(0.), Size::XSmall => px(0.),
_ => px(4.), _ => px(2.),
} }
} }
} }

View file

@ -584,7 +584,6 @@
"editor.active_line.background": "#363a4f", "editor.active_line.background": "#363a4f",
"editor.line_number": "#b8c0e0", "editor.line_number": "#b8c0e0",
"editor.active_line_number": "#cad3f5", "editor.active_line_number": "#cad3f5",
"conflict": "#ed8796", "conflict": "#ed8796",
"created": "#a6da95", "created": "#a6da95",
"deleted": "#ed8796", "deleted": "#ed8796",
@ -730,7 +729,7 @@
"highlight": { "highlight": {
"editor.foreground": "#cdd6f4", "editor.foreground": "#cdd6f4",
"editor.background": "#181825", "editor.background": "#181825",
"editor.active_line.background": "#302d41", "editor.active_line.background": "#222230AA",
"editor.line_number": "#6c7086", "editor.line_number": "#6c7086",
"editor.active_line_number": "#cdd6f4", "editor.active_line_number": "#cdd6f4",
"conflict": "#f38ba8", "conflict": "#f38ba8",