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}