Skip to main content

teksilo_core/
dim_when_inactive.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `DimWhenInactive` — a wrapper widget that dims its subtree when the host
5//! window loses active status.
6//!
7//! This is the per-widget **opt-in** layer of the window-active appearance
8//! model (the analogue of SwiftUI's `@Environment(\.appearsActive)` read or
9//! GTK's `:backdrop`-driven custom styling). The automatic layers — caret
10//! hiding and selection desaturation in stock widgets — need no wrapping; this
11//! is for *custom content* an app wants to fade back when its window isn't the
12//! active one (a colourful side panel, a bespoke accent surface, a banner).
13//!
14//! It reads [`BuildContext::window_active_signal`] and drives an
15//! `opacity: Signal<f32>` (1.0 when active, `factor` when inactive) onto its
16//! own subtree via [`BuildContext::set_opacity`]. The render walker emits the
17//! matching `SetOpacity`/`RestoreOpacity` pair, so the multiplier composes with
18//! any ancestor opacity scope.
19//!
20//! ```ignore
21//! use teksilo_core::widget_builder::WidgetBuilder;
22//! // Fade a custom panel to 40 % when the window is inactive:
23//! ctx.add(my_panel.dim_when_inactive(0.4));
24//! // Or directly:
25//! ctx.add(DimWhenInactive::new().child(my_panel).factor(0.4));
26//! ```
27//!
28//! ## Layout & a11y semantics
29//!
30//! Layout-transparent: the wrapped child reports its full natural size at every
31//! opacity, so dimming never drives layout jitter. The wrapper is also
32//! a11y-transparent — the child owns its own semantics. The opacity **snaps**
33//! (no tween) on the active flip, which is already correct under
34//! `prefers-reduced-motion`: window activation is an OS state change, not a
35//! user-initiated motion.
36
37use teksilo_canvas::{Point, Rect, SizeProposal};
38
39use crate::accessibility::AccessNodeBuilder;
40use crate::build_context::BuildContext;
41use crate::widget::{LayoutContext, PendingChild, Widget, WidgetPlacement};
42use crate::widget_id::WidgetId;
43
44/// Default dim factor: the subtree drops to 70 % opacity when the window is
45/// inactive — perceptible but not distracting.
46pub const DEFAULT_DIM_FACTOR: f32 = 0.7;
47
48/// Wraps a child and dims the whole subtree (multiplies its opacity by
49/// `factor`) whenever the host window is not active. See the module docs.
50pub struct DimWhenInactive {
51    pending_child: Option<PendingChild>,
52    child_id: Option<WidgetId>,
53    factor: f32,
54}
55
56impl DimWhenInactive {
57    /// New dim wrapper with the [`DEFAULT_DIM_FACTOR`]. Attach a child with
58    /// [`child`](Self::child) / [`child_id`](Self::child_id).
59    pub fn new() -> Self {
60        Self {
61            pending_child: None,
62            child_id: None,
63            factor: DEFAULT_DIM_FACTOR,
64        }
65    }
66
67    /// Inline child widget (deferred insertion).
68    pub fn child(mut self, widget: impl Widget + 'static) -> Self {
69        self.pending_child = Some(PendingChild::Deferred(Box::new(widget)));
70        self
71    }
72
73    /// Pre-registered child by `WidgetId`.
74    pub fn child_id(mut self, id: WidgetId) -> Self {
75        self.pending_child = Some(PendingChild::Id(id));
76        self
77    }
78
79    /// Opacity applied while the window is inactive (clamped to `0.0..=1.0`).
80    /// `1.0` is a no-op; `0.0` fully hides the subtree. Default
81    /// [`DEFAULT_DIM_FACTOR`].
82    pub fn factor(mut self, factor: f32) -> Self {
83        self.factor = factor.clamp(0.0, 1.0);
84        self
85    }
86}
87
88impl Default for DimWhenInactive {
89    fn default() -> Self {
90        Self::new()
91    }
92}
93
94impl std::fmt::Debug for DimWhenInactive {
95    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
96        f.debug_struct("DimWhenInactive")
97            .field("factor", &self.factor)
98            .finish()
99    }
100}
101
102impl Widget for DimWhenInactive {
103    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
104        if let Some(pending) = self.pending_child.take() {
105            self.child_id = Some(match pending {
106                PendingChild::Id(id) => id,
107                PendingChild::Deferred(w) => ctx.add_boxed(w),
108            });
109        }
110        let Some(child_id) = self.child_id else {
111            return vec![];
112        };
113
114        // Derive opacity from the window-active signal: full when active,
115        // `factor` when not. The derived signal's upstream (window_active) is
116        // registered by `set_opacity` at RepaintOnly, so a focus flip repaints
117        // this subtree with the new multiplier (no relayout).
118        let factor = self.factor;
119        let opacity = ctx
120            .window_active_signal()
121            .map(move |&active| if active { 1.0 } else { factor });
122
123        let id = ctx.self_id();
124        ctx.set_opacity(id, opacity);
125
126        vec![child_id]
127    }
128
129    fn layout_response(
130        &self,
131        proposal: SizeProposal,
132        ctx: &LayoutContext,
133    ) -> crate::widget::LayoutResponse {
134        // Layout-transparent: report the child's natural size at all opacities.
135        self.child_id
136            .and_then(|id| ctx.child_size(id, proposal))
137            .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
138            .into()
139    }
140
141    fn place_children(
142        &self,
143        bounds: Rect,
144        _proposal: SizeProposal,
145        children: &mut [WidgetPlacement],
146        _ctx: &LayoutContext,
147    ) {
148        for child in children.iter_mut() {
149            child.origin = Point::new(bounds.x, bounds.y);
150            child.size = bounds.size();
151        }
152    }
153
154    fn accessibility(&self, _builder: &mut AccessNodeBuilder) {
155        // Visual-modulation wrapper only — the wrapped subtree owns its a11y.
156    }
157
158    fn children(&self) -> Vec<WidgetId> {
159        self.child_id.into_iter().collect()
160    }
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166    use crate::test_widgets::FillWidget;
167    use crate::widget_tree::WidgetTree;
168    use teksilo_canvas::{DrawCommand, SizeProposal};
169    use teksilo_tokens::Color;
170
171    fn set_opacities(frame: &teksilo_canvas::RenderFrame) -> Vec<f32> {
172        frame
173            .draw_order
174            .iter()
175            .filter_map(|c| match c {
176                DrawCommand::SetOpacity(v) => Some(*v),
177                _ => None,
178            })
179            .collect()
180    }
181
182    #[test]
183    fn window_active_defaults_true() {
184        // A window must not be born inactive (winit may not send Focused(true)
185        // for the first window).
186        let tree = WidgetTree::new();
187        assert!(tree.is_window_active());
188        assert!(tree.window_active_signal().get());
189    }
190
191    #[test]
192    fn window_active_state_is_per_tree() {
193        // Each window owns its own state — deactivating one must not touch
194        // another (no app-wide fan-out, unlike theme / text-scale).
195        let mut a = WidgetTree::new();
196        let b = WidgetTree::new();
197        a.set_window_active(false);
198        assert!(!a.is_window_active(), "tree A is inactive");
199        assert!(b.is_window_active(), "tree B is unaffected");
200    }
201
202    #[test]
203    fn factor_is_clamped() {
204        assert_eq!(DimWhenInactive::new().factor(2.0).factor, 1.0);
205        assert_eq!(DimWhenInactive::new().factor(-1.0).factor, 0.0);
206        assert_eq!(DimWhenInactive::new().factor, DEFAULT_DIM_FACTOR);
207    }
208
209    #[test]
210    fn dims_subtree_only_when_window_inactive() {
211        let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
212        tree.add(
213            DimWhenInactive::new()
214                .factor(0.5)
215                .child(FillWidget::new().background(Color::RED)),
216        );
217        tree.layout(SizeProposal::exact(100.0, 50.0));
218
219        // Active: no dimming scope (< 1.0) is emitted.
220        let ops = set_opacities(&tree.render());
221        assert!(
222            !ops.iter().any(|o| *o < 0.99),
223            "active window must not dim, got {ops:?}"
224        );
225
226        // Inactive: a 0.5 opacity scope wraps the subtree.
227        tree.set_window_active(false);
228        let ops = set_opacities(&tree.render());
229        assert!(
230            ops.iter().any(|o| (*o - 0.5).abs() < 1e-3),
231            "inactive window must dim to the factor, got {ops:?}"
232        );
233
234        // Reactivate: dimming clears.
235        tree.set_window_active(true);
236        let ops = set_opacities(&tree.render());
237        assert!(
238            !ops.iter().any(|o| *o < 0.99),
239            "reactivated window must not dim, got {ops:?}"
240        );
241    }
242}