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] Pane creation/deletion
- [x] Opener
- [x] Find
- [x] Search in buffer switcher
- [x] File saving
- [x] Syntax highlighting
## Todo
- [ ] Find
- [ ] Replace
- [ ] Project search
- [ ] Search in buffer switcher
- [ ] File saving
- [ ] Syntax highlighting
- [ ] Auto-indent (and related features)
- [ ] Undo/redo

View file

@ -14,7 +14,7 @@ pub enum Dir {
pub enum Action {
Char(char), // Insert a character
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
PaneOpen(Dir), // Create a new 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
OpenSwitcher, // Open the buffer switcher
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
OpenFile(PathBuf), // Open the file and switch the current pane to it
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!(
&self.0,
TerminalEvent::Key(KeyEvent {
@ -262,7 +262,7 @@ impl RawEvent {
..
})
) {
Some(Action::OpenFinder)
Some(Action::OpenFinder(selection))
} else {
None
}

View file

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

View file

@ -251,7 +251,9 @@ impl Buffer {
self.insert(pos, (0..n).map(|_| ' '));
} else {
// 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
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(&' ') => {
self.remove(pos..pos + 1);
pos
},
}
_ => break,
};
}
}
}
@ -306,7 +307,11 @@ impl Buffer {
let class = self.text.chars().get(pos).copied().map(classify);
loop {
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,
}
}
@ -325,7 +330,7 @@ impl Buffer {
pos = if self.text.chars().get(pos).copied().map(classify) == class {
pos + 1
} else {
break pos
break pos;
};
}
} else {
@ -461,7 +466,6 @@ fn classify(c: char) -> u8 {
}
}
pub struct State {
pub buffers: HopSlotMap<BufferId, Buffer>,
pub tick: u64,
@ -477,8 +481,12 @@ impl TryFrom<Args> for State {
theme: theme::Theme::default(),
};
for path in args.paths {
this.buffers.insert(Buffer::from_file(path)?);
if args.paths.is_empty() {
this.buffers.insert(Buffer::default());
} else {
for path in args.paths {
this.buffers.insert(Buffer::from_file(path)?);
}
}
Ok(this)

View file

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

View file

@ -65,19 +65,33 @@ impl Element for Doc {
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| {
e.to_open_switcher()
.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_save())
}) {
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::OpenFinder) => {
self.search = Some(Search::new(buffer.cursors[cursor_id]));
ref action @ Some(Action::OpenFinder(ref query)) => {
self.search = Some(Search::new(
buffer.cursors[cursor_id],
query.clone(),
state,
&mut self.input,
self.buffer,
cursor_id,
));
Ok(Resp::handled(None))
}
Some(Action::SwitchBuffer(new_buffer)) => {
@ -124,7 +138,7 @@ impl Visual for Doc {
[0, 0],
[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| {
self.input.render(
state,
@ -162,19 +176,35 @@ pub struct 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();
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,
cursor_id: buffer.start_session(),
cursor_id,
buffer,
input: Input::filter(),
selected: 0,
needle: Vec::new(),
results: Vec::new(),
}
};
this.update(state, input, buffer_id, cursor_id);
this
}
pub fn contains(&self, pos: usize) -> Option<bool> {
@ -189,6 +219,37 @@ impl Search {
.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(
&mut self,
state: &mut State,
@ -225,21 +286,7 @@ impl Search {
.map(Resp::into_can_end),
};
let needle = self.buffer.text.chars();
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);
}
self.update(state, input, buffer_id, cursor_id);
res
}

View file

@ -177,7 +177,10 @@ impl Input {
} else {
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() {
Some('\n') if selected => (state.theme.whitespace, '⮠'),
Some(c) => {
@ -197,18 +200,22 @@ impl Input {
let bg = match search.map(|s| s.contains(pos?)) {
Some(Some(true)) => state.theme.select_bg,
Some(Some(false)) => state.theme.search_result_bg,
Some(None) if line_selected && frame.has_focus() => state.theme.line_select_bg,
_ => if selected {
if frame.has_focus() {
state.theme.select_bg
} else {
state.theme.unfocus_select_bg
}
} else if line_selected && frame.has_focus() {
Some(None) if line_selected && frame.has_focus() => {
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
.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())) {
Some(Action::Move(dir, false, false, false)) => {
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(),
_ => return Err(event),
}

View file

@ -1,6 +1,6 @@
use super::*;
use crate::state::{Buffer, BufferId, CursorId};
use std::{fs, path::PathBuf};
use std::{cmp::Reverse, fs, path::PathBuf};
pub struct Prompt {
buffer: Buffer,
@ -345,15 +345,21 @@ impl Opener {
// TODO
self.options.set_options(options, |e| {
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) {
// Special-case: the 'new file' entry always matches last
Some((3, name.chars().count()))
Some((1000, 0, 0))
} else if name == filter {
Some((0, name.chars().count()))
Some((0, modify_time, name.chars().count()))
} else if name.starts_with(&filter) {
Some((1, name.chars().count()))
Some((1, modify_time, name.chars().count()))
} else if name.contains(&filter) {
Some((2, name.chars().count()))
Some((2, modify_time, name.chars().count()))
} else {
None
}