use crate::input_ui::{Input, InputState, TextDecoration, TextDecorationCollection};
use crate::prelude::FluentBuilder as _;
use crate::*;
use std::{rc::Rc, sync::Arc};
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
pub struct SearchOptions {
pub case_sensitive: bool,
pub whole_word: bool,
pub regex: bool,
}
#[derive(Clone, Debug)]
pub struct SearchMatch {
pub line: usize,
pub start_col: usize,
pub end_col: usize,
pub text: SharedString,
}
pub struct SearchState {
query: String,
replacement: String,
options: SearchOptions,
matches: Vec<SearchMatch>,
current_index: usize,
}
impl SearchState {
pub fn new() -> Self {
Self {
query: String::new(),
replacement: String::new(),
options: SearchOptions::default(),
matches: Vec::new(),
current_index: usize::MAX,
}
}
pub fn query(&self) -> &str {
&self.query
}
pub fn replacement(&self) -> &str {
&self.replacement
}
pub fn options(&self) -> SearchOptions {
self.options
}
pub fn matches(&self) -> &[SearchMatch] {
&self.matches
}
pub fn current_index(&self) -> Option<usize> {
if self.current_index < self.matches.len() {
Some(self.current_index)
} else {
None
}
}
pub fn current_match(&self) -> Option<&SearchMatch> {
self.matches.get(self.current_index)
}
pub fn match_count(&self) -> usize {
self.matches.len()
}
pub fn has_matches(&self) -> bool {
!self.matches.is_empty()
}
pub fn set_query(&mut self, query: String, source: &str) {
self.query = query;
self.recompute_matches(source);
}
pub fn set_replacement(&mut self, replacement: String) {
self.replacement = replacement;
}
pub fn set_options(&mut self, options: SearchOptions, source: &str) {
self.options = options;
self.recompute_matches(source);
}
pub fn toggle_case_sensitive(&mut self, source: &str) {
self.options.case_sensitive = !self.options.case_sensitive;
self.recompute_matches(source);
}
pub fn toggle_whole_word(&mut self, source: &str) {
self.options.whole_word = !self.options.whole_word;
self.recompute_matches(source);
}
pub fn toggle_regex(&mut self, source: &str) {
self.options.regex = !self.options.regex;
self.recompute_matches(source);
}
pub fn next_match(&mut self) -> Option<&SearchMatch> {
if self.matches.is_empty() {
return None;
}
self.current_index = (self.current_index + 1) % self.matches.len();
self.matches.get(self.current_index)
}
pub fn prev_match(&mut self) -> Option<&SearchMatch> {
if self.matches.is_empty() {
return None;
}
if self.current_index == 0 || self.current_index == usize::MAX {
self.current_index = self.matches.len() - 1;
} else {
self.current_index -= 1;
}
self.matches.get(self.current_index)
}
pub fn reset_to_first(&mut self) {
if self.matches.is_empty() {
self.current_index = usize::MAX;
} else {
self.current_index = 0;
}
}
pub fn clear(&mut self) {
self.query.clear();
self.replacement.clear();
self.matches.clear();
self.current_index = usize::MAX;
}
fn recompute_matches(&mut self, source: &str) {
self.matches.clear();
self.current_index = usize::MAX;
if self.query.is_empty() {
return;
}
let matches = if self.options.regex {
self.find_regex_matches(source)
} else {
self.find_literal_matches(source)
};
self.matches = matches;
if !self.matches.is_empty() {
self.current_index = 0;
}
}
fn find_literal_matches(&self, source: &str) -> Vec<SearchMatch> {
let mut matches = Vec::new();
let query_lower: Vec<char> = if self.options.case_sensitive {
Vec::new()
} else {
self.query.to_lowercase().chars().collect()
};
for (line_idx, line) in source.lines().enumerate() {
let line_bytes = line.as_bytes();
let mut start = 0;
while start < line.len() {
let rest = &line[start..];
let first_len = rest.chars().next().map(|c| c.len_utf8()).unwrap_or(1);
let matched_len = if self.options.case_sensitive {
if rest.starts_with(self.query.as_str()) {
Some(self.query.len())
} else {
None
}
} else {
literal_insensitive_prefix_len(rest, &query_lower)
};
if let Some(len) = matched_len {
let match_end = start + len;
if self.options.whole_word {
let before_ok =
start == 0 || !line_bytes[start - 1].is_ascii_alphanumeric();
let after_ok = match_end >= line_bytes.len()
|| !line_bytes[match_end].is_ascii_alphanumeric();
if !before_ok || !after_ok {
start += first_len;
continue;
}
}
matches.push(SearchMatch {
line: line_idx,
start_col: start,
end_col: match_end,
text: line[start..match_end].into(),
});
}
start += first_len;
}
}
matches
}
fn find_regex_matches(&self, source: &str) -> Vec<SearchMatch> {
let mut matches = Vec::new();
let re = match regex::RegexBuilder::new(&self.query)
.case_insensitive(!self.options.case_sensitive)
.build()
{
Ok(re) => re,
Err(_) => return matches, };
for (line_idx, line) in source.lines().enumerate() {
for mat in re.find_iter(line) {
matches.push(SearchMatch {
line: line_idx,
start_col: mat.start(),
end_col: mat.end(),
text: mat.as_str().into(),
});
}
}
matches
}
}
impl Default for SearchState {
fn default() -> Self {
Self::new()
}
}
#[derive(Clone)]
pub struct SearchHighlight {
collection: Option<TextDecorationCollection>,
background: Hsla,
foreground: Option<Hsla>,
}
impl Default for SearchHighlight {
fn default() -> Self {
Self::new()
}
}
impl SearchHighlight {
pub fn new() -> Self {
Self {
collection: None,
background: yellow(),
foreground: None,
}
}
pub fn set_colors(&mut self, background: Hsla, foreground: Option<Hsla>) {
self.background = background;
self.foreground = foreground;
}
pub fn background(&self) -> Hsla {
self.background
}
pub fn foreground(&self) -> Option<Hsla> {
self.foreground
}
pub fn mark(
&mut self,
text: &Entity<InputState>,
source: &str,
matches: &[SearchMatch],
cx: &mut App,
) {
let mut line_starts = Vec::new();
let mut offset = 0;
for part in source.split('\n') {
line_starts.push((offset, part.len()));
offset += part.len() + 1;
}
let decorations: Vec<TextDecoration> = matches
.iter()
.map(|m| {
let (base, len) = line_starts
.get(m.line)
.copied()
.unwrap_or((source.len(), 0));
let start = base + m.start_col.min(len);
let end = base + m.end_col.min(len).max(start - base);
TextDecoration::new(
start..end,
HighlightStyle {
background_color: Some(self.background),
color: self.foreground,
..Default::default()
},
)
})
.collect();
match self.collection.take() {
Some(collection) => {
collection.set(decorations, cx);
self.collection = Some(collection);
}
None => {
self.collection = Some(text.update(cx, |state, cx| {
state.create_decorations_collection(decorations, cx)
}));
}
}
}
pub fn clear(&mut self, cx: &mut App) {
if let Some(collection) = self.collection.take() {
collection.clear(cx);
}
}
}
fn byte_offset_of(source: &str, line: usize, col: usize) -> usize {
let mut offset = 0;
for (ix, part) in source.split('\n').enumerate() {
if ix == line {
return offset + col.min(part.len());
}
offset += part.len() + 1;
}
offset
}
fn literal_insensitive_prefix_len(rest: &str, query_lower: &[char]) -> Option<usize> {
if query_lower.is_empty() {
return None;
}
let mut chars = rest.chars();
let mut orig_consumed = 0usize;
let mut qi = 0usize;
while qi < query_lower.len() {
let Some(c) = chars.next() else {
return None;
};
orig_consumed += c.len_utf8();
let mut lowered = c.to_lowercase();
while let Some(lc) = lowered.next() {
if qi >= query_lower.len() || lc != query_lower[qi] {
return None;
}
qi += 1;
}
}
Some(orig_consumed)
}
pub struct SearchPanelState {
state: Entity<SearchState>,
search_input: Entity<InputState>,
replace_input: Option<Entity<InputState>>,
show_replace: bool,
source: String,
attached_editor: Option<Entity<InputState>>,
highlight: Option<SearchHighlight>,
pending_navigate: bool,
pending_replace: bool,
on_navigate: Option<Rc<dyn Fn(usize, usize, usize, &mut Window, &mut App)>>,
on_replace: Option<Arc<dyn Fn(String, String, &mut Window, &mut App) + Send + Sync>>,
on_replace_all: Option<Arc<dyn Fn(String, String, &mut Window, &mut App) + Send + Sync>>,
on_close: Option<Rc<dyn Fn(&mut Window, &mut App)>>,
focus_handle: FocusHandle,
style: StyleRefinement,
}
impl SearchPanelState {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let state = cx.new(|_| SearchState::new());
let search_input = cx.new(|cx| InputState::new(window, cx).placeholder("Search..."));
let replace_input = cx.new(|cx| InputState::new(window, cx).placeholder("Replace..."));
let focus_handle = cx.focus_handle();
cx.observe(&state, |_, _, cx| cx.notify()).detach();
cx.subscribe(&search_input, |this, _input, event, cx| match event {
crate::input_ui::InputEvent::Change => {
let query = this.search_input.read(cx).text().to_string();
this.update_search(&query, cx);
}
crate::input_ui::InputEvent::PressEnter { shift, .. } => {
if *shift {
this.navigate_prev(cx);
} else {
this.navigate_next(cx);
}
}
_ => {}
})
.detach();
{
let ri = replace_input.clone();
cx.subscribe(&ri.clone(), move |this, _input, event, cx| match event {
crate::input_ui::InputEvent::Change => {
let replacement = ri.read(cx).text().to_string();
this.state.update(cx, |state, _cx| {
state.set_replacement(replacement);
});
}
crate::input_ui::InputEvent::PressEnter { .. } => {
this.replace_current(cx);
}
_ => {}
})
.detach();
}
Self {
state,
search_input,
replace_input: Some(replace_input),
show_replace: true,
source: String::new(),
attached_editor: None,
highlight: None,
pending_navigate: false,
pending_replace: false,
on_navigate: None,
on_replace: None,
on_replace_all: None,
on_close: None,
focus_handle,
style: StyleRefinement::default(),
}
}
pub fn search_only(window: &mut Window, cx: &mut Context<Self>) -> Self {
let state = cx.new(|_| SearchState::new());
let search_input = cx.new(|cx| InputState::new(window, cx).placeholder("Search..."));
let focus_handle = cx.focus_handle();
cx.observe(&state, |_, _, cx| cx.notify()).detach();
cx.subscribe(&search_input, |this, _input, event, cx| match event {
crate::input_ui::InputEvent::Change => {
let query = this.search_input.read(cx).text().to_string();
this.update_search(&query, cx);
}
crate::input_ui::InputEvent::PressEnter { shift, .. } => {
if *shift {
this.navigate_prev(cx);
} else {
this.navigate_next(cx);
}
}
_ => {}
})
.detach();
Self {
state,
search_input,
replace_input: None,
show_replace: false,
source: String::new(),
attached_editor: None,
highlight: None,
pending_navigate: false,
pending_replace: false,
on_navigate: None,
on_replace: None,
on_replace_all: None,
on_close: None,
focus_handle,
style: StyleRefinement::default(),
}
}
pub fn on_navigate<F>(mut self, handler: F) -> Self
where
F: Fn(usize, usize, usize, &mut Window, &mut App) + 'static,
{
self.on_navigate = Some(Rc::new(handler));
self
}
pub fn on_replace<F>(mut self, handler: F) -> Self
where
F: Fn(String, String, &mut Window, &mut App) + Send + Sync + 'static,
{
self.on_replace = Some(Arc::new(handler));
self
}
pub fn on_replace_all<F>(mut self, handler: F) -> Self
where
F: Fn(String, String, &mut Window, &mut App) + Send + Sync + 'static,
{
self.on_replace_all = Some(Arc::new(handler));
self
}
pub fn on_close<F>(mut self, handler: F) -> Self
where
F: Fn(&mut Window, &mut App) + 'static,
{
self.on_close = Some(Rc::new(handler));
self
}
pub fn set_on_replace<F>(&mut self, handler: F)
where
F: Fn(String, String, &mut Window, &mut App) + Send + Sync + 'static,
{
self.on_replace = Some(Arc::new(handler));
}
pub fn set_on_replace_all<F>(&mut self, handler: F)
where
F: Fn(String, String, &mut Window, &mut App) + Send + Sync + 'static,
{
self.on_replace_all = Some(Arc::new(handler));
}
pub fn set_source(&mut self, source: String, cx: &mut App) {
let query = self.state.read(cx).query().to_string();
self.source = source;
let source = self.source.clone();
self.state.update(cx, |state, cx| {
state.set_query(query, &source);
cx.notify();
});
}
pub fn attach_editor(&mut self, editor: &Entity<InputState>, cx: &mut Context<Self>) {
self.attached_editor = Some(editor.clone());
if self.on_navigate.is_none() {
let editor_handle = editor.clone();
self.on_navigate = Some(Rc::new(move |line, start, end, _, cx| {
let full = editor_handle.read_with(cx, |state, _| state.text().to_string());
let start = byte_offset_of(&full, line, start);
let end = byte_offset_of(&full, line, end).max(start);
editor_handle.update(cx, |state, cx| {
state.set_selected_range(start..end, cx);
state.reveal_offset(start, cx);
});
}));
}
let editor_handle = editor.clone();
cx.subscribe(editor, move |this, _editor, event, cx| {
if !matches!(event, crate::input_ui::InputEvent::Change) {
return;
}
let full = editor_handle.read_with(cx, |state, _| state.text().to_string());
this.set_source(full, cx);
})
.detach();
cx.observe(&self.state.clone(), |this, _, cx| {
this.mark_attached(cx);
})
.detach();
let full = editor.read_with(cx, |state, _| state.text().to_string());
self.set_source(full, cx);
self.mark_attached(cx);
}
pub fn set_highlight_colors(
&mut self,
background: Hsla,
foreground: Option<Hsla>,
cx: &mut App,
) {
self.highlight
.get_or_insert_with(SearchHighlight::new)
.set_colors(background, foreground);
self.mark_attached(cx);
}
pub fn focus_search_input(&self, window: &mut Window, cx: &mut App) {
self.search_input.update(cx, |state, cx| {
state.focus(window, cx);
});
}
pub fn set_on_navigate<F>(&mut self, handler: F)
where
F: Fn(usize, usize, usize, &mut Window, &mut App) + 'static,
{
self.on_navigate = Some(Rc::new(handler));
}
pub fn set_show_replace(&mut self, show: bool, cx: &mut Context<Self>) {
self.show_replace = show;
if self.replace_input.is_none() {
self.show_replace = false;
}
cx.notify();
}
pub fn show_replace(&self) -> bool {
self.show_replace
}
fn mark_attached(&mut self, cx: &mut App) {
let Some(editor) = self.attached_editor.clone() else {
return;
};
let matches = self.state.read(cx).matches().to_vec();
let source = self.source.clone();
self.highlight
.get_or_insert_with(SearchHighlight::new)
.mark(&editor, &source, &matches, cx);
}
pub fn clear_highlights(&mut self, cx: &mut App) {
if let Some(highlight) = self.highlight.as_mut() {
highlight.clear(cx);
}
}
pub fn refresh_highlights(&mut self, cx: &mut App) {
self.mark_attached(cx);
}
pub fn search_input(&self) -> &Entity<InputState> {
&self.search_input
}
pub fn state(&self) -> &Entity<SearchState> {
&self.state
}
fn update_search(&mut self, query: &str, cx: &mut App) {
let source = self.source.clone();
self.state.update(cx, |state, cx| {
state.set_query(query.to_string(), &source);
cx.notify();
});
}
fn navigate_next(&mut self, cx: &mut Context<Self>) {
self.state.update(cx, |state, cx| {
state.next_match();
cx.notify();
});
self.pending_navigate = true;
cx.notify();
}
fn navigate_prev(&mut self, cx: &mut Context<Self>) {
self.state.update(cx, |state, cx| {
state.prev_match();
cx.notify();
});
self.pending_navigate = true;
cx.notify();
}
fn replace_current(&mut self, cx: &mut Context<Self>) {
self.pending_replace = true;
cx.notify();
}
}
impl Styled for SearchPanelState {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
impl Focusable for SearchPanelState {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for SearchPanelState {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
if self.pending_navigate {
self.pending_navigate = false;
if let (Some(cb), Some(m)) = (
self.on_navigate.clone(),
self.state.read(cx).current_match().cloned(),
) {
cb(m.line, m.start_col, m.end_col, window, cx);
}
}
if self.pending_replace {
self.pending_replace = false;
let (query, replacement) = self.state.read_with(cx, |state, _| {
(state.query().to_string(), state.replacement().to_string())
});
if let Some(ref cb) = self.on_replace.clone() {
cb(query, replacement, window, cx);
}
}
let theme = cx.theme();
let state = self.state.read(cx);
let match_count = state.match_count();
let current_idx = state.current_index();
let has_matches = state.has_matches();
let options = state.options();
let radius = theme.radius;
let border = theme.tokens.border;
let muted_foreground = theme.tokens.muted_foreground;
let popover = theme.tokens.popover;
let panel = cx.entity();
let state_entity = self.state.clone();
let search_input = self.search_input.clone();
let replace_input = self.replace_input.clone();
let on_replace = self.on_replace.clone();
let on_replace_all = self.on_replace_all.clone();
let source = self.source.clone();
div()
.flex()
.flex_col()
.w(px(360.0))
.bg(popover)
.border_1()
.border_color(border)
.rounded(radius)
.shadow(vec![BoxShadow {
color: hsla(0.0, 0.0, 0.0, 0.15),
offset: point(px(0.0), px(2.0)),
blur_radius: px(8.0),
spread_radius: px(0.0),
inset: false,
}])
.overflow_hidden()
.child(
div()
.flex()
.items_center()
.gap(px(4.0))
.px(px(8.0))
.py(px(6.0))
.child(Input::new(&search_input).w(px(200.0)))
.child(
div()
.text_size(px(12.0))
.text_color(muted_foreground)
.child(if match_count > 0 {
let idx = current_idx.map(|i| i + 1).unwrap_or(0);
format!("{idx}/{match_count}")
} else if !state.query().is_empty() {
"No matches".to_string()
} else {
String::new()
}),
)
.child({
let navigate = panel.clone();
Button::new("prev-match")
.ghost()
.small()
.icon(IconName::ChevronUp)
.disabled(!has_matches)
.on_click(move |_, _, cx| {
navigate.update(cx, |this, cx| this.navigate_prev(cx));
})
})
.child({
Button::new("next-match")
.ghost()
.small()
.icon(IconName::ChevronDown)
.disabled(!has_matches)
.on_click(move |_, _, cx| {
panel.update(cx, |this, cx| this.navigate_next(cx));
})
}),
)
.when(self.show_replace, |d| {
d.child(
div()
.flex()
.items_center()
.gap(px(4.0))
.px(px(8.0))
.py(px(4.0))
.border_t_1()
.border_color(border)
.child({
if let Some(ref replace_input) = replace_input {
Input::new(replace_input).w(px(200.0)).into_any_element()
} else {
div().into_any_element()
}
})
.child({
let on_replace = on_replace.clone();
let state_entity = state_entity.clone();
Button::new("replace-current")
.ghost()
.small()
.label("Replace")
.disabled(!has_matches)
.on_click(move |_, window, cx| {
let (query, replacement) =
state_entity.read_with(cx, |state, _| {
(
state.query().to_string(),
state.replacement().to_string(),
)
});
if let Some(ref cb) = on_replace {
cb(query, replacement, window, cx);
}
})
})
.child({
let on_replace_all = on_replace_all.clone();
let state_entity = state_entity.clone();
Button::new("replace-all")
.ghost()
.small()
.label("All")
.disabled(!has_matches)
.on_click(move |_, window, cx| {
let (query, replacement) =
state_entity.read_with(cx, |state, _| {
(
state.query().to_string(),
state.replacement().to_string(),
)
});
if let Some(ref cb) = on_replace_all {
cb(query, replacement, window, cx);
}
})
}),
)
})
.child(
div()
.flex()
.items_center()
.gap(px(4.0))
.px(px(8.0))
.py(px(4.0))
.border_t_1()
.border_color(border)
.child({
let state_entity = state_entity.clone();
let source = source.clone();
ToggleButton::new("case-sensitive", options.case_sensitive)
.label("Aa")
.tooltip("Case Sensitive")
.on_change(move |_is_on, _, cx| {
state_entity.update(cx, |state, cx| {
state.toggle_case_sensitive(&source);
cx.notify();
});
})
})
.child({
let state_entity = state_entity.clone();
let source = source.clone();
ToggleButton::new("whole-word", options.whole_word)
.label("Ab")
.tooltip("Whole Word")
.on_change(move |_is_on, _, cx| {
state_entity.update(cx, |state, cx| {
state.toggle_whole_word(&source);
cx.notify();
});
})
})
.child({
ToggleButton::new("regex", options.regex)
.label(".*")
.tooltip("Regular Expression")
.on_change(move |_is_on, _, cx| {
state_entity.update(cx, |state, cx| {
state.toggle_regex(&source);
cx.notify();
});
})
}),
)
}
}
#[derive(IntoElement)]
struct ToggleButton {
id: ElementId,
label: SharedString,
tooltip_text: SharedString,
active: bool,
on_change: Option<Arc<dyn Fn(bool, &mut Window, &mut App) + Send + Sync>>,
}
impl ToggleButton {
fn new(id: impl Into<ElementId>, active: bool) -> Self {
Self {
id: id.into(),
label: SharedString::default(),
tooltip_text: SharedString::default(),
active,
on_change: None,
}
}
fn label(mut self, label: impl Into<SharedString>) -> Self {
self.label = label.into();
self
}
fn tooltip(mut self, text: impl Into<SharedString>) -> Self {
self.tooltip_text = text.into();
self
}
fn on_change(
mut self,
handler: impl Fn(bool, &mut Window, &mut App) + Send + Sync + 'static,
) -> Self {
self.on_change = Some(Arc::new(handler));
self
}
}
impl RenderOnce for ToggleButton {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let on_change = self.on_change;
let active = self.active;
let btn = Button::new(self.id).ghost().small().label(self.label);
let btn = if active {
btn.bg(cx.theme().tokens.accent.color)
} else {
btn
};
btn.on_click(move |_, window, cx| {
if let Some(ref cb) = on_change {
cb(!active, window, cx);
}
})
}
}
#[cfg(test)]
mod tests {
use super::{InputState, SearchPanelState};
use crate::AppContext as _;
use crate::{Context, Entity, IntoElement, Render, Window, div};
struct Probe {
text: Entity<InputState>,
panel: Entity<SearchPanelState>,
}
impl Render for Probe {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div()
}
}
fn type_query(
panel: &Entity<SearchPanelState>,
query: &str,
cx: &mut crate::VisualTestContext,
) {
cx.update(|window, cx| {
let input = panel.read(cx).search_input().clone();
input.update(cx, |state, cx| state.replace(query, window, cx));
});
}
#[rgpui::test]
fn show_replace_toggles(cx: &mut crate::TestAppContext) {
cx.update(crate::input_ui::init);
cx.update(crate::theme::init);
let (probe, cx) = cx.add_window_view(|window, cx| {
let panel = cx.new(|cx| SearchPanelState::new(window, cx));
let replace_only = cx.new(|cx| SearchPanelState::search_only(window, cx));
ProbeReplace {
panel,
replace_only,
}
});
let (panel, replace_only) = probe.read_with(cx, |probe, _| {
(probe.panel.clone(), probe.replace_only.clone())
});
assert!(panel.read_with(cx, |panel, _| panel.show_replace()));
cx.update(|_, cx| {
panel.update(cx, |panel, cx| {
panel.set_show_replace(false, cx);
});
});
assert!(!panel.read_with(cx, |panel, _| panel.show_replace()));
assert!(!replace_only.read_with(cx, |panel, _| panel.show_replace()));
cx.update(|_, cx| {
replace_only.update(cx, |panel, cx| {
panel.set_show_replace(true, cx);
});
});
assert!(!replace_only.read_with(cx, |panel, _| panel.show_replace()));
}
struct ProbeReplace {
panel: Entity<SearchPanelState>,
replace_only: Entity<SearchPanelState>,
}
impl Render for ProbeReplace {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div()
}
}
fn match_stats(
panel: &Entity<SearchPanelState>,
cx: &mut crate::VisualTestContext,
) -> (usize, usize) {
cx.update(|_, cx| {
let panel_ref = panel.read(cx);
let count = panel_ref.state().read(cx).match_count();
let highlighted = panel_ref
.highlight
.as_ref()
.and_then(|highlight| highlight.collection.as_ref())
.map(|collection| collection.get_ranges(cx).len())
.unwrap_or(0);
(count, highlighted)
})
}
#[rgpui::test]
fn attach_editor_syncs_source_and_highlights(cx: &mut crate::TestAppContext) {
cx.update(crate::input_ui::init);
cx.update(crate::theme::init);
let (probe, cx) = cx.add_window_view(|window, cx| {
let text = cx.new(|cx| {
let mut state = InputState::new(window, cx).multi_line(true);
state.replace("hello world\nhello rgpui", window, cx);
state
});
let panel = cx.new(|cx| SearchPanelState::new(window, cx));
panel.update(cx, |panel, cx| panel.attach_editor(&text, cx));
Probe { text, panel }
});
let (text, panel) =
probe.read_with(cx, |probe, _| (probe.text.clone(), probe.panel.clone()));
type_query(&panel, "hello", cx);
assert_eq!(match_stats(&panel, cx), (2, 2));
cx.update(|window, cx| {
text.update(cx, |state, cx| {
state.replace_all("hello hello hello", window, cx)
});
});
assert_eq!(match_stats(&panel, cx), (3, 3));
cx.update(|window, cx| {
let navigate = panel
.read(cx)
.on_navigate
.clone()
.expect("attach 后应有默认导航");
navigate(0, 0, 5, window, cx);
});
assert_eq!(text.read_with(cx, |state, _| state.selected_range()), 0..5);
}
}