Added buffer switching

This commit is contained in:
Joshua Barretto 2025-06-06 10:30:36 +01:00
parent 8c0a033f3c
commit ebc4d97dbc
8 changed files with 232 additions and 91 deletions

View file

@ -1,4 +1,4 @@
use crate::{state::State, terminal::TerminalEvent}; use crate::{state::BufferId, terminal::TerminalEvent};
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
@ -11,15 +11,19 @@ pub enum Dir {
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub enum Action { pub enum Action {
Char(char), // Insert a character Char(char), // Insert a character
Backspace, // Backspace a character Backspace, // Backspace a character
Move(Dir), // Move the cursor Move(Dir), // Move the cursor
PaneMove(Dir), // Move panes PaneMove(Dir), // Move panes
Cancel, // Cancels the current context Cancel, // Cancels the current context
Go, // Search, accept, or select the current option Go, // Search, accept, or select the current option
Quit, // Quit the application Yes, // A binary confirmation is answered 'yes'
OpenPrompt, // Open the command prompt No, // A binary confirmation is answered 'no'
Show(String), // Display some arbitrary text to the user Quit, // Quit the application
OpenPrompt, // Open the command prompt
OpenSwitcher, // Open the buffer switcher
Show(String), // Display some arbitrary text to the user
SwitchBuffer(BufferId), // Switch the current pane to the given buffer
} }
pub enum Event { pub enum Event {
@ -117,8 +121,8 @@ impl RawEvent {
) )
} }
pub fn is_prompt(&self) -> bool { pub fn to_open(&self) -> Option<Action> {
matches!( if matches!(
&self.0, &self.0,
TerminalEvent::Key(KeyEvent { TerminalEvent::Key(KeyEvent {
code: KeyCode::Enter, code: KeyCode::Enter,
@ -126,11 +130,49 @@ impl RawEvent {
kind: KeyEventKind::Press, kind: KeyEventKind::Press,
.. ..
}) })
) ) {
Some(Action::OpenPrompt)
} else if matches!(
&self.0,
TerminalEvent::Key(KeyEvent {
code: KeyCode::Char('b'),
modifiers: KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
..
})
) {
Some(Action::OpenSwitcher)
} else {
None
}
} }
pub fn is_cancel(&self) -> bool { pub fn to_go(&self) -> Option<Action> {
matches!( if matches!(
&self.0,
TerminalEvent::Key(KeyEvent {
code: KeyCode::Enter,
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Press,
..
})
) {
Some(Action::Go)
} else {
None
}
}
pub fn to_yes(&self) -> Option<Action> {
if matches!(self.to_char(), Some('y' | 'Y')) {
Some(Action::Yes)
} else {
None
}
}
pub fn to_cancel(&self) -> Option<Action> {
if matches!(
&self.0, &self.0,
TerminalEvent::Key(KeyEvent { TerminalEvent::Key(KeyEvent {
code: KeyCode::Esc, code: KeyCode::Esc,
@ -138,6 +180,18 @@ impl RawEvent {
kind: KeyEventKind::Press, kind: KeyEventKind::Press,
.. ..
}) })
) ) {
Some(Action::Cancel)
} else {
None
}
}
pub fn to_no(&self) -> Option<Action> {
if matches!(self.to_char(), Some('n' | 'N')) {
Some(Action::No)
} else {
None
}
} }
} }

View file

@ -45,6 +45,14 @@ impl Buffer {
} }
}); });
} }
pub fn begin_session(&mut self) -> CursorId {
self.cursors.insert(Cursor::default())
}
pub fn end_session(&mut self, cursor: CursorId) {
self.cursors.remove(cursor);
}
} }
pub struct State { pub struct State {

View file

@ -74,7 +74,7 @@ impl<'a> Rect<'a> {
} }
} }
pub fn with_border(&mut self, theme: &theme::BorderTheme) -> Rect { pub fn with_border(&mut self, theme: &theme::BorderTheme, title: Option<&str>) -> Rect {
let edge = self.size().map(|e| e.saturating_sub(1)); let edge = self.size().map(|e| e.saturating_sub(1));
for col in 0..edge[0] { for col in 0..edge[0] {
self.get_mut([col, 0]).map(|c| { self.get_mut([col, 0]).map(|c| {
@ -112,6 +112,19 @@ impl<'a> Rect<'a> {
c.c = theme.bottom_right; c.c = theme.bottom_right;
c.fg = theme.fg; c.fg = theme.fg;
}); });
if let Some(title) = title {
for (i, c) in [theme.join_right, ' ']
.into_iter()
.chain(title.chars())
.chain([' ', theme.join_left])
.enumerate()
{
self.get_mut([2 + i, 0]).map(|cell| {
cell.fg = theme.fg;
cell.c = c
});
}
}
self.rect([1, 1], self.size().map(|e| e.saturating_sub(2))) self.rect([1, 1], self.size().map(|e| e.saturating_sub(2)))
} }

View file

@ -9,6 +9,8 @@ pub struct BorderTheme {
pub top_right: char, pub top_right: char,
pub bottom_left: char, pub bottom_left: char,
pub bottom_right: char, pub bottom_right: char,
pub join_left: char,
pub join_right: char,
pub fg: Color, pub fg: Color,
} }
@ -23,6 +25,8 @@ impl Default for BorderTheme {
top_right: '', top_right: '',
bottom_left: '', bottom_left: '',
bottom_right: '', bottom_right: '',
join_left: '',
join_right: '',
fg: Color::DarkGrey, fg: Color::DarkGrey,
} }
} }
@ -30,7 +34,7 @@ impl Default for BorderTheme {
pub struct Theme { pub struct Theme {
pub ui_bg: Color, pub ui_bg: Color,
pub status_bg: Color, pub select_bg: Color,
pub border: BorderTheme, pub border: BorderTheme,
pub focus_border: BorderTheme, pub focus_border: BorderTheme,
} }
@ -39,7 +43,7 @@ impl Default for Theme {
fn default() -> Self { fn default() -> Self {
Self { Self {
ui_bg: Color::AnsiValue(235), ui_bg: Color::AnsiValue(235),
status_bg: Color::AnsiValue(23), select_bg: Color::AnsiValue(23),
border: BorderTheme::default(), border: BorderTheme::default(),
focus_border: BorderTheme { focus_border: BorderTheme {
fg: Color::White, fg: Color::White,

View file

@ -7,7 +7,7 @@ mod status;
pub use self::{ pub use self::{
input::Input, input::Input,
panes::Panes, panes::Panes,
prompt::{Confirm, Prompt, Show}, prompt::{Confirm, Prompt, Show, Switcher},
root::Root, root::Root,
status::Status, status::Status,
}; };

View file

@ -14,7 +14,8 @@ impl Doc {
pub fn new(state: &mut State, buffer: BufferId) -> Self { pub fn new(state: &mut State, buffer: BufferId) -> Self {
Self { Self {
buffer, buffer,
cursor: state.buffers[buffer].cursors.insert(Cursor::default()), // TODO: Don't index directly
cursor: state.buffers[buffer].begin_session(),
} }
} }
} }
@ -34,6 +35,15 @@ impl Element for Doc {
.or_else(|| e.to_move().map(Action::Move)) .or_else(|| e.to_move().map(Action::Move))
.or_else(|| e.to_pane_move().map(Action::PaneMove)) .or_else(|| e.to_pane_move().map(Action::PaneMove))
}) { }) {
Some(Action::SwitchBuffer(new_buffer)) => {
buffer.end_session(self.cursor);
self.buffer = new_buffer;
let Some(buffer) = state.buffers.get_mut(self.buffer) else {
return Err(event);
};
self.cursor = buffer.begin_session();
Ok(Resp::handled(None))
}
Some(Action::Char(c)) => { Some(Action::Char(c)) => {
buffer.insert(cursor.pos, c); buffer.insert(cursor.pos, c);
Ok(Resp::handled(None)) Ok(Resp::handled(None))
@ -70,6 +80,19 @@ pub enum Pane {
Doc(Doc), Doc(Doc),
} }
impl Pane {
fn title(&self, state: &State) -> Option<String> {
match self {
Self::Doc(doc) => {
let Some(buffer) = state.buffers.get(doc.buffer) else {
return None;
};
Some(buffer.path.display().to_string())
}
}
}
}
pub struct Panes { pub struct Panes {
selected: usize, selected: usize,
panes: Vec<Pane>, panes: Vec<Pane>,
@ -127,9 +150,11 @@ impl Visual for Panes {
} else { } else {
&state.theme.border &state.theme.border
}; };
// Draw pane contents
frame frame
.rect([x0, 0], [x1 - x0, frame.size()[1]]) .rect([x0, 0], [x1 - x0, frame.size()[1]])
.with_border(border_theme) .with_border(border_theme, pane.title(state).as_deref())
.with_focus(is_selected) .with_focus(is_selected)
.with(|frame| match pane { .with(|frame| match pane {
Pane::Doc(doc) => doc.render(state, frame), Pane::Doc(doc) => doc.render(state, frame),

View file

@ -1,24 +1,7 @@
use super::*; use super::*;
use crate::state::BufferId;
use std::str::FromStr; use std::str::FromStr;
pub enum Command {
Quit,
Help,
Version,
}
impl FromStr for Command {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"q" | "quit" => Ok(Command::Quit),
"version" => Ok(Command::Version),
"?" | "help" => Ok(Command::Help),
_ => Err(()),
}
}
}
pub struct Prompt { pub struct Prompt {
pub input: Input, pub input: Input,
} }
@ -26,15 +9,23 @@ pub struct Prompt {
impl Prompt { impl Prompt {
pub fn get_action(&self) -> Option<Action> { pub fn get_action(&self) -> Option<Action> {
match self.input.get_text().as_str() { match self.input.get_text().as_str() {
"quit" => Some(Action::Quit), // The root sees 'cancel' as an initiator for quitting
"q" | "quit" => Some(Action::Cancel),
"version" => Some(Action::Show(format!( "version" => Some(Action::Show(format!(
"{} {}", "{} {}",
env!("CARGO_PKG_NAME"), env!("CARGO_PKG_NAME"),
env!("CARGO_PKG_VERSION") env!("CARGO_PKG_VERSION")
))), ))),
"help" => Some(Action::Show(format!( "?" | "help" => Some(Action::Show(format!(
"Temporary help info:\n- quit\n- version\n- help" "Temporary help info:\n\
- quit\n\
- version\n\
- pane_move_left\n\
- pane_move_right\n\
- help"
))), ))),
"pane_move_left" => Some(Action::PaneMove(Dir::Left)),
"pane_move_right" => Some(Action::PaneMove(Dir::Right)),
_ => None, _ => None,
} }
} }
@ -42,18 +33,8 @@ impl Prompt {
impl Element<CanEnd> for Prompt { impl Element<CanEnd> for Prompt {
fn handle(&mut self, state: &mut State, event: Event) -> Result<Resp<CanEnd>, Event> { fn handle(&mut self, state: &mut State, event: Event) -> Result<Resp<CanEnd>, Event> {
match event.to_action(|e| { match event.to_action(|e| e.to_go().or_else(|| e.to_cancel())) {
if e.is_cancel() { Some(Action::Cancel) => Ok(Resp::end(None)),
Some(Action::Cancel)
} else if e.is_go() {
Some(Action::Go)
} else if e.is_prompt() {
Some(Action::OpenPrompt)
} else {
None
}
}) {
Some(Action::Cancel /*| Action::Prompt*/) => Ok(Resp::end(None)),
Some(Action::Go) => { Some(Action::Go) => {
if let Some(action) = self.get_action() { if let Some(action) = self.get_action() {
Ok(Resp::end(action)) Ok(Resp::end(action))
@ -81,13 +62,7 @@ pub struct Show {
impl Element<CanEnd> for Show { impl Element<CanEnd> for Show {
fn handle(&mut self, state: &mut State, event: Event) -> Result<Resp<CanEnd>, Event> { fn handle(&mut self, state: &mut State, event: Event) -> Result<Resp<CanEnd>, Event> {
match event.to_action(|e| { match event.to_action(|e| e.to_cancel()) {
if e.is_cancel() {
Some(Action::Cancel)
} else {
None
}
}) {
Some(Action::Cancel) => Ok(Resp::end(None)), Some(Action::Cancel) => Ok(Resp::end(None)),
_ => Err(event), _ => Err(event),
} }
@ -114,18 +89,11 @@ pub struct Confirm {
impl Element<CanEnd> for Confirm { impl Element<CanEnd> for Confirm {
fn handle(&mut self, state: &mut State, event: Event) -> Result<Resp<CanEnd>, Event> { fn handle(&mut self, state: &mut State, event: Event) -> Result<Resp<CanEnd>, Event> {
match event.to_action(|e| { match event.to_action(|e| e.to_yes().or_else(|| e.to_no()).or_else(|| e.to_cancel())) {
if e.is_cancel() || e.to_char() == Some('n') { Some(Action::Yes) => Ok(Resp::end(Some(self.action.clone()))),
Some(Action::Cancel) Some(Action::No | Action::Cancel) => Ok(Resp::end(None)),
} else if e.to_char() == Some('y') { // All other events get swallowed
Some(Action::Go) _ => Ok(Resp::handled(None)),
} else {
None
}
}) {
Some(Action::Go) => Ok(Resp::end(Some(self.action.clone()))),
Some(Action::Cancel) => Ok(Resp::end(None)),
_ => Err(event),
} }
} }
} }
@ -142,3 +110,62 @@ impl Visual for Confirm {
); );
} }
} }
pub struct Switcher {
pub selected: usize,
pub options: Vec<BufferId>,
}
impl Element<CanEnd> for Switcher {
fn handle(&mut self, state: &mut State, event: Event) -> Result<Resp<CanEnd>, Event> {
match event.to_action(|e| {
e.to_cancel()
.or_else(|| e.to_go())
.or_else(|| e.to_move().map(Action::Move))
}) {
Some(Action::Move(Dir::Up)) => {
self.selected = (self.selected + self.options.len() - 1) % self.options.len();
Ok(Resp::handled(None))
}
Some(Action::Move(Dir::Down)) => {
self.selected = (self.selected + 1) % self.options.len();
Ok(Resp::handled(None))
}
Some(Action::Go) => Ok(Resp::end(
if let Some(buffer) = self.options.get(self.selected) {
Some(Action::SwitchBuffer(*buffer))
} else {
None
},
)),
Some(Action::Cancel) => Ok(Resp::end(None)),
// All other events get swallowed
_ => Ok(Resp::handled(None)),
}
}
}
impl Visual for Switcher {
fn render(&self, state: &State, frame: &mut Rect) {
for (i, buffer) in self.options.iter().enumerate() {
let Some(buffer) = state.buffers.get(*buffer) else {
continue;
};
frame
.rect(
[
0,
frame.size()[1].saturating_sub(3 + self.options.len()) + i,
],
[frame.size()[0], 1],
)
.with_bg(if self.selected == i {
state.theme.select_bg
} else {
state.theme.ui_bg
})
.fill(' ')
.text([0, 0], buffer.path.display().to_string().chars());
}
}
}

View file

@ -11,6 +11,7 @@ pub enum Task {
Prompt(Prompt), Prompt(Prompt),
Show(Show), Show(Show),
Confirm(Confirm), Confirm(Confirm),
Switcher(Switcher),
} }
impl Root { impl Root {
@ -33,15 +34,7 @@ impl Element<CanEnd> for Root {
None => { None => {
break match self.panes.handle(state, event) { break match self.panes.handle(state, event) {
Ok(resp) => resp.action, Ok(resp) => resp.action,
Err(event) => event.to_action(|e| { Err(event) => event.to_action(|e| e.to_open().or_else(|| e.to_cancel())),
if e.is_prompt() {
Some(Action::OpenPrompt)
} else if e.is_cancel() {
Some(Action::Cancel)
} else {
None
}
}),
}; };
} }
}; };
@ -50,6 +43,7 @@ impl Element<CanEnd> for Root {
Task::Prompt(p) => p.handle(state, event), Task::Prompt(p) => p.handle(state, event),
Task::Show(s) => s.handle(state, event), Task::Show(s) => s.handle(state, event),
Task::Confirm(c) => c.handle(state, event), Task::Confirm(c) => c.handle(state, event),
Task::Switcher(s) => s.handle(state, event),
}; };
match res { match res {
@ -76,13 +70,25 @@ impl Element<CanEnd> for Root {
}, },
})); }));
} }
Action::OpenSwitcher => {
self.tasks.clear(); // Prompt overrides all
self.tasks.push(Task::Switcher(Switcher {
selected: 0,
options: state.buffers.keys().collect(),
}));
}
Action::Cancel => self.tasks.push(Task::Confirm(Confirm { Action::Cancel => self.tasks.push(Task::Confirm(Confirm {
label: Label("Are you sure you wish to quit? (y/n)".to_string()), label: Label("Are you sure you wish to quit? (y/n)".to_string()),
action: Action::Quit, action: Action::Quit,
})), })),
Action::Show(text) => self.tasks.push(Task::Show(Show { label: Label(text) })), Action::Show(text) => self.tasks.push(Task::Show(Show { label: Label(text) })),
Action::Quit => return Ok(Resp::end(None)), Action::Quit => return Ok(Resp::end(None)),
action => todo!("Unhandled action {action:?}"), action => {
return self
.panes
.handle(state, Event::Action(action))
.map(|r| r.into_can_end());
}
} }
} }
@ -95,16 +101,19 @@ impl Visual for Root {
fn render(&self, state: &State, frame: &mut Rect) { fn render(&self, state: &State, frame: &mut Rect) {
frame.fill(' '); frame.fill(' ');
let task_has_focus = self.tasks.last().is_some(); let task_has_focus = matches!(self.tasks.last(), Some(Task::Prompt(_)));
// Display status bar // Display status bar
frame frame
.rect([0, frame.size()[1].saturating_sub(3)], [frame.size()[0], 3]) .rect([0, frame.size()[1].saturating_sub(3)], [frame.size()[0], 3])
.with_border(if task_has_focus { .with_border(
&state.theme.focus_border if task_has_focus {
} else { &state.theme.focus_border
&state.theme.border } else {
}) &state.theme.border
},
Some("Prompt (press alt + enter)"),
)
.with(|frame| { .with(|frame| {
if let Some(Task::Prompt(p)) = self.tasks.last() { if let Some(Task::Prompt(p)) = self.tasks.last() {
p.render(state, frame); p.render(state, frame);
@ -120,9 +129,10 @@ impl Visual for Root {
if let Some(task) = self.tasks.last() { if let Some(task) = self.tasks.last() {
match task { match task {
Task::Prompt(_) => {} // Prompt isn't rendered, it's always rendered above
Task::Show(s) => s.render(state, frame), Task::Show(s) => s.render(state, frame),
Task::Confirm(c) => c.render(state, frame), Task::Confirm(c) => c.render(state, frame),
_ => {} Task::Switcher(s) => s.render(state, frame),
} }
} }
} }