//! Literal source search and line navigation, independent of accepted model state. use iced::widget::text_editor::{Cursor, Position}; #[derive(Debug, Clone)] pub enum Event { Open(bool), Close, Query(String), Next(bool), Line(String), Go, } #[derive(Default)] pub struct State { pub open: bool, pub query: String, pub line: String, matches: Vec, selected: Option, } impl State { pub fn refresh(&mut self, source: &str) { self.matches.clear(); self.selected = None; if self.query.is_empty() { return; } let starts: Vec<_> = std::iter::once(0) .chain(source.match_indices('\n').map(|(i, _)| i + 1)) .collect(); let position = |offset: usize| { let line = starts .partition_point(|start| *start <= offset) .saturating_sub(1); // Iced's text renderer uses UTF-8 byte columns, not scalar counts. Position { line, column: offset - starts[line], } }; self.matches = source .match_indices(&self.query) .map(|(offset, matched)| Cursor { position: position(offset), selection: Some(position(offset + matched.len())), }) .collect(); } pub fn next(&mut self, backwards: bool) -> Option { let count = self.matches.len(); if count == 0 { return None; } let selected = match self.selected { None => { if backwards { count - 1 } else { 0 } } Some(i) => { if backwards { (i + count - 1) % count } else { (i + 1) % count } } }; self.selected = Some(selected); Some(self.matches[selected]) } pub fn label(&self) -> String { if self.query.is_empty() { return "Case sensitive".into(); } match self.selected { Some(i) => format!("{} / {}", i + 1, self.matches.len()), None => format!("{} matches", self.matches.len()), } } pub fn destination(&self, line_count: usize) -> Result { let line: usize = self .line .trim() .parse() .map_err(|_| "Enter a line number.".to_string())?; if line == 0 || line > line_count { return Err(format!("Choose a line from 1 to {line_count}.")); } Ok(Cursor { position: Position { line: line - 1, column: 0, }, selection: None, }) } } #[cfg(test)] mod tests { use super::*; #[test] fn search_uses_utf8_columns_wraps_and_refreshes_after_edits() { let mut state = State { query: "救助".into(), ..State::default() }; state.refresh("é 救助\n救助"); let first = state.next(false).unwrap(); assert_eq!(first.position, Position { line: 0, column: 3 }); assert_eq!(first.selection, Some(Position { line: 0, column: 9 })); assert_eq!(state.next(false).unwrap().position.line, 1); assert_eq!(state.next(false), Some(first)); assert_eq!(state.next(true).unwrap().position.line, 1); state.refresh("no matches"); assert!(state.next(false).is_none()); assert_eq!(state.label(), "0 matches"); state.line = "0".into(); assert!(state.destination(2).is_err()); state.line = "2".into(); assert_eq!(state.destination(2).unwrap().position.line, 1); state.line = "3".into(); assert!(state.destination(2).is_err()); } }