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::TextStyle,
10    renderer_model::DrawCommand,
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.
18///
19/// Background/hover/active visuals are resolved via UA/CSS and container paint
20/// (phase 1). The component draws label content using inherited text style.
21pub struct ButtonComponent {
22    pub label: String,
23    measurer: Arc<dyn text::TextMeasurer>,
24    measured_cache: Mutex<Option<(f32, f32)>>,
25    hovered: AtomicBool,
26    pressed: AtomicBool,
27    dirty: AtomicBool,
28    label_dirty: AtomicBool,
29}
30
31impl std::fmt::Debug for ButtonComponent {
32    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33        f.debug_struct("ButtonComponent")
34            .field("label", &self.label)
35            .field("hovered", &self.hovered)
36            .field("pressed", &self.pressed)
37            .finish_non_exhaustive()
38    }
39}
40
41impl ButtonComponent {
42    pub fn new(label: impl Into<String>, measurer: Arc<dyn text::TextMeasurer>) -> Self {
43        Self {
44            label: label.into(),
45            measurer,
46            measured_cache: Mutex::new(None),
47            hovered: AtomicBool::new(false),
48            pressed: AtomicBool::new(false),
49            dirty: AtomicBool::new(true),
50            label_dirty: AtomicBool::new(false),
51        }
52    }
53}
54
55impl CustomNode for ButtonComponent {
56    fn draw_sized(
57        &self,
58        cmd_buf: &mut Vec<DrawCommand>,
59        text_style: &TextStyle,
60        text_flow_style: &TextFlowStyle,
61        _style: &Style,
62        size: ContentSize,
63    ) {
64        let y = ((size.height - text_flow_style.font_size) * 0.5).max(0.0);
65        cmd_buf.push(DrawCommand::DrawText {
66            x: 0.0,
67            y,
68            text: self.label.as_str().into(),
69            style: text_style.clone(),
70            flow_style: *text_flow_style,
71        });
72    }
73
74    fn intrinsic_size(&self) -> ContentSize {
75        let text_style = TextStyle::default();
76        let cached_size = self.measured_cache.lock().ok().and_then(|cache| *cache);
77
78        let (label_width, label_height) = if !self.label_dirty.load(Ordering::Relaxed)
79            && let Some((label_width, label_height)) = cached_size
80        {
81            (label_width, label_height)
82        } else {
83            let measured = self.measurer.measure(&TextMeasureRequest {
84                text: self.label.clone(),
85                attribute: text::TextAttribute {
86                    style: text_style,
87                    flow_style: TextFlowStyle::default(),
88                },
89            });
90            let (label_width, label_height) = measured.map_or(DEFAULT_BUTTON_SIZE, |fragments| {
91                let width: f32 = fragments.iter().map(|f| f.width).sum();
92                let height = fragments.iter().map(|f| f.height).fold(0.0, f32::max);
93                (width, height)
94            });
95
96            if let Ok(mut cache) = self.measured_cache.lock() {
97                *cache = Some((label_width, label_height));
98            }
99
100            (label_width, label_height)
101        };
102
103        // Intrinsic size is the pure label extent.  CSS padding/border is
104        // added by the bridge, so it must not be baked in here (otherwise a
105        // styled button would pad its content twice).
106        ContentSize {
107            width: label_width,
108            height: label_height,
109        }
110    }
111
112    fn on_pointer_event(&self, event: PointerEvent) -> bool {
113        match event {
114            PointerEvent::Move { .. } => {
115                let was_hovered = self.hovered.swap(true, Ordering::Relaxed);
116                if !was_hovered {
117                    self.dirty.store(true, Ordering::Relaxed);
118                }
119                true
120            }
121            PointerEvent::Down { .. } => {
122                self.hovered.store(true, Ordering::Relaxed);
123                self.pressed.store(true, Ordering::Relaxed);
124                self.dirty.store(true, Ordering::Relaxed);
125                true
126            }
127            PointerEvent::Up { .. } => {
128                let clicked = self.pressed.swap(false, Ordering::Relaxed);
129                self.hovered.store(false, Ordering::Relaxed);
130                self.dirty.store(true, Ordering::Relaxed);
131                clicked
132            }
133            PointerEvent::Leave => {
134                let was_hovered = self.hovered.swap(false, Ordering::Relaxed);
135                let was_pressed = self.pressed.swap(false, Ordering::Relaxed);
136                if was_hovered || was_pressed {
137                    self.dirty.store(true, Ordering::Relaxed);
138                }
139                false
140            }
141        }
142    }
143
144    fn set_hovered(&self, hovered: bool) {
145        if self.hovered.swap(hovered, Ordering::Relaxed) != hovered {
146            self.dirty.store(true, Ordering::Relaxed);
147        }
148    }
149
150    fn is_hovered(&self) -> bool {
151        self.hovered.load(Ordering::Relaxed)
152    }
153
154    fn needs_repaint(&self) -> bool {
155        self.dirty.swap(false, Ordering::Relaxed)
156    }
157
158    fn role(&self) -> Option<&'static str> {
159        Some("button")
160    }
161
162    fn label(&self) -> Option<String> {
163        Some(self.label.clone())
164    }
165
166    fn is_disabled(&self) -> bool {
167        false
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174    use crate::engine::bridge::text::FallbackTextMeasurer;
175
176    fn component() -> ButtonComponent {
177        ButtonComponent::new("OK", Arc::new(FallbackTextMeasurer))
178    }
179
180    #[test]
181    fn intrinsic_size_measured_from_label() {
182        let button = component();
183        let size = button.intrinsic_size();
184        // Intrinsic size is the pure label extent; CSS padding is applied by
185        // the bridge on top of it.
186        assert!(size.width > 0.0);
187        assert!(size.height > 0.0);
188    }
189
190    #[test]
191    fn pointer_click_requires_down_then_up() {
192        let button = component();
193        assert!(!button.is_hovered());
194
195        assert!(button.on_pointer_event(PointerEvent::Move { x: 5.0, y: 5.0 }));
196        assert!(button.is_hovered());
197
198        assert!(button.on_pointer_event(PointerEvent::Down { x: 5.0, y: 5.0 }));
199        assert!(button.on_pointer_event(PointerEvent::Up { x: 5.0, y: 5.0 }));
200
201        // Up without a prior Down must not report a click.
202        assert!(!button.on_pointer_event(PointerEvent::Up { x: 5.0, y: 5.0 }));
203    }
204
205    #[test]
206    fn leave_clears_hover_and_press() {
207        let button = component();
208        button.on_pointer_event(PointerEvent::Down { x: 0.0, y: 0.0 });
209        button.on_pointer_event(PointerEvent::Leave);
210        assert!(!button.is_hovered());
211        // A later Up must not report a click.
212        assert!(!button.on_pointer_event(PointerEvent::Up { x: 0.0, y: 0.0 }));
213    }
214
215    #[test]
216    fn hover_state_via_set_hovered() {
217        let button = component();
218        button.set_hovered(true);
219        assert!(button.is_hovered());
220        button.set_hovered(false);
221        assert!(!button.is_hovered());
222    }
223
224    #[test]
225    fn draw_emits_label_only_phase1() {
226        use crate::engine::layouter::types::TextFlowStyle;
227        let button = component();
228        let mut cmds = Vec::new();
229        button.draw_sized(
230            &mut cmds,
231            &TextStyle::default(),
232            &TextFlowStyle::default(),
233            &Style::default(),
234            ContentSize {
235                width: 80.0,
236                height: 30.0,
237            },
238        );
239        assert!(matches!(cmds.first(), Some(DrawCommand::DrawText { .. })));
240        assert_eq!(cmds.len(), 1);
241    }
242
243    #[test]
244    fn exposes_role_and_label() {
245        let button = component();
246        assert_eq!(button.role(), Some("button"));
247        assert_eq!(button.label(), Some("OK".to_string()));
248        assert!(!button.is_disabled());
249    }
250
251    #[test]
252    fn needs_repaint_tracks_visual_state_changes() {
253        let button = component();
254        // Fresh component is dirty.
255        assert!(button.needs_repaint());
256        assert!(!button.needs_repaint());
257
258        // Moving into the button (hover on) marks it dirty.
259        button.on_pointer_event(PointerEvent::Move { x: 5.0, y: 5.0 });
260        assert!(button.needs_repaint());
261        assert!(!button.needs_repaint());
262
263        // Repeated moves while already hovered do not mark it dirty.
264        button.on_pointer_event(PointerEvent::Move { x: 6.0, y: 6.0 });
265        assert!(!button.needs_repaint());
266
267        // Leave clears hover and marks it dirty again.
268        button.on_pointer_event(PointerEvent::Leave);
269        assert!(button.needs_repaint());
270    }
271
272    #[test]
273    fn set_hovered_marks_dirty_on_change() {
274        let button = component();
275        button.needs_repaint();
276
277        button.set_hovered(true);
278        assert!(button.needs_repaint());
279        button.set_hovered(true);
280        assert!(!button.needs_repaint());
281    }
282}