use serde::{Deserialize, Serialize};
use std::collections::VecDeque;
use url::Url;
pub const DEFAULT_MAX_TABS: usize = 100;
pub const DEFAULT_MAX_RECENTLY_CLOSED: usize = 20;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct TabId(pub u64);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct WindowId(pub u64);
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum LoadState {
Idle,
Loading,
Error(String),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TabState {
pub id: TabId,
pub url: Option<Url>,
pub title: String,
pub pinned: bool,
pub muted: bool,
pub private: bool,
pub zoom: f64,
pub load_state: LoadState,
}
impl TabState {
fn new(id: TabId, url: Option<Url>, private: bool) -> Self {
Self {
id,
title: url
.as_ref()
.map(ToString::to_string)
.unwrap_or_else(|| "Start".to_string()),
url,
pinned: false,
muted: false,
private,
zoom: 1.0,
load_state: LoadState::Idle,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClosedTab {
pub tab: TabState,
pub closed_from_index: usize,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum BrowserEvent {
TabCreated { tab_id: TabId, url: Option<Url> },
TabClosed { tab_id: TabId },
TabActivated { tab_id: TabId },
TabDuplicated { source: TabId, duplicate: TabId },
TabPinned { tab_id: TabId, pinned: bool },
TabMuted { tab_id: TabId, muted: bool },
NavigateRequested { tab_id: TabId, url: Url },
LoadStarted { tab_id: TabId, url: Url },
LoadFinished { tab_id: TabId, url: Url },
TitleChanged { tab_id: TabId, title: String },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BrowserState {
pub window_id: WindowId,
pub tabs: Vec<TabState>,
pub active_tab: Option<TabId>,
pub recently_closed: VecDeque<ClosedTab>,
next_tab_id: u64,
max_tabs: usize,
max_recently_closed: usize,
}
impl Default for BrowserState {
fn default() -> Self {
Self {
window_id: WindowId(1),
tabs: Vec::new(),
active_tab: None,
recently_closed: VecDeque::new(),
next_tab_id: 1,
max_tabs: DEFAULT_MAX_TABS,
max_recently_closed: DEFAULT_MAX_RECENTLY_CLOSED,
}
}
}
impl BrowserState {
pub fn create_tab(&mut self, url: Option<Url>, private: bool) -> BrowserEvent {
self.ensure_tab_capacity();
let id = self.alloc_tab_id();
let tab = TabState::new(id, url.clone(), private);
self.tabs.push(tab);
self.active_tab = Some(id);
BrowserEvent::TabCreated { tab_id: id, url }
}
pub fn close_tab(&mut self, id: TabId) -> Option<BrowserEvent> {
let index = self.tabs.iter().position(|tab| tab.id == id)?;
let tab = self.tabs.remove(index);
self.recently_closed.push_front(ClosedTab {
tab,
closed_from_index: index,
});
while self.recently_closed.len() > self.max_recently_closed {
self.recently_closed.pop_back();
}
if self.active_tab == Some(id) {
self.active_tab = self
.tabs
.get(index)
.or_else(|| index.checked_sub(1).and_then(|index| self.tabs.get(index)))
.map(|tab| tab.id);
}
Some(BrowserEvent::TabClosed { tab_id: id })
}
pub fn activate_tab(&mut self, id: TabId) -> Option<BrowserEvent> {
self.tabs.iter().any(|tab| tab.id == id).then(|| {
self.active_tab = Some(id);
BrowserEvent::TabActivated { tab_id: id }
})
}
pub fn duplicate_tab(&mut self, id: TabId) -> Option<BrowserEvent> {
let mut duplicate = self.tabs.iter().find(|tab| tab.id == id)?.clone();
self.ensure_tab_capacity();
duplicate.id = self.alloc_tab_id();
let duplicate_id = duplicate.id;
self.tabs.push(duplicate);
self.active_tab = Some(duplicate_id);
Some(BrowserEvent::TabDuplicated {
source: id,
duplicate: duplicate_id,
})
}
pub fn reopen_closed_tab(&mut self) -> Option<BrowserEvent> {
let closed = self.recently_closed.pop_front()?;
self.ensure_tab_capacity();
let mut tab = closed.tab;
tab.id = self.alloc_tab_id();
let id = tab.id;
let url = tab.url.clone();
let index = closed.closed_from_index.min(self.tabs.len());
self.tabs.insert(index, tab);
self.active_tab = Some(id);
Some(BrowserEvent::TabCreated { tab_id: id, url })
}
pub fn set_pinned(&mut self, id: TabId, pinned: bool) -> Option<BrowserEvent> {
let tab = self.tabs.iter_mut().find(|tab| tab.id == id)?;
tab.pinned = pinned;
Some(BrowserEvent::TabPinned { tab_id: id, pinned })
}
pub fn set_muted(&mut self, id: TabId, muted: bool) -> Option<BrowserEvent> {
let tab = self.tabs.iter_mut().find(|tab| tab.id == id)?;
tab.muted = muted;
Some(BrowserEvent::TabMuted { tab_id: id, muted })
}
pub fn update_title(&mut self, id: TabId, title: impl Into<String>) -> Option<BrowserEvent> {
let title = title.into();
let tab = self.tabs.iter_mut().find(|tab| tab.id == id)?;
tab.title = title.clone();
Some(BrowserEvent::TitleChanged { tab_id: id, title })
}
pub fn update_url(&mut self, id: TabId, url: Url) -> Option<BrowserEvent> {
let tab = self.tabs.iter_mut().find(|tab| tab.id == id)?;
tab.url = Some(url.clone());
Some(BrowserEvent::LoadFinished { tab_id: id, url })
}
fn alloc_tab_id(&mut self) -> TabId {
let id = TabId(self.next_tab_id);
self.next_tab_id += 1;
id
}
fn ensure_tab_capacity(&mut self) {
let max_tabs = self.max_tabs.max(1);
while self.tabs.len() >= max_tabs {
let index = self
.tabs
.iter()
.position(|tab| !tab.pinned && Some(tab.id) != self.active_tab)
.or_else(|| {
self.tabs
.iter()
.position(|tab| Some(tab.id) != self.active_tab)
})
.unwrap_or(0);
let tab = self.tabs.remove(index);
self.recently_closed.push_front(ClosedTab {
tab,
closed_from_index: index,
});
while self.recently_closed.len() > self.max_recently_closed.max(1) {
self.recently_closed.pop_back();
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn creates_and_activates_tabs() {
let mut state = BrowserState::default();
let first = state.create_tab(None, false);
let second_url = Url::parse("https://example.com/").unwrap();
let second = state.create_tab(Some(second_url.clone()), false);
assert!(matches!(first, BrowserEvent::TabCreated { .. }));
assert_eq!(state.tabs.len(), 2);
assert_eq!(state.active_tab, Some(TabId(2)));
assert_eq!(
second,
BrowserEvent::TabCreated {
tab_id: TabId(2),
url: Some(second_url)
}
);
}
#[test]
fn close_tracks_recently_closed_and_reopens() {
let mut state = BrowserState::default();
state.create_tab(Some(Url::parse("https://a.example/").unwrap()), false);
state.create_tab(Some(Url::parse("https://b.example/").unwrap()), false);
state.close_tab(TabId(2)).unwrap();
assert_eq!(state.tabs.len(), 1);
assert_eq!(state.recently_closed.len(), 1);
assert_eq!(state.active_tab, Some(TabId(1)));
state.reopen_closed_tab().unwrap();
assert_eq!(state.tabs.len(), 2);
assert_eq!(state.active_tab, Some(TabId(3)));
}
#[test]
fn duplicate_preserves_state_with_new_id() {
let mut state = BrowserState::default();
state.create_tab(Some(Url::parse("https://example.com/").unwrap()), false);
state.set_pinned(TabId(1), true).unwrap();
state.duplicate_tab(TabId(1)).unwrap();
assert_eq!(state.tabs.len(), 2);
assert_eq!(state.tabs[1].id, TabId(2));
assert!(state.tabs[1].pinned);
}
#[test]
fn tab_capacity_evicts_to_recently_closed() {
let mut state = BrowserState {
max_tabs: 2,
..BrowserState::default()
};
state.create_tab(Some(Url::parse("https://one.example/").unwrap()), false);
state.create_tab(Some(Url::parse("https://two.example/").unwrap()), false);
state.create_tab(Some(Url::parse("https://three.example/").unwrap()), false);
assert_eq!(state.tabs.len(), 2);
assert_eq!(state.recently_closed.len(), 1);
assert_eq!(state.active_tab, Some(TabId(3)));
}
#[test]
fn reopen_closed_tab_preserves_tab_capacity() {
let mut state = BrowserState {
max_tabs: 2,
max_recently_closed: 3,
..BrowserState::default()
};
state.create_tab(Some(Url::parse("https://one.example/").unwrap()), false);
state.create_tab(Some(Url::parse("https://two.example/").unwrap()), false);
state.create_tab(Some(Url::parse("https://three.example/").unwrap()), false);
assert_eq!(state.tabs.len(), 2);
assert_eq!(state.recently_closed.len(), 1);
state.reopen_closed_tab().unwrap();
assert_eq!(state.tabs.len(), 2);
assert_eq!(state.recently_closed.len(), 1);
assert_eq!(state.active_tab, Some(TabId(4)));
}
}