use anyhow::Result;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crate::buffer_ref::BufferRef;
use crate::editor::{Buffer, Cursor, JumpEntry};
use crate::effect::ScrollAnchor;
use crate::lsp::{Location, Position, Range, path_to_uri};
use super::lsp_apply::format_location_label;
use super::{App, Toast, root_cause};
const ALPHABET: &[char] = &[
'a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', 'q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p',
'z', 'x', 'c', 'v', 'b', 'n', 'm',
];
#[derive(Debug, Clone)]
pub struct JumpLabel {
pub pos: Cursor,
pub first: char,
pub second: Option<char>,
}
#[derive(Debug)]
pub struct JumpState {
pub labels: Vec<JumpLabel>,
pub typed_first: Option<char>,
}
impl App {
pub(super) fn start_jump_label(&mut self) {
let targets = collect_jump_targets(self.active_doc());
if targets.is_empty() {
self.push_toast(Toast::info("no jump targets"));
return;
}
let labels = assign_labels(targets);
self.jump_state = Some(JumpState {
labels,
typed_first: None,
});
self.push_toast(Toast::info("jump: type label (Esc to cancel)"));
}
pub(super) fn handle_jump_key(&mut self, key: KeyEvent) {
if key.code == KeyCode::Esc
|| (key.modifiers.contains(KeyModifiers::CONTROL)
&& matches!(key.code, KeyCode::Char('c') | KeyCode::Char('g')))
{
self.cancel_jump();
return;
}
let KeyCode::Char(ch) = key.code else {
self.cancel_jump();
return;
};
let Some(state) = self.jump_state.as_mut() else {
return;
};
match state.typed_first {
None => {
let mut matched: Vec<&JumpLabel> =
state.labels.iter().filter(|l| l.first == ch).collect();
if matched.is_empty() {
self.cancel_jump();
return;
}
if matched.len() == 1 {
let pos = matched.remove(0).pos;
self.finish_jump(pos);
return;
}
state.typed_first = Some(ch);
}
Some(first) => {
let target = state
.labels
.iter()
.find(|l| l.first == first && l.second == Some(ch))
.map(|l| l.pos);
match target {
Some(pos) => self.finish_jump(pos),
None => self.cancel_jump(),
}
}
}
}
fn finish_jump(&mut self, pos: Cursor) {
self.record_jump();
self.editor.cursor = pos;
self.jump_state = None;
}
fn cancel_jump(&mut self) {
self.jump_state = None;
self.push_toast(Toast::info("jump cancelled"));
}
}
fn collect_jump_targets(buffer: &Buffer) -> Vec<Cursor> {
let scroll = buffer.scroll.get();
let height = buffer.viewport_height.get();
if height == 0 {
return Vec::new();
}
let last = (scroll + height).min(buffer.lines.len());
let is_word = |c: char| c.is_alphanumeric() || c == '_';
let mut out = Vec::new();
for row in scroll..last {
let mut prev_word = false;
for (col, c) in buffer.lines[row].chars().enumerate() {
let cur_word = is_word(c);
if cur_word && !prev_word {
out.push(Cursor { row, col });
}
prev_word = cur_word;
}
}
out
}
impl App {
pub(super) fn record_jump(&mut self) {
if self.navigating_jumplist {
return;
}
let entry = JumpEntry {
doc: self.editor.doc.clone(),
cursor: self.editor.cursor,
};
self.editor.jumps.push(entry);
}
pub(super) fn jump_back(&mut self, count: u32) {
let here = JumpEntry {
doc: self.editor.doc.clone(),
cursor: self.editor.cursor,
};
match self.editor.jumps.backward(count.max(1) as usize, here) {
Some(target) => self.goto_jump_entry(target),
None => self.push_toast(Toast::info("at oldest jump")),
}
}
pub(super) fn jump_forward(&mut self, count: u32) {
match self.editor.jumps.forward(count.max(1) as usize) {
Some(target) => self.goto_jump_entry(target),
None => self.push_toast(Toast::info("at newest jump")),
}
}
fn goto_jump_entry(&mut self, entry: JumpEntry) {
self.navigating_jumplist = true;
let res = self.goto_jump_entry_inner(&entry);
self.navigating_jumplist = false;
if let Err(e) = res {
self.push_toast(Toast::error(format!("jump: {}", root_cause(&e))));
}
}
fn goto_jump_entry_inner(&mut self, entry: &JumpEntry) -> Result<()> {
if self.editor.doc != entry.doc {
self.switch_to_buffer(entry.doc.clone())?;
}
let last = self.active_doc().lines.len().saturating_sub(1);
self.editor.cursor.row = entry.cursor.row.min(last);
self.editor.cursor.col = entry.cursor.col;
ed_op_ref!(self, clamp_col(false));
self.run_scroll(ScrollAnchor::Center);
Ok(())
}
pub(super) fn open_jump_list(&mut self) {
let here = JumpEntry {
doc: self.editor.doc.clone(),
cursor: self.editor.cursor,
};
let entries = std::iter::once(here.clone()).chain(
self.editor
.jumps
.entries()
.iter()
.rev()
.filter(|e| !(e.doc == here.doc && e.cursor.row == here.cursor.row))
.cloned(),
);
let mut items: Vec<String> = Vec::new();
let mut locations: Vec<Location> = Vec::new();
for entry in entries {
let BufferRef::File(path) = &entry.doc else {
continue;
};
let line = entry.cursor.row as u32;
let character = entry.cursor.col as u32;
let loc = Location {
uri: path_to_uri(path),
range: Range {
start: Position { line, character },
end: Position { line, character },
},
};
items.push(format_location_label(&loc, &self.startup_cwd));
locations.push(loc);
}
if items.is_empty() {
self.push_toast(Toast::info("no file-backed jumps to show"));
return;
}
self.prompt.open_jumps(items, locations);
}
}
fn assign_labels(targets: Vec<Cursor>) -> Vec<JumpLabel> {
let a = ALPHABET.len();
let n = targets.len();
let max = a * a;
targets
.into_iter()
.take(max)
.enumerate()
.map(|(i, pos)| {
let (first, second) = if n <= a {
(ALPHABET[i], None)
} else {
(ALPHABET[i % a], Some(ALPHABET[i / a]))
};
JumpLabel { pos, first, second }
})
.collect()
}