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
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
/// Mermaid-modal open + key handling, mirroring `table_modal.rs`.
///
/// All methods are part of `impl App`.
// Submodule of app — intentionally imports all parent symbols.
#[allow(clippy::wildcard_imports)]
use super::*;
impl App {
/// Open the mermaid modal if the block at (or nearest to) the cursor is a
/// mermaid block.
///
/// Resolution order mirrors [`Self::try_open_table_modal`]:
/// 1. The mermaid block whose row range contains the cursor.
/// 2. Otherwise, the first mermaid block intersecting the viewport.
///
/// `block_id` + `source` are captured so the renderer can re-look-up
/// the cache state on every frame (live updates while the modal is
/// open, e.g. when a queued image render finishes).
pub(super) fn try_open_mermaid_modal(&mut self) {
let view_height = self.tabs.view_height;
let Some(tab) = self.tabs.active_tab() else {
return;
};
let viewport_start = tab.view.scroll_offset;
let viewport_end = viewport_start + view_height;
let cursor_line = tab.view.cursor_line;
let mut cursor_match: Option<(crate::markdown::MermaidBlockId, &str)> = None;
let mut viewport_match: Option<(crate::markdown::MermaidBlockId, &str)> = None;
let mut block_start = 0u32;
for doc_block in &tab.view.rendered {
let block_end = block_start + doc_block.height();
if let crate::markdown::DocBlock::Mermaid { id, source, .. } = doc_block {
if cursor_line >= block_start && cursor_line < block_end {
cursor_match = Some((*id, source.as_str()));
break;
}
if viewport_match.is_none()
&& block_end > viewport_start
&& block_start < viewport_end
{
viewport_match = Some((*id, source.as_str()));
}
}
block_start = block_end;
if block_start >= viewport_end && cursor_match.is_none() && cursor_line < block_start {
// No more blocks can intersect the viewport AND we've
// passed the cursor line — nothing left to find.
break;
}
}
let Some((block_id, source)) = cursor_match.or(viewport_match) else {
return;
};
self.mermaid_modal = Some(MermaidModalState {
tab_id: tab.id,
block_id,
source: source.to_string(),
h_scroll: 0,
v_scroll: 0,
text_zoom: 0,
});
self.focus = Focus::MermaidModal;
}
/// Handle a key press while the mermaid modal is open. Mirrors
/// `handle_table_modal_key` so the two modals share muscle memory:
///
/// - `q` / `Esc` / `Enter` — close.
/// - `j` / `k` / arrows — scroll one row.
/// - `h` / `l` / arrows — scroll one column.
/// - `H` / `L` — half-page horizontal step.
/// - `d` / `u` / `PageDown` / `PageUp` — half-page vertical step.
/// - `g` then `g` — jump to top-left.
/// - `G` — jump to bottom.
/// - `0` / `$` — jump to leftmost / rightmost column.
pub(super) fn handle_mermaid_modal_key(&mut self, code: KeyCode) {
// `g` chord — same shape as `handle_table_modal_key`.
if self.pending_chord.take() == Some('g')
&& code == KeyCode::Char('g')
&& let Some(s) = self.mermaid_modal.as_mut()
{
s.v_scroll = 0;
s.h_scroll = 0;
return;
}
let view_height = crate::cast::u16_from_u32(self.tabs.view_height);
let inner_width = self
.mermaid_modal_rect
.map_or(80, |r| r.width.saturating_sub(2));
match code {
KeyCode::Char('q') | KeyCode::Esc | KeyCode::Enter => {
self.close_mermaid_modal();
}
KeyCode::Char('h') | KeyCode::Left => {
if let Some(s) = self.mermaid_modal.as_mut() {
s.h_scroll = s.h_scroll.saturating_sub(1);
}
}
KeyCode::Char('l') | KeyCode::Right => {
if let Some(s) = self.mermaid_modal.as_mut() {
s.h_scroll = s.h_scroll.saturating_add(1);
}
}
KeyCode::Char('H') => {
if let Some(s) = self.mermaid_modal.as_mut() {
s.h_scroll = s.h_scroll.saturating_sub(inner_width / 2);
}
}
KeyCode::Char('L') => {
if let Some(s) = self.mermaid_modal.as_mut() {
s.h_scroll = s.h_scroll.saturating_add(inner_width / 2);
}
}
KeyCode::Char('j') | KeyCode::Down => {
if let Some(s) = self.mermaid_modal.as_mut() {
s.v_scroll = s.v_scroll.saturating_add(1);
}
}
KeyCode::Char('k') | KeyCode::Up => {
if let Some(s) = self.mermaid_modal.as_mut() {
s.v_scroll = s.v_scroll.saturating_sub(1);
}
}
KeyCode::Char('d') | KeyCode::PageDown => {
if let Some(s) = self.mermaid_modal.as_mut() {
s.v_scroll = s.v_scroll.saturating_add(view_height / 2);
}
}
KeyCode::Char('u') | KeyCode::PageUp => {
if let Some(s) = self.mermaid_modal.as_mut() {
s.v_scroll = s.v_scroll.saturating_sub(view_height / 2);
}
}
KeyCode::Char('G') => {
if let Some(s) = self.mermaid_modal.as_mut() {
// The renderer clamps v_scroll against the actual content
// height each frame, so a generous value here is safe.
s.v_scroll = u16::MAX;
}
}
KeyCode::Char('0') => {
if let Some(s) = self.mermaid_modal.as_mut() {
s.h_scroll = 0;
}
}
KeyCode::Char('$') => {
if let Some(s) = self.mermaid_modal.as_mut() {
s.h_scroll = u16::MAX;
}
}
KeyCode::Char('g') => {
self.pending_chord = Some('g');
}
// Text-mode zoom — `+` requests a more spacious layout, `-`
// a more compact one, `=` resets. The renderer re-runs
// `mermaid_text::render_with_width` synchronously next frame
// (sub-millisecond for typical diagrams), so each press is
// visible immediately. Image-mode entries ignore zoom.
// We accept both `+` and `=` (US/UK keyboards put `+` on
// shift-`=`, but some users land on `=` directly), and both
// `-` and `_`. We reset on `0` chord-style? No — `0` is
// already taken for "scroll to leftmost", so we use the bare
// `=` for reset and require `Shift+=` (which crossterm sends
// as `+`) for zoom-in. `-` zooms out (more compact).
KeyCode::Char('+') => {
if let Some(s) = self.mermaid_modal.as_mut() {
s.text_zoom = s.text_zoom.saturating_add(1);
s.h_scroll = 0;
s.v_scroll = 0;
}
}
KeyCode::Char('-') => {
if let Some(s) = self.mermaid_modal.as_mut() {
s.text_zoom = s.text_zoom.saturating_sub(1);
s.h_scroll = 0;
s.v_scroll = 0;
}
}
KeyCode::Char('=') => {
if let Some(s) = self.mermaid_modal.as_mut() {
s.text_zoom = 0;
s.h_scroll = 0;
s.v_scroll = 0;
}
}
_ => {}
}
}
/// Mouse handling for the mermaid modal — same shape as
/// `handle_table_modal_mouse`. Click-outside closes; scroll wheel pans.
pub(super) fn handle_mermaid_modal_mouse(&mut self, m: crossterm::event::MouseEvent) {
use crossterm::event::{KeyModifiers, MouseButton, MouseEventKind};
let col = m.column;
let row = m.row;
let inside = self.mermaid_modal_rect.is_some_and(|r| {
col >= r.x && col < r.x + r.width && row >= r.y && row < r.y + r.height
});
match m.kind {
// Click inside the modal is a no-op; click outside closes it.
MouseEventKind::Down(MouseButton::Left) if !inside => {
self.close_mermaid_modal();
}
MouseEventKind::ScrollDown if inside => {
if let Some(s) = self.mermaid_modal.as_mut() {
s.v_scroll = s.v_scroll.saturating_add(3);
}
}
MouseEventKind::ScrollUp if inside => {
if let Some(s) = self.mermaid_modal.as_mut() {
s.v_scroll = s.v_scroll.saturating_sub(3);
}
}
MouseEventKind::ScrollLeft if inside => {
if let Some(s) = self.mermaid_modal.as_mut() {
s.h_scroll = s.h_scroll.saturating_sub(3);
}
}
MouseEventKind::ScrollRight if inside => {
if let Some(s) = self.mermaid_modal.as_mut() {
s.h_scroll = s.h_scroll.saturating_add(3);
}
}
// Shift + scroll wheel = horizontal pan, matching the table modal.
_ if inside
&& m.modifiers.contains(KeyModifiers::SHIFT)
&& matches!(
m.kind,
MouseEventKind::ScrollDown | MouseEventKind::ScrollUp
) =>
{
if let Some(s) = self.mermaid_modal.as_mut() {
if matches!(m.kind, MouseEventKind::ScrollDown) {
s.h_scroll = s.h_scroll.saturating_add(3);
} else {
s.h_scroll = s.h_scroll.saturating_sub(3);
}
}
}
_ => {}
}
}
}