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/// A [`selected`](Self::selected) icon button draws its glyph in the accent colour, for a
28/// control that is on, such as a bookmark star for the page shown or the chosen view of a few.
29///
30/// Style keys: `icon-button` (`fg`, `bg`, `bold`) with states `hover`, `focus`, `pressed`,
31/// `selected` and `disabled`; `tooltip` for its words.
32///
33/// ```
34/// use qframe::prelude::*;
35/// use qframe::widgets::IconButton;
36///
37/// struct Header;
38///
39/// impl App for Header {
40///     type Msg = ();
41///     fn update(&mut self, (): ()) -> Command<()> {
42///         Command::none()
43///     }
44///     fn view(&self, ui: &mut View<'_, ()>) {
45///         ui.row(|ui| {
46///             ui.add(Text::new("Packages")).fill_width();
47///             ui.add(IconButton::new("settings").tooltip("Settings").on_press(()));
48///         })
49///         .fill_width();
50///     }
51/// }
52///
53/// let app = Harness::new(Header, 20, 1);
54/// let glyph = app.env().icons().glyph("settings").into_owned();
55/// assert_eq!(app.find(&glyph), Some((18, 0)), "a space, the glyph and a space at the end");
56/// ```
57pub struct IconButton<Msg> {
58    icon: String,
59    tooltip: Option<String>,
60    disabled: bool,
61    selected: bool,
62    on_press: Option<Msg>,
63}
64
65/// When the pointer came to rest on the button and when its tooltip began to show.
66#[derive(Debug, Default)]
67struct IconButtonMemory {
68    hovered_since: Option<Duration>,
69    shown_since: Option<Duration>,
70}
71
72/// Cells an icon button takes around its glyph: one space on each side.
73const SIDES: u16 = 2;
74
75impl<Msg> IconButton<Msg> {
76    /// A button showing the icon `key` of the icon set, such as `"settings"` or `"close"`.
77    #[must_use]
78    pub fn new(key: impl Into<String>) -> Self {
79        Self { icon: key.into(), tooltip: None, disabled: false, selected: false, on_press: None }
80    }
81
82    /// The message sent when the button is pressed.
83    #[must_use]
84    pub fn on_press(mut self, message: Msg) -> Self {
85        self.on_press = Some(message);
86        self
87    }
88
89    /// Words that say what the button does, shown below it after the hover delay and at once
90    /// when it is reached with the keyboard.
91    #[must_use]
92    pub fn tooltip(mut self, text: impl Into<String>) -> Self {
93        self.tooltip = Some(text.into());
94        self
95    }
96
97    /// Greys the button out; it cannot be focused or pressed.
98    #[must_use]
99    pub fn disabled(mut self, disabled: bool) -> Self {
100        self.disabled = disabled;
101        self
102    }
103
104    /// Shows the button on: its glyph takes the accent colour (the light accent over the
105    /// accent-tinted keyboard focus tone, where the accent itself would not read), and hover,
106    /// focus and presses still light its cells as usual. A theme without an
107    /// `icon-button:selected` style still shows the accent. It stays pressable; the application flips the state in its
108    /// message. Default: `false`.
109    #[must_use]
110    pub fn selected(mut self, selected: bool) -> Self {
111        self.selected = selected;
112        self
113    }
114
115    fn active(&self) -> bool {
116        !self.disabled && self.on_press.is_some()
117    }
118}
119
120impl<Msg: Clone + 'static> Widget<Msg> for IconButton<Msg> {
121    fn measure(&self, cx: &mut MeasureCx<'_>, available: Size) -> Size {
122        let glyph = text::width(&cx.env().icons().glyph(&self.icon));
123        Size::new(glyph.saturating_add(SIDES), 1).min(available)
124    }
125
126    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
127        let active = self.active();
128        let mut states = if active { cx.pressable_states() } else { Vec::new() };
129        if self.disabled {
130            states.push(State::Disabled);
131        }
132        if self.selected {
133            states.push(State::Selected);
134        }
135
136        let mut style = cx.style("icon-button", None, &states).text();
137        // A theme that says nothing about the selected state, so that a selected button at rest
138        // looks like any other, still shows it: the glyph takes the accent, unless the button is
139        // disabled, which reads as faint whatever it is.
140        if self.selected && !self.disabled {
141            let quiet =
142                cx.style("icon-button", None, &[State::Selected]).text() == cx.style("icon-button", None, &[]).text();
143            if quiet {
144                style.fg = Some(cx.color("accent"));
145            }
146        }
147        // At rest the theme gives no ground, so the button keeps whatever it stands on.
148        if let Some(bg) = style.bg {
149            cx.fill(area, bg);
150        }
151        if active {
152            cx.register_hit(area);
153        }
154        let glyph = cx.env().icons().glyph(&self.icon).into_owned();
155        let width = text::width(&glyph);
156        let x = area.x + i32::from(area.width.saturating_sub(width) / 2);
157        cx.text(x, area.y, &glyph, CellStyle { bg: None, ..style }, area.width);
158        self.schedule_tooltip(cx, area, &states);
159    }
160
161    fn paint_overlay(&self, cx: &mut PaintCx<'_>, anchor: Rect) {
162        let Some(text) = &self.tooltip else {
163            return;
164        };
165        let since = cx.memory::<IconButtonMemory>().shown_since.unwrap_or_default();
166        tooltip::paint_tip(cx, anchor, text, Placement::Below, since);
167    }
168
169    fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
170        if !self.active() {
171            return false;
172        }
173        match press::read(cx, event) {
174            Press::Ignored => false,
175            Press::Used => true,
176            Press::Key | Press::Click(..) => {
177                if let Some(message) = &self.on_press {
178                    cx.flash();
179                    cx.emit(message.clone());
180                }
181                true
182            }
183        }
184    }
185
186    fn focusable(&self) -> bool {
187        self.active()
188    }
189}
190
191impl<Msg: Clone + 'static> IconButton<Msg> {
192    /// Tracks how long the pointer has rested on the button and asks for the overlay once its
193    /// tooltip is due, or at once while keyboard focus is on it.
194    fn schedule_tooltip(&self, cx: &mut PaintCx<'_>, area: Rect, states: &[State]) {
195        if self.tooltip.is_none() {
196            return;
197        }
198        let now = cx.now();
199        let delay = cx.env().theme().motion().hover_delay;
200        let hovered = states.contains(&State::Hover);
201        let keyboard = states.contains(&State::Focus);
202        let memory = cx.memory::<IconButtonMemory>();
203        memory.hovered_since = if hovered { Some(memory.hovered_since.unwrap_or(now)) } else { None };
204        let due = memory.hovered_since.map(|since| since + delay);
205        let visible = keyboard || due.is_some_and(|due| now >= due);
206        memory.shown_since = if visible { Some(memory.shown_since.unwrap_or(now)) } else { None };
207        if visible {
208            cx.request_overlay(area);
209        } else if let Some(due) = due {
210            cx.request_frame_in(due.saturating_sub(now));
211        }
212    }
213}