Skip to main content

damascene_core/tree/
visual_modifiers.rs

1//! Visual, cursor, and paint-transform modifiers for [`El`].
2//!
3//! Every value-setting modifier here is **last-write-wins**: calling
4//! it again replaces the earlier value silently. That's load-bearing
5//! for the catalog — widgets bake a recipe (`button` sets a cursor,
6//! `card_content` sets padding) and callers override per-call. The one
7//! exception with a debug-build guard is [`El::tooltip`]: no stock
8//! widget pre-sets a tooltip, so a re-set is always two user calls
9//! racing for the same slot — usually one of them on the wrong node.
10
11use crate::anim::Timing;
12use crate::shader::ShaderBinding;
13use crate::style::StyleProfile;
14
15use super::geometry::{Corners, Sides};
16use super::node::{El, FocusRingPlacement};
17use super::semantics::SurfaceRole;
18use crate::color::Color;
19
20/// Debug-build stderr warning, deduplicated per callsite so a warning
21/// inside `App::build` prints once, not once per frame.
22#[cfg(debug_assertions)]
23fn warn_once(loc: &'static std::panic::Location<'static>, msg: impl FnOnce() -> String) {
24    use std::collections::HashSet;
25    use std::sync::Mutex;
26    static SEEN: Mutex<Option<HashSet<(&'static str, u32)>>> = Mutex::new(None);
27    let mut seen = SEEN.lock().unwrap();
28    if seen
29        .get_or_insert_with(HashSet::new)
30        .insert((loc.file(), loc.line()))
31    {
32        eprintln!("{}", msg());
33    }
34}
35
36impl El {
37    // ---- Visual ----
38    pub fn fill(mut self, c: Color) -> Self {
39        self.fill = Some(c);
40        self
41    }
42
43    /// Fill applied when the nearest focusable ancestor isn't focused;
44    /// the painter lerps from `dim_fill` toward `fill` as the focus
45    /// envelope rises from 0 to 1. See [`Self::dim_fill`] field doc.
46    pub fn dim_fill(mut self, c: Color) -> Self {
47        self.dim_fill = Some(c);
48        self
49    }
50
51    pub fn stroke(mut self, c: Color) -> Self {
52        self.stroke = Some(c);
53        if self.stroke_width == 0.0 {
54            self.stroke_width = 1.0;
55        }
56        self
57    }
58
59    pub fn stroke_width(mut self, w: f32) -> Self {
60        self.stroke_width = w;
61        self
62    }
63
64    /// Set the element's corner radii. A scalar (e.g.
65    /// `.radius(tokens::RADIUS_MD)`) sets all four corners uniformly
66    /// via [`Corners::from`]; pass [`Corners::top`] / [`Corners::bottom`]
67    /// / [`Corners::left`] / [`Corners::right`], or a directly-built
68    /// [`Corners`], to round only a subset of corners.
69    pub fn radius(mut self, r: impl Into<Corners>) -> Self {
70        self.radius = r.into();
71        self.explicit_radius = true;
72        self
73    }
74
75    pub fn shadow(mut self, s: f32) -> Self {
76        self.shadow = s;
77        self
78    }
79
80    /// Tag this node with a semantic [`SurfaceRole`] so the theme can
81    /// route it through the appropriate paint recipe. Most app code
82    /// should not call this directly: the catalog widgets (`card()`,
83    /// `sidebar()`, `dialog()`, `popover()`, `tabs_list()`, etc.) set
84    /// the right role *and* the matching fill / stroke / radius /
85    /// shadow together, while the `.selected()` and `.current()`
86    /// chainables wrap the corresponding state recipes.
87    ///
88    /// Reach for the raw chainable when authoring a new widget or when
89    /// composing a custom container that the catalog doesn't cover —
90    /// and remember that decorative roles (`Panel`, `Raised`, `Popover`,
91    /// `Danger`) require you to supply a fill yourself; see the
92    /// [`SurfaceRole`] doc for the per-variant contract. The bundle
93    /// lint pass flags `Panel` without a fill as
94    /// [`crate::bundle::lint::FindingKind::MissingSurfaceFill`].
95    pub fn surface_role(mut self, role: SurfaceRole) -> Self {
96        self.surface_role = role;
97        self
98    }
99
100    /// Permit paint to extend beyond this element's layout bounds by
101    /// `outset` on each side. Layout-neutral; siblings don't move and
102    /// hit-testing still uses the layout rect.
103    pub fn paint_overflow(mut self, outset: impl Into<Sides>) -> Self {
104        self.paint_overflow = outset.into();
105        self
106    }
107
108    /// Draw the stock focus ring just inside this node's layout rect.
109    ///
110    /// The default focus ring is outside the rect so it does not reduce
111    /// usable control area. Inside rings are for dense, flush stacks such as
112    /// menu rows, where adding gaps would change the intended visual recipe.
113    pub fn focus_ring_inside(mut self) -> Self {
114        self.focus_ring_placement = FocusRingPlacement::Inside;
115        self
116    }
117
118    /// Draw the stock focus ring outside this node's layout rect.
119    pub fn focus_ring_outside(mut self) -> Self {
120        self.focus_ring_placement = FocusRingPlacement::Outside;
121        self
122    }
123
124    /// Attach a hover tooltip to this element. The runtime synthesizes
125    /// a floating tooltip layer when the pointer rests on the node for
126    /// the configured delay.
127    ///
128    /// **The node must also have a [`key`](Self::key).** Tooltips fire
129    /// through the hit-test pipeline, and `crate::hit_test` only
130    /// returns keyed nodes — an unkeyed leaf with `.tooltip()` is
131    /// silently dead, because hover skips past it to the nearest
132    /// keyed ancestor (which has a different `computed_id` and a
133    /// different tooltip). The bundle lint flags this case as
134    /// [`crate::bundle::lint::FindingKind::DeadTooltip`].
135    ///
136    /// For info-only chrome inside list rows (sha cells, timestamps,
137    /// chips, identicon avatars) the usual key is a synthetic one
138    /// like `"row:{idx}.<part>"` — its only purpose is to make the
139    /// tooltip's hover land. The tooltip text is snapshotted onto the
140    /// hit target at hit-test time, so tooltips fire correctly even
141    /// on `virtual_list_dyn` rows whose children are realized only
142    /// during layout.
143    ///
144    /// Like every modifier, last-write-wins — but unlike `fill` or
145    /// `padding`, no stock widget pre-sets a tooltip, so a second
146    /// `.tooltip()` on the same element is always two app calls racing
147    /// for one slot (usually one belongs on a different node). Debug
148    /// builds print a once-per-callsite warning when a re-set replaces
149    /// different text.
150    #[track_caller]
151    pub fn tooltip(mut self, text: impl Into<String>) -> Self {
152        let text = text.into();
153        #[cfg(debug_assertions)]
154        if let Some(prev) = &self.tooltip
155            && *prev != text
156        {
157            let loc = std::panic::Location::caller();
158            warn_once(loc, || {
159                format!(
160                    "damascene: .tooltip({text:?}) at {file}:{line} replaces the earlier \
161                     .tooltip({prev:?}) on the same element — last value wins. If one of \
162                     these belongs on a different node, move it; tooltips are looked up \
163                     by the hovered node's id.",
164                    file = loc.file(),
165                    line = loc.line(),
166                )
167            });
168        }
169        self.tooltip = Some(text);
170        self
171    }
172
173    /// Declare the pointer cursor when the pointer is over this
174    /// element.
175    pub fn cursor(mut self, cursor: crate::cursor::Cursor) -> Self {
176        self.cursor = Some(cursor);
177        self
178    }
179
180    /// Declare the cursor shown only while a press is captured at this
181    /// exact node.
182    pub fn cursor_pressed(mut self, cursor: crate::cursor::Cursor) -> Self {
183        self.cursor_pressed = Some(cursor);
184        self
185    }
186
187    // ---- Paint-time transforms (animatable via `.animate()`) ----
188    /// Multiply this element's paint alpha by `v` (clamped to `[0, 1]`).
189    pub fn opacity(mut self, v: f32) -> Self {
190        self.opacity = v.clamp(0.0, 1.0);
191        self
192    }
193
194    /// Offset this element's paint and its descendants by `(x, y)` in
195    /// logical pixels.
196    pub fn translate(mut self, x: f32, y: f32) -> Self {
197        self.translate = (x, y);
198        self
199    }
200
201    /// Uniformly scale this element's paint around its rect centre.
202    pub fn scale(mut self, v: f32) -> Self {
203        self.scale = v.max(0.0);
204        self
205    }
206
207    /// Opt this element into app-driven prop interpolation.
208    pub fn animate(mut self, timing: Timing) -> Self {
209        self.animate = Some(timing);
210        self
211    }
212
213    /// Bind a shader for the surface paint, replacing the implicit
214    /// `stock::rounded_rect`.
215    pub fn shader(mut self, binding: ShaderBinding) -> Self {
216        self.shader_override = Some(binding);
217        self
218    }
219
220    // ---- Internal: style profile ----
221    pub fn style_profile(mut self, p: StyleProfile) -> Self {
222        self.style_profile = p;
223        self
224    }
225
226    pub(crate) fn default_radius(mut self, r: impl Into<Corners>) -> Self {
227        self.radius = r.into();
228        self.explicit_radius = false;
229        self
230    }
231}