Skip to main content

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}