Files
ARCADIA-Studio/crates/oa-desktop/src/source_navigation.rs
T
sorokin.oleg.edu 2df7f580aa Make the Text workspace compact and preserve editing context
Replace the oversized header and reference rail with stable editor slots and a retained supporting drawer. Bind diagnostic, result and menu actions to their original publication and visit; preserve effective selections on navigation and dismiss Actions, Find and the drawer one surface per Escape.

Retain exact Windows source captures, eight final gates (871 desktop passes), independent reviews, native selection/rename/recovery trials and the original failures. All 526 tested inputs and staged evidence were byte-checked before commit. Combined preview integration and packaging remain with issue 27; Linux remains paused.
2026-09-12 22:00:36 -07:00

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