use std::cell::{Cell, RefCell};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use teksilo_text::text_document::{
DocumentEvent, HighlightFormat, RangeHighlight, SessionId, Subscription, TextDocument,
};
pub const CARET_HIGHLIGHT_PRIORITY: i32 = -1000;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CaretHighlightScope {
Sentence,
Paragraph,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CaretHighlight {
pub scope: CaretHighlightScope,
pub format: HighlightFormat,
pub content_locale: Option<String>,
}
pub(crate) struct CaretHighlightSession {
doc: TextDocument,
session: SessionId,
config: RefCell<Option<CaretHighlight>>,
active: Cell<bool>,
last: Cell<Option<(usize, usize)>>,
dirty: Arc<AtomicBool>,
_sub: Subscription,
}
impl CaretHighlightSession {
pub(crate) fn new(doc: &TextDocument) -> Self {
let session = doc.add_range_session_with_priority(CARET_HIGHLIGHT_PRIORITY);
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,
config: RefCell::new(None),
active: Cell::new(false),
last: Cell::new(None),
dirty,
_sub: sub,
}
}
#[allow(dead_code)]
pub(crate) fn session_id(&self) -> SessionId {
self.session
}
pub(crate) fn config(&self) -> Option<CaretHighlight> {
self.config.borrow().clone()
}
pub(crate) fn set_config(&self, config: Option<CaretHighlight>) -> bool {
let previous = self.config.replace(config.clone());
if previous == config {
return false;
}
match (&previous, &config) {
(None, _) | (_, None) => {
if config.is_none() {
return self.clear();
}
self.last.set(None);
true
}
(Some(before), Some(after))
if before.scope == after.scope && before.content_locale == after.content_locale =>
{
match self.last.get() {
Some(range) => self.push(Some(range), after),
None => true,
}
}
_ => {
self.last.set(None);
true
}
}
}
pub(crate) fn set_active(&self, active: bool) -> bool {
if self.active.replace(active) == active {
return false;
}
if active { true } else { self.clear() }
}
pub(crate) fn refresh(&self, caret: usize) -> bool {
let stale = self.dirty.swap(false, Ordering::Relaxed);
let config = self.config.borrow().clone();
let Some(config) = config else {
return false;
};
if !self.active.get() {
return false;
}
let range = self.resolve(caret, &config);
if range == self.last.get() && !stale {
return false;
}
self.push(range, &config)
}
fn resolve(&self, caret: usize, config: &CaretHighlight) -> Option<(usize, usize)> {
match config.scope {
CaretHighlightScope::Sentence => self
.doc
.sentence_at(caret, config.content_locale.as_deref()),
CaretHighlightScope::Paragraph => {
let block = self.doc.block_at_caret(caret).ok()?;
let (start, len) = (block.start, block.length);
(len > 0).then_some((start, start + len))
}
}
}
fn push(&self, range: Option<(usize, usize)>, config: &CaretHighlight) -> bool {
let ranges = match range {
Some((start, end)) if end > start => vec![RangeHighlight {
start,
length: end - start,
format: config.format.clone(),
}],
_ => Vec::new(),
};
self.doc.set_session_ranges(self.session, ranges);
self.last.set(range);
true
}
fn clear(&self) -> bool {
if self.last.get().is_none() {
return false;
}
self.doc.set_session_ranges(self.session, Vec::new());
self.last.set(None);
true
}
}
impl Drop for CaretHighlightSession {
fn drop(&mut self) {
self.doc.remove_session(self.session);
}
}
#[cfg(test)]
mod tests {
use super::*;
use teksilo_text::text_document::{Color, FlowElementSnapshot, HighlightMask};
const BAND: Color = Color {
red: 255,
green: 254,
blue: 235,
alpha: 255,
};
const OTHER: Color = Color {
red: 255,
green: 140,
blue: 0,
alpha: 255,
};
fn doc(text: &str) -> TextDocument {
let d = TextDocument::new();
d.set_plain_text(text).unwrap();
d
}
fn band(scope: CaretHighlightScope) -> CaretHighlight {
CaretHighlight {
scope,
format: HighlightFormat {
background_color: Some(BAND),
..Default::default()
},
content_locale: Some("en".into()),
}
}
fn spans(doc: &TextDocument, block: usize) -> Vec<(usize, usize)> {
match &doc.snapshot_flow_masked(&HighlightMask::all()).elements[block] {
FlowElementSnapshot::Block(b) => b
.paint_highlights
.iter()
.filter(|s| s.background_color == Some(BAND))
.map(|s| (s.start, s.length))
.collect(),
_ => panic!("block"),
}
}
fn live(d: &TextDocument, scope: CaretHighlightScope) -> CaretHighlightSession {
let s = CaretHighlightSession::new(d);
s.set_config(Some(band(scope)));
s.set_active(true);
s
}
#[test]
fn each_scope_bands_its_own_extent() {
let d = doc("One is first. Two is second.");
let s = live(&d, CaretHighlightScope::Sentence);
s.refresh(16);
assert_eq!(spans(&d, 0), [(14, 14)], "just \"Two is second.\"");
let p = live(&d, CaretHighlightScope::Paragraph);
drop(s);
p.refresh(16);
assert_eq!(spans(&d, 0), [(0, 28)], "the whole block");
}
#[test]
fn the_band_follows_the_caret_between_sentences() {
let d = doc("One is first. Two is second.");
let s = live(&d, CaretHighlightScope::Sentence);
s.refresh(2);
assert_eq!(spans(&d, 0), [(0, 13)]);
assert!(s.refresh(16), "moving to another sentence re-pushes");
assert_eq!(spans(&d, 0), [(14, 14)]);
}
#[test]
fn a_caret_move_inside_the_same_sentence_pushes_nothing() {
let d = doc("One is first. Two is second.");
let s = live(&d, CaretHighlightScope::Sentence);
assert!(s.refresh(2), "the first resolve always pushes");
assert!(!s.refresh(3), "same sentence: no push");
assert!(!s.refresh(10), "still the same sentence");
}
#[test]
fn an_inactive_view_draws_no_band() {
let d = doc("One is first. Two is second.");
let s = live(&d, CaretHighlightScope::Sentence);
s.refresh(2);
assert!(!spans(&d, 0).is_empty());
assert!(s.set_active(false), "going inactive is a repaint");
assert!(spans(&d, 0).is_empty(), "the band goes away");
assert!(!s.refresh(2), "and stays away while inactive");
assert!(spans(&d, 0).is_empty());
s.set_active(true);
s.refresh(2);
assert!(!spans(&d, 0).is_empty(), "focus brings it back");
}
#[test]
fn clearing_the_config_clears_the_band() {
let d = doc("One is first.");
let s = live(&d, CaretHighlightScope::Sentence);
s.refresh(2);
assert!(!spans(&d, 0).is_empty());
assert!(s.set_config(None));
assert!(spans(&d, 0).is_empty());
assert!(!s.refresh(2), "nothing to draw");
}
#[test]
fn a_format_only_change_repaints_the_same_extent() {
let d = doc("One is first. Two is second.");
let s = live(&d, CaretHighlightScope::Sentence);
s.refresh(16);
let before = spans(&d, 0);
let mut recoloured = band(CaretHighlightScope::Sentence);
recoloured.format.background_color = Some(OTHER);
assert!(s.set_config(Some(recoloured)));
let after = match &d.snapshot_flow_masked(&HighlightMask::all()).elements[0] {
FlowElementSnapshot::Block(b) => b.paint_highlights.clone(),
_ => panic!("block"),
};
assert_eq!(after.len(), 1);
assert_eq!((after[0].start, after[0].length), before[0]);
assert_eq!(after[0].background_color, Some(OTHER));
}
#[test]
fn changing_scope_re_resolves_the_extent() {
let d = doc("One is first. Two is second.");
let s = live(&d, CaretHighlightScope::Sentence);
s.refresh(16);
assert_eq!(spans(&d, 0), [(14, 14)]);
s.set_config(Some(band(CaretHighlightScope::Paragraph)));
s.refresh(16);
assert_eq!(spans(&d, 0), [(0, 28)]);
}
#[test]
fn an_edit_stales_the_band_and_refresh_re_derives_it() {
let d = doc("One is first. Two is second.");
let s = live(&d, CaretHighlightScope::Sentence);
s.refresh(16);
assert_eq!(spans(&d, 0), [(14, 14)]);
d.set_plain_text("XXXX. One is first. Two is second.")
.unwrap();
assert!(s.refresh(22), "the edit staled it");
assert_eq!(spans(&d, 0), [(20, 14)], "the band followed its text");
}
#[test]
fn dropping_the_session_removes_the_layer() {
let d = doc("One is first.");
{
let s = live(&d, CaretHighlightScope::Sentence);
s.refresh(2);
assert!(!spans(&d, 0).is_empty());
}
assert!(
spans(&d, 0).is_empty(),
"the band must not outlive its editor"
);
}
#[test]
fn another_layer_paints_over_the_band() {
for band_first in [true, false] {
let d = doc("One is first.");
let (s, other) = if band_first {
let s = live(&d, CaretHighlightScope::Sentence);
(s, d.add_range_session())
} else {
let o = d.add_range_session();
(live(&d, CaretHighlightScope::Sentence), o)
};
s.refresh(2);
d.set_session_ranges(
other,
vec![RangeHighlight {
start: 0,
length: 3,
format: HighlightFormat {
background_color: Some(OTHER),
..Default::default()
},
}],
);
let painted = match &d.snapshot_flow_masked(&HighlightMask::all()).elements[0] {
FlowElementSnapshot::Block(b) => b.paint_highlights.clone(),
_ => panic!("block"),
};
let at_zero = painted
.iter()
.rfind(|s| s.start == 0 && 0 < s.start + s.length)
.and_then(|s| s.background_color);
assert_eq!(
at_zero,
Some(OTHER),
"the other layer must win (band registered first: {band_first})"
);
}
}
#[test]
fn a_caret_at_the_end_of_a_paragraph_bands_that_paragraph() {
let d = doc("One is first.\nTwo is second.");
let end_of_first = "One is first.".chars().count();
let p = live(&d, CaretHighlightScope::Paragraph);
p.refresh(end_of_first);
assert_eq!(
spans(&d, 0),
[(0, end_of_first)],
"the band belongs to the paragraph the caret is finishing"
);
assert!(spans(&d, 1).is_empty(), "and not to the one after it");
drop(p);
let s = live(&d, CaretHighlightScope::Sentence);
s.refresh(end_of_first);
assert_eq!(spans(&d, 0), [(0, end_of_first)]);
assert!(spans(&d, 1).is_empty());
}
#[test]
fn the_band_does_cross_once_the_caret_enters_the_next_paragraph() {
let d = doc("One is first.\nTwo is second.");
let start_of_second = "One is first.\n".chars().count();
let p = live(&d, CaretHighlightScope::Paragraph);
p.refresh(start_of_second);
assert!(spans(&d, 0).is_empty(), "left the first paragraph");
assert_eq!(spans(&d, 1), [(0, "Two is second.".chars().count())]);
}
#[test]
fn an_empty_block_bands_nothing() {
let d = doc("Text.\n\nMore.");
let s = live(&d, CaretHighlightScope::Paragraph);
let blank = "Text.\n".chars().count();
s.refresh(blank);
assert!(spans(&d, 1).is_empty());
}
}