use crate::event::Rect;
use crate::types::{Color, StyledText, WidgetId};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Panel {
pub id: WidgetId,
#[serde(default)]
pub title: Option<StyledText>,
#[serde(default)]
pub actions: Vec<PanelAction>,
#[serde(default)]
pub accent: Option<Color>,
#[serde(default)]
pub collapsed: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PanelAction {
pub id: WidgetId,
pub icon: String,
#[serde(default)]
pub tooltip: String,
#[serde(default)]
pub is_active: bool,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PanelMeasure {
pub title_bar_height: f32,
pub action_button_width: f32,
pub content_padding: f32,
}
impl PanelMeasure {
pub fn new(title_bar_height: f32) -> Self {
Self {
title_bar_height,
action_button_width: 24.0,
content_padding: 0.0,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct VisiblePanelAction {
pub action_idx: usize,
pub id: WidgetId,
pub bounds: Rect,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PanelHit {
Action(WidgetId),
TitleBar(WidgetId),
Content(WidgetId),
Outside,
}
#[derive(Debug, Clone, PartialEq)]
pub struct PanelLayout {
pub bounds: Rect,
pub title_bar_bounds: Option<Rect>,
pub content_bounds: Rect,
pub visible_actions: Vec<VisiblePanelAction>,
pub hit_regions: Vec<(Rect, PanelHit)>,
}
impl PanelLayout {
pub fn hit_test(&self, x: f32, y: f32) -> PanelHit {
let inside = x >= self.bounds.x
&& x < self.bounds.x + self.bounds.width
&& y >= self.bounds.y
&& y < self.bounds.y + self.bounds.height;
if !inside {
return PanelHit::Outside;
}
for (rect, hit) in &self.hit_regions {
if x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height {
return hit.clone();
}
}
PanelHit::Outside
}
}
impl Panel {
pub fn layout(&self, bounds: Rect, measure: PanelMeasure) -> PanelLayout {
let mut visible_actions: Vec<VisiblePanelAction> = Vec::new();
let mut hit_regions: Vec<(Rect, PanelHit)> = Vec::new();
let title_bar_bounds = if self.title.is_some() && measure.title_bar_height > 0.0 {
let tb = Rect::new(bounds.x, bounds.y, bounds.width, measure.title_bar_height);
Some(tb)
} else {
None
};
let mut action_area_right = bounds.x + bounds.width;
if let Some(tb) = title_bar_bounds {
for (i, action) in self.actions.iter().enumerate() {
let ax = action_area_right - measure.action_button_width;
if ax < tb.x {
break;
}
let ab = Rect::new(ax, tb.y, measure.action_button_width, tb.height);
visible_actions.push(VisiblePanelAction {
action_idx: i,
id: action.id,
bounds: ab,
});
hit_regions.push((ab, PanelHit::Action(action.id)));
action_area_right = ax;
}
let tb_body = Rect::new(tb.x, tb.y, action_area_right - tb.x, tb.height);
hit_regions.push((tb_body, PanelHit::TitleBar(self.id)));
}
let content_bounds = if self.collapsed {
let y = title_bar_bounds.map(|b| b.y + b.height).unwrap_or(bounds.y);
Rect::new(bounds.x + measure.content_padding, y, 0.0, 0.0)
} else {
let content_y = title_bar_bounds
.map(|b| b.y + b.height + measure.content_padding)
.unwrap_or(bounds.y + measure.content_padding);
let content_h =
(bounds.y + bounds.height - content_y - measure.content_padding).max(0.0);
let content_w = (bounds.width - measure.content_padding * 2.0).max(0.0);
Rect::new(
bounds.x + measure.content_padding,
content_y,
content_w,
content_h,
)
};
if content_bounds.width > 0.0 && content_bounds.height > 0.0 {
hit_regions.push((content_bounds, PanelHit::Content(self.id)));
}
PanelLayout {
bounds,
title_bar_bounds,
content_bounds,
visible_actions,
hit_regions,
}
}
}
#[cfg(any(
feature = "gtk",
feature = "win",
all(feature = "macos", target_os = "macos")
))]
#[allow(dead_code)]
pub(crate) mod native_surface_paint {
use super::{Panel, PanelLayout};
use crate::event::Rect;
use crate::paint_surface::PaintSurface;
use crate::theme::Theme;
fn plain_text(t: &crate::types::StyledText) -> String {
t.spans.iter().map(|s| s.text.as_str()).collect()
}
pub(crate) fn paint(
panel: &Panel,
layout: &PanelLayout,
surface: &mut dyn PaintSurface,
theme: &Theme,
) {
let Some(tb) = layout.title_bar_bounds else {
return;
};
let title_bg = panel.accent.unwrap_or(theme.separator);
surface.surface_fill_rect(tb, title_bg);
if let Some(ref title) = panel.title {
let text = plain_text(title);
let (tw, th) = surface.surface_measure_text(&text);
surface.surface_draw_text_run(
Rect::new(tb.x + 4.0, tb.y, tw, th),
&text,
theme.foreground,
);
}
for va in &layout.visible_actions {
let Some(action) = panel.actions.get(va.action_idx) else {
continue;
};
let action_bg = if action.is_active {
theme.accent_bg
} else {
title_bg
};
surface.surface_fill_rect(va.bounds, action_bg);
let (gw, gh) = surface.surface_measure_text(&action.icon);
let glyph_x = va.bounds.x + (va.bounds.width - gw) / 2.0;
surface.surface_draw_icon_glyph(
Rect::new(glyph_x, va.bounds.y, gw, gh),
&action.icon,
theme.foreground,
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backend::ImagePaintResult;
use crate::event::Viewport;
use crate::primitives::panel::{PanelAction, PanelMeasure};
use crate::types::{Color, StyledText, WidgetId};
use crate::Image;
#[derive(Default)]
struct RecordingSurface {
fills: Vec<(Rect, Color)>,
text_runs: Vec<(Rect, String, Color)>,
}
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) {}
fn surface_stroke_rounded_rect(
&mut self,
_rect: Rect,
_radius: f32,
_color: Color,
_stroke_width: f32,
) {
}
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: crate::Point,
_to: crate::Point,
_color: Color,
_stroke_width: f32,
) {
}
fn surface_push_clip(&mut self, _rect: Rect) {}
fn surface_pop_clip(&mut self) {}
fn surface_draw_image(&mut self, _rect: Rect, _image: &Image) -> ImagePaintResult {
ImagePaintResult::Unsupported
}
}
fn sample_panel() -> Panel {
Panel {
id: WidgetId::new("p"),
title: Some(StyledText::plain("Terminal")),
actions: vec![
PanelAction {
id: WidgetId::new("p:close"),
icon: "x".into(),
tooltip: String::new(),
is_active: false,
},
PanelAction {
id: WidgetId::new("p:pin"),
icon: "o".into(),
tooltip: String::new(),
is_active: true,
},
],
accent: None,
collapsed: false,
}
}
#[test]
fn title_bar_paints_accent_when_set_else_separator() {
let mut panel = sample_panel();
let bounds = Rect::new(0.0, 0.0, 200.0, 100.0);
let layout = panel.layout(bounds, PanelMeasure::new(20.0));
let theme = Theme::default();
let mut surface = RecordingSurface::default();
paint(&panel, &layout, &mut surface, &theme);
assert_eq!(surface.fills[0].1, theme.separator);
panel.accent = Some(Color::rgb(10, 20, 30));
let layout = panel.layout(bounds, PanelMeasure::new(20.0));
let mut surface = RecordingSurface::default();
paint(&panel, &layout, &mut surface, &theme);
assert_eq!(surface.fills[0].1, panel.accent.unwrap());
}
#[test]
fn title_text_drawn_at_title_bar_top_left_inset() {
let panel = sample_panel();
let bounds = Rect::new(0.0, 0.0, 200.0, 100.0);
let layout = panel.layout(bounds, PanelMeasure::new(20.0));
let theme = Theme::default();
let mut surface = RecordingSurface::default();
paint(&panel, &layout, &mut surface, &theme);
let tb = layout.title_bar_bounds.unwrap();
let (rect, text, color) = &surface.text_runs[0];
assert_eq!(text, "Terminal");
assert_eq!(rect.x, tb.x + 4.0);
assert_eq!(rect.y, tb.y);
assert_eq!(*color, theme.foreground);
}
#[test]
fn active_action_paints_accent_bg_inactive_paints_title_bg() {
let panel = sample_panel();
let bounds = Rect::new(0.0, 0.0, 200.0, 100.0);
let layout = panel.layout(bounds, PanelMeasure::new(20.0));
let theme = Theme::default();
let mut surface = RecordingSurface::default();
paint(&panel, &layout, &mut surface, &theme);
let close_fill = surface.fills[1].1;
let pin_fill = surface.fills[2].1;
assert_eq!(
close_fill, theme.separator,
"inactive action keeps title bg"
);
assert_eq!(pin_fill, theme.accent_bg, "active action gets accent_bg");
}
#[test]
fn action_glyph_centred_horizontally_in_its_button() {
let panel = sample_panel();
let bounds = Rect::new(0.0, 0.0, 200.0, 100.0);
let layout = panel.layout(bounds, PanelMeasure::new(20.0));
let theme = Theme::default();
let mut surface = RecordingSurface::default();
paint(&panel, &layout, &mut surface, &theme);
let va = &layout.visible_actions[0];
let (rect, _, _) = surface
.text_runs
.iter()
.find(|(_, t, _)| t == "x")
.expect("close glyph drawn");
let expected_x = va.bounds.x + (va.bounds.width - rect.width) / 2.0;
assert!((rect.x - expected_x).abs() < 0.01);
}
#[test]
fn no_title_paints_nothing() {
let panel = Panel {
id: WidgetId::new("p"),
title: None,
actions: vec![],
accent: None,
collapsed: false,
};
let bounds = Rect::new(0.0, 0.0, 200.0, 100.0);
let layout = panel.layout(bounds, PanelMeasure::new(20.0));
let theme = Theme::default();
let mut surface = RecordingSurface::default();
paint(&panel, &layout, &mut surface, &theme);
assert!(surface.fills.is_empty());
assert!(surface.text_runs.is_empty());
}
#[test]
fn collapsed_panel_still_paints_title_bar_chrome() {
let mut panel = sample_panel();
panel.collapsed = true;
let bounds = Rect::new(0.0, 0.0, 200.0, 100.0);
let layout = panel.layout(bounds, PanelMeasure::new(20.0));
let theme = Theme::default();
let mut surface = RecordingSurface::default();
paint(&panel, &layout, &mut surface, &theme);
assert!(
!surface.fills.is_empty(),
"title bar chrome still paints when collapsed"
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn mk_panel_action(id: &str, icon: char) -> PanelAction {
PanelAction {
id: WidgetId::new(id),
icon: icon.to_string(),
tooltip: String::new(),
is_active: false,
}
}
#[test]
fn panel_layout_with_title_and_actions() {
let p = Panel {
id: WidgetId::new("terminal"),
title: Some(StyledText::plain("Terminal")),
actions: vec![
mk_panel_action("term:split", '+'),
mk_panel_action("term:max", '□'),
mk_panel_action("term:close", '×'),
],
accent: None,
collapsed: false,
};
let bounds = Rect::new(0.0, 0.0, 400.0, 200.0);
let layout = p.layout(bounds, PanelMeasure::new(24.0));
assert!(layout.title_bar_bounds.is_some());
let tb = layout.title_bar_bounds.unwrap();
assert_eq!(tb.height, 24.0);
assert_eq!(layout.visible_actions.len(), 3);
assert_eq!(layout.visible_actions[0].id.as_str(), "term:split");
assert_eq!(
layout.visible_actions[0].bounds.x + layout.visible_actions[0].bounds.width,
400.0
);
assert_eq!(layout.content_bounds.y, 24.0);
assert_eq!(layout.content_bounds.height, 200.0 - 24.0);
}
#[test]
fn panel_layout_no_title() {
let p = Panel {
id: WidgetId::new("p"),
title: None,
actions: vec![],
accent: None,
collapsed: false,
};
let bounds = Rect::new(10.0, 20.0, 300.0, 100.0);
let layout = p.layout(bounds, PanelMeasure::new(24.0));
assert!(layout.title_bar_bounds.is_none());
assert_eq!(layout.content_bounds.y, 20.0);
assert_eq!(layout.content_bounds.height, 100.0);
}
#[test]
fn panel_layout_collapsed() {
let p = Panel {
id: WidgetId::new("p"),
title: Some(StyledText::plain("Collapsed")),
actions: vec![],
accent: None,
collapsed: true,
};
let bounds = Rect::new(0.0, 0.0, 200.0, 150.0);
let layout = p.layout(bounds, PanelMeasure::new(20.0));
assert!(layout.title_bar_bounds.is_some());
assert_eq!(layout.content_bounds.width, 0.0);
assert_eq!(layout.content_bounds.height, 0.0);
}
#[test]
fn panel_layout_hit_test_dispatches_correctly() {
let p = Panel {
id: WidgetId::new("p"),
title: Some(StyledText::plain("T")),
actions: vec![mk_panel_action("close", '×')],
accent: None,
collapsed: false,
};
let bounds = Rect::new(0.0, 0.0, 200.0, 100.0);
let layout = p.layout(bounds, PanelMeasure::new(20.0));
let close = &layout.visible_actions[0];
let cx = close.bounds.x + close.bounds.width / 2.0;
let cy = close.bounds.y + close.bounds.height / 2.0;
match layout.hit_test(cx, cy) {
PanelHit::Action(id) => assert_eq!(id.as_str(), "close"),
_ => panic!("expected Action(close)"),
}
match layout.hit_test(20.0, 10.0) {
PanelHit::TitleBar(id) => assert_eq!(id.as_str(), "p"),
_ => panic!("expected TitleBar"),
}
match layout.hit_test(100.0, 50.0) {
PanelHit::Content(id) => assert_eq!(id.as_str(), "p"),
_ => panic!("expected Content"),
}
assert_eq!(layout.hit_test(500.0, 500.0), PanelHit::Outside);
}
}