Added opener

This commit is contained in:
Joshua Barretto 2025-06-11 23:44:03 +01:00
parent 58a8c5c9e5
commit d352d04030
9 changed files with 315 additions and 61 deletions

View file

@ -2,17 +2,17 @@
## Features
- [ ] Buffers
- [ ] Buffer switching
- [ ] Prompt
- [ ] Cursor selection
- [ ] Basic cursor movement
- [ ] Multiple panes
- [ ] Pane creation/deletion
- [x] Buffers
- [x] Buffer switching
- [x] Prompt
- [x] Cursor selection
- [x] Basic cursor movement
- [x] Multiple panes
- [x] Pane creation/deletion
- [x] Opener
## Todo
- [ ] Opener
- [ ] Find
- [ ] Replace
- [ ] Project search

View file

@ -1,5 +1,6 @@
use crate::{state::BufferId, terminal::TerminalEvent};
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use std::path::PathBuf;
#[derive(Clone, Debug)]
pub enum Dir {
@ -24,9 +25,11 @@ pub enum Action {
No, // A binary confirmation is answered 'no'
Quit, // Quit the application
OpenPrompt, // Open the command prompt
OpenSwitcher, // Open the buffer switcher
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
SwitchBuffer(BufferId), // Switch the current pane to the given buffer
OpenFile(PathBuf), // Open the file and switch the current pane to it
}
#[derive(Debug)]
@ -201,6 +204,22 @@ impl RawEvent {
}
}
pub fn to_open_opener(&self, path: PathBuf) -> Option<Action> {
if matches!(
&self.0,
TerminalEvent::Key(KeyEvent {
code: KeyCode::Char('o'),
modifiers: KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
..
})
) {
Some(Action::OpenOpener(path))
} else {
None
}
}
pub fn to_go(&self) -> Option<Action> {
if matches!(
&self.0,

View file

@ -1,6 +1,10 @@
use crate::{Args, Dir, Error, theme};
use slotmap::{HopSlotMap, new_key_type};
use std::{io, ops::Range, path::PathBuf};
use std::{
io,
ops::Range,
path::{Path, PathBuf},
};
new_key_type! {
pub struct BufferId;
@ -103,6 +107,7 @@ impl Text {
#[derive(Default)]
pub struct Buffer {
pub dir: Option<PathBuf>,
pub path: Option<PathBuf>,
pub text: Text,
pub cursors: HopSlotMap<CursorId, Cursor>,
@ -110,19 +115,35 @@ pub struct Buffer {
impl Buffer {
pub fn from_file(path: PathBuf) -> Result<Self, Error> {
let chars = match std::fs::read_to_string(&path) {
Ok(s) => s.chars().collect(),
let (dir, chars) = match std::fs::read_to_string(&path) {
Ok(s) => {
let mut path = path.canonicalize()?;
path.pop();
(Some(path), s.chars().collect())
}
// If the file doesn't exist, create a new file
Err(err) if err.kind() == io::ErrorKind::NotFound => Vec::new(),
Err(err) if err.kind() == io::ErrorKind::NotFound => {
(path.parent().map(Path::to_owned), Vec::new())
}
Err(err) => return Err(err.into()),
};
Ok(Self {
dir,
path: Some(path),
text: Text { chars },
cursors: HopSlotMap::default(),
})
}
pub fn name(&self) -> Option<&str> {
Some(
match self.path.as_ref()?.file_name().and_then(|n| n.to_str()) {
Some(name) => name,
None => "<error>",
},
)
}
pub fn clear(&mut self) {
self.text.chars.clear();
// Reset cursors
@ -183,28 +204,34 @@ impl Buffer {
}
}
pub fn insert(&mut self, pos: usize, c: char) {
self.text.chars.insert(pos.min(self.text.chars.len()), c);
pub fn insert(&mut self, pos: usize, chars: impl IntoIterator<Item = char>) {
let mut n = 0;
for c in chars {
self.text
.chars
.insert((pos + n).min(self.text.chars.len()), c);
n += 1;
}
self.cursors.values_mut().for_each(|cursor| {
if cursor.base >= pos {
cursor.base += 1;
cursor.base += n;
}
if cursor.pos >= pos {
cursor.pos += 1;
cursor.pos += n;
cursor.reset_desired_col(&self.text);
}
});
}
pub fn enter(&mut self, cursor_id: CursorId, c: char) {
pub fn enter(&mut self, cursor_id: CursorId, chars: impl IntoIterator<Item = char>) {
let Some(cursor) = self.cursors.get(cursor_id) else {
return;
};
if let Some(selection) = cursor.selection() {
self.remove(selection);
self.enter(cursor_id, c);
self.enter(cursor_id, chars);
} else {
self.insert(cursor.pos, c);
self.insert(cursor.pos, chars);
}
}

View file

@ -42,6 +42,9 @@ pub struct Theme {
pub focus_border: BorderTheme,
pub text: Color,
pub whitespace: Color,
pub option_dir: Color,
pub option_file: Color,
pub option_new: Color,
}
impl Default for Theme {
@ -59,6 +62,9 @@ impl Default for Theme {
},
text: Color::Reset,
whitespace: Color::AnsiValue(245),
option_dir: Color::AnsiValue(178),
option_file: Color::Reset,
option_new: Color::AnsiValue(148),
}
}
}

View file

@ -1,9 +1,9 @@
use super::*;
use crate::{
state::{BufferId, CursorId},
state::{Buffer, BufferId, CursorId},
terminal::CursorStyle,
};
use std::collections::HashMap;
use std::{collections::HashMap, path::PathBuf};
#[derive(Clone)]
pub struct Doc {
@ -25,13 +25,6 @@ impl Doc {
}
}
pub fn title(&self, state: &State) -> Option<String> {
let Some(buffer) = state.buffers.get(self.buffer) else {
return None;
};
Some(buffer.path.as_ref()?.display().to_string())
}
pub fn close(self, state: &mut State) {
for (buffer, cursor) in self.cursors {
let Some(buffer) = state.buffers.get_mut(buffer) else {
@ -40,6 +33,19 @@ impl Doc {
buffer.end_session(cursor);
}
}
fn switch_buffer(&mut self, state: &mut State, buffer: BufferId) {
self.buffer = buffer;
let Some(buffer) = state.buffers.get_mut(self.buffer) else {
return;
};
// Start a new cursor session for this buffer if one doesn't exist
let cursor_id = *self
.cursors
.entry(self.buffer)
.or_insert_with(|| buffer.start_session());
self.input.refocus(buffer, cursor_id);
}
}
impl Element for Doc {
@ -48,21 +54,25 @@ impl Element for Doc {
return Err(event);
};
match event.to_action(|e| e.to_open_switcher()) {
let open_path = buffer.dir.to_owned().unwrap_or(PathBuf::from("/"));
match event.to_action(|e| e.to_open_switcher().or_else(|| e.to_open_opener(open_path))) {
action @ Some(Action::OpenSwitcher) => Ok(Resp::handled(action.map(Into::into))),
action @ Some(Action::OpenOpener(_)) => Ok(Resp::handled(action.map(Into::into))),
Some(Action::SwitchBuffer(new_buffer)) => {
self.buffer = new_buffer;
let Some(buffer) = state.buffers.get_mut(self.buffer) else {
return Err(event);
};
// Start a new cursor session for this buffer if one doesn't exist
let cursor_id = *self
.cursors
.entry(self.buffer)
.or_insert_with(|| buffer.start_session());
self.input.refocus(buffer, cursor_id);
self.switch_buffer(state, new_buffer);
Ok(Resp::handled(None))
}
Some(Action::OpenFile(path)) => match Buffer::from_file(path) {
Ok(buffer) => {
let buffer_id = state.buffers.insert(buffer);
self.switch_buffer(state, buffer_id);
Ok(Resp::handled(None))
}
Err(err) => Ok(Resp::handled(Some(
Action::Show(Some(format!("Could not open file")), format!("{err}")).into(),
))),
},
_ => {
let Some(buffer) = state.buffers.get_mut(self.buffer) else {
return Err(event);

View file

@ -9,6 +9,7 @@ enum Mode {
#[default]
Doc,
Prompt,
Filter,
}
#[derive(Clone, Default)]
@ -28,6 +29,13 @@ impl Input {
}
}
pub fn filter() -> Self {
Self {
mode: Mode::Filter,
..Self::default()
}
}
pub fn refocus(&mut self, buffer: &mut Buffer, cursor_id: CursorId) {
let Some(cursor) = buffer.cursors.get(cursor_id) else {
return;
@ -54,7 +62,7 @@ impl Input {
} else if c == '\x7F' {
buffer.delete(cursor_id);
} else {
buffer.enter(cursor_id, c);
buffer.enter(cursor_id, [c]);
}
self.refocus(buffer, cursor_id);
Ok(Resp::handled(None))
@ -80,11 +88,7 @@ impl Input {
cursor_id: CursorId,
frame: &mut Rect,
) {
let title = if let Some(path) = &buffer.path {
Some(path.display().to_string())
} else {
None
};
let title = buffer.name();
// Add frame
let mut frame = frame.with_border(
@ -104,6 +108,7 @@ impl Input {
let line_num_w = buffer.text.lines().count().max(1).ilog10() as usize + 1;
let margin_w = match self.mode {
Mode::Prompt => 2,
Mode::Filter => 0,
Mode::Doc => line_num_w + 2,
};
@ -125,6 +130,7 @@ impl Input {
{
// Margin
match self.mode {
Mode::Filter => frame.rect([0, 0], frame.size()),
Mode::Prompt => frame
.rect([0, i], [1, 1])
.with_bg(state.theme.margin_bg)

View file

@ -9,7 +9,7 @@ pub use self::{
doc::Doc,
input::Input,
panes::Panes,
prompt::{Confirm, Prompt, Show, Switcher},
prompt::{Confirm, Opener, Prompt, Show, Switcher},
root::Root,
status::Status,
};
@ -120,6 +120,15 @@ impl<T> Options<T> {
}
}
pub fn set_options<F: FnMut(&T) -> Option<u32>>(
&mut self,
options: impl IntoIterator<Item = T>,
mut f: F,
) {
self.options = options.into_iter().collect();
self.apply_scoring(f);
}
pub fn apply_scoring<F: FnMut(&T) -> Option<u32>>(&mut self, mut f: F) {
let mut ranking = self
.options
@ -127,8 +136,9 @@ impl<T> Options<T> {
.enumerate()
.filter_map(|(i, o)| Some((i, f(o)?)))
.collect::<Vec<_>>();
ranking.sort_by_key(|(_, score)| *score);
ranking.sort_by_key(|(_, score)| std::cmp::Reverse(*score));
self.ranking = ranking.into_iter().map(|(i, _)| i).collect();
self.selected = 0;
}
pub fn requested_height(&self) -> usize {
@ -136,7 +146,7 @@ impl<T> Options<T> {
}
}
impl<T> Element<T> for Options<T> {
impl<T: Clone> Element<T> for Options<T> {
fn handle(&mut self, state: &mut State, event: Event) -> Result<Resp<T>, Event> {
match event.to_action(|e| e.to_go().or_else(|| e.to_move())) {
Some(Action::Move(Dir::Up, false, _)) => {
@ -150,7 +160,7 @@ impl<T> Element<T> for Options<T> {
Some(Action::Go) => {
if self.selected < self.ranking.len() {
Ok(Resp::end_with(
self.options.remove(self.ranking[self.selected]),
self.options[self.ranking[self.selected]].clone(),
None,
))
} else {

View file

@ -1,5 +1,6 @@
use super::*;
use crate::state::{Buffer, BufferId, CursorId};
use std::{fs, path::PathBuf};
pub struct Prompt {
buffer: Buffer,
@ -188,7 +189,7 @@ impl Switcher {
options: Options::new(buffers),
cursor_id: buffer.start_session(),
buffer,
input: Input::prompt(),
input: Input::filter(),
}
}
@ -215,10 +216,13 @@ impl Element<()> for Switcher {
let Some(buffer) = state.buffers.get(*b) else {
return None;
};
match buffer.path.as_ref() {
Some(path) if path.display().to_string().contains(&filter) => Some(1),
Some(_) => None,
None => Some(0),
let name = buffer.name()?;
if name.starts_with(&filter) {
Some(2)
} else if name.contains(&filter) {
Some(1)
} else {
None
}
});
res
@ -244,10 +248,174 @@ impl Visual for BufferId {
let Some(buffer) = state.buffers.get(*self) else {
return;
};
let buffer_name = match &buffer.path {
Some(path) => path.display().to_string(),
None => format!("<Untitled>"),
};
frame.text([0, 0], buffer_name.chars());
frame.text([0, 0], buffer.name().unwrap_or("<unknown>").chars());
}
}
pub struct Opener {
pub options: Options<FileOption>,
// Filter
pub buffer: Buffer,
pub cursor_id: CursorId,
pub input: Input,
}
impl Opener {
pub fn new(path: PathBuf) -> Self {
let mut buffer = Buffer::default();
let cursor_id = buffer.start_session();
match path.display().to_string().as_str() {
s @ "/" => buffer.enter(cursor_id, s.chars()),
s => buffer.enter(cursor_id, s.chars().chain(['/'])),
}
let mut this = Self {
options: Options::new([]),
cursor_id,
buffer,
input: Input::filter(),
};
this.update_completions();
this
}
pub fn requested_height(&self) -> usize {
self.options.requested_height() + 3
}
fn set_string(&mut self, s: &str) {
self.buffer.clear();
self.buffer.enter(self.cursor_id, s.chars());
self.update_completions();
}
fn update_completions(&mut self) {
let path_str = self.buffer.text.to_string();
let (dir, filter) = match path_str.rsplit_once('/') {
Some(("", filter)) => ("/", filter),
Some((dir, filter)) => (dir, filter),
None => ("/", path_str.as_str()),
};
let filter = filter.to_lowercase();
match fs::read_dir(dir) {
Ok(entries) => {
let options = entries
.filter_map(|e| e.ok())
.filter_map(|entry| {
Some(FileOption {
path: entry.path(),
kind: if entry.file_type().ok()?.is_dir() {
FileKind::Dir
} else if entry.file_type().ok()?.is_file() {
FileKind::File
} else {
FileKind::Unknown
},
is_link: entry.file_type().ok()?.is_symlink(),
})
})
.chain([FileOption {
path: [dir, &filter].into_iter().collect(),
kind: FileKind::New,
is_link: false,
}]);
// TODO
self.options.set_options(options, |e| {
let name = e.path.file_name()?.to_str()?.to_lowercase();
if matches!(e.kind, FileKind::New) {
// Special-case: the 'new file' entry always matches last
Some(0)
} else if name == filter {
Some(3)
} else if name.starts_with(&filter) {
Some(2)
} else if name.contains(&filter) {
Some(1)
} else {
None
}
})
}
Err(err) => self.options.set_options(Vec::new(), |_| None),
}
}
}
impl Element<()> for Opener {
fn handle(&mut self, state: &mut State, event: Event) -> Result<Resp<()>, Event> {
let path_str = self.buffer.text.to_string();
match event.to_action(|e| e.to_cancel().or_else(|| e.to_char().map(Action::Char))) {
Some(Action::Cancel) => Ok(Resp::end(None)),
// Backspace removes the entire path segment!
Some(Action::Char('\x08')) if path_str.ends_with("/") => {
if path_str != "/" {
self.set_string(
path_str
.trim_end_matches("/")
.trim_end_matches(|c| c != '/'),
);
}
Ok(Resp::handled(None))
}
_ => match self.options.handle(state, event).map(Resp::into_ended) {
// Selecting a directory enters the directory
Ok(Some(file)) if matches!(file.kind, FileKind::Dir) => {
self.set_string(&format!("{}/", file.path.display()));
Ok(Resp::handled(None))
}
Ok(Some(file)) => Ok(Resp::end(Some(Action::OpenFile(file.path).into()))),
Ok(None) => Ok(Resp::handled(None)),
Err(event) => {
let res = self
.input
.handle(&mut self.buffer, self.cursor_id, event)
.map(Resp::into_can_end);
self.update_completions();
res
}
},
}
}
}
#[derive(Copy, Clone)]
enum FileKind {
Unknown,
Dir,
File,
New,
}
#[derive(Clone)]
pub struct FileOption {
pub path: PathBuf,
pub kind: FileKind,
pub is_link: bool,
}
impl Visual for FileOption {
fn render(&mut self, state: &State, frame: &mut Rect) {
let name = match self.path.file_name().and_then(|n| n.to_str()) {
Some(name) if matches!(self.kind, FileKind::Dir) => format!("{}/", name),
Some(name) => name.to_string(),
None => format!("<unknown>"),
};
frame
.with_fg(match self.kind {
FileKind::Dir => state.theme.option_dir,
FileKind::File | FileKind::Unknown => state.theme.option_file,
FileKind::New => state.theme.option_new,
})
.text([0, 0], name.chars());
}
}
impl Visual for Opener {
fn render(&mut self, state: &State, frame: &mut Rect) {
frame
.rect([0, 0], [frame.size()[0], frame.size()[1].saturating_sub(3)])
.with(|f| self.options.render(state, f));
frame
.rect([0, frame.size()[1].saturating_sub(3)], [frame.size()[0], 3])
.with(|f| self.input.render(state, &self.buffer, self.cursor_id, f));
}
}

View file

@ -12,6 +12,7 @@ pub enum Task {
Show(Show),
Confirm(Confirm),
Switcher(Switcher),
Opener(Opener),
}
impl Task {
@ -21,6 +22,7 @@ impl Task {
Self::Show(s) => s.requested_height(),
Self::Confirm(c) => c.requested_height(),
Self::Switcher(s) => s.requested_height(),
Self::Opener(o) => o.requested_height(),
}
}
}
@ -55,6 +57,7 @@ impl Element<()> for Root {
Task::Show(s) => s.handle(state, event),
Task::Confirm(c) => c.handle(state, event),
Task::Switcher(s) => s.handle(state, event),
Task::Opener(o) => o.handle(state, event),
};
match res {
@ -83,10 +86,14 @@ impl Element<()> for Root {
self.tasks.push(Task::Prompt(Prompt::new()));
}
Action::OpenSwitcher => {
self.tasks.clear(); // Prompt overrides all
self.tasks.clear(); // Overrides all
self.tasks
.push(Task::Switcher(Switcher::new(state.buffers.keys())));
}
Action::OpenOpener(path) => {
self.tasks.clear(); // Overrides all
self.tasks.push(Task::Opener(Opener::new(path)));
}
Action::Cancel => self.tasks.push(Task::Confirm(Confirm {
label: Label("Are you sure you wish to quit? (y/n)".to_string()),
action: Action::Quit,
@ -132,6 +139,7 @@ impl Visual for Root {
Task::Show(s) => s.render(state, frame),
Task::Confirm(c) => c.render(state, frame),
Task::Switcher(s) => s.render(state, frame),
Task::Opener(o) => o.render(state, frame),
});
}