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