Skip to main content

orinium_browser/engine/ui/components/
button.rs

1use 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
14/// Default button size when the label cannot be measured.
15const DEFAULT_BUTTON_SIZE: (f32, f32) = (120.0, 36.0);
16
17/// An HTML button rendered by the engine.
18pub 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        // Intrinsic size is the pure label extent.  CSS padding/border is
136        // added by the bridge, so it must not be baked in here (otherwise a
137        // styled button would pad its content twice).
138        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        // Intrinsic size is the pure label extent; CSS padding is applied by
227        // the bridge on top of it.
228        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        // Up without a prior Down must not report a click.
244        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        // A later Up must not report a click.
254        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        // Fresh component is dirty.
335        assert!(button.needs_repaint());
336        assert!(!button.needs_repaint());
337
338        // Moving into the button (hover on) marks it dirty.
339        button.on_pointer_event(PointerEvent::Move { x: 5.0, y: 5.0 });
340        assert!(button.needs_repaint());
341        assert!(!button.needs_repaint());
342
343        // Repeated moves while already hovered do not mark it dirty.
344        button.on_pointer_event(PointerEvent::Move { x: 6.0, y: 6.0 });
345        assert!(!button.needs_repaint());
346
347        // Leave clears hover and marks it dirty again.
348        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}