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 // TODO: Wire from platform accessibility settings (winit doesn't expose these yet)
49 pub prefers_high_contrast: bool,
50 pub prefers_reduced_motion: bool,
51 pub prefers_large_text: bool,
52 /// Whether the host window is currently active (`focused AND not
53 /// occluded`). Widgets that change appearance when the window loses focus
54 /// read this directly in `paint()` — the selection band in
55 /// `TableView`/`TreeTableView` desaturates, text engines swap their
56 /// selection colour, custom paint can dim. A window-active flip triggers a
57 /// global repaint ([`WidgetArena::mark_all_needs_paint_only`]), so no
58 /// per-widget signal binding is required to keep a paint-time read correct.
59 /// `true` in headless test contexts.
60 pub window_active: bool,
61 /// The accumulated clip rectangle this widget is painted within — the
62 /// intersection of every `clips_children` ancestor's bounds (a `ScrollArea`
63 /// viewport, a `MaxSize`, …), in the same screen space as the widget's own
64 /// `bounds`. `None` when no ancestor clips (the widget can paint anywhere).
65 ///
66 /// The paint walker already computes this to skip fully-offscreen subtrees;
67 /// surfacing it lets a widget that is laid out larger than its visible slot
68 /// — an editor at full document height inside an outer `ScrollArea`
69 /// ("dubious mode") — window its own expensive work to `clip ∩ bounds`
70 /// instead of processing the whole document. Correct under arbitrary
71 /// nesting, since it is the intersection of *all* clipping ancestors.
72 pub clip_bounds: Option<Rect>,
73}
74
75impl PaintContext<'_> {
76 /// Emit `painter` a second time inside a transform-and-clip scope.
77 ///
78 /// The mechanism behind the touch magnifier (see
79 /// [`crate::text_touch::magnifier`]): `RenderFrame` is a display list, so
80 /// content can be drawn again under a different transform instead of being
81 /// read back from a framebuffer. `clip` is in the same space as the
82 /// widget's own `bounds`; `transform` is composed **beneath** whatever
83 /// scope encloses the caller, so a replay inside a scene's view transform
84 /// still lands where that scene puts it.
85 ///
86 /// Three things this deliberately is not:
87 ///
88 /// * It is not `&mut self`. `Widget::paint` receives a shared
89 /// `&PaintContext`, so a replay a widget could not call from its own
90 /// paint would be useless.
91 /// * The clip is a **rectangle**. The renderer realises `SetClip` as a
92 /// scissor rectangle and the pipeline has no path clip or mask pass, so a
93 /// rounded lens is painted as a rounded frame over a rectangular clip —
94 /// see [`crate::text_touch::magnifier`] for what shows in the corners.
95 /// * It does not check `painter`. The closure is re-entered during the same
96 /// frame; whether that is safe is the caller's contract, stated at the
97 /// call site in [`crate::text_touch::magnifier`].
98 pub fn replay(
99 &self,
100 canvas: &mut teksilo_canvas::Canvas,
101 painter: &dyn Fn(&mut teksilo_canvas::Canvas, &PaintContext<'_>),
102 transform: teksilo_canvas::Transform2D,
103 clip: Rect,
104 ) {
105 canvas.save();
106 // Clip first, transform second: the renderer maps a clip rect through
107 // the transform stack as it stands when the command is emitted, so
108 // emitting it before the magnification keeps the lens fixed on screen
109 // instead of being magnified along with its contents.
110 canvas.set_clip(clip);
111 canvas.apply_transform(transform);
112 painter(canvas, self);
113 canvas.clear_clip();
114 canvas.restore();
115 }
116}
117
118/// Read-only view of the widget tree's geometry passed to
119/// [`Widget::after_paint`](super::Widget::after_paint). Wraps a borrow of
120/// the arena so a parent widget can read the layout-resolved bounds of
121/// any descendant (typically by ids it memoised during `build`) once
122/// their paint pass has committed.
123///
124/// The view exposes only immutable queries; constructing one is
125/// crate-internal so the contract stays narrow.
126pub struct WidgetTreeView<'a> {
127 arena: &'a WidgetArena,
128 /// Screen rects of every interactive (non-fading) overlay this
129 /// frame — see [`overlay_rects`](Self::overlay_rects).
130 overlay_rects: &'a [Rect],
131}
132
133impl<'a> WidgetTreeView<'a> {
134 pub(crate) fn new(arena: &'a WidgetArena, overlay_rects: &'a [Rect]) -> Self {
135 Self {
136 arena,
137 overlay_rects,
138 }
139 }
140
141 /// Logical-pixel bounds of `id` after the most recent layout pass.
142 /// Returns `Rect::ZERO` for unknown ids.
143 pub fn bounds(&self, id: WidgetId) -> Rect {
144 self.arena.bounds(id)
145 }
146
147 /// Direct arena children of `id`, in order.
148 pub fn children(&self, id: WidgetId) -> &[WidgetId] {
149 self.arena.children(id)
150 }
151
152 /// Whether `id` is a gesture dead-zone boundary — see
153 /// [`WidgetNode::gesture_dead_zone`](crate::arena::WidgetNode::gesture_dead_zone)
154 /// and the `DeadZone` wrapper widget. Lets a parent classify its own
155 /// subtree from `after_paint`: `TitleBar` uses it to carve interactive
156 /// controls out of the OS caption region it publishes.
157 pub fn is_gesture_dead_zone(&self, id: WidgetId) -> bool {
158 self.arena
159 .get(id)
160 .map(|n| n.gesture_dead_zone)
161 .unwrap_or(false)
162 }
163
164 /// Whether `id` is active — i.e. not parked dormant by a `Switcher` or a
165 /// `visible_when` gate. A dormant node's [`bounds`](Self::bounds) are
166 /// stale, so callers walking a subtree must skip it.
167 pub fn is_active(&self, id: WidgetId) -> bool {
168 self.arena.is_active(id)
169 }
170
171 /// Screen rects of every overlay that is interactive this frame —
172 /// open and not fading out, the predicate the overlay manager's own
173 /// pointer routing uses. Overlay content floats *above* the widget
174 /// that anchors it, so an aggregator publishing geometry to the OS
175 /// must treat these rects as covering its own: `TitleBar` subtracts
176 /// them from the caption it publishes, or a revealed hamburger
177 /// `MenuBar` (any overlay over the title bar) would hit-test as
178 /// `HTCAPTION` on Windows and the OS would swallow its clicks as a
179 /// window drag.
180 pub fn overlay_rects(&self) -> &[Rect] {
181 self.overlay_rects
182 }
183}
184
185/// Placement of a child widget during layout.
186#[derive(Debug, Clone, Copy)]
187pub struct WidgetPlacement {
188 pub id: WidgetId,
189 pub origin: teksilo_canvas::Point,
190 pub size: Size,
191}