Better switcher behaviour

This commit is contained in:
Joshua Barretto 2025-06-09 23:42:08 +01:00
parent 0bd78521e3
commit 7aafdaa90f
8 changed files with 337 additions and 155 deletions

View file

@ -11,22 +11,22 @@ pub enum Dir {
#[derive(Clone, Debug)]
pub enum Action {
Char(char), // Insert a character
Backspace, // Backspace a character
Move(Dir, bool, bool), // Move the cursor (dir, page, retain_base)
PaneMove(Dir), // Move panes
PaneOpen(Dir), // Create a new pane
PaneClose, // Close the current pane
Cancel, // Cancels the current action
Continue, // Continue past an info-only element (like a help screen)
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
Char(char), // Insert a character
Backspace, // Backspace a character
Move(Dir, bool, bool), // Move the cursor (dir, page, retain_base)
PaneMove(Dir), // Move panes
PaneOpen(Dir), // Create a new pane
PaneClose, // Close the current pane
Cancel, // Cancels the current action
Continue, // Continue past an info-only element (like a help screen)
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(Option<String>, String), // Display an optionally titled informational text box to the user
SwitchBuffer(BufferId), // Switch the current pane to the given buffer
}
#[derive(Debug)]
@ -37,6 +37,12 @@ pub enum Event {
Raw(RawEvent),
}
impl From<Action> for Event {
fn from(action: Action) -> Self {
Self::Action(action)
}
}
impl Event {
pub fn from_raw(e: TerminalEvent) -> Self {
Self::Raw(RawEvent(e))

View file

@ -50,7 +50,7 @@ fn main() -> Result<(), Error> {
// Have the UI handle events
if ui
.handle(&mut state, Event::from_raw(ev))
.map_or(false, |r| r.should_end())
.map_or(false, |r| r.into_ended().is_some())
{
return Ok(());
}

View file

@ -35,6 +35,7 @@ impl Default for BorderTheme {
pub struct Theme {
pub ui_bg: Color,
pub select_bg: Color,
pub unfocus_select_bg: Color,
pub margin_bg: Color,
pub margin_line_num: Color,
pub border: BorderTheme,
@ -48,6 +49,7 @@ impl Default for Theme {
Self {
ui_bg: Color::AnsiValue(235),
select_bg: Color::AnsiValue(23),
unfocus_select_bg: Color::AnsiValue(240),
margin_bg: Color::Reset,
margin_line_num: Color::AnsiValue(245),
border: BorderTheme::default(),

View file

@ -49,7 +49,7 @@ impl Element for Doc {
};
match event.to_action(|e| e.to_open_switcher()) {
action @ Some(Action::OpenSwitcher) => Ok(Resp::handled(action)),
action @ Some(Action::OpenSwitcher) => 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 {

View file

@ -152,10 +152,12 @@ impl Input {
c => (state.theme.text, c),
};
frame
.with_bg(if selected {
.with_bg(if !selected {
Color::Reset
} else if frame.has_focus() {
state.theme.select_bg
} else {
Color::Reset
state.theme.unfocus_select_bg
})
.with_fg(fg)
.text([i as isize, 0], &[c]);

View file

@ -20,42 +20,51 @@ use crate::{
};
pub enum CannotEnd {}
pub struct CanEnd;
pub struct Resp<CanEnd = CannotEnd> {
should_end: Option<CanEnd>,
pub action: Option<Action>,
pub struct Resp<End = CannotEnd> {
ended: Option<End>,
pub event: Option<Event>,
}
impl Resp<CanEnd> {
pub fn end(action: impl Into<Option<Action>>) -> Self {
Self {
should_end: Some(CanEnd),
action: action.into(),
}
}
pub fn should_end(&self) -> bool {
self.should_end.is_some()
}
}
impl<T> Resp<T> {
pub fn handled(action: impl Into<Option<Action>>) -> Self {
Self {
should_end: None,
action: action.into(),
}
}
pub fn into_can_end(self) -> Resp<CanEnd> {
impl Resp<CannotEnd> {
pub fn into_can_end<End>(self) -> Resp<End> {
Resp {
should_end: None,
action: self.action,
ended: None,
event: self.event,
}
}
}
impl<End> Resp<End> {
pub fn end(event: Option<Event>) -> Self
where
End: Default,
{
Self::end_with(Default::default(), event)
}
pub fn end_with(end: End, event: Option<Event>) -> Self {
Self {
ended: Some(end),
event: event.into(),
}
}
pub fn handled(event: Option<Event>) -> Self {
Self {
ended: None,
event: event.into(),
}
}
pub fn is_end(&self) -> bool {
self.ended.is_some()
}
pub fn into_ended(mut self) -> Option<End> {
self.ended
}
}
pub trait Element<CanEnd = CannotEnd> {
/// Attempt to handle an event.
///
@ -77,12 +86,104 @@ impl std::ops::Deref for Label {
}
}
impl Label {
pub fn requested_height(&self) -> usize {
self.0.lines().count()
}
}
impl Visual for Label {
fn render(&mut self, state: &State, frame: &mut Rect) {
frame.with_bg(state.theme.ui_bg).fill(' ').with(|frame| {
frame.with(|frame| {
for (idx, line) in self.lines().enumerate() {
frame.text([0, idx as isize], line.chars());
}
});
}
}
/// List selection
pub struct Options<T> {
pub selected: usize,
// (score, option)
pub options: Vec<T>,
pub ranking: Vec<usize>,
}
impl<T> Options<T> {
pub fn new(options: impl IntoIterator<Item = T>) -> Self {
let (ranking, options) = options.into_iter().enumerate().unzip();
Self {
selected: 0,
options,
ranking,
}
}
pub fn apply_scoring<F: FnMut(&T) -> Option<u32>>(&mut self, mut f: F) {
let mut ranking = self
.options
.iter()
.enumerate()
.filter_map(|(i, o)| Some((i, f(o)?)))
.collect::<Vec<_>>();
ranking.sort_by_key(|(_, score)| *score);
self.ranking = ranking.into_iter().map(|(i, _)| i).collect();
}
pub fn requested_height(&self) -> usize {
2 + self.ranking.len()
}
}
impl<T> 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, _)) => {
self.selected = (self.selected + self.ranking.len() - 1) % self.ranking.len();
Ok(Resp::handled(None))
}
Some(Action::Move(Dir::Down, false, _)) => {
self.selected = (self.selected + 1) % self.ranking.len();
Ok(Resp::handled(None))
}
Some(Action::Go) => {
if self.selected < self.ranking.len() {
Ok(Resp::end_with(
self.options.remove(self.ranking[self.selected]),
None,
))
} else {
Err(event)
}
}
_ => Err(event),
}
}
}
impl<T: Visual> Visual for Options<T> {
fn render(&mut self, state: &State, frame: &mut Rect) {
let mut frame = frame.with_border(
if frame.has_focus() {
&state.theme.focus_border
} else {
&state.theme.border
},
None,
);
for (i, idx) in self.ranking.iter().enumerate() {
let option = &mut self.options[*idx];
frame
.rect([0, i], [frame.size()[0], 1])
.with_bg(if self.selected == i {
state.theme.select_bg
} else {
Color::Reset
})
.fill(' ')
.with(|f| option.render(state, f));
}
}
}

View file

@ -21,39 +21,48 @@ impl Prompt {
match self.buffer.text.to_string().as_str() {
// 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\
"version" => Some(Action::Show(
Some(format!("Version")),
format!("{} {}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION")),
)),
"?" | "help" => Some(Action::Show(
Some(format!("Help")),
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,
}
}
pub fn requested_height(&self) -> usize {
3
}
}
impl Element<CanEnd> for Prompt {
fn handle(&mut self, state: &mut State, event: Event) -> Result<Resp<CanEnd>, Event> {
impl Element<()> for Prompt {
fn handle(&mut self, state: &mut State, event: Event) -> Result<Resp<()>, Event> {
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() {
self.buffer.clear();
Ok(Resp::handled(action))
Ok(Resp::end(Some(action.into())))
} else {
Ok(Resp::handled(Action::Show(format!(
"unknown command `{}`",
self.buffer.text.to_string()
))))
Ok(Resp::handled(Some(
Action::Show(
Some(format!("Error")),
format!("unknown command `{}`", self.buffer.text.to_string()),
)
.into(),
)))
}
}
_ => self
@ -71,30 +80,44 @@ impl Visual for Prompt {
}
pub struct Show {
pub title: Option<String>,
pub label: Label,
}
impl Element<CanEnd> for Show {
fn handle(&mut self, state: &mut State, event: Event) -> Result<Resp<CanEnd>, Event> {
impl Show {
pub fn requested_height(&self) -> usize {
self.label.requested_height() + 2
}
}
impl Element<()> for Show {
fn handle(&mut self, state: &mut State, event: Event) -> Result<Resp<()>, Event> {
match event.to_action(|e| {
e.to_cancel()
.or_else(|| e.to_continue())
.or_else(|| e.to_char().map(Action::Char))
}) {
// Shows cannot be cancelled, so pass the cancel along to the parent task
Some(Action::Cancel) => Ok(Resp::end(Some(Action::Cancel))),
Some(Action::Cancel) => Ok(Resp::end(Some(Action::Cancel.into()))),
// A continue ends the show
Some(Action::Continue) => Ok(Resp::end(None)),
// Pass attempts to type to the parent prompt task
// TODO: Don't assume that a `Show` is always the child of the prompt
Some(Action::Char(c)) => Ok(Resp::end(Some(Action::Char(c)))),
_ => Ok(Resp::handled(None)),
// All other events end the show and get passed to the parent
_ => Ok(Resp::end(Some(event))),
}
}
}
impl Visual for Show {
fn render(&mut self, state: &State, frame: &mut Rect) {
let mut frame = frame.with_border(
if frame.has_focus() {
&state.theme.focus_border
} else {
&state.theme.border
},
self.title.as_deref(),
);
let lines = self.label.lines().count();
self.label.render(
state,
@ -111,10 +134,16 @@ pub struct Confirm {
pub action: Action,
}
impl Element<CanEnd> for Confirm {
fn handle(&mut self, state: &mut State, event: Event) -> Result<Resp<CanEnd>, Event> {
impl Confirm {
pub fn requested_height(&self) -> usize {
self.label.requested_height() + 2
}
}
impl Element<()> for Confirm {
fn handle(&mut self, state: &mut State, event: Event) -> Result<Resp<()>, 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::Yes) => Ok(Resp::end(Some(self.action.clone().into()))),
Some(Action::No | Action::Cancel) => Ok(Resp::end(None)),
// All other events get swallowed
_ => Ok(Resp::handled(None)),
@ -124,6 +153,15 @@ impl Element<CanEnd> for Confirm {
impl Visual for Confirm {
fn render(&mut self, state: &State, frame: &mut Rect) {
let mut frame = frame.with_border(
if frame.has_focus() {
&state.theme.focus_border
} else {
&state.theme.border
},
Some("Question"),
);
let lines = self.label.lines().count();
self.label.render(
state,
@ -136,60 +174,80 @@ impl Visual for Confirm {
}
pub struct Switcher {
pub selected: usize,
pub options: Vec<BufferId>,
pub options: Options<BufferId>,
// Filter
pub buffer: Buffer,
pub cursor_id: CursorId,
pub input: Input,
}
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())) {
Some(Action::Move(Dir::Up, false, _)) => {
self.selected = (self.selected + self.options.len() - 1) % self.options.len();
Ok(Resp::handled(None))
}
Some(Action::Move(Dir::Down, false, _)) => {
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
},
)),
impl Switcher {
pub fn new(buffers: impl IntoIterator<Item = BufferId>) -> Self {
let mut buffer = Buffer::default();
Self {
options: Options::new(buffers),
cursor_id: buffer.start_session(),
buffer,
input: Input::prompt(),
}
}
pub fn requested_height(&self) -> usize {
self.options.requested_height() + 3
}
}
impl Element<()> for Switcher {
fn handle(&mut self, state: &mut State, event: Event) -> Result<Resp<()>, Event> {
match event.to_action(|e| e.to_cancel()) {
Some(Action::Cancel) => Ok(Resp::end(None)),
// All other events get swallowed
_ => Ok(Resp::handled(None)),
_ => match self.options.handle(state, event).map(Resp::into_ended) {
Ok(Some(buffer_id)) => Ok(Resp::end(Some(Action::SwitchBuffer(buffer_id).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);
// Score entries
let filter = self.buffer.text.to_string();
self.options.apply_scoring(|b| {
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),
}
});
res
}
},
}
}
}
impl Visual for Switcher {
fn render(&mut self, state: &State, frame: &mut Rect) {
for (i, buffer) in self.options.iter().enumerate() {
let Some(buffer) = state.buffers.get(*buffer) else {
continue;
};
let buffer_name = match &buffer.path {
Some(path) => path.display().to_string(),
None => format!("<Untitled>"),
};
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_name.chars());
}
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));
}
}
impl Visual for BufferId {
fn render(&mut self, state: &State, frame: &mut Rect) {
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());
}
}

View file

@ -14,6 +14,17 @@ pub enum Task {
Switcher(Switcher),
}
impl Task {
pub fn requested_height(&self) -> usize {
match self {
Self::Prompt(p) => p.requested_height(),
Self::Show(s) => s.requested_height(),
Self::Confirm(c) => c.requested_height(),
Self::Switcher(s) => s.requested_height(),
}
}
}
impl Root {
pub fn new(state: &mut State, buffers: &[BufferId]) -> Self {
Self {
@ -24,8 +35,8 @@ impl Root {
}
}
impl Element<CanEnd> for Root {
fn handle(&mut self, state: &mut State, mut event: Event) -> Result<Resp<CanEnd>, Event> {
impl Element<()> for Root {
fn handle(&mut self, state: &mut State, mut event: Event) -> Result<Resp<()>, Event> {
// Pass the event down through the list of tasks until we meet one that can handle it
let mut task_idx = self.tasks.len();
let action = loop {
@ -33,10 +44,8 @@ impl Element<CanEnd> for Root {
Some(task_idx) => task_idx,
None => {
break match self.panes.handle(state, event) {
Ok(resp) => resp.action,
Err(event) => {
event.to_action(|e| e.to_open_prompt().or_else(|| e.to_cancel()))
}
Ok(resp) => resp.event,
Err(event) => Some(event),
};
}
};
@ -51,21 +60,23 @@ impl Element<CanEnd> for Root {
match res {
Ok(resp) => {
// If the task has requested that it should end, kill it and all of its children
if resp.should_end() {
if resp.is_end() {
self.tasks.truncate(task_idx);
}
if let Some(action) = resp.action {
event = Event::Action(action);
event = if let Some(event) = resp.event {
event
} else {
break None;
}
};
}
Err(e) => event = e,
}
};
// Handle 'top-level' actions
if let Some(action) = action {
if let Some(action) =
action.and_then(|e| e.to_action(|e| e.to_open_prompt().or_else(|| e.to_cancel())))
{
match action {
Action::OpenPrompt => {
self.tasks.clear(); // Prompt overrides all
@ -73,16 +84,17 @@ 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(),
}));
self.tasks
.push(Task::Switcher(Switcher::new(state.buffers.keys())));
}
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::Show(title, text) => self.tasks.push(Task::Show(Show {
title,
label: Label(text),
})),
Action::Quit => return Ok(Resp::end(None)),
action => {
return self
@ -102,35 +114,36 @@ impl Visual for Root {
fn render(&mut self, state: &State, frame: &mut Rect) {
frame.fill(' ');
let task_has_focus = matches!(self.tasks.last(), Some(Task::Prompt(_)));
let task_has_focus = !self.tasks.is_empty();
// Display status bar
let status_size = if let Some(Task::Prompt(p)) = self.tasks.first_mut() {
// Determine how much space the active task should use
let task_h = self.tasks.last().map_or(0, |t| t.requested_height());
// Render active task
if let Some(task) = self.tasks.last_mut() {
frame
.rect([0, frame.size()[1].saturating_sub(3)], [frame.size()[0], 3])
.with(|frame| p.render(state, frame));
3
} else {
0
};
.rect(
[0, frame.size()[1].saturating_sub(task_h)],
[frame.size()[0], task_h],
)
.with_focus(task_has_focus)
.with(|frame| match task {
Task::Prompt(p) => p.render(state, frame),
Task::Show(s) => s.render(state, frame),
Task::Confirm(c) => c.render(state, frame),
Task::Switcher(s) => s.render(state, frame),
});
}
// Render panes
frame
.rect(
[0, 0],
[frame.size()[0], frame.size()[1].saturating_sub(status_size)],
[frame.size()[0], frame.size()[1].saturating_sub(task_h)],
)
.with_focus(!task_has_focus)
.with(|frame| {
self.panes.render(state, frame);
});
if let Some(task) = self.tasks.last_mut() {
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),
}
}
}
}