orinium_browser/engine/ui/components/
button.rs1use std::sync::atomic::{AtomicBool, Ordering};
2use std::sync::{Arc, Mutex};
3
4use ui_layout::Style;
5
6use crate::engine::layouter::types::TextFlowStyle;
7use crate::engine::{
8 bridge::text::{self, TextMeasureRequest},
9 layouter::types::{Color, TextStyle},
10 renderer_model::{Brush, DrawCommand, FillRule, Paint, rect_path},
11 ui::custom_node::{ContentSize, CustomNode, PointerEvent},
12};
13
14const DEFAULT_BUTTON_SIZE: (f32, f32) = (120.0, 36.0);
16
17pub struct ButtonComponent {
19 pub label: String,
20 pub button_color: Color,
21 pub label_color: Color,
22 measurer: Arc<dyn text::TextMeasurer>,
23 measured_cache: Mutex<Option<(f32, f32)>>,
24 hovered: AtomicBool,
25 pressed: AtomicBool,
26 dirty: AtomicBool,
27 label_dirty: AtomicBool,
28}
29
30impl std::fmt::Debug for ButtonComponent {
31 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32 f.debug_struct("ButtonComponent")
33 .field("label", &self.label)
34 .field("button_color", &self.button_color)
35 .field("label_color", &self.label_color)
36 .field("hovered", &self.hovered)
37 .field("pressed", &self.pressed)
38 .finish_non_exhaustive()
39 }
40}
41
42impl ButtonComponent {
43 pub fn new(
44 label: impl Into<String>,
45 button_color: Color,
46 label_color: Color,
47 measurer: Arc<dyn text::TextMeasurer>,
48 ) -> Self {
49 Self {
50 label: label.into(),
51 button_color,
52 label_color,
53 measurer,
54 measured_cache: Mutex::new(None),
55 hovered: AtomicBool::new(false),
56 pressed: AtomicBool::new(false),
57 dirty: AtomicBool::new(true),
58 label_dirty: AtomicBool::new(false),
59 }
60 }
61}
62
63impl CustomNode for ButtonComponent {
64 fn draw_sized(
65 &self,
66 cmd_buf: &mut Vec<DrawCommand>,
67 text_style: &TextStyle,
68 text_flow_style: &TextFlowStyle,
69 _style: &Style,
70 size: ContentSize,
71 ) {
72 let base = self.button_color;
73 let bg = if self.pressed.load(Ordering::Relaxed) {
74 shade(base, -30)
75 } else if self.hovered.load(Ordering::Relaxed) {
76 shade(base, 20)
77 } else {
78 base
79 };
80 if bg.3 > 0 {
81 cmd_buf.push(DrawCommand::Fill {
82 path: rect_path(0.0, 0.0, size.width, size.height),
83 rule: FillRule::NonZero,
84 paint: Paint {
85 brush: Brush::Solid(bg),
86 opacity: 1.0,
87 },
88 });
89 }
90
91 let mut style = text_style.clone();
92 style.color = self.label_color;
93 let y = ((size.height - text_flow_style.font_size) * 0.5).max(0.0);
94 cmd_buf.push(DrawCommand::DrawText {
95 x: 0.0,
96 y,
97 text: self.label.as_str().into(),
98 style,
99 flow_style: *text_flow_style,
100 });
101 }
102
103 fn intrinsic_size(&self) -> ContentSize {
104 let text_style = TextStyle::default();
105 let cached_size = self.measured_cache.lock().ok().and_then(|cache| *cache);
106
107 let (label_width, label_height) = if !self.label_dirty.load(Ordering::Relaxed)
108 && let Some((label_width, label_height)) = cached_size
109 {
110 (label_width, label_height)
111 } else {
112 let measured = self.measurer.measure(&TextMeasureRequest {
113 text: self.label.clone(),
114 attribute: text::TextAttribute {
115 style: text_style,
116 flow_style: TextFlowStyle::default(),
117 },
118 });
119 let (label_width, label_height) = measured.map_or_else(
120 |_| DEFAULT_BUTTON_SIZE,
121 |fragments| {
122 let width: f32 = fragments.iter().map(|f| f.width).sum();
123 let height = fragments.iter().map(|f| f.height).fold(0.0, f32::max);
124 (width, height)
125 },
126 );
127
128 if let Ok(mut cache) = self.measured_cache.lock() {
129 *cache = Some((label_width, label_height));
130 }
131
132 (label_width, label_height)
133 };
134
135 ContentSize {
139 width: label_width,
140 height: label_height,
141 }
142 }
143
144 fn on_pointer_event(&self, event: PointerEvent) -> bool {
145 match event {
146 PointerEvent::Move { .. } => {
147 let was_hovered = self.hovered.swap(true, Ordering::Relaxed);
148 if !was_hovered {
149 self.dirty.store(true, Ordering::Relaxed);
150 }
151 true
152 }
153 PointerEvent::Down { .. } => {
154 self.hovered.store(true, Ordering::Relaxed);
155 self.pressed.store(true, Ordering::Relaxed);
156 self.dirty.store(true, Ordering::Relaxed);
157 true
158 }
159 PointerEvent::Up { .. } => {
160 let clicked = self.pressed.swap(false, Ordering::Relaxed);
161 self.hovered.store(false, Ordering::Relaxed);
162 self.dirty.store(true, Ordering::Relaxed);
163 clicked
164 }
165 PointerEvent::Leave => {
166 let was_hovered = self.hovered.swap(false, Ordering::Relaxed);
167 let was_pressed = self.pressed.swap(false, Ordering::Relaxed);
168 if was_hovered || was_pressed {
169 self.dirty.store(true, Ordering::Relaxed);
170 }
171 false
172 }
173 }
174 }
175
176 fn set_hovered(&self, hovered: bool) {
177 if self.hovered.swap(hovered, Ordering::Relaxed) != hovered {
178 self.dirty.store(true, Ordering::Relaxed);
179 }
180 }
181
182 fn is_hovered(&self) -> bool {
183 self.hovered.load(Ordering::Relaxed)
184 }
185
186 fn needs_repaint(&self) -> bool {
187 self.dirty.swap(false, Ordering::Relaxed)
188 }
189
190 fn role(&self) -> Option<&'static str> {
191 Some("button")
192 }
193
194 fn label(&self) -> Option<String> {
195 Some(self.label.clone())
196 }
197
198 fn is_disabled(&self) -> bool {
199 false
200 }
201}
202
203fn shade(Color(r, g, b, a): Color, amount: i16) -> Color {
204 let clamp = |channel: u8| (channel as i16 + amount).clamp(0, 255) as u8;
205 Color(clamp(r), clamp(g), clamp(b), a)
206}
207
208#[cfg(test)]
209mod tests {
210 use super::*;
211 use crate::engine::bridge::text::FallbackTextMeasurer;
212
213 fn component() -> ButtonComponent {
214 ButtonComponent::new(
215 "OK",
216 Color(200, 200, 200, 255),
217 Color(0, 0, 0, 255),
218 Arc::new(FallbackTextMeasurer),
219 )
220 }
221
222 #[test]
223 fn intrinsic_size_measured_from_label() {
224 let button = component();
225 let size = button.intrinsic_size();
226 assert!(size.width > 0.0);
229 assert!(size.height > 0.0);
230 }
231
232 #[test]
233 fn pointer_click_requires_down_then_up() {
234 let button = component();
235 assert!(!button.is_hovered());
236
237 assert!(button.on_pointer_event(PointerEvent::Move { x: 5.0, y: 5.0 }));
238 assert!(button.is_hovered());
239
240 assert!(button.on_pointer_event(PointerEvent::Down { x: 5.0, y: 5.0 }));
241 assert!(button.on_pointer_event(PointerEvent::Up { x: 5.0, y: 5.0 }));
242
243 assert!(!button.on_pointer_event(PointerEvent::Up { x: 5.0, y: 5.0 }));
245 }
246
247 #[test]
248 fn leave_clears_hover_and_press() {
249 let button = component();
250 button.on_pointer_event(PointerEvent::Down { x: 0.0, y: 0.0 });
251 button.on_pointer_event(PointerEvent::Leave);
252 assert!(!button.is_hovered());
253 assert!(!button.on_pointer_event(PointerEvent::Up { x: 0.0, y: 0.0 }));
255 }
256
257 #[test]
258 fn hover_state_via_set_hovered() {
259 let button = component();
260 button.set_hovered(true);
261 assert!(button.is_hovered());
262 button.set_hovered(false);
263 assert!(!button.is_hovered());
264 }
265
266 #[test]
267 fn background_changes_with_state() {
268 use crate::engine::layouter::types::TextFlowStyle;
269 let button = component();
270 let text_style = TextStyle::default();
271 let flow_style = TextFlowStyle::default();
272
273 let mut cmds = Vec::new();
274 button.draw_sized(
275 &mut cmds,
276 &text_style,
277 &flow_style,
278 &Style::default(),
279 ContentSize {
280 width: 80.0,
281 height: 30.0,
282 },
283 );
284 let normal = match &cmds[0] {
285 DrawCommand::Fill {
286 paint:
287 Paint {
288 brush: Brush::Solid(c),
289 ..
290 },
291 ..
292 } => *c,
293 other => panic!("expected Fill, got {other:?}"),
294 };
295
296 button.on_pointer_event(PointerEvent::Down { x: 0.0, y: 0.0 });
297 let mut cmds = Vec::new();
298 button.draw_sized(
299 &mut cmds,
300 &text_style,
301 &flow_style,
302 &Style::default(),
303 ContentSize {
304 width: 80.0,
305 height: 30.0,
306 },
307 );
308 let pressed = match &cmds[0] {
309 DrawCommand::Fill {
310 paint:
311 Paint {
312 brush: Brush::Solid(c),
313 ..
314 },
315 ..
316 } => *c,
317 other => panic!("expected Fill, got {other:?}"),
318 };
319
320 assert_ne!(normal, pressed);
321 }
322
323 #[test]
324 fn exposes_role_and_label() {
325 let button = component();
326 assert_eq!(button.role(), Some("button"));
327 assert_eq!(button.label(), Some("OK".to_string()));
328 assert!(!button.is_disabled());
329 }
330
331 #[test]
332 fn needs_repaint_tracks_visual_state_changes() {
333 let button = component();
334 assert!(button.needs_repaint());
336 assert!(!button.needs_repaint());
337
338 button.on_pointer_event(PointerEvent::Move { x: 5.0, y: 5.0 });
340 assert!(button.needs_repaint());
341 assert!(!button.needs_repaint());
342
343 button.on_pointer_event(PointerEvent::Move { x: 6.0, y: 6.0 });
345 assert!(!button.needs_repaint());
346
347 button.on_pointer_event(PointerEvent::Leave);
349 assert!(button.needs_repaint());
350 }
351
352 #[test]
353 fn set_hovered_marks_dirty_on_change() {
354 let button = component();
355 button.needs_repaint();
356
357 button.set_hovered(true);
358 assert!(button.needs_repaint());
359 button.set_hovered(true);
360 assert!(!button.needs_repaint());
361 }
362}