use crate::event::Rect;
use crate::primitives::toolbar::{Toolbar, ToolbarHit, ToolbarItemMeasure, ToolbarLayout};
use crate::types::WidgetId;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SidebarPanel {
pub id: WidgetId,
#[serde(default)]
pub toolbar: Option<Toolbar>,
#[serde(default)]
pub toolbar_height: Option<f32>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct SidebarPanelLayout {
pub panel_bounds: Rect,
pub toolbar_bounds: Option<Rect>,
pub content_bounds: Rect,
pub toolbar_layout: Option<ToolbarLayout>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum SidebarPanelHit {
ToolbarButton(WidgetId),
ToolbarEmpty,
Content { x: f32, y: f32 },
Empty,
}
impl SidebarPanelLayout {
pub fn hit_test(&self, x: f32, y: f32) -> SidebarPanelHit {
if let (Some(tb), Some(tlayout)) = (self.toolbar_bounds, &self.toolbar_layout) {
if contains(tb, x, y) {
return match tlayout.hit_test(x, y) {
ToolbarHit::Button(id) => SidebarPanelHit::ToolbarButton(id),
ToolbarHit::Empty => SidebarPanelHit::ToolbarEmpty,
};
}
}
if contains(self.content_bounds, x, y) {
return SidebarPanelHit::Content {
x: x - self.content_bounds.x,
y: y - self.content_bounds.y,
};
}
SidebarPanelHit::Empty
}
}
fn contains(r: Rect, x: f32, y: f32) -> bool {
x >= r.x && x < r.x + r.width && y >= r.y && y < r.y + r.height
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SidebarPanelMeasure {
pub default_toolbar_height: f32,
pub item_width: f32,
}
impl SidebarPanelMeasure {
pub fn new(default_toolbar_height: f32, item_width: f32) -> Self {
Self {
default_toolbar_height,
item_width,
}
}
}
impl SidebarPanel {
pub fn layout<F>(
&self,
bounds: Rect,
measure: SidebarPanelMeasure,
measure_item: F,
) -> SidebarPanelLayout
where
F: Fn(&crate::primitives::toolbar::ToolbarButton) -> ToolbarItemMeasure,
{
let toolbar_height = self
.toolbar_height
.unwrap_or(measure.default_toolbar_height);
match &self.toolbar {
Some(bar) => {
let slot_height = toolbar_height.min(bounds.height).max(0.0);
let toolbar_bounds = Rect::new(bounds.x, bounds.y, bounds.width, slot_height);
let content_y = bounds.y + slot_height;
let content_h = (bounds.height - slot_height).max(0.0);
let content_bounds = Rect::new(bounds.x, content_y, bounds.width, content_h);
let toolbar_layout = bar.layout(
toolbar_bounds.x,
toolbar_bounds.y,
toolbar_bounds.width,
toolbar_bounds.height,
measure_item,
);
SidebarPanelLayout {
panel_bounds: bounds,
toolbar_bounds: Some(toolbar_bounds),
content_bounds,
toolbar_layout: Some(toolbar_layout),
}
}
None => SidebarPanelLayout {
panel_bounds: bounds,
toolbar_bounds: None,
content_bounds: bounds,
toolbar_layout: None,
},
}
}
}
#[cfg(any(
feature = "gtk",
feature = "win",
all(feature = "macos", target_os = "macos")
))]
#[allow(dead_code)]
pub(crate) mod native_surface_paint {
use super::{SidebarPanel, SidebarPanelLayout, SidebarPanelMeasure};
use crate::event::{Point, Rect};
use crate::paint_surface::PaintSurface;
use crate::primitives::layout_metrics::TextMeasure;
use crate::primitives::toolbar::{
action_text, measure_button, Toolbar, ToolbarButton, ToolbarItemMeasure, ToolbarLayout,
};
use crate::theme::Theme;
use crate::types::WidgetId;
struct SurfaceMeasure<'a>(&'a dyn PaintSurface);
impl TextMeasure for SurfaceMeasure<'_> {
fn width_of(&self, text: &str) -> f32 {
self.0.surface_measure_text(text).0
}
}
pub(crate) fn paint(
panel: &SidebarPanel,
surface: &mut dyn PaintSurface,
theme: &Theme,
bounds: Rect,
default_toolbar_height: f32,
hovered_toolbar_id: Option<&WidgetId>,
pressed_toolbar_id: Option<&WidgetId>,
) -> SidebarPanelLayout {
let layout = {
let measurer = SurfaceMeasure(&*surface);
panel.layout(
bounds,
SidebarPanelMeasure::new(default_toolbar_height, 0.0),
|btn| ToolbarItemMeasure::new(measure_button(&measurer, btn)),
)
};
if bounds.width <= 0.0 || bounds.height <= 0.0 {
return layout;
}
if let (Some(bar), Some(tb_bounds)) = (&panel.toolbar, layout.toolbar_bounds) {
if let Some(toolbar_layout) = &layout.toolbar_layout {
paint_toolbar_header(
bar,
toolbar_layout,
surface,
theme,
tb_bounds,
hovered_toolbar_id,
pressed_toolbar_id,
);
}
}
layout
}
fn paint_toolbar_header(
bar: &Toolbar,
toolbar_layout: &ToolbarLayout,
surface: &mut dyn PaintSurface,
theme: &Theme,
tb_bounds: Rect,
hovered_id: Option<&WidgetId>,
pressed_id: Option<&WidgetId>,
) {
surface.surface_push_clip(tb_bounds);
let bar_bg = bar.bg.unwrap_or(theme.header_bg);
surface.surface_fill_rect(tb_bounds, bar_bg);
for vis in &toolbar_layout.visible_items {
let item = vis.bounds;
if item.width <= 0.0 || item.height <= 0.0 {
continue;
}
let Some(btn) = bar.buttons.get(vis.item_idx) else {
continue;
};
match btn {
ToolbarButton::Action {
id,
label,
icon,
key_hint,
enabled,
is_active,
..
} => {
let is_hovered = *enabled && hovered_id == Some(id);
let is_pressed = *enabled && pressed_id == Some(id);
let is_focused = *enabled && bar.focused_index == Some(vis.item_idx);
let highlight = if is_pressed || *is_active {
Some(theme.selected_bg)
} else if is_hovered {
Some(theme.hover_bg)
} else {
None
};
if let Some(bg) = highlight {
let inset = Rect::new(
item.x + 2.0,
item.y + 2.0,
(item.width - 4.0).max(0.0),
(item.height - 4.0).max(0.0),
);
surface.surface_fill_rect(inset, bg);
}
if is_focused && !is_hovered && !is_pressed && !*is_active {
let ring = Rect::new(
item.x + 1.5,
item.y + 1.5,
(item.width - 3.0).max(0.0),
(item.height - 3.0).max(0.0),
);
surface.surface_stroke_rect(ring, theme.accent_fg, 1.0);
}
let fg = if !*enabled {
theme.muted_fg
} else if is_hovered {
theme.hover_fg
} else {
theme.foreground
};
let text = action_text(label, icon.as_deref(), key_hint.as_deref());
let (tw, th) = surface.surface_measure_text(&text);
let tx = item.x + (item.width - tw) / 2.0;
let ty = item.y + (item.height - th) / 2.0;
surface.surface_draw_text_run(Rect::new(tx, ty, tw, th), &text, fg);
}
ToolbarButton::Separator => {
let mid_x = item.x + item.width / 2.0;
let pad_y = (item.height * 0.2).max(2.0);
surface.surface_draw_line(
Point::new(mid_x, item.y + pad_y),
Point::new(mid_x, item.y + item.height - pad_y),
theme.muted_fg,
1.0,
);
}
ToolbarButton::Label { text, fg } => {
let color = fg.unwrap_or(theme.muted_fg);
let (tw, th) = surface.surface_measure_text(text);
let ty = item.y + (item.height - th) / 2.0;
surface.surface_draw_text_run(Rect::new(item.x, ty, tw, th), text, color);
}
}
}
surface.surface_pop_clip();
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backend::ImagePaintResult;
use crate::event::Viewport;
use crate::types::Color;
use crate::Image;
#[derive(Default)]
struct RecordingSurface {
fills: Vec<(Rect, Color)>,
strokes: Vec<(Rect, Color, f32)>,
text_runs: Vec<(Rect, String, Color)>,
lines: Vec<(Point, Point, Color)>,
clip_pushes: Vec<Rect>,
clip_pops: usize,
}
impl PaintSurface for RecordingSurface {
fn surface_begin_frame(&mut self, _viewport: Viewport) {}
fn surface_end_frame(&mut self) {}
fn surface_viewport(&self) -> Viewport {
Viewport::new(200.0, 100.0, 1.0)
}
fn surface_line_height(&self) -> f32 {
16.0
}
fn surface_char_width(&self) -> f32 {
8.0
}
fn surface_measure_text(&self, text: &str) -> (f32, f32) {
(text.chars().count() as f32 * 8.0, 16.0)
}
fn surface_fill_rect(&mut self, rect: Rect, color: Color) {
self.fills.push((rect, color));
}
fn surface_fill_rounded_rect(&mut self, rect: Rect, _radius: f32, color: Color) {
self.fills.push((rect, color));
}
fn surface_stroke_rect(&mut self, rect: Rect, color: Color, stroke_width: f32) {
self.strokes.push((rect, color, stroke_width));
}
fn surface_stroke_rounded_rect(
&mut self,
rect: Rect,
_radius: f32,
color: Color,
stroke_width: f32,
) {
self.strokes.push((rect, color, stroke_width));
}
fn surface_draw_text_run(&mut self, rect: Rect, text: &str, color: Color) {
self.text_runs.push((rect, text.to_string(), color));
}
fn surface_draw_line(
&mut self,
from: Point,
to: Point,
color: Color,
_stroke_width: f32,
) {
self.lines.push((from, to, color));
}
fn surface_push_clip(&mut self, rect: Rect) {
self.clip_pushes.push(rect);
}
fn surface_pop_clip(&mut self) {
self.clip_pops += 1;
}
fn surface_draw_image(&mut self, _rect: Rect, _image: &Image) -> ImagePaintResult {
ImagePaintResult::Unsupported
}
}
fn mk_action(id: &str, label: &str, enabled: bool) -> ToolbarButton {
ToolbarButton::Action {
id: WidgetId::new(id),
label: label.into(),
icon: None,
key_hint: None,
enabled,
is_active: false,
tooltip: String::new(),
}
}
fn panel_with_toolbar() -> SidebarPanel {
SidebarPanel {
id: WidgetId::new("sb"),
toolbar: Some(Toolbar {
id: WidgetId::new("sb:toolbar"),
buttons: vec![mk_action("a", "Refine", true), mk_action("b", "Drop", true)],
bg: None,
focused_index: None,
}),
toolbar_height: None,
}
}
#[test]
fn no_toolbar_paints_nothing() {
let panel = SidebarPanel {
id: WidgetId::new("sb"),
toolbar: None,
toolbar_height: None,
};
let theme = Theme::default();
let mut surface = RecordingSurface::default();
let layout = paint(
&panel,
&mut surface,
&theme,
Rect::new(0.0, 0.0, 100.0, 50.0),
16.0,
None,
None,
);
assert!(surface.fills.is_empty());
assert!(surface.text_runs.is_empty());
assert_eq!(layout.content_bounds, Rect::new(0.0, 0.0, 100.0, 50.0));
}
#[test]
fn zero_size_bounds_paints_nothing() {
let panel = panel_with_toolbar();
let theme = Theme::default();
let mut surface = RecordingSurface::default();
paint(
&panel,
&mut surface,
&theme,
Rect::new(0.0, 0.0, 0.0, 0.0),
16.0,
None,
None,
);
assert!(surface.fills.is_empty());
assert!(surface.clip_pushes.is_empty());
}
#[test]
fn toolbar_header_fills_bar_background_and_clips() {
let panel = panel_with_toolbar();
let theme = Theme::default();
let mut surface = RecordingSurface::default();
let layout = paint(
&panel,
&mut surface,
&theme,
Rect::new(0.0, 0.0, 100.0, 50.0),
16.0,
None,
None,
);
let tb = layout.toolbar_bounds.expect("toolbar slot reserved");
assert_eq!(surface.clip_pushes, vec![tb]);
assert_eq!(surface.clip_pops, 1);
assert_eq!(surface.fills[0], (tb, theme.header_bg));
}
#[test]
fn hovered_button_gets_hover_bg_and_hover_fg() {
let panel = panel_with_toolbar();
let theme = Theme::default();
let mut surface = RecordingSurface::default();
paint(
&panel,
&mut surface,
&theme,
Rect::new(0.0, 0.0, 100.0, 50.0),
16.0,
Some(&WidgetId::new("a")),
None,
);
assert_eq!(surface.fills[1].1, theme.hover_bg);
let (_, _text, color) = surface
.text_runs
.iter()
.find(|(_, t, _)| t == "Refine")
.expect("Refine label drawn");
assert_eq!(*color, theme.hover_fg);
}
#[test]
fn pressed_button_gets_selected_bg() {
let panel = panel_with_toolbar();
let theme = Theme::default();
let mut surface = RecordingSurface::default();
paint(
&panel,
&mut surface,
&theme,
Rect::new(0.0, 0.0, 100.0, 50.0),
16.0,
None,
Some(&WidgetId::new("a")),
);
assert_eq!(surface.fills[1].1, theme.selected_bg);
}
#[test]
fn disabled_action_paints_muted_fg_no_highlight() {
let panel = SidebarPanel {
id: WidgetId::new("sb"),
toolbar: Some(Toolbar {
id: WidgetId::new("sb:toolbar"),
buttons: vec![mk_action("a", "Refine", false)],
bg: None,
focused_index: None,
}),
toolbar_height: None,
};
let theme = Theme::default();
let mut surface = RecordingSurface::default();
paint(
&panel,
&mut surface,
&theme,
Rect::new(0.0, 0.0, 100.0, 50.0),
16.0,
Some(&WidgetId::new("a")), None,
);
assert_eq!(surface.fills.len(), 1);
let (_, _, color) = &surface.text_runs[0];
assert_eq!(*color, theme.muted_fg);
}
#[test]
fn focused_button_gets_stroke_ring_when_not_hovered_or_pressed() {
let panel = SidebarPanel {
id: WidgetId::new("sb"),
toolbar: Some(Toolbar {
id: WidgetId::new("sb:toolbar"),
buttons: vec![mk_action("a", "Refine", true)],
bg: None,
focused_index: Some(0),
}),
toolbar_height: None,
};
let theme = Theme::default();
let mut surface = RecordingSurface::default();
paint(
&panel,
&mut surface,
&theme,
Rect::new(0.0, 0.0, 100.0, 50.0),
16.0,
None,
None,
);
assert_eq!(surface.strokes.len(), 1);
assert_eq!(surface.strokes[0].1, theme.accent_fg);
}
#[test]
fn separator_draws_a_vertical_line_not_a_fill() {
let panel = SidebarPanel {
id: WidgetId::new("sb"),
toolbar: Some(Toolbar {
id: WidgetId::new("sb:toolbar"),
buttons: vec![mk_action("a", "Refine", true), ToolbarButton::Separator],
bg: None,
focused_index: None,
}),
toolbar_height: None,
};
let theme = Theme::default();
let mut surface = RecordingSurface::default();
paint(
&panel,
&mut surface,
&theme,
Rect::new(0.0, 0.0, 100.0, 50.0),
16.0,
None,
None,
);
assert_eq!(surface.lines.len(), 1);
assert_eq!(surface.lines[0].2, theme.muted_fg);
}
#[test]
fn label_draws_left_aligned_unhighlighted_text() {
let panel = SidebarPanel {
id: WidgetId::new("sb"),
toolbar: Some(Toolbar {
id: WidgetId::new("sb:toolbar"),
buttons: vec![ToolbarButton::Label {
text: "2 of 5".into(),
fg: None,
}],
bg: None,
focused_index: None,
}),
toolbar_height: None,
};
let theme = Theme::default();
let mut surface = RecordingSurface::default();
let layout = paint(
&panel,
&mut surface,
&theme,
Rect::new(0.0, 0.0, 100.0, 50.0),
16.0,
None,
None,
);
let tb = layout.toolbar_bounds.unwrap();
assert_eq!(surface.fills.len(), 1);
let (rect, text, color) = &surface.text_runs[0];
assert_eq!(text, "2 of 5");
assert_eq!(rect.x, tb.x);
assert_eq!(*color, theme.muted_fg);
}
#[test]
fn content_bounds_still_start_below_toolbar_slot() {
let panel = panel_with_toolbar();
let theme = Theme::default();
let mut surface = RecordingSurface::default();
let layout = paint(
&panel,
&mut surface,
&theme,
Rect::new(0.0, 0.0, 100.0, 50.0),
16.0,
None,
None,
);
let tb = layout.toolbar_bounds.unwrap();
assert_eq!(layout.content_bounds.y, tb.y + tb.height);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::primitives::toolbar::ToolbarButton;
use crate::types::WidgetId;
fn mk_action(id: &str, label: &str) -> ToolbarButton {
ToolbarButton::Action {
id: WidgetId::new(id),
label: label.to_string(),
icon: None,
key_hint: None,
enabled: true,
is_active: false,
tooltip: String::new(),
}
}
fn panel_with_toolbar() -> SidebarPanel {
SidebarPanel {
id: WidgetId::new("sb"),
toolbar: Some(Toolbar {
id: WidgetId::new("sb:toolbar"),
buttons: vec![mk_action("a", "Refine"), mk_action("b", "Drop")],
bg: None,
focused_index: None,
}),
toolbar_height: None,
}
}
fn measure() -> (
SidebarPanelMeasure,
impl Fn(&ToolbarButton) -> ToolbarItemMeasure,
) {
let m = SidebarPanelMeasure::new(1.0, 8.0);
(m, |_btn: &ToolbarButton| ToolbarItemMeasure::new(8.0))
}
#[test]
fn no_toolbar_gives_full_rect_to_content() {
let panel = SidebarPanel {
id: WidgetId::new("sb"),
toolbar: None,
toolbar_height: None,
};
let (m, mi) = measure();
let layout = panel.layout(Rect::new(0.0, 0.0, 30.0, 10.0), m, mi);
assert!(layout.toolbar_bounds.is_none());
assert!(layout.toolbar_layout.is_none());
assert_eq!(layout.content_bounds, Rect::new(0.0, 0.0, 30.0, 10.0));
}
#[test]
fn toolbar_reserves_top_slot() {
let panel = panel_with_toolbar();
let (m, mi) = measure();
let layout = panel.layout(Rect::new(0.0, 0.0, 30.0, 10.0), m, mi);
let tb = layout.toolbar_bounds.expect("toolbar slot reserved");
assert_eq!(tb.x, 0.0);
assert_eq!(tb.y, 0.0);
assert_eq!(tb.width, 30.0);
assert_eq!(tb.height, 1.0); assert_eq!(layout.content_bounds.y, 1.0);
assert_eq!(layout.content_bounds.height, 9.0);
}
#[test]
fn empty_toolbar_still_reserves_slot() {
let panel = SidebarPanel {
id: WidgetId::new("sb"),
toolbar: Some(Toolbar {
id: WidgetId::new("sb:toolbar"),
buttons: vec![], bg: None,
focused_index: None,
}),
toolbar_height: None,
};
let (m, mi) = measure();
let layout = panel.layout(Rect::new(0.0, 0.0, 30.0, 10.0), m, mi);
assert!(layout.toolbar_bounds.is_some());
assert_eq!(layout.content_bounds.y, 1.0);
assert_eq!(layout.content_bounds.height, 9.0);
}
#[test]
fn explicit_toolbar_height_overrides_default() {
let mut panel = panel_with_toolbar();
panel.toolbar_height = Some(3.0);
let (m, mi) = measure();
let layout = panel.layout(Rect::new(0.0, 0.0, 30.0, 10.0), m, mi);
let tb = layout.toolbar_bounds.unwrap();
assert_eq!(tb.height, 3.0);
assert_eq!(layout.content_bounds.y, 3.0);
assert_eq!(layout.content_bounds.height, 7.0);
}
#[test]
fn hit_test_routes_toolbar_button_click() {
let panel = panel_with_toolbar();
let (m, mi) = measure();
let layout = panel.layout(Rect::new(0.0, 0.0, 30.0, 10.0), m, mi);
match layout.hit_test(2.0, 0.0) {
SidebarPanelHit::ToolbarButton(id) => assert_eq!(id.as_str(), "a"),
other => panic!("expected ToolbarButton hit, got {other:?}"),
}
}
#[test]
fn hit_test_inside_toolbar_slot_off_button_is_toolbar_empty() {
let panel = panel_with_toolbar();
let (m, mi) = measure();
let layout = panel.layout(Rect::new(0.0, 0.0, 30.0, 10.0), m, mi);
let hit = layout.hit_test(20.0, 0.0);
assert_eq!(hit, SidebarPanelHit::ToolbarEmpty);
}
#[test]
fn hit_test_content_returns_content_local_coords() {
let panel = panel_with_toolbar();
let (m, mi) = measure();
let layout = panel.layout(Rect::new(10.0, 5.0, 30.0, 10.0), m, mi);
match layout.hit_test(12.0, 8.0) {
SidebarPanelHit::Content { x, y } => {
assert_eq!(x, 2.0);
assert_eq!(y, 2.0);
}
other => panic!("expected Content hit, got {other:?}"),
}
}
#[test]
fn hit_test_outside_panel_is_empty() {
let panel = panel_with_toolbar();
let (m, mi) = measure();
let layout = panel.layout(Rect::new(0.0, 0.0, 30.0, 10.0), m, mi);
assert_eq!(layout.hit_test(100.0, 100.0), SidebarPanelHit::Empty);
}
#[test]
fn toolbar_height_clamps_to_panel_height() {
let mut panel = panel_with_toolbar();
panel.toolbar_height = Some(50.0);
let (m, mi) = measure();
let layout = panel.layout(Rect::new(0.0, 0.0, 30.0, 10.0), m, mi);
let tb = layout.toolbar_bounds.unwrap();
assert_eq!(tb.height, 10.0); assert_eq!(layout.content_bounds.height, 0.0);
}
#[test]
fn serde_roundtrip() {
let panel = panel_with_toolbar();
let json = serde_json::to_string(&panel).unwrap();
let back: SidebarPanel = serde_json::from_str(&json).unwrap();
assert_eq!(panel, back);
}
}