From 014f6a4fbf77169966b64bad79189aeeb29d0f70 Mon Sep 17 00:00:00 2001
From: thunderballfists <130205658+thunderballfists@users.noreply.github.com>
Date: Wed, 30 Sep 2026 18:03:24 -0500
Subject: [PATCH] Preserve control backgrounds in focus tints
---
CHANGELOG.md | 1 +
docs/controls.md | 2 +-
docs/project-runtime.md | 15 +++++-
tests/test_styles.py | 52 +++++++++++++++++++
.../darwin/showcase-first-modal-80x24.svg | 6 +--
.../darwin/showcase-reopened-modal-80x24.svg | 6 +--
.../darwin/showcase-shell-120x50.svg | 4 +-
.../darwin/showcase-shell-80x24.svg | 4 +-
.../linux/showcase-first-modal-80x24.svg | 6 +--
.../linux/showcase-reopened-modal-80x24.svg | 6 +--
.../linux/showcase-shell-120x50.svg | 4 +-
.../linux/showcase-shell-80x24.svg | 4 +-
textui/presets.py | 5 +-
13 files changed, 91 insertions(+), 24 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3b35905..15fea8e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,7 @@ All notable changes to TextUI are documented here.
### Fixed
+- Focus cues now blend over native and author backgrounds instead of replacing them, so variant buttons retain their colors. Compact inputs, selects, and text areas also preserve their backgrounds; tint strengths remain 15% by default and 25% for compact editable controls.
- Clicking a data-table column heading now sorts once per click. The table's click and mouse-move handlers also called `super()`, which Textual had already run, so every heading click was delivered twice and the ascending/descending toggle always ended on descending. A column-resize press no longer sorts either, and no longer swallows the next heading click when the drag is released.
- Controller resize hooks now receive terminal dimensions even when the active screen has padding or a border.
- Declared autofocus now waits for hidden ancestors to be displayed and runs again when controls are revealed after startup. Disabled controls and background screens do not take focus; anonymous modal autofocus registrations are released on dismissal.
diff --git a/docs/controls.md b/docs/controls.md
index 3473132..7150a35 100644
--- a/docs/controls.md
+++ b/docs/controls.md
@@ -27,7 +27,7 @@ Static tab views use native `TabbedContent`:
```
-Each pane needs an ID and title. `initial` must name one of those panes; without it, Textual selects the first. The `tab-activated` event provides `context.event.pane`. Tabs respond to native mouse and keyboard input. Add `autofocus="true"` to a focusable control when it should receive keyboard input after the document or a modal opens, or after a hidden control or its parent is shown with `display=True`. Focus waits for layout, skips disabled and hidden controls, and applies only to the active screen. In an inactive tab, autofocus waits for that tab to activate and does not override `initial`. TextUI gives focused native controls an accent background tint by default; application TCSS may override it. The [controls example](../examples/controls/app.ui) shows all four controls in a runnable project.
+Each pane needs an ID and title. `initial` must name one of those panes; without it, Textual selects the first. The `tab-activated` event provides `context.event.pane`. Tabs respond to native mouse and keyboard input. Add `autofocus="true"` to a focusable control when it should receive keyboard input after the document or a modal opens, or after a hidden control or its parent is shown with `display=True`. Focus waits for layout, skips disabled and hidden controls, and applies only to the active screen. In an inactive tab, autofocus waits for that tab to activate and does not override `initial`. TextUI gives focused native controls an accent `background-tint` that preserves their own backgrounds and button variant colors; application TCSS may [adjust or disable the tint](project-runtime.md). The [controls example](../examples/controls/app.ui) shows all four controls in a runnable project.
## Choices, disclosure, and indicators
diff --git a/docs/project-runtime.md b/docs/project-runtime.md
index 3c57486..af26c01 100644
--- a/docs/project-runtime.md
+++ b/docs/project-runtime.md
@@ -12,7 +12,20 @@ Use `` when an application needs denser native controls
```
-`compact` is opt-in; documents without it retain Textual's normal control density. Presets have no body and only accept the `preset` attribute. A linked controller can switch any declared preset while the application runs. The method returns its new enabled state and reapplies the host stylesheet plus all active document blocks in their original order:
+`compact` is opt-in; documents without it retain Textual's normal control density. Presets have no body and only accept the `preset` attribute.
+
+Focus uses native `background-tint`, preserving button variants and author backgrounds. The default accent tint is 15%; compact inputs, selects, and text areas use 25%. Adjust it in application TCSS, after presets, with a focused selector:
+
+```css
+Button:focus, Input:focus, Select:focus, TextArea:focus,
+DataTable:focus, Tree:focus, RuntimeList:focus {
+ background-tint: $accent 6%;
+}
+```
+
+Use `background-tint: transparent` to disable TextUI's tint for selected controls while retaining their other native focus styling. Changing only `background` does not disable the tint.
+
+A linked controller can switch any declared preset while the application runs. The method returns its new enabled state and reapplies the host stylesheet plus all active document blocks in their original order:
```python
@command(label="Compact", shortcut="ctrl+d")
diff --git a/tests/test_styles.py b/tests/test_styles.py
index 3a1e1b3..23b4d65 100644
--- a/tests/test_styles.py
+++ b/tests/test_styles.py
@@ -304,3 +304,55 @@ async def test_focused_compact_controls_keep_content_across_preset_switches():
await pilot.pause()
rendered = "\n".join(_screen_rows(app)[choices.region.y:choices.region.bottom])
assert "First" in rendered and "Last" in rendered
+
+
+@pytest.mark.asyncio
+async def test_focus_preserves_button_variant_colors_when_compact_is_toggled():
+ app = textui.TextUI(textui.DocumentLoader().from_string('''
+
+
+
+ '''))
+ app.theme = "textual-dark"
+ async with app.run_test(size=(40, 10)) as pilot:
+ enabled = True
+ for compact in (True, False, True):
+ if compact != enabled:
+ enabled = app.document.toggle_style_preset("compact")
+ for name, dominant in (("go", "b"), ("stop", "r")):
+ button = app.document.get_by_id(name)
+ button.focus()
+ await pilot.pause()
+ focused = button.background_colors[1]
+ others = [getattr(focused, channel) for channel in "rgb" if channel != dominant]
+ assert getattr(focused, dominant) > max(others), (name, compact, focused)
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("markup", [
+ '',
+ '',
+ 'Ready',
+])
+async def test_focus_tints_author_backgrounds_without_replacing_them(markup):
+ app = textui.TextUI(textui.DocumentLoader().from_string(f'''
+
+
+ {markup}
+
+ '''))
+ app.theme = "textual-dark"
+ async with app.run_test(size=(40, 12)) as pilot:
+ control = app.document.get_by_id("control")
+ enabled = True
+ for compact in (True, False, True):
+ if compact != enabled:
+ enabled = app.document.toggle_style_preset("compact")
+ app.document.get_by_id("other").focus()
+ await pilot.pause()
+ resting = control.background_colors[1]
+ control.focus()
+ await pilot.pause()
+ focused = control.background_colors[1]
+ assert focused.b > max(focused.r, focused.g), (markup, compact, focused)
+ assert focused != resting
diff --git a/tests/visual/__snapshots__/darwin/showcase-first-modal-80x24.svg b/tests/visual/__snapshots__/darwin/showcase-first-modal-80x24.svg
index 017a243..83a7c45 100644
--- a/tests/visual/__snapshots__/darwin/showcase-first-modal-80x24.svg
+++ b/tests/visual/__snapshots__/darwin/showcase-first-modal-80x24.svg
@@ -37,10 +37,10 @@
.terminal-2523035515-r3 { fill: #c5c8c6 }
.terminal-2523035515-r4 { fill: #e0e0e0 }
.terminal-2523035515-r5 { fill: #646464;font-weight: bold }
-.terminal-2523035515-r6 { fill: #201a14;font-weight: bold }
+.terminal-2523035515-r6 { fill: #241e17;font-weight: bold }
.terminal-2523035515-r7 { fill: #646464 }
.terminal-2523035515-r8 { fill: #0178d4 }
-.terminal-2523035515-r9 { fill: #3f3221;font-weight: bold }
+.terminal-2523035515-r9 { fill: #3f321f;font-weight: bold }
.terminal-2523035515-r10 { fill: #6c6668;font-weight: bold }
@@ -127,7 +127,7 @@
-
+
╭──────╮TextUIfeatureshowcase12:34:56╭───────────╮╔═══════
│ ☰ ││ Compact │║ Help
diff --git a/tests/visual/__snapshots__/darwin/showcase-reopened-modal-80x24.svg b/tests/visual/__snapshots__/darwin/showcase-reopened-modal-80x24.svg
index 017a243..83a7c45 100644
--- a/tests/visual/__snapshots__/darwin/showcase-reopened-modal-80x24.svg
+++ b/tests/visual/__snapshots__/darwin/showcase-reopened-modal-80x24.svg
@@ -37,10 +37,10 @@
.terminal-2523035515-r3 { fill: #c5c8c6 }
.terminal-2523035515-r4 { fill: #e0e0e0 }
.terminal-2523035515-r5 { fill: #646464;font-weight: bold }
-.terminal-2523035515-r6 { fill: #201a14;font-weight: bold }
+.terminal-2523035515-r6 { fill: #241e17;font-weight: bold }
.terminal-2523035515-r7 { fill: #646464 }
.terminal-2523035515-r8 { fill: #0178d4 }
-.terminal-2523035515-r9 { fill: #3f3221;font-weight: bold }
+.terminal-2523035515-r9 { fill: #3f321f;font-weight: bold }
.terminal-2523035515-r10 { fill: #6c6668;font-weight: bold }
@@ -127,7 +127,7 @@
-
+
╭──────╮TextUIfeatureshowcase12:34:56╭───────────╮╔═══════
│ ☰ ││ Compact │║ Help
diff --git a/tests/visual/__snapshots__/darwin/showcase-shell-120x50.svg b/tests/visual/__snapshots__/darwin/showcase-shell-120x50.svg
index 99bfaea..81282ce 100644
--- a/tests/visual/__snapshots__/darwin/showcase-shell-120x50.svg
+++ b/tests/visual/__snapshots__/darwin/showcase-shell-120x50.svg
@@ -35,7 +35,7 @@
.terminal-3865339023-r1 { fill: #0178d4 }
.terminal-3865339023-r2 { fill: #ffffff }
.terminal-3865339023-r3 { fill: #c5c8c6 }
-.terminal-3865339023-r4 { fill: #352817;font-weight: bold }
+.terminal-3865339023-r4 { fill: #3f321f;font-weight: bold }
.terminal-3865339023-r5 { fill: #e0e0e0;font-weight: bold }
.terminal-3865339023-r6 { fill: #e0e0e0 }
.terminal-3865339023-r7 { fill: #f5e5e9;font-weight: bold }
@@ -202,7 +202,7 @@
-
+
╔══════╗TextUIfeatureshowcase12:34:56╭───────────╮╭────────╮
║ ☰ ║│ Compact ││ Help │
diff --git a/tests/visual/__snapshots__/darwin/showcase-shell-80x24.svg b/tests/visual/__snapshots__/darwin/showcase-shell-80x24.svg
index c65f422..33620ed 100644
--- a/tests/visual/__snapshots__/darwin/showcase-shell-80x24.svg
+++ b/tests/visual/__snapshots__/darwin/showcase-shell-80x24.svg
@@ -35,7 +35,7 @@
.terminal-1566995877-r1 { fill: #0178d4 }
.terminal-1566995877-r2 { fill: #ffffff }
.terminal-1566995877-r3 { fill: #c5c8c6 }
-.terminal-1566995877-r4 { fill: #352817;font-weight: bold }
+.terminal-1566995877-r4 { fill: #3f321f;font-weight: bold }
.terminal-1566995877-r5 { fill: #e0e0e0;font-weight: bold }
.terminal-1566995877-r6 { fill: #e0e0e0 }
.terminal-1566995877-r7 { fill: #f5e5e9;font-weight: bold }
@@ -124,7 +124,7 @@
-
+
╔══════╗TextUIfeatureshowcase12:34:56╭───────────╮╭───────
║ ☰ ║│ Compact ││ Help
diff --git a/tests/visual/__snapshots__/linux/showcase-first-modal-80x24.svg b/tests/visual/__snapshots__/linux/showcase-first-modal-80x24.svg
index 017a243..83a7c45 100644
--- a/tests/visual/__snapshots__/linux/showcase-first-modal-80x24.svg
+++ b/tests/visual/__snapshots__/linux/showcase-first-modal-80x24.svg
@@ -37,10 +37,10 @@
.terminal-2523035515-r3 { fill: #c5c8c6 }
.terminal-2523035515-r4 { fill: #e0e0e0 }
.terminal-2523035515-r5 { fill: #646464;font-weight: bold }
-.terminal-2523035515-r6 { fill: #201a14;font-weight: bold }
+.terminal-2523035515-r6 { fill: #241e17;font-weight: bold }
.terminal-2523035515-r7 { fill: #646464 }
.terminal-2523035515-r8 { fill: #0178d4 }
-.terminal-2523035515-r9 { fill: #3f3221;font-weight: bold }
+.terminal-2523035515-r9 { fill: #3f321f;font-weight: bold }
.terminal-2523035515-r10 { fill: #6c6668;font-weight: bold }
@@ -127,7 +127,7 @@
-
+
╭──────╮TextUIfeatureshowcase12:34:56╭───────────╮╔═══════
│ ☰ ││ Compact │║ Help
diff --git a/tests/visual/__snapshots__/linux/showcase-reopened-modal-80x24.svg b/tests/visual/__snapshots__/linux/showcase-reopened-modal-80x24.svg
index 017a243..83a7c45 100644
--- a/tests/visual/__snapshots__/linux/showcase-reopened-modal-80x24.svg
+++ b/tests/visual/__snapshots__/linux/showcase-reopened-modal-80x24.svg
@@ -37,10 +37,10 @@
.terminal-2523035515-r3 { fill: #c5c8c6 }
.terminal-2523035515-r4 { fill: #e0e0e0 }
.terminal-2523035515-r5 { fill: #646464;font-weight: bold }
-.terminal-2523035515-r6 { fill: #201a14;font-weight: bold }
+.terminal-2523035515-r6 { fill: #241e17;font-weight: bold }
.terminal-2523035515-r7 { fill: #646464 }
.terminal-2523035515-r8 { fill: #0178d4 }
-.terminal-2523035515-r9 { fill: #3f3221;font-weight: bold }
+.terminal-2523035515-r9 { fill: #3f321f;font-weight: bold }
.terminal-2523035515-r10 { fill: #6c6668;font-weight: bold }
@@ -127,7 +127,7 @@
-
+
╭──────╮TextUIfeatureshowcase12:34:56╭───────────╮╔═══════
│ ☰ ││ Compact │║ Help
diff --git a/tests/visual/__snapshots__/linux/showcase-shell-120x50.svg b/tests/visual/__snapshots__/linux/showcase-shell-120x50.svg
index 99bfaea..81282ce 100644
--- a/tests/visual/__snapshots__/linux/showcase-shell-120x50.svg
+++ b/tests/visual/__snapshots__/linux/showcase-shell-120x50.svg
@@ -35,7 +35,7 @@
.terminal-3865339023-r1 { fill: #0178d4 }
.terminal-3865339023-r2 { fill: #ffffff }
.terminal-3865339023-r3 { fill: #c5c8c6 }
-.terminal-3865339023-r4 { fill: #352817;font-weight: bold }
+.terminal-3865339023-r4 { fill: #3f321f;font-weight: bold }
.terminal-3865339023-r5 { fill: #e0e0e0;font-weight: bold }
.terminal-3865339023-r6 { fill: #e0e0e0 }
.terminal-3865339023-r7 { fill: #f5e5e9;font-weight: bold }
@@ -202,7 +202,7 @@
-
+
╔══════╗TextUIfeatureshowcase12:34:56╭───────────╮╭────────╮
║ ☰ ║│ Compact ││ Help │
diff --git a/tests/visual/__snapshots__/linux/showcase-shell-80x24.svg b/tests/visual/__snapshots__/linux/showcase-shell-80x24.svg
index c65f422..33620ed 100644
--- a/tests/visual/__snapshots__/linux/showcase-shell-80x24.svg
+++ b/tests/visual/__snapshots__/linux/showcase-shell-80x24.svg
@@ -35,7 +35,7 @@
.terminal-1566995877-r1 { fill: #0178d4 }
.terminal-1566995877-r2 { fill: #ffffff }
.terminal-1566995877-r3 { fill: #c5c8c6 }
-.terminal-1566995877-r4 { fill: #352817;font-weight: bold }
+.terminal-1566995877-r4 { fill: #3f321f;font-weight: bold }
.terminal-1566995877-r5 { fill: #e0e0e0;font-weight: bold }
.terminal-1566995877-r6 { fill: #e0e0e0 }
.terminal-1566995877-r7 { fill: #f5e5e9;font-weight: bold }
@@ -124,7 +124,7 @@
-
+
╔══════╗TextUIfeatureshowcase12:34:56╭───────────╮╭───────
║ ☰ ║│ Compact ││ Help
diff --git a/textui/presets.py b/textui/presets.py
index f928fed..dbdb2bc 100644
--- a/textui/presets.py
+++ b/textui/presets.py
@@ -20,15 +20,16 @@
}
Input:focus, Select:focus, TextArea:focus {
outline: none;
- background: $accent 25%;
+ background-tint: $accent 25%;
}
"""
FOCUS_TCSS = """
+/* Blend over native variant and author backgrounds instead of replacing them. */
Button:focus, Input:focus, Select:focus, TextArea:focus, Checkbox:focus,
Switch:focus, RadioButton:focus, RadioSet:focus, DataTable:focus,
Tree:focus, RuntimeList:focus {
- background: $accent 15%;
+ background-tint: $accent 15%;
}
"""