Added undo/redo support

This commit is contained in:
Joshua Barretto 2025-09-22 17:25:42 +01:00
parent 5925e37fba
commit 5169d5ae92
7 changed files with 244 additions and 55 deletions

View file

@ -42,6 +42,8 @@ pub enum Action {
SelectAll, // Fully select the entire input
Save, // Save the current buffer
Mouse(MouseAction, [isize; 2]),
Undo,
Redo,
}
/// How far should movement go?
@ -65,6 +67,8 @@ pub enum Event {
Action(Action),
// The incoming event is a raw user input.
Raw(RawEvent),
// A terminal bell ring
Bell,
}
impl From<Action> for Event {
@ -85,6 +89,7 @@ impl Event {
match self {
Self::Action(a) => Some(a.clone()),
Self::Raw(te) => translate(te),
Self::Bell => None,
}
}
}
@ -409,6 +414,24 @@ impl RawEvent {
}
}
pub fn to_undo_redo(&self) -> Option<Action> {
match &self.0 {
TerminalEvent::Key(KeyEvent {
code: KeyCode::Char('z'),
modifiers: KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
..
}) => Some(Action::Undo),
TerminalEvent::Key(KeyEvent {
code: KeyCode::Char('y'),
modifiers: KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
..
}) => Some(Action::Redo),
_ => None,
}
}
pub fn to_mouse(&self, area: Area) -> Option<Action> {
let TerminalEvent::Mouse(ev) = self.0 else {
return None;

View file

@ -48,11 +48,12 @@ fn main() -> Result<(), Error> {
}
// Have the UI handle events
if ui
.handle(&mut state, Event::from_raw(ev))
.map_or(false, |r| r.into_ended().is_some())
{
return Ok(());
match ui.handle(&mut state, Event::from_raw(ev)) {
Ok(r) if r.is_end() => return Ok(()),
Ok(_) => {}
Err(Event::Bell) => term.ring_bell(),
// Unhandled event!
Err(_) => {}
}
}
}

View file

@ -5,6 +5,7 @@ use crate::{
};
use slotmap::{HopSlotMap, new_key_type};
use std::{
collections::HashMap,
io,
ops::Range,
path::{Path, PathBuf},
@ -15,7 +16,7 @@ new_key_type! {
pub struct CursorId;
}
#[derive(Copy, Clone, Default)]
#[derive(Copy, Clone, Debug, Default)]
pub struct Cursor {
pub base: usize,
pub pos: usize,
@ -147,6 +148,31 @@ pub struct Buffer {
pub cursors: HopSlotMap<CursorId, Cursor>,
pub dir: Option<PathBuf>,
pub path: Option<PathBuf>,
pub undo: Vec<Change>,
pub redo: Vec<Change>,
}
pub struct Change {
kind: ChangeKind,
cursors: HashMap<CursorId, (Cursor, Cursor)>,
}
pub enum ChangeKind {
Insert(usize, Vec<char>),
Remove(usize, Vec<char>),
}
impl Change {
fn invert(mut self) -> Self {
self.kind = match self.kind {
ChangeKind::Insert(at, s) => ChangeKind::Remove(at, s),
ChangeKind::Remove(at, s) => ChangeKind::Insert(at, s),
};
for (from, to) in self.cursors.values_mut() {
core::mem::swap(from, to);
}
self
}
}
impl Buffer {
@ -175,6 +201,8 @@ impl Buffer {
cursors: HopSlotMap::default(),
dir,
path: Some(path),
undo: Vec::new(),
redo: Vec::new(),
})
}
@ -205,7 +233,7 @@ impl Buffer {
.map(|hl| hl.highlighter.highlight(self.text.chars()));
}
pub fn clear(&mut self) {
pub fn reset(&mut self) {
self.unsaved = true;
self.text.chars.clear();
@ -214,6 +242,7 @@ impl Buffer {
self.cursors.values_mut().for_each(|cursor| {
*cursor = Cursor::default();
});
self.undo = Vec::new();
}
pub fn goto_cursor(&mut self, cursor_id: CursorId, pos: [isize; 2]) {
@ -398,26 +427,149 @@ impl Buffer {
}
}
pub fn insert(&mut self, pos: usize, chars: impl IntoIterator<Item = char>) {
self.unsaved = true;
fn push_undo(&mut self, mut change: Change) {
self.redo.clear(); // TODO: Maybe add tree undos?
let Some(last) = self.undo.last_mut() else {
return self.undo.push(change);
};
// Attempt to merge changes together
match (&mut last.kind, &mut change.kind) {
(ChangeKind::Insert(at, s), ChangeKind::Insert(at2, s2)) if *at + s.len() == *at2 => {
s.append(s2);
}
(ChangeKind::Remove(at, s), ChangeKind::Remove(at2, s2)) if *at == *at2 + s2.len() => {
s2.append(s);
*s = core::mem::take(s2);
*at = *at2;
}
_ => return self.undo.push(change),
}
for (id, (from2, to2)) in change.cursors {
last.cursors
.entry(id)
.and_modify(|(_, to)| *to = to2)
.or_insert((from2, to2));
}
}
fn apply_change(&mut self, change: &Change) {
match &change.kind {
ChangeKind::Insert(at, s) => {
for (i, c) in s.iter().enumerate() {
self.text.chars.insert(at + i, *c);
}
}
ChangeKind::Remove(at, s) => {
self.text.chars.drain(*at..*at + s.len());
}
}
for (id, (_, to)) in change.cursors.iter() {
if let Some(c) = self.cursors.get_mut(*id) {
// panic!("Changing {c:?} to {to:?}");
*c = *to;
}
}
self.update_highlights();
}
pub fn undo(&mut self) -> bool {
if let Some(change) = self.undo.pop() {
let change = change.invert();
self.apply_change(&change);
self.redo.push(change);
true
} else {
false
}
}
pub fn redo(&mut self) -> bool {
if let Some(change) = self.redo.pop() {
let change = change.invert();
self.apply_change(&change);
self.undo.push(change);
true
} else {
false
}
}
fn insert_inner(&mut self, pos: usize, chars: impl IntoIterator<Item = char>) -> Change {
let chars = chars.into_iter().collect::<Vec<_>>();
let mut n = 0;
for c in chars {
self.text
.chars
.insert((pos + n).min(self.text.chars.len()), c);
let base = pos.min(self.text.chars.len());
for c in &chars {
self.text.chars.insert(base + n, *c);
n += 1;
}
self.update_highlights();
self.cursors.values_mut().for_each(|cursor| {
if cursor.base >= pos {
cursor.base += n;
}
if cursor.pos >= pos {
cursor.pos += n;
cursor.reset_desired_col(&self.text);
}
});
Change {
kind: ChangeKind::Insert(base, chars),
cursors: self
.cursors
.iter_mut()
.map(|(id, cursor)| {
let old = *cursor;
if cursor.base >= pos {
cursor.base += n;
}
if cursor.pos >= pos {
cursor.pos += n;
cursor.reset_desired_col(&self.text);
}
(id, (old, *cursor))
})
.collect(),
}
}
pub fn insert(&mut self, pos: usize, chars: impl IntoIterator<Item = char>) {
self.unsaved = true;
let change = self.insert_inner(pos, chars);
self.push_undo(change);
}
// Assumes range is well-formed
fn remove_inner(&mut self, range: Range<usize>) -> Change {
self.unsaved = true;
// TODO: Bell if false?
let removed = self.text.chars.drain(range.clone()).collect();
self.update_highlights();
Change {
kind: ChangeKind::Remove(range.start, removed),
cursors: self
.cursors
.iter_mut()
.map(|(id, cursor)| {
let old = *cursor;
if cursor.base >= range.start {
cursor.base = cursor
.base
.saturating_sub(range.end - range.start)
.max(range.start);
}
if cursor.pos >= range.start {
cursor.pos = cursor
.pos
.saturating_sub(range.end - range.start)
.max(range.start);
cursor.reset_desired_col(&self.text);
}
(id, (old, *cursor))
})
.collect(),
}
}
// Assumes range is well-formed
pub fn remove(&mut self, range: Range<usize>) {
self.unsaved = true;
let change = self.remove_inner(range);
self.push_undo(change);
}
pub fn insert_after(&mut self, cursor_id: CursorId, chars: impl IntoIterator<Item = char>) {
@ -444,30 +596,6 @@ impl Buffer {
}
}
// Assumes range is well-formed
pub fn remove(&mut self, range: Range<usize>) {
self.unsaved = true;
// TODO: Bell if false?
self.text.chars.drain(range.clone());
self.update_highlights();
self.cursors.values_mut().for_each(|cursor| {
if cursor.base >= range.start {
cursor.base = cursor
.base
.saturating_sub(range.end - range.start)
.max(range.start);
}
if cursor.pos >= range.start {
cursor.pos = cursor
.pos
.saturating_sub(range.end - range.start)
.max(range.start);
cursor.reset_desired_col(&self.text);
}
});
}
pub fn backspace(&mut self, cursor_id: CursorId) {
let Some(cursor) = self.cursors.get(cursor_id) else {
return;
@ -507,11 +635,17 @@ impl Buffer {
let Some(cursor) = self.cursors.get(cursor_id) else {
return;
};
let line = self.text.to_coord(cursor.pos)[1];
let line_start = self.text.to_pos([0, line]);
let coord = self.text.to_coord(cursor.pos);
let line_start = self.text.to_pos([0, coord[1]]);
let prev_indent = self.text.indent_of_line(line).to_vec();
let next_indent = self.text.indent_of_line(line + 1).to_vec();
let prev_indent = self
.text
.indent_of_line(coord[1])
.iter()
.copied()
.take(coord[0] as usize)
.collect::<Vec<_>>();
let next_indent = self.text.indent_of_line(coord[1] + 1).to_vec();
let (close_block, extra_indent, trailing_indent, base_indent) = if let Some(last_pos) =
cursor

View file

@ -277,6 +277,7 @@ pub struct Terminal<'a> {
stdout: StdoutLock<'a>,
size: [u16; 2],
fb: [Framebuffer; 2],
bell: bool,
}
impl<'a> Terminal<'a> {
@ -304,6 +305,7 @@ impl<'a> Terminal<'a> {
stdout: io::stdout().lock(),
size: [size.columns, size.rows],
fb: [Framebuffer::default(), Framebuffer::default()],
bell: false,
};
let hook = panic::take_hook();
@ -322,6 +324,10 @@ impl<'a> Terminal<'a> {
self.size = size;
}
pub fn ring_bell(&mut self) {
self.bell = true;
}
pub fn update(&mut self, render: impl FnOnce(&mut Rect)) {
// Reset framebuffer
if self.fb[0].size != self.size {
@ -337,6 +343,11 @@ impl<'a> Terminal<'a> {
self.stdout
.sync_update(|stdout| {
if self.bell {
self.bell = false;
stdout.queue(style::Print('\x07')).unwrap();
}
let mut cursor_pos = [0, 0];
let mut fg = Color::Reset;
let mut bg = Color::Reset;

View file

@ -65,6 +65,7 @@ impl Input {
.or_else(|| e.to_select_all())
.or_else(|| e.to_indent())
.or_else(|| e.to_mouse(self.last_area))
.or_else(|| e.to_undo_redo())
}) {
Some(Action::Char(c)) => {
if c == '\x08' {
@ -92,6 +93,7 @@ impl Input {
}
Some(Action::Indent(forward)) => {
buffer.indent(cursor_id, forward);
self.refocus(buffer, cursor_id);
Ok(Resp::handled(None))
}
Some(Action::GotoLine(line)) => {
@ -101,6 +103,7 @@ impl Input {
}
Some(Action::SelectToken) => {
buffer.select_token_cursor(cursor_id);
self.refocus(buffer, cursor_id);
Ok(Resp::handled(None))
}
Some(Action::SelectAll) => {
@ -109,9 +112,24 @@ impl Input {
}
Some(Action::Mouse(MouseAction::Click, pos)) => {
buffer.goto_cursor(cursor_id, [self.focus[0] + pos[0], self.focus[1] + pos[1]]);
self.refocus(buffer, cursor_id);
Ok(Resp::handled(None))
}
Some(Action::Undo) => {
if buffer.undo() {
self.refocus(buffer, cursor_id);
Ok(Resp::handled(None))
} else {
Ok(Resp::handled(Some(Event::Bell)))
}
}
Some(Action::Redo) => {
if buffer.redo() {
self.refocus(buffer, cursor_id);
Ok(Resp::handled(None))
} else {
Ok(Resp::handled(Some(Event::Bell)))
}
}
_ => Err(event),
}
}

View file

@ -73,7 +73,7 @@ impl Element<()> for Prompt {
Some(Action::Cancel) => Ok(Resp::end(None)),
Some(Action::Go) => match self.parse_action() {
Ok(action) => {
self.buffer.clear();
self.buffer.reset();
Ok(Resp::end(Some(action.into())))
}
Err(err) => Ok(Resp::handled(Some(
@ -303,7 +303,7 @@ impl Opener {
}
fn set_string(&mut self, s: &str) {
self.buffer.clear();
self.buffer.reset();
self.buffer.enter(self.cursor_id, s.chars());
self.update_completions();
}

View file

@ -41,7 +41,7 @@ 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 {
let event = loop {
task_idx = match task_idx.checked_sub(1) {
Some(task_idx) => task_idx,
None => {
@ -77,7 +77,7 @@ impl Element<()> for Root {
};
// Handle 'top-level' actions
if let Some(action) = action.and_then(|e| {
if let Some(action) = event.as_ref().and_then(|e| {
e.to_action(|e| {
e.to_open_prompt()
.or_else(|| e.to_cancel())
@ -126,6 +126,8 @@ impl Element<()> for Root {
.map(|r| r.into_can_end());
}
}
} else if let Some(event) = event {
return Err(event);
}
// Root element swallows all other events