139 lines
4.0 KiB
Rust
139 lines
4.0 KiB
Rust
//! Literal source search and line navigation, independent of accepted model state.
|
|
mod controller;
|
|
pub mod interaction;
|
|
pub mod query;
|
|
pub mod selection;
|
|
pub mod symbols;
|
|
pub use controller::*;
|
|
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<Cursor>,
|
|
selected: Option<usize>,
|
|
}
|
|
|
|
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<Cursor> {
|
|
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<Cursor, String> {
|
|
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());
|
|
}
|
|
}
|