Window-active appearance
Teksilo adjusts carets, selections, and themed colors when a window loses OS focus. Custom widgets can read the active-window state or opt into dimming.
What "active" means
A window is active when it is focused AND not occluded, it holds OS
keyboard focus and isn't fully hidden behind another window. This is computed
per window by teksilo-app from winit's Focused / Occluded events and
published as a reactive signal on each window's widget tree.
window_active is distinct from view focus. A ListView can hold keyboard
focus within its window while that window is inactive. The vivid selection
shows only when both are true (view focused and window active); otherwise
the selection is muted. The same muted colour serves both "focus is elsewhere in
this window" and "this window is inactive", matching macOS's single
"unemphasized" selection colour and GTK's :backdrop.
State is per window: deactivating one window never affects another (there is no app-wide fan-out, unlike theme or text-scale).
Reading it
| Surface | API |
|---|---|
In build(), reactive | ctx.window_active_signal() -> Signal<bool> (bind at RepaintOnly) |
In build(), one-shot | ctx.window_active() -> bool |
In paint() | ctx.window_active: bool (on PaintContext) |
| In an event handler | ctx.window_active() -> bool (on EventContext) |
| On the tree | WidgetTree::window_active_signal() / is_window_active() |
It starts true, a window must not be born inactive before its first focus
event arrives. A flip triggers a repaint only (never a relayout): geometry is
unchanged, so the caret keeps its space and nothing reflows.
Automatic behaviour (no opt-in)
These are correctness, not features, so they are on by default:
- Accent desaturation (theme-side, covers every control). When the window is
inactive, the paint walker swaps in a theme projection
(
ColorTokens::for_inactive_window, unless the theme registers its ownPaintProjection, see below) whose accent family and focus indicators are desaturated toward graphite, the macOS / QtQPalette::Inactivemodel. Because every themed control resolves its accent from the liveColorTokensat paint time, this single swap greys out all of them with no per-widget code: the default (Filled)Button,Toggle's on-track, checkedCheckbox/RadioButton, the selectedTabBartab andSegmentedControlsegment,Sliderfills,ProgressBar,Badge, links-as-accent, and focus rings (BorderRole::Focused/focus_ring). It applies to any preset that populates these tokens, IntUI, Material 3 (whereaccent= M3 primary), and future presets, for free. Deliberately untouched: selection, status, and text tokens (see below). - Caret hiding. The text caret hides in an inactive window for every caret
policy, in both text stacks (
RichTextEditorand everyTextInput/PasswordField/SpinBox/SearchFieldbuilt onTextInputField). It returns immediately when the window reactivates and the field still holds focus. There is no opt-out, every native toolkit hides the caret here. - Selection desaturation. A selected row/cell/run shows the vivid selection only while its view is focused and the window is active; otherwise it falls back to the muted inactive colour. This is handled per widget (not theme-side) because it depends on view focus, which a theme projection can't express. Covered surfaces:
| Widget | Active role / token | Inactive role / token |
|---|---|---|
StandardListItem / StandardTreeItem | SurfaceRole::Selected | SurfaceRole::SelectedInactive |
TableView / TreeTableView | SurfaceRole::Selected | SurfaceRole::SelectedInactive |
RichTextEditor | editor_selection_bg | selection_bg_inactive |
TextInput family | selection_bg_active | selection_bg_inactive |
Keyboard focus rings grey out (not hide) in an inactive window, uniformly, via
the theme-side accent projection above, no per-widget check. MenuList is
excluded by design, an open menu is always active.
A style chooses what mutes a list row's selection
For StandardListItem / StandardTreeItem the "view focused and window
active" rule above is the default of a style hook,
StandardItemStyle::selection_emphasis() -> SelectionEmphasis:
SelectionEmphasis | A selected row is emphasised while | Used by |
|---|---|---|
ViewFocusedAndWindowActive (default) | its view holds keyboard focus and its window is active | IntUI, Fluent, macOS, Material 3 |
WindowActive | its window is active, whichever view holds focus | the KDE preset (Breeze), and any style following Qt's per-window colour groups |
Qt picks a palette colour group per window: in an active window every view
paints its selection with the active Highlight, focused or not, so only the
window going inactive mutes it. A style that returns WindowActive gets that;
the KDE preset's list and tree rows do. What they are muted to is the
SelectedInactive token, which the KDE preset fills with the scheme's own
Inactive highlight (see below) rather than Int UI's hand-tuned grey.
The row reads the hook for its foregrounds: the label, the subtitle and a tree
row's chevron take selected_label_role only while the row is emphasised. A
style reads the same hook for its chrome through
SelectionEmphasis::emphasised(&cfg.is_focused, &cfg.is_window_active), so a
solid selection fill and the text on it change together. The shipped
RecipeStandardItemStyle keeps the default, so a style that delegates its chrome
to it and returns WindowActive has to paint its own selection to follow its
rule.
Tests that assume a selected row mutes when its view loses focus therefore hold for the presets on the default only; scope them per preset.
Single-line fields hide their selection when they lose focus
The table above is the window axis. On the view-focus axis the
TextInput family (TextInput, PasswordField, SpinBox, SearchField,
DateEdit / TimeEdit / DateTimeEdit, HexColorInput, and anything else
built on TextInputField) does not desaturate, it paints no selection at
all while the field itself is unfocused, even in an active window. So a form
of fields tabbed through in turn shows exactly one selection: the one that the
keystrokes reach.
That follows the native single-line entry everywhere except GTK. A Win32 edit
control hides the selection on focus-out unless created with ES_NOHIDESEL
(WinForms: TextBoxBase.HideSelection, default true); QLineEdit's
focusOutEvent calls deselect() for every focus reason except
ActiveWindowFocusReason and PopupFocusReason; and an NSTextField that
stops being first responder has the shared field editor detached from it, so
there is no selection left to draw. Qt's carve-out is the same distinction made
here: losing the window dims, losing the caret hides.
Multi-line editors (RichTextEditor, CodeEditor, LogView) stay on the
desaturating rule for both axes, QTextEdit, NSTextView and every code
editor keep a blurred view's selection visible, because there it marks a region
of a document being worked on rather than a transient edit state.
The selection state is kept across blur in both families (the right-click Copy path needs it, and native fields keep it too); only the painting changes.
Custom selection colours stay fixed
If an app sets an explicit selection colour, e.g.
RichTextEditor::editor(doc).selection_color(my_blue), that colour is used
as-is and is not auto-desaturated when the window goes inactive. This
matches macOS, where an app-set selection colour opts out of system management.
Only theme-driven (default) selections desaturate.
DimWhenInactive: opt-in for custom content
The automatic layers cover stock widgets. For custom content an app wants to fade back in a background window (a colourful side panel, a bespoke accent surface), wrap it:
#![allow(unused)] fn main() { use teksilo::prelude::*; ctx.add(DimWhenInactive::new().factor(0.4).child(my_panel)); // 40 % when inactive ctx.add(DimWhenInactive::new().child(my_panel)); // default 70 % }
The wrapper drives a node-level opacity scope from window_active_signal.
There is no WidgetBuilder::dim_when_inactive: it was the one builder method
returning a foreign wrapper instead of WidgetWithHandlers<Self>, so the rest
of a chain past it resolved against DimWhenInactive, whose child replaces
its pending child, and VStack::new().dim_when_inactive(0.7).child(a).child(b)
silently built DimWhenInactive > b. It was removed, and
teksilo_teksu_guard::foreign_wrapper_returns now fails the build if a method
of that shape comes back. It is layout- and a11y-transparent, and the opacity
snaps (no tween), correct under prefers-reduced-motion, since window
activation is an OS state change, not a user-initiated motion.
A theme's own inactive and high-contrast colours: PaintProjection
The accent desaturation above is a fixed transform of the tokens. It suits a
palette that defines no inactive colours of its own, which is every palette
built from tokens alone. A theme whose source palette does define them (a
desktop colour scheme with an inactive colour group, say) registers a
PaintProjection instead, and paints those colours:
#![allow(unused)] fn main() { use teksilo::core::styles::{PaintState, Theme}; fn project(theme: &Theme, state: PaintState) -> Theme { let mut out = theme.clone(); if !state.window_active { out.colors = my_inactive_colors(theme); } if state.high_contrast { out = out.for_high_contrast(); // keep the generic part it wants } out } let theme = my_theme().with_paint_projection(project); }
The paint walker asks Theme::for_paint_state(PaintState { window_active, high_contrast }) for the theme of each frame. With a PaintProjection
registered, that is the hook's answer; without one it is the generic chain,
for_inactive_window when the window is inactive and then for_high_contrast
when the "increase contrast" preference is set. That last step lifts the
theme's own accent, focus ring, secondary text and selection fills (focused
and unfocused) until they reach 7:1 for text and 4.5:1 for the ring, keeping
their hue. The hook receives the active theme and both flags at once, so it
decides how they combine, and it replaces the generic chain entirely:
Theme::for_inactive_window and Theme::for_high_contrast stay the generic
transforms and do not consult it. Only change colours in a hook. The swap is
repaint-only, so geometry a hook changes is not honoured.
The KDE preset is the example. A KDE colour scheme defines an Inactive
colour group, so teksilo-theme-kde registers a PaintProjection that paints
it: for an inactive window the theme takes the tokens it computed from the
scheme's Inactive state, and for the high-contrast preference its own
high-contrast tokens, in the combination the state names. For Breeze that is a
selection and accent muted toward the window colour (the window colour tinted
toward the selection colour, as Plasma paints a backdrop window) with the focus
ring unchanged, where the generic chain would desaturate the accent toward
graphite and dim the ring. The scheme's colours therefore decide what muting
looks like, which is why the muting is "scheme-driven". A theme whose
colours an app changed after building it gets the generic chain over the app's
colours instead, so an app's override is never painted over. Combined with SelectionEmphasis::WindowActive above, a Breeze list's
selection stays vivid while its view lacks focus and mutes only with its window.
Colours a recipe bakes at build time (a Filled button's fill and focus
border, held as a ColorProp::Bound) resolve through the same call,
Theme::paint_colors, against BuildContext::window_active_signal and
BuildContext::high_contrast_signal, so they follow the hook, and the
high-contrast preference, like the colours resolved at paint.
Keeping a widget vivid when inactive
There is no need to opt out of the automatic behaviour for normal apps. If a
widget genuinely must stay vivid regardless of window focus (a live status
indicator, a kiosk display), paint it directly from theme tokens and simply
don't consult ctx.window_active, or, app-wide, never call
set_window_active(false).
Accessibility
Caret hiding and selection desaturation are paint-only. They do not change the AccessKit tree, the announced selection state, or any node value, a screen reader still reports the selection and caret position normally. The visual change is purely cosmetic.
Testing
WidgetTree::set_window_active(bool) drives the state in a headless test:
#![allow(unused)] fn main() { let mut tree = WidgetTree::new().with_theme(intui::light()); let id = tree.add(some_editor); tree.layout(SizeProposal::exact(400.0, 300.0)); // ...focus the widget... tree.set_window_active(false); let _ = tree.render(); // assert the caret is gone / the selection colour swapped }
A fresh tree starts active (is_window_active() == true). See the tests in
crates/teksilo-core/src/dim_when_inactive.rs,
crates/teksilo-widgets/src/rich_text/tests.rs, and the
window_active-named tests under teksilo-widgets.
Demo
cargo run -p multi-window, two windows, each with a status label, a
TextInput, and a DimWhenInactive panel. Click between them to watch the
inactive window hide its caret, mute its selection, dim its panel, and flip its
status label.
Implementation notes
- The reactive primitive lives on
WidgetTree(window_active_signal), written byset_window_activeand threaded ontoPaintContext/BuildContext/EventContextexactly like the global text-scale value. - A focus flip calls
WidgetArena::mark_all_needs_paint_only(), a repaint of every active node, with no relayout and no cache clearing. Window-focus changes are rare (user-driven), so this is cheaper thanset_theme'smark_all_dirty(which also relayouts) and means any paint-timectx.window_activereader is correct without per-widget binding ceremony. - The frame-loop
tick()of each text stack has no context, sobuild()registers an effect onwindow_active_signalthat mirrors the value onto the editor state and, on deactivation, hides the caret synchronously (the frame loop may not tick while the window is parked).