use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use teksilo_text::text_document::{
DocumentEvent, FindMatch, FindOptions, HighlightFormat, RangeHighlight, SessionId,
Subscription, TextDocument,
};
pub struct FindSession {
doc: TextDocument,
session: SessionId,
matches: Vec<FindMatch>,
current: Option<usize>,
current_format: HighlightFormat,
other_format: HighlightFormat,
query: String,
options: FindOptions,
dirty: Arc<AtomicBool>,
_sub: Subscription,
}
impl FindSession {
pub fn new(
doc: &TextDocument,
current_format: HighlightFormat,
other_format: HighlightFormat,
) -> Self {
let session = doc.add_range_session();
let dirty = Arc::new(AtomicBool::new(false));
let sub = {
let dirty = dirty.clone();
doc.on_change(move |event| {
if matches!(
event,
DocumentEvent::ContentsChanged { .. }
| DocumentEvent::DocumentReset
| DocumentEvent::BlockCountChanged(_)
| DocumentEvent::FlowElementsInserted { .. }
| DocumentEvent::FlowElementsRemoved { .. }
) {
dirty.store(true, Ordering::Relaxed);
}
})
};
Self {
doc: doc.clone(),
session,
matches: Vec::new(),
current: None,
current_format,
other_format,
query: String::new(),
options: FindOptions::default(),
dirty,
_sub: sub,
}
}
pub fn session_id(&self) -> SessionId {
self.session
}
pub fn set_query(&mut self, query: &str, options: &FindOptions) {
self.query = query.to_string();
self.options = options.clone();
self.rerun();
self.current = (!self.matches.is_empty()).then_some(0);
self.apply();
}
pub fn set_current(&mut self, index: Option<usize>) -> Option<FindMatch> {
self.current = match index {
Some(_) if self.matches.is_empty() => None,
Some(i) => Some(i.min(self.matches.len() - 1)),
None => None,
};
self.apply();
self.current_match()
}
pub fn refresh_if_stale(&mut self) -> bool {
if !self.dirty.swap(false, Ordering::Relaxed) {
return false;
}
self.rerun();
if let Some(i) = self.current
&& i >= self.matches.len()
{
self.current = self.matches.len().checked_sub(1);
}
self.apply();
true
}
fn rerun(&mut self) {
self.matches = if self.query.is_empty() {
Vec::new()
} else {
self.doc
.find_all(&self.query, &self.options)
.unwrap_or_default()
};
self.dirty.store(false, Ordering::Relaxed);
}
pub fn match_count(&self) -> usize {
self.matches.len()
}
pub fn current_index(&self) -> usize {
self.current.unwrap_or(0)
}
pub fn current_match(&self) -> Option<FindMatch> {
self.matches.get(self.current?).cloned()
}
pub fn next_match(&mut self) -> Option<FindMatch> {
if self.matches.is_empty() {
return None;
}
self.current = Some(match self.current {
Some(i) => (i + 1) % self.matches.len(),
None => 0,
});
self.apply();
self.current_match()
}
pub fn prev_match(&mut self) -> Option<FindMatch> {
if self.matches.is_empty() {
return None;
}
self.current = Some(match self.current {
Some(i) => (i + self.matches.len() - 1) % self.matches.len(),
None => self.matches.len() - 1,
});
self.apply();
self.current_match()
}
pub fn clear(&mut self) {
self.query.clear();
self.matches.clear();
self.current = None;
self.dirty.store(false, Ordering::Relaxed);
self.apply();
}
fn apply(&self) {
let mut ranges: Vec<RangeHighlight> = Vec::with_capacity(self.matches.len());
for (i, m) in self.matches.iter().enumerate() {
if Some(i) == self.current {
continue;
}
ranges.push(RangeHighlight {
start: m.position,
length: m.length,
format: self.other_format.clone(),
});
}
if let Some(cur) = self.current.and_then(|i| self.matches.get(i)) {
ranges.push(RangeHighlight {
start: cur.position,
length: cur.length,
format: self.current_format.clone(),
});
}
self.doc.set_session_ranges(self.session, ranges);
}
}
impl Drop for FindSession {
fn drop(&mut self) {
self.doc.remove_session(self.session);
}
}
#[cfg(test)]
mod tests {
use super::*;
use teksilo_text::text_document::{Color, FlowElementSnapshot, HighlightMask};
fn bg(color: Color) -> HighlightFormat {
HighlightFormat {
background_color: Some(color),
..Default::default()
}
}
const CURRENT: Color = Color {
red: 0,
green: 120,
blue: 255,
alpha: 255,
};
const OTHER: Color = Color {
red: 255,
green: 214,
blue: 0,
alpha: 150,
};
fn doc(text: &str) -> TextDocument {
let d = TextDocument::new();
d.set_plain_text(text).unwrap();
d
}
fn paint_spans(doc: &TextDocument) -> Vec<teksilo_text::text_document::PaintHighlightSpan> {
match &doc.snapshot_flow_masked(&HighlightMask::all()).elements[0] {
FlowElementSnapshot::Block(b) => b.paint_highlights.clone(),
_ => panic!("block"),
}
}
#[test]
fn a_query_highlights_every_match_with_the_current_distinguished() {
let d = doc("elena and Elena and ELENA");
let mut fs = FindSession::new(&d, bg(CURRENT), bg(OTHER));
fs.set_query("elena", &FindOptions::default());
assert_eq!(fs.match_count(), 3, "case-folded: all three");
let spans = paint_spans(&d);
let current: Vec<_> = spans
.iter()
.filter(|s| s.background_color == Some(CURRENT))
.collect();
let others: Vec<_> = spans
.iter()
.filter(|s| s.background_color == Some(OTHER))
.collect();
assert_eq!(current.len(), 1, "one current match");
assert_eq!(others.len(), 2, "two other matches");
assert_eq!(current[0].start, 0);
}
#[test]
fn next_and_prev_move_the_current_match_and_wrap() {
let d = doc("a x a x a");
let mut fs = FindSession::new(&d, bg(CURRENT), bg(OTHER));
fs.set_query("a", &FindOptions::default());
assert_eq!(fs.match_count(), 3);
assert_eq!(fs.current_index(), 0);
assert_eq!(fs.next_match().unwrap().position, 4); assert_eq!(fs.current_index(), 1);
fs.next_match();
assert_eq!(fs.current_index(), 2);
fs.next_match(); assert_eq!(fs.current_index(), 0);
fs.prev_match(); assert_eq!(fs.current_index(), 2);
}
#[test]
fn an_empty_query_clears_the_highlighting() {
let d = doc("hello hello");
let mut fs = FindSession::new(&d, bg(CURRENT), bg(OTHER));
fs.set_query("hello", &FindOptions::default());
assert!(!paint_spans(&d).is_empty());
fs.set_query("", &FindOptions::default());
assert!(paint_spans(&d).is_empty(), "cleared");
assert_eq!(fs.match_count(), 0);
}
#[test]
fn an_edit_stales_the_matches_and_refresh_re_derives_them() {
let d = doc("the cat sat");
let mut fs = FindSession::new(&d, bg(CURRENT), bg(OTHER));
fs.set_query("cat", &FindOptions::default());
let before = fs.current_match().unwrap();
assert_eq!(before.position, 4, "`cat` starts at char 4");
d.set_plain_text("XXXXthe cat sat").unwrap();
assert!(
fs.refresh_if_stale(),
"the edit must have marked the session stale"
);
let after = fs.current_match().unwrap();
assert_eq!(after.position, 8, "the match followed the text it names");
assert!(!fs.refresh_if_stale());
}
#[test]
fn refresh_clamps_the_current_index_when_matches_shrink() {
let d = doc("a a a");
let mut fs = FindSession::new(&d, bg(CURRENT), bg(OTHER));
fs.set_query("a", &FindOptions::default());
fs.next_match();
fs.next_match(); assert_eq!(fs.current_index(), 2);
d.set_plain_text("a").unwrap(); assert!(fs.refresh_if_stale());
assert_eq!(fs.match_count(), 1);
assert_eq!(fs.current_index(), 0, "clamped to the one remaining match");
}
#[test]
fn clearing_the_current_match_leaves_every_match_an_other() {
let d = doc("elena and Elena and ELENA");
let mut fs = FindSession::new(&d, bg(CURRENT), bg(OTHER));
fs.set_query("elena", &FindOptions::default());
assert!(fs.current_match().is_some(), "set_query lands on the first");
assert!(
fs.set_current(None).is_none(),
"no current match to report back"
);
let spans = paint_spans(&d);
assert_eq!(spans.len(), 3, "all three still highlighted");
assert!(
spans.iter().all(|s| s.background_color == Some(OTHER)),
"and every one of them as an `other`"
);
}
#[test]
fn stepping_into_a_document_with_no_current_match_enters_from_the_right_end() {
let d = doc("a x a x a");
let mut fs = FindSession::new(&d, bg(CURRENT), bg(OTHER));
fs.set_query("a", &FindOptions::default());
fs.set_current(None);
assert_eq!(fs.next_match().unwrap().position, 0, "forwards: the first");
fs.set_current(None);
assert_eq!(fs.prev_match().unwrap().position, 8, "backwards: the last");
}
#[test]
fn setting_a_current_match_out_of_range_clamps_instead_of_panicking() {
let d = doc("a x a");
let mut fs = FindSession::new(&d, bg(CURRENT), bg(OTHER));
fs.set_query("a", &FindOptions::default());
assert_eq!(
fs.set_current(Some(99)).unwrap().position,
4,
"the last one"
);
fs.set_query("zzz", &FindOptions::default());
assert!(fs.set_current(Some(0)).is_none(), "nothing to stand on");
assert_eq!(fs.current_index(), 0, "and the index reads as zero");
}
#[test]
fn a_refresh_does_not_give_a_currentless_document_a_current_match() {
let d = doc("a a a");
let mut fs = FindSession::new(&d, bg(CURRENT), bg(OTHER));
fs.set_query("a", &FindOptions::default());
fs.set_current(None);
d.set_plain_text("a").unwrap();
assert!(fs.refresh_if_stale());
assert!(
fs.current_match().is_none(),
"the reader is still standing somewhere else"
);
}
#[test]
fn dropping_the_session_removes_the_layer() {
let d = doc("hello hello");
{
let mut fs = FindSession::new(&d, bg(CURRENT), bg(OTHER));
fs.set_query("hello", &FindOptions::default());
assert!(!paint_spans(&d).is_empty());
} assert!(
paint_spans(&d).is_empty(),
"the find session's layer must not outlive it"
);
}
}