"""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("", on_inner_configure) canvas.bind("", 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("", 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("", 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("", lambda _e: confirm()) dialog.wait_window() return result["value"]