use strop_core::id::{DisplayColumn, DocumentId};
use super::Editor;
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct JumpRecord {
pub document: DocumentId,
pub offset: usize,
pub view_top: usize,
pub hscroll: DisplayColumn,
pub anchor: usize,
pub extras: Vec<strop_core::selection::Selection>,
}
impl Editor {
pub(crate) fn map_navigation_records(
&mut self,
document: DocumentId,
map: impl Fn(usize) -> usize,
) {
for record in self
.jumplist_past
.iter_mut()
.chain(self.jumplist_future.iter_mut())
.chain(
self.docs
.values_mut()
.filter_map(|doc| doc.return_point_mut()),
)
.chain(
self.review
.replace_context
.iter_mut()
.map(|context| &mut context.origin),
)
{
if record.document == document {
record.offset = map(record.offset);
record.anchor = map(record.anchor);
record.view_top = map(record.view_top);
for selection in &mut record.extras {
selection.anchor = map(selection.anchor);
selection.head = map(selection.head);
}
}
}
}
pub(crate) fn jump_record(&self) -> JumpRecord {
let view = self.view();
let top_line = view.view_top.min(self.buf().len_lines().saturating_sub(1));
JumpRecord {
document: view.doc,
offset: self.head(),
view_top: self.buf().line_start(top_line),
hscroll: view.hscroll,
anchor: self.sels().primary().anchor,
extras: self.sels().extra_heads().to_vec(),
}
}
pub(crate) fn push_jump(&mut self) {
if self.docs.is_empty() {
return;
}
let record = self.jump_record();
match self.jumplist_past.last_mut() {
Some(last) if last.document == record.document && last.offset == record.offset => {
*last = record;
}
_ => self.jumplist_past.push(record),
}
self.jumplist_future.clear(); }
pub(crate) fn jump_back(&mut self) {
while let Some(record) = self.jumplist_past.pop() {
if self.docs.get(record.document).is_none() {
continue; }
let here = self.jump_record();
self.jumplist_future.push(here);
self.jump_to(record);
return;
}
self.message = "no jumps".into();
}
pub(crate) fn jump_forward(&mut self) {
while let Some(record) = self.jumplist_future.pop() {
if self.docs.get(record.document).is_none() {
continue;
}
let here = self.jump_record();
self.jumplist_past.push(here);
self.jump_to(record);
return;
}
self.message = "at newest jump".into();
}
pub(crate) fn jump_to(&mut self, record: JumpRecord) {
debug_assert!(
self.docs.get(record.document).is_some(),
"jump_to: dead entry"
);
if record.document != self.current() {
self.switch_to(record.document);
self.discover_git();
}
let len = self.buf().len_bytes();
let head = self.buf().clamp_boundary(record.offset.min(len));
let anchor = self.buf().clamp_boundary(record.anchor.min(len));
self.sels_mut().stretch_primary(anchor, head);
let extras: Vec<_> = record
.extras
.into_iter()
.map(|selection| strop_core::selection::Selection {
anchor: self.buf().clamp_boundary(selection.anchor.min(len)),
head: self.buf().clamp_boundary(selection.head.min(len)),
})
.collect();
self.sels_mut().set_extra_selections(extras);
self.clamp_cursor();
self.view_mut().hscroll = record.hscroll;
let rows = self.view_rows();
if rows > 0 {
let max_top = (self.buf().last_content_line() + 1).saturating_sub(rows);
let top = self
.buf()
.line_of(record.view_top.min(self.buf().len_bytes()))
.min(max_top);
self.view_mut().view_top = top;
let line = self.buf().line_of(self.head());
if !(top..top + rows).contains(&line) {
self.view_place('z');
}
}
self.flash(strop_core::Range::charwise(self.head(), self.head()));
}
pub(crate) fn jump_land(&mut self, document: DocumentId, offset: usize) {
debug_assert!(self.docs.get(document).is_some(), "jump_land: dead entry");
if document != self.current() {
self.switch_to(document);
self.discover_git();
}
self.set_head(
self.buf()
.clamp_boundary(offset.min(self.buf().len_bytes())),
);
self.clamp_cursor();
self.place_jump_target();
self.flash(strop_core::Range::charwise(self.head(), self.head()));
}
pub(crate) fn place_jump_target(&mut self) {
let rows = self.view_rows();
if rows == 0 {
return; }
let line = self.buf().line_of(self.head());
let top = self.view_top();
let margin = (rows / 8).clamp(1, 4).min(rows.saturating_sub(1) / 2);
if (top + margin..top + rows - margin).contains(&line) {
return;
}
self.view_place('z');
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::editor::Document;
use strop_core::Buffer;
#[test]
fn jumplist_walks_back_and_forward_across_buffers() {
let mut e = Editor::new(Buffer::from_text("one\ntwo\nthree\n"));
e.feed_text("j"); e.push_jump();
e.feed_text("G"); assert_eq!(e.buf().line_of(e.head()), 2);
e.jump_back();
assert_eq!(e.buf().line_of(e.head()), 1, "ctrl-o back to line 2");
e.jump_forward();
assert_eq!(e.buf().line_of(e.head()), 2, "ctrl-i forward again");
}
#[test]
fn new_jump_truncates_the_forward_path() {
let mut e = Editor::new(Buffer::from_text("a\nb\nc\nd\n"));
e.push_jump();
e.feed_text("jj");
e.push_jump();
e.feed_text("j"); e.jump_back(); e.push_jump();
e.feed_text("k"); assert!(e.jumplist_future.is_empty());
e.jump_forward();
assert!(e.message.contains("newest"));
}
#[test]
fn search_then_ctrl_o_ctrl_i() {
let mut e = Editor::new(Buffer::from_text("one\ntwo hone\nthree\n"));
e.feed_text("/hone\r");
assert_eq!(e.buf().line_of(e.head()), 1, "landed on the match");
e.feed(crate::editor::Key::CtrlO);
assert_eq!(e.buf().line_of(e.head()), 0, "ctrl-o back to the top");
e.feed(crate::editor::Key::Tab); assert_eq!(e.buf().line_of(e.head()), 1, "ctrl-i forward again");
}
#[test]
fn search_lands_with_jump_recorded() {
let mut e = Editor::new(Buffer::from_text("one\ntwo hone\nthree\n"));
e.feed_text("/hone\r");
assert_eq!(e.buf().line_of(e.head()), 1, "landed on the match");
assert_eq!(e.jumplist_past.len(), 1, "the jump was recorded");
}
#[test]
fn ctrl_o_skips_jumps_into_closed_buffers() {
let mut e = Editor::new(Buffer::from_text("one\ntwo\nthree\n"));
e.feed_text("j"); e.push_jump(); e.feed_text("G"); let b = e.docs.insert(Document::output(Buffer::from_text("x\ny\n")));
e.switch_to(b);
e.push_jump(); e.close_buffer(true); e.jump_back();
assert_eq!(e.buf().line_of(e.head()), 1);
e.jump_back();
assert_eq!(e.message, "no jumps");
}
fn viewed_editor(lines: usize, line: usize, top: usize, hscroll: usize) -> Editor {
let text: String = (0..lines).fold(String::new(), |mut acc, i| {
acc.push_str(&format!("line {i:03} with some text in it\n"));
acc
});
let mut e = Editor::new(Buffer::from_text(&text));
e.scroll_to_cursor(20); let at = e.buf().line_start(line) + 2;
e.sels_mut().collapse_primary(at); e.view_mut().view_top = top;
e.view_mut().hscroll = DisplayColumn::new(hscroll);
e
}
#[test]
fn jump_restores_caret_view_hscroll_and_selection() {
let mut e = viewed_editor(200, 60, 55, 7);
let (anchor_at, head_at) = (e.buf().line_start(60) + 5, e.head());
e.sels_mut().stretch_primary(anchor_at, head_at);
let anchor = e.sels().primary().anchor;
e.push_jump();
e.feed_text("G"); e.view_mut().hscroll = DisplayColumn::new(0);
e.jump_back();
assert_eq!(e.buf().line_of(e.head()), 60, "caret restored");
assert_eq!(e.sels().primary().anchor, anchor, "selection restored");
assert_eq!(e.view_top(), 55, "viewport top restored");
assert_eq!(
e.view().hscroll,
DisplayColumn::new(7),
"horizontal origin restored"
);
e.jump_forward();
assert_eq!(e.buf().line_of(e.head()), 199, "forward to the jump target");
}
#[test]
fn closing_help_restores_and_remaps_the_complete_source_view() {
let mut editor = viewed_editor(200, 60, 55, 7);
let source = editor.current();
let head = editor.head();
editor.sels_mut().stretch_primary(head + 3, head);
editor
.sels_mut()
.set_extra_selections(vec![strop_core::selection::Selection::cursor(head + 10)]);
editor.open_help();
assert!(editor.sels().extra_heads().is_empty());
editor
.doc_mut(source)
.buf
.edit()
.insert(0, "new line\n")
.unwrap();
editor.feed_text(":q<cr>");
assert_eq!(editor.current(), source);
assert_eq!(editor.head(), head + 9);
assert_eq!(editor.sels().primary().anchor, head + 12);
assert_eq!(editor.sels().extra_heads()[0].head, head + 19);
assert_eq!(editor.view_top(), 56);
assert_eq!(editor.view().hscroll, DisplayColumn::new(7));
}
#[test]
fn jump_restores_view_across_buffers() {
let mut e = viewed_editor(200, 80, 75, 3);
e.push_jump();
let b = e.docs.insert(Document::output(Buffer::from_text("x\ny\n")));
e.switch_to(b);
e.jump_back();
assert_ne!(e.current(), b, "switched back to the first document");
assert_eq!(e.buf().line_of(e.head()), 80);
assert_eq!(e.view_top(), 75);
assert_eq!(e.view().hscroll, DisplayColumn::new(3));
}
#[test]
fn edits_above_the_record_remap_caret_and_view() {
let mut e = viewed_editor(200, 60, 55, 0);
e.push_jump();
e.feed_text("G");
e.feed_text("ggOnew\nmore\u{1b}");
let record = e.jumplist_past[0].clone();
assert_eq!(
e.buf().line_of(record.offset),
62,
"the recorded caret rode the edit down"
);
assert_eq!(e.buf().line_of(record.anchor), 62, "the anchor rode too");
assert_eq!(
e.buf().line_of(record.view_top),
57,
"the recorded viewport rode the same edit"
);
e.jump_to(record);
assert_eq!(e.buf().line_of(e.head()), 62);
assert_eq!(e.view_top(), 57);
}
#[test]
fn landing_centers_an_offscreen_target() {
let mut e = viewed_editor(200, 10, 5, 0);
let target = e.buf().line_start(120);
e.push_jump();
e.jump_land(e.current(), target);
assert_eq!(e.buf().line_of(e.head()), 120, "landed on the target");
let top = e.view_top();
let rows = e.view_rows();
assert!(
(top + rows / 4..top + rows - rows / 4).contains(&120),
"target near vertical center: top={top} rows={rows}"
);
assert_eq!(top, 110, "zz geometry: 120 + 1 - (20/2 + 1)");
}
#[test]
fn landing_keeps_a_comfortably_visible_target() {
let mut e = viewed_editor(200, 100, 95, 0);
let target = e.buf().line_start(105); e.push_jump();
e.jump_land(e.current(), target);
assert_eq!(e.buf().line_of(e.head()), 105);
assert_eq!(e.view_top(), 95, "no jitter for an already-visible target");
}
#[test]
fn landing_centers_a_target_at_the_view_edge() {
let mut e = viewed_editor(200, 100, 95, 0);
let target = e.buf().line_start(114); e.push_jump();
e.jump_land(e.current(), target);
assert_eq!(e.buf().line_of(e.head()), 114);
assert_ne!(e.view_top(), 95, "edge targets recenter");
}
#[test]
fn restored_view_invalid_after_resize_falls_back_to_centering() {
let mut e = viewed_editor(200, 60, 55, 0);
e.push_jump();
e.feed_text("G");
e.scroll_to_cursor(4);
e.jump_back();
let line = e.buf().line_of(e.head());
assert_eq!(line, 60, "caret restored");
let top = e.view_top();
assert!(
(top..top + 4).contains(&line),
"the caret is visible after the restore: top={top} line={line}"
);
assert_eq!(top, 58, "zz geometry for 4 rows: 60 + 1 - (4/2 + 1)");
}
}