Skip to main content

teksilo_core/widget/
paint_context.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4use teksilo_canvas::{Rect, Size};
5
6use crate::arena::WidgetArena;
7use crate::widget_id::WidgetId;
8
9/// Context available during painting.
10pub struct PaintContext<'a> {
11    pub theme: &'a crate::styles::Theme,
12    /// Accumulated (quantized) scale of the transform scopes enclosing
13    /// this widget — `1.0` outside any scale transform, the zoom-derived
14    /// raster ladder value inside a `SceneView` / `Scale` wrapper. This
15    /// is the ambient text raster scale the walker has already set on
16    /// the shared `TextBackend`; widgets normally don't need it (text
17    /// drawn via `Canvas::draw_text` / `draw_paragraph` picks it up
18    /// automatically), but resolution-dependent custom paint can read
19    /// it to densify its own raster content. NOT the HiDPI device scale
20    /// — that lives on the renderer/text-service.
21    pub scale_factor: f32,
22    /// Combined user×OS text-scale factor (`1.0` = 100 %) — the *logical*
23    /// accessibility magnification, distinct from the raster `scale_factor`
24    /// above. Widgets that paint text via `Theme.typography` already scale
25    /// through the effective theme; this is for paint paths that size text
26    /// from another source (e.g. a scene `TextItem` that opts in). `1.0` when
27    /// no scale is active.
28    pub text_scale: f32,
29    /// Active layout direction. Used by widgets that have to resolve
30    /// Leading/Trailing semantics into geometric Left/Right at paint
31    /// time (e.g. attached-side shadow suppression on a popover that
32    /// opened off the trailing edge of its anchor).
33    pub layout_direction: crate::environment::LayoutDirection,
34    /// Whether the widget being painted is effectively enabled —
35    /// `false` iff this node or any ancestor has its arena-level
36    /// `enabled_state` resolved to `false`. Computed once per node by
37    /// the paint walker (start `true` at root, AND with each node's
38    /// `enabled_state` value as the walker descends).
39    ///
40    /// Leaf widgets that paint role-derived colors
41    /// ([`crate::color_prop::ColorProp::TextRole`] and the dynamic
42    /// variants) consult this to substitute `TextRole::Disabled`
43    /// automatically — the single hook that makes any descendant of a
44    /// disabled subtree dim without the composite parent doing
45    /// per-color bookkeeping. Static and bound color props are not
46    /// substituted (caller's literal wins).
47    pub effective_enabled: bool,
48    // Fed by `teksilo-platform`'s `AccessibilityPreferences::query()` (winit
49    // exposes none of these, so the platform crate asks the OS directly) via
50    // `WidgetTree::set_accessibility_preferences`; `prefers_large_text` is
51    // derived from the OS text-scale factor being greater than 1.0.
52    pub prefers_high_contrast: bool,
53    pub prefers_reduced_motion: bool,
54    pub prefers_large_text: bool,
55    /// Whether the host window is currently active (`focused AND not
56    /// occluded`). Widgets that change appearance when the window loses focus
57    /// read this directly in `paint()` — the selection band in
58    /// `TableView`/`TreeTableView` desaturates, text engines swap their
59    /// selection colour, custom paint can dim. A window-active flip triggers a
60    /// global repaint ([`WidgetArena::mark_all_needs_paint_only`]), so no
61    /// per-widget signal binding is required to keep a paint-time read correct.
62    /// `true` in headless test contexts.
63    pub window_active: bool,
64    /// The accumulated clip rectangle this widget is painted within — the
65    /// intersection of every `clips_children` ancestor's bounds (a `ScrollArea`
66    /// viewport, a `MaxSize`, …), in the same screen space as the widget's own
67    /// `bounds`. `None` when no ancestor clips (the widget can paint anywhere).
68    ///
69    /// The paint walker already computes this to skip fully-offscreen subtrees;
70    /// surfacing it lets a widget that is laid out larger than its visible slot
71    /// — an editor at full document height inside an outer `ScrollArea`
72    /// ("dubious mode") — window its own expensive work to `clip ∩ bounds`
73    /// instead of processing the whole document. Correct under arbitrary
74    /// nesting, since it is the intersection of *all* clipping ancestors.
75    pub clip_bounds: Option<Rect>,
76}
77
78impl PaintContext<'_> {
79    /// Emit `painter` a second time inside a transform-and-clip scope.
80    ///
81    /// The mechanism behind the touch magnifier (see
82    /// [`crate::text_touch::magnifier`]): `RenderFrame` is a display list, so
83    /// content can be drawn again under a different transform instead of being
84    /// read back from a framebuffer. `clip` is in the same space as the
85    /// widget's own `bounds`; `transform` is composed **beneath** whatever
86    /// scope encloses the caller, so a replay inside a scene's view transform
87    /// still lands where that scene puts it.
88    ///
89    /// Three things this deliberately is not:
90    ///
91    /// * It is not `&mut self`. `Widget::paint` receives a shared
92    ///   `&PaintContext`, so a replay a widget could not call from its own
93    ///   paint would be useless.
94    /// * The clip is a **rectangle**. The renderer realises `SetClip` as a
95    ///   scissor rectangle and the pipeline has no path clip or mask pass, so a
96    ///   rounded lens is painted as a rounded frame over a rectangular clip —
97    ///   see [`crate::text_touch::magnifier`] for what shows in the corners.
98    /// * It does not check `painter`. The closure is re-entered during the same
99    ///   frame; whether that is safe is the caller's contract, stated at the
100    ///   call site in [`crate::text_touch::magnifier`].
101    pub fn replay(
102        &self,
103        canvas: &mut teksilo_canvas::Canvas,
104        painter: &dyn Fn(&mut teksilo_canvas::Canvas, &PaintContext<'_>),
105        transform: teksilo_canvas::Transform2D,
106        clip: Rect,
107    ) {
108        canvas.save();
109        // Clip first, transform second: the renderer maps a clip rect through
110        // the transform stack as it stands when the command is emitted, so
111        // emitting it before the magnification keeps the lens fixed on screen
112        // instead of being magnified along with its contents.
113        canvas.set_clip(clip);
114        canvas.apply_transform(transform);
115        painter(canvas, self);
116        canvas.clear_clip();
117        canvas.restore();
118    }
119}
120
121/// Read-only view of the widget tree's geometry passed to
122/// [`Widget::after_paint`](super::Widget::after_paint). Wraps a borrow of
123/// the arena so a parent widget can read the layout-resolved bounds of
124/// any descendant (typically by ids it memoised during `build`) once
125/// their paint pass has committed.
126///
127/// The view exposes only immutable queries; constructing one is
128/// crate-internal so the contract stays narrow.
129pub struct WidgetTreeView<'a> {
130    arena: &'a WidgetArena,
131    /// Screen rects of every interactive (non-fading) overlay this
132    /// frame — see [`overlay_rects`](Self::overlay_rects).
133    overlay_rects: &'a OverlayRects,
134}
135
136/// The rects [`WidgetTreeView::overlay_rects`] and
137/// [`WidgetTreeView::overlay_rects_over_chrome`] answer, taken once a frame.
138#[derive(Debug, Default)]
139pub(crate) struct OverlayRects {
140    pub(crate) all: Vec<Rect>,
141    pub(crate) over_chrome: Vec<Rect>,
142}
143
144impl<'a> WidgetTreeView<'a> {
145    pub(crate) fn new(arena: &'a WidgetArena, overlay_rects: &'a OverlayRects) -> Self {
146        Self {
147            arena,
148            overlay_rects,
149        }
150    }
151
152    /// Logical-pixel bounds of `id` after the most recent layout pass.
153    /// Returns `Rect::ZERO` for unknown ids.
154    pub fn bounds(&self, id: WidgetId) -> Rect {
155        self.arena.bounds(id)
156    }
157
158    /// Direct arena children of `id`, in order.
159    pub fn children(&self, id: WidgetId) -> &[WidgetId] {
160        self.arena.children(id)
161    }
162
163    /// Whether `id` is a gesture dead-zone boundary — see
164    /// [`WidgetNode::gesture_dead_zone`](crate::arena::WidgetNode::gesture_dead_zone)
165    /// and the `DeadZone` wrapper widget. Lets a parent classify its own
166    /// subtree from `after_paint`: `TitleBar` uses it to carve interactive
167    /// controls out of the OS caption region it publishes.
168    pub fn is_gesture_dead_zone(&self, id: WidgetId) -> bool {
169        self.arena
170            .get(id)
171            .map(|n| n.gesture_dead_zone)
172            .unwrap_or(false)
173    }
174
175    /// Whether `id` is active — i.e. not parked dormant by a `Switcher` or a
176    /// `visible_when` gate. A dormant node's [`bounds`](Self::bounds) are
177    /// stale, so callers walking a subtree must skip it.
178    pub fn is_active(&self, id: WidgetId) -> bool {
179        self.arena.is_active(id)
180    }
181
182    /// Screen rects of every overlay that is interactive this frame —
183    /// open and not fading out, the predicate the overlay manager's own
184    /// pointer routing uses. Overlay content floats *above* the widget
185    /// that anchors it, so an aggregator publishing geometry to the OS
186    /// must treat these rects as covering its own: `TitleBar` subtracts
187    /// them from the caption it publishes, or a revealed hamburger
188    /// `MenuBar` (any overlay over the title bar) would hit-test as
189    /// `HTCAPTION` on Windows and the OS would swallow its clicks as a
190    /// window drag, and a `WebView` hides its engine's page under them.
191    ///
192    /// A pass-through overlay (one whose content root lets presses through,
193    /// such as the full-window layer holding a text field's touch handles)
194    /// counts only by what in it is not pass-through: its visible handles,
195    /// nothing while it is empty.
196    pub fn overlay_rects(&self) -> &[Rect] {
197        &self.overlay_rects.all
198    }
199
200    /// [`overlay_rects`](Self::overlay_rects) less a modal's scrim: the
201    /// overlays the window's chrome is under. A modal dims a title bar but
202    /// leaves its drag surface, window buttons and resize edges live
203    /// ([`HandlerSet::window_chrome`]), so `TitleBar` carves only these out of
204    /// the caption it publishes to the OS.
205    ///
206    /// [`HandlerSet::window_chrome`]: crate::widget_builder::HandlerSet::window_chrome
207    #[doc(hidden)]
208    pub fn overlay_rects_over_chrome(&self) -> &[Rect] {
209        &self.overlay_rects.over_chrome
210    }
211}
212
213/// Placement of a child widget during layout.
214///
215/// `#[non_exhaustive]`: a parent reads and writes the fields of the slice it is
216/// handed, and never builds one — so the attribute costs a `place_children`
217/// implementation nothing, and it is what makes the next field addition a
218/// non-event rather than the breaking change `dormant` was. Use
219/// [`WidgetPlacement::new`] where one genuinely has to be constructed.
220#[derive(Debug, Clone, Copy)]
221#[non_exhaustive]
222pub struct WidgetPlacement {
223    pub id: WidgetId,
224    pub origin: teksilo_canvas::Point,
225    pub size: Size,
226    /// Park this child dormant for as long as the flag stays set.
227    ///
228    /// **Only read for a parent whose [`Widget::culls_children`](crate::widget::Widget::culls_children) returns
229    /// `true`**, and only such a parent is handed its dormant children here at
230    /// all — every other widget still sees its active children and nothing
231    /// else, so setting this without opting in does nothing.
232    ///
233    /// A dormant child leaves paint, the layout recursion, the accessibility
234    /// tree and the Tab ring, and keeps every piece of its state — focus,
235    /// text, animations — for when it comes back. Clearing the flag wakes it
236    /// in the same pass, so a container that culls by viewport shows no hole
237    /// on the frame a camera jumps.
238    ///
239    /// Arrives pre-set to the child's current state, so a parent that ignores
240    /// the field changes nothing.
241    ///
242    /// This is a heavier decision than collapsing `size` to zero, and the two
243    /// are worth layering rather than merging: zero size costs the child its
244    /// geometry, dormancy costs it its existence. Parking what the user is
245    /// interacting with clears focus and cancels pointers — see
246    /// [`LayoutContext::for_each_interaction_ancestor`](crate::widget::LayoutContext::for_each_interaction_ancestor).
247    pub dormant: bool,
248}
249
250impl WidgetPlacement {
251    /// A placement at `origin` sized `size`, awake.
252    ///
253    /// The framework fills the slice a parent is handed; this is for the cases
254    /// that stand outside a layout pass — a test driving `place_children`
255    /// directly, or a widget assembling placements of its own.
256    pub fn new(id: WidgetId, origin: teksilo_canvas::Point, size: Size) -> Self {
257        Self {
258            id,
259            origin,
260            size,
261            dormant: false,
262        }
263    }
264}