Unify prompt and doc input

This commit is contained in:
Joshua Barretto 2025-06-08 22:08:36 +01:00
parent 84056b5a74
commit 67dac456af
9 changed files with 337 additions and 292 deletions

View file

@ -17,7 +17,8 @@ pub enum Action {
PaneMove(Dir), // Move panes
PaneOpen(Dir), // Create a new pane
PaneClose, // Close the current pane
Cancel, // Cancels the current context
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'
@ -234,6 +235,22 @@ impl RawEvent {
}
}
pub fn to_continue(&self) -> Option<Action> {
if matches!(
&self.0,
TerminalEvent::Key(KeyEvent {
code: KeyCode::Esc | KeyCode::Enter | KeyCode::Char(' '),
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Press,
..
})
) {
Some(Action::Continue)
} else {
None
}
}
pub fn to_no(&self) -> Option<Action> {
if matches!(self.to_char(), Some('n' | 'N')) {
Some(Action::No)

View file

@ -1,8 +1,4 @@
use crate::{
Action, Args, Color, Dir, Error, Event, theme,
ui::{self, Element as _, Resp},
};
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use crate::{Args, Dir, Error, theme};
use slotmap::{HopSlotMap, new_key_type};
use std::{io, ops::Range, path::PathBuf};
@ -33,10 +29,17 @@ impl Cursor {
}
}
#[derive(Default)]
pub struct Text {
chars: Vec<char>,
}
impl ToString for Text {
fn to_string(&self) -> String {
self.chars.iter().copied().collect()
}
}
impl Text {
pub fn to_coord(&self, pos: usize) -> [isize; 2] {
let mut n = 0;
@ -53,7 +56,7 @@ impl Text {
[(pos - last_n) as isize, i.saturating_sub(1) as isize]
}
pub fn to_pos(&self, mut coord: [isize; 2]) -> usize {
pub fn to_pos(&self, coord: [isize; 2]) -> usize {
if coord[1] < 0 {
return 0;
}
@ -98,14 +101,15 @@ impl Text {
}
}
#[derive(Default)]
pub struct Buffer {
pub path: PathBuf,
pub path: Option<PathBuf>,
pub text: Text,
pub cursors: HopSlotMap<CursorId, Cursor>,
}
impl Buffer {
pub fn new(path: PathBuf) -> Result<Self, Error> {
pub fn from_file(path: PathBuf) -> Result<Self, Error> {
let chars = match std::fs::read_to_string(&path) {
Ok(s) => s.chars().collect(),
// If the file doesn't exist, create a new file
@ -113,7 +117,7 @@ impl Buffer {
Err(err) => return Err(err.into()),
};
Ok(Self {
path,
path: Some(path),
text: Text { chars },
cursors: HopSlotMap::default(),
})
@ -139,7 +143,7 @@ impl Buffer {
cursor.reset_desired_col(&self.text);
}
Dir::Up => {
let mut coord = self.text.to_coord(cursor.pos);
let coord = self.text.to_coord(cursor.pos);
// Special case: pressing 'up' at the top of the screen resets the cursor to the beginning
if coord[1] <= 0 {
cursor.pos = 0;
@ -151,7 +155,7 @@ impl Buffer {
}
}
Dir::Down => {
let mut coord = self.text.to_coord(cursor.pos);
let coord = self.text.to_coord(cursor.pos);
cursor.pos = self
.text
.to_pos([cursor.desired_col, coord[1] + dist[1] as isize]);
@ -258,7 +262,7 @@ impl TryFrom<Args> for State {
};
for path in args.paths {
this.buffers.insert(Buffer::new(path)?);
this.buffers.insert(Buffer::from_file(path)?);
}
Ok(this)

85
src/ui/doc.rs Normal file
View file

@ -0,0 +1,85 @@
use super::*;
use crate::{
state::{BufferId, CursorId},
terminal::CursorStyle,
};
use std::collections::HashMap;
#[derive(Clone)]
pub struct Doc {
buffer: BufferId,
// Remember the cursor we use for each buffer
cursors: HashMap<BufferId, CursorId>,
input: Input,
}
impl Doc {
pub fn new(state: &mut State, buffer: BufferId) -> Self {
Self {
buffer,
// TODO: Don't index directly
cursors: [(buffer, state.buffers[buffer].start_session())]
.into_iter()
.collect(),
input: Input::default(),
}
}
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 {
continue;
};
buffer.end_session(cursor);
}
}
}
impl Element for Doc {
fn handle(&mut self, state: &mut State, event: Event) -> Result<Resp, Event> {
let Some(buffer) = state.buffers.get_mut(self.buffer) else {
return Err(event);
};
match event.to_action(|e| e.to_open_switcher()) {
action @ Some(Action::OpenSwitcher) => Ok(Resp::handled(action)),
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);
Ok(Resp::handled(None))
}
_ => {
let Some(buffer) = state.buffers.get_mut(self.buffer) else {
return Err(event);
};
let cursor_id = self.cursors[&self.buffer];
self.input.handle(buffer, cursor_id, event)
}
}
}
}
impl Visual for Doc {
fn render(&mut self, state: &State, frame: &mut Rect) {
let Some(buffer) = state.buffers.get(self.buffer) else {
return;
};
let cursor_id = self.cursors[&self.buffer];
self.input.render(state, buffer, cursor_id, frame);
}
}

View file

@ -1,59 +1,193 @@
use super::*;
use crate::terminal::CursorStyle;
use crate::{
state::{Buffer, CursorId},
terminal::CursorStyle,
};
#[derive(Default)]
#[derive(Copy, Clone, Default)]
enum Mode {
#[default]
Doc,
Prompt,
}
#[derive(Clone, Default)]
pub struct Input {
pub text: Vec<char>,
pub cursor: usize,
pub preamble: &'static str,
pub mode: Mode,
// x/y location in the buffer that the pane is trying to focus on
pub focus: [isize; 2],
// Remember the last known size for things like scrolling
pub last_size: [usize; 2],
}
impl Input {
pub fn get_text(&self) -> String {
self.text.iter().copied().collect()
pub fn prompt() -> Self {
Self {
mode: Mode::Prompt,
..Self::default()
}
}
}
impl Element for Input {
fn handle(&mut self, state: &mut State, event: Event) -> Result<Resp, Event> {
pub fn refocus(&mut self, buffer: &mut Buffer, cursor_id: CursorId) {
let Some(cursor) = buffer.cursors.get(cursor_id) else {
return;
};
let cursor_coord = buffer.text.to_coord(cursor.pos);
for i in 0..2 {
self.focus[i] = self.focus[i].clamp(
cursor_coord[i] - self.last_size[i] as isize + 1,
cursor_coord[i],
);
}
}
pub fn handle(
&mut self,
buffer: &mut Buffer,
cursor_id: CursorId,
event: Event,
) -> Result<Resp, Event> {
match event.to_action(|e| e.to_char().map(Action::Char).or_else(|| e.to_move())) {
Some(Action::Char('\x08')) => {
self.cursor = self.cursor.saturating_sub(1);
if self.text.len() > self.cursor {
self.text.remove(self.cursor);
}
Ok(Resp::handled(None))
}
Some(Action::Char(c)) => {
self.text.insert(self.cursor, c);
self.cursor += 1;
if c == '\x08' {
buffer.backspace(cursor_id);
} else if c == '\x7F' {
buffer.delete(cursor_id);
} else {
buffer.enter(cursor_id, c);
}
self.refocus(buffer, cursor_id);
Ok(Resp::handled(None))
}
Some(Action::Move(Dir::Left, _, _)) => {
self.cursor = self.cursor.saturating_sub(1);
Ok(Resp::handled(None))
}
Some(Action::Move(Dir::Right, _, _)) => {
self.cursor = (self.cursor + 1).min(self.text.len());
Some(Action::Move(dir, page, retain_base)) => {
let dist = if page {
self.last_size.map(|s| s.saturating_sub(3).max(1))
} else {
[1, 1]
};
buffer.move_cursor(cursor_id, dir, dist, retain_base);
self.refocus(buffer, cursor_id);
Ok(Resp::handled(None))
}
_ => Err(event),
}
}
}
impl Visual for Input {
fn render(&mut self, state: &State, frame: &mut Rect) {
frame.with(|frame| {
frame.fill(' ');
frame.text([0, 0], self.preamble.chars());
pub fn render(
&mut self,
state: &State,
buffer: &Buffer,
cursor_id: CursorId,
frame: &mut Rect,
) {
let title = if let Some(path) = &buffer.path {
Some(path.display().to_string())
} else {
None
};
frame
.rect([self.preamble.chars().count(), 0], frame.size())
.with(|frame| {
frame.text([0, 0], &self.text);
frame.set_cursor([self.cursor as isize, 0], CursorStyle::BlinkingBar);
});
});
// Add frame
let mut frame = frame.with_border(
if frame.has_focus() {
&state.theme.focus_border
} else {
&state.theme.border
},
title.as_deref(),
);
let Some(cursor) = buffer.cursors.get(cursor_id) else {
return;
};
let cursor_coord = buffer.text.to_coord(cursor.pos);
let line_num_w = buffer.text.lines().count().max(1).ilog10() as usize + 1;
let margin_w = match self.mode {
Mode::Prompt => 2,
Mode::Doc => line_num_w + 2,
};
self.last_size = [frame.size()[0].saturating_sub(margin_w), frame.size()[1]];
let mut pos = 0;
for (i, (line_num, (line_pos, line))) in buffer
.text
.lines()
.map(move |line| {
let line_pos = pos;
pos += line.len();
(line_pos, line)
})
.enumerate()
.skip(self.focus[1].max(0) as usize)
.enumerate()
.take(frame.size()[1])
{
// Margin
match self.mode {
Mode::Prompt => frame
.rect([0, i], [1, 1])
.with_bg(state.theme.margin_bg)
.with_fg(state.theme.margin_line_num)
.fill(' ')
.text([0, 0], ">".chars()),
Mode::Doc => frame
.rect([0, i], [margin_w, 1])
.with_bg(state.theme.margin_bg)
.with_fg(state.theme.margin_line_num)
.fill(' ')
.text([1, 0], format!("{:>line_num_w$}", line_num + 1).chars()),
};
// Line
{
let mut frame = frame.rect([margin_w, i], [!0, 1]);
for i in 0..frame.size()[0] {
let coord = self.focus[0] + i as isize;
if (0..line.len() as isize).contains(&coord) {
let pos = line_pos + coord as usize;
let selected = cursor.selection().map_or(false, |s| s.contains(&pos));
let (fg, c) = match line[coord as usize] {
'\n' if selected => (state.theme.whitespace, '⮠'),
c => (state.theme.text, c),
};
frame
.with_bg(if selected {
state.theme.select_bg
} else {
Color::Reset
})
.with_fg(fg)
.text([i as isize, 0], &[c]);
}
}
// Set cursor position
if cursor_coord[1] == line_num as isize {
frame.set_cursor(
[cursor_coord[0] - self.focus[0], 0],
CursorStyle::BlinkingBar,
);
}
}
pos += line.len();
}
}
}
// impl Visual for Input {
// fn render(&mut self, state: &State, frame: &mut Rect) {
// frame.with(|frame| {
// frame.fill(' ');
// frame.text([0, 0], self.preamble.chars());
// frame
// .rect([self.preamble.chars().count(), 0], frame.size())
// .with(|frame| {
// frame.text([0, 0], &self.text);
// frame.set_cursor([self.cursor as isize, 0], CursorStyle::BlinkingBar);
// });
// });
// }
// }

View file

@ -1,3 +1,4 @@
mod doc;
mod input;
mod panes;
mod prompt;
@ -5,6 +6,7 @@ mod root;
mod status;
pub use self::{
doc::Doc,
input::Input,
panes::Panes,
prompt::{Confirm, Prompt, Show, Switcher},

View file

@ -1,186 +1,5 @@
use super::*;
use crate::{
state::{Buffer, BufferId, Cursor, CursorId},
terminal::CursorStyle,
};
use std::collections::HashMap;
#[derive(Clone)]
pub struct Doc {
buffer: BufferId,
// Remember the cursor we use for each buffer
cursors: HashMap<BufferId, CursorId>,
// x/y location in the buffer that the pane is trying to focus on
focus: [isize; 2],
// Remember the last known size for things like scrolling
last_size: [usize; 2],
}
impl Doc {
pub fn new(state: &mut State, buffer: BufferId) -> Self {
Self {
buffer,
// TODO: Don't index directly
cursors: [(buffer, state.buffers[buffer].start_session())]
.into_iter()
.collect(),
focus: [0, 0],
last_size: [1, 1],
}
}
fn refocus(&mut self, state: &mut State) {
let Some(buffer) = state.buffers.get_mut(self.buffer) else {
return;
};
let Some(cursor) = buffer.cursors.get(self.cursors[&self.buffer]) else {
return;
};
let cursor_coord = buffer.text.to_coord(cursor.pos);
for i in 0..2 {
self.focus[i] = self.focus[i].clamp(
cursor_coord[i] - self.last_size[i] as isize + 1,
cursor_coord[i],
);
}
}
pub fn close(self, state: &mut State) {
for (buffer, cursor) in self.cursors {
let Some(buffer) = state.buffers.get_mut(buffer) else {
continue;
};
buffer.end_session(cursor);
}
}
}
impl Element for Doc {
fn handle(&mut self, state: &mut State, event: Event) -> Result<Resp, Event> {
let Some(buffer) = state.buffers.get_mut(self.buffer) else {
return Err(event);
};
match event.to_action(|e| {
e.to_char()
.map(Action::Char)
.or_else(|| e.to_move())
.or_else(|| e.to_pane_move().map(Action::PaneMove))
.or_else(|| e.to_open_switcher())
}) {
action @ Some(Action::OpenSwitcher) => Ok(Resp::handled(action)),
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
self.cursors
.entry(self.buffer)
.or_insert_with(|| buffer.start_session());
self.refocus(state);
Ok(Resp::handled(None))
}
Some(Action::Char(c)) => {
let cursor_id = self.cursors[&self.buffer];
if c == '\x08' {
buffer.backspace(cursor_id);
} else if c == '\x7F' {
buffer.delete(cursor_id);
} else {
buffer.enter(cursor_id, c);
}
self.refocus(state);
Ok(Resp::handled(None))
}
Some(Action::Move(dir, page, retain_base)) => {
let dist = if page {
self.last_size.map(|s| s.saturating_sub(3).max(1))
} else {
[1, 1]
};
buffer.move_cursor(self.cursors[&self.buffer], dir, dist, retain_base);
self.refocus(state);
Ok(Resp::handled(None))
}
_ => Err(event),
}
}
}
impl Visual for Doc {
fn render(&mut self, state: &State, frame: &mut Rect) {
let Some(buffer) = state.buffers.get(self.buffer) else {
return;
};
let Some(cursor) = buffer.cursors.get(self.cursors[&self.buffer]) else {
return;
};
let cursor_coord = buffer.text.to_coord(cursor.pos);
let line_num_w = buffer.text.lines().count().max(1).ilog10() as usize + 1;
let margin_w = line_num_w + 2;
self.last_size = [frame.size()[0].saturating_sub(margin_w), frame.size()[1]];
let mut pos = 0;
for (i, (line_num, (line_pos, line))) in buffer
.text
.lines()
.map(move |line| {
let line_pos = pos;
pos += line.len();
(line_pos, line)
})
.enumerate()
.skip(self.focus[1].max(0) as usize)
.enumerate()
.take(frame.size()[1])
{
// Margin
frame
.rect([0, i], [margin_w, 1])
.with_bg(state.theme.margin_bg)
.with_fg(state.theme.margin_line_num)
.fill(' ')
.text([1, 0], format!("{:>line_num_w$}", line_num + 1).chars());
// Line
{
let mut frame = frame.rect([margin_w, i], [!0, 1]);
for i in 0..frame.size()[0] {
let coord = self.focus[0] + i as isize;
if (0..line.len() as isize).contains(&coord) {
let pos = line_pos + coord as usize;
let selected = cursor.selection().map_or(false, |s| s.contains(&pos));
let (fg, c) = match line[coord as usize] {
'\n' if selected => (state.theme.whitespace, '⮠'),
c => (state.theme.text, c),
};
frame
.with_bg(if selected {
state.theme.select_bg
} else {
Color::Reset
})
.with_fg(fg)
.text([i as isize, 0], &[c]);
}
}
// Set cursor position
if cursor_coord[1] == line_num as isize {
frame.set_cursor(
[cursor_coord[0] - self.focus[0], 0],
CursorStyle::BlinkingBar,
);
}
}
pos += line.len();
}
}
}
use crate::state::BufferId;
#[derive(Clone)]
pub enum Pane {
@ -188,20 +7,6 @@ pub enum Pane {
Doc(Doc),
}
impl Pane {
fn title(&self, state: &State) -> Option<String> {
match self {
Self::Empty => None,
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>,
@ -262,7 +67,7 @@ impl Element for Panes {
Ok(Resp::handled(None))
}
// Pass anything else through to the active pane
err => {
_ => {
if let Some(pane) = self.panes.get_mut(self.selected) {
// Pass to pane
match pane {
@ -287,18 +92,10 @@ impl Visual for Panes {
for (i, pane) in self.panes.iter_mut().enumerate() {
let (x0, x1) = (boundary(i), boundary(i + 1));
let is_selected = self.selected == i;
let border_theme = if frame.has_focus() && is_selected {
&state.theme.focus_border
} else {
&state.theme.border
};
// Draw pane contents
frame
.rect([x0, 0], [x1 - x0, frame.size()[1]])
.with_border(border_theme, pane.title(state).as_deref())
.with_focus(is_selected)
.with_focus(self.selected == i)
.with(|frame| match pane {
Pane::Empty => {}
Pane::Doc(doc) => doc.render(state, frame),

View file

@ -1,14 +1,24 @@
use super::*;
use crate::state::BufferId;
use std::str::FromStr;
use crate::state::{Buffer, BufferId, CursorId};
pub struct Prompt {
pub input: Input,
buffer: Buffer,
cursor_id: CursorId,
input: Input,
}
impl Prompt {
pub fn new() -> Self {
let mut buffer = Buffer::default();
Self {
cursor_id: buffer.start_session(),
buffer,
input: Input::prompt(),
}
}
pub fn get_action(&self) -> Option<Action> {
match self.input.get_text().as_str() {
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!(
@ -41,18 +51,21 @@ impl Element<CanEnd> for Prompt {
} else {
Ok(Resp::end(Action::Show(format!(
"unknown command `{}`",
self.input.get_text()
self.buffer.text.to_string()
))))
}
}
_ => self.input.handle(state, event).map(Resp::into_can_end),
_ => self
.input
.handle(&mut self.buffer, self.cursor_id, event)
.map(Resp::into_can_end),
}
}
}
impl Visual for Prompt {
fn render(&mut self, state: &State, frame: &mut Rect) {
frame.with(|f| self.input.render(state, f));
frame.with(|f| self.input.render(state, &self.buffer, self.cursor_id, f));
}
}
@ -62,9 +75,9 @@ 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| e.to_cancel()) {
Some(Action::Cancel) => Ok(Resp::end(None)),
_ => Err(event),
match event.to_action(|e| e.to_continue()) {
Some(Action::Continue) => Ok(Resp::end(None)),
_ => Ok(Resp::handled(None)),
}
}
}
@ -147,6 +160,10 @@ impl Visual for Switcher {
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(
[
@ -161,7 +178,7 @@ impl Visual for Switcher {
state.theme.ui_bg
})
.fill(' ')
.text([0, 0], buffer.path.display().to_string().chars());
.text([0, 0], buffer_name.chars());
}
}
}

View file

@ -65,12 +65,7 @@ impl Element<CanEnd> for Root {
match action {
Action::OpenPrompt => {
self.tasks.clear(); // Prompt overrides all
self.tasks.push(Task::Prompt(Prompt {
input: Input {
preamble: "> ",
..Input::default()
},
}));
self.tasks.push(Task::Prompt(Prompt::new()));
}
Action::OpenSwitcher => {
self.tasks.clear(); // Prompt overrides all
@ -106,24 +101,20 @@ impl Visual for Root {
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
},
Some("Prompt (press alt + enter)"),
)
.with(|frame| {
if let Some(Task::Prompt(p)) = self.tasks.last_mut() {
p.render(state, frame);
}
});
let status_size = if let Some(Task::Prompt(p)) = 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
};
frame
.rect([0, 0], [frame.size()[0], frame.size()[1].saturating_sub(3)])
.rect(
[0, 0],
[frame.size()[0], frame.size()[1].saturating_sub(status_size)],
)
.with_focus(!task_has_focus)
.with(|frame| {
self.panes.render(state, frame);

View file

@ -1,3 +1 @@
use super::*;
pub struct Status;