use crate::backend::BackendWidget;
use crate::event::Rect;
use crate::interaction::InteractionState;
use crate::primitives::status_bar::{StatusBar, StatusBarHit, StatusBarSegment};
use crate::types::{Color, WidgetId};
use crate::Backend;
#[derive(Debug, Clone, PartialEq, Default)]
#[non_exhaustive]
pub enum SidebarPanelChrome {
#[default]
None,
Header(String),
HeaderAndSearch {
header: String,
query: String,
placeholder: String,
active: bool,
},
Search {
query: String,
placeholder: String,
active: bool,
fg: Color,
bg: Color,
},
StatusBars(Vec<StatusBar>),
}
impl SidebarPanelChrome {
fn rows(&self) -> f32 {
match self {
SidebarPanelChrome::None => 0.0,
SidebarPanelChrome::Header(_) => 1.0,
SidebarPanelChrome::HeaderAndSearch { .. } => 2.0,
SidebarPanelChrome::Search { .. } => 1.0,
SidebarPanelChrome::StatusBars(bars) => bars.len() as f32,
}
}
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct SidebarPanelBody {
pub background: Option<Color>,
pub chrome: SidebarPanelChrome,
pub scrollbar_gutter: Option<f32>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct SidebarPanelBodyLayout {
pub chrome_rect: Option<Rect>,
pub body_rect: Rect,
pub scrollbar_rect: Option<Rect>,
pub status_bar_hit_regions: Vec<(Rect, StatusBarHit)>,
}
impl SidebarPanelBody {
pub fn layout(&self, rect: Rect, unit_h: f32) -> SidebarPanelBodyLayout {
let chrome_rows = self.chrome.rows();
let chrome_h = (chrome_rows * unit_h.max(0.0)).min(rect.height.max(0.0));
let chrome_rect = if chrome_rows > 0.0 && chrome_h > 0.0 {
Some(Rect::new(rect.x, rect.y, rect.width, chrome_h))
} else {
None
};
let body_y = rect.y + chrome_h;
let body_h = (rect.height - chrome_h).max(0.0);
let gutter = self
.scrollbar_gutter
.filter(|w| *w > 0.0 && *w < rect.width);
let (body_w, scrollbar_rect) = match gutter {
Some(w) => (
rect.width - w,
Some(Rect::new(rect.x + rect.width - w, body_y, w, body_h)),
),
None => (rect.width, None),
};
SidebarPanelBodyLayout {
chrome_rect,
body_rect: Rect::new(rect.x, body_y, body_w, body_h),
scrollbar_rect,
status_bar_hit_regions: Vec::new(),
}
}
pub fn render(
&self,
backend: &mut dyn Backend,
rect: Rect,
body: &dyn BackendWidget,
) -> SidebarPanelBodyLayout {
self.render_with(backend, rect, |b, r| body.render(b, r))
}
pub fn render_with(
&self,
backend: &mut dyn Backend,
rect: Rect,
body: impl FnOnce(&mut dyn Backend, Rect),
) -> SidebarPanelBodyLayout {
let unit_h = backend.chrome_line_height();
let mut layout = self.layout(rect, unit_h);
if let Some(bg) = self.background {
backend.draw_solid_fill(rect, bg);
}
if let Some(chrome_rect) = layout.chrome_rect {
match &self.chrome {
SidebarPanelChrome::None => {}
SidebarPanelChrome::Header(text) => {
backend.draw_settings_chrome(chrome_rect, text, "", "", false);
}
SidebarPanelChrome::HeaderAndSearch {
header,
query,
placeholder,
active,
} => {
backend.draw_settings_chrome(chrome_rect, header, query, placeholder, *active);
}
SidebarPanelChrome::Search {
query,
placeholder,
active,
fg,
bg,
} => {
let show_placeholder = query.is_empty() && !placeholder.is_empty() && !*active;
let text = if show_placeholder {
format!(" / {placeholder}")
} else {
format!(" / {query}")
};
let row_bg = if *active { bg.lighten(0.08) } else { *bg };
let search_bar = StatusBar {
id: WidgetId::new("sidebar-panel-body:search"),
left_segments: vec![StatusBarSegment {
text,
fg: *fg,
bg: row_bg,
bold: false,
action_id: None,
}],
right_segments: Vec::new(),
};
let _ = backend.draw_status_bar_interactive(
chrome_rect,
&search_bar,
&InteractionState::new(),
);
}
SidebarPanelChrome::StatusBars(bars) => {
let interaction = InteractionState::new();
let mut row_y = chrome_rect.y;
for bar in bars {
let remaining = (chrome_rect.y + chrome_rect.height - row_y).max(0.0);
let row_h = unit_h.min(remaining);
if row_h <= 0.0 {
break;
}
let row_rect = Rect::new(chrome_rect.x, row_y, chrome_rect.width, row_h);
let bar_layout =
backend.draw_status_bar_interactive(row_rect, bar, &interaction);
for (r, hit) in bar_layout.hit_regions {
layout.status_bar_hit_regions.push((
Rect::new(row_rect.x + r.x, row_rect.y + r.y, r.width, r.height),
hit,
));
}
row_y += row_h;
}
}
}
}
body(backend, layout.body_rect);
layout
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testing::RecordingBackend;
struct RecordingWidget;
impl BackendWidget for RecordingWidget {
fn render(&self, backend: &mut dyn Backend, rect: Rect) {
backend.draw_solid_fill(rect, Color::rgb(1, 2, 3));
}
}
fn rect() -> Rect {
Rect::new(0.0, 0.0, 40.0, 20.0)
}
#[test]
fn no_chrome_no_scrollbar_body_fills_rect() {
let panel = SidebarPanelBody::default();
let layout = panel.layout(rect(), 1.0);
assert_eq!(layout.chrome_rect, None);
assert_eq!(layout.scrollbar_rect, None);
assert_eq!(layout.body_rect, rect());
}
#[test]
fn header_only_reserves_one_row() {
let panel = SidebarPanelBody {
chrome: SidebarPanelChrome::Header("DEBUG".into()),
..Default::default()
};
let layout = panel.layout(rect(), 1.0);
assert_eq!(layout.chrome_rect, Some(Rect::new(0.0, 0.0, 40.0, 1.0)));
assert_eq!(layout.body_rect, Rect::new(0.0, 1.0, 40.0, 19.0));
}
#[test]
fn header_and_search_reserves_two_rows() {
let panel = SidebarPanelBody {
chrome: SidebarPanelChrome::HeaderAndSearch {
header: "EXTENSIONS".into(),
query: String::new(),
placeholder: "Search".into(),
active: false,
},
..Default::default()
};
let layout = panel.layout(rect(), 1.0);
assert_eq!(layout.chrome_rect, Some(Rect::new(0.0, 0.0, 40.0, 2.0)));
assert_eq!(layout.body_rect, Rect::new(0.0, 2.0, 40.0, 18.0));
}
#[test]
fn chrome_rows_use_pixel_unit_h() {
let panel = SidebarPanelBody {
chrome: SidebarPanelChrome::HeaderAndSearch {
header: "SETTINGS".into(),
query: "q".into(),
placeholder: String::new(),
active: true,
},
..Default::default()
};
let tall = Rect::new(0.0, 0.0, 240.0, 200.0);
let layout = panel.layout(tall, 18.0);
assert_eq!(layout.chrome_rect, Some(Rect::new(0.0, 0.0, 240.0, 36.0)));
assert_eq!(layout.body_rect, Rect::new(0.0, 36.0, 240.0, 164.0));
}
#[test]
fn scrollbar_gutter_reserves_right_column() {
let panel = SidebarPanelBody {
scrollbar_gutter: Some(1.0),
..Default::default()
};
let layout = panel.layout(rect(), 1.0);
assert_eq!(layout.scrollbar_rect, Some(Rect::new(39.0, 0.0, 1.0, 20.0)));
assert_eq!(layout.body_rect, Rect::new(0.0, 0.0, 39.0, 20.0));
}
#[test]
fn scrollbar_gutter_and_chrome_compose() {
let panel = SidebarPanelBody {
chrome: SidebarPanelChrome::Header("EXT".into()),
scrollbar_gutter: Some(2.0),
..Default::default()
};
let layout = panel.layout(rect(), 1.0);
assert_eq!(layout.chrome_rect, Some(Rect::new(0.0, 0.0, 40.0, 1.0)));
assert_eq!(layout.scrollbar_rect, Some(Rect::new(38.0, 1.0, 2.0, 19.0)));
assert_eq!(layout.body_rect, Rect::new(0.0, 1.0, 38.0, 19.0));
}
#[test]
fn zero_width_gutter_is_treated_as_none() {
let panel = SidebarPanelBody {
scrollbar_gutter: Some(0.0),
..Default::default()
};
let layout = panel.layout(rect(), 1.0);
assert_eq!(layout.scrollbar_rect, None);
assert_eq!(layout.body_rect, rect());
}
#[test]
fn gutter_wider_than_rect_is_treated_as_none() {
let panel = SidebarPanelBody {
scrollbar_gutter: Some(1000.0),
..Default::default()
};
let layout = panel.layout(rect(), 1.0);
assert_eq!(layout.scrollbar_rect, None);
assert_eq!(layout.body_rect, rect());
}
#[test]
fn chrome_taller_than_rect_is_clamped() {
let panel = SidebarPanelBody {
chrome: SidebarPanelChrome::HeaderAndSearch {
header: "X".into(),
query: String::new(),
placeholder: String::new(),
active: false,
},
..Default::default()
};
let tiny = Rect::new(0.0, 0.0, 10.0, 1.0);
let layout = panel.layout(tiny, 1.0);
assert_eq!(layout.chrome_rect, Some(Rect::new(0.0, 0.0, 10.0, 1.0)));
assert_eq!(layout.body_rect, Rect::new(0.0, 1.0, 10.0, 0.0));
}
#[test]
fn render_paints_background_then_chrome_then_body_in_order() {
let panel = SidebarPanelBody {
background: Some(Color::rgb(10, 10, 10)),
chrome: SidebarPanelChrome::HeaderAndSearch {
header: "EXTENSIONS".into(),
query: String::new(),
placeholder: "Search".into(),
active: false,
},
scrollbar_gutter: None,
};
let mut backend = RecordingBackend::new();
panel.render(&mut backend, rect(), &RecordingWidget);
assert_eq!(
backend.calls,
vec!["draw_solid_fill", "draw_settings_chrome", "draw_solid_fill"]
);
}
#[test]
fn render_with_no_background_and_no_chrome_only_paints_body() {
let panel = SidebarPanelBody::default();
let mut backend = RecordingBackend::new();
panel.render(&mut backend, rect(), &RecordingWidget);
assert_eq!(backend.calls, vec!["draw_solid_fill"]);
}
#[test]
fn render_header_only_chrome_does_not_call_settings_chrome_twice() {
let panel = SidebarPanelBody {
chrome: SidebarPanelChrome::Header("DEBUG".into()),
..Default::default()
};
let mut backend = RecordingBackend::new();
panel.render(&mut backend, rect(), &RecordingWidget);
assert_eq!(
backend
.calls
.iter()
.filter(|c| **c == "draw_settings_chrome")
.count(),
1
);
}
#[test]
fn render_returns_same_layout_as_layout_method() {
let panel = SidebarPanelBody {
chrome: SidebarPanelChrome::Header("DEBUG".into()),
scrollbar_gutter: Some(1.0),
..Default::default()
};
let mut backend = RecordingBackend::new();
let rendered = panel.render(&mut backend, rect(), &RecordingWidget);
let computed = panel.layout(rect(), backend.chrome_line_height());
assert_eq!(rendered, computed);
}
#[test]
fn render_with_paints_borrowed_rc_refcell_state() {
use std::cell::RefCell;
use std::rc::Rc;
let rows = Rc::new(RefCell::new(vec!["alpha".to_string(), "beta".to_string()]));
let panel = SidebarPanelBody {
background: Some(Color::rgb(5, 5, 5)),
chrome: SidebarPanelChrome::Header("TREE".into()),
..Default::default()
};
let mut backend = RecordingBackend::new();
let layout = panel.render_with(&mut backend, rect(), |b, body_rect| {
rows.borrow_mut().push("gamma".to_string());
if !rows.borrow().is_empty() {
b.draw_solid_fill(body_rect, Color::rgb(1, 2, 3));
}
});
assert_eq!(rows.borrow().len(), 3);
assert_eq!(
backend.calls,
vec!["draw_solid_fill", "draw_settings_chrome", "draw_solid_fill"]
);
assert_eq!(layout, panel.layout(rect(), 1.0));
}
#[test]
fn render_with_body_receives_body_rect_not_full_rect() {
let panel = SidebarPanelBody {
chrome: SidebarPanelChrome::Header("DEBUG".into()),
scrollbar_gutter: Some(1.0),
..Default::default()
};
let mut backend = RecordingBackend::new();
let mut seen_rect = None;
let layout = panel.render_with(&mut backend, rect(), |_b, body_rect| {
seen_rect = Some(body_rect);
});
assert_eq!(seen_rect, Some(layout.body_rect));
assert_ne!(seen_rect, Some(rect()));
}
fn search_chrome(query: &str, placeholder: &str, active: bool) -> SidebarPanelChrome {
SidebarPanelChrome::Search {
query: query.into(),
placeholder: placeholder.into(),
active,
fg: Color::rgb(200, 200, 200),
bg: Color::rgb(30, 30, 40),
}
}
#[test]
fn search_reserves_one_row() {
let panel = SidebarPanelBody {
chrome: search_chrome("", "Search extensions", false),
..Default::default()
};
let layout = panel.layout(rect(), 1.0);
assert_eq!(layout.chrome_rect, Some(Rect::new(0.0, 0.0, 40.0, 1.0)));
assert_eq!(layout.body_rect, Rect::new(0.0, 1.0, 40.0, 19.0));
assert!(layout.status_bar_hit_regions.is_empty());
}
#[test]
fn render_search_paints_background_then_one_status_bar_call_then_body() {
let panel = SidebarPanelBody {
background: Some(Color::rgb(10, 10, 10)),
chrome: search_chrome("rust", "Search", true),
..Default::default()
};
let mut backend = RecordingBackend::new();
panel.render(&mut backend, rect(), &RecordingWidget);
assert_eq!(
backend.calls,
vec!["draw_solid_fill", "draw_status_bar", "draw_solid_fill"]
);
}
#[test]
fn render_search_does_not_call_settings_chrome() {
let panel = SidebarPanelBody {
chrome: search_chrome("", "", false),
..Default::default()
};
let mut backend = RecordingBackend::new();
panel.render(&mut backend, rect(), &RecordingWidget);
assert!(!backend.calls.contains(&"draw_settings_chrome"));
}
fn status_bar_with_segment(id: &str, seg_text: &str, action_id: Option<&str>) -> StatusBar {
StatusBar {
id: WidgetId::new(id),
left_segments: vec![StatusBarSegment {
text: seg_text.into(),
fg: Color::rgb(255, 255, 255),
bg: Color::rgb(20, 20, 20),
bold: false,
action_id: action_id.map(WidgetId::new),
}],
right_segments: Vec::new(),
}
}
#[test]
fn status_bars_reserves_one_row_per_bar() {
let bars = vec![
status_bar_with_segment("debug:title", "DEBUG", None),
status_bar_with_segment("debug:actions", "RUN", Some("debug:run")),
];
let panel = SidebarPanelBody {
chrome: SidebarPanelChrome::StatusBars(bars),
..Default::default()
};
let layout = panel.layout(rect(), 1.0);
assert_eq!(layout.chrome_rect, Some(Rect::new(0.0, 0.0, 40.0, 2.0)));
assert_eq!(layout.body_rect, Rect::new(0.0, 2.0, 40.0, 18.0));
assert!(layout.status_bar_hit_regions.is_empty());
}
#[test]
fn status_bars_with_no_bars_reserves_nothing() {
let panel = SidebarPanelBody {
chrome: SidebarPanelChrome::StatusBars(Vec::new()),
..Default::default()
};
let layout = panel.layout(rect(), 1.0);
assert_eq!(layout.chrome_rect, None);
assert_eq!(layout.body_rect, rect());
}
#[test]
fn render_status_bars_paints_one_call_per_bar_in_order() {
let bars = vec![
status_bar_with_segment("debug:title", "DEBUG", None),
status_bar_with_segment("debug:actions", "RUN", Some("debug:run")),
];
let panel = SidebarPanelBody {
background: Some(Color::rgb(1, 1, 1)),
chrome: SidebarPanelChrome::StatusBars(bars),
..Default::default()
};
let mut backend = RecordingBackend::new();
panel.render(&mut backend, rect(), &RecordingWidget);
assert_eq!(
backend.calls,
vec![
"draw_solid_fill",
"draw_status_bar",
"draw_status_bar",
"draw_solid_fill",
]
);
}
#[test]
fn render_status_bars_surfaces_hit_regions_translated_into_panel_space() {
let title = status_bar_with_segment("debug:title", "DEBUG", None);
let actions = status_bar_with_segment("debug:actions", "RUN", Some("debug:run"));
let panel = SidebarPanelBody {
chrome: SidebarPanelChrome::StatusBars(vec![title, actions.clone()]),
..Default::default()
};
let mut backend = RecordingBackend::new(); let layout = panel.render(&mut backend, rect(), &RecordingWidget);
let bar_layout = actions.layout(rect().width, 1.0, 2.0, |seg| {
crate::StatusSegmentMeasure::new(seg.text.chars().count() as f32)
});
let (bar_local_rect, hit) = bar_layout.hit_regions[0].clone();
let expected = (
Rect::new(
bar_local_rect.x,
1.0 + bar_local_rect.y,
bar_local_rect.width,
bar_local_rect.height,
),
hit,
);
assert_eq!(layout.status_bar_hit_regions, vec![expected]);
}
#[test]
fn render_delegates_to_render_with_identically() {
let panel = SidebarPanelBody {
background: Some(Color::rgb(10, 10, 10)),
chrome: SidebarPanelChrome::HeaderAndSearch {
header: "EXTENSIONS".into(),
query: String::new(),
placeholder: "Search".into(),
active: false,
},
scrollbar_gutter: Some(1.0),
};
let mut via_render = RecordingBackend::new();
let layout_a = panel.render(&mut via_render, rect(), &RecordingWidget);
let mut via_render_with = RecordingBackend::new();
let layout_b = panel.render_with(&mut via_render_with, rect(), |b, r| {
RecordingWidget.render(b, r)
});
assert_eq!(layout_a, layout_b);
assert_eq!(via_render.calls, via_render_with.calls);
}
}