Stupidly reliable opener search, better find functionality

This commit is contained in:
Joshua Barretto 2025-08-31 15:09:35 +01:00
parent 281f75c958
commit 551b2816b3
9 changed files with 130 additions and 60 deletions

View file

@ -10,14 +10,14 @@
- [x] Multiple panes - [x] Multiple panes
- [x] Pane creation/deletion - [x] Pane creation/deletion
- [x] Opener - [x] Opener
- [x] Find
- [x] Search in buffer switcher
- [x] File saving
- [x] Syntax highlighting
## Todo ## Todo
- [ ] Find
- [ ] Replace - [ ] Replace
- [ ] Project search - [ ] Project search
- [ ] Search in buffer switcher
- [ ] File saving
- [ ] Syntax highlighting
- [ ] Auto-indent (and related features) - [ ] Auto-indent (and related features)
- [ ] Undo/redo - [ ] Undo/redo

View file

@ -14,7 +14,7 @@ pub enum Dir {
pub enum Action { pub enum Action {
Char(char), // Insert a character Char(char), // Insert a character
Indent(bool), // Indent (indent vs deindent) Indent(bool), // Indent (indent vs deindent)
Move(Dir, bool, bool, bool), // Move the cursor (dir, page, retain_base, word) Move(Dir, bool, bool, bool), // Move the cursor (dir, page, retain_base, word)
PaneMove(Dir), // Move panes PaneMove(Dir), // Move panes
PaneOpen(Dir), // Create a new pane PaneOpen(Dir), // Create a new pane
PaneClose, // Close the current pane PaneClose, // Close the current pane
@ -28,7 +28,7 @@ pub enum Action {
Show(Option<String>, String), // Display an optionally titled informational text box to the user Show(Option<String>, String), // Display an optionally titled informational text box to the user
OpenSwitcher, // Open the buffer switcher OpenSwitcher, // Open the buffer switcher
OpenOpener(PathBuf), // Open the file opener OpenOpener(PathBuf), // Open the file opener
OpenFinder, // Open the finder OpenFinder(Option<String>), // Open the finder, with the given default query
SwitchBuffer(BufferId), // Switch the current pane to the given buffer SwitchBuffer(BufferId), // Switch the current pane to the given buffer
OpenFile(PathBuf), // Open the file and switch the current pane to it OpenFile(PathBuf), // Open the file and switch the current pane to it
CommandStart(&'static str), // Start a new command CommandStart(&'static str), // Start a new command
@ -252,7 +252,7 @@ impl RawEvent {
} }
} }
pub fn to_open_finder(&self) -> Option<Action> { pub fn to_open_finder(&self, selection: Option<String>) -> Option<Action> {
if matches!( if matches!(
&self.0, &self.0,
TerminalEvent::Key(KeyEvent { TerminalEvent::Key(KeyEvent {
@ -262,7 +262,7 @@ impl RawEvent {
.. ..
}) })
) { ) {
Some(Action::OpenFinder) Some(Action::OpenFinder(selection))
} else { } else {
None None
} }

View file

@ -27,7 +27,6 @@ pub enum Error {
fn main() -> Result<(), Error> { fn main() -> Result<(), Error> {
let args = Args::parse(); let args = Args::parse();
println!("{args:?}");
let mut state = State::try_from(args)?; let mut state = State::try_from(args)?;
let open_buffers = state.buffers.keys().collect::<Vec<_>>(); let open_buffers = state.buffers.keys().collect::<Vec<_>>();

View file

@ -251,7 +251,9 @@ impl Buffer {
self.insert(pos, (0..n).map(|_| ' ')); self.insert(pos, (0..n).map(|_| ' '));
} else { } else {
// First, find the next non-space character in the line // First, find the next non-space character in the line
while self.text.chars().get(pos) == Some(&' ') { pos += 1; } while self.text.chars().get(pos) == Some(&' ') {
pos += 1;
}
// Find the desired column, and hence the number of spaces to remove // Find the desired column, and hence the number of spaces to remove
let coord = self.text.to_coord(pos).map(|e| e.max(0) as usize); let coord = self.text.to_coord(pos).map(|e| e.max(0) as usize);
@ -264,11 +266,10 @@ impl Buffer {
Some(pos) if self.text.chars().get(pos) == Some(&' ') => { Some(pos) if self.text.chars().get(pos) == Some(&' ') => {
self.remove(pos..pos + 1); self.remove(pos..pos + 1);
pos pos
}, }
_ => break, _ => break,
}; };
} }
} }
} }
@ -306,7 +307,11 @@ impl Buffer {
let class = self.text.chars().get(pos).copied().map(classify); let class = self.text.chars().get(pos).copied().map(classify);
loop { loop {
pos = match pos.checked_sub(1) { pos = match pos.checked_sub(1) {
Some(pos) if self.text.chars().get(pos).copied().map(classify) == class => pos, Some(pos)
if self.text.chars().get(pos).copied().map(classify) == class =>
{
pos
}
_ => break pos, _ => break pos,
} }
} }
@ -325,7 +330,7 @@ impl Buffer {
pos = if self.text.chars().get(pos).copied().map(classify) == class { pos = if self.text.chars().get(pos).copied().map(classify) == class {
pos + 1 pos + 1
} else { } else {
break pos break pos;
}; };
} }
} else { } else {
@ -461,7 +466,6 @@ fn classify(c: char) -> u8 {
} }
} }
pub struct State { pub struct State {
pub buffers: HopSlotMap<BufferId, Buffer>, pub buffers: HopSlotMap<BufferId, Buffer>,
pub tick: u64, pub tick: u64,
@ -477,8 +481,12 @@ impl TryFrom<Args> for State {
theme: theme::Theme::default(), theme: theme::Theme::default(),
}; };
for path in args.paths { if args.paths.is_empty() {
this.buffers.insert(Buffer::from_file(path)?); this.buffers.insert(Buffer::default());
} else {
for path in args.paths {
this.buffers.insert(Buffer::from_file(path)?);
}
} }
Ok(this) Ok(this)

View file

@ -72,7 +72,7 @@ impl Default for Theme {
select_bg: Color::AnsiValue(23), select_bg: Color::AnsiValue(23),
line_select_bg: Color::AnsiValue(8), line_select_bg: Color::AnsiValue(8),
unfocus_select_bg: Color::AnsiValue(240), unfocus_select_bg: Color::AnsiValue(240),
search_result_bg: Color::AnsiValue(66), search_result_bg: Color::AnsiValue(60),
margin_bg: Color::Reset, margin_bg: Color::Reset,
margin_line_num: Color::AnsiValue(245), margin_line_num: Color::AnsiValue(245),
border: BorderTheme::default(), border: BorderTheme::default(),

View file

@ -65,19 +65,33 @@ impl Element for Doc {
return Err(event); return Err(event);
}; };
let open_path = buffer.dir.to_owned().unwrap_or(PathBuf::from("/")); let open_path = buffer
.dir
.to_owned()
.unwrap_or_else(|| std::env::current_dir().expect("no working dir"));
let selection = buffer.cursors[cursor_id]
.selection()
.map(|range| buffer.text.chars()[range].iter().copied().collect());
match event.to_action(|e| { match event.to_action(|e| {
e.to_open_switcher() e.to_open_switcher()
.or_else(|| e.to_open_opener(open_path)) .or_else(|| e.to_open_opener(open_path))
.or_else(|| e.to_open_finder()) .or_else(|| e.to_open_finder(selection))
.or_else(|| e.to_move()) .or_else(|| e.to_move())
.or_else(|| e.to_save()) .or_else(|| e.to_save())
}) { }) {
action @ Some(Action::OpenSwitcher) => Ok(Resp::handled(action.map(Into::into))), action @ Some(Action::OpenSwitcher) => Ok(Resp::handled(action.map(Into::into))),
action @ Some(Action::OpenOpener(_)) => Ok(Resp::handled(action.map(Into::into))), action @ Some(Action::OpenOpener(_)) => Ok(Resp::handled(action.map(Into::into))),
action @ Some(Action::OpenFinder) => { ref action @ Some(Action::OpenFinder(ref query)) => {
self.search = Some(Search::new(buffer.cursors[cursor_id])); self.search = Some(Search::new(
buffer.cursors[cursor_id],
query.clone(),
state,
&mut self.input,
self.buffer,
cursor_id,
));
Ok(Resp::handled(None)) Ok(Resp::handled(None))
} }
Some(Action::SwitchBuffer(new_buffer)) => { Some(Action::SwitchBuffer(new_buffer)) => {
@ -124,7 +138,7 @@ impl Visual for Doc {
[0, 0], [0, 0],
[frame.size()[0], frame.size()[1].saturating_sub(search_h)], [frame.size()[0], frame.size()[1].saturating_sub(search_h)],
) )
.with_focus(true/*self.search.is_none()*/) .with_focus(true /*self.search.is_none()*/)
.with(|f| { .with(|f| {
self.input.render( self.input.render(
state, state,
@ -162,19 +176,35 @@ pub struct Search {
} }
impl Search { impl Search {
fn new(old_cursor: Cursor) -> Self { fn new(
old_cursor: Cursor,
query: Option<String>,
state: &mut State,
input: &mut Input,
buffer_id: BufferId,
cursor_id: CursorId,
) -> Self {
let mut buffer = Buffer::default(); let mut buffer = Buffer::default();
Self { let cursor_id = buffer.start_session();
// Insert default query
buffer.insert(0, query.iter().flat_map(|s| s.chars()));
let mut this = Self {
old_cursor, old_cursor,
cursor_id: buffer.start_session(), cursor_id,
buffer, buffer,
input: Input::filter(), input: Input::filter(),
selected: 0, selected: 0,
needle: Vec::new(), needle: Vec::new(),
results: Vec::new(), results: Vec::new(),
} };
this.update(state, input, buffer_id, cursor_id);
this
} }
pub fn contains(&self, pos: usize) -> Option<bool> { pub fn contains(&self, pos: usize) -> Option<bool> {
@ -189,6 +219,37 @@ impl Search {
.map(|_| idx == self.selected) .map(|_| idx == self.selected)
} }
fn update(
&mut self,
state: &mut State,
input: &mut Input,
buffer_id: BufferId,
cursor_id: CursorId,
) {
let buffer = &mut state.buffers[buffer_id];
let needle = self.buffer.text.chars();
if self.needle != needle {
// The needle has changed!
let haystack = buffer.text.chars();
self.needle = needle.to_vec();
self.results = (0..haystack.len().saturating_sub(needle.len()))
.filter(|i| haystack[*i..].starts_with(needle))
.collect();
// Select the first entry that comes after the current cursor position
self.selected = (0..self.results.len())
.find(|i| self.results[*i] >= self.old_cursor.pos)
.unwrap_or(0);
}
if let Some(result) = self.results.get(self.selected) {
buffer.cursors[cursor_id].select(*result..*result + self.needle.len());
input.refocus(buffer, cursor_id);
}
}
fn handle( fn handle(
&mut self, &mut self,
state: &mut State, state: &mut State,
@ -225,21 +286,7 @@ impl Search {
.map(Resp::into_can_end), .map(Resp::into_can_end),
}; };
let needle = self.buffer.text.chars(); self.update(state, input, buffer_id, cursor_id);
if self.needle != needle {
let haystack = buffer.text.chars();
self.selected = 0;
self.needle = needle.to_vec();
self.results = (0..haystack.len().saturating_sub(needle.len()))
.filter(|i| haystack[*i..].starts_with(needle))
.collect();
}
if let Some(result) = self.results.get(self.selected) {
buffer.cursors[cursor_id].select(*result..*result + self.needle.len());
input.refocus(buffer, cursor_id);
}
res res
} }

View file

@ -177,7 +177,10 @@ impl Input {
} else { } else {
None None
}; };
let selected = cursor.selection().zip(pos).map_or(false, |(s, pos)| s.contains(&pos)); let selected = cursor
.selection()
.zip(pos)
.map_or(false, |(s, pos)| s.contains(&pos));
let (fg, c) = match line.get(coord as usize).copied() { let (fg, c) = match line.get(coord as usize).copied() {
Some('\n') if selected => (state.theme.whitespace, '⮠'), Some('\n') if selected => (state.theme.whitespace, '⮠'),
Some(c) => { Some(c) => {
@ -197,18 +200,22 @@ impl Input {
let bg = match search.map(|s| s.contains(pos?)) { let bg = match search.map(|s| s.contains(pos?)) {
Some(Some(true)) => state.theme.select_bg, Some(Some(true)) => state.theme.select_bg,
Some(Some(false)) => state.theme.search_result_bg, Some(Some(false)) => state.theme.search_result_bg,
Some(None) if line_selected && frame.has_focus() => state.theme.line_select_bg, Some(None) if line_selected && frame.has_focus() => {
_ => if selected {
if frame.has_focus() {
state.theme.select_bg
} else {
state.theme.unfocus_select_bg
}
} else if line_selected && frame.has_focus() {
state.theme.line_select_bg state.theme.line_select_bg
} else { }
Color::Reset _ => {
}, if selected {
if frame.has_focus() {
state.theme.select_bg
} else {
state.theme.unfocus_select_bg
}
} else if line_selected && frame.has_focus() {
state.theme.line_select_bg
} else {
Color::Reset
}
}
}; };
frame frame
.with_bg(bg) .with_bg(bg)

View file

@ -151,7 +151,10 @@ impl<T: Clone> Element<T> for Options<T> {
match event.to_action(|e| e.to_go().or_else(|| e.to_move())) { match event.to_action(|e| e.to_go().or_else(|| e.to_move())) {
Some(Action::Move(dir, false, false, false)) => { Some(Action::Move(dir, false, false, false)) => {
match dir { match dir {
Dir::Up => self.selected = (self.selected + self.ranking.len() - 1) % self.ranking.len(), Dir::Up => {
self.selected =
(self.selected + self.ranking.len() - 1) % self.ranking.len()
}
Dir::Down => self.selected = (self.selected + 1) % self.ranking.len(), Dir::Down => self.selected = (self.selected + 1) % self.ranking.len(),
_ => return Err(event), _ => return Err(event),
} }

View file

@ -1,6 +1,6 @@
use super::*; use super::*;
use crate::state::{Buffer, BufferId, CursorId}; use crate::state::{Buffer, BufferId, CursorId};
use std::{fs, path::PathBuf}; use std::{cmp::Reverse, fs, path::PathBuf};
pub struct Prompt { pub struct Prompt {
buffer: Buffer, buffer: Buffer,
@ -345,15 +345,21 @@ impl Opener {
// TODO // TODO
self.options.set_options(options, |e| { self.options.set_options(options, |e| {
let name = e.path.file_name()?.to_str()?.to_lowercase(); let name = e.path.file_name()?.to_str()?.to_lowercase();
let modify_time = e
.path
.metadata()
.ok()
.and_then(|m| Some(m.modified().ok()?.elapsed().ok()?.as_secs()))
.unwrap_or(!0);
if matches!(e.kind, FileKind::New) { if matches!(e.kind, FileKind::New) {
// Special-case: the 'new file' entry always matches last // Special-case: the 'new file' entry always matches last
Some((3, name.chars().count())) Some((1000, 0, 0))
} else if name == filter { } else if name == filter {
Some((0, name.chars().count())) Some((0, modify_time, name.chars().count()))
} else if name.starts_with(&filter) { } else if name.starts_with(&filter) {
Some((1, name.chars().count())) Some((1, modify_time, name.chars().count()))
} else if name.contains(&filter) { } else if name.contains(&filter) {
Some((2, name.chars().count())) Some((2, modify_time, name.chars().count()))
} else { } else {
None None
} }