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
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
use crate::app::DocSearchState;
use crate::theme::Palette;
use crate::ui::editor::TabEditor;
use crate::ui::markdown_view::MarkdownViewState;
use std::path::PathBuf;
/// Maximum number of tabs that can be open simultaneously.
pub const MAX_TABS: usize = 32;
/// Opaque stable identifier for a tab.
///
/// Uses a monotonically increasing counter on [`Tabs`] so the id is stable
/// across insertions and removals (unlike a bare index, which shifts).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TabId(pub u32);
/// All per-document state owned by a single tab.
pub struct Tab {
pub id: TabId,
pub view: MarkdownViewState,
/// In-document find state is document-specific and travels with the tab.
pub doc_search: DocSearchState,
/// Vim-style editor state. `Some` while the tab is in edit mode, `None`
/// when viewing the rendered markdown.
pub editor: Option<TabEditor>,
}
/// Ordered collection of open tabs with an active-tab pointer.
///
/// `view_height` is a viewport property shared across all tabs; it is updated
/// once per draw call and used by every tab's scroll methods.
#[allow(clippy::struct_field_names)]
pub struct Tabs {
pub tabs: Vec<Tab>,
/// The currently visible tab.
pub active: Option<TabId>,
/// The previously active tab, used for backtick (`` ` ``) navigation.
pub previous: Option<TabId>,
next_id: u32,
/// Inner height of the viewer panel (rows minus borders), updated each draw.
pub view_height: u32,
}
impl Tabs {
/// Create an empty tab set with no active tab.
pub fn new() -> Self {
Self {
tabs: Vec::new(),
active: None,
previous: None,
next_id: 0,
view_height: 0,
}
}
fn alloc_id(&mut self) -> TabId {
let id = TabId(self.next_id);
self.next_id += 1;
id
}
fn index_of(&self, id: TabId) -> Option<usize> {
self.tabs.iter().position(|t| t.id == id)
}
/// Return a shared reference to the active tab, if any.
pub fn active_tab(&self) -> Option<&Tab> {
self.active.and_then(|id| {
let idx = self.index_of(id)?;
self.tabs.get(idx)
})
}
/// Return a mutable reference to the active tab, if any.
pub fn active_tab_mut(&mut self) -> Option<&mut Tab> {
let id = self.active?;
let idx = self.index_of(id)?;
self.tabs.get_mut(idx)
}
/// Return the 0-based index of the active tab in the `tabs` slice.
pub fn active_index(&self) -> Option<usize> {
self.active.and_then(|id| self.index_of(id))
}
/// Make `id` the active tab, recording the previous active tab for `activate_previous`.
pub fn set_active(&mut self, id: TabId) {
if self.active != Some(id) {
self.previous = self.active;
self.active = Some(id);
}
}
/// Open or focus a tab for `path`.
///
/// Behavior:
/// - If `path` is already open, activate that tab and return its id.
/// - If `new_tab == false` and there is an active tab, replace it.
/// - If `tabs.len() >= MAX_TABS`, silently refuse and return current active.
/// - Otherwise push a new tab, activate it, and return its id.
///
/// This method does **not** do filesystem I/O; the caller is responsible
/// for calling [`MarkdownViewState::load`] when a new tab was created.
pub fn open_or_focus(&mut self, path: &PathBuf, new_tab: bool) -> (TabId, OpenOutcome) {
// Deduplicate: if already open, just switch.
if let Some(existing) = self
.tabs
.iter()
.find(|t| t.view.current_path.as_ref() == Some(path))
{
let id = existing.id;
self.set_active(id);
return (id, OpenOutcome::Focused);
}
// Replace active tab when requested (no new tab).
if !new_tab && let Some(id) = self.active {
return (id, OpenOutcome::Replaced);
}
// Enforce cap.
if self.tabs.len() >= MAX_TABS {
let fallback = self.active.unwrap_or(TabId(0));
return (fallback, OpenOutcome::Capped);
}
// Push new tab with the path set immediately so the dedup check
// catches it when apply_file_loaded runs after the async read.
let id = self.alloc_id();
self.tabs.push(Tab {
id,
view: MarkdownViewState {
current_path: Some(path.clone()),
..MarkdownViewState::default()
},
doc_search: DocSearchState::default(),
editor: None,
});
self.set_active(id);
(id, OpenOutcome::Opened)
}
/// Close the tab with `id`. Returns `true` if the tab was found and removed.
///
/// After closing, the active tab is updated: previous tab if it still
/// exists, otherwise the neighbour at the same index (clamped), or `None`.
pub fn close(&mut self, id: TabId) -> bool {
let Some(idx) = self.index_of(id) else {
return false;
};
self.tabs.remove(idx);
if self.tabs.is_empty() {
self.active = None;
self.previous = None;
return true;
}
if let Some(prev) = self.previous
&& prev != id
&& self.index_of(prev).is_some()
{
self.previous = None;
self.active = Some(prev);
} else {
let new_idx = idx.min(self.tabs.len() - 1);
self.active = Some(self.tabs[new_idx].id);
self.previous = None;
}
true
}
/// Return the number of open tabs.
pub fn len(&self) -> usize {
self.tabs.len()
}
/// Iterate over all open tabs in order.
pub fn iter(&self) -> std::slice::Iter<'_, Tab> {
self.tabs.iter()
}
/// Iterate mutably over all open tabs in order.
pub fn iter_mut(&mut self) -> std::slice::IterMut<'_, Tab> {
self.tabs.iter_mut()
}
/// Return `true` when no tabs are open.
pub fn is_empty(&self) -> bool {
self.tabs.is_empty()
}
/// Return a mutable reference to the tab whose `current_path` equals `path`.
pub fn find_tab_by_path_mut(&mut self, path: &PathBuf) -> Option<&mut Tab> {
self.tabs
.iter_mut()
.find(|t| t.view.current_path.as_ref() == Some(path))
}
/// Activate the tab after the current one, wrapping around.
pub fn next(&mut self) {
let Some(idx) = self.active_index() else {
return;
};
let next_idx = (idx + 1) % self.tabs.len();
let id = self.tabs[next_idx].id;
self.set_active(id);
}
/// Activate the tab before the current one, wrapping around.
pub fn prev(&mut self) {
let Some(idx) = self.active_index() else {
return;
};
let prev_idx = if idx == 0 {
self.tabs.len() - 1
} else {
idx - 1
};
let id = self.tabs[prev_idx].id;
self.set_active(id);
}
/// Activate a tab by 1-based index. Out-of-range is a silent no-op.
pub fn activate_by_index(&mut self, one_based: usize) {
if one_based == 0 || one_based > self.tabs.len() {
return;
}
let id = self.tabs[one_based - 1].id;
self.set_active(id);
}
/// Jump to the last tab.
pub fn activate_last(&mut self) {
if let Some(last) = self.tabs.last() {
let id = last.id;
self.set_active(id);
}
}
/// Activate the previously active tab (backtick navigation).
pub fn activate_previous(&mut self) {
let Some(prev) = self.previous else {
return;
};
if self.index_of(prev).is_none() {
self.previous = None;
return;
}
let current = self.active;
self.active = Some(prev);
self.previous = current;
}
/// Re-render every open tab with the given palette and theme, preserving scroll offsets.
///
/// # Arguments
///
/// * `palette` – color palette for the active UI theme.
/// * `theme` – the active UI theme; forwarded to the markdown renderer so
/// fenced code blocks are highlighted with a matching syntect theme.
pub fn rerender_all(&mut self, palette: &Palette, theme: crate::theme::Theme) {
for tab in &mut self.tabs {
if let Some(path) = tab.view.current_path.clone() {
let content = tab.view.content.clone();
let name = tab.view.file_name.clone();
let scroll = tab.view.scroll_offset;
tab.view.load(path, name, content, palette, theme);
tab.view.scroll_offset = scroll.min(tab.view.total_lines.saturating_sub(1));
}
}
}
}
/// The outcome of a [`Tabs::open_or_focus`] call.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OpenOutcome {
/// An existing tab was focused (deduplicated).
Focused,
/// The active tab's content was replaced.
Replaced,
/// A new tab was pushed and activated.
Opened,
/// The tab cap was reached; nothing changed.
Capped,
}
#[cfg(test)]
mod tests {
use super::*;
fn make_path(name: &str) -> PathBuf {
PathBuf::from(format!("/fake/{name}"))
}
/// Open `name` in `tabs`, simulating the caller loading content.
fn open(tabs: &mut Tabs, name: &str, new_tab: bool) -> (TabId, OpenOutcome) {
let path = make_path(name);
let (id, outcome) = tabs.open_or_focus(&path, new_tab);
if matches!(outcome, OpenOutcome::Opened | OpenOutcome::Replaced) {
let tab = tabs.active_tab_mut().unwrap();
tab.view.current_path = Some(path);
tab.view.file_name = name.to_string();
}
(id, outcome)
}
#[test]
fn open_or_focus_creates_new_tab() {
let mut tabs = Tabs::new();
let (_, outcome) = open(&mut tabs, "a.md", true);
assert_eq!(outcome, OpenOutcome::Opened);
assert_eq!(tabs.len(), 1);
assert!(tabs.active.is_some());
}
#[test]
fn open_or_focus_dedupes_by_path() {
let mut tabs = Tabs::new();
open(&mut tabs, "a.md", true);
let (_, outcome) = open(&mut tabs, "a.md", true);
assert_eq!(outcome, OpenOutcome::Focused);
assert_eq!(tabs.len(), 1);
}
#[test]
fn open_or_focus_replaces_active_when_new_tab_false() {
let mut tabs = Tabs::new();
open(&mut tabs, "a.md", true);
let (_, outcome) = open(&mut tabs, "b.md", false);
assert_eq!(outcome, OpenOutcome::Replaced);
assert_eq!(tabs.len(), 1);
assert_eq!(tabs.active_tab().unwrap().view.file_name, "b.md");
}
#[test]
fn open_or_focus_pushes_when_new_tab_true() {
let mut tabs = Tabs::new();
open(&mut tabs, "a.md", true);
let (_, outcome) = open(&mut tabs, "b.md", true);
assert_eq!(outcome, OpenOutcome::Opened);
assert_eq!(tabs.len(), 2);
assert_eq!(tabs.active_tab().unwrap().view.file_name, "b.md");
}
#[test]
fn open_or_focus_caps_at_32() {
let mut tabs = Tabs::new();
for i in 0..MAX_TABS {
open(&mut tabs, &format!("{i}.md"), true);
}
assert_eq!(tabs.len(), MAX_TABS);
let (_, outcome) = open(&mut tabs, "overflow.md", true);
assert_eq!(outcome, OpenOutcome::Capped);
assert_eq!(tabs.len(), MAX_TABS);
}
#[test]
fn close_active_last_tab() {
let mut tabs = Tabs::new();
let (id, _) = open(&mut tabs, "a.md", true);
let removed = tabs.close(id);
assert!(removed);
assert_eq!(tabs.len(), 0);
assert!(tabs.active.is_none());
}
#[test]
fn close_active_switches_to_most_recent() {
let mut tabs = Tabs::new();
open(&mut tabs, "a.md", true);
let (b_id, _) = open(&mut tabs, "b.md", true);
let (c_id, _) = open(&mut tabs, "c.md", true);
tabs.close(c_id);
assert_eq!(tabs.active, Some(b_id));
}
#[test]
fn next_prev_wraparound() {
let mut tabs = Tabs::new();
let (a_id, _) = open(&mut tabs, "a.md", true);
open(&mut tabs, "b.md", true);
let (c_id, _) = open(&mut tabs, "c.md", true);
// Active is C (index 2), next wraps to A (index 0).
tabs.next();
assert_eq!(tabs.active, Some(a_id));
// Active is A (index 0), prev wraps to C (index 2).
tabs.prev();
assert_eq!(tabs.active, Some(c_id));
}
#[test]
fn activate_previous_roundtrip() {
let mut tabs = Tabs::new();
open(&mut tabs, "a.md", true);
let (b_id, _) = open(&mut tabs, "b.md", true);
let (c_id, _) = open(&mut tabs, "c.md", true);
// active=C, previous=B
tabs.activate_previous(); // now active=B, previous=C
assert_eq!(tabs.active, Some(b_id));
tabs.activate_previous(); // now active=C, previous=B
assert_eq!(tabs.active, Some(c_id));
}
#[test]
fn activate_by_index_bounds() {
let mut tabs = Tabs::new();
let (a_id, _) = open(&mut tabs, "a.md", true);
open(&mut tabs, "b.md", true);
// Out-of-range: no-op.
tabs.activate_by_index(0);
tabs.activate_by_index(99);
// Activate first tab by 1-based index 1.
tabs.activate_by_index(1);
assert_eq!(tabs.active, Some(a_id));
}
}