denise_ui/widgets/style.rs
1//! Shared visual vocabulary, so the widgets agree with each other.
2
3use denise::Pen;
4use denise::theme::{AA_LARGE, contrast_x100, derive_content};
5use denise::{Color, Point, Rect, Role, Size, Theme};
6use denise_text::{TextEngine, TextStyle};
7
8use crate::widget::VisualState;
9
10/// How much a hovered surface shifts towards its own content colour.
11const HOVER_MIX: u8 = 24;
12/// How much a pressed surface shifts. Larger, so press is unmistakably a
13/// different state and not just a stronger hover.
14///
15/// This cannot be turned up freely: moving a background towards its own text
16/// colour costs contrast, and the light theme's `primary` pair breaks 3:1 at 72.
17/// `every_state_keeps_the_pair_readable` is what found that, and is what will
18/// find it again if a future theme has a tighter pair than today's do.
19const PRESS_MIX: u8 = 64;
20
21/// How far a de-emphasised label is moved towards the surface behind it.
22///
23/// For text that is *present but not the point*: an unselected tab, a row a list
24/// will not let you choose. Enough to make the emphasised one obviously
25/// emphasised, and not so far that the others stop being readable.
26///
27/// Swept against the built-in themes: 96 leaves the light theme at 2.93:1, under
28/// the 3:1 floor, and 64 leaves `Base100` at 3.84:1 in the worst of the three.
29/// The same number `PRESS_MIX` arrived at, for the same reason.
30///
31/// Not enough on its own, though — see [`muted`].
32const MUTE: u8 = 64;
33
34/// `content` moved towards `surface`, but only as far as it can afford to go.
35///
36/// De-emphasis costs contrast, and **not every pair has contrast to spend.** Two
37/// separate widgets found that out the hard way, and this is the rule that covers
38/// both:
39///
40/// - [`interactive_pair`] *derives* a disabled widget's content by mixing until it
41/// **just** clears the floor. Muting that drops a label to 2.33:1.
42/// - A theme's saturated pairs are only guaranteed to *reach* the floor. Muting
43/// the dark theme's `Primary` content leaves 2.94:1, so a selected row that was
44/// also disabled would have been unreadable in one theme out of three.
45///
46/// A pair with room to give — `Base100` against `BaseContent` is near-black on
47/// near-white — mutes as asked. One that has none is returned unchanged, because
48/// legible and undifferentiated beats differentiated and illegible.
49pub(crate) fn muted(surface: Color, content: Color) -> Color {
50 let muted = content.mix(surface, MUTE);
51 if contrast_x100(surface, muted) >= AA_LARGE {
52 muted
53 } else {
54 content
55 }
56}
57
58/// Which way a widget runs.
59///
60/// Shared rather than owned by one widget: a divider, and later a slider or a
61/// group of options, all mean the same thing by it.
62#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
63pub enum Orientation {
64 /// Left to right, splitting a column of content.
65 #[default]
66 Horizontal,
67 /// Top to bottom, splitting a row.
68 Vertical,
69}
70
71/// Where text sits along one axis of its box.
72#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
73pub enum Align {
74 /// Left, or top.
75 #[default]
76 Start,
77 /// Centred.
78 Center,
79 /// Right, or bottom.
80 End,
81}
82
83impl Align {
84 /// Offset of a `content`-long run inside an `available`-long box.
85 #[inline]
86 pub const fn offset(self, available: i32, content: i32) -> i32 {
87 match self {
88 Align::Start => 0,
89 Align::Center => (available - content) / 2,
90 Align::End => available - content,
91 }
92 }
93}
94
95/// Surface and content colours for an interactive widget in a given state.
96///
97/// The shift is always *towards the widget's own content colour*, never towards
98/// black or white. That is what keeps a hover readable on a light theme and on a
99/// dark one without either being special-cased: the pair already guarantees
100/// contrast, so moving along the line between them cannot break it.
101pub(crate) fn interactive_pair(theme: &Theme, role: Role, state: VisualState) -> (Color, Color) {
102 let (background, content) = theme.pair(role);
103 if state.contains(VisualState::DISABLED) {
104 // Disabled is a *recessed* surface with *derived* content, not a faded
105 // one. Fading text towards its background is what produces the grey-on-
106 // grey that nobody can read in daylight; deriving stops the moment it
107 // clears 3:1, so it looks muted without becoming a guess.
108 let background = theme.color(Role::Base200);
109 return (background, derive_content(background, AA_LARGE));
110 }
111 if state.contains(VisualState::PRESSED) {
112 return (background.mix(content, PRESS_MIX), content);
113 }
114 if state.contains(VisualState::HOVERED) {
115 return (background.mix(content, HOVER_MIX), content);
116 }
117 (background, content)
118}
119
120/// Draws the keyboard focus ring, just inside `bounds`.
121///
122/// A ring rather than a colour change, because a panel driven only by Tab has to
123/// show focus on a widget that may already be hovered or pressed.
124pub(crate) fn focus_ring(theme: &Theme, bounds: Rect, radius: i32, canvas: &mut Pen<'_>) {
125 canvas.stroke_rounded_rect(
126 bounds.inflate(-1),
127 (radius - 1).max(0),
128 2,
129 theme.color(Role::Accent),
130 );
131}
132
133/// Draws `text` inside `bounds` with the given alignment, and returns its extent.
134///
135/// Measurement goes through the engine, so the box a widget centres in is the box
136/// the glyphs actually occupy — including with a proportional font, where the
137/// answer is not the character count times anything.
138pub(crate) fn draw_aligned(
139 canvas: &mut Pen<'_>,
140 engine: &mut TextEngine,
141 style: TextStyle,
142 bounds: Rect,
143 align: (Align, Align),
144 text: &str,
145 color: Color,
146) -> Size {
147 let extent = engine.measure(style, text);
148 let at = Point::new(
149 bounds.x + align.0.offset(bounds.width, extent.width as i32),
150 bounds.y + align.1.offset(bounds.height, extent.height as i32),
151 );
152 engine.draw(canvas, style, at, text, color);
153 extent
154}
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159
160 #[test]
161 fn alignment_offsets() {
162 assert_eq!(Align::Start.offset(100, 20), 0);
163 assert_eq!(Align::Center.offset(100, 20), 40);
164 assert_eq!(Align::End.offset(100, 20), 80);
165 // Content wider than its box overflows to the left of it, not off the
166 // right, which keeps the first characters readable.
167 assert_eq!(Align::Center.offset(20, 100), -40);
168 }
169
170 #[test]
171 fn every_state_keeps_the_pair_readable() {
172 for theme in Theme::BUILT_IN {
173 for role in [Role::Primary, Role::Secondary, Role::Accent, Role::Error] {
174 for state in [
175 VisualState::NONE,
176 VisualState::HOVERED,
177 VisualState::PRESSED,
178 VisualState::DISABLED,
179 ] {
180 let (background, content) = interactive_pair(&theme, role, state);
181 let ratio = denise::theme::contrast_x100(background, content);
182 assert!(
183 ratio >= AA_LARGE,
184 "{} {role:?} {state:?} is {ratio} against a floor of {AA_LARGE}",
185 theme.name
186 );
187 }
188 }
189 }
190 }
191}
192
193/// How a row is drawn.
194#[derive(Clone, Copy, Debug, PartialEq, Eq)]
195pub(crate) enum RowKind {
196 /// Neither selected nor under the pointer.
197 Resting,
198 /// Under the pointer.
199 Hovered,
200 /// The selected row.
201 Selected,
202}
203
204/// Fill and text colour for one row.
205///
206/// One function so the paint path and the contrast test cannot disagree about
207/// what is actually drawn — and shared between [`List`](super::List) and
208/// [`Table`](super::Table), so the disabled-selection answer lives once.
209pub(crate) fn row_colors(
210 theme: &Theme,
211 state: VisualState,
212 role: Role,
213 kind: RowKind,
214 enabled: bool,
215) -> (Color, Color) {
216 // The tree's HOVERED and PRESSED bits describe the *list*, not a row. Passing
217 // them through would tint all twenty rows the moment the pointer entered the
218 // widget, which is the opposite of what a hover highlight is for; the row
219 // says it is hovered by being drawn in `Base200`.
220 let state = state
221 .set(VisualState::HOVERED, false)
222 .set(VisualState::PRESSED, false);
223 // Every pairing comes out of `interactive_pair`, so both colours of a row are
224 // guaranteed against each other. A role is only ever guaranteed against its
225 // own content — never against whatever surface it happens to sit on.
226 let (surface, content) = match kind {
227 // A disabled list still has to show which row is selected.
228 // `interactive_pair` recesses *every* role to `Base200` when disabled, so
229 // the selected row and a resting one would be the same drawing — the
230 // mistake `RadioGroup` avoided by keeping a mark inside its disabled disc.
231 // `Base300` is the theme's own next step up from that surface, and it
232 // comes with its own content colour.
233 RowKind::Selected if state.contains(VisualState::DISABLED) => theme.pair(Role::Base300),
234 RowKind::Selected => interactive_pair(theme, role, state),
235 RowKind::Hovered => interactive_pair(theme, Role::Base200, state),
236 RowKind::Resting => interactive_pair(theme, Role::Base100, state),
237 };
238 if enabled {
239 (surface, content)
240 } else {
241 // A row nobody can choose is de-emphasised — as far as this particular
242 // pair can afford, which for a disabled list, or for a selected row in a
243 // saturated role, is not at all. `muted` is what decides that.
244 (surface, muted(surface, content))
245 }
246}
247
248/// The row to highlight under the pointer.
249///
250/// `None` unless the tree still says the pointer is over this widget. The tree
251/// clears `HOVERED` when the pointer moves to another widget and **does not send
252/// this widget an event when it does** — [`InputEvent::PointerLeft`] never
253/// reaches a widget at all. Trusting the remembered row on its own leaves a row
254/// lit up under a pointer that is somewhere else entirely.
255pub(crate) fn hovered_row(state: VisualState, remembered: Option<usize>) -> Option<usize> {
256 if state.contains(VisualState::HOVERED) {
257 remembered
258 } else {
259 None
260 }
261}
262
263/// How long after a click a second one on the same row still counts as a pair.
264///
265/// The platform default nearly everywhere. Measured against
266/// [`Ui::tick`](crate::Ui::tick)'s clock — an application that never calls
267/// `tick` has a clock frozen at zero, and every second click reads as a pair.
268pub(crate) const DOUBLE_CLICK_MS: u64 = 400;
269
270/// How long a caret blinks after it was last moved or typed at, in
271/// milliseconds; then it stays lit until it is moved again. GTK's default.
272///
273/// A caret that blinks forever wakes the event loop twice a second, for as
274/// long as a window sits focused and forgotten — a frame each time, and on a
275/// software-rendered desktop that is most of what an idle editor spends. An
276/// even number of half-periods, so the blink ends on a lit caret and the last
277/// wake is the one that stops it.
278pub(crate) const CARET_BLINKS_FOR_MS: u64 = 10_000;
279
280/// What a click on a row turned out to mean.
281#[derive(Clone, Copy, Debug, PartialEq, Eq)]
282pub(crate) enum Intent {
283 Select,
284 Activate,
285}
286
287/// Double-click detection, shared by [`List`](super::List) and
288/// [`Table`](super::Table) so the pairing rules cannot drift apart.
289#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
290pub(crate) struct ClickPair {
291 last: Option<(usize, u64)>,
292}
293
294impl ClickPair {
295 /// What a click on `row` at `at_ms` means, remembering it for the next one.
296 ///
297 /// With `single_click`, every click activates — the touch-panel answer,
298 /// where a double-tap is unreliable and unexpected.
299 pub(crate) fn classify(&mut self, row: usize, at_ms: u64, single_click: bool) -> Intent {
300 if single_click {
301 return Intent::Activate;
302 }
303 let pair = self
304 .last
305 .is_some_and(|(r, at)| r == row && at_ms >= at && at_ms - at <= DOUBLE_CLICK_MS);
306 if pair {
307 // Forgotten rather than updated, so a third click starts a new pair
308 // instead of firing again — a triple-click is one activation.
309 self.last = None;
310 Intent::Activate
311 } else {
312 self.last = Some((row, at_ms));
313 Intent::Select
314 }
315 }
316
317 /// Drops a half-finished pair — called when the rows change, because a
318 /// remembered click points at a row that may now be something else.
319 pub(crate) fn forget(&mut self) {
320 self.last = None;
321 }
322}
323
324/// The leading, label and trailing boxes inside one row.
325///
326/// Shared by [`List`](super::List) and [`Tree`](super::Tree), so a row of one
327/// and a row of the other put their columns in the same places.
328///
329/// The label takes what the other two leave. A row too narrow to hold all three
330/// gives it nothing rather than a negative width — each column is clipped to
331/// itself when it is drawn, so the result is text cut short rather than a label
332/// running across the value at the other end of the row.
333pub(crate) fn columns(row: Rect, pad: i32, leading: i32, trailing: i32) -> (Rect, Rect, Rect) {
334 let left = row.x + pad;
335 let right = (row.right() - pad).max(left);
336 let box_of =
337 |start: i32, end: i32| Rect::from_edges(start, row.y, end.max(start), row.bottom());
338
339 let leading_box = box_of(left, (left + leading).min(right));
340 let trailing_box = box_of((right - trailing).max(left), right);
341 // Clamped into the row's own span, so a column that was pushed outside by a
342 // rectangle too narrow for it does not drag the label out with it.
343 let start = if leading > 0 {
344 (leading_box.right() + pad).clamp(left, right)
345 } else {
346 left
347 };
348 let end = if trailing > 0 {
349 (trailing_box.x - pad).clamp(left, right)
350 } else {
351 right
352 };
353 (leading_box, box_of(start, end), trailing_box)
354}