Development patterns and conventions for the bluefinctl Textual TUI project. Use when working in /var/home/jorge/src/bluefinctl — adding screens, wiring actions, creating modals, modifying the theme/bundle system, working with core/ modules, or debugging layout issues. Covers the 4-screen navigation (System, Updates, Developer, AI), ADW widget library, OpsBar, @work pattern, bootc image ref handling, and all non-obvious Textual behaviors discovered in this codebase.
Standardmäßig ist der Prompt ausgewählt, der zuerst die Quelle prüft. Sie können zu einem direkten Befehl wechseln oder eine lokale Kopie herunterladen.
Quelldateien prüfen
Lesen Sie SKILL.md und alle von SkillsMP angezeigten Begleitdateien, bevor Sie sich für eine Installation entscheiden.
Mit Codex oder Claude installieren Kopieren Sie diesen Prompt, fügen Sie ihn in Codex, Claude oder einen anderen Assistant ein und lassen Sie die Skill-Seite prüfen und installieren.
Ein direkter Befehl überspringt den Prüf-Prompt. Prüfen Sie die Quelle, bevor Sie ihn ausführen.
Development patterns and conventions for the bluefinctl Textual TUI project. Use when working in /var/home/jorge/src/bluefinctl — adding screens, wiring actions, creating modals, modifying the theme/bundle system, working with core/ modules, or debugging layout issues. Covers the 4-screen navigation (System, Updates, Developer, AI), ADW widget library, OpsBar, @work pattern, bootc image ref handling, and all non-obvious Textual behaviors discovered in this codebase.
Full-width image banner; radio schedule; staged-update alert; OpsBar footer
3
screens/devmode.py
core/devmode.py
Full-width DevMode toggle at top; 2-col grid of install rows below (no reboot for tools)
4
screens/ai.py
core/ai.py
GPU-gated; hidden when no GPU detected
Navigation items: System · Updates · Developer (number keys 1–3). AI screen (key 4) only shown when GPU is detected.
ADW widget library — widgets/adw.py
All screens use GNOME HIG-compliant widgets. Never use raw Static + borders for layout.
from bluefinctl.widgets.adw import (
AdwPreferencesGroup, # bordered group: title ABOVE box, rows inside
AdwActionRow, # title+subtitle left, trailing widget right
AdwSwitchRow, # [✓]/[ ] toggle at height 1; subtitle supported
AdwComboRow, # cycling value label, fires AdwComboRow.Changed
AdwButtonRow, # full-width text row; subtitle supported; fires AdwButtonRow.Pressed
AdwButtonsRow, # real Textual Buttons side-by-side (accent-coloured)
AdwPropertyRow, # read-only key: value at height 1
AdwExpanderRow, # collapsible row
)
Two-column layout
with Horizontal(classes="adw-cols"):
with Vertical(classes="adw-col"): # leftyield AdwPreferencesGroup(...)
with Vertical(classes="adw-col"): # rightyield AdwPreferencesGroup(...)
Action buttons
# Primary actions — real Textual Button widgets with accent colouryield AdwButtonsRow(
Button("Update Now", variant="primary", id="btn-update"),
Button("Check for Updates", id="btn-check"),
)
# Handle via on_button_pressed# Suggestion rows with optional subtitle (2-line when subtitle present)yield AdwButtonRow(
"Roll Back to Previous Build",
subtitle="Requires reboot to apply",
variant="destructive",
id="btn-rollback",
)
# Handle via on_adw_button_row_pressed
Content area convention
Every custom Screen must set overflow: hidden hidden in DEFAULT_CSS.
The Screen base class has overflow-y: auto, which causes the Screen itself to scroll
instead of the inner ScrollableContainer.
App-level Header/Footer are dead chrome when all screens are push_screen’d.
Pushed screens render over the app’s base layer. Remove them from App.compose().
Never use self.notify() anywhere in the app. All user-facing notifications go to
notify-send via system_notify(). The in-app Textual Toast system is disabled.
OpsBar — animated block progress bar
The redesigned OpsBar (height: 3, dock: bottom) shows animated Unicode block bars.
New API beyond the basics:
ops.set_running("Installing Docker…", step=1, total=4) # block bar + spinner
ops.add_completed("Docker") # adds ✓ Docker to ticker
ops.set_running("Installing Lima…", step=2, total=4)
ops.set_complete("✓ Done — 2 tools installed") # full green bar
ops.set_error("✗ Failed — brew install: exit 1") # red
The stage= keyword still works (backward compat alias for step=).
The add_completed(name) method scrolls ✓ name into the left ticker strip.
Feature Portal pattern — devmode screen
The Developer screen is a feature portal: each AdwActionRow presents a named
capability with a subtitle pitch + inline Install/Remove button as the trailing= widget.
All buttons share the same id prefix install-<tool_id>. State is toggled via a
remove-mode CSS class — one button, two modes:
# Helper used at compose timedef_install_btn(tool_id: str) -> Button:
return Button("Install", id=f"install-{tool_id}", variant="primary")
AdwActionRow(
"Docker",
subtitle="The Bluefin DX ships Docker with compose, lazydocker, and dive.",
trailing=_install_btn("docker"),
id="tool-docker",
)
Install/remove state is detected on mount via a background worker and buttons are updated:
Not installed → "Install" button, variant="primary" (no remove-mode class)
Core dispatchers in core/devmode.py — get_install_steps(tool_id) and
get_remove_steps(tool_id) — return the appropriate async generator. Both use a
lazy dict of callables (not pre-called generators):
Reboot strategies write systemd user units to ~/.config/systemd/user/.
Always run systemctl --user daemon-reload after writing or removing unit files.
Timer units need systemctl --user enable --now <name>.timer to activate.
Safety gate: always check systemd-inhibit --list --no-pager | grep -qE 'audio|video|idle'
before any auto-reboot — skip and log to ~/.local/share/bluefinctl/reboot-skipped.log.
@work — the correct pattern for async actions
push_screen_wait requires a worker context. In Textual 1.x, async action methods called via keybindings do NOT automatically run in a worker — get_current_worker() raises NoActiveWorker.
The fix:@work(exclusive=True) on every async method that calls push_screen_wait.
self.run_worker(self.action_toggle_devmode()) # ✗ wrong when method is @work
This was fixed twice in this codebase. Any async method that calls push_screen_wait must be @work.
core/system.py — SystemInfo
from bluefinctl.core.system import SystemInfo, get_system_info, get_image_compression
info = await get_system_info()
info.image_name # "dakota"
info.image_tag # "latest" or "testing"
info.image_ref # "ostree-image-signed:docker://ghcr.io/projectbluefin/dakota"
info.clean_image_ref # "ghcr.io/projectbluefin/dakota" (no transport prefix, NO tag)
info.full_clean_ref # "ghcr.io/projectbluefin/dakota:latest" ← use this for display
info.image_signed # True when ref starts with "ostree-image-signed:"
info.image_staged # True when bootc status shows a staged update# Compression (network call — always run as background worker):
comp = await get_image_compression(info.full_clean_ref)
# Returns: "zstd:chunked", "zstd", "gzip", or "unknown"
Critical:image_ref from /usr/share/ublue-os/image-info.json has no tag — tag is in image_tag. Always use full_clean_ref for display and user-facing strings.
Channel switching — correct bootc target
info = await get_system_info()
base = info.clean_image_ref # "ghcr.io/projectbluefin/dakota" (NO tag)
target = f"{base}:testing"# "ghcr.io/projectbluefin/dakota:testing"# Both :latest and :testing verified to exist on ghcr.io
proc = await asyncio.create_subprocess_exec("pkexec", "bootc", "switch", target, ...)
_CheckToggle — compact checkbox widget
Textual's built-in Switch uses border: tall forcing 3 rows. Every AdwSwitchRow uses _CheckToggle instead — renders [✓]/[ ] at exactly height: 1 (bumps to height: 2 when subtitle is present). set_value() never fires Changed. This has been fixed twice — do not regress by importing Switch.
pkexec patterns
One pkexec prompt per logical operation — batch multiple systemctl calls:
Actions registered in ActionsProvider (commands.py) are called via app.run_action(name). If the action is defined on a specific Screen subclass rather than the App, it won't dispatch correctly when a different screen is active.
Pattern: define the action on App, navigate to the target screen, then call the screen method:
All four command-palette actions (action_update_now, action_system_report, action_toggle_devmode, action_launch_podman_tui) follow this pattern.
DevMode screen — toggle at top, tools below
The Developer screen has a full-width AdwSwitchRow at the top for group membership
(docker, mock, lxd — requires reboot). The tool install rows below it do NOT require a reboot.
This separation is intentional and must stay clear to the user.
Idempotent toggle pattern — read actual state first, sync switch if already in desired state:
@work(exclusive=True)asyncdef_toggle_devmode(self, enable: bool) -> None:
loop = asyncio.get_running_loop()
state = await loop.run_in_executor(None, _check_devmode_active)
if state.active == enable: # already in desired stateself.query_one("#devmode-switch", AdwSwitchRow).set_value(enable)
return# ... prompt + pkexec ...# On cancel/failure — revert the switch:self.query_one("#devmode-switch", AdwSwitchRow).set_value(not enable)
Use set_value() (not direct assignment) to change a switch without firing Changed.
Load initial state in a separate worker on mount so the switch reflects reality:
AdwSwitchRow("opt into testing stream") — switch labels must use header caps: "Opt Into Testing Stream"
Row subtitle in Title Case — subtitles use sentence case: "bootc system image" not "Bootc System Image"
Button label that is a noun, not a verb — HIG requires imperative: "Update" not "Updates"
variant="warning" on a Button — not a standard HIG style; use "primary" (suggested) or "error" (destructive)
variant="success" for primary call-to-action — use "primary" (suggested); "success" is for confirmation states only
from textual.widgets import Switch in any file — should be _CheckToggle
self.notify() anywhere — banned; use system_notify() from core/notify.py
self.run_worker(self.action_something()) on a @work-decorated method
async def action_* that calls push_screen_wait without @work
info.clean_image_ref used as display string (missing tag)
height: auto on Horizontal containers (expands to fill, not shrink)
ScrollableContainer that never scrolls — missing height: 1fr on #adw-content
Screen has no overflow: hidden hidden in DEFAULT_CSS — Screen base class has overflow-y: auto; pushed screens steal scroll from the inner container
.adw-col without explicit height: auto — Vertical defaults to height: 1fr; inside a height: auto Horizontal this creates a circular dependency and breaks content measurement
scrollbar-gutter: stable in any CSS — not a Textual property, silently ignored
App-level Header/Footer in App.compose() when all screens are push_screen’d — they live behind the screen stack and are never visible; pure dead chrome
Hardcoded color variables ($background, $surface) in TCSS
asyncio.get_event_loop() — use get_running_loop() inside async functions
Rich Console() inside Textual screen/widget — Console writes to stdout and garbles TUI
Widget subclass with BOTH render() AND compose() — children overlap render text
KitsTab(Static) with layout: horizontal CSS — use KitsTab(Horizontal) directly
RollbackCalendar — Label-based calendar grid
The RollbackCalendar extends Vertical and uses two Label children — #cal-grid and #cal-hint — instead of mixing render() with compose(). Mixing them causes visual overlap where children float on top of the render text. The correct pattern:
Never add render() to a widget that also has children from compose().
ViewSwitcherTab centering
To reliably center the tab label text in a Static subclass, return Text(self._tab_name, justify="center") from render(). The CSS content-align: center middle aligns the content block but Rich's justify="center" ensures the text string itself is horizontally centered within the widget width.
from rich.text import Text
classViewSwitcherTab(Static):
defrender(self) -> Text:
return Text(self._tab_name, justify="center")
Rich Progress for CLI headless commands
For bctl <command> headless paths that need a live display (not a full TUI), subclass Progress and override get_renderables() to add a header Panel above the task table:
Use os.pipe() + pass_fds=(w_fd,) to pass the write fd to the subprocess. sudo preserves non-tty file descriptors by default — this works. Do not add --quiet: it suppresses visible bootc progress, so bctl update must keep normal stderr output available and optionally parse it with BootcSwitchParser as a fallback.
BootcEvent has a percent: float | None field populated from BootcSwitchParser when JSON progress is absent. The runner reads both the JSON pipe and stderr concurrently and funnels them through a single asyncio.Queue. This avoids blocking either stream:
progress_done = object()
stderr_done = object()
queue: asyncio.Queue[object] = asyncio.Queue()
saw_json_progress = Falseasyncdef_read_progress_pipe() -> None:
nonlocal saw_json_progress
asyncfor raw_line in reader:
...
saw_json_progress = Trueawait queue.put(BootcEvent(...))
await queue.put(progress_done)
asyncdef_read_stderr() -> None:
asyncfor raw_line in proc.stderr:
if saw_json_progress:
continue# JSON wins; discard text fallback
update = parser.parse_line(line)
if update:
await queue.put(BootcEvent(percent=update.percent, description=update.message))
await queue.put(stderr_done)
asyncio.create_task(_read_progress_pipe())
asyncio.create_task(_read_stderr())
completed = 0while completed < 2:
item = await queue.get()
if item is progress_done or item is stderr_done:
completed += 1elifisinstance(item, BootcEvent):
yield item
Why not two separate async for loops? Awaiting one blocks the other. The queue is the only correct pattern when two async streams must be consumed concurrently inside an async generator.
After the loop: check proc.returncode != 0 and raise RuntimeError with last_bootc_line as detail — gives the user the actual bootc error message.
Parallel via asyncio.gather(): flatpak update -y --noninteractive, brew update && brew upgrade, distrobox upgrade -a
All runners live in core/update_runner.py. Display lives in the update command in cli.py.
When a tab widget has a two-pane horizontal layout, extend Horizontal directly rather than Static with layout: horizontal CSS. Static is for text display; Horizontal/Vertical are the correct layout containers.
# GoodclassKitsTab(Horizontal): ...
classToolsTab(Vertical): ...
# Bad — Static with layout override is fragileclassKitsTab(Static):
DEFAULT_CSS = "KitsTab { layout: horizontal; }"
pytest passing (112 tests)
ruff check src/ tests/ clean
mypy src/ clean (strict)
ghostty -e bctl & launched and affected screen visible/functional
No Switch imports remain in ADW widget code
All new async actions that call push_screen_wait have @work(exclusive=True)
Skill file updated in same PR if new pattern discovered
Common pitfalls
See docs/skills/textual-dev.md for the full pitfall catalogue.