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