stipple_core/element.rs
1//! The element IR: the lowered, paint-ready description of a UI subtree.
2//!
3//! Declarative [`View`](crate::View)s build a tree of [`Element`]s; the layout
4//! and paint passes (see [`crate::render`]) consume it. Keeping a concrete IR
5//! between widgets and rendering is what lets the (future) reactive runtime
6//! diff one tree against the next.
7
8use crate::runtime::{
9 ActionId, ContextId, Cx, DragId, FocusId, KeyInput, ScrollId, TextPosId, ViewportId,
10};
11use stipple_geometry::Insets;
12use stipple_layout::Axis;
13use stipple_render::Color;
14
15/// Alignment of children along an axis.
16#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
17pub enum Align {
18 #[default]
19 Start,
20 Center,
21 End,
22 /// Cross-axis only: stretch children to fill the cross extent.
23 Stretch,
24}
25
26/// Fixed-size overrides for an element. `None` means "size to content / fill".
27#[derive(Clone, Copy, Debug, Default, PartialEq)]
28pub struct SizeOverride {
29 pub width: Option<f64>,
30 pub height: Option<f64>,
31}
32
33/// Layout properties common to every element.
34#[derive(Clone, Copy, Debug, Default, PartialEq)]
35pub struct LayoutStyle {
36 pub padding: Insets,
37 pub size: SizeOverride,
38 /// Main-axis grow weight when this element is a flex child. `0.0` = fixed.
39 pub grow: f64,
40}
41
42/// The painted decoration of an element's box: fill, corner radius, border.
43/// Applies to any element — a leaf bar, a button, or a container panel.
44#[derive(Clone, Copy, Debug, Default, PartialEq)]
45pub struct BoxStyle {
46 pub fill: Option<Color>,
47 pub radius: f64,
48 /// Border as `(color, width)`.
49 pub border: Option<(Color, f64)>,
50}
51
52/// What an element arranges; all kinds share [`Element::decoration`].
53#[derive(Clone, Debug, PartialEq)]
54pub enum ElementKind {
55 /// A leaf with no children.
56 Leaf,
57 /// A single line of text. Sizes to the shaped run (via the active font);
58 /// painted with [`Scene::fill_text`](stipple_render::Scene::fill_text).
59 Text {
60 text: String,
61 size: f64,
62 color: Color,
63 },
64 /// A linear container that lays children along `axis`.
65 Stack {
66 axis: Axis,
67 gap: f64,
68 main_align: Align,
69 cross_align: Align,
70 children: Vec<Element>,
71 },
72 /// An embedded-content viewport: a leaf reserving a rectangle the compositor
73 /// fills with externally-rendered pixels for `id`. Sizes to its
74 /// [`width`](Element::width)/[`height`](Element::height) overrides (or grow),
75 /// not to content.
76 Viewport { id: ViewportId },
77}
78
79/// A node in the element tree: layout properties, decoration, an optional
80/// interaction handle, and a kind.
81///
82/// `PartialEq` enables the runtime's no-op repaint skip: if a rebuilt tree
83/// equals the previous one, the cached frame is reused. Handler ids compare by
84/// value and are assigned deterministically per build, so an unchanged view
85/// produces an equal tree.
86#[derive(Clone, Debug, PartialEq)]
87pub struct Element {
88 pub layout: LayoutStyle,
89 pub decoration: BoxStyle,
90 /// Handler this element routes pointer taps to, if any. Set via
91 /// [`Element::on_tap`]; resolved against the [`Cx`] handler table.
92 pub action: Option<ActionId>,
93 /// Focus + keyboard handle, if this element is focusable. Set via
94 /// [`Element::on_key`].
95 pub focus: Option<FocusId>,
96 /// Drag handle, if this element responds to pointer drags. Set via
97 /// [`Element::on_drag`].
98 pub drag: Option<DragId>,
99 /// Secondary-click (context) handle: this element opens a context menu on
100 /// right-click. Set via [`Element::on_context`].
101 pub context: Option<ContextId>,
102 /// Caret position as a byte index into this element's text, for an editable
103 /// text leaf. `None` for non-editable text and non-text elements. Set via
104 /// [`Element::caret`]; the focus overlay draws the caret bar there.
105 pub caret: Option<usize>,
106 /// Selected byte range `[start, end)` into this element's text, for an
107 /// editable text leaf. `None` when there is no selection. Set via
108 /// [`Element::selection`]; the focus overlay highlights it.
109 pub selection: Option<(usize, usize)>,
110 /// Text-pointer handle: pointer presses/drags on this element resolve to a
111 /// byte index in its text. Set via [`Element::on_text_pos`].
112 pub text_pos: Option<TextPosId>,
113 /// When `true` on a text element, the text greedily word-wraps to the
114 /// element's content width instead of laying out on one line. Set via
115 /// [`Element::wrap`].
116 pub wrap: bool,
117 /// Scroll-container handle: when set, this element lays its children out at
118 /// natural size (overflowing its bounds), clips them, and routes wheel events
119 /// to the app's offset for this id. Set via [`Element::scrollable`].
120 pub scroll: Option<ScrollId>,
121 /// When `true`, children are clipped to this element's painted bounds. Set
122 /// implicitly by [`Element::scrollable`] and via [`Element::clip`].
123 pub clip: bool,
124 pub kind: ElementKind,
125}
126
127impl Element {
128 /// A decorated leaf (background/button/divider).
129 pub fn boxed(style: BoxStyle) -> Self {
130 Self {
131 layout: LayoutStyle::default(),
132 decoration: style,
133 action: None,
134 focus: None,
135 drag: None,
136 context: None,
137 caret: None,
138 selection: None,
139 text_pos: None,
140 wrap: false,
141 scroll: None,
142 clip: false,
143 kind: ElementKind::Leaf,
144 }
145 }
146
147 /// A single line of text in `color` at `size` logical pixels.
148 pub fn text(text: impl Into<String>, size: f64, color: Color) -> Self {
149 Self {
150 layout: LayoutStyle::default(),
151 decoration: BoxStyle::default(),
152 action: None,
153 focus: None,
154 drag: None,
155 context: None,
156 caret: None,
157 selection: None,
158 text_pos: None,
159 wrap: false,
160 scroll: None,
161 clip: false,
162 kind: ElementKind::Text {
163 text: text.into(),
164 size,
165 color,
166 },
167 }
168 }
169
170 /// An embedded-content viewport leaf for `id`: a reserved rectangle the app
171 /// composites externally-rendered content into. Give it a size via
172 /// [`width`](Element::width)/[`height`](Element::height) (or
173 /// [`grow`](Element::grow) inside a flex parent); add a
174 /// [`border`](Element::border) to frame it. The app locates it by `id` with
175 /// [`collect_viewports`](crate::collect_viewports) to blit content over the
176 /// placeholder, and routes pointer/keys landing inside it to that content.
177 pub fn viewport(id: ViewportId) -> Self {
178 Self {
179 layout: LayoutStyle::default(),
180 decoration: BoxStyle::default(),
181 action: None,
182 focus: None,
183 drag: None,
184 context: None,
185 caret: None,
186 selection: None,
187 text_pos: None,
188 wrap: false,
189 scroll: None,
190 clip: false,
191 kind: ElementKind::Viewport { id },
192 }
193 }
194
195 /// An undecorated container laying `children` along `axis`.
196 pub fn stack(axis: Axis, children: Vec<Element>) -> Self {
197 Self {
198 layout: LayoutStyle::default(),
199 decoration: BoxStyle::default(),
200 action: None,
201 focus: None,
202 drag: None,
203 context: None,
204 caret: None,
205 selection: None,
206 text_pos: None,
207 wrap: false,
208 scroll: None,
209 clip: false,
210 kind: ElementKind::Stack {
211 axis,
212 gap: 0.0,
213 main_align: Align::Start,
214 cross_align: Align::Start,
215 children,
216 },
217 }
218 }
219
220 /// Route pointer taps on this element to `handler`, which runs against the
221 /// app state. Registers the handler in `cx` and stamps its [`ActionId`].
222 ///
223 /// ```
224 /// # use stipple_core::{Element, BoxStyle, runtime::Cx};
225 /// # use stipple_style::Theme;
226 /// let theme = Theme::light();
227 /// let mut cx = Cx::new(&theme);
228 /// let button = Element::boxed(BoxStyle::default())
229 /// .width(80.0)
230 /// .height(32.0)
231 /// .on_tap(&mut cx, |count: &mut i32| *count += 1);
232 /// assert!(button.action.is_some());
233 /// ```
234 pub fn on_tap<S>(mut self, cx: &mut Cx<'_, S>, handler: impl FnMut(&mut S) + 'static) -> Self {
235 self.action = Some(cx.register(handler));
236 self
237 }
238
239 /// Route secondary (right) clicks on this element to `handler`, which runs
240 /// against the app state and receives the click position in logical pixels —
241 /// typically used to open a context menu there via [`Cx::overlay`]. Registers
242 /// the handler in `cx` and stamps its [`ContextId`].
243 pub fn on_context<S>(
244 mut self,
245 cx: &mut Cx<'_, S>,
246 handler: impl FnMut(&mut S, stipple_geometry::Point) + 'static,
247 ) -> Self {
248 self.context = Some(cx.register_context(handler));
249 self
250 }
251
252 /// Make this element focusable and route keyboard input to `handler`.
253 /// The handler receives a [`KeyInput`] (committed text or an editing key)
254 /// while this element holds focus. Registers in `cx` and stamps the
255 /// resulting [`FocusId`].
256 pub fn on_key<S>(
257 mut self,
258 cx: &mut Cx<'_, S>,
259 handler: impl FnMut(&mut S, &KeyInput) + 'static,
260 ) -> Self {
261 self.focus = Some(cx.register_key(handler));
262 self
263 }
264
265 /// Make this element respond to pointer drags. The `handler` receives the
266 /// pointer's fractional x position (0..=1) across the element on press and
267 /// while dragging. Registers in `cx` and stamps the resulting [`DragId`].
268 pub fn on_drag<S>(
269 mut self,
270 cx: &mut Cx<'_, S>,
271 handler: impl FnMut(&mut S, f64) + 'static,
272 ) -> Self {
273 self.drag = Some(cx.register_drag(handler));
274 self
275 }
276
277 /// Resolve pointer presses/drags on this element to a byte index in its
278 /// text. The `handler` receives the resolved index and an `extend` flag
279 /// (`false` = initial press / place caret, `true` = drag / extend
280 /// selection). Registers in `cx` and stamps the resulting [`TextPosId`].
281 pub fn on_text_pos<S>(
282 mut self,
283 cx: &mut Cx<'_, S>,
284 handler: impl FnMut(&mut S, usize, bool) + 'static,
285 ) -> Self {
286 self.text_pos = Some(cx.register_text_pos(handler));
287 self
288 }
289
290 // --- decoration modifiers ---
291
292 pub fn fill(mut self, color: Color) -> Self {
293 self.decoration.fill = Some(color);
294 self
295 }
296
297 pub fn radius(mut self, radius: f64) -> Self {
298 self.decoration.radius = radius;
299 self
300 }
301
302 pub fn border(mut self, color: Color, width: f64) -> Self {
303 self.decoration.border = Some((color, width));
304 self
305 }
306
307 // --- layout modifiers ---
308
309 pub fn padding(mut self, insets: Insets) -> Self {
310 self.layout.padding = insets;
311 self
312 }
313
314 pub fn width(mut self, w: f64) -> Self {
315 self.layout.size.width = Some(w);
316 self
317 }
318
319 pub fn height(mut self, h: f64) -> Self {
320 self.layout.size.height = Some(h);
321 self
322 }
323
324 pub fn grow(mut self, grow: f64) -> Self {
325 self.layout.grow = grow;
326 self
327 }
328
329 /// Mark this text element's caret position (a byte index into its text), so
330 /// the focus overlay draws the caret bar there instead of at the end. No
331 /// effect on non-text elements.
332 pub fn caret(mut self, byte_index: usize) -> Self {
333 self.caret = Some(byte_index);
334 self
335 }
336
337 /// Mark a selected byte range `[start, end)` into this text element, so the
338 /// focus overlay highlights it. An empty or reversed range is ignored.
339 pub fn selection(mut self, range: Option<(usize, usize)>) -> Self {
340 self.selection = range.filter(|(s, e)| e > s);
341 self
342 }
343
344 /// Enable word-wrapping for a text element: the text wraps to the element's
345 /// content width across as many lines as needed. No effect on non-text
346 /// elements.
347 pub fn wrap(mut self) -> Self {
348 self.wrap = true;
349 self
350 }
351
352 /// Clip this element's children to its painted bounds (e.g. for an overlay
353 /// panel). [`scrollable`](Element::scrollable) sets this implicitly.
354 pub fn clip(mut self) -> Self {
355 self.clip = true;
356 self
357 }
358
359 /// Make this element a scroll container for `id`: its children lay out at
360 /// natural size (overflowing), are clipped to its bounds, and wheel events
361 /// over it scroll the app's offset for `id`. Give it a fixed height (e.g.
362 /// `.height(..)`) to define the viewport.
363 pub fn scrollable(mut self, id: ScrollId) -> Self {
364 self.scroll = Some(id);
365 self.clip = true;
366 self
367 }
368
369 pub fn gap(mut self, gap: f64) -> Self {
370 if let ElementKind::Stack { gap: g, .. } = &mut self.kind {
371 *g = gap;
372 }
373 self
374 }
375
376 pub fn align(mut self, main: Align, cross: Align) -> Self {
377 if let ElementKind::Stack {
378 main_align,
379 cross_align,
380 ..
381 } = &mut self.kind
382 {
383 *main_align = main;
384 *cross_align = cross;
385 }
386 self
387 }
388}