teksilo_widgets/
group_box.rs1use teksilo_canvas::{Rect, Size, SizeProposal};
39use teksilo_core::accessibility::AccessNodeBuilder;
40use teksilo_core::binding::BindingLevel;
41use teksilo_core::build_context::BuildContext;
42use teksilo_core::signal::Signal;
43use teksilo_core::widget::{LayoutContext, Widget, WidgetPlacement};
44use teksilo_core::widget_id::WidgetId;
45
46use crate::Checkbox;
47use crate::primitives::{HStack, Padding, RectWidget, TextWidget, VStack, ZStack};
48use teksilo_core::styles::density::spacing;
49use teksilo_i18n::LocalizedString;
50use teksilo_tokens::{InputTokens, TextRole, TextStyleRole};
51
52pub const GROUP_BOX_CONTENT_INDENT: f32 = 24.0;
54
55pub fn group_box_content_indent(tokens: &InputTokens) -> f32 {
58 spacing(GROUP_BOX_CONTENT_INDENT, tokens)
59}
60pub const GROUP_BOX_TITLE_CONTENT_SPACING: f32 = 8.0;
62
63pub fn group_box_title_content_spacing(tokens: &InputTokens) -> f32 {
66 spacing(GROUP_BOX_TITLE_CONTENT_SPACING, tokens)
67}
68pub const GROUP_BOX_CHECKBOX_GAP: f32 = 6.0;
70
71pub fn group_box_checkbox_gap(tokens: &InputTokens) -> f32 {
74 spacing(GROUP_BOX_CHECKBOX_GAP, tokens)
75}
76
77pub struct GroupBox {
82 title: LocalizedString,
83 checked: Option<Signal<bool>>,
84 pending_content: Option<Box<dyn Widget>>,
85 content_id: Option<WidgetId>,
86 root_child_id: Option<WidgetId>,
87}
88
89impl GroupBox {
90 pub fn new(title: impl Into<LocalizedString>) -> Self {
92 let ls: LocalizedString = title.into();
93 Self {
94 title: ls,
95 checked: None,
96 pending_content: None,
97 content_id: None,
98 root_child_id: None,
99 }
100 }
101
102 pub fn checkable(mut self, checked: Signal<bool>) -> Self {
106 self.checked = Some(checked);
107 self
108 }
109
110 pub fn child(mut self, widget: impl teksilo_core::IntoTeksiChild) -> Self {
112 match teksilo_core::IntoTeksiChild::into_pending(widget) {
113 teksilo_core::PendingChild::Id(id) => {
114 self.content_id = Some(id);
115 self
116 }
117 teksilo_core::PendingChild::Deferred(w) => {
118 self.pending_content = Some(w);
119 self
120 }
121 }
122 }
123 pub fn child_opt(self, widget: Option<impl teksilo_core::IntoTeksiChild>) -> Self {
130 match widget {
131 Some(w) => self.child(w),
132 None => self,
133 }
134 }
135}
136
137impl std::fmt::Debug for GroupBox {
138 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
139 f.debug_struct("GroupBox")
140 .field("title", &self.title)
141 .field("checkable", &self.checked.is_some())
142 .finish()
143 }
144}
145
146impl Widget for GroupBox {
147 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
148 if let Some(pending) = self.pending_content.take() {
149 self.content_id = Some(ctx.add_boxed(pending));
150 }
151
152 if let Some(ref checked) = self.checked {
155 let self_id = ctx.self_id();
156 checked.bind_to(
157 self_id,
158 ctx.binding_registry(),
159 BindingLevel::AccessibilityOnly,
160 );
161 }
162
163 let theme_signal = ctx.theme_signal();
164 let _ = theme_signal.get();
165
166 let title_label = TextWidget::new(self.title.clone())
167 .style(TextStyleRole::BodyBold)
168 .color(TextRole::Primary)
169 .single_line()
170 .a11y_hidden();
171
172 let title_row_id = if let Some(ref checked) = self.checked {
173 let checkbox = Checkbox::new(checked.clone()).label(self.title.clone());
176 ctx.add(
177 HStack::new()
178 .spacing(group_box_checkbox_gap(&ctx.theme().input))
179 .child(checkbox)
180 .child(title_label),
181 )
182 } else {
183 ctx.add(title_label)
184 };
185
186 let padded_content_id = if let Some(content_id) = self.content_id {
187 ctx.add(
188 Padding::new(0.0, 0.0, 0.0, group_box_content_indent(&ctx.theme().input))
189 .child(content_id),
190 )
191 } else {
192 ctx.add(Padding::new(
193 0.0,
194 0.0,
195 0.0,
196 group_box_content_indent(&ctx.theme().input),
197 ))
198 };
199
200 let content_wrapper_id = if let Some(ref checked) = self.checked {
205 let dim_color = theme_signal.map(|t| t.colors.surface_main.with_alpha(0.6));
206 let dim_overlay_id = ctx.add(RectWidget::new().background(dim_color));
207 ctx.visible_when(dim_overlay_id, checked.map(|v| !*v));
208 ctx.enabled_when(padded_content_id, checked.clone());
209 ctx.add(ZStack::new().child(padded_content_id).child(dim_overlay_id))
210 } else {
211 padded_content_id
212 };
213
214 let root = ctx.add(
215 VStack::new()
216 .spacing(group_box_title_content_spacing(&ctx.theme().input))
217 .child(title_row_id)
218 .child(content_wrapper_id),
219 );
220 self.root_child_id = Some(root);
221
222 vec![root]
223 }
224
225 fn layout_response(
226 &self,
227 proposal: SizeProposal,
228 ctx: &LayoutContext,
229 ) -> teksilo_core::widget::LayoutResponse {
230 if let Some(root) = self.root_child_id
231 && let Some(size) = ctx.child_size(root, proposal)
232 {
233 return (size).into();
234 }
235 proposal.resolve(0.0, 0.0).into()
236 }
237
238 fn place_children(
239 &self,
240 bounds: Rect,
241 _proposal: SizeProposal,
242 children: &mut [WidgetPlacement],
243 _ctx: &LayoutContext,
244 ) {
245 for child in children.iter_mut() {
246 child.origin = teksilo_canvas::Point::new(bounds.x, bounds.y);
247 child.size = Size::new(bounds.width, bounds.height);
248 }
249 }
250
251 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
252 builder.set_role(teksilo_core::accesskit::Role::Group);
253 builder.set_name(self.title.resolve_now());
254 if let Some(ref checked) = self.checked
255 && !checked.get()
256 {
257 builder.set_disabled();
258 }
259 }
260
261 fn children(&self) -> Vec<WidgetId> {
262 self.root_child_id.into_iter().collect()
263 }
264}