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}