Blueforce-Linux/bfos/ui_widgets.py
emirhanmamak-blueforce 2bad7fbb7f feat(console): application shell with overview, browser, drawer and safety UX
The console was a flat form: every control on the root window, 48 operations
as undifferentiated rows, a giant output textarea, privilege as a buried
sentence. It now has a shell: a fixed header band (product, target, device
state, privilege) plus a page body, and in detailed mode a sidebar with five
operation categories and an Activity page.

What changed (presentation layer only; _execute, gates, run_plan, audit and
redaction untouched):
- simple mode is an Overview: four state cards (target/device/privilege/
  inventory) plus Install/Status/Exit, instead of the raw form;
- the target is one segmented control (Device/Wave/Entire fleet) with a single
  scope source of truth; the dealer text can no longer silently override the
  wave, and the scope stays visible across pages;
- operations are browsed, not dumped: icon + name + explanation + scope/risk
  badges, with a detail panel (meta grid, warnings, options form, backend-built
  command preview on demand);
- destructive confirmations are dedicated dialogs showing OPERATION/TARGET/
  SCOPE/EFFECT with a fleet/production strip and focus on Cancel; the token
  dialog is styled; secrets never enter either;
- the output textarea is an execution drawer (IDLE/RUNNING with mm:ss /
  DONE-FAILED with duration and exit code) streaming the same backend output;
- write operations stay visible but disabled with their reason when
  unprivileged, in both entry points;
- new module bfos/ui_widgets.py (~490 lines, stdlib + tkinter only, no colour
  literals): canvas line icons, segmented control, collapsible panel, meta
  grid, timer, the two safety dialogs.

Tests: test_bfos_gui_static.py 95 OK, test_bfos.py 155 OK, run-all.sh 12/12.
The colour-literal test now also scans ui_widgets.py (proven by mutation:
a #ff0000 probe goes red, green after removal). Live QA: zero geometry errors
over 712 widgets, all 48 operations listed, 13 write ops disabled with reasons
without root, TUI and --list smoke-tested.

Known limits: no dark theme (tokens ready); the detail page's target line does
not refresh when the target changes mid-form (the confirmation dialog always
shows the current one); visual QA was geometry + OCR, not a human eye.
2026-09-16 23:21:41 +03:00

489 lines
19 KiB
Python

"""bfos/ui_widgets.py -- reusable presentational components for the console.
What this module is
-------------------
The small widget vocabulary the redesigned operator console is built from:
line icons (Tk canvas, shipped locally, no assets), a segmented scope
control, a collapsible panel, a two-column meta grid, a scrollable row
container, an elapsed timer, and two safety dialogs (plan confirmation and
typed-token gate).
What this module is NOT
-----------------------
It knows nothing about operations, targets, gates, commands, auditing or
execution. It receives plain strings and token colours and returns plain
widget handles and booleans. Every behavioural decision (which gates exist,
what a token must equal, when a command may run) stays in ``bfos/ui_gui.py``
and the backend behind it.
Design rules (docs/DESIGN.md, docs/34-CONSOLE-VISUAL-CONCEPT.md):
* no colour literals -- every fill/foreground comes from ``ui_theme``,
* no user-visible English -- caller-facing labels are Turkish,
* status is word + colour, never colour alone (callers pass the words),
* tkinter is optional: the module imports without it so static tests never
need a display (mirrors the ``ui_gui.py`` guard).
"""
from __future__ import annotations
import time
from pathlib import Path
from typing import Any, Callable, Mapping, Sequence
try: # pragma: no cover - environment dependent
import tkinter as tk
from tkinter import ttk
HAVE_TKINTER = True
except Exception: # pragma: no cover - environment dependent
tk = ttk = None # type: ignore[assignment]
HAVE_TKINTER = False
# The view layer only calls these with a live display; Any keeps the optional
# import from tripping the type checker (same pattern as bfos/ui_gui.py).
tk: Any
ttk: Any
try:
from bfos import ui_theme as theme
except ImportError: # pragma: no cover - loaded outside the package
import sys
_WIDGETS_DIR = str(Path(__file__).resolve().parent)
if _WIDGETS_DIR not in sys.path:
sys.path.insert(0, _WIDGETS_DIR)
import ui_theme as theme # type: ignore[no-redef]
# ---------------------------------------------------------------------------
# Line icons -- one stroke vocabulary, drawn on a Tk canvas
# ---------------------------------------------------------------------------
def draw_icon(canvas, name: str, size: int, colour: str) -> None:
"""Draw a ``name`` line icon centred on ``canvas`` (already sized).
Coordinates are derived from ``size`` so every icon scales together; the
stroke is a single colour passed by the caller (a theme token), never a
literal. Unknown names fall back to a neutral square so a typo degrades
to a box instead of an exception.
"""
pad = max(2, size // 6)
x0, y0, x1, y1 = pad, pad, size - pad, size - pad
w = 2
line = lambda *pts: canvas.create_line(*pts, fill=colour, width=w)
rect = lambda a, b, c, d: canvas.create_rectangle(
a, b, c, d, outline=colour, width=w
)
if name == "device": # monitor: screen + stand
rect(x0, y0, x1, y1 - 5)
mid = (x0 + x1) / 2
line(mid, y1 - 5, mid, y1 - 1)
line(mid - 4, y1 - 1, mid + 4, y1 - 1)
elif name == "install": # arrow down into a tray
mid = (x0 + x1) / 2
line(mid, y0, mid, y1 - 5)
line(mid, y0, mid - 4, y0 + 4)
line(mid, y0, mid + 4, y0 + 4)
line(x0, y1 - 2, x1, y1 - 2)
line(x0, y1 - 2, x0, y1 - 5)
line(x1, y1 - 2, x1, y1 - 5)
elif name == "health": # check mark in a circle
canvas.create_oval(x0, y0, x1, y1, outline=colour, width=w)
line(x0 + 3, (y0 + y1) / 2, (x0 + x1) / 2 - 1, y1 - 4)
line((x0 + x1) / 2 - 1, y1 - 4, x1 - 3, y0 + 4)
elif name == "info": # small i in a circle
canvas.create_oval(x0, y0, x1, y1, outline=colour, width=w)
mid = (x0 + x1) / 2
line(mid, y0 + 4, mid, y0 + 5)
line(mid, y0 + 7, mid, y1 - 4)
elif name == "tool": # sliders: two horizontal tracks with knobs
third = (y1 - y0) / 3
line(x0, y0 + third, x1, y0 + third)
line(x0, y0 + 2 * third, x1, y0 + 2 * third)
canvas.create_oval(x0 + 2, y0 + third - 3, x0 + 8, y0 + third + 3,
outline=colour, width=w)
canvas.create_oval(x1 - 8, y0 + 2 * third - 3, x1 - 2,
y0 + 2 * third + 3, outline=colour, width=w)
elif name == "wave": # signal arcs from a dot
canvas.create_oval(x0 + 1, y1 - 5, x0 + 5, y1 - 1,
fill=colour, outline=colour)
canvas.create_arc(x0 - 1, y0 + 3, x1 - 3, y1 + 1, start=270, extent=90,
outline=colour, width=w, style="arc")
canvas.create_arc(x0 + 3, y0 - 1, x1 + 1, y1 - 3, start=270, extent=90,
outline=colour, width=w, style="arc")
elif name == "fleet": # four small squares: many machines
mx, my = (x0 + x1) / 2, (y0 + y1) / 2
rect(x0, y0, mx - 1, my - 1)
rect(mx + 1, y0, x1, my - 1)
rect(x0, my + 1, mx - 1, y1)
rect(mx + 1, my + 1, x1, y1)
elif name == "activity": # clock: recent runs
canvas.create_oval(x0, y0, x1, y1, outline=colour, width=w)
mid = ((x0 + x1) / 2, (y0 + y1) / 2)
line(*mid, *mid, mid[0], y0 + 5)
line(*mid, *mid, mid[0] + 4, mid[1] + 1)
elif name == "warn": # triangle with a mark
mid = (x0 + x1) / 2
line(mid, y0 + 1, x1 - 1, y1 - 1)
line(x1 - 1, y1 - 1, x0 + 1, y1 - 1)
line(x0 + 1, y1 - 1, mid, y0 + 1)
line(mid, y0 + 6, mid, y0 + 10)
line(mid, y1 - 5, mid, y1 - 4)
elif name == "shield": # privilege outline
mid = (x0 + x1) / 2
line(x0 + 1, y0 + 1, mid, y0 + 1)
line(mid, y0 + 1, x1 - 1, y0 + 1)
line(x1 - 1, y0 + 1, x1 - 1, y0 + 7)
line(x1 - 1, y0 + 7, mid, y1 - 1)
line(mid, y1 - 1, x0 + 1, y0 + 7)
line(x0 + 1, y0 + 7, x0 + 1, y0 + 1)
elif name == "chevron": # row affordance: there is a next step
line(x0 + 2, y0 + 2, x1 - 2, (y0 + y1) / 2)
line(x1 - 2, (y0 + y1) / 2, x0 + 2, y1 - 2)
else: # neutral box: a missing icon is visible, never fatal
rect(x0 + 2, y0 + 2, x1 - 2, y1 - 2)
def line_icon(parent, name: str, size: int = 22, colour: str | None = None,
bg: str | None = None):
"""A canvas carrying one line icon (offline, no image assets)."""
canvas = tk.Canvas(
parent,
width=size,
height=size,
background=bg or theme.BG_PANEL,
highlightthickness=0,
borderwidth=0,
)
draw_icon(canvas, name, size, colour or theme.TEXT_MUTED)
return canvas
# ---------------------------------------------------------------------------
# Segmented control -- exactly one scope is on
# ---------------------------------------------------------------------------
class SegmentedControl:
"""Three-way scope switch: [Cihaz | Dalga | Tüm filo].
Exactly one segment is selected and the selection is always visible, so
the operator can never believe a device is targeted while the fleet is.
Implemented on ttk buttons (keyboard-focusable, native tab order); the
selected segment carries the accent style, the rest the quiet style.
"""
def __init__(self, parent, styles: Mapping[str, str],
options: Sequence[tuple[str, str]],
initial: str,
command: Callable[[str], None] | None = None) -> None:
self.styles = styles
self.command = command
self.frame = ttk.Frame(parent, style=styles["panel"])
self._buttons: dict[str, Any] = {}
self._value = initial
for key, label in options:
button = ttk.Button(self.frame, text=label,
command=lambda k=key: self.set(k))
button.pack(side="left", padx=(0, theme.SP_1))
self._buttons[key] = button
self._paint()
def _paint(self) -> None:
for key, button in self._buttons.items():
if key == self._value:
button.configure(style=self.styles["accent_button"],
state="disabled")
else:
button.configure(style=self.styles["button"], state="normal")
def set(self, key: str, notify: bool = True) -> None:
if key not in self._buttons or key == self._value:
return
self._value = key
self._paint()
if notify and self.command is not None:
self.command(key)
def get(self) -> str:
return self._value
def grid(self, **kwargs) -> None: # honour the caller's geometry
self.frame.grid(**kwargs)
def pack(self, **kwargs) -> None:
self.frame.pack(**kwargs)
# ---------------------------------------------------------------------------
# Collapsible panel -- long detail hidden until asked for
# ---------------------------------------------------------------------------
class CollapsiblePanel:
"""A titled section whose body folds away (command preview, details)."""
def __init__(self, parent, styles: Mapping[str, str], title: str,
*, expanded: bool = False,
show_label: str = "Göster",
hide_label: str = "Gizle") -> None:
self.styles = styles
self.show_label = show_label
self.hide_label = hide_label
self.expanded = expanded
self.frame = ttk.Frame(parent, style=styles["panel"])
self.toggle = ttk.Button(self.frame, style=styles["quiet_button"],
command=self.toggle_body)
self.toggle.pack(fill="x")
self.body = ttk.Frame(self.frame, style=styles["panel"])
self._title = title
self._paint()
def _paint(self) -> None:
marker = "▾" if self.expanded else "▸"
action = self.hide_label if self.expanded else self.show_label
self.toggle.configure(text=f"{marker} {self._title} — {action}")
if self.expanded:
self.body.pack(fill="both", expand=True,
pady=(theme.SP_2, 0))
else:
self.body.pack_forget()
def toggle_body(self) -> None:
self.expanded = not self.expanded
self._paint()
def set_expanded(self, expanded: bool) -> None:
if expanded != self.expanded:
self.toggle_body()
def pack(self, **kwargs) -> None:
self.frame.pack(**kwargs)
def grid(self, **kwargs) -> None:
self.frame.grid(**kwargs)
# ---------------------------------------------------------------------------
# Meta grid -- label/value pairs with one shared label column
# ---------------------------------------------------------------------------
def meta_grid(parent, styles: Mapping[str, str],
pairs: Sequence[tuple[str, str]],
label_width: int = 14) -> Any:
"""Two-column fact block (İŞLEM/HEDEF/KAPSAM/ETKİ); labels right-aligned."""
frame = ttk.Frame(parent, style=styles["panel"])
frame.columnconfigure(1, weight=1)
for row, (label, value) in enumerate(pairs):
ttk.Label(frame, text=label, style=styles["field_label"],
width=label_width, anchor="e").grid(
row=row, column=0, sticky="ne", padx=(0, theme.SP_2),
pady=theme.SP_1)
text = ttk.Label(frame, text=value, style=styles["panel_label"],
justify="left")
text.grid(row=row, column=1, sticky="nw", pady=theme.SP_1)
return frame
# ---------------------------------------------------------------------------
# Scrollable row container -- the operation browser lives here
# ---------------------------------------------------------------------------
def scrollable_rows(parent, styles: Mapping[str, str]):
"""A canvas + scrollbar pair with an inner frame rows pack into."""
frame = ttk.Frame(parent, style=styles["panel"])
canvas = tk.Canvas(frame, background=theme.BG_PANEL,
highlightthickness=0, borderwidth=0)
scroll = ttk.Scrollbar(frame, orient="vertical", command=canvas.yview)
canvas.configure(yscrollcommand=scroll.set)
scroll.pack(side="right", fill="y")
canvas.pack(side="left", fill="both", expand=True)
inner = ttk.Frame(canvas, style=styles["panel"])
window = canvas.create_window((0, 0), window=inner, anchor="nw")
def on_inner_configure(_event=None) -> None:
canvas.configure(scrollregion=canvas.bbox("all"))
def on_canvas_configure(event) -> None:
canvas.itemconfigure(window, width=event.width)
inner.bind("<Configure>", on_inner_configure)
canvas.bind("<Configure>", on_canvas_configure)
return frame, inner
# ---------------------------------------------------------------------------
# Elapsed timer -- mm:ss for the running state, no widget of its own
# ---------------------------------------------------------------------------
class Elapsed:
"""Monotonic stopwatch; the drawer polls :meth:`text` via ``after``."""
def __init__(self) -> None:
self._start = 0.0
self._end = 0.0
def start(self) -> None:
self._start = time.monotonic()
self._end = 0.0
def stop(self) -> None:
self._end = time.monotonic()
def seconds(self) -> float:
if not self._start:
return 0.0
return (self._end or time.monotonic()) - self._start
def text(self) -> str:
total = int(self.seconds())
return f"{total // 60:02d}:{total % 60:02d}"
# ---------------------------------------------------------------------------
# Safety dialogs -- the confirm gate and the typed-token gate, as windows
# ---------------------------------------------------------------------------
def _base_dialog(root, title: str, min_width: int = 560):
dialog = tk.Toplevel(root)
dialog.title(title)
dialog.configure(bg=theme.BG_APP)
dialog.transient(root)
dialog.grab_set()
dialog.minsize(min_width, 200)
dialog.bind("<Escape>", lambda _e: dialog.destroy())
return dialog
def _dialog_buttons(dialog, styles: Mapping[str, str],
confirm_label: str, danger: bool,
on_confirm, cancel_label: str = "Vazgeç") -> Any:
row = ttk.Frame(dialog, style=styles["frame"])
row.pack(fill="x", padx=theme.SP_4, pady=theme.SP_4)
cancel = ttk.Button(row, text=cancel_label,
style=styles["quiet_button"],
command=dialog.destroy)
cancel.pack(side="right")
confirm = ttk.Button(
row, text=confirm_label,
style=styles["danger_button"] if danger else styles["accent_button"],
command=on_confirm,
)
confirm.pack(side="right", padx=(0, theme.SP_2))
return confirm, cancel
def ask_confirmation(root, styles: Mapping[str, str], *,
title: str,
operation: str,
target_label: str,
scope_label: str,
effect: str,
preview_lines: Sequence[str],
confirm_label: str,
scope_warning: str | None = None,
danger: bool = False) -> bool:
"""Plan confirmation: OPERATION / TARGET / SCOPE / EFFECT + preview.
Returns True only on an explicit confirm click. For destructive plans
the Cancel control takes the initial focus and the Return key, so Enter
can never confirm destruction; the dialog closes (False) on Escape.
"""
result: dict[str, bool] = {"value": False}
dialog = _base_dialog(root, title)
ttk.Label(dialog, text=title, style=styles["dialog_title"],
wraplength=520, justify="left").pack(
padx=theme.SP_4, pady=(theme.SP_4, theme.SP_2), anchor="w")
if scope_warning:
banner = ttk.Frame(dialog, style=styles["warn_band"])
banner.pack(fill="x", padx=theme.SP_4, pady=(0, theme.SP_2))
inner = ttk.Frame(banner, style=styles["warn_band"])
inner.pack(fill="x", padx=theme.SP_3, pady=theme.SP_2)
ttk.Label(inner, text=scope_warning,
style=styles["warn_label"],
wraplength=480, justify="left").pack(anchor="w")
grid = meta_grid(dialog, styles, (
("İŞLEM", operation),
("HEDEF", target_label),
("KAPSAM", scope_label),
("ETKİ", effect),
))
grid.pack(fill="x", padx=theme.SP_4, pady=(0, theme.SP_2))
preview = CollapsiblePanel(dialog, styles, "Komut önizlemesi",
expanded=danger)
preview.pack(fill="both", expand=True, padx=theme.SP_4)
text = tk.Text(
preview.body,
height=min(12, max(4, len(preview_lines))),
wrap="word",
font=theme.data_font(theme.SIZE_SM),
background=theme.BG_SUNKEN,
foreground=theme.TEXT,
relief="flat",
borderwidth=0,
highlightthickness=0,
padx=theme.SP_3,
pady=theme.SP_2,
cursor="arrow",
takefocus=0,
)
text.pack(fill="both", expand=True)
for line in preview_lines:
text.insert("end", line + "\n")
text.configure(state="disabled")
def confirm() -> None:
result["value"] = True
dialog.destroy()
confirm_btn, cancel_btn = _dialog_buttons(
dialog, styles, confirm_label, danger, confirm)
if danger:
cancel_btn.focus_set() # Enter must never confirm destruction
else:
confirm_btn.focus_set()
dialog.bind("<Return>", lambda _e: confirm() if not danger else None)
dialog.wait_window()
return result["value"]
def ask_token(root, styles: Mapping[str, str], *,
title: str,
message: str,
scope_warning: str | None = None,
confirm_label: str = "Onayla") -> str | None:
"""Typed-token gate: the operator retypes the exact token to proceed.
Empty input or window close aborts (None). The comparison itself stays
with the caller in ``ui_gui.py``; this window only collects the text.
"""
result: dict[str, str | None] = {"value": None}
dialog = _base_dialog(root, title, min_width=480)
ttk.Label(dialog, text=title, style=styles["dialog_title"]).pack(
padx=theme.SP_4, pady=(theme.SP_4, theme.SP_2), anchor="w")
if scope_warning:
banner = ttk.Frame(dialog, style=styles["warn_band"])
banner.pack(fill="x", padx=theme.SP_4, pady=(0, theme.SP_2))
inner = ttk.Frame(banner, style=styles["warn_band"])
inner.pack(fill="x", padx=theme.SP_3, pady=theme.SP_2)
ttk.Label(inner, text=scope_warning,
style=styles["warn_label"],
wraplength=420, justify="left").pack(anchor="w")
ttk.Label(dialog, text=message, style=styles["dialog"],
wraplength=440, justify="left").pack(
padx=theme.SP_4, pady=(0, theme.SP_3), anchor="w")
var = tk.StringVar()
entry = ttk.Entry(dialog, textvariable=var, width=32,
style=styles["entry"])
entry.pack(padx=theme.SP_4, anchor="w")
entry.focus_set()
def confirm() -> None:
result["value"] = var.get().strip() or None
dialog.destroy()
confirm_btn, _cancel_btn = _dialog_buttons(
dialog, styles, confirm_label, False, confirm)
confirm_btn.focus_set()
dialog.bind("<Return>", lambda _e: confirm())
dialog.wait_window()
return result["value"]