Skip to main content

qframe/widgets/
icon_button.rs

1//! Icon buttons: one glyph, pressable, for the small controls at the edge of a header or a row.
2
3use std::time::Duration;
4
5use super::placement::Placement;
6use super::press::{self, Press};
7use super::tooltip;
8use crate::event::Event;
9use crate::geometry::{Rect, Size};
10use crate::style::CellStyle;
11use crate::text;
12use crate::theme::State;
13use crate::widget::{EventCx, MeasureCx, PaintCx, Widget};
14
15/// A button that is one icon: a space, the glyph and a space, three cells in every glyph mode.
16///
17/// It stands on the ground around it, with no raised surface, so a row of them reads as quiet
18/// marks rather than as buttons with labels. The pointer lightens all three cells, keyboard focus
19/// lightens them one step further, and a press flashes them one step more; there is no pillar,
20/// because three cells have no room for one before the glyph. Enter or Space while focused, or a
21/// click released over it, sends its message, like a [`Button`](super::Button).
22///
23/// Its meaning is only the glyph, so give it a [`tooltip`](Self::tooltip): the words show below it
24/// after the pointer rests on it for the theme's hover delay, and at once when it is reached with
25/// the keyboard.
26///
27/// Style keys: `icon-button` (`fg`, `bg`, `bold`) with states `hover`, `focus`, `pressed` and
28/// `disabled`; `tooltip` for its words.
29///
30/// ```
31/// use qframe::prelude::*;
32/// use qframe::widgets::IconButton;
33///
34/// struct Header;
35///
36/// impl App for Header {
37///     type Msg = ();
38///     fn update(&mut self, (): ()) -> Command<()> {
39///         Command::none()
40///     }
41///     fn view(&self, ui: &mut View<'_, ()>) {
42///         ui.row(|ui| {
43///             ui.add(Text::new("Packages")).fill_width();
44///             ui.add(IconButton::new("settings").tooltip("Settings").on_press(()));
45///         })
46///         .fill_width();
47///     }
48/// }
49///
50/// let app = Harness::new(Header, 20, 1);
51/// let glyph = app.env().icons().glyph("settings").into_owned();
52/// assert_eq!(app.find(&glyph), Some((18, 0)), "a space, the glyph and a space at the end");
53/// ```
54pub struct IconButton<Msg> {
55    icon: String,
56    tooltip: Option<String>,
57    disabled: bool,
58    on_press: Option<Msg>,
59}
60
61/// When the pointer came to rest on the button and when its tooltip began to show.
62#[derive(Debug, Default)]
63struct IconButtonMemory {
64    hovered_since: Option<Duration>,
65    shown_since: Option<Duration>,
66}
67
68/// Cells an icon button takes around its glyph: one space on each side.
69const SIDES: u16 = 2;
70
71impl<Msg> IconButton<Msg> {
72    /// A button showing the icon `key` of the icon set, such as `"settings"` or `"close"`.
73    #[must_use]
74    pub fn new(key: impl Into<String>) -> Self {
75        Self { icon: key.into(), tooltip: None, disabled: false, on_press: None }
76    }
77
78    /// The message sent when the button is pressed.
79    #[must_use]
80    pub fn on_press(mut self, message: Msg) -> Self {
81        self.on_press = Some(message);
82        self
83    }
84
85    /// Words that say what the button does, shown below it after the hover delay and at once
86    /// when it is reached with the keyboard.
87    #[must_use]
88    pub fn tooltip(mut self, text: impl Into<String>) -> Self {
89        self.tooltip = Some(text.into());
90        self
91    }
92
93    /// Greys the button out; it cannot be focused or pressed.
94    #[must_use]
95    pub fn disabled(mut self, disabled: bool) -> Self {
96        self.disabled = disabled;
97        self
98    }
99
100    fn active(&self) -> bool {
101        !self.disabled && self.on_press.is_some()
102    }
103}
104
105impl<Msg: Clone + 'static> Widget<Msg> for IconButton<Msg> {
106    fn measure(&self, cx: &mut MeasureCx<'_>, available: Size) -> Size {
107        let glyph = text::width(&cx.env().icons().glyph(&self.icon));
108        Size::new(glyph.saturating_add(SIDES), 1).min(available)
109    }
110
111    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
112        let active = self.active();
113        let mut states = if active { cx.pressable_states() } else { Vec::new() };
114        if self.disabled {
115            states.push(State::Disabled);
116        }
117        let style = cx.style("icon-button", None, &states).text();
118        // At rest the theme gives no ground, so the button keeps whatever it stands on.
119        if let Some(bg) = style.bg {
120            cx.fill(area, bg);
121        }
122        if active {
123            cx.register_hit(area);
124        }
125        let glyph = cx.env().icons().glyph(&self.icon).into_owned();
126        let width = text::width(&glyph);
127        let x = area.x + i32::from(area.width.saturating_sub(width) / 2);
128        cx.text(x, area.y, &glyph, CellStyle { bg: None, ..style }, area.width);
129        self.schedule_tooltip(cx, area, &states);
130    }
131
132    fn paint_overlay(&self, cx: &mut PaintCx<'_>, anchor: Rect) {
133        let Some(text) = &self.tooltip else {
134            return;
135        };
136        let since = cx.memory::<IconButtonMemory>().shown_since.unwrap_or_default();
137        tooltip::paint_tip(cx, anchor, text, Placement::Below, since);
138    }
139
140    fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
141        if !self.active() {
142            return false;
143        }
144        match press::read(cx, event) {
145            Press::Ignored => false,
146            Press::Used => true,
147            Press::Key | Press::Click(..) => {
148                if let Some(message) = &self.on_press {
149                    cx.flash();
150                    cx.emit(message.clone());
151                }
152                true
153            }
154        }
155    }
156
157    fn focusable(&self) -> bool {
158        self.active()
159    }
160}
161
162impl<Msg: Clone + 'static> IconButton<Msg> {
163    /// Tracks how long the pointer has rested on the button and asks for the overlay once its
164    /// tooltip is due, or at once while keyboard focus is on it.
165    fn schedule_tooltip(&self, cx: &mut PaintCx<'_>, area: Rect, states: &[State]) {
166        if self.tooltip.is_none() {
167            return;
168        }
169        let now = cx.now();
170        let delay = cx.env().theme().motion().hover_delay;
171        let hovered = states.contains(&State::Hover);
172        let keyboard = states.contains(&State::Focus);
173        let memory = cx.memory::<IconButtonMemory>();
174        memory.hovered_since = if hovered { Some(memory.hovered_since.unwrap_or(now)) } else { None };
175        let due = memory.hovered_since.map(|since| since + delay);
176        let visible = keyboard || due.is_some_and(|due| now >= due);
177        memory.shown_since = if visible { Some(memory.shown_since.unwrap_or(now)) } else { None };
178        if visible {
179            cx.request_overlay(area);
180        } else if let Some(due) = due {
181            cx.request_frame_in(due.saturating_sub(now));
182        }
183    }
184}