1use std::rc::Rc;
52
53use frust_core::accesskit::{Action, Role};
54use frust_core::{
55 AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult, InputEvent,
56 LayoutCtx, PaintCtx, PaintScene, PointerPhase, SemanticsCtx, View, Widget, any,
57};
58use frust_theme::Theme;
59use kurbo::{Point, Size};
60use peniko::Color;
61
62use crate::authoring::{ErasedCallback, PRESSED_OPACITY, erase_callback, presses};
63use crate::icon;
64use crate::icon::IconData;
65
66const DEFAULT_ICON_SIZE: f64 = 24.0;
69
70const PAD_X: f64 = 12.0;
75const PAD_Y: f64 = 8.0;
77
78const RADIUS: f64 = 6.0;
82
83const UNTHEMED_INK: Color = Color::from_rgb8(0x1D, 0x1B, 0x20);
88
89fn with_alpha(color: Color, alpha: f32) -> Color {
92 let c = color.components;
93 Color::new([c[0], c[1], c[2], alpha])
94}
95
96type OnPress<State> = Rc<dyn Fn(&mut State)>;
98
99fn inside(pos: Point, size: Size) -> bool {
103 pos.x >= 0.0 && pos.y >= 0.0 && pos.x < size.width && pos.y < size.height
104}
105
106fn mark_view<State: 'static>(mark: IconData, icon_size: f64, ink: Option<Color>) -> AnyView<State> {
111 let mut view = icon(mark).size(icon_size);
112 if let Some(ink) = ink {
113 view = view.color(ink);
114 }
115 any::<State, _>(view)
116}
117
118pub struct IconButtonView<State: 'static> {
120 mark: IconData,
121 label: String,
122 ink: Option<Color>,
125 icon_size: f64,
126 slot_size: Option<f64>,
129 on_press: OnPress<State>,
130}
131
132pub fn icon_button<State: 'static, F: Fn(&mut State) + 'static>(
136 mark: impl Into<IconData>,
137 label: impl Into<String>,
138 on_press: F,
139) -> IconButtonView<State> {
140 IconButtonView {
141 mark: mark.into(),
142 label: label.into(),
143 ink: None,
144 icon_size: DEFAULT_ICON_SIZE,
145 slot_size: None,
146 on_press: Rc::new(on_press),
147 }
148}
149
150#[allow(non_snake_case)]
153pub fn IconButton<State: 'static, F: Fn(&mut State) + 'static>(
154 mark: impl Into<IconData>,
155 label: impl Into<String>,
156 on_press: F,
157) -> IconButtonView<State> {
158 icon_button(mark, label, on_press)
159}
160
161impl<State: 'static> IconButtonView<State> {
162 pub fn ink(mut self, ink: Color) -> Self {
165 self.ink = Some(ink);
166 self
167 }
168
169 pub fn icon_size(mut self, size: f64) -> Self {
171 self.icon_size = size;
172 self
173 }
174
175 pub fn slot_size(mut self, size: f64) -> Self {
179 self.slot_size = Some(size);
180 self
181 }
182}
183
184pub struct IconButtonWidget {
187 mark: ChildPod,
188 label: String,
191 ink: Option<Color>,
192 icon_size: f64,
193 slot_size: Option<f64>,
194 pressed: bool,
197 captured: bool,
202 on_press: ErasedCallback,
203}
204
205impl IconButtonWidget {
206 fn resolve_radius(theme: Option<&Theme>, size: Size) -> f64 {
211 match theme {
212 Some(theme) => {
213 frust_theme::ShapeScale::resolve(theme.shape.small, size.width, size.height)
214 }
215 None => RADIUS,
216 }
217 }
218
219 fn resolve_ink(&self, theme: Option<&Theme>) -> Color {
224 if let Some(ink) = self.ink {
225 return ink;
226 }
227 match theme {
228 Some(theme) => theme.scheme().on_surface,
229 None => UNTHEMED_INK,
230 }
231 }
232}
233
234impl<State: 'static> View<State> for IconButtonView<State> {
235 type Element = IconButtonWidget;
236
237 fn build(&self, ctx: &mut BuildCtx<'_>) -> IconButtonWidget {
238 let mark_view = mark_view::<State>(self.mark.clone(), self.icon_size, self.ink);
239 IconButtonWidget {
240 mark: crate::authoring::build_child(&mark_view, ctx),
241 label: self.label.clone(),
242 ink: self.ink,
243 icon_size: self.icon_size,
244 slot_size: self.slot_size,
245 pressed: false,
246 captured: false,
247 on_press: erase_callback(&self.on_press),
248 }
249 }
250
251 fn rebuild(
252 &self,
253 prev: &Self,
254 element: &mut IconButtonWidget,
255 ctx: &mut BuildCtx<'_>,
256 ) -> ChangeFlags {
257 element.on_press = erase_callback(&self.on_press);
259 let prev_view = mark_view::<State>(prev.mark.clone(), prev.icon_size, prev.ink);
260 let next_view = mark_view::<State>(self.mark.clone(), self.icon_size, self.ink);
261 let mut flags =
262 crate::authoring::rebuild_child(&prev_view, &next_view, &mut element.mark, ctx);
263 if prev.icon_size != self.icon_size || prev.slot_size != self.slot_size {
264 element.icon_size = self.icon_size;
265 element.slot_size = self.slot_size;
266 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
267 }
268 if prev.ink != self.ink {
269 element.ink = self.ink;
270 flags |= ChangeFlags::PAINT;
271 }
272 if prev.label != self.label {
273 element.label = self.label.clone();
274 flags |= ChangeFlags::PAINT;
278 }
279 flags
280 }
281
282 fn teardown(&self, element: &mut IconButtonWidget, ctx: &mut BuildCtx<'_>) {
283 let mark_view = mark_view::<State>(self.mark.clone(), self.icon_size, self.ink);
284 crate::authoring::teardown_child(&mark_view, &mut element.mark, ctx);
285 }
286}
287
288impl Widget for IconButtonWidget {
289 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
290 let icon_bc = BoxConstraints::tight(Size::new(self.icon_size, self.icon_size));
293 let mark_size = self.mark.layout_child(ctx, &icon_bc);
294 let side = self.slot_size.unwrap_or_else(|| {
295 let width = mark_size.width + PAD_X * 2.0;
296 let height = mark_size.height + PAD_Y * 2.0;
297 width.max(height)
298 });
299 let size = bc.constrain(Size::new(side, side));
300 self.mark.set_origin(Point::new(
301 (size.width - mark_size.width) / 2.0,
302 (size.height - mark_size.height) / 2.0,
303 ));
304 size
305 }
306
307 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
308 let theme = Theme::from_paint_ctx(ctx);
309 let ink = self.resolve_ink(theme);
310 let radius = Self::resolve_radius(theme, ctx.size());
311 self.mark.paint_child(ctx, scene);
312 if self.pressed {
313 scene.fill_rounded_rect(
317 ctx.origin(),
318 ctx.size(),
319 radius,
320 with_alpha(ink, PRESSED_OPACITY),
321 );
322 }
323 }
324
325 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
326 let InputEvent::Pointer(p) = event else {
327 return EventResult::Ignored;
328 };
329 match p.phase {
330 PointerPhase::Down => {
331 if !presses(p) {
332 return EventResult::Ignored;
333 }
334 self.pressed = true;
335 self.captured = true;
336 ctx.capture_pointer();
337 ctx.request_redraw();
338 EventResult::Handled
339 }
340 PointerPhase::Move => {
341 if !self.captured {
344 return EventResult::Ignored;
345 }
346 self.pressed = inside(p.position, ctx.size());
347 ctx.request_redraw();
348 EventResult::Handled
349 }
350 PointerPhase::Up => {
351 if !self.captured {
352 return EventResult::Ignored;
353 }
354 if inside(p.position, ctx.size()) {
356 (self.on_press)(ctx);
357 }
358 self.pressed = false;
359 self.captured = false;
360 ctx.request_redraw();
361 EventResult::Handled
362 }
363 PointerPhase::Cancel => {
364 if !self.captured {
365 return EventResult::Ignored;
366 }
367 self.pressed = false;
368 self.captured = false;
369 ctx.request_redraw();
370 EventResult::Handled
371 }
372 }
373 }
374
375 fn semantics(&self, ctx: &mut SemanticsCtx) {
376 ctx.push_node(Role::Button, |node| {
381 node.set_label(self.label.as_str());
382 node.add_action(Action::Click);
383 });
384 }
385
386 crate::authoring::visit_children!(mark);
387}
388
389#[cfg(test)]
390mod tests {
391 use super::*;
392 use std::any::Any;
393
394 #[derive(Default)]
395 struct Counter {
396 presses: u32,
397 }
398
399 fn widget() -> IconButtonWidget {
403 let view = icon_button::<Counter, _>(crate::icons::CLOSE, "Close", |s: &mut Counter| {
404 s.presses += 1
405 });
406 let mut counter = 0u64;
407 View::<Counter>::build(&view, &mut BuildCtx::new(&mut counter))
408 }
409
410 fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
411 InputEvent::Pointer(frust_core::PointerEvent {
412 phase,
413 position: Point::new(x, y),
414 button: frust_core::PointerButton::Primary,
415 })
416 }
417
418 fn secondary_ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
419 InputEvent::Pointer(frust_core::PointerEvent {
420 phase,
421 position: Point::new(x, y),
422 button: frust_core::PointerButton::Secondary,
423 })
424 }
425
426 fn dispatch(w: &mut IconButtonWidget, state: &mut Counter, event: &InputEvent) {
427 let state_any: &mut dyn Any = state;
428 let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(48.0, 48.0));
429 w.event(&mut ctx, event);
430 }
431
432 #[test]
433 fn down_then_up_inside_fires_once() {
434 let mut w = widget();
435 let mut state = Counter::default();
436 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 10.0));
437 assert!(w.pressed);
438 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 12.0, 12.0));
439 assert_eq!(state.presses, 1);
440 assert!(!w.pressed);
441 }
442
443 #[test]
444 fn down_inside_move_out_up_outside_does_not_fire() {
445 let mut w = widget();
446 let mut state = Counter::default();
447 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 10.0));
448 dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 200.0, 10.0));
449 assert!(!w.pressed, "moving out clears the pressed wash");
450 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 200.0, 10.0));
451 assert_eq!(state.presses, 0, "up outside must not fire");
452 }
453
454 #[test]
455 fn cancel_clears_pressed_without_firing() {
456 let mut w = widget();
457 let mut state = Counter::default();
458 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 10.0));
459 dispatch(&mut w, &mut state, &ev(PointerPhase::Cancel, 10.0, 10.0));
460 assert!(!w.pressed);
461 assert_eq!(state.presses, 0);
462 }
463
464 #[test]
465 fn move_back_inside_then_up_fires() {
466 let mut w = widget();
467 let mut state = Counter::default();
468 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 10.0));
469 dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 200.0, 10.0));
470 dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 20.0, 10.0));
471 assert!(w.pressed);
472 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 20.0, 10.0));
473 assert_eq!(state.presses, 1);
474 }
475
476 #[test]
477 fn up_without_down_does_not_fire() {
478 let mut w = widget();
479 let mut state = Counter::default();
480 let state_any: &mut dyn Any = &mut state;
481 let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(48.0, 48.0));
482 let result = w.event(&mut ctx, &ev(PointerPhase::Up, 20.0, 20.0));
483 assert!(matches!(result, EventResult::Ignored));
484 assert_eq!(state.presses, 0, "an unarmed Up must never fire");
485 }
486
487 #[test]
488 fn hover_move_without_down_is_ignored_noop() {
489 let mut w = widget();
490 let mut state = Counter::default();
491 let state_any: &mut dyn Any = &mut state;
492 let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(48.0, 48.0));
493 let result = w.event(&mut ctx, &ev(PointerPhase::Move, 20.0, 20.0));
494 assert!(matches!(result, EventResult::Ignored));
495 assert!(!w.pressed, "hover must not press");
496 assert!(!ctx.needs_redraw(), "hover must not request a redraw");
497 assert_eq!(state.presses, 0);
498 }
499
500 #[test]
501 fn a_secondary_press_neither_presses_nor_captures_nor_fires() {
502 let mut w = widget();
503 let mut state = Counter::default();
504 dispatch(
505 &mut w,
506 &mut state,
507 &secondary_ev(PointerPhase::Down, 10.0, 10.0),
508 );
509 assert!(!w.pressed, "no pressed wash on a right-click");
510 assert!(!w.captured, "and no capture for the shell to wedge on");
511 dispatch(
512 &mut w,
513 &mut state,
514 &secondary_ev(PointerPhase::Up, 10.0, 10.0),
515 );
516 assert_eq!(state.presses, 0);
517
518 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 10.0));
520 assert!(w.pressed);
521 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 10.0, 10.0));
522 assert_eq!(state.presses, 1);
523 }
524
525 #[test]
526 fn semantics_publishes_a_single_button_node_named_by_the_label() {
527 fn logic(_s: &mut ()) -> IconButtonView<()> {
532 icon_button::<(), _>(crate::icons::CLOSE, "Close", |_: &mut ()| {})
533 }
534 let mut root: frust_core::RenderRoot<(), IconButtonView<()>> =
535 frust_core::RenderRoot::new();
536 let mut state = ();
537 root.rebuild(&mut logic, &mut state);
538 let mut tcx = frust_text::TextContext::new();
539 root.layout_with_text(Size::new(200.0, 200.0), &mut tcx as &mut dyn Any);
540 let update = root.semantics();
541 let matches: Vec<_> = update
542 .nodes
543 .iter()
544 .filter(|(_, n)| n.role() == Role::Button)
545 .collect();
546 assert_eq!(matches.len(), 1, "one a11y node, not one per glyph path");
547 let (_, node) = matches[0];
548 assert_eq!(node.label(), Some("Close"));
549 assert!(node.supports_action(Action::Click));
550 }
551
552 #[test]
555 fn default_slot_squares_a_24px_glyph_to_48px() {
556 let mut w = widget();
557 let mut lctx = LayoutCtx::new();
558 let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(500.0, 500.0)));
559 assert_eq!(size, Size::new(48.0, 48.0));
560 }
561
562 #[test]
563 fn explicit_slot_size_overrides_the_padding_derived_default() {
564 let view = icon_button::<Counter, _>(crate::icons::CLOSE, "Close", |_: &mut Counter| {})
565 .slot_size(30.0);
566 let mut counter = 0u64;
567 let mut w = View::<Counter>::build(&view, &mut BuildCtx::new(&mut counter));
568 let mut lctx = LayoutCtx::new();
569 let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(500.0, 500.0)));
570 assert_eq!(size, Size::new(30.0, 30.0));
571 }
572
573 #[test]
574 fn explicit_icon_size_grows_the_default_slot() {
575 let view = icon_button::<Counter, _>(crate::icons::CLOSE, "Close", |_: &mut Counter| {})
576 .icon_size(32.0);
577 let mut counter = 0u64;
578 let mut w = View::<Counter>::build(&view, &mut BuildCtx::new(&mut counter));
579 let mut lctx = LayoutCtx::new();
580 let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(500.0, 500.0)));
581 assert_eq!(size, Size::new(56.0, 56.0));
583 }
584}