Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

SurfaceAPI
In build(), reactivectx.window_active_signal() -> Signal<bool> (bind at RepaintOnly)
In build(), one-shotctx.window_active() -> bool
In paint()ctx.window_active: bool (on PaintContext)
In an event handlerctx.window_active() -> bool (on EventContext)
On the treeWidgetTree::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 own PaintProjection, see below) whose accent family and focus indicators are desaturated toward graphite, the macOS / Qt QPalette::Inactive model. Because every themed control resolves its accent from the live ColorTokens at paint time, this single swap greys out all of them with no per-widget code: the default (Filled) Button, Toggle's on-track, checked Checkbox / RadioButton, the selected TabBar tab and SegmentedControl segment, Slider fills, ProgressBar, Badge, links-as-accent, and focus rings (BorderRole::Focused / focus_ring). It applies to any preset that populates these tokens, IntUI, Material 3 (where accent = 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 (RichTextEditor and every TextInput / PasswordField / SpinBox / SearchField built on TextInputField). 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:
WidgetActive role / tokenInactive role / token
StandardListItem / StandardTreeItemSurfaceRole::SelectedSurfaceRole::SelectedInactive
TableView / TreeTableViewSurfaceRole::SelectedSurfaceRole::SelectedInactive
RichTextEditoreditor_selection_bgselection_bg_inactive
TextInput familyselection_bg_activeselection_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:

SelectionEmphasisA selected row is emphasised whileUsed by
ViewFocusedAndWindowActive (default)its view holds keyboard focus and its window is activeIntUI, Fluent, macOS, Material 3
WindowActiveits window is active, whichever view holds focusthe 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 by set_window_active and threaded onto PaintContext / BuildContext / EventContext exactly 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 than set_theme's mark_all_dirty (which also relayouts) and means any paint-time ctx.window_active reader is correct without per-widget binding ceremony.
  • The frame-loop tick() of each text stack has no context, so build() registers an effect on window_active_signal that 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).