#[cfg(full_widgets)]
use crate::compat::Vec;
use crate::compat::{Rc, RefCell, String, ToString};
use crate::core::{Alignment, Color, Font, HorizontalAlignment, Point, Rect, Size};
use crate::event::{Event, EventHandler};
#[cfg(full_widgets)]
use crate::layout::{
AlignItems, FlexDirection, FlexLayout, FlexWrap, JustifyContent, LayoutParams,
};
use crate::render::RenderContext;
use crate::signal::Signal1;
#[cfg(full_widgets)]
use crate::style::EdgeOffsets;
use crate::widget::capability::coercion::{
alignment_to_str, expect_alignment, expect_bool, expect_string,
};
use crate::widget::capability::properties_trait::{base_property_get, base_property_set};
use crate::widget::capability::types::{CapabilityAccessError, CapabilityValue};
use crate::widget::capability::WidgetProperties;
#[cfg(full_widgets)]
use crate::widget::composite::CompositeBuilder;
use crate::widget::metrics::{dimensions, ControlMetrics};
#[cfg(full_widgets)]
use crate::widget::WidgetFactory;
use crate::widget::{BaseWidget, Draw, SimpleRegistry, Widget, WidgetKind};
use crate::{impl_widget_property_hooks, property_names_of};
const TITLE_PADDING: i32 = 10;
const BORDER_WIDTH: u32 = 2;
const TITLE_CONTENT_SPACING: u32 = dimensions::BUTTON_ICON_SPACING;
const FRAME_PADDING: u32 = 4;
pub struct GroupBox {
base: BaseWidget,
title: String,
alignment: Alignment,
checkable: bool,
checked: bool,
pub toggled: Signal1<bool>,
cached_title_width: Option<u32>,
registry: Option<Rc<RefCell<SimpleRegistry>>>,
}
impl GroupBox {
pub fn new(geometry: Rect) -> Self {
Self {
base: BaseWidget::new(WidgetKind::GroupBox, geometry, "GroupBox"),
title: String::new(),
alignment: Alignment::Left,
checkable: false,
checked: true,
toggled: Signal1::new(),
cached_title_width: None,
registry: None,
}
}
pub fn title(&self) -> &str {
&self.title
}
pub fn set_title(&mut self, title: String) {
self.title = title;
self.base.request_redraw();
}
pub fn alignment(&self) -> Alignment {
self.alignment
}
pub fn set_alignment(&mut self, alignment: Alignment) {
self.alignment = alignment;
self.base.request_redraw();
}
pub fn is_checkable(&self) -> bool {
self.checkable
}
pub fn set_checkable(&mut self, checkable: bool) {
self.checkable = checkable;
}
pub fn is_checked(&self) -> bool {
self.checked
}
pub fn set_checked(&mut self, checked: bool) {
if self.checked == checked {
return;
}
self.checked = checked;
self.toggled.emit(checked);
}
pub fn toggle(&mut self) {
self.set_checked(!self.checked);
}
pub fn set_registry(&mut self, registry: Rc<RefCell<SimpleRegistry>>) {
self.registry = Some(registry);
}
const TITLE_INSET: i32 = TITLE_PADDING;
fn title_rect(&self) -> Rect {
let rect = self.geometry();
self.title_row(rect).1
}
fn indicator_reserve(&self) -> u32 {
if !self.checkable {
return 0;
}
dimensions::CHECKBOX_BOX + dimensions::INDICATOR_TEXT_SPACING
}
fn title_row(&self, frame: Rect) -> (Option<Rect>, Rect) {
let text_width = self.cached_title_width.unwrap_or_else(|| self.title.len() as u32 * 8);
let band_height = self.title_band_height();
let band_y = (frame.y + band_height as i32 / 2).max(frame.y);
let leading = Self::TITLE_INSET.max(0) as u32;
let row = Rect::new(
frame.x + leading as i32,
band_y,
frame.width.saturating_sub(leading * 2),
band_height,
);
if row.width == 0 {
return (None, Rect::new(frame.x, band_y, 0, band_height));
}
let (indicator, title) = self.assemble_title_row(row, text_width, band_height);
let free = title.width.saturating_sub(text_width);
let offset = match self.alignment {
Alignment::Left | Alignment::Top | Alignment::Bottom => 0,
Alignment::Center => free / 2,
Alignment::Right => free,
};
(
indicator,
Rect::new(title.x + offset as i32, band_y, text_width.min(title.width), band_height),
)
}
fn assemble_title_row(
&self,
row: Rect,
text_width: u32,
band_height: u32,
) -> (Option<Rect>, Rect) {
#[cfg(not(full_widgets))]
{
let indicator_width = if self.checkable { dimensions::CHECKBOX_BOX } else { 0 };
let indicator = if self.checkable {
Some(Rect::new(row.x, row.y, indicator_width, band_height))
} else {
None
};
let reserve = self.indicator_reserve();
let title_x = row.x + reserve as i32;
let title_width = text_width.min(row.width.saturating_sub(reserve));
(indicator, Rect::new(title_x, row.y, title_width, band_height))
}
#[cfg(full_widgets)]
{
let factory = WidgetFactory::new_with_defaults();
let mut columns = CompositeBuilder::new(
Box::new(FlexLayout::with_params(
FlexDirection::Row,
FlexWrap::NoWrap,
JustifyContent::FlexStart,
AlignItems::Stretch,
0,
0,
)),
EdgeOffsets::all(0),
Size::new(0, 0),
);
let indicator_width = if self.checkable { dimensions::CHECKBOX_BOX } else { 0 };
let gap = if self.checkable {
self.indicator_reserve().saturating_sub(dimensions::CHECKBOX_BOX)
} else {
0
};
if self.checkable {
let created = columns.add_sized(
&factory,
"label",
"",
Size::new(indicator_width, band_height),
LayoutParams::new(),
);
debug_assert!(created.is_some(), "the indicator column is a core control");
}
let created = columns.add_sized(
&factory,
"label",
&self.title,
Size::new(text_width, band_height),
LayoutParams::filled().with_margins(EdgeOffsets::new(0, 0, 0, gap)),
);
debug_assert!(created.is_some(), "the title column is a core control");
let mut placed: Vec<Rect> = Vec::new();
columns.arrange(row, &mut |_, rect| placed.push(rect));
let mut iter = placed.into_iter();
let indicator = if self.checkable { iter.next() } else { None };
let title = iter.next().unwrap_or_else(|| {
Rect::new(row.x + (indicator_width + gap) as i32, row.y, text_width, band_height)
});
(indicator, title)
}
}
fn title_band_height(&self) -> u32 {
Font::default().size().max(1.0) as u32
}
fn content_top(&self) -> i32 {
let rect = self.geometry();
let offset = FRAME_PADDING + self.title_band_height() + TITLE_CONTENT_SPACING;
rect.y.saturating_add(offset as i32)
}
fn checkbox_rect(&self) -> Option<Rect> {
let frame = self.geometry();
let (column, title) = self.title_row(frame);
let column = column?;
let size = dimensions::CHECKBOX_BOX.min(title.height).min(column.width) as i32;
Some(Rect::new(
(column.x + column.width as i32 - size).max(frame.x),
column.y + (column.height as i32 - size) / 2,
size.max(0) as u32,
size.max(0) as u32,
))
}
fn content_rect(&self) -> Rect {
let rect = self.geometry();
let top = self.content_top();
Rect::new(
rect.x + FRAME_PADDING as i32,
top,
rect.width.saturating_sub(FRAME_PADDING * 2),
(rect.y + rect.height as i32)
.saturating_sub(top)
.saturating_sub(FRAME_PADDING as i32)
.max(0) as u32,
)
}
}
impl Widget for GroupBox {
fn base(&self) -> &BaseWidget {
&self.base
}
fn base_mut(&mut self) -> &mut BaseWidget {
&mut self.base
}
fn size_hint(&self) -> Size {
let padding = crate::style::EdgeOffsets {
top: FRAME_PADDING + self.title_band_height() + TITLE_CONTENT_SPACING,
right: FRAME_PADDING,
bottom: FRAME_PADDING,
left: FRAME_PADDING,
};
let title = self.cached_title_width.unwrap_or_else(|| self.title.len() as u32 * 8);
let floor = Size::new(
title + padding.horizontal_total() + (Self::TITLE_INSET as u32) * 2,
self.content_top() as u32 + padding.bottom,
);
ControlMetrics::implicit_size(Size::new(0, 0), padding, floor)
}
impl_draw_bridge!();
impl_widget_property_hooks!();
}
impl WidgetProperties for GroupBox {
fn get(&self, name: &str) -> Result<CapabilityValue, CapabilityAccessError> {
match name {
"title" => Ok(CapabilityValue::String(self.title().to_string())),
"alignment" => {
Ok(CapabilityValue::String(alignment_to_str(self.alignment()).to_string()))
}
"checkable" => Ok(CapabilityValue::Bool(self.is_checkable())),
"checked" => Ok(CapabilityValue::Bool(self.is_checked())),
_ => base_property_get(self, name),
}
}
fn set(&mut self, name: &str, value: CapabilityValue) -> Result<(), CapabilityAccessError> {
match name {
"title" => {
self.set_title(expect_string(value)?);
Ok(())
}
"alignment" => {
self.set_alignment(expect_alignment(value)?);
Ok(())
}
"checkable" => {
self.set_checkable(expect_bool(value)?);
Ok(())
}
"checked" => {
self.set_checked(expect_bool(value)?);
Ok(())
}
_ => base_property_set(self, name, value),
}
}
fn property_names(&self) -> &'static [&'static str] {
property_names_of!["title", "alignment", "checkable", "checked", BASE_PROPERTY_NAMES]
}
fn command(&mut self, name: &str) -> Result<(), CapabilityAccessError> {
match name {
"toggle" => {
self.toggle();
Ok(())
}
"set_title" | "set_checkable" | "set_checked" => Err(CapabilityAccessError::OutOfRange),
_ => Err(CapabilityAccessError::UnknownCommand),
}
}
}
impl EventHandler for GroupBox {
fn handle_event(&mut self, event: &Event) {
self.base.handle_event(event);
if !self.base.is_enabled() {
return;
}
if self.checkable {
if let Event::MousePress { pos, button } = event {
if *button == 1 {
if let Some(checkbox_rect) = self.checkbox_rect() {
if checkbox_rect.contains(*pos) {
self.toggle();
}
}
}
}
}
let content = self.content_rect();
if let Some(ref reg) = self.registry {
let target = match event {
Event::MousePress { pos, .. }
| Event::MouseRelease { pos, .. }
| Event::MouseMove { pos } => {
content.contains(*pos).then(|| self.base.children.first().copied()).flatten()
}
_ => self.base.children.first().copied(),
};
if let Some(child_id) = target {
let _ = reg.borrow_mut().forward_event(child_id, event);
}
}
}
}
impl Draw for GroupBox {
fn draw(&mut self, context: &mut RenderContext) {
if !self.title.is_empty() {
let metrics = context.measure_text(&self.title, &Font::default());
self.cached_title_width = Some(metrics.width);
}
let rect = self.geometry();
let content = self.content_rect();
let title_rect = self.title_rect();
let style = self.style();
let face = style
.background_color
.filter(|_| !style.theme_derived)
.or_else(|| crate::style::layer_color(crate::style::LayerColor::SurfaceContainer));
if let Some(face) = face {
context.fill_rect(rect, face);
}
context.draw_rect(rect, style.border_color.unwrap_or(Color::rgb(200, 200, 200)));
let title_bg_left = (title_rect.x - TITLE_PADDING).max(rect.x);
let title_bg_right = (title_rect.x + title_rect.width as i32 + TITLE_PADDING)
.min(rect.x + rect.width as i32);
let title_bg_width = (title_bg_right - title_bg_left).max(0) as u32;
if title_bg_width > 0 {
context.fill_rect(
Rect::new(
title_bg_left,
rect.y,
title_bg_width,
BORDER_WIDTH.max(title_rect.height),
),
style
.background_color
.filter(|_| !style.theme_derived)
.or_else(|| {
crate::style::layer_color(crate::style::LayerColor::SurfaceContainer)
})
.unwrap_or_else(|| {
crate::style::theme_manager()
.current_theme()
.map(|active| active.colors.background)
.unwrap_or(Color::rgb(255, 255, 255))
}),
);
}
if self.checkable {
if let Some(checkbox_rect) = self.checkbox_rect() {
let box_color = style.border_color.unwrap_or(Color::rgb(100, 100, 100));
let field = style.background_color.unwrap_or(Color::WHITE);
context.fill_rect(checkbox_rect, field);
context.draw_rect(checkbox_rect, box_color);
if self.checked {
let tick_color = field.contrast_color();
context.draw_line(
Point::from_f32(
checkbox_rect.x as f32 + 2.0,
checkbox_rect.y as f32 + checkbox_rect.height as f32 * 0.5,
),
Point::from_f32(
checkbox_rect.x as f32 + checkbox_rect.width as f32 * 0.5,
checkbox_rect.y as f32 + checkbox_rect.height as f32 - 2.0,
),
tick_color,
);
context.draw_line(
Point::from_f32(
checkbox_rect.x as f32 + checkbox_rect.width as f32 * 0.5,
checkbox_rect.y as f32 + checkbox_rect.height as f32 - 2.0,
),
Point::from_f32(
checkbox_rect.x as f32 + checkbox_rect.width as f32 - 2.0,
checkbox_rect.y as f32 + 2.0,
),
tick_color,
);
}
}
}
if !self.title.is_empty() {
let text_color = if self.base.is_enabled() {
style.text_color.unwrap_or(Color::rgb(0, 0, 0))
} else {
Color::rgb(150, 150, 150)
};
context.draw_text(
Point::from_f32(title_rect.x as f32, title_rect.y as f32),
&self.title,
&Font::default(),
text_color,
HorizontalAlignment::Left,
);
}
if let Some(ref reg) = self.registry {
context.push_clip(content.x, content.y, content.width, content.height);
for child_id in &self.base.children {
reg.borrow_mut().draw_widget(*child_id, context);
}
context.pop_clip();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::{ObjectId, Rect};
#[cfg(device_profile)]
use crate::theme::AppearanceMode;
#[test]
fn groupbox_creation_defaults() {
let gb = GroupBox::new(Rect::new(0, 0, 200, 100));
assert_eq!(gb.geometry(), Rect::new(0, 0, 200, 100));
assert!(gb.title().is_empty());
assert!(gb.is_checked());
assert!(!gb.is_checkable());
}
#[test]
fn groupbox_title_and_toggle() {
let mut gb = GroupBox::new(Rect::new(0, 0, 200, 100));
gb.set_title("Options".to_string());
assert_eq!(gb.title(), "Options");
gb.set_checkable(true);
assert!(gb.is_checkable());
gb.set_checked(false);
assert!(!gb.is_checked());
gb.toggle();
assert!(gb.is_checked());
}
#[test]
fn test_panel_add_remove_children() {
let mut gb = GroupBox::new(Rect::new(0, 0, 200, 100));
let child1: ObjectId = 100;
let child2: ObjectId = 200;
let child3: ObjectId = 300;
assert!(gb.children().is_empty());
gb.add_child(child1);
assert_eq!(gb.children().len(), 1);
assert_eq!(gb.children()[0], child1);
gb.add_child(child2);
assert_eq!(gb.children().len(), 2);
gb.add_child(child3);
assert_eq!(gb.children().len(), 3);
gb.remove_child(child2);
assert_eq!(gb.children().len(), 2);
assert_eq!(gb.children()[0], child1);
assert_eq!(gb.children()[1], child3);
gb.remove_child(child1);
assert_eq!(gb.children().len(), 1);
gb.remove_child(child3);
assert!(gb.children().is_empty());
}
#[test]
fn a_checkable_groups_indicator_is_inside_the_frame() {
let frame = Rect::new(0, 0, 200, 100);
let mut gb = GroupBox::new(frame);
gb.set_title("Options".to_string());
assert!(gb.checkbox_rect().is_none());
let plain = gb.title_rect();
gb.set_checkable(true);
let indicator = gb.checkbox_rect().expect("a checkable group has an indicator");
let reserved = gb.title_rect();
assert!(
indicator.x >= frame.x,
"the indicator must not start left of the frame: {indicator:?}"
);
assert!(
indicator.x + indicator.width as i32 <= frame.x + frame.width as i32,
"and it must not run past the frame: {indicator:?}"
);
assert_eq!(
reserved.x,
indicator.x + indicator.width as i32 + dimensions::INDICATOR_TEXT_SPACING as i32,
"the title begins one gap after the indicator ends"
);
assert_eq!(
reserved.x - plain.x,
(dimensions::CHECKBOX_BOX + dimensions::INDICATOR_TEXT_SPACING) as i32,
"the title reserved exactly the indicator column"
);
}
#[test]
fn the_indicator_reserve_and_placement_are_one_derivation() {
for width in [80u32, 200, 400] {
let frame = Rect::new(0, 0, width, 100);
let mut gb = GroupBox::new(frame);
gb.set_title("T".to_string());
gb.set_checkable(true);
let indicator = gb.checkbox_rect().expect("checkable");
let title = gb.title_rect();
assert!(
indicator.x >= frame.x,
"width {width}: the indicator left the frame at {}",
indicator.x
);
assert_eq!(
gb.indicator_reserve(),
dimensions::CHECKBOX_BOX + dimensions::INDICATOR_TEXT_SPACING,
"width {width}: the declared reserve must be the checkbox plus its gap"
);
assert!(
indicator.width <= dimensions::CHECKBOX_BOX,
"width {width}: the square may shrink but never grow past its nominal size"
);
assert_eq!(
title.x,
indicator.x + indicator.width as i32 + dimensions::INDICATOR_TEXT_SPACING as i32,
"width {width}: the reserved column and the used column must agree"
);
}
}
#[test]
fn an_uncheckable_box_reserves_no_indicator_column() {
let gb = GroupBox::new(Rect::new(0, 0, 200, 100));
assert_eq!(gb.indicator_reserve(), 0);
assert!(gb.checkbox_rect().is_none());
}
#[test]
fn test_panel_empty_has_no_children() {
let gb = GroupBox::new(Rect::new(0, 0, 200, 100));
assert!(gb.children().is_empty());
assert_eq!(gb.children().len(), 0);
}
#[test]
fn the_tick_is_the_contrast_of_the_box_it_sits_in() {
#[cfg(device_profile)]
let _guard = crate::style::theme_test_guard();
#[cfg(device_profile)]
crate::widget::census::install_preset_appearances();
let frame = Rect::new(0, 0, 200, 100);
let rgb = |fragment: &str, key: &str| -> Option<(u8, u8, u8)> {
let at = fragment.find(key)? + key.len();
let end = fragment[at..].find(')')? + at;
let mut parts = fragment[at..end].split(',');
let r = parts.next()?.trim().parse().ok()?;
let g = parts.next()?.trim().parse().ok()?;
let b = parts.next()?.trim().parse().ok()?;
Some((r, g, b))
};
let mut seen_fills = crate::compat::Vec::new();
let mut seen_ticks: crate::compat::Vec<crate::compat::Vec<(u8, u8, u8)>> =
crate::compat::Vec::new();
#[cfg(device_profile)]
let appearances = [Some(AppearanceMode::Dark), Some(AppearanceMode::Light)].as_slice();
#[cfg(not(device_profile))]
let appearances = [Option::<crate::style::AppearanceMode>::None].as_slice();
for appearance in appearances {
let mut ticks_this_appearance = crate::compat::Vec::new();
#[cfg(device_profile)]
let backdrop = {
crate::theme::global_theme_manager()
.set_appearance(appearance.expect("an appearance"));
crate::theme::global_theme_manager()
.current_theme()
.map(|active| active.colors.background)
.unwrap_or(Color::WHITE)
};
#[cfg(not(device_profile))]
let backdrop = Color::WHITE;
let mut gb = GroupBox::new(frame);
gb.set_title("Options".to_string());
gb.set_checkable(true);
gb.set_checked(true);
#[cfg(device_profile)]
crate::theme::apply_theme_to_widget(&mut gb);
let svg = crate::widget::svg::render_widget_to_svg_on(&mut gb, frame, backdrop);
let tick_at = svg.find("<line").expect("a checked box draws the tick's two strokes");
let fill_at = svg[..tick_at].rfind("fill=\"rgba(").expect("the indicator's fill");
let fill = rgb(&svg[fill_at..], "fill=\"rgba(").expect("a parseable fill");
let tick_lines: crate::compat::Vec<&str> =
svg.match_indices("<line").map(|(i, _)| &svg[i..]).collect();
assert!(tick_lines.len() >= 2, "the tick is two strokes");
for (n, line) in tick_lines.iter().enumerate() {
let tick = rgb(line, "stroke=\"rgba(").expect("the tick carries a stroke colour");
let want = Color::rgb(fill.0, fill.1, fill.2).contrast_color();
assert_eq!(
tick,
(want.r, want.g, want.b),
"tick stroke {n} ({tick:?}) must be the contrast colour of the box fill \
{fill:?} it sits in; a literal black is the BLUE21 B22 defect this pins"
);
ticks_this_appearance.push(tick);
}
seen_fills.push(fill);
seen_ticks.push(ticks_this_appearance);
}
#[cfg(device_profile)]
assert_ne!(
seen_ticks[0], seen_ticks[1],
"the tick must follow the fill; the two appearances produced {seen_ticks:?} from fills \
{seen_fills:?}"
);
let _ = seen_fills;
let mut unchecked = GroupBox::new(frame);
unchecked.set_title("Options".to_string());
unchecked.set_checkable(true);
unchecked.set_checked(false);
let plain = crate::widget::svg::render_widget_to_svg(&mut unchecked, frame);
assert_eq!(
plain.matches("<line").count(),
0,
"an unchecked box draws no tick, so the checked state is not implied"
);
}
}