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:
parent
e01b139995
commit
8a3ef51ea1
16 changed files with 883 additions and 156 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -3206,6 +3206,7 @@ dependencies = [
|
|||
name = "gpui-component"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"enum-iterator",
|
||||
|
|
|
|||
1
assets/icons/case-sensitive.svg
Normal file
1
assets/icons/case-sensitive.svg
Normal 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
16
assets/icons/replace.svg
Normal 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 |
|
|
@ -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![]));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||

|
||||

|
||||
|
||||

|
||||

|
||||
|
||||
## 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| {
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
547
crates/ui/src/input/search.rs
Normal file
547
crates/ui/src/input/search.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue