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::TextStyle,
10 renderer_model::DrawCommand,
11 ui::custom_node::{ContentSize, CustomNode, PointerEvent},
12};
13
14const DEFAULT_BUTTON_SIZE: (f32, f32) = (120.0, 36.0);
16
17pub 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 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 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 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 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 assert!(button.needs_repaint());
256 assert!(!button.needs_repaint());
257
258 button.on_pointer_event(PointerEvent::Move { x: 5.0, y: 5.0 });
260 assert!(button.needs_repaint());
261 assert!(!button.needs_repaint());
262
263 button.on_pointer_event(PointerEvent::Move { x: 6.0, y: 6.0 });
265 assert!(!button.needs_repaint());
266
267 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}