pub use crate::backend::BackendWidget;
#[allow(deprecated)]
use crate::primitives::tab_bar::{TabBar, TabBarHits, TabBarSegment, TabItem};
use crate::types::WidgetId;
use crate::{Backend, Rect};
pub struct BottomPanelTab {
pub id: String,
pub label: String,
pub closable: bool,
pub badge: Option<String>,
pub content: Box<dyn BackendWidget>,
}
pub struct BottomPanelConfig {
pub tabs: Vec<BottomPanelTab>,
pub active_tab_id: String,
pub maximised: bool,
pub height_fraction: f32,
}
impl Default for BottomPanelConfig {
fn default() -> Self {
Self {
tabs: Vec::new(),
active_tab_id: String::new(),
maximised: false,
height_fraction: 0.3,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum BottomPanelEvent {
TabActivated(String),
TabClosed(String),
MaximiseToggled,
Resized(f32),
}
#[derive(Debug, Clone, PartialEq)]
pub struct BottomPanelLayout {
pub tab_strip_bounds: Rect,
pub content_bounds: Rect,
}
pub struct BottomPanelController {
pub active_tab_id: String,
pub maximised: bool,
tabs: Vec<BottomPanelTab>,
pub height_fraction: f32,
tab_scroll_offset: usize,
#[allow(deprecated)] last_hits: Option<TabBarHits>,
last_strip_bounds: Option<Rect>,
}
impl BottomPanelController {
pub fn new(config: BottomPanelConfig) -> Self {
let active_tab_id = if config.tabs.iter().any(|t| t.id == config.active_tab_id) {
config.active_tab_id
} else {
config
.tabs
.first()
.map(|t| t.id.clone())
.unwrap_or_default()
};
Self {
active_tab_id,
maximised: config.maximised,
tabs: config.tabs,
height_fraction: config.height_fraction,
tab_scroll_offset: 0,
last_hits: None,
last_strip_bounds: None,
}
}
pub fn tabs(&self) -> &[BottomPanelTab] {
&self.tabs
}
pub fn toggle_maximised(&mut self) {
self.maximised = !self.maximised;
}
pub fn activate_tab(&mut self, id: String) {
self.active_tab_id = id;
self.tab_scroll_offset = 0;
}
pub fn close_tab(&mut self, id: &str) -> bool {
let Some(idx) = self.tabs.iter().position(|t| t.id == id) else {
return false;
};
self.tabs.remove(idx);
if self.active_tab_id == id {
self.active_tab_id = self.tabs.first().map(|t| t.id.clone()).unwrap_or_default();
}
true
}
#[allow(deprecated)] pub fn render(&mut self, backend: &mut dyn Backend, panel_bounds: Rect) -> BottomPanelLayout {
let lh = backend.chrome_line_height();
let strip_h = lh;
let strip = Rect::new(panel_bounds.x, panel_bounds.y, panel_bounds.width, strip_h);
let content_h = (panel_bounds.height - strip_h).max(0.0);
let content = Rect::new(
panel_bounds.x,
panel_bounds.y + strip_h,
panel_bounds.width,
content_h,
);
let tab_bar = self.build_tab_bar();
let hits = backend.draw_tab_bar(strip, &tab_bar, None);
self.tab_scroll_offset = hits.correct_scroll_offset;
self.last_hits = Some(hits);
self.last_strip_bounds = Some(strip);
if content.width > 0.0 && content.height > 0.0 {
if let Some(idx) = self.tabs.iter().position(|t| t.id == self.active_tab_id) {
self.tabs[idx].content.render(backend, content);
}
}
BottomPanelLayout {
tab_strip_bounds: strip,
content_bounds: content,
}
}
#[allow(deprecated)] pub fn handle_click(&mut self, x: f32, y: f32) -> Option<BottomPanelEvent> {
let strip = self.last_strip_bounds?;
if y < strip.y || y >= strip.y + strip.height {
return None;
}
let hits = self.last_hits.as_ref()?;
let click_x = x as f64;
let in_range = |range: (f64, f64)| click_x >= range.0 && click_x < range.1;
for (idx, cb) in hits.close_bounds.iter().enumerate() {
if let Some(range) = cb {
if in_range(*range) {
if let Some(tab) = self.tabs.get(idx) {
if tab.closable {
let id = tab.id.clone();
self.close_tab(&id);
return Some(BottomPanelEvent::TabClosed(id));
}
}
return None;
}
}
}
if hits.right_segment_bounds.iter().copied().any(in_range) {
self.toggle_maximised();
return Some(BottomPanelEvent::MaximiseToggled);
}
for (idx, range) in hits.slot_positions.iter().enumerate() {
if in_range(*range) {
if let Some(tab) = self.tabs.get(idx) {
let id = tab.id.clone();
if id != self.active_tab_id {
self.activate_tab(id.clone());
return Some(BottomPanelEvent::TabActivated(id));
}
}
return None;
}
}
None
}
fn build_tab_bar(&self) -> TabBar {
let tabs: Vec<TabItem> = self
.tabs
.iter()
.map(|t| {
let label = match &t.badge {
Some(b) => format!("{} ({})", t.label, b),
None => t.label.clone(),
};
TabItem {
label,
is_active: t.id == self.active_tab_id,
is_dirty: false,
is_preview: false,
is_closable: t.closable,
}
})
.collect();
let max_text = if self.maximised { " v " } else { " ^ " };
let right_segments = vec![TabBarSegment {
text: max_text.to_string(),
width_cells: 3,
id: Some(WidgetId::new("bp:maximise")),
is_active: self.maximised,
}];
TabBar {
id: WidgetId::new("bottom-panel:tabs"),
tabs,
scroll_offset: self.tab_scroll_offset,
right_segments,
active_accent: None,
show_tab_close: self.tabs.iter().any(|t| t.closable),
compact: true,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
struct NoOpWidget;
impl BackendWidget for NoOpWidget {
fn render(&self, _backend: &mut dyn Backend, _rect: Rect) {}
}
fn tab(id: &str, label: &str, closable: bool) -> BottomPanelTab {
BottomPanelTab {
id: id.to_string(),
label: label.to_string(),
closable,
badge: None,
content: Box::new(NoOpWidget),
}
}
fn make_config(tabs: Vec<BottomPanelTab>, active: &str) -> BottomPanelConfig {
BottomPanelConfig {
tabs,
active_tab_id: active.to_string(),
maximised: false,
height_fraction: 0.3,
}
}
#[allow(deprecated)] fn prime_layout(ctrl: &mut BottomPanelController, strip_x: f32, strip_y: f32, bar_w: f32) {
use crate::backend::tab_bar_hits_from_layout;
use crate::primitives::tab_bar::{SegmentMeasure, TabMeasure};
let tab_bar = ctrl.build_tab_bar();
let n = ctrl.tabs.len();
let layout = tab_bar.layout(
bar_w,
1.0,
0.0,
|i| {
if i < n && ctrl.tabs[i].closable {
TabMeasure::new(6.0, 2.0)
} else {
TabMeasure::new(6.0, 0.0)
}
},
|_| SegmentMeasure::new(3.0),
);
let mut hits = tab_bar_hits_from_layout(&layout, &tab_bar);
let ox = strip_x as f64;
for sp in &mut hits.slot_positions {
if *sp != (0.0, 0.0) {
sp.0 += ox;
sp.1 += ox;
}
}
for cb in hits.close_bounds.iter_mut().flatten() {
cb.0 += ox;
cb.1 += ox;
}
for rb in &mut hits.right_segment_bounds {
rb.0 += ox;
rb.1 += ox;
}
ctrl.last_hits = Some(hits);
ctrl.last_strip_bounds = Some(Rect::new(strip_x, strip_y, bar_w, 1.0));
}
#[test]
fn new_controller_reflects_initial_state() {
let cfg = make_config(vec![tab("t0", "TERM", false)], "t0");
let ctrl = BottomPanelController::new(cfg);
assert_eq!(ctrl.active_tab_id, "t0");
assert!(!ctrl.maximised);
assert_eq!(ctrl.tabs().len(), 1);
}
#[test]
fn toggle_maximised_flips_state_twice() {
let cfg = make_config(vec![], "");
let mut ctrl = BottomPanelController::new(cfg);
ctrl.toggle_maximised();
assert!(ctrl.maximised);
ctrl.toggle_maximised();
assert!(!ctrl.maximised);
}
#[test]
fn activate_tab_changes_active_and_resets_scroll() {
let cfg = make_config(vec![tab("t0", "A", false), tab("t1", "B", false)], "t0");
let mut ctrl = BottomPanelController::new(cfg);
ctrl.tab_scroll_offset = 2; ctrl.activate_tab("t1".to_string());
assert_eq!(ctrl.active_tab_id, "t1");
assert_eq!(ctrl.tab_scroll_offset, 0);
}
#[test]
fn close_tab_removes_it_and_falls_back_to_first() {
let cfg = make_config(
vec![
tab("t0", "A", true),
tab("t1", "B", false),
tab("t2", "C", false),
],
"t0",
);
let mut ctrl = BottomPanelController::new(cfg);
let removed = ctrl.close_tab("t0");
assert!(removed);
assert_eq!(ctrl.tabs().len(), 2);
assert_eq!(ctrl.active_tab_id, "t1"); }
#[test]
fn close_inactive_tab_preserves_active() {
let cfg = make_config(vec![tab("t0", "A", false), tab("t1", "B", true)], "t0");
let mut ctrl = BottomPanelController::new(cfg);
ctrl.close_tab("t1");
assert_eq!(ctrl.active_tab_id, "t0"); assert_eq!(ctrl.tabs().len(), 1);
}
#[test]
fn close_unknown_tab_returns_false() {
let cfg = make_config(vec![tab("t0", "A", false)], "t0");
let mut ctrl = BottomPanelController::new(cfg);
assert!(!ctrl.close_tab("no-such-id"));
}
#[test]
fn handle_click_without_render_returns_none() {
let cfg = make_config(vec![tab("t0", "T", false)], "t0");
let mut ctrl = BottomPanelController::new(cfg);
assert!(ctrl.handle_click(0.0, 0.0).is_none());
}
#[test]
fn handle_click_above_strip_is_none() {
let cfg = make_config(vec![tab("t0", "T", false)], "t0");
let mut ctrl = BottomPanelController::new(cfg);
prime_layout(&mut ctrl, 0.0, 10.0, 80.0);
assert!(ctrl.handle_click(5.0, 5.0).is_none()); }
#[test]
fn handle_click_below_strip_is_none() {
let cfg = make_config(vec![tab("t0", "T", false)], "t0");
let mut ctrl = BottomPanelController::new(cfg);
prime_layout(&mut ctrl, 0.0, 10.0, 80.0);
assert!(ctrl.handle_click(5.0, 12.0).is_none()); }
#[test]
fn click_on_inactive_tab_emits_tab_activated() {
let cfg = make_config(
vec![tab("t0", "AAAA", false), tab("t1", "BBBB", false)],
"t0",
);
let mut ctrl = BottomPanelController::new(cfg);
prime_layout(&mut ctrl, 0.0, 0.0, 80.0);
let ev = ctrl.handle_click(9.0, 0.5); assert_eq!(ev, Some(BottomPanelEvent::TabActivated("t1".to_string())));
assert_eq!(ctrl.active_tab_id, "t1"); }
#[test]
fn click_on_already_active_tab_returns_none() {
let cfg = make_config(vec![tab("t0", "AAAA", false)], "t0");
let mut ctrl = BottomPanelController::new(cfg);
prime_layout(&mut ctrl, 0.0, 0.0, 80.0);
let ev = ctrl.handle_click(2.0, 0.5); assert_eq!(ev, None);
}
#[test]
fn click_on_close_button_emits_tab_closed_and_removes_tab() {
let cfg = make_config(vec![tab("t0", "AAAA", true)], "t0");
let mut ctrl = BottomPanelController::new(cfg);
prime_layout(&mut ctrl, 0.0, 0.0, 80.0);
let ev = ctrl.handle_click(5.0, 0.5); assert_eq!(ev, Some(BottomPanelEvent::TabClosed("t0".to_string())));
assert_eq!(ctrl.tabs().len(), 0);
}
#[test]
fn click_on_non_closable_tab_close_area_returns_none() {
let cfg = make_config(vec![tab("t0", "AAAA", false)], "t0");
let mut ctrl = BottomPanelController::new(cfg);
prime_layout(&mut ctrl, 0.0, 0.0, 80.0);
let ev = ctrl.handle_click(5.0, 0.5);
assert_eq!(ev, None);
}
#[test]
fn click_switches_and_closes_when_strip_offset_from_origin() {
let cfg = make_config(
vec![tab("t0", "AAAA", true), tab("t1", "BBBB", false)],
"t0",
);
let mut ctrl = BottomPanelController::new(cfg);
prime_layout(&mut ctrl, 20.0, 0.0, 100.0);
let ev = ctrl.handle_click(28.0, 0.5);
assert_eq!(ev, Some(BottomPanelEvent::TabActivated("t1".to_string())));
assert_eq!(ctrl.active_tab_id, "t1");
prime_layout(&mut ctrl, 20.0, 0.0, 100.0);
let ev = ctrl.handle_click(25.0, 0.5);
assert_eq!(ev, Some(BottomPanelEvent::TabClosed("t0".to_string())));
assert_eq!(ctrl.tabs().len(), 1);
}
#[test]
fn click_on_maximise_toggles_and_emits_event() {
let cfg = make_config(vec![tab("t0", "AAAA", false)], "t0");
let mut ctrl = BottomPanelController::new(cfg);
prime_layout(&mut ctrl, 0.0, 0.0, 80.0);
let ev = ctrl.handle_click(78.0, 0.5);
assert_eq!(ev, Some(BottomPanelEvent::MaximiseToggled));
assert!(ctrl.maximised);
prime_layout(&mut ctrl, 0.0, 0.0, 80.0); let ev2 = ctrl.handle_click(78.0, 0.5);
assert_eq!(ev2, Some(BottomPanelEvent::MaximiseToggled));
assert!(!ctrl.maximised);
}
#[test]
fn build_tab_bar_shows_caret_when_docked() {
let cfg = make_config(vec![tab("t0", "T", false)], "t0");
let ctrl = BottomPanelController::new(cfg);
let bar = ctrl.build_tab_bar();
assert_eq!(bar.right_segments[0].text, " ^ ");
}
#[test]
fn build_tab_bar_shows_down_arrow_when_maximised() {
let cfg = make_config(vec![tab("t0", "T", false)], "t0");
let mut ctrl = BottomPanelController::new(cfg);
ctrl.maximised = true;
let bar = ctrl.build_tab_bar();
assert_eq!(bar.right_segments[0].text, " v ");
}
#[test]
fn build_tab_bar_propagates_per_tab_closability() {
let cfg = make_config(
vec![tab("t0", "TERM", false), tab("t1", "LOGS", true)],
"t0",
);
let ctrl = BottomPanelController::new(cfg);
let bar = ctrl.build_tab_bar();
assert!(
!bar.tabs[0].is_closable,
"non-closable tab should have is_closable=false"
);
assert!(
bar.tabs[1].is_closable,
"closable tab should have is_closable=true"
);
}
#[test]
fn mixed_closable_tabs_only_closable_tab_emits_close_event() {
let cfg = make_config(
vec![tab("t0", "AAAA", false), tab("t1", "BBBB", true)],
"t0",
);
let mut ctrl = BottomPanelController::new(cfg);
prime_layout(&mut ctrl, 0.0, 0.0, 80.0);
let ev = ctrl.handle_click(3.0, 0.5);
assert_eq!(ev, None, "click on non-closable active tab body → None");
ctrl.activate_tab("t1".to_string());
prime_layout(&mut ctrl, 0.0, 0.0, 80.0);
let ev = ctrl.handle_click(3.0, 0.5);
assert_eq!(ev, Some(BottomPanelEvent::TabActivated("t0".to_string())));
prime_layout(&mut ctrl, 0.0, 0.0, 80.0);
let ev = ctrl.handle_click(11.0, 0.5); assert_eq!(ev, Some(BottomPanelEvent::TabClosed("t1".to_string())));
assert_eq!(ctrl.tabs().len(), 1, "t1 removed; only t0 remains");
}
#[test]
fn new_falls_back_to_first_tab_when_active_id_unknown() {
let cfg = make_config(
vec![tab("t0", "A", false), tab("t1", "B", false)],
"no-such-id",
);
let ctrl = BottomPanelController::new(cfg);
assert_eq!(ctrl.active_tab_id, "t0", "should fall back to first tab");
}
#[test]
fn new_uses_empty_string_when_no_tabs_and_unknown_id() {
let cfg = make_config(vec![], "no-such-id");
let ctrl = BottomPanelController::new(cfg);
assert_eq!(ctrl.active_tab_id, "", "no tabs → empty active_tab_id");
}
}