1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
//! Flash/leap-style 2-char jump motion state.
//!
//! The vim handler accumulates `s<a><b>` and escalates to
//! `AppCommand::FlashStart(a, b)`. The App finds every visible occurrence of
//! `ab` in the active editor pane, assigns each a single-letter label from
//! `LABEL_POOL`, and stashes them in `App.flash_state`. While Some, the
//! `tui::dispatch_key` head intercepts: Esc cancels; any other char that
//! matches a label commits the jump (cursor → that match); anything else
//! cancels and re-dispatches the keystroke.
//!
//! Labels are deliberately drawn from chars that don't include `a` or `b`
//! so the user's mental "I'm typing the third letter to commit" doesn't
//! collide with the trigger pair.
use ratatui::layout::Rect;
use crate::layout::PaneId;
/// Up to this many matches get labelled. Beyond this, the rest are silently
/// dropped — flash isn't a search tool, it's a navigation gesture for
/// what's on screen RIGHT NOW.
pub const MAX_MATCHES: usize = 60;
/// Label alphabet — lowercase first (home-row biased), then uppercase. The
/// trigger pair's chars are filtered out when picking, so typing `sab<next>`
/// can't get confused.
const LABEL_POOL: &str = "fjdkslaghrueiwoqptyzxcvbnmFJDKSLAGHRUEIWOQPTYZXCVBNM";
/// One labelled target in the editor.
#[derive(Debug, Clone)]
pub struct FlashTarget {
/// File row (0-based).
pub row: usize,
/// Column in chars from the start of the line (0-based, NOT byte offset
/// — display column for cursor placement).
pub col_chars: usize,
/// The label char the user types to commit this jump.
pub label: char,
}
/// Active flash session.
#[derive(Debug, Clone)]
pub struct FlashState {
/// Which pane owns the labels. Switching panes drops the state.
pub pane_id: PaneId,
/// The trigger pair (for visual display + filter of label pool).
pub pair: (char, char),
/// All labelled targets — sorted top-to-bottom, left-to-right.
pub targets: Vec<FlashTarget>,
}
/// Pick `n` labels from the alphabet pool, skipping any char that equals
/// `pair.0` or `pair.1` (case-insensitive) so typing the label is
/// unambiguous.
pub fn pick_labels(pair: (char, char), n: usize) -> Vec<char> {
let a_lower = pair.0.to_ascii_lowercase();
let b_lower = pair.1.to_ascii_lowercase();
LABEL_POOL
.chars()
.filter(|&c| {
let lc = c.to_ascii_lowercase();
lc != a_lower && lc != b_lower
})
.take(n)
.collect()
}
/// Map a target's `(row, col_chars)` to a screen `(x, y)` cell inside the
/// editor pane's text area, given the buffer's scroll/h_scroll state and
/// the pane's text rect. Returns `None` when the target is off-screen.
///
/// `text_rect` is the rect that `editor_view::draw_pane` uses for the body
/// (gutter excluded). `vert_scroll` is the file row at the top of the
/// viewport; `h_scroll` is the leftmost visible column. `wrap_width`
/// is `Some(text_w)` when soft-wrap is enabled — in that case multiple
/// visual rows map onto a single file row.
pub fn target_to_screen(
target: &FlashTarget,
text_rect: Rect,
vert_scroll: usize,
h_scroll: usize,
wrap_width: Option<usize>,
) -> Option<(u16, u16)> {
let text_w = text_rect.width as usize;
let text_h = text_rect.height as usize;
if text_w == 0 || text_h == 0 {
return None;
}
if target.row < vert_scroll {
return None;
}
let row_off = target.row - vert_scroll;
let (visual_y, visual_x) = match wrap_width {
Some(w) if w > 0 => {
// Best-effort: this ignores fold-collapse + multi-line wrap of
// earlier rows in the viewport. For a non-wrapping file this
// matches the simple branch exactly. For wrap, the cursor can
// land slightly off — the user will be able to see the label,
// just not at the perfect cell. Improving this means walking
// `next_visible_line` like editor_view does.
(row_off + target.col_chars / w, target.col_chars % w)
}
_ => {
if target.col_chars < h_scroll {
return None;
}
(row_off, target.col_chars - h_scroll)
}
};
if visual_y >= text_h || visual_x >= text_w {
return None;
}
Some((text_rect.x + visual_x as u16, text_rect.y + visual_y as u16))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pick_labels_skips_trigger_chars() {
let labels = pick_labels(('f', 'j'), 5);
for c in &labels {
assert_ne!(c.to_ascii_lowercase(), 'f');
assert_ne!(c.to_ascii_lowercase(), 'j');
}
assert_eq!(labels.len(), 5);
}
#[test]
fn pick_labels_caps_at_pool_size() {
let labels = pick_labels(('a', 'b'), 1000);
// Two letters excluded → at most LABEL_POOL.len() - chars-excluded.
let expected_max = LABEL_POOL
.chars()
.filter(|&c| {
let lc = c.to_ascii_lowercase();
lc != 'a' && lc != 'b'
})
.count();
assert_eq!(labels.len(), expected_max);
}
#[test]
fn target_to_screen_clips_off_screen() {
let r = Rect {
x: 5,
y: 10,
width: 20,
height: 8,
};
// Target row below viewport.
let t = FlashTarget {
row: 100,
col_chars: 0,
label: 'a',
};
assert_eq!(target_to_screen(&t, r, 0, 0, None), None);
// Target row above viewport.
let t2 = FlashTarget {
row: 0,
col_chars: 0,
label: 'a',
};
assert_eq!(target_to_screen(&t2, r, 5, 0, None), None);
// On-screen, no scroll.
let t3 = FlashTarget {
row: 3,
col_chars: 7,
label: 'a',
};
assert_eq!(target_to_screen(&t3, r, 0, 0, None), Some((12, 13)));
// Horizontal scroll clip.
assert_eq!(target_to_screen(&t3, r, 0, 10, None), None);
}
}