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};
#[derive(Clone, Debug)]
@ -11,15 +11,19 @@ pub enum Dir {
#[derive(Clone, Debug)]
pub enum Action {
Char(char), // Insert a character
Backspace, // Backspace a character
Move(Dir), // Move the cursor
PaneMove(Dir), // Move panes
Cancel, // Cancels the current context
Go, // Search, accept, or select the current option
Quit, // Quit the application
OpenPrompt, // Open the command prompt
Show(String), // Display some arbitrary text to the user
Char(char), // Insert a character
Backspace, // Backspace a character
Move(Dir), // Move the cursor
PaneMove(Dir), // Move panes
Cancel, // Cancels the current context
Go, // Search, accept, or select the current option
Yes, // A binary confirmation is answered 'yes'
No, // A binary confirmation is answered 'no'
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 {
@ -117,8 +121,8 @@ impl RawEvent {
)
}
pub fn is_prompt(&self) -> bool {
matches!(
pub fn to_open(&self) -> Option<Action> {
if matches!(
&self.0,
TerminalEvent::Key(KeyEvent {
code: KeyCode::Enter,
@ -126,11 +130,49 @@ impl RawEvent {
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 {
matches!(
pub fn to_go(&self) -> Option<Action> {
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,
TerminalEvent::Key(KeyEvent {
code: KeyCode::Esc,
@ -138,6 +180,18 @@ impl RawEvent {
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 {

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));
for col in 0..edge[0] {
self.get_mut([col, 0]).map(|c| {
@ -112,6 +112,19 @@ impl<'a> Rect<'a> {
c.c = theme.bottom_right;
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)))
}

View file

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

View file

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

View file

@ -14,7 +14,8 @@ impl Doc {
pub fn new(state: &mut State, buffer: BufferId) -> Self {
Self {
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_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)) => {
buffer.insert(cursor.pos, c);
Ok(Resp::handled(None))
@ -70,6 +80,19 @@ pub enum Pane {
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 {
selected: usize,
panes: Vec<Pane>,
@ -127,9 +150,11 @@ impl Visual for Panes {
} else {
&state.theme.border
};
// Draw pane contents
frame
.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(|frame| match pane {
Pane::Doc(doc) => doc.render(state, frame),

View file

@ -1,24 +1,7 @@
use super::*;
use crate::state::BufferId;
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 input: Input,
}
@ -26,15 +9,23 @@ pub struct Prompt {
impl Prompt {
pub fn get_action(&self) -> Option<Action> {
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!(
"{} {}",
env!("CARGO_PKG_NAME"),
env!("CARGO_PKG_VERSION")
))),
"help" => Some(Action::Show(format!(
"Temporary help info:\n- quit\n- version\n- help"
"?" | "help" => Some(Action::Show(format!(
"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,
}
}
@ -42,18 +33,8 @@ impl Prompt {
impl Element<CanEnd> for Prompt {
fn handle(&mut self, state: &mut State, event: Event) -> Result<Resp<CanEnd>, Event> {
match event.to_action(|e| {
if e.is_cancel() {
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)),
match event.to_action(|e| e.to_go().or_else(|| e.to_cancel())) {
Some(Action::Cancel) => Ok(Resp::end(None)),
Some(Action::Go) => {
if let Some(action) = self.get_action() {
Ok(Resp::end(action))
@ -81,13 +62,7 @@ pub struct Show {
impl Element<CanEnd> for Show {
fn handle(&mut self, state: &mut State, event: Event) -> Result<Resp<CanEnd>, Event> {
match event.to_action(|e| {
if e.is_cancel() {
Some(Action::Cancel)
} else {
None
}
}) {
match event.to_action(|e| e.to_cancel()) {
Some(Action::Cancel) => Ok(Resp::end(None)),
_ => Err(event),
}
@ -114,18 +89,11 @@ pub struct Confirm {
impl Element<CanEnd> for Confirm {
fn handle(&mut self, state: &mut State, event: Event) -> Result<Resp<CanEnd>, Event> {
match event.to_action(|e| {
if e.is_cancel() || e.to_char() == Some('n') {
Some(Action::Cancel)
} else if e.to_char() == Some('y') {
Some(Action::Go)
} else {
None
}
}) {
Some(Action::Go) => Ok(Resp::end(Some(self.action.clone()))),
Some(Action::Cancel) => Ok(Resp::end(None)),
_ => Err(event),
match event.to_action(|e| e.to_yes().or_else(|| e.to_no()).or_else(|| e.to_cancel())) {
Some(Action::Yes) => Ok(Resp::end(Some(self.action.clone()))),
Some(Action::No | Action::Cancel) => Ok(Resp::end(None)),
// All other events get swallowed
_ => Ok(Resp::handled(None)),
}
}
}
@ -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),
Show(Show),
Confirm(Confirm),
Switcher(Switcher),
}
impl Root {
@ -33,15 +34,7 @@ impl Element<CanEnd> for Root {
None => {
break match self.panes.handle(state, event) {
Ok(resp) => resp.action,
Err(event) => event.to_action(|e| {
if e.is_prompt() {
Some(Action::OpenPrompt)
} else if e.is_cancel() {
Some(Action::Cancel)
} else {
None
}
}),
Err(event) => event.to_action(|e| e.to_open().or_else(|| e.to_cancel())),
};
}
};
@ -50,6 +43,7 @@ impl Element<CanEnd> for Root {
Task::Prompt(p) => p.handle(state, event),
Task::Show(s) => s.handle(state, event),
Task::Confirm(c) => c.handle(state, event),
Task::Switcher(s) => s.handle(state, event),
};
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 {
label: Label("Are you sure you wish to quit? (y/n)".to_string()),
action: Action::Quit,
})),
Action::Show(text) => self.tasks.push(Task::Show(Show { label: Label(text) })),
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) {
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
frame
.rect([0, frame.size()[1].saturating_sub(3)], [frame.size()[0], 3])
.with_border(if task_has_focus {
&state.theme.focus_border
} else {
&state.theme.border
})
.with_border(
if task_has_focus {
&state.theme.focus_border
} else {
&state.theme.border
},
Some("Prompt (press alt + enter)"),
)
.with(|frame| {
if let Some(Task::Prompt(p)) = self.tasks.last() {
p.render(state, frame);
@ -120,9 +129,10 @@ impl Visual for Root {
if let Some(task) = self.tasks.last() {
match task {
Task::Prompt(_) => {} // Prompt isn't rendered, it's always rendered above
Task::Show(s) => s.render(state, frame),
Task::Confirm(c) => c.render(state, frame),
_ => {}
Task::Switcher(s) => s.render(state, frame),
}
}
}