use crate::app::DocSearchState;
use crate::theme::Palette;
use crate::ui::markdown_view::MarkdownViewState;
use std::path::PathBuf;
pub const MAX_TABS: usize = 32;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TabId(pub u32);
pub struct Tab {
pub id: TabId,
pub view: MarkdownViewState,
pub doc_search: DocSearchState,
}
pub struct Tabs {
pub tabs: Vec<Tab>,
pub active: Option<TabId>,
pub previous: Option<TabId>,
next_id: u32,
pub view_height: u32,
}
impl Tabs {
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)
}
pub fn active_tab(&self) -> Option<&Tab> {
self.active.and_then(|id| {
let idx = self.index_of(id)?;
self.tabs.get(idx)
})
}
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)
}
pub fn active_index(&self) -> Option<usize> {
self.active.and_then(|id| self.index_of(id))
}
pub fn set_active(&mut self, id: TabId) {
if self.active != Some(id) {
self.previous = self.active;
self.active = Some(id);
}
}
pub fn open_or_focus(&mut self, path: &PathBuf, new_tab: bool) -> (TabId, OpenOutcome) {
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);
}
if !new_tab && let Some(id) = self.active {
return (id, OpenOutcome::Replaced);
}
if self.tabs.len() >= MAX_TABS {
let fallback = self.active.unwrap_or(TabId(0));
return (fallback, OpenOutcome::Capped);
}
let id = self.alloc_id();
self.tabs.push(Tab {
id,
view: MarkdownViewState::default(),
doc_search: DocSearchState::default(),
});
self.set_active(id);
(id, OpenOutcome::Opened)
}
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
}
pub fn len(&self) -> usize {
self.tabs.len()
}
pub fn is_empty(&self) -> bool {
self.tabs.is_empty()
}
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);
}
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);
}
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);
}
pub fn activate_last(&mut self) {
if let Some(last) = self.tabs.last() {
let id = last.id;
self.set_active(id);
}
}
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;
}
pub fn rerender_all(&mut self, palette: &Palette) {
for tab in &mut self.tabs {
if tab.view.current_path.is_some() {
let content = tab.view.content.clone();
let path = tab.view.current_path.clone().unwrap();
let name = tab.view.file_name.clone();
let scroll = tab.view.scroll_offset;
tab.view.load(path, name, content, palette);
tab.view.scroll_offset = scroll.min(tab.view.total_lines.saturating_sub(1));
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OpenOutcome {
Focused,
Replaced,
Opened,
Capped,
}
#[cfg(test)]
mod tests {
use super::*;
fn make_path(name: &str) -> PathBuf {
PathBuf::from(format!("/fake/{name}"))
}
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);
tabs.next();
assert_eq!(tabs.active, Some(a_id));
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);
tabs.activate_previous(); assert_eq!(tabs.active, Some(b_id));
tabs.activate_previous(); 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);
tabs.activate_by_index(0);
tabs.activate_by_index(99);
tabs.activate_by_index(1);
assert_eq!(tabs.active, Some(a_id));
}
}