Establish OA/SA prototype baseline and first-release work
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
//! 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<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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user