Files
ElyOffice/apps/ely-mark/src/finding.rs
T

259 lines
9.0 KiB
Rust

//! Find and replace in the shown file (MARK-06).
use std::ops::Range;
use ely_gpui_component::{
editor::{FindOptions, FindWidget, find_all},
forms::{InputEvent, TextInput},
primitives::{Severity, raise},
theme::ActiveTheme as _,
};
use ely_office::prelude::*;
use ely_ui::i18n::text;
use gpui::{
AnyElement, AppContext as _, Context, Entity, Focusable as _, InteractiveElement as _,
IntoElement, ParentElement as _, SharedString, Styled as _, Subscription, Window, div,
};
use crate::desk::Desk;
use crate::view::MarkView;
/// The find box over one tab, and what it found.
pub struct Find {
tab: u64,
query: Entity<TextInput>,
replace: Option<Entity<TextInput>>,
options: FindOptions,
hits: Vec<Range<usize>>,
current: Option<usize>,
/// The pattern did not compile; why.
error: Option<SharedString>,
_query: Subscription,
}
impl Desk {
pub fn on_find(
&mut self,
_: &ely_office::FindReplace,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(tab) = self.core.active else {
let said = text(cx, "command.blocked.no_document", &[]);
return self.toast(said, Severity::Warning, cx);
};
if let Some(find) = self.finding.as_ref().filter(|find| find.tab == tab) {
return window.focus(&find.query.focus_handle(cx), cx);
}
self.close_find(window, cx);
let query = cx.new(|cx| TextInput::new(window, cx));
let subscription =
cx.subscribe_in(&query, window, |desk, _, event, window, cx| match event {
InputEvent::Changed => desk.count(cx),
InputEvent::Submit => desk.step(true, window, cx),
_ => {}
});
window.focus(&query.focus_handle(cx), cx);
log::info!("mark: find opens on tab {tab}");
self.finding = Some(Find {
tab,
query,
replace: None,
options: FindOptions::default(),
hits: Vec::new(),
current: None,
error: None,
_query: subscription,
});
cx.notify();
}
fn found_view(&self) -> Option<(Entity<MarkView>, &Find)> {
let find = self.finding.as_ref()?;
Some((self.tab(find.tab)?.view.clone(), find))
}
/// Matches again in the tab's text, marked in source.
pub fn count(&mut self, cx: &mut Context<Self>) {
let Some((view, find)) = self.found_view() else {
return;
};
let query = find.query.read(cx).text().to_string();
let options = find.options;
let document = view.read(cx).document().clone();
let found = find_all(&document.read(cx).text(cx), &query, options);
let Some(find) = self.finding.as_mut() else {
return;
};
(find.hits, find.error) = match found {
Ok(hits) => (hits, None),
Err(why) => (Vec::new(), Some(why.into())),
};
find.current = find.current.filter(|current| *current < find.hits.len());
let (hits, current) = (find.hits.clone(), find.current);
mark(&document, &hits, current, cx);
cx.notify();
}
fn step(&mut self, forward: bool, window: &mut Window, cx: &mut Context<Self>) {
let Some((view, find)) = self.found_view() else {
return;
};
let count = find.hits.len();
if count == 0 {
return;
}
let current = match (find.current, forward) {
(None, true) => 0,
(None, false) => count - 1,
(Some(at), true) => (at + 1) % count,
(Some(at), false) => (at + count - 1) % count,
};
let range = find.hits[current].clone();
let hits = find.hits.clone();
if let Some(find) = self.finding.as_mut() {
find.current = Some(current);
}
let document = view.read(cx).document().clone();
mark(&document, &hits, Some(current), cx);
document.update(cx, |document, cx| document.show_match(range, window, cx));
cx.notify();
}
fn replace(&mut self, every: bool, window: &mut Window, cx: &mut Context<Self>) {
let Some((view, find)) = self.found_view() else {
return;
};
let with = find
.replace
.as_ref()
.map_or(String::new(), |field| field.read(cx).text().to_string());
let edits: Vec<(Range<usize>, String)> = match (every, find.current) {
(true, _) => find
.hits
.iter()
.map(|hit| (hit.clone(), with.clone()))
.collect(),
(false, Some(at)) => vec![(find.hits[at].clone(), with)],
(false, None) => return self.step(true, window, cx),
};
if edits.is_empty() {
return;
}
// The next match starts past the replaced text.
let after = edits[0].0.start + edits[0].1.len();
let document = view.read(cx).document().clone();
if !document.update(cx, |document, cx| document.replace(edits, cx)) {
let said = text(cx, "mark.preview.read_only", &[]);
return self.toast(said, Severity::Info, cx);
}
self.count(cx);
if every {
return;
}
let Some(find) = self.finding.as_mut() else {
return;
};
let next = find.hits.iter().position(|hit| hit.start >= after);
find.current = next.or((!find.hits.is_empty()).then_some(0));
let Some(range) = find.current.map(|at| find.hits[at].clone()) else {
return;
};
let hits = find.hits.clone();
mark(&document, &hits, find.current, cx);
document.update(cx, |document, cx| document.show_match(range, window, cx));
}
fn close_find(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(find) = self.finding.take() else {
return;
};
if let Some(tab) = self.tab(find.tab) {
let document = tab.view.read(cx).document().clone();
mark(&document, &[], None, cx);
window.focus(&tab.view.read(cx).focus.clone(), cx);
}
cx.notify();
}
/// The find box over the file, while open.
pub fn find_box(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
let find = self
.finding
.as_ref()
.filter(|find| Some(find.tab) == self.core.active)?;
let this = cx.entity();
let mut widget = FindWidget::new("mark-find", &find.query, find.current, find.hits.len())
.options(find.options)
.on_options({
let this = this.clone();
move |options, _, cx| {
this.update(cx, |desk, cx| {
if let Some(find) = desk.finding.as_mut() {
find.options = options;
}
desk.count(cx);
})
}
})
.on_step({
let this = this.clone();
move |forward, window, cx| {
this.update(cx, |desk, cx| desk.step(forward, window, cx))
}
})
.on_replace({
let this = this.clone();
move |every, window, cx| this.update(cx, |desk, cx| desk.replace(every, window, cx))
})
.on_toggle_replace({
let this = this.clone();
move |show, window, cx| {
this.update(cx, |desk, cx| {
if let Some(find) = desk.finding.as_mut() {
find.replace = show.then(|| cx.new(|cx| TextInput::new(window, cx)));
}
cx.notify();
})
}
})
.on_close(move |window, cx| this.update(cx, |desk, cx| desk.close_find(window, cx)));
if let Some(field) = &find.replace {
widget = widget.replace(field);
}
if let Some(error) = &find.error {
widget = widget.error(error.clone());
}
let place = div()
.absolute()
.top_2()
.right_4()
.block_mouse_except_scroll()
.child(widget);
Some(raise("mark-find-layer", place).into_any_element())
}
}
/// The matches marked in the source; the current one stronger.
fn mark(
document: &Entity<ely_gpui_component::documents::MarkdownDocument>,
hits: &[Range<usize>],
current: Option<usize>,
cx: &mut Context<Desk>,
) {
let tone = cx.theme().colors.warning;
let marks = hits
.iter()
.enumerate()
.map(|(at, hit)| {
let strength = if Some(at) == current { 0.45 } else { 0.2 };
(hit.clone(), tone.opacity(strength))
})
.collect();
let editor = document.read(cx).editor().clone();
editor.update(cx, |editor, cx| editor.set_backgrounds(marks, cx));
}
#[cfg(test)]
mod tests;