feat: add multiple buffers and file switching
This commit is contained in:
+1023
-280
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,8 @@ Visual: motions extend selections, y copy, d/x/Delete cut, c change
|
||||
Insert: Esc returns to Normal retaining carets
|
||||
Search: Enter finds next literal match, Esc cancels (case-sensitive, wraps)
|
||||
Commands: :w [PATH], :q, :q!, :wq [PATH], :plugins, :PLUGIN [ARGS]
|
||||
Buffers: :edit PATH, :buffers, :buffer N, :bnext, :bprev, :bdelete[!] [N]
|
||||
:q refuses any dirty buffer; :wq saves active and quits only if all are clean
|
||||
|
||||
Ruby config: $XDG_CONFIG_HOME/vamp/config.rb or ~/.config/vamp/config.rb
|
||||
Set VAMP_RUBY to choose the Ruby executable.";
|
||||
|
||||
@@ -155,6 +155,67 @@ Vamp.command("healthy") { |editor, args| editor.replace "healthy #{args}" }
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buffers_share_config_and_plugin_host_but_keep_history_and_protect_hidden_edits() {
|
||||
let fixture = Fixture::new();
|
||||
let first = fixture.file("buffer.txt", "alpha");
|
||||
let second = fixture.file("second.txt", "beta");
|
||||
fixture.file(
|
||||
"shared.rb",
|
||||
r##"
|
||||
Vamp.plugin "shared", requires: ["text"]
|
||||
calls = 0
|
||||
Vamp.command("stamp") do |editor, _|
|
||||
calls += 1
|
||||
editor.insert "#{calls}:#{File.basename(editor.path)}:"
|
||||
end
|
||||
Vamp.command("fail") { |_, _| raise "shared host failure" }
|
||||
"##,
|
||||
);
|
||||
let mut session = fixture.open(
|
||||
r#"
|
||||
Vamp.configure do |config|
|
||||
config.plugin "shared.rb"
|
||||
config.bind "g", "stamp"
|
||||
end
|
||||
"#,
|
||||
);
|
||||
session.handle(Input::Char('g'));
|
||||
assert_eq!(session.editor().lines(), &["1:buffer.txt:alpha"]);
|
||||
session.execute(&format!("edit {}", second.display()));
|
||||
assert_eq!(session.editor().buffer_id(), 2);
|
||||
session.handle(Input::Char('g'));
|
||||
assert_eq!(session.editor().lines(), &["2:second.txt:beta"]);
|
||||
session.execute("bdelete");
|
||||
assert_eq!(session.editor().buffer_id(), 2);
|
||||
assert_eq!(session.editor().buffer_count(), 2);
|
||||
session.handle(Input::Char('u'));
|
||||
assert_eq!(session.editor().lines(), &["beta"]);
|
||||
session.execute("q");
|
||||
assert!(!session.editor().should_quit());
|
||||
session.execute("wq");
|
||||
assert!(!session.editor().should_quit());
|
||||
assert_eq!(fs::read_to_string(&first).unwrap(), "alpha");
|
||||
assert_eq!(fs::read_to_string(&second).unwrap(), "beta");
|
||||
session.execute("buffer 1");
|
||||
assert_eq!(session.editor().lines(), &["1:buffer.txt:alpha"]);
|
||||
session.handle(Input::Char('u'));
|
||||
assert_eq!(session.editor().lines(), &["alpha"]);
|
||||
session.handle(Input::Char('g'));
|
||||
assert_eq!(session.editor().lines(), &["3:buffer.txt:alpha"]);
|
||||
session.execute("w");
|
||||
assert_eq!(fs::read_to_string(&first).unwrap(), "3:buffer.txt:alpha");
|
||||
session.execute("bnext");
|
||||
assert_eq!(session.editor().lines(), &["beta"]);
|
||||
session.execute("fail");
|
||||
assert!(session.editor().message().contains("disabled"));
|
||||
session.execute("buffer 1");
|
||||
session.handle(Input::Char('g'));
|
||||
assert_eq!(session.editor().lines(), &["3:buffer.txt:alpha"]);
|
||||
session.execute("q");
|
||||
assert!(session.editor().should_quit());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_palette_binding_prepares_arguments_and_retires_failed_plugin_commands() {
|
||||
let fixture = Fixture::new();
|
||||
|
||||
+293
-16
@@ -1,4 +1,6 @@
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
fmt::Write as _,
|
||||
io::{self, Stdout},
|
||||
panic::{self, AssertUnwindSafe, PanicHookInfo},
|
||||
sync::{Arc, Mutex},
|
||||
@@ -109,15 +111,28 @@ impl Drop for TerminalSession {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct BufferViewports {
|
||||
offsets: BTreeMap<usize, (usize, usize)>,
|
||||
}
|
||||
|
||||
impl BufferViewports {
|
||||
fn draw(&mut self, frame: &mut Frame, editor: &Editor) {
|
||||
self.offsets.retain(|id, _| editor.has_buffer(*id));
|
||||
let (top, left) = self.offsets.entry(editor.buffer_id()).or_default();
|
||||
draw(frame, editor, top, left);
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders session state and translates terminal input; the session owns plugin
|
||||
/// execution and retirement, so the adapter needs no lifecycle callback.
|
||||
pub fn run(session: &mut EditorSession) -> io::Result<()> {
|
||||
TerminalSession::run(|| {
|
||||
let mut terminal: Terminal<CrosstermBackend<Stdout>> =
|
||||
Terminal::new(CrosstermBackend::new(io::stdout()))?;
|
||||
let (mut top, mut left) = (0, 0);
|
||||
let mut viewports = BufferViewports::default();
|
||||
while !session.editor().should_quit() {
|
||||
terminal.draw(|frame| draw(frame, session.editor(), &mut top, &mut left))?;
|
||||
terminal.draw(|frame| viewports.draw(frame, session.editor()))?;
|
||||
match event::read()? {
|
||||
Event::Key(key) if key.kind != KeyEventKind::Release => {
|
||||
if let Some(input) = translate(key) {
|
||||
@@ -325,24 +340,31 @@ fn draw(frame: &mut Frame, editor: &Editor, top: &mut usize, left: &mut usize) {
|
||||
.chars()
|
||||
.map(|c| if c.is_control() { '�' } else { c })
|
||||
.collect();
|
||||
let status = format!(
|
||||
" {} {}{} {}:{}{}{}",
|
||||
editor.mode(),
|
||||
let mut status = format!(" {}", editor.mode());
|
||||
if editor.buffer_count() > 1 {
|
||||
write!(
|
||||
status,
|
||||
" [buffer {}/{}]",
|
||||
editor.buffer_id(),
|
||||
editor.buffer_count()
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
write!(
|
||||
status,
|
||||
" {}{} {}:{}",
|
||||
path,
|
||||
if editor.dirty() { " [+]" } else { "" },
|
||||
cursor.row + 1,
|
||||
cursor.column + 1,
|
||||
if editor.selection_count() > 1 {
|
||||
format!(" {} selections", editor.selection_count())
|
||||
} else {
|
||||
String::new()
|
||||
},
|
||||
if let Some(count) = editor.count() {
|
||||
format!(" count: {count}")
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
);
|
||||
)
|
||||
.unwrap();
|
||||
if editor.selection_count() > 1 {
|
||||
write!(status, " {} selections", editor.selection_count()).unwrap();
|
||||
}
|
||||
if let Some(count) = editor.count() {
|
||||
write!(status, " count: {count}").unwrap();
|
||||
}
|
||||
frame.render_widget(
|
||||
Paragraph::new(status).style(Style::default().fg(Color::Black).bg(Color::Cyan)),
|
||||
status_area,
|
||||
@@ -479,6 +501,36 @@ fn draw_palette(frame: &mut Frame, palette: &Palette, mode: Mode) {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::{
|
||||
fs,
|
||||
path::PathBuf,
|
||||
sync::atomic::{AtomicUsize, Ordering},
|
||||
};
|
||||
|
||||
struct Directory(PathBuf);
|
||||
|
||||
impl Directory {
|
||||
fn new() -> Self {
|
||||
static NEXT_ID: AtomicUsize = AtomicUsize::new(0);
|
||||
let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
|
||||
let path =
|
||||
std::env::temp_dir().join(format!("vamp-tui-test-{}-{id}", std::process::id()));
|
||||
fs::create_dir(&path).unwrap();
|
||||
Self(path)
|
||||
}
|
||||
|
||||
fn file(&self, name: &str, text: &str) -> PathBuf {
|
||||
let path = self.0.join(name);
|
||||
fs::write(&path, text).unwrap();
|
||||
path
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Directory {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn screen_text(terminal: &Terminal<ratatui::backend::TestBackend>) -> String {
|
||||
let buffer = terminal.backend().buffer();
|
||||
@@ -493,6 +545,231 @@ mod tests {
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buffer_switches_restore_independent_vertical_and_horizontal_viewports() {
|
||||
let directory = Directory::new();
|
||||
let a = directory.file("a", &vec!["a".repeat(50); 20].join("\n"));
|
||||
let b = directory.file("b", &vec!["b".repeat(40); 20].join("\n"));
|
||||
let mut editor = Editor::open(Some(a), vec![]).unwrap();
|
||||
let a_id = editor.buffer_id();
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(16, 6)).unwrap();
|
||||
let mut viewports = BufferViewports::default();
|
||||
for _ in 0..14 {
|
||||
editor.handle(Input::Down);
|
||||
}
|
||||
editor.handle(Input::End);
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, &editor))
|
||||
.unwrap();
|
||||
assert_eq!(viewports.offsets[&a_id], (11, 37));
|
||||
for _ in 0..2 {
|
||||
editor.handle(Input::Up);
|
||||
}
|
||||
for _ in 0..3 {
|
||||
editor.handle(Input::Left);
|
||||
}
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, &editor))
|
||||
.unwrap();
|
||||
let a_viewport = viewports.offsets[&a_id];
|
||||
let a_cursor = terminal.get_cursor_position().unwrap();
|
||||
assert_eq!(a_viewport, (11, 37));
|
||||
|
||||
editor.execute(&format!("edit {}", b.display()));
|
||||
let b_id = editor.buffer_id();
|
||||
assert_ne!(a_id, b_id);
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, &editor))
|
||||
.unwrap();
|
||||
assert_eq!(viewports.offsets[&b_id], (0, 0));
|
||||
for _ in 0..8 {
|
||||
editor.handle(Input::Down);
|
||||
}
|
||||
editor.handle(Input::End);
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, &editor))
|
||||
.unwrap();
|
||||
editor.handle(Input::Up);
|
||||
editor.handle(Input::Left);
|
||||
editor.handle(Input::Left);
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, &editor))
|
||||
.unwrap();
|
||||
let b_viewport = viewports.offsets[&b_id];
|
||||
let b_cursor = terminal.get_cursor_position().unwrap();
|
||||
assert_eq!(b_viewport, (5, 27));
|
||||
|
||||
editor.execute(&format!("buffer {a_id}"));
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, &editor))
|
||||
.unwrap();
|
||||
assert_eq!(viewports.offsets[&a_id], a_viewport);
|
||||
assert_eq!(terminal.get_cursor_position().unwrap(), a_cursor);
|
||||
assert_eq!(
|
||||
terminal.backend().buffer()[(a_cursor.x, a_cursor.y)].symbol(),
|
||||
"a"
|
||||
);
|
||||
editor.execute("bnext");
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, &editor))
|
||||
.unwrap();
|
||||
assert_eq!(editor.buffer_id(), b_id);
|
||||
assert_eq!(viewports.offsets[&b_id], b_viewport);
|
||||
assert_eq!(terminal.get_cursor_position().unwrap(), b_cursor);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_inactive_buffer_retires_its_viewport_without_moving_active_buffer() {
|
||||
let directory = Directory::new();
|
||||
let a = directory.file("a", &vec!["a".repeat(50); 20].join("\n"));
|
||||
let b = directory.file("b", &vec!["b".repeat(40); 20].join("\n"));
|
||||
let c = directory.file("c", "new buffer");
|
||||
let mut editor = Editor::open(Some(a), vec![]).unwrap();
|
||||
let a_id = editor.buffer_id();
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(16, 6)).unwrap();
|
||||
let mut viewports = BufferViewports::default();
|
||||
for _ in 0..14 {
|
||||
editor.handle(Input::Down);
|
||||
}
|
||||
editor.handle(Input::End);
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, &editor))
|
||||
.unwrap();
|
||||
editor.execute(&format!("edit {}", b.display()));
|
||||
let b_id = editor.buffer_id();
|
||||
for _ in 0..8 {
|
||||
editor.handle(Input::Down);
|
||||
}
|
||||
editor.handle(Input::End);
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, &editor))
|
||||
.unwrap();
|
||||
let b_viewport = viewports.offsets[&b_id];
|
||||
let b_cursor = terminal.get_cursor_position().unwrap();
|
||||
assert_eq!(viewports.offsets.len(), 2);
|
||||
|
||||
editor.execute(&format!("bdelete {a_id}"));
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, &editor))
|
||||
.unwrap();
|
||||
assert_eq!(editor.buffer_id(), b_id);
|
||||
assert!(!editor.has_buffer(a_id));
|
||||
assert!(!viewports.offsets.contains_key(&a_id));
|
||||
assert_eq!(viewports.offsets.len(), 1);
|
||||
assert_eq!(viewports.offsets[&b_id], b_viewport);
|
||||
assert_eq!(terminal.get_cursor_position().unwrap(), b_cursor);
|
||||
editor.execute(&format!("edit {}", c.display()));
|
||||
let c_id = editor.buffer_id();
|
||||
assert!(c_id > b_id);
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, &editor))
|
||||
.unwrap();
|
||||
assert_eq!(viewports.offsets[&c_id], (0, 0));
|
||||
assert!(!viewports.offsets.contains_key(&a_id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restoring_buffer_after_narrow_resize_clamps_caret_inside_text_region() {
|
||||
let directory = Directory::new();
|
||||
let a = directory.file("a", &vec!["a".repeat(50); 20].join("\n"));
|
||||
let b = directory.file("b", "other");
|
||||
let mut editor = Editor::open(Some(a), vec![]).unwrap();
|
||||
let a_id = editor.buffer_id();
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(24, 8)).unwrap();
|
||||
let mut viewports = BufferViewports::default();
|
||||
for _ in 0..14 {
|
||||
editor.handle(Input::Down);
|
||||
}
|
||||
editor.handle(Input::End);
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, &editor))
|
||||
.unwrap();
|
||||
assert_eq!(viewports.offsets[&a_id], (9, 29));
|
||||
editor.execute(&format!("edit {}", b.display()));
|
||||
let b_id = editor.buffer_id();
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, &editor))
|
||||
.unwrap();
|
||||
terminal.backend_mut().resize(4, 4);
|
||||
terminal
|
||||
.resize(ratatui::layout::Rect::new(0, 0, 4, 4))
|
||||
.unwrap();
|
||||
editor.execute(&format!("buffer {a_id}"));
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, &editor))
|
||||
.unwrap();
|
||||
assert_eq!(viewports.offsets[&a_id], (13, 49));
|
||||
assert_eq!(viewports.offsets[&b_id], (0, 0));
|
||||
assert_eq!(
|
||||
terminal.get_cursor_position().unwrap(),
|
||||
ratatui::layout::Position::new(3, 1)
|
||||
);
|
||||
|
||||
for (width, height) in [(1, 1), (24, 8)] {
|
||||
let area = ratatui::layout::Rect::new(0, 0, width, height);
|
||||
terminal.backend_mut().resize(width, height);
|
||||
terminal.resize(area).unwrap();
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, &editor))
|
||||
.unwrap();
|
||||
let cursor = terminal.get_cursor_position().unwrap();
|
||||
assert!(area.contains(cursor), "{area:?}: {cursor:?}");
|
||||
assert_eq!(
|
||||
terminal.backend().buffer()[(cursor.x, cursor.y)].symbol(),
|
||||
"a"
|
||||
);
|
||||
assert_eq!(viewports.offsets[&b_id], (0, 0));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_identifies_active_buffer_and_restored_visual_selection_count() {
|
||||
let directory = Directory::new();
|
||||
let a = directory.file("a", "word\nword\nword");
|
||||
let b = directory.file("b", "other");
|
||||
let mut editor = Editor::open(Some(a), vec![]).unwrap();
|
||||
let a_id = editor.buffer_id();
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(240, 6)).unwrap();
|
||||
let mut viewports = BufferViewports::default();
|
||||
editor.handle(Input::Char('v'));
|
||||
editor.handle(Input::Ctrl('a'));
|
||||
assert_eq!(editor.selection_count(), 3);
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, &editor))
|
||||
.unwrap();
|
||||
assert!(!screen_text(&terminal).contains("[buffer"));
|
||||
editor.execute(&format!("edit {}", b.display()));
|
||||
let b_id = editor.buffer_id();
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, &editor))
|
||||
.unwrap();
|
||||
let text = screen_text(&terminal);
|
||||
assert!(
|
||||
text.contains(&format!("NORMAL [buffer {b_id}/2]")),
|
||||
"{text}"
|
||||
);
|
||||
assert!(!text.contains("3 selections"), "{text}");
|
||||
editor.execute(&format!("buffer {a_id}"));
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, &editor))
|
||||
.unwrap();
|
||||
let text = screen_text(&terminal);
|
||||
assert!(
|
||||
text.contains(&format!("VISUAL [buffer {a_id}/2]")),
|
||||
"{text}"
|
||||
);
|
||||
assert!(text.contains("3 selections"), "{text}");
|
||||
let (top, left) = viewports.offsets[&a_id];
|
||||
for selection in editor.selections() {
|
||||
let x = 2 + (selection.start.column - left) as u16;
|
||||
let y = (selection.start.row - top) as u16;
|
||||
assert!(matches!(
|
||||
terminal.backend().buffer()[(x, y)].bg,
|
||||
Color::Blue | Color::Magenta
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_motion_count_is_visible_and_escape_cancels_without_moving() {
|
||||
let mut editor = Editor::open(None, vec![]).unwrap();
|
||||
|
||||
Reference in New Issue
Block a user