| name | bluefinctl-dev |
| description | 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. |
| metadata | {"context7-sources":["/textualize/textual","/tiangolo/typer","/bootc-dev/bootc","/gnome/libadwaita","/websites/developer_gnome"]} |
bluefinctl Development
When to Use
- Adding or modifying any screen in
screens/
- Working with
core/ modules (system, updates, bundles, devmode, ai)
- Debugging widget layout, CSS, or async behaviour
- Wiring new actions, keybindings, or Command Palette entries
- Working with bootc image refs, channel switching, or rollback
When NOT to Use
- Pure AI stack work (GPU detection, quadlet deploy) โ also load
docs/skills/ai-stacks.md
- Human gate decisions โ
docs/skills/human-gates.md
Quick start
cd /var/home/jorge/src/bluefinctl
pip install -e ".[dev]"
bctl
ghostty -e bctl &
ghostty -e textual run --dev src/bluefinctl/app.py &
Architecture
core/ Business logic only โ NO Textual imports, fully testable
screens/ One Screen subclass per panel, thin presentation layer
widgets/ adw.py ยท ops_bar.py ยท segmented_progress.py ยท rollback_calendar.py ยท operation_modal.py
theme/ accent.py (gsettings reader + theme builder) + bluefin.tcss
stacks/ Bundled AI stack quadlet files (nvidia/ and amd/)
util/ OSC escape sequences, Ghostty detection, terminal launcher
Rule: All subprocess calls, file I/O, and system state live in core/. Screens only call core functions and present results.
Four screens
| Key | Screen | Core module | Notes |
|---|
| 1 | screens/system.py | core/system.py | 2-col; left: Image/System/Health; right: Update All โ Testing switch โ Rollback calendar |
| 2 | screens/updates.py | core/updates.py | 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,
AdwActionRow,
AdwSwitchRow,
AdwComboRow,
AdwButtonRow,
AdwButtonsRow,
AdwPropertyRow,
AdwExpanderRow,
)
Two-column layout
with Horizontal(classes="adw-cols"):
with Vertical(classes="adw-col"):
yield AdwPreferencesGroup(...)
with Vertical(classes="adw-col"):
yield AdwPreferencesGroup(...)
Action buttons
yield AdwButtonsRow(
Button("Update Now", variant="primary", id="btn-update"),
Button("Check for Updates", id="btn-check"),
)
yield AdwButtonRow(
"Roll Back to Previous Build",
subtitle="Requires reboot to apply",
variant="destructive",
id="btn-rollback",
)
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.
class MyScreen(Screen[None]):
DEFAULT_CSS = """
MyScreen { layout: vertical; overflow: hidden hidden; }
.adw-cols { height: auto; }
.adw-col { width: 1fr; height: auto; padding: 0 2; } /* height: auto required */
#adw-content { height: 1fr; }
"""
def compose(self) -> ComposeResult:
yield ViewSwitcher("myscreen")
with ScrollableContainer(id="adw-content"):
with Horizontal(classes="adw-cols"):
with Vertical(classes="adw-col"):
yield AdwPreferencesGroup(...)
with Vertical(classes="adw-col"):
yield AdwPreferencesGroup(...)
yield OpsBar()
ViewSwitcher height: 2, OpsBar height: 2. Donโt increase them โ chrome eats content rows.
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().
system_notify โ zero in-app toasts
from bluefinctl.core.notify import system_notify
system_notify("Operation complete", "Details here")
system_notify("Failed", "brew exited 1", urgency="critical")
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)
ops.add_completed("Docker")
ops.set_running("Installing Limaโฆ", step=2, total=4)
ops.set_complete("โ Done โ 2 tools installed")
ops.set_error("โ Failed โ brew install: exit 1")
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:
def _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)
- Installed โ
"Remove" button, variant="error", .add_class("remove-mode")
def _update_tool_button(self, tool_id: str, installed: bool) -> None:
btn = self.query_one(f"#install-{tool_id}", Button)
if installed:
btn.label = "Remove"
btn.variant = "error"
btn.add_class("remove-mode")
else:
btn.label = "Install"
btn.variant = "primary"
btn.remove_class("remove-mode")
Event routing dispatches to _install_tool or _remove_tool based on the class:
def on_button_pressed(self, event: Button.Pressed) -> None:
btn_id = event.button.id or ""
if btn_id.startswith("install-"):
tool_id = btn_id[len("install-"):]
event.stop()
if event.button.has_class("remove-mode"):
self._remove_tool(tool_id)
else:
self._install_tool(tool_id)
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):
async def get_remove_steps(tool_id: str) -> AsyncGenerator[ProgressUpdate]:
_dispatch = {"docker": remove_docker_steps, "podman": remove_podman_desktop_steps, ...}
fn = _dispatch.get(tool_id)
if fn is None:
raise ValueError(f"Unknown tool: {tool_id}")
async for update in fn():
yield update
All operations stream ProgressUpdate objects to OpsBar directly โ no modals.
display: flex is invalid in Textual CSS
Textual only accepts display: block or display: none. Using flex throws
StylesheetParseError at runtime. To make a widget visible/hidden:
.visible { display: block; }
.visible { display: flex; }
Smart Reboot strategy โ systemd user units
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.
from textual import work
@work(exclusive=True)
async def action_toggle_devmode(self, desired: bool | None = None) -> None:
confirmed = await self.app.push_screen_wait(ConfirmModal(...))
...
def on_button_pressed(self, event: Button.Pressed) -> None:
self.action_toggle_devmode()
def on_adw_switch_row_changed(self, event: AdwSwitchRow.Changed) -> None:
self.action_toggle_devmode(event.value)
Never do this:
self.run_worker(self.action_toggle_devmode())
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
info.image_tag
info.image_ref
info.clean_image_ref
info.full_clean_ref
info.image_signed
info.image_staged
comp = await get_image_compression(info.full_clean_ref)
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
target = f"{base}:testing"
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:
script = "systemctl unmask uupd.timer && systemctl enable --now uupd.timer"
proc = await asyncio.create_subprocess_exec("pkexec", "bash", "-c", script, ...)
proc1 = await asyncio.create_subprocess_exec("pkexec", "systemctl", "unmask", ...)
proc2 = await asyncio.create_subprocess_exec("pkexec", "systemctl", "enable", ...)
AdwButtonRow โ updating displayed text
AdwButtonRow renders via render() from self._title. Use the public method added in 0.1.0:
row = self.query_one("#my-row", AdwButtonRow)
row.update_title("โ Active")
App-level action delegation pattern
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:
def action_toggle_devmode(self) -> None:
self.switch_screen("system")
self.call_after_refresh(self._trigger_toggle_devmode)
def _trigger_toggle_devmode(self) -> None:
from bluefinctl.screens.system import SystemScreen
try:
screen = self.get_screen("system")
if isinstance(screen, SystemScreen):
screen.action_toggle_devmode()
except Exception:
pass
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)
async def _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:
self.query_one("#devmode-switch", AdwSwitchRow).set_value(enable)
return
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:
def on_mount(self) -> None:
self.run_worker(self._load_devmode_state(), exclusive=False)
async def _load_devmode_state(self) -> None:
state = await loop.run_in_executor(None, _check_devmode_active)
self.query_one("#devmode-switch", AdwSwitchRow).set_value(state.active)
Do NOT call pkexec in on_mount โ it fires a polkit auth dialog on every screen switch.
GNOME HIG Quick Reference
See docs/skills/textual-dev.md for the full HIG section. Summary for this codebase:
| Context | Capitalization | Example |
|---|
Group headings (AdwPreferencesGroup title) | Header caps | "Update Components", "Reboot Strategy" |
| Button labels | Header caps + imperative verb | "Update Now", "Check for Updates", "Roll Back" |
Switch/toggle row titles (AdwSwitchRow) | Header caps | "Reboot on Logout", "OS Image" |
| Row subtitles | Sentence case | "bootc system image", "Downloads automatically" |
| Body/explanatory text in dialogs | Sentence case | "Are you sure you want to roll back?" |
Button variant mapping to HIG:
variant="primary" โ suggested action (affirmative, accent colour)
variant="error" โ destructive action (permanent/dangerous, red)
variant="default" โ neutral
Red Flags
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
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:
class RollbackCalendar(Vertical):
def compose(self) -> ComposeResult:
yield Label("", id="cal-grid")
yield Label("", id="cal-hint")
def _update_grid(self) -> None:
self.query_one("#cal-grid", Label).update(self._build_grid_text())
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
class ViewSwitcherTab(Static):
def render(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:
class UpdateProgress(Progress):
def __init__(self, info: ImageInfo, *args: object, **kwargs: object) -> None:
super().__init__(*args, **kwargs)
self._info = info
def get_renderables(self) -> object:
yield Panel(header_text, border_style="dim white", padding=(0, 0))
yield self.make_tasks_table(self.tasks)
Add custom fields to tasks via kwargs (e.g., detail=) โ reference them in TextColumn as {task.fields[detail]}.
bootc --progress-fd JSON schema
sudo bootc upgrade --progress-fd N writes JSON lines to fd N:
{"type": "ProgressSteps", "task": "pulling", "steps": 12, "stepsTotal": 23, "bytes": 0, "bytesTotal": 0}
{"type": "ProgressBytes", "task": "pulling", "steps": 12, "stepsTotal": 23, "bytes": 152399872, "bytesTotal": 327155712}
Stage โ OSC% mapping (mirrors uupd): pulling 0โ80%, importing 80โ90%, staging 90โ100%.
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.
r_fd, w_fd = os.pipe()
proc = await asyncio.create_subprocess_exec(
"sudo", "bootc", "upgrade", "--progress-fd", str(w_fd),
pass_fds=(w_fd,),
)
os.close(w_fd)
Dual-stream async queue pattern
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 = False
async def _read_progress_pipe() -> None:
nonlocal saw_json_progress
async for raw_line in reader:
...
saw_json_progress = True
await queue.put(BootcEvent(...))
await queue.put(progress_done)
async def _read_stderr() -> None:
async for raw_line in proc.stderr:
if saw_json_progress:
continue
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 = 0
while completed < 2:
item = await queue.get()
if item is progress_done or item is stderr_done:
completed += 1
elif isinstance(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.
Full-update stage order (bctl update)
sudo bootc upgrade --progress-fd N โ sequential (needs root, large, first)
- 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.
class KitsTab(Horizontal): ...
class ToolsTab(Vertical): ...
class KitsTab(Static):
DEFAULT_CSS = "KitsTab { layout: horizontal; }"
Common pitfalls
See docs/skills/textual-dev.md for the full pitfall catalogue.