Files
ARCADIA-Studio/crates/oa-desktop/src/source_navigation.rs
T

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());
}
}