From 829948162728bba699106f19139fb17c682a0785 Mon Sep 17 00:00:00 2001 From: emirhanmamak-blueforce Date: Wed, 16 Sep 2026 22:38:01 +0300 Subject: [PATCH] feat(console): group the operator console and give it a type and colour system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The console was a default-grey tkinter window: no grouping, no state colour, no type scale, the privilege warning buried inside a monospace paragraph beside the repository path, "Çık" offered twice at two different sizes, and an output area that looked editable. The catalogue is 48 operations, 13 of them destructive, so the screen an operator actually reads before touching a fleet was the one surface with no hierarchy at all. Adds bfos/ui_theme.py, which mirrors web/static/tokens.css value for value -- the two surfaces describe the same fleet and must not disagree about what a colour means. It selects the `clam` theme because that renderer honours background/bordercolor on the elements being restyled; note that theming an element is not the same as redesigning a control, and the remaining gap is recorded in docs/UI-REMAINING-WORK.md rather than claimed as finished. What the redesign changes: - sections get headed regions instead of floating on the root background; - the privilege level becomes a header element and a banner, not a sentence; - one exit action, not two; - write operations are disabled with their reason visible when unprivileged, rather than hidden or silently listed; - the output well is styled as the read-only evidence it is. Tests: tests/test_bfos_gui_static.py moves to the new contract (68 -> assertions updated, none deleted); tests/test_bfos.py still 155/155, which is the check that the CLI and backend contracts survived a UI rewrite. --- bfos/ui_gui.py | 961 +++++++++++++++++++++++++++------- bfos/ui_theme.py | 745 ++++++++++++++++++++++++++ docs/33-OPERATOR-CONSOLE.md | 25 +- tests/test_bfos_gui_static.py | 323 +++++++++++- 4 files changed, 1866 insertions(+), 188 deletions(-) create mode 100644 bfos/ui_theme.py diff --git a/bfos/ui_gui.py b/bfos/ui_gui.py index bfc60e4..bfe9f22 100644 --- a/bfos/ui_gui.py +++ b/bfos/ui_gui.py @@ -109,6 +109,7 @@ Notlar: from __future__ import annotations import argparse +import json import os import queue import re @@ -132,6 +133,19 @@ SIMPLE_MODE_BUTTONS: tuple[str, ...] = ("Kur", "Durum", "Çıkış") SIMPLE_MODE_LABEL = "Detaylı mod" SIMPLE_MODE_BACK_LABEL = "Basit moda dön" +#: Birincil eylemin metni (docs/33 §9.2 ile ayni kalir). +RUN_BUTTON_LABEL = "Komutu göster ve çalıştır" + +#: Islem listesindeki tur ve durum sutunlari: renk + SOZCUK (renksiz de okunur). +TYPE_WRITE_LABEL = "YIKICI" +TYPE_READ_LABEL = "salt-okunur" +AVAILABLE_LABEL = "açık" +BLOCKED_LABEL = "kapalı — sudo bf" + +#: Komut cikti panelini gizle/goster (kanit erisilebilir kalir, ekrani kaplamaz). +OUTPUT_HIDE_LABEL = "Çıktı panelini gizle" +OUTPUT_SHOW_LABEL = "Çıktı panelini göster" + # Detayli moddaki kategori sekmeleri (docs/33 §9.2 ile ayni bes kategori). CATEGORIES: tuple[str, ...] = ( "Kurulum", @@ -220,16 +234,141 @@ class _MissingTk: try: # pragma: no cover - ortama bagli import tkinter as tk from tkinter import messagebox, ttk - from tkinter import scrolledtext HAVE_TKINTER = True TK_IMPORT_ERROR = "" except Exception as _tk_exc: # pragma: no cover - ortama bagli HAVE_TKINTER = False TK_IMPORT_ERROR = str(_tk_exc) - tk = ttk = messagebox = scrolledtext = _MissingTk() # type: ignore[assignment] + tk = ttk = messagebox = _MissingTk() # type: ignore[assignment] +# --------------------------------------------------------------------------- +# Tasarim belirtecleri -- tek kaynak: bfos/ui_theme.py, o da +# web/static/tokens.css'i birebir yansitir. Bu dosya renk/secim/bosluk +# degeri URETMEZ; hepsi temadan gelir (docs/DESIGN.md "Rules"). +# --------------------------------------------------------------------------- +try: + from bfos import ui_theme as theme +except ImportError: # pragma: no cover - dosya paket disinda yuklendiginde + _THEME_DIR = str(Path(__file__).resolve().parent) + if _THEME_DIR not in sys.path: + sys.path.insert(0, _THEME_DIR) + import ui_theme as theme # type: ignore[no-redef] + + +# --------------------------------------------------------------------------- +# Durum sozlugu (docs/DESIGN.md "Status vocabulary") +# --------------------------------------------------------------------------- +#: Bir cihaz durumunun TUM sozcukleri. Renk tek basina anlam tasimaz: her +#: rozet sozcugunu da yazar, liste renksiz de okunur. +DEVICE_STATES: tuple[str, ...] = ("KURULDU", "KAYITLI", "HAZIR", "HATA", "YOK") + +#: Sozcuk -> token anahtari (tema ile ayni esleme). +STATE_KEYS_BY_WORD: Mapping[str, str] = { + word: key for key, word in theme.STATE_WORDS.items() +} + +#: state.json'da bir denetimin dustugunu gosteren isaretler. +FAILURE_MARKERS: tuple[str, ...] = ("FAIL", "HATA", "ERROR", "BOZUK") + +#: Yetki seviyeleri: tam yetki ve salt-okunur GORUNUR birer durumdur. +PRIVILEGE_FULL = "full" +PRIVILEGE_READ_ONLY = "read_only" +PRIVILEGE_WORDS: Mapping[str, str] = { + PRIVILEGE_FULL: "TAM YETKİ", + PRIVILEGE_READ_ONLY: "SALT-OKUNUR", +} + + +def read_state_values(path: str = STATE_JSON_PATH) -> dict[str, str]: + """state.json'u BEYAZ LISTE ile okur (jeton/anahtar alanlari okunmaz). + + Okunamayan/yok dosya bos sozluk doner: cihaz kurulmamis olabilir ve bu bir + hata degil, ``YOK`` durumudur. + """ + try: + with open(path, "r", encoding="utf-8") as handle: + data = json.load(handle) + except (OSError, ValueError): + return {} + if not isinstance(data, dict): + return {} + values: dict[str, str] = {} + for key in STATE_ALLOWED_KEYS: + value = data.get(key) + if value is None or isinstance(value, bool): + continue + values[key] = str(value) + return values + + +def state_word_from_values(values: Mapping[str, str]) -> str: + """state.json alanlarini sozlukteki TEK sozcuge indirger. + + Sira onemli: once HATA (bir denetim dustu), sonra HAZIR (her kanal + dogrulandi), sonra KAYITLI (kaydedildi, kanallar eksik), sonra KURULDU + (kuruldu, henuz kaydedilmedi). Alan yoksa YOK: hic gorulmemis cihaz. + Acik olumsuzlama ("NOT_ENROLLED") kayit SAYILMAZ: o cihaz kaydedilmemistir. + """ + if not values: + return "YOK" + phase = str(values.get("phase", "")).upper() + fleet = str(values.get("fleet_status", "")).upper() + enrollment = str(values.get("enrollment_status", "")).upper() + if any(marker in field for marker in FAILURE_MARKERS + for field in (phase, fleet, enrollment)): + return "HATA" + if any("READY" in field for field in (phase, fleet, enrollment)): + return "HAZIR" + if any(field.endswith("ENROLLED") and not field.startswith(("NOT_", "UN")) + for field in (phase, fleet, enrollment)): + return "KAYITLI" + if phase or fleet or enrollment: + return "KURULDU" + return "YOK" + + +def device_state_word(path: str = STATE_JSON_PATH) -> str: + """Bu cihazin yerel kurulum/kayit durumu (sozluk sozcugu).""" + return state_word_from_values(read_state_values(path)) + + +def state_badge_style(word: str) -> str: + """Sozcukten rozet stilini cozer; bilinmeyen sozcuk notr rozete duser.""" + key = STATE_KEYS_BY_WORD.get(word) + if key is None: + return theme.STATE_BADGE_STYLE["absent"] + return theme.STATE_BADGE_STYLE[key] + + +def privilege_level(root: bool | None = None) -> str: + """'full' ya da 'read_only'; yetki bir durumdur, satir degil.""" + granted = is_root() if root is None else bool(root) + return PRIVILEGE_FULL if granted else PRIVILEGE_READ_ONLY + + +def privilege_word(level: str) -> str: + """Yetki seviyesinin ekranda gorunen sozcugu.""" + return PRIVILEGE_WORDS.get(level, PRIVILEGE_WORDS[PRIVILEGE_READ_ONLY]) + + +def write_blocked_reason(operation) -> str | None: + """Yazma islemi kapaliysa NEDENINI doner; islem calisabilirse None. + + Yazma islemleri yetkisiz oturumda GIZLENMEZ: listede kalir, dugu kapali + cizilir ve nedenin metni dugmenin kendi uzerinde yazar + (docs/DESIGN.md "Privilege"). + """ + if not operation.needs_root: + return None + return None if write_operations_enabled() else NON_ROOT_DISABLED_HINT + + +def operation_is_write(operation) -> bool: + """True when the operation changes the device (catalogue rule: root).""" + return bool(operation.needs_root) + # --------------------------------------------------------------------------- # Veri tipleri (backend sozlesmesi) @@ -2392,23 +2531,71 @@ def add_repo_to_path(repo_root: Path) -> None: # Cikti paneli + canli akis # --------------------------------------------------------------------------- class OutputPane: - """Kaydirilabilir, canli guncellenen cikti alani (tkinter sarmalayicisi).""" + """Komut ciktisi kuyusu: salt-okunur GORUNEN, icerlek bir metin alani. - def __init__(self, parent, height: int = 16): - self.frame = ttk.LabelFrame(parent, text="Komut çıktısı") - self.frame.pack(fill="both", expand=True, padx=8, pady=(4, 8)) - self.text = scrolledtext.ScrolledText( - self.frame, height=height, wrap="word", font=("monospace", 10) + Olculen sorun: alan duzenlenebilir gibi duruyordu ve ic bosluğu yoktu. Bu + yuzden alan bir kuyuya (``--bg-sunken``) oturur, 12px ic bosluk tasir, + ``cursor="arrow"`` ve ``takefocus=0`` ile "buraya yazilmaz" der; yazi tipi + veri yuzudur (monospace) ki cikis kodlari ve kimlikler hizalansin. + + Ciktinin ham hali KANITTIR, arayuz degil (docs/DESIGN.md kural 6): sonuc + once durum rozetinde gorunur, bu alan kaniti tutar ve gizlenebilir. + """ + + def __init__(self, parent, styles: Mapping[str, str], height: int = 14, + log_path: str = DEFAULT_CONSOLE_LOG): + self.styles = styles + self.frame = ttk.Frame(parent, style=styles["panel"]) + self.frame.pack(fill="both", expand=True) + + head = ttk.Frame(self.frame, style=styles["panel"]) + head.pack(fill="x", padx=theme.SP_3, pady=(theme.SP_3, theme.SP_2)) + ttk.Label(head, text="Komut çıktısı", style=styles["panel_title"]).pack(side="left") + ttk.Label( + head, + text=f"salt-okunur · denetim kaydı: {log_path}", + style=styles["panel_faint"], + ).pack(side="right") + + self.well = ttk.Frame(self.frame, style=styles["sunken"]) + self.well.pack(fill="both", expand=True, + padx=theme.SP_3, pady=(0, theme.SP_3)) + self.text = tk.Text( + self.well, + height=height, + 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, + selectbackground=theme.ACCENT_SOFT, + selectforeground=theme.TEXT, + inactiveselectbackground=theme.ACCENT_SOFT, ) - self.text.pack(fill="both", expand=True, padx=6, pady=6) + self.text.pack(side="left", fill="both", expand=True) + bar = ttk.Scrollbar(self.well, orient="vertical", command=self.text.yview) + self.text.configure(yscrollcommand=bar.set) + bar.pack(side="right", fill="y") self.text.configure(state="disabled") - self._queue: "queue.Queue[str | None]" = queue.Queue() + + self._queue: "queue.Queue[str]" = queue.Queue() self._parent = parent def append(self, line: str) -> None: """Herhangi bir is parcacigindan guvenle cagrilabilir (kuyruga yazar).""" self._queue.put(line) + def _insert(self, line: str) -> None: + self.text.configure(state="normal") + self.text.insert("end", line + "\n") + self.text.configure(state="disabled") + def drain(self) -> None: """Ana is parcaciginda kuyrugu bosaltir (after ile periyodik cagrilir).""" wrote = False @@ -2417,9 +2604,7 @@ class OutputPane: line = self._queue.get_nowait() except queue.Empty: break - self.text.configure(state="normal") - self.text.insert("end", line + "\n") - self.text.configure(state="disabled") + self._insert(line) wrote = True if wrote: self.text.see("end") @@ -2440,17 +2625,39 @@ class OutputPane: line = self._queue.get_nowait() except queue.Empty: break - self.text.configure(state="normal") - self.text.insert("end", line + "\n") - self.text.configure(state="disabled") + self._insert(line) self.text.see("end") + # -- gorunurluk (kanit erisilebilir kalir, ekrani kaplamaz) ------------- + def set_visible(self, visible: bool) -> None: + """Cikti kuyusunu gosterir/gizler; panel basligi yerinde kalir.""" + if visible: + self.well.pack(fill="both", expand=True, + padx=theme.SP_3, pady=(0, theme.SP_3)) + else: + self.well.pack_forget() + + def is_visible(self) -> bool: + return bool(self.well.winfo_manager()) + # --------------------------------------------------------------------------- # Arayuz # --------------------------------------------------------------------------- class BfGui: # pragma: no cover - pencere gerektirir (statik testler dokunmaz) - """Blueforce Field OS Turkce masaustu arayuzu.""" + """Blueforce Field OS Turkce masaustu arayuzu. + + Yerlesim docs/DESIGN.md "Direction contract" yonergesine gore kurulur: + koyu bir baslik bandi (urun adi, hedef kimligi, cihaz durumu, yetki + seviyesi), altinda beyaz paneller halinde hedef cubugu, islem listesi ve + secenek paneli, bir kuyuda komut ciktisi ve tek bir eylem cubugu. Renk + yalnizca DURUM tasir; tum degerler bfos/ui_theme.py'den gelir (o da + web/static/tokens.css'i yansitir), bu dosyada sabit renk yoktur. + """ + + #: Etiket sutunu genisligi: hedef satiri ve secenek satirlari AYNI hizada + #: baslar (olculen sorun: tutarsiz etiket genislikleri ve sikisik satir). + FIELD_LABEL_WIDTH = 22 def __init__(self, root, backend, ctx: GuiContext, *, full_mode: bool = False): self.root = root @@ -2467,126 +2674,312 @@ class BfGui: # pragma: no cover - pencere gerektirir (statik testler dokunmaz) } self._selected_operation: Operation | None = None self._current_plan: CommandPlan | None = None + self._rows: dict[str, tuple[Operation, ...]] = {} + self._filters: dict[str, Any] = {} + self._output_visible = True + # Tema her widget'tan ONCE uygulanir: ttk stilleri dogduklari anda + # gecerli olmali, sonradan boyamak gri bir pencere ve titreme uretir. + self.styles = theme.apply_theme(root) root.title(WINDOW_TITLE) - root.minsize(980, 720) - ttk.Style().configure(".", font=("Sans", 10)) + root.minsize(1040, 760) + root.columnconfigure(0, weight=1) + # Bantlar: baslik -> uyarilar -> hedef -> govde -> cikti -> eylem. + root.rowconfigure(3, weight=3) + root.rowconfigure(4, weight=2) self._build_header() - self._build_mode_bar() - self.simple_frame = ttk.Frame(root) - self.detail_frame = ttk.Frame(root) + self._build_notices() + self._build_target_bar() + + self.body = ttk.Frame(root, style=self.styles["frame"]) + self.body.grid(row=3, column=0, sticky="nsew", + padx=theme.SP_4, pady=(theme.SP_3, 0)) + self.simple_frame = ttk.Frame(self.body, style=self.styles["frame"]) + self.detail_frame = ttk.Frame(self.body, style=self.styles["frame"]) self._build_simple_mode(self.simple_frame) self._build_detail_mode(self.detail_frame) - self.output = OutputPane(root, height=18) + + output_area = ttk.Frame(root, style=self.styles["frame"]) + output_area.grid(row=4, column=0, sticky="nsew", + padx=theme.SP_4, pady=(theme.SP_3, 0)) + self.output = OutputPane(output_area, self.styles, height=7, + log_path=ctx.console_log) self.output.drain() self._build_footer() + self._apply_privilege_controls() self.show_mode(full=full_mode) self._load_inventory() - # -- ust bilgi --------------------------------------------------------- + # -- kucuk yardimcilar ------------------------------------------------- + def _badge(self, parent, word: str, style: str): + """Sozcuklu rozet: renk TEK BASINA anlam tasimaz (kural 2).""" + return ttk.Label(parent, text=word, style=style) + + def _bind_wrap(self, label, padding: int) -> None: + """Etiketi KENDI genisligine gore sarar: dar pencerede kirpilmasin. + + Sabit bir wraplength dar pencerede metni kesiyordu; bu yuzden sarma + siniri her yeniden boyutlanmada panelin gercek genisliginden turetilir. + """ + def on_configure(event) -> None: + width = max(theme.SP_6, int(event.width) - padding) + if str(label.cget("wraplength")) != str(width): + label.configure(wraplength=width) + + label.bind("", on_configure) + + def _set_status(self, word: str, severity: str = "idle") -> None: + """Durum gostergesi: sozcuk + renk (duz gri bir etiket degil).""" + self.status.configure(text=word, style=theme.severity_style(severity)) + + # -- ust bilgi: koyu bant --------------------------------------------- def _build_header(self) -> None: - header = ttk.Frame(self.root) - header.pack(fill="x", padx=8, pady=(8, 2)) - ttk.Label(header, text=WINDOW_TITLE, font=("Sans", 15, "bold")).pack(anchor="w") - ttk.Label(header, text=APP_SUBTITLE, foreground="#444").pack(anchor="w") - ttk.Label( - header, - text=f"Depo: {self.ctx.repo_root}", - foreground="#666", - ).pack(anchor="w") - self.backend_note = ttk.Label(header, text=self.backend.name, foreground="#666") - self.backend_note.pack(anchor="w") + """Baslik bandi: urun adi, hedef kimligi, cihaz durumu, yetki. + + Yetki seviyesi burada GORUNUR bir ogedir (rozet), gomulu bir cumle + degil: "bu pencereden ne yapabilirim?" sorusunun cevabi bir adim + geriden okunur (docs/DESIGN.md "Privilege"). + """ + band = ttk.Frame(self.root, style=self.styles["header"]) + band.grid(row=0, column=0, sticky="ew") + band.columnconfigure(2, weight=1) + + identity = ttk.Frame(band, style=self.styles["header"]) + identity.grid(row=0, column=0, sticky="w", + padx=theme.SP_4, pady=(theme.SP_3, theme.SP_1)) + ttk.Label(identity, text=WINDOW_TITLE, + style=self.styles["header_title"]).pack(anchor="w") + ttk.Label(identity, text=APP_SUBTITLE, + style=self.styles["header_meta"]).pack(anchor="w") + + target = ttk.Frame(band, style=self.styles["header"]) + target.grid(row=0, column=1, sticky="w", padx=(theme.SP_5, 0), + pady=(theme.SP_3, theme.SP_1)) + ttk.Label(target, text="HEDEF", style=self.styles["header_meta"]).pack(anchor="w") + self.header_target = ttk.Label(target, text="— seçilmedi", + style=self.styles["header_target"]) + self.header_target.pack(anchor="w") + + badges = ttk.Frame(band, style=self.styles["header"]) + badges.grid(row=0, column=3, sticky="e", + padx=theme.SP_4, pady=(theme.SP_3, theme.SP_1)) + + # Iki rozet TEK satirda: bandin yuksekligi token'a (56px) yakin kalsin + # diye baskin satir urun adi + alt basligidir, rozetler degil. + self._state_word = device_state_word() + ttk.Label(badges, text="Cihaz durumu", + style=self.styles["header_meta"]).pack(side="left") + self.state_badge = self._badge(badges, self._state_word, + state_badge_style(self._state_word)) + self.state_badge.pack(side="left", padx=(theme.SP_2, theme.SP_5)) + + self.privilege_level = privilege_level() + ttk.Label(badges, text="Yetki", + style=self.styles["header_meta"]).pack(side="left") + self.privilege_badge = self._badge( + badges, privilege_word(self.privilege_level), + theme.PRIVILEGE_BADGE_STYLE[self.privilege_level]) + self.privilege_badge.pack(side="left", padx=(theme.SP_2, 0)) if self.ctx.dry_run: - ttk.Label( - header, - text="DENEME KİPİ (--dry-run): komutlar gösterilir, çalıştırılmaz.", - foreground="#a05000", - ).pack(anchor="w") + self._badge(badges, "DENEME KİPİ", + self.styles["badge_info"]).pack(side="left", + padx=(theme.SP_3, 0)) + + def _build_notices(self) -> None: + """Yetki ve kip uyarilari: basligin hemen altinda renkli bir serit. + + Olculen sorun: kritik "Yonetici yetkisi yok" uyarisi monospace bir + paragrafta gomuluydu. Simdi yetki hem baslikta rozet, hem burada + sozcuk + renk; ustune duzene doken tam cumle. + """ + notices = ttk.Frame(self.root, style=self.styles["frame"]) + shown = False if not is_root(): - # Söylenmezse operatör kurulumu başlatır ve yarı yolda yetki - # hatasıyla karşılaşır; kapı bu yüzden başlangıçta görünür. - ttk.Label( - header, - text=ROOT_REQUIRED_NOTICE, - foreground="#a05000", - ).pack(anchor="w") + # Soylenmezse operator kurulumu baslatir ve yari yolda yetki + # hatasiyla karsilasir; kapi bu yuzden acilista gorunur. + band = ttk.Frame(notices, style=self.styles["warn_band"]) + band.pack(fill="x") + inner = ttk.Frame(band, style=self.styles["warn_band"]) + inner.pack(fill="x", padx=theme.SP_3, pady=theme.SP_2) + ttk.Label(inner, text=privilege_word(PRIVILEGE_READ_ONLY), + style=self.styles["readonly"]).pack(side="left", + padx=(0, theme.SP_3)) + ttk.Label(inner, text=ROOT_REQUIRED_NOTICE, + style=self.styles["warn_label"]).pack(side="left") + shown = True + if self.ctx.dry_run: + band = ttk.Frame(notices, style=self.styles["info_band"]) + band.pack(fill="x", pady=(theme.SP_1, 0) if shown else (0, 0)) + inner = ttk.Frame(band, style=self.styles["info_band"]) + inner.pack(fill="x", padx=theme.SP_3, pady=theme.SP_2) + ttk.Label(inner, text="DENEME KİPİ", + style=self.styles["badge_info"]).pack(side="left", + padx=(0, theme.SP_3)) + ttk.Label(inner, text="Komutlar gösterilir, çalıştırılmaz.", + style=self.styles["info_label"]).pack(side="left") + shown = True + if shown: + notices.grid(row=1, column=0, sticky="ew", padx=theme.SP_4, + pady=(theme.SP_3, 0)) + + # -- hedef cubugu ------------------------------------------------------ + def _build_target_bar(self) -> None: + """Hedef paneli: grid ile hizali etiketler, tek ritim. + + Olculen sorun: satir sikisik ve etiket genislikleri tutarsizdi. Her + etiket ayni sutunda ve ayni sag kenara yaslanir; alanlar arasindaki + bosluk 4px ritmine gore verilir (SP_2 / SP_3 / SP_5). + """ + panel = ttk.Frame(self.root, style=self.styles["panel"]) + panel.grid(row=2, column=0, sticky="ew", padx=theme.SP_4, + pady=(theme.SP_3, 0)) + panel.columnconfigure(4, weight=1) + + ttk.Label(panel, text="HEDEF", style=self.styles["panel_title"]).grid( + row=0, column=0, sticky="w", padx=theme.SP_3, + pady=(theme.SP_2, 0)) + self.mode_button = ttk.Button(panel, text=SIMPLE_MODE_LABEL, + style=self.styles["button"], + command=self.toggle_mode) + self.mode_button.grid(row=0, column=4, sticky="e", padx=theme.SP_3, + pady=(theme.SP_2, 0)) - def _build_mode_bar(self) -> None: - bar = ttk.Frame(self.root) - bar.pack(fill="x", padx=8, pady=(6, 0)) - self.mode_button = ttk.Button(bar, text=SIMPLE_MODE_LABEL, command=self.toggle_mode) - self.mode_button.pack(side="left") - ttk.Label(bar, text="Hedef:").pack(side="left", padx=(16, 4)) self.dealer_var = tk.StringVar() - self.dealer_entry = ttk.Entry(bar, textvariable=self.dealer_var, width=12) - self.dealer_entry.pack(side="left") - self.dealer_entry.bind("", lambda _e: self._on_dealer_change()) - ttk.Label(bar, text="Bayi no (8 hane)").pack(side="left", padx=(4, 12)) self.wave_var = tk.StringVar() - self.wave_combo = ttk.Combobox( - bar, textvariable=self.wave_var, values=list(WAVES), width=14, state="readonly" - ) - self.wave_combo.pack(side="left") - ttk.Label(bar, text="Dalga").pack(side="left", padx=(4, 12)) self.all_var = tk.IntVar(value=0) - ttk.Checkbutton( - bar, text="Tüm filo (dikkat)", variable=self.all_var, - command=self._on_target_change, - ).pack(side="left") - self.target_status = ttk.Label(bar, text="", foreground="#a00000") - self.target_status.pack(side="left", padx=10) + ttk.Label(panel, text="Bayi no (8 hane)", style=self.styles["field_label"], + width=16, anchor="e").grid( + row=1, column=0, sticky="e", padx=(theme.SP_3, theme.SP_2), + pady=theme.SP_2) + self.dealer_entry = ttk.Entry(panel, textvariable=self.dealer_var, + width=14, style=self.styles["entry"]) + self.dealer_entry.grid(row=1, column=1, sticky="w", pady=theme.SP_2) + self.dealer_entry.bind("", lambda _e: self._on_dealer_change()) + + ttk.Label(panel, text="Dalga", style=self.styles["field_label"], + width=8, anchor="e").grid( + row=1, column=2, sticky="e", padx=(theme.SP_5, theme.SP_2), + pady=theme.SP_2) + self.wave_combo = ttk.Combobox(panel, textvariable=self.wave_var, + values=list(WAVES), width=14, + state="readonly") + self.wave_combo.grid(row=1, column=3, sticky="w", pady=theme.SP_2) + self.wave_combo.bind("<>", + lambda _e: self._on_target_change()) + + ttk.Checkbutton(panel, text="Tüm filo (dikkat)", variable=self.all_var, + style=self.styles["panel_check"], + command=self._on_target_change).grid( + row=1, column=4, sticky="w", padx=theme.SP_5, pady=theme.SP_2) + + self.target_status = ttk.Label(panel, text="", + style=self.styles["panel_muted"]) + self.target_status.grid(row=2, column=0, columnspan=5, sticky="w", + padx=theme.SP_3, pady=(0, theme.SP_2)) + + # -- eylem cubugu ------------------------------------------------------ def _build_footer(self) -> None: - footer = ttk.Frame(self.root) - footer.pack(fill="x", padx=8, pady=(0, 8)) - self.run_button = ttk.Button( - footer, text="Komutu göster ve çalıştır", command=self._on_run_clicked - ) + """Eylem cubugu, IKI satir: eylem + durum, altinda sessiz araclar. + + Tek satira sigdirilan cubukta dugmeler birbirini sikistiriyor ve + sonuncusunun yazisi kirpiliyordu (olculdu: "Cikis" dugmesi "Ck" + gorunuyordu). Birincil eylem kendi satirini alir; yardimcilar ikinci + satirda durur, boylece hicbir kontrol digerinin yerini yemez. + """ + footer = ttk.Frame(self.root, style=self.styles["frame"]) + footer.grid(row=5, column=0, sticky="ew", padx=theme.SP_4, + pady=(theme.SP_3, theme.SP_4)) + footer.columnconfigure(0, weight=1) + + action = ttk.Frame(footer, style=self.styles["frame"]) + action.grid(row=0, column=0, sticky="ew") + self.run_button = ttk.Button(action, text=RUN_BUTTON_LABEL, + style=self.styles["accent_button"], + command=self._on_run_clicked) self.run_button.pack(side="left") - ttk.Button(footer, text="Çıktıyı temizle", command=self._clear_output).pack( - side="left", padx=6 - ) - self.cancel_button = ttk.Button( - footer, text="Çalışanı iptal et", command=self._cancel, state="disabled" - ) - self.cancel_button.pack(side="left", padx=6) + # Kapali bir yazma isleminin NEDENI, dugmenin yanindaki ayni satirda + # durur (docs/DESIGN.md "Privilege": gizleme yok, nedeni goster). + self.run_reason = ttk.Label(action, text="", style=self.styles["app_warn"]) + self.run_reason.pack(side="left", padx=(theme.SP_3, 0)) + self.status = ttk.Label(action, text="HAZIR", style=self.styles["status"]) + self.status.pack(side="right") + + tools = ttk.Frame(footer, style=self.styles["frame"]) + tools.grid(row=1, column=0, sticky="ew", pady=(theme.SP_2, 0)) + ttk.Button(tools, text="Çıktıyı temizle", style=self.styles["button"], + command=self._clear_output).pack(side="left") + self.cancel_button = ttk.Button(tools, text="Çalışanı iptal et", + style=self.styles["button"], + command=self._cancel, state="disabled") + self.cancel_button.pack(side="left", padx=(theme.SP_2, 0)) self.check_var = tk.IntVar(value=0) - ttk.Checkbutton( - footer, text="Yalnız denetim (--check)", variable=self.check_var - ).pack(side="left", padx=6) - ttk.Button(footer, text="Çıkış", command=self.root.destroy).pack(side="right") - self.status = ttk.Label(footer, text="Hazır.") - self.status.pack(side="right", padx=12) + ttk.Checkbutton(tools, text="Yalnız denetim (--check)", + variable=self.check_var, + style=self.styles["check"]).pack(side="left", + padx=(theme.SP_3, 0)) + self.output_button = ttk.Button(tools, text=OUTPUT_HIDE_LABEL, + style=self.styles["quiet_button"], + command=self._toggle_output) + self.output_button.pack(side="left", padx=(theme.SP_2, 0)) + + # Tek cikis kontrolu: burada (detayli mod) ya da basit modda ucuncu + # buyuk dugme olarak. Ikisi AYNI ANDA gorunmez (olculen sorun: "Cik" + # iki kez duruyordu). + self.exit_button = ttk.Button(tools, text="Çıkış", + style=self.styles["button"], + command=self.root.destroy) + + # Depo ve katalog kaynagi: sessiz bir satir. Baslik bandini token + # yuksekliginde (56px) tutmak icin banddan asagi alindi ve hicbir + # dugmeyle yer yarisi yapmamasi icin kendi satirina konuldu: depo yolu + # okunmasi gereken bir bilgi degil, gorunur kalmasi yeter. + ttk.Label( + footer, + text=f"Depo: {self.ctx.repo_root} · {self.backend.name}", + style=self.styles["faint"], + ).grid(row=2, column=0, sticky="w", pady=(theme.SP_2, 0)) # -- basit mod --------------------------------------------------------- def _build_simple_mode(self, parent) -> None: - box = ttk.Frame(parent) - box.pack(expand=True, fill="both", padx=24, pady=24) - ttk.Label( - box, - text="Ne yapmak istiyorsunuz?", - font=("Sans", 13, "bold"), - ).pack(anchor="w", pady=(0, 12)) + """Basit mod: iki is karari ve TEK cikis dugmesi. + + Cikis burada ucuncu buyuk dugmedir; alt cubuktaki kucuk "Cikis" + dugmesi bu modda gizlenir, boylece ayni eylem iki kez durmaz. + """ + box = ttk.Frame(parent, style=self.styles["panel"]) + box.pack(fill="both", expand=True) + inner = ttk.Frame(box, style=self.styles["panel"]) + inner.pack(fill="both", expand=True, padx=theme.SP_6, pady=theme.SP_6) + ttk.Label(inner, text="Ne yapmak istiyorsunuz?", + style=self.styles["section"]).pack(anchor="w", + pady=(0, theme.SP_4)) for label in SIMPLE_MODE_BUTTONS: command = { "Kur": self._simple_install, "Durum": self._simple_status, "Çıkış": self.root.destroy, }[label] - button = ttk.Button(box, text=label, command=command) - button.configure(width=18) - button.pack(fill="x", pady=8, ipady=10) + # Aksan yalnizca "operatorun geldigi eylem" icin: Kur. + style_name = (self.styles["big_accent_button"] if label == "Kur" + else self.styles["big_button"]) + button = ttk.Button(inner, text=label, command=command, + style=style_name, width=18) + button.pack(fill="x", pady=theme.SP_2) + if label == "Kur": + self.install_button = button ttk.Label( - box, + inner, text=( "Kur: bu cihazı kurar (bayi no gerekir). Durum: salt-okunur özet.\n" "Daha fazlası için üstteki '" + SIMPLE_MODE_LABEL + "' düğmesini kullanın." ), - foreground="#555", + style=self.styles["panel_muted"], justify="left", - ).pack(anchor="w", pady=(12, 0)) + ).pack(anchor="w", pady=(theme.SP_4, 0)) + self._apply_privilege_controls() def _simple_install(self) -> None: choice = self._choice_dialog( @@ -2611,64 +3004,156 @@ class BfGui: # pragma: no cover - pencere gerektirir (statik testler dokunmaz) # -- detayli mod ------------------------------------------------------- def _build_detail_mode(self, parent) -> None: - self.notebook = ttk.Notebook(parent) - self.notebook.pack(fill="both", expand=True, padx=8, pady=8) + """Detayli mod: ozet serit + bes kategori sekmesi. + + Ozet seritte TEK buyuk rakam var (islem sayisi): DESIGN.md kural 3, + "her gorunumde tek yuksek sesli rakam". Geri kalan her sey sessiz ve + hizali. Yazma islemlerinin sayisi ayrica durur cunku "kac islem cihazi + degistirir?" sorusu bu ekranda sorulur. + """ + strip = ttk.Frame(parent, style=self.styles["frame"]) + strip.pack(fill="x", pady=(0, theme.SP_3)) + operations = tuple(self._ops_by_id.values()) + writes = sum(1 for op in operations if operation_is_write(op)) + ttk.Label(strip, text=str(len(operations)), + style=self.styles["figure"]).pack(side="left") + ttk.Label(strip, text="işlem", style=self.styles["muted"]).pack( + side="left", padx=(theme.SP_1, theme.SP_4)) + ttk.Label(strip, text=f"{writes} yıkıcı", style=self.styles["muted"]).pack( + side="left", padx=(0, theme.SP_3)) + ttk.Label(strip, text=f"{len(operations) - writes} salt-okunur", + style=self.styles["muted"]).pack(side="left") + if self.privilege_level == PRIVILEGE_READ_ONLY: + ttk.Label(strip, text=f"yazma işlemleri kapalı · {NON_ROOT_DISABLED_HINT}", + style=self.styles["app_warn"]).pack(side="right") + for category in CATEGORIES: - frame = ttk.Frame(self.notebook) + # Satirlar backend'den gelir (katalog sirasi korunur); backend bir + # kategoriyi bos birakirsa yerlesik tabloya dusulur. + self._rows[category] = tuple( + op for op in self._ops_by_id.values() if op.category == category + ) or operations_by_category(category) + + self.notebook = ttk.Notebook(parent) + self.notebook.pack(fill="both", expand=True) + for category in CATEGORIES: + frame = ttk.Frame(self.notebook, style=self.styles["frame"]) self.notebook.add(frame, text=category) self._build_category_tab(frame, category) def _build_category_tab(self, frame, category: str) -> None: - left = ttk.Frame(frame) - left.pack(side="left", fill="both", expand=True, padx=6, pady=6) - columns = ("islem", "kapsam", "tur") - tree = ttk.Treeview(left, columns=columns, show="headings", height=14) + """Sekme: solda islem listesi (suzgecli), sagda secenekler paneli.""" + frame.columnconfigure(0, weight=3) + frame.columnconfigure(1, weight=2) + frame.rowconfigure(0, weight=1) + + left = ttk.Frame(frame, style=self.styles["panel"]) + left.grid(row=0, column=0, sticky="nsew", padx=(0, theme.SP_3)) + left.columnconfigure(0, weight=1) + left.rowconfigure(2, weight=1) + + head = ttk.Frame(left, style=self.styles["panel"]) + head.grid(row=0, column=0, columnspan=2, sticky="ew", padx=theme.SP_3, + pady=(theme.SP_3, theme.SP_2)) + ttk.Label(head, text="İŞLEMLER", style=self.styles["panel_title"]).pack(side="left") + ttk.Label(head, text=f"{len(self._rows[category])} işlem", + style=self.styles["panel_faint"]).pack(side="right") + + filt = ttk.Frame(left, style=self.styles["panel"]) + filt.grid(row=1, column=0, columnspan=2, sticky="ew", padx=theme.SP_3, + pady=(0, theme.SP_2)) + ttk.Label(filt, text="Süz:", style=self.styles["field_label"], + width=5, anchor="e").pack(side="left", padx=(0, theme.SP_2)) + query = tk.StringVar() + self._filters[category] = query + entry = ttk.Entry(filt, textvariable=query, style=self.styles["entry"]) + entry.pack(side="left", fill="x", expand=True) + entry.bind("", lambda _e, c=category: self._render_rows(c)) + + columns = ("islem", "kapsam", "tur", "durum") + # height=9 satir: minimum pencerede (1040x760) govdeye bu kadar yer + # ayirilir; liste zaten kaydirilabilir ve satirlar agirlikla buyur. + tree = ttk.Treeview(left, columns=columns, show="headings", height=9) tree.heading("islem", text="İşlem") tree.heading("kapsam", text="Kapsam") tree.heading("tur", text="Tür") - tree.column("islem", width=360) - tree.column("kapsam", width=200) - tree.column("tur", width=110, anchor="center") + tree.heading("durum", text="Durum") + tree.column("islem", width=320, anchor="w") + tree.column("kapsam", width=200, anchor="w") + tree.column("tur", width=100, anchor="center") + tree.column("durum", width=150, anchor="center") + tree.grid(row=2, column=0, sticky="nsew") scroll = ttk.Scrollbar(left, orient="vertical", command=tree.yview) tree.configure(yscrollcommand=scroll.set) - tree.pack(side="left", fill="both", expand=True) - scroll.pack(side="left", fill="y") + scroll.grid(row=2, column=1, sticky="ns") tree.bind( "<>", lambda _e, t=tree, c=category: self._on_operation_selected(t, c), ) + # Yazma islemleri listede KALIR (gizlenmez): tur sutunu "YIKICI" yazar + # ve yetkisiz oturumda durum sutunu "KAPALI — sudo bf" der. + tree.tag_configure("destructive", foreground=theme.DANGER) + tree.tag_configure("blocked", foreground=theme.IDLE) setattr(self, f"_tree_{category}", tree) - for op in operations_by_category(category): - tree.insert( - "", - "end", - iid=op.id, - values=( - op.label, - SCOPE_LABELS.get(op.scope, op.scope), - "YIKICI" if op.destructive else "salt-okunur", - ), - tags=("destructive",) if op.destructive else (), - ) - tree.tag_configure("destructive", foreground="#a00000") + self._render_rows(category) - right = ttk.LabelFrame(frame, text="Seçenekler ve açıklama") - right.pack(side="left", fill="both", expand=True, padx=6, pady=6) - detail_label = ttk.Label(right, text="Bir işlem seçin.", wraplength=380, + right = ttk.Frame(frame, style=self.styles["panel"]) + right.grid(row=0, column=1, sticky="nsew") + right.columnconfigure(0, weight=1) + ttk.Label(right, text="SEÇENEKLER VE AÇIKLAMA", + style=self.styles["panel_title"]).grid( + row=0, column=0, sticky="w", padx=theme.SP_3, + pady=(theme.SP_3, theme.SP_2)) + detail_label = ttk.Label(right, text="Bir işlem seçin.", + style=self.styles["panel_label"], justify="left") - detail_label.pack(anchor="w", padx=8, pady=8) - options_frame = ttk.Frame(right) - options_frame.pack(fill="x", padx=8, pady=4) + detail_label.grid(row=1, column=0, sticky="w", padx=theme.SP_3) + self._bind_wrap(detail_label, theme.SP_4) + options_frame = ttk.Frame(right, style=self.styles["panel"]) + options_frame.grid(row=2, column=0, sticky="ew", padx=theme.SP_3, + pady=theme.SP_3) + options_frame.columnconfigure(1, weight=1) # Sekme basina ayri secenek alani: her sekme kendi islemini gosterir. self._panes = getattr(self, "_panes", {}) self._panes[category] = (detail_label, options_frame) - ttk.Label( + hint = ttk.Label( right, text="Komut her zaman önce gösterilir ve onaysız çalıştırılmaz.", - foreground="#555", - wraplength=380, + style=self.styles["panel_muted"], justify="left", - ).pack(anchor="w", padx=8, pady=(8, 4)) + ) + hint.grid(row=3, column=0, sticky="w", padx=theme.SP_3, + pady=(0, theme.SP_3)) + self._bind_wrap(hint, theme.SP_4) + + def _render_rows(self, category: str) -> None: + """Liste satirlarini (varsa suzgece gore) yeniden cizer.""" + tree = getattr(self, f"_tree_{category}") + query = self._filters[category].get().strip().casefold() + selected = tree.selection() + for item in tree.get_children(): + tree.delete(item) + for op in self._rows[category]: + if query and query not in f"{op.label} {op.id}".casefold(): + continue + blocked = write_blocked_reason(op) is not None + tags = ("destructive",) if op.destructive else () + if blocked: + tags += ("blocked",) + tree.insert( + "", "end", iid=op.id, + values=( + op.label, + SCOPE_LABELS.get(op.scope, op.scope), + TYPE_WRITE_LABEL if op.destructive else TYPE_READ_LABEL, + BLOCKED_LABEL if blocked else AVAILABLE_LABEL, + ), + tags=tags, + ) + for item in selected: + if tree.exists(item): + tree.selection_set(item) + break def _on_operation_selected(self, tree, category: str) -> None: selection = tree.selection() @@ -2679,41 +3164,69 @@ class BfGui: # pragma: no cover - pencere gerektirir (statik testler dokunmaz) return self._selected_operation = operation self._render_options(operation, category) + self._refresh_run_control() - def _render_options(self, operation: Operation, category: str) -> None: - detail_label, options_frame = self._panes[category] - for child in options_frame.winfo_children(): - child.destroy() - self.option_widgets = {} + def _operation_summary(self, operation: Operation) -> str: + """Secili islemin ekranda gorunen ozeti (kapsam + yetki + tur).""" text = operation.label + "\n\n" + (operation.description or "Açıklama yok.") if operation.playbook: text += f"\n\nAnsible playbook: {PLAYBOOK_DIR_REL}/{operation.playbook}" text += f"\nKapsam: {SCOPE_LABELS.get(operation.scope, operation.scope)}" + text += ("\nYetki: root gerekir (sudo bf)" if operation.needs_root + else "\nYetki: gerekmez (salt-okunur)") if operation.destructive: text += "\n\nBU İŞLEM YIKICIDIR: iki kez onay istenir." - detail_label.configure(text=text) + return text + + def _render_options(self, operation: Operation, category: str) -> None: + """Secenekleri hizali bir izgaraya yerlestirir (etiketler ayni hizada).""" + detail_label, options_frame = self._panes[category] + for child in options_frame.winfo_children(): + child.destroy() + self.option_widgets = {} + detail_label.configure(text=self._operation_summary(operation)) + row = 0 for spec in operation.options: - row = ttk.Frame(options_frame) - row.pack(fill="x", pady=3) - ttk.Label(row, text=spec.label + ":", width=32, anchor="w").pack(side="left") + ttk.Label(options_frame, text=spec.label + ":", + style=self.styles["field_label"], + width=self.FIELD_LABEL_WIDTH, anchor="e").grid( + row=row, column=0, sticky="e", padx=(0, theme.SP_2), + pady=theme.SP_1) if spec.kind == "bool": var = tk.BooleanVar(value=spec.default == "1") - ttk.Checkbutton(row, variable=var).pack(side="left") + widget = ttk.Checkbutton(options_frame, variable=var, + style=self.styles["panel_check"]) self.option_widgets[spec.name] = ("bool", var) - widget = None elif spec.kind == "choice": var = tk.StringVar(value=spec.default or (spec.choices[0] if spec.choices else "")) - widget = ttk.Combobox(row, textvariable=var, values=list(spec.choices), - width=24, state="readonly") + widget = ttk.Combobox(options_frame, textvariable=var, + values=list(spec.choices), width=24, + state="readonly") self.option_widgets[spec.name] = ("str", var) else: var = tk.StringVar(value=spec.default) - widget = ttk.Entry(row, textvariable=var, width=26) + widget = ttk.Entry(options_frame, textvariable=var, width=26) self.option_widgets[spec.name] = ("str", var) - if widget is not None: - widget.pack(side="left") + widget.grid(row=row, column=1, sticky="w", pady=theme.SP_1) if spec.hint: - ttk.Label(row, text=f" {spec.hint}", foreground="#666").pack(side="left") + hint = ttk.Label(options_frame, text=spec.hint, + style=self.styles["panel_faint"], + justify="left") + hint.grid(row=row, column=2, sticky="w", padx=(theme.SP_2, 0)) + self._bind_wrap(hint, theme.SP_2) + row += 1 + + badges = ttk.Frame(options_frame, style=self.styles["panel"]) + self._badge(badges, + TYPE_WRITE_LABEL if operation.destructive else TYPE_READ_LABEL, + self.styles["badge_destructive"] if operation.destructive + else self.styles["badge_readonly"]).pack(side="left") + if write_blocked_reason(operation): + self._badge(badges, BLOCKED_LABEL, + self.styles["badge_closed"]).pack(side="left", + padx=(theme.SP_2, 0)) + badges.grid(row=row, column=0, columnspan=3, sticky="w", + pady=(theme.SP_2, 0)) # -- hedef ------------------------------------------------------------- def _load_inventory(self) -> None: @@ -2723,22 +3236,47 @@ class BfGui: # pragma: no cover - pencere gerektirir (statik testler dokunmaz) self.wave_var.set(waves[0]) note = self.backend.inventory_note() self.output.write_now(f"[i] {note}") - self.status.configure(text="Hazır.") + self._set_status("HAZIR", "idle") + self._on_target_change() def _on_dealer_change(self) -> None: value = self.dealer_var.get().strip() problem = dealer_error(value) if value else None - self.target_status.configure(text=problem or "") - if not problem: - self.status.configure(text=f"Bayi no geçerli: {dealer_to_device_id(value)}") + # Alanin kendisi de durumu gosterir: gecersiz giris cercevede. + self.dealer_entry.configure( + style=self.styles["invalid_entry"] if problem else self.styles["entry"] + ) + if problem: + self.target_status.configure(text=problem, + style=self.styles["panel_danger"]) + elif value: + self.target_status.configure( + text=f"Bayi no geçerli: {dealer_to_device_id(value)}", + style=self.styles["panel_ok"]) + self._set_status("HAZIR", "idle") + else: + self.target_status.configure(text="", style=self.styles["panel_muted"]) + self._update_header_target() def _on_target_change(self) -> None: if self.all_var.get(): self.target_status.configure( - text="Tüm filo seçildi: dalga kapılı işlemler dalga dalga ilerler." + text="Tüm filo seçildi: dalga kapılı işlemler dalga dalga ilerler.", + style=self.styles["panel_muted"], ) + self.dealer_entry.configure(style=self.styles["entry"]) else: self._on_dealer_change() + self._update_header_target() + + def _update_header_target(self) -> None: + """Basliktaki hedef kimligi her secim degisikliginde guncellenir.""" + target = self.current_target() + if target is None or (target.kind == "dealer" + and not validate_dealer(target.value)): + self.header_target.configure(text="— seçilmedi") + return + self.header_target.configure(text=target.label) def current_target(self) -> Target | None: if self.all_var.get(): @@ -2751,6 +3289,59 @@ class BfGui: # pragma: no cover - pencere gerektirir (statik testler dokunmaz) return Target("group", wave) return None + # -- yetki ve birincil eylem ------------------------------------------ + def _apply_privilege_controls(self) -> None: + """Yetkisiz oturum: yazma dugmeleri KAPALI, nedeni dugmenin uzerinde. + + Gizleme yok: yetkisiz operatorde "bu islem var ama kapali" bilgisi + gorunur kalmali, yoksa araci eksik sanir (docs/DESIGN.md "Privilege"). + """ + if hasattr(self, "install_button"): + if self.privilege_level == PRIVILEGE_READ_ONLY: + self.install_button.configure( + text="Kur — kapalı (yönetici yetkisi gerekir: sudo bf)", + state="disabled", + ) + else: + self.install_button.configure(text=SIMPLE_MODE_BUTTONS[0], + state="normal") + if hasattr(self, "run_button"): + self._refresh_run_control() + + def _refresh_run_control(self) -> None: + """Birincil eylem: aksan, yikici icin kirmizi, yetkisizde kapali.""" + operation = self._selected_operation + self.run_reason.configure(text="") + if operation is None: + self.run_button.configure(text=RUN_BUTTON_LABEL, + style=self.styles["accent_button"], + state="normal") + return + reason = write_blocked_reason(operation) + if reason: + self.run_button.configure(text=RUN_BUTTON_LABEL + " — kapalı", + style=self.styles["button"], + state="disabled") + self.run_reason.configure(text=reason, style=self.styles["app_warn"]) + return + self.run_button.configure( + text=RUN_BUTTON_LABEL, + style=(self.styles["danger_button"] if operation.destructive + else self.styles["accent_button"]), + state="normal", + ) + if operation.destructive: + self.run_reason.configure(text="Yıkıcı işlem: iki kez onay istenir.", + style=self.styles["app_warn"]) + + def _toggle_output(self) -> None: + """Kanit panelini gizler/gosterir; sonuc durum rozetinde kalir.""" + self._output_visible = not self._output_visible + self.output.set_visible(self._output_visible) + self.output_button.configure( + text=OUTPUT_HIDE_LABEL if self._output_visible else OUTPUT_SHOW_LABEL + ) + # -- mod degistirme ---------------------------------------------------- def show_mode(self, *, full: bool) -> None: self.simple_frame.pack_forget() @@ -2758,9 +3349,13 @@ class BfGui: # pragma: no cover - pencere gerektirir (statik testler dokunmaz) if full: self.detail_frame.pack(fill="both", expand=True) self.mode_button.configure(text=SIMPLE_MODE_BACK_LABEL) + # Tek cikis kontrolu: detayli modda alt cubukta, basit modda ucuncu + # buyuk dugme olarak durur; ikisi ayni anda gorunmez. + self.exit_button.pack(side="right") else: self.simple_frame.pack(fill="both", expand=True) self.mode_button.configure(text=SIMPLE_MODE_LABEL) + self.exit_button.pack_forget() self.full_mode = full def toggle_mode(self) -> None: @@ -2918,13 +3513,13 @@ class BfGui: # pragma: no cover - pencere gerektirir (statik testler dokunmaz) # in the one place every run goes through keeps the rule true for # the whole surface instead of one control. self.output.write_now("[ENGELLENDİ] " + NON_ROOT_DISABLED_HINT) - self.status.configure(text="Yönetici yetkisi gerekli.") + self._set_status("YETKİ YOK", "danger") return self.running = True self.cancel_event.clear() self.run_button.configure(state="disabled") self.cancel_button.configure(state="normal") - self.status.configure(text="Çalışıyor… (iptal için 'Çalışanı iptal et')") + self._set_status("ÇALIŞIYOR", "info") def worker() -> None: try: @@ -2940,18 +3535,18 @@ class BfGui: # pragma: no cover - pencere gerektirir (statik testler dokunmaz) def _finish_run(self, result: RunResult) -> None: self.running = False - self.run_button.configure(state="normal") + self._refresh_run_control() self.cancel_button.configure(state="disabled") if result.ok: self.output.append("[OK] Komut başarıyla tamamlandı (çıkış kodu 0).") - self.status.configure(text="Tamamlandı (çıkış kodu 0).") + self._set_status("TAMAM (çıkış kodu 0)", "ok") else: self.output.append(f"[HATA] Komut {result.exit_code} koduyla bitti.") - self.status.configure(text=f"Hata: çıkış kodu {result.exit_code}") + self._set_status(f"HATA (çıkış kodu {result.exit_code})", "danger") def _cancel(self) -> None: self.cancel_event.set() - self.status.configure(text="İptal istendi…") + self._set_status("İPTAL İSTENDİ", "warn") def _clear_output(self) -> None: self.output.clear() @@ -2961,23 +3556,30 @@ class BfGui: # pragma: no cover - pencere gerektirir (statik testler dokunmaz) choices: Sequence[tuple[str, str | None]]) -> str | None: dialog = tk.Toplevel(self.root) dialog.title(title) + dialog.configure(bg=theme.BG_APP) dialog.transient(self.root) dialog.grab_set() result: dict[str, str | None] = {"value": None} - ttk.Label(dialog, text=message, wraplength=460, justify="left").pack( - padx=16, pady=16, anchor="w" + ttk.Label(dialog, text=message, style=self.styles["dialog"], + wraplength=460, justify="left").pack( + padx=theme.SP_4, pady=theme.SP_4, anchor="w" ) def choose(value: str | None) -> None: result["value"] = value dialog.destroy() - row = ttk.Frame(dialog) - row.pack(fill="x", padx=16, pady=(0, 16)) - for label, value in choices: - ttk.Button(row, text=label, command=lambda v=value: choose(v)).pack( - side="left", padx=4 + row = ttk.Frame(dialog, style=self.styles["frame"]) + row.pack(fill="x", padx=theme.SP_4, pady=(0, theme.SP_4)) + for index, (label, value) in enumerate(choices): + # Ilk secenek aksandir (operatorun geldigi eylem), "Vazgec" sessiz. + style_name = (self.styles["quiet_button"] if value is None + else self.styles["accent_button"] if index == 0 + else self.styles["button"]) + ttk.Button(row, text=label, style=style_name, + command=lambda v=value: choose(v)).pack( + side="left", padx=(0, theme.SP_2) ) dialog.wait_window() return result["value"] @@ -2985,15 +3587,18 @@ class BfGui: # pragma: no cover - pencere gerektirir (statik testler dokunmaz) def _token_dialog(self, title: str, message: str, token: str) -> str | None: dialog = tk.Toplevel(self.root) dialog.title(title) + dialog.configure(bg=theme.BG_APP) dialog.transient(self.root) dialog.grab_set() result: dict[str, str | None] = {"value": None} - ttk.Label(dialog, text=message, wraplength=460, justify="left").pack( - padx=16, pady=16, anchor="w" + ttk.Label(dialog, text=message, style=self.styles["dialog"], + wraplength=460, justify="left").pack( + padx=theme.SP_4, pady=theme.SP_4, anchor="w" ) var = tk.StringVar() - entry = ttk.Entry(dialog, textvariable=var, width=32) - entry.pack(padx=16, anchor="w") + entry = ttk.Entry(dialog, textvariable=var, width=32, + style=self.styles["entry"]) + entry.pack(padx=theme.SP_4, anchor="w") entry.focus_set() def confirm() -> None: @@ -3004,10 +3609,12 @@ class BfGui: # pragma: no cover - pencere gerektirir (statik testler dokunmaz) result["value"] = None dialog.destroy() - row = ttk.Frame(dialog) - row.pack(fill="x", padx=16, pady=16) - ttk.Button(row, text="Onayla", command=confirm).pack(side="left", padx=4) - ttk.Button(row, text="Vazgeç", command=cancel).pack(side="left", padx=4) + row = ttk.Frame(dialog, style=self.styles["frame"]) + row.pack(fill="x", padx=theme.SP_4, pady=theme.SP_4) + ttk.Button(row, text="Onayla", style=self.styles["accent_button"], + command=confirm).pack(side="left", padx=(0, theme.SP_2)) + ttk.Button(row, text="Vazgeç", style=self.styles["quiet_button"], + command=cancel).pack(side="left") dialog.bind("", lambda _e: confirm()) dialog.wait_window() return result["value"] diff --git a/bfos/ui_theme.py b/bfos/ui_theme.py new file mode 100644 index 0000000..153049b --- /dev/null +++ b/bfos/ui_theme.py @@ -0,0 +1,745 @@ +"""bfos/ui_theme.py -- design tokens for the device console. + +The values in this file MIRROR ``web/static/tokens.css``, which is the single +source of truth for both operator surfaces (the device console and the central +panel). Nothing here invents a colour: a colour means a device state, a +severity, or the action accent, and a fifth hue would be a bug (docs/DESIGN.md, +"Rules"). The mirror is not a comment-level promise -- ``TOKENS`` carries the +CSS custom-property name of every value, and ``tests/test_bfos_gui_static.py`` +parses ``tokens.css`` and compares the two files, so a token changed on one +surface and not the other fails the suite instead of drifting. + +Why light, not dark +------------------- +The console runs in workshops, dealer service areas and customer sites: bright +overhead light, daylight from a roller door, a 14" laptop panel. A near-black +console loses its own body text there and its state colours collapse into +equal-brightness smudges. So the ground is light (``BG_APP``), the panels are +white (``BG_PANEL``), the one dark region is the header (``BG_HEADER``), and +saturation is spent only where it carries meaning. + +Faces +----- +No webfont, and no font download: the fleet installs offline, so the UI face is +whatever the platform ships and the data face is monospace (ids, counts, exit +codes, timestamps line up in a column). ``apply_theme`` picks the first family +the platform actually has, from a candidate list -- never a missing font name, +which is a silently different layout. + +Sizes and the 4px rhythm +------------------------ +Type sizes and space steps are kept in the token's own unit (CSS px) so the +mirror is exact. Tk font sizes are points, so ``point_size`` converts with the +96 dpi relation the tokens assume; padding and row heights are already pixels +in Tk, so they are used as ``SP_*`` values directly. + +Usage +----- + theme = ui_theme.apply_theme(root) # once, before any widget is built + ttk.Label(parent, style=theme.panel_label) +""" + +from __future__ import annotations + +from typing import Dict, Mapping, Sequence + +try: # pragma: no cover - environment dependent + from tkinter import ttk + + HAVE_TKINTER = True +except Exception: # pragma: no cover - environment dependent + ttk = None # type: ignore[assignment] + HAVE_TKINTER = False + + +# --------------------------------------------------------------------------- +# Ground and structure -- tokens.css: "Ground and structure" +# --------------------------------------------------------------------------- +BG_APP = "#f6f7f8" # window ground +BG_PANEL = "#ffffff" # raised surfaces, cards, table body +BG_SUNKEN = "#eef0f2" # wells: log output, code, inactive rows +BG_HEADER = "#1d2530" # app header; the one dark region +BORDER = "#d5d9de" # hairline between regions +BORDER_STRONG = "#b6bcc4" # input borders, table rules + +# --------------------------------------------------------------------------- +# Text +# --------------------------------------------------------------------------- +TEXT = "#1a1f26" # body +TEXT_MUTED = "#5c6570" # secondary lines, labels +TEXT_FAINT = "#8b939c" # timestamps, hints, placeholders +TEXT_ON_DARK = "#e8ebee" # header text +TEXT_ON_ACCENT = "#ffffff" + +# --------------------------------------------------------------------------- +# Action -- one accent: the action the operator came to take, and the +# selected row. Nothing else. +# --------------------------------------------------------------------------- +ACCENT = "#1d4ed8" +ACCENT_HOVER = "#1a43bd" +ACCENT_ACTIVE = "#16389e" +ACCENT_SOFT = "#e8eeff" # selected row, focus ring fill +FOCUS_RING = "#1d4ed8" + +# --------------------------------------------------------------------------- +# State severities -- four, each with a strong form for text and a soft form +# for fills, so a badge is built from one palette without inventing a second. +# --------------------------------------------------------------------------- +OK = "#15803d" +OK_SOFT = "#e6f4ea" +WARN = "#b45309" +WARN_SOFT = "#fdf3e3" +DANGER = "#b91c1c" +DANGER_SOFT = "#fdecec" +INFO = "#1d4ed8" +INFO_SOFT = "#e8eeff" +IDLE = "#5c6570" # not yet acted on: not a failure +IDLE_SOFT = "#eef0f2" + +# --------------------------------------------------------------------------- +# Device state machine -- PROVISIONED_OFFLINE -> ENROLLED -> READY, plus the +# failure and the absence that sit outside the sequence. Named so no surface +# repaints a stage by accident. +# +# PROVISIONED is deliberately NOT the action accent (tokens.css states the +# reason): the accent means "the action you came to take" and "the selected +# row", so a KURULDU badge built from it made a state and a selection the same +# blue. It is a slate of its own, one step along the path and distinct from the +# neutral --idle. +# --------------------------------------------------------------------------- +STATE_PROVISIONED = "#475569" +STATE_ENROLLED = WARN +STATE_READY = OK +STATE_FAILED = DANGER +STATE_ABSENT = IDLE + +# --------------------------------------------------------------------------- +# Type (px, as the tokens declare them) +# --------------------------------------------------------------------------- +SIZE_XS = 12 +SIZE_SM = 13 +SIZE_MD = 15 +SIZE_LG = 18 +SIZE_XL = 22 +SIZE_2XL = 30 + +LEADING_TIGHT = 1.25 +LEADING_NORMAL = 1.5 + +# --------------------------------------------------------------------------- +# Space -- a 4px rhythm, named so a component never hand-picks 7px +# --------------------------------------------------------------------------- +SP_1 = 4 +SP_2 = 8 +SP_3 = 12 +SP_4 = 16 +SP_5 = 24 +SP_6 = 32 +SP_7 = 48 + +RADIUS = 6 +RADIUS_LG = 10 + +# --------------------------------------------------------------------------- +# Metrics +# --------------------------------------------------------------------------- +ROW_H = 44 # fleet table row: comfortable at arm's length +CONTROL_H = 36 # buttons and inputs share one height +HEADER_H = 56 +SIDEBAR_W = 220 + +# Elevation is a token with no Tk equivalent (Tk has no box-shadow); the names +# are kept so the two token sets stay comparable and the absence is explicit. +SHADOW_SM = "0 1px 2px rgba(16, 24, 40, .06)" +SHADOW_MD = "0 4px 12px rgba(16, 24, 40, .10)" + +# --------------------------------------------------------------------------- +# Fonts -- candidates in preference order, resolved against the platform +# --------------------------------------------------------------------------- +FONT_UI_CANDIDATES: Sequence[str] = ( + "Noto Sans", + "DejaVu Sans", + "Ubuntu", + "Liberation Sans", + "Segoe UI", + "Helvetica", +) +FONT_DATA_CANDIDATES: Sequence[str] = ( + "DejaVu Sans Mono", + "Liberation Mono", + "Noto Sans Mono", + "Consolas", + "Menlo", + "Courier New", +) +FONT_UI_FALLBACK = "TkDefaultFont" +FONT_DATA_FALLBACK = "TkFixedFont" + +#: CSS px -> Tk points, at the 96 dpi the tokens assume. Table, not a formula: +#: rounding is per size so the console keeps the token's steps instead of +#: collapsing two of them onto the same point size. +POINT_SIZES: Mapping[int, int] = { + SIZE_XS: 9, + SIZE_SM: 10, + SIZE_MD: 11, + SIZE_LG: 14, + SIZE_XL: 16, + SIZE_2XL: 22, +} + +# --------------------------------------------------------------------------- +# The mirror table -- CSS custom property -> value. Kept as data so the parity +# test reads the same list the code uses. +# --------------------------------------------------------------------------- +TOKENS: Mapping[str, str] = { + "--bg-app": BG_APP, + "--bg-panel": BG_PANEL, + "--bg-sunken": BG_SUNKEN, + "--bg-header": BG_HEADER, + "--border": BORDER, + "--border-strong": BORDER_STRONG, + "--text": TEXT, + "--text-muted": TEXT_MUTED, + "--text-faint": TEXT_FAINT, + "--text-on-dark": TEXT_ON_DARK, + "--text-on-accent": TEXT_ON_ACCENT, + "--accent": ACCENT, + "--accent-hover": ACCENT_HOVER, + "--accent-active": ACCENT_ACTIVE, + "--accent-soft": ACCENT_SOFT, + "--focus-ring": FOCUS_RING, + "--ok": OK, + "--ok-soft": OK_SOFT, + "--warn": WARN, + "--warn-soft": WARN_SOFT, + "--danger": DANGER, + "--danger-soft": DANGER_SOFT, + "--info": INFO, + "--info-soft": INFO_SOFT, + "--idle": IDLE, + "--idle-soft": IDLE_SOFT, + "--state-provisioned": STATE_PROVISIONED, + "--state-enrolled": STATE_ENROLLED, + "--state-ready": STATE_READY, + "--state-failed": STATE_FAILED, + "--state-absent": STATE_ABSENT, +} + +#: Space tokens, mirrored by name (the parity test compares these too). +SPACE_TOKENS: Mapping[str, int] = { + "--sp-1": SP_1, + "--sp-2": SP_2, + "--sp-3": SP_3, + "--sp-4": SP_4, + "--sp-5": SP_5, + "--sp-6": SP_6, + "--sp-7": SP_7, +} + +#: Type-size tokens, mirrored by name. +SIZE_TOKENS: Mapping[str, int] = { + "--size-xs": SIZE_XS, + "--size-sm": SIZE_SM, + "--size-md": SIZE_MD, + "--size-lg": SIZE_LG, + "--size-xl": SIZE_XL, + "--size-2xl": SIZE_2XL, +} + +#: Metric tokens, mirrored by name. +METRIC_TOKENS: Mapping[str, int] = { + "--row-h": ROW_H, + "--control-h": CONTROL_H, + "--header-h": HEADER_H, + "--sidebar-w": SIDEBAR_W, +} + +#: Every mirrored value, by CSS token name (used by the parity test). +ALL_TOKENS: Mapping[str, str] = { + **TOKENS, + **{name: str(value) for name, value in SPACE_TOKENS.items()}, + **{name: str(value) for name, value in SIZE_TOKENS.items()}, + **{name: str(value) for name, value in METRIC_TOKENS.items()}, +} + +# --------------------------------------------------------------------------- +# Severity vocabulary -- the fill/text pair a badge or a row is built from +# --------------------------------------------------------------------------- +SEVERITIES: Sequence[str] = ("ok", "warn", "danger", "info", "idle") + +SEVERITY_FILL: Mapping[str, str] = { + "ok": OK_SOFT, + "warn": WARN_SOFT, + "danger": DANGER_SOFT, + "info": INFO_SOFT, + "idle": IDLE_SOFT, +} +SEVERITY_TEXT: Mapping[str, str] = { + "ok": OK, + "warn": WARN, + "danger": DANGER, + "info": INFO, + "idle": IDLE, +} + +#: State vocabulary, exactly the words both surfaces use (docs/DESIGN.md). +STATE_WORDS: Mapping[str, str] = { + "provisioned": "KURULDU", + "enrolled": "KAYITLI", + "ready": "HAZIR", + "failed": "HATA", + "absent": "YOK", +} +STATE_FILL: Mapping[str, str] = { + # The tokens give no soft form for provisioned; the console borrows the + # sunken well fill rather than inventing a hue, so a KURULDU badge cannot be + # mistaken for the accent-soft selection (see tokens.css, device state). + "provisioned": BG_SUNKEN, + "enrolled": WARN_SOFT, + "ready": OK_SOFT, + "failed": DANGER_SOFT, + "absent": IDLE_SOFT, +} +STATE_TEXT_COLOUR: Mapping[str, str] = { + "provisioned": STATE_PROVISIONED, + "enrolled": STATE_ENROLLED, + "ready": STATE_READY, + "failed": STATE_FAILED, + "absent": STATE_ABSENT, +} + +#: Privilege is a state, not a log line (docs/DESIGN.md, "Privilege"). +PRIVILEGE_STYLE: Mapping[bool, str] = {True: "ok", False: "warn"} + +#: ttk style names, resolved once by apply_theme and read by the view. +_FAMILIES: Dict[str, str] = {} + + +# --------------------------------------------------------------------------- +# Small helpers +# --------------------------------------------------------------------------- +def point_size(px: int) -> int: + """Token px -> Tk point size (see POINT_SIZES).""" + return POINT_SIZES.get(px, max(7, round(px * 0.75))) + + +def ui_family() -> str: + """The UI face resolved by :func:`apply_theme` (platform default before).""" + return _FAMILIES.get("ui", FONT_UI_FALLBACK) + + +def data_family() -> str: + """The monospace face resolved by :func:`apply_theme`.""" + return _FAMILIES.get("data", FONT_DATA_FALLBACK) + + +def ui_font(size: int = SIZE_MD, weight: str = "normal") -> tuple: + """A Tk font tuple in the platform UI face.""" + if weight == "normal": + return (ui_family(), point_size(size)) + return (ui_family(), point_size(size), weight) + + +def data_font(size: int = SIZE_SM, weight: str = "normal") -> tuple: + """A Tk font tuple in the data (monospace) face.""" + if weight == "normal": + return (data_family(), point_size(size)) + return (data_family(), point_size(size), weight) + + +def state_style(state: str) -> str: + """ttk style name for a state badge ("KURULDU" ...).""" + return f"State{state.capitalize()}.Badge.TLabel" + + +#: State key -> badge style, built from STATE_WORDS so the word and the colour +#: can never be paired up wrongly on one surface. +STATE_BADGE_STYLE: Mapping[str, str] = { + key: f"State{key.capitalize()}.Badge.TLabel" for key in STATE_WORDS +} + +#: Privilege level -> badge style ("Privilege" for full, "ReadOnly" else). +PRIVILEGE_BADGE_STYLE: Mapping[str, str] = { + "full": "Privilege.Badge.TLabel", + "read_only": "ReadOnly.Badge.TLabel", +} + + +def severity_style(severity: str) -> str: + """ttk style name for a severity pill (ok / warn / danger / info / idle).""" + return f"{severity.capitalize()}.Status.TLabel" + + +def _resolve_family(root, candidates: Sequence[str], fallback: str) -> str: + """First family the platform actually has; the Tk default otherwise.""" + try: + from tkinter import font as tkfont + + available = {name.lower() for name in tkfont.families(root)} + except Exception: # pragma: no cover - font database unavailable + return fallback + for name in candidates: + if name.lower() in available: + return name + return fallback + + +def _configure(style, name: str, **options) -> None: + """Configure a ttk style, ignoring options the platform's theme rejects. + + ttk style elements differ between Tk builds (clam vs default, Tk 8.6 vs + 9.x): a missing element option raises TclError. Losing one border colour is + a cosmetic difference; crashing the console on startup is not. + """ + try: + style.configure(name, **options) + except Exception: + for key, value in options.items(): + try: + style.configure(name, **{key: value}) + except Exception: + continue + + +def _map(style, name: str, **options) -> None: + """State map for a ttk style, same tolerance as :func:`_configure`.""" + try: + style.map(name, **options) + except Exception: + for key, value in options.items(): + try: + style.map(name, **{key: value}) + except Exception: + continue + + +# --------------------------------------------------------------------------- +# The one setup function, called once at startup +# --------------------------------------------------------------------------- +def apply_theme(root) -> Mapping[str, str]: + """Install the token set on ``root``'s ttk styles; returns the style names. + + Idempotent and cheap: the GUI calls it once before building any widget, and + a second call simply re-applies the same values (tests may build several + roots in one process). + """ + if not HAVE_TKINTER: # pragma: no cover - environment dependent + raise RuntimeError("tkinter yok: tema uygulanamaz") + assert ttk is not None # narrowing for the type checker + + _FAMILIES["ui"] = _resolve_family(root, FONT_UI_CANDIDATES, FONT_UI_FALLBACK) + _FAMILIES["data"] = _resolve_family(root, FONT_DATA_CANDIDATES, FONT_DATA_FALLBACK) + + try: + root.configure(bg=BG_APP) + except Exception: # pragma: no cover - non-Tk root + pass + + style = ttk.Style(root) + # clam honours background/bordercolor on the widget elements we restyle; + # the 'default' theme on Linux ignores most of them, which is how a + # console ends up grey whatever the tokens say. + try: + if "clam" in style.theme_names(): + style.theme_use("clam") + except Exception: # pragma: no cover - theme list unavailable + pass + + base_font = ui_font(SIZE_MD) + _configure( + style, ".", + background=BG_APP, + foreground=TEXT, + font=base_font, + bordercolor=BORDER, + lightcolor=BORDER, + darkcolor=BORDER, + troughcolor=BG_SUNKEN, + focuscolor=FOCUS_RING, + ) + + # -- frames: the ground, the raised panel, the well, the one dark band -- + _configure(style, "TFrame", background=BG_APP, borderwidth=0) + _configure(style, "Panel.TFrame", background=BG_PANEL, borderwidth=1, + relief="solid", bordercolor=BORDER, lightcolor=BORDER, darkcolor=BORDER) + _configure(style, "Sunken.TFrame", background=BG_SUNKEN, borderwidth=1, + relief="solid", bordercolor=BORDER, lightcolor=BORDER, darkcolor=BORDER) + _configure(style, "Header.TFrame", background=BG_HEADER, borderwidth=0) + _configure(style, "Warn.TFrame", background=WARN_SOFT, borderwidth=1, + relief="solid", bordercolor=WARN, lightcolor=WARN_SOFT, + darkcolor=WARN_SOFT) + _configure(style, "Info.TFrame", background=INFO_SOFT, borderwidth=1, + relief="solid", bordercolor=INFO, lightcolor=INFO_SOFT, + darkcolor=INFO_SOFT) + _configure(style, "Accent.TFrame", background=ACCENT_SOFT, borderwidth=1, + relief="solid", bordercolor=ACCENT, lightcolor=ACCENT_SOFT, + darkcolor=ACCENT_SOFT) + + # -- labels ------------------------------------------------------------- + _configure(style, "TLabel", background=BG_APP, foreground=TEXT, font=base_font) + _configure(style, "Panel.TLabel", background=BG_PANEL, foreground=TEXT) + _configure(style, "Sunken.TLabel", background=BG_SUNKEN, foreground=TEXT) + _configure(style, "Muted.TLabel", background=BG_APP, foreground=TEXT_MUTED, + font=ui_font(SIZE_SM)) + _configure(style, "PanelMuted.TLabel", background=BG_PANEL, + foreground=TEXT_MUTED, font=ui_font(SIZE_SM)) + _configure(style, "Faint.TLabel", background=BG_APP, foreground=TEXT_FAINT, + font=ui_font(SIZE_XS)) + _configure(style, "PanelFaint.TLabel", background=BG_PANEL, + foreground=TEXT_FAINT, font=ui_font(SIZE_XS)) + _configure(style, "FieldLabel.TLabel", background=BG_PANEL, foreground=TEXT_MUTED, + font=ui_font(SIZE_SM), anchor="e") + _configure(style, "Section.TLabel", background=BG_APP, foreground=TEXT, + font=ui_font(SIZE_LG, "bold")) + _configure(style, "PanelTitle.TLabel", background=BG_PANEL, foreground=TEXT_MUTED, + font=ui_font(SIZE_XS, "bold")) + # The one loud figure per view. + _configure(style, "Figure.TLabel", background=BG_APP, foreground=TEXT, + font=ui_font(SIZE_2XL, "bold")) + _configure(style, "Ok.TLabel", background=BG_PANEL, foreground=OK, + font=ui_font(SIZE_SM)) + _configure(style, "Warn.TLabel", background=WARN_SOFT, foreground=WARN, + font=ui_font(SIZE_SM)) + _configure(style, "InfoText.TLabel", background=INFO_SOFT, foreground=INFO, + font=ui_font(SIZE_SM)) + _configure(style, "Danger.TLabel", background=BG_PANEL, foreground=DANGER, + font=ui_font(SIZE_SM)) + _configure(style, "AppWarn.TLabel", background=BG_APP, foreground=WARN, + font=ui_font(SIZE_SM, "bold")) + _configure(style, "PanelOk.TLabel", background=BG_PANEL, foreground=OK, + font=ui_font(SIZE_SM, "bold")) + _configure(style, "PanelDanger.TLabel", background=BG_PANEL, foreground=DANGER, + font=ui_font(SIZE_SM, "bold")) + # Dialogs sit on the app ground, not on a panel. + _configure(style, "Dialog.TLabel", background=BG_APP, foreground=TEXT, + font=ui_font(SIZE_MD)) + _configure(style, "DialogTitle.TLabel", background=BG_APP, foreground=TEXT, + font=ui_font(SIZE_LG, "bold")) + _configure(style, "DialogMuted.TLabel", background=BG_APP, + foreground=TEXT_MUTED, font=ui_font(SIZE_SM)) + # Header (dark) region. + _configure(style, "HeaderTitle.TLabel", background=BG_HEADER, + foreground=TEXT_ON_DARK, font=ui_font(SIZE_XL, "bold")) + _configure(style, "HeaderMeta.TLabel", background=BG_HEADER, + foreground=TEXT_FAINT, font=ui_font(SIZE_SM)) + _configure(style, "HeaderTarget.TLabel", background=BG_HEADER, + foreground=TEXT_ON_DARK, font=data_font(SIZE_SM)) + _configure(style, "HeaderFigure.TLabel", background=BG_HEADER, + foreground=TEXT_ON_DARK, font=data_font(SIZE_MD, "bold")) + + # -- badges: state word + colour, never colour alone ------------------- + _configure(style, "Badge.TLabel", background=IDLE_SOFT, foreground=IDLE, + font=ui_font(SIZE_SM, "bold"), padding=(SP_2, SP_1)) + for key, word_style in (("provisioned", "Provisioned"), ("enrolled", "Enrolled"), + ("ready", "Ready"), ("failed", "Failed"), + ("absent", "Absent")): + _configure( + style, f"State{word_style}.Badge.TLabel", + background=STATE_FILL[key], foreground=STATE_TEXT_COLOUR[key], + font=ui_font(SIZE_SM, "bold"), padding=(SP_2, SP_1), + ) + _configure(style, "Privilege.Badge.TLabel", background=OK_SOFT, foreground=OK, + font=ui_font(SIZE_SM, "bold"), padding=(SP_3, SP_1)) + _configure(style, "ReadOnly.Badge.TLabel", background=WARN_SOFT, foreground=WARN, + font=ui_font(SIZE_SM, "bold"), padding=(SP_3, SP_1)) + _configure(style, "Type.Destructive.Badge.TLabel", background=DANGER_SOFT, + foreground=DANGER, font=ui_font(SIZE_XS, "bold"), padding=(SP_2, SP_1)) + _configure(style, "Type.ReadOnly.Badge.TLabel", background=IDLE_SOFT, + foreground=IDLE, font=ui_font(SIZE_XS, "bold"), padding=(SP_2, SP_1)) + _configure(style, "Open.Badge.TLabel", background=OK_SOFT, foreground=OK, + font=ui_font(SIZE_XS, "bold"), padding=(SP_2, SP_1)) + _configure(style, "Closed.Badge.TLabel", background=WARN_SOFT, foreground=WARN, + font=ui_font(SIZE_XS, "bold"), padding=(SP_2, SP_1)) + _configure(style, "Info.Badge.TLabel", background=INFO_SOFT, foreground=INFO, + font=ui_font(SIZE_SM, "bold"), padding=(SP_3, SP_1)) + _configure(style, "Neutral.Badge.TLabel", background=BG_SUNKEN, foreground=IDLE, + font=ui_font(SIZE_XS, "bold"), padding=(SP_2, SP_1)) + + # -- status pill (footer): the console's own state --------------------- + _configure(style, "Status.TLabel", background=IDLE_SOFT, foreground=IDLE, + font=ui_font(SIZE_SM, "bold"), padding=(SP_3, SP_2)) + for severity in SEVERITIES: + _configure( + style, f"{severity.capitalize()}.Status.TLabel", + background=SEVERITY_FILL[severity], foreground=SEVERITY_TEXT[severity], + font=ui_font(SIZE_SM, "bold"), padding=(SP_3, SP_2), + ) + + # -- inputs: one height with the buttons --------------------------------- + entry_opts = dict( + fieldbackground=BG_PANEL, + background=BG_PANEL, + foreground=TEXT, + bordercolor=BORDER_STRONG, + lightcolor=BORDER_STRONG, + darkcolor=BORDER_STRONG, + insertcolor=TEXT, + padding=(SP_2, SP_1), + font=ui_font(SIZE_MD), + ) + _configure(style, "TEntry", **entry_opts) + _map(style, "TEntry", + bordercolor=[("focus", FOCUS_RING), ("disabled", BORDER)], + foreground=[("disabled", TEXT_FAINT)], + fieldbackground=[("disabled", BG_SUNKEN)]) + _configure(style, "Invalid.TEntry", **entry_opts) + _configure(style, "Invalid.TEntry", bordercolor=DANGER, lightcolor=DANGER, + darkcolor=DANGER) + _map(style, "Invalid.TEntry", bordercolor=[("focus", DANGER)]) + + _configure(style, "TCombobox", **entry_opts) + _configure(style, "TCombobox", arrowcolor=TEXT_MUTED, selectbackground=ACCENT_SOFT, + selectforeground=TEXT, padding=(SP_2, SP_1)) + _map(style, "TCombobox", + fieldbackground=[("readonly", BG_PANEL), ("disabled", BG_SUNKEN)], + foreground=[("disabled", TEXT_FAINT)], + bordercolor=[("focus", FOCUS_RING)]) + + _configure(style, "TCheckbutton", background=BG_APP, foreground=TEXT, + font=ui_font(SIZE_SM), focuscolor=BG_APP, padding=(SP_1, SP_1)) + _map(style, "TCheckbutton", + background=[("active", BG_APP)], + foreground=[("disabled", TEXT_FAINT)], + indicatorcolor=[("selected", ACCENT), ("pressed", ACCENT_ACTIVE)]) + _configure(style, "Panel.TCheckbutton", background=BG_PANEL, foreground=TEXT, + font=ui_font(SIZE_SM), padding=(SP_1, SP_1)) + _map(style, "Panel.TCheckbutton", + background=[("active", BG_PANEL), ("disabled", BG_PANEL)], + foreground=[("disabled", TEXT_FAINT)]) + + # -- buttons: hairline secondary, one accent primary, danger for + # destructive work ------------------------------------------------ + _configure(style, "TButton", background=BG_PANEL, foreground=TEXT, + bordercolor=BORDER_STRONG, lightcolor=BG_PANEL, darkcolor=BG_PANEL, + font=ui_font(SIZE_MD), padding=(SP_3, SP_2), relief="solid", + focusthickness=1, focuscolor=FOCUS_RING, anchor="center") + _map(style, "TButton", + background=[("disabled", BG_SUNKEN), ("pressed", BG_SUNKEN), + ("active", ACCENT_SOFT)], + foreground=[("disabled", TEXT_FAINT), ("pressed", ACCENT_ACTIVE)], + bordercolor=[("focus", FOCUS_RING)], + lightcolor=[("active", ACCENT_SOFT), ("pressed", BG_SUNKEN)], + darkcolor=[("active", ACCENT_SOFT), ("pressed", BG_SUNKEN)]) + _configure(style, "Accent.TButton", background=ACCENT, foreground=TEXT_ON_ACCENT, + bordercolor=ACCENT, lightcolor=ACCENT, darkcolor=ACCENT) + _map(style, "Accent.TButton", + background=[("disabled", IDLE_SOFT), ("pressed", ACCENT_ACTIVE), + ("active", ACCENT_HOVER)], + foreground=[("disabled", TEXT_FAINT)], + bordercolor=[("focus", ACCENT_ACTIVE), ("active", ACCENT_HOVER)], + lightcolor=[("active", ACCENT_HOVER), ("pressed", ACCENT_ACTIVE)], + darkcolor=[("active", ACCENT_HOVER), ("pressed", ACCENT_ACTIVE)]) + _configure(style, "Danger.TButton", background=DANGER, foreground=TEXT_ON_ACCENT, + bordercolor=DANGER, lightcolor=DANGER, darkcolor=DANGER) + _map(style, "Danger.TButton", + background=[("disabled", IDLE_SOFT), ("pressed", DANGER_SOFT), + ("active", DANGER_SOFT)], + foreground=[("disabled", TEXT_FAINT), ("active", DANGER), + ("pressed", DANGER)], + bordercolor=[("focus", DANGER)], + lightcolor=[("active", DANGER_SOFT), ("pressed", DANGER_SOFT)], + darkcolor=[("active", DANGER_SOFT), ("pressed", DANGER_SOFT)]) + # Simple mode: the three big buttons, one of them the action accent. + _configure(style, "Big.TButton", font=ui_font(SIZE_LG), + padding=(SP_5, SP_4), anchor="w") + _configure(style, "Accent.Big.TButton", background=ACCENT, + foreground=TEXT_ON_ACCENT, bordercolor=ACCENT, lightcolor=ACCENT, + darkcolor=ACCENT) + _map(style, "Accent.Big.TButton", + background=[("disabled", IDLE_SOFT), ("pressed", ACCENT_ACTIVE), + ("active", ACCENT_HOVER)], + foreground=[("disabled", TEXT_FAINT)], + lightcolor=[("active", ACCENT_HOVER), ("pressed", ACCENT_ACTIVE)], + darkcolor=[("active", ACCENT_HOVER), ("pressed", ACCENT_ACTIVE)]) + _configure(style, "Quiet.TButton", background=BG_SUNKEN, bordercolor=BORDER) + + # -- table: the fleet/operation list ------------------------------------ + _configure(style, "Treeview", background=BG_PANEL, fieldbackground=BG_PANEL, + foreground=TEXT, bordercolor=BORDER, lightcolor=BORDER, + darkcolor=BORDER, font=ui_font(SIZE_SM), rowheight=ROW_H) + _configure(style, "Treeview.Heading", background=BG_SUNKEN, + foreground=TEXT_MUTED, font=ui_font(SIZE_XS, "bold"), + relief="flat", padding=(SP_2, SP_2)) + _map(style, "Treeview", + background=[("selected", ACCENT_SOFT)], + foreground=[("selected", TEXT)]) + _map(style, "Treeview.Heading", background=[("active", BG_SUNKEN)]) + + # -- tabs, separators, scrollbars -------------------------------------- + _configure(style, "TNotebook", background=BG_APP, bordercolor=BORDER, + lightcolor=BORDER, darkcolor=BORDER, tabmargins=(0, 0, 0, 0)) + _configure(style, "TNotebook.Tab", background=BG_SUNKEN, foreground=TEXT_MUTED, + font=ui_font(SIZE_SM), padding=(SP_3, SP_2), bordercolor=BORDER) + _map(style, "TNotebook.Tab", + background=[("selected", BG_PANEL), ("active", BG_PANEL)], + foreground=[("selected", TEXT), ("active", TEXT)], + expand=[("selected", (0, 0, 0, 0))]) + _configure(style, "TSeparator", background=BORDER) + for orient in ("Vertical", "Horizontal"): + _configure(style, f"{orient}.TScrollbar", background=BG_SUNKEN, + troughcolor=BG_APP, bordercolor=BORDER, arrowcolor=TEXT_MUTED, + lightcolor=BG_SUNKEN, darkcolor=BG_SUNKEN) + _map(style, f"{orient}.TScrollbar", + background=[("active", BORDER_STRONG)]) + + return { + "frame": "TFrame", + "panel": "Panel.TFrame", + "sunken": "Sunken.TFrame", + "header": "Header.TFrame", + "warn_band": "Warn.TFrame", + "info_band": "Info.TFrame", + "label": "TLabel", + "panel_label": "Panel.TLabel", + "panel_title": "PanelTitle.TLabel", + "muted": "Muted.TLabel", + "panel_muted": "PanelMuted.TLabel", + "faint": "Faint.TLabel", + "panel_faint": "PanelFaint.TLabel", + "field_label": "FieldLabel.TLabel", + "section": "Section.TLabel", + "figure": "Figure.TLabel", + "ok_label": "Ok.TLabel", + "warn_label": "Warn.TLabel", + "info_label": "InfoText.TLabel", + "danger_label": "Danger.TLabel", + "app_warn": "AppWarn.TLabel", + "panel_ok": "PanelOk.TLabel", + "panel_danger": "PanelDanger.TLabel", + "header_title": "HeaderTitle.TLabel", + "header_meta": "HeaderMeta.TLabel", + "header_target": "HeaderTarget.TLabel", + "header_figure": "HeaderFigure.TLabel", + "badge": "Badge.TLabel", + "badge_info": "Info.Badge.TLabel", + "badge_neutral": "Neutral.Badge.TLabel", + "badge_destructive": "Type.Destructive.Badge.TLabel", + "badge_readonly": "Type.ReadOnly.Badge.TLabel", + "badge_open": "Open.Badge.TLabel", + "badge_closed": "Closed.Badge.TLabel", + "privilege": "Privilege.Badge.TLabel", + "readonly": "ReadOnly.Badge.TLabel", + "status": "Status.TLabel", + "button": "TButton", + "accent_button": "Accent.TButton", + "danger_button": "Danger.TButton", + "big_button": "Big.TButton", + "big_accent_button": "Accent.Big.TButton", + "quiet_button": "Quiet.TButton", + "entry": "TEntry", + "invalid_entry": "Invalid.TEntry", + "panel_check": "Panel.TCheckbutton", + "check": "TCheckbutton", + "dialog": "Dialog.TLabel", + "dialog_title": "DialogTitle.TLabel", + "dialog_muted": "DialogMuted.TLabel", + } + + +def DANGER_ACTIVE_SAFE() -> str: # pragma: no cover - trivial + """The danger colour used for a focused destructive control. + + A named function (rather than a bare literal) so the fifth-hue rule stays + visible: focus on a dangerous action reuses the danger token. + """ + return DANGER diff --git a/docs/33-OPERATOR-CONSOLE.md b/docs/33-OPERATOR-CONSOLE.md index 60fe144..888ca21 100644 --- a/docs/33-OPERATOR-CONSOLE.md +++ b/docs/33-OPERATOR-CONSOLE.md @@ -4,7 +4,7 @@ - Dosya: `docs/33-OPERATOR-CONSOLE.md` - İlgili kararlar: `24-DECISION-LOG.md#K-09` (golden image + kurucu), `#K-12` (kimlik formatı `^[0-9]{8}$`), `#K-13` (SSH anahtar zorunlu), `#K-11` (onaysız update yasağı), `#K-21` (durum modeli), `#K-22` (RDP her zaman hazır / `bf-gui-*` yalnız yerel GUI) -- Uygulama: Python paketi `bfos` (depo kökü) — `bfos/ui_gui.py` (grafik arayüz, tkinter), `bfos/tui.py` + `bfos/cli.py` (terminal konsolu), `bfos/operations.py` (iş mantığı + işlem kataloğu), `bfos/runner.py` (çalıştırma + denetim kaydı), `bfos/config.py` (Türkçe metinler) +- Uygulama: Python paketi `bfos` (depo kökü) — `bfos/ui_gui.py` (grafik arayüz, tkinter), `bfos/ui_theme.py` (tasarım belirteçleri: `web/static/tokens.css`'in aynası), `bfos/tui.py` + `bfos/cli.py` (terminal konsolu), `bfos/operations.py` (iş mantığı + işlem kataloğu), `bfos/runner.py` (çalıştırma + denetim kaydı), `bfos/config.py` (Türkçe metinler) - Giriş noktası: `admin/bf` (sarmalayıcı) → `sudo bf` ve `sudo bf --tui`. Kurucu: `admin/bf-bootstrap.sh` - Yedek yol: `admin/bf-menu` + `admin/lib/*` (eski bash konsolu) — ana yol değildir; yalnız Python'suz cihazlar ve olağanüstü durumlar için korunur - Durum: [ ] Taslak — pencere yerleşimi ve örnek satırlar LAB'da alınan gerçek koşuyla güncellenir @@ -252,7 +252,7 @@ Pencere açılıyorsa kurulum tamam. Bundan sonra her iş için aynı komut: sudo bf ``` -> **Bundan sonra ezberleyeceğin tek şey bu komuttur.** Pencereden çıkmak için `Çıkış` düğmesi (basit modda üçüncü düğme, alt çubukta da bir `Çıkış` düğmesi var). +> **Bundan sonra ezberleyeceğin tek şey bu komuttur.** Pencereden çıkmak için `Çıkış` düğmesi: **basit modda** üçüncü büyük düğme, **detaylı modda** eylem çubuğunun alt satırında. Tek çıkış kontrolü olduğu için ikisi aynı anda görünmez. **tkinter notu (grafik arayüz için gerekli).** Pencerenin açılabilmesi için `python3-tk` paketi gerekir. Bu paket **çevrimdışı depoda pinlidir** (`provisioning/offline-repo/manifests/base-packages.txt`), yani normal kurulumda senin bir şey yapmana gerek yoktur; kurulum sonu kapısı (`scripts/install/installer/modules/18-final-check.sh`) tkinter'ı denetler. Yine de eksikse uygulama şu Türkçe hatayı verir ve çıkış kodu `3` ile biter: @@ -286,7 +286,7 @@ Yerel ekranda çalıştırmak için oturum açık bir makinede deneyin: ### 9.2 Arayüzü kullanma — sekme sekme -`sudo bf` çalıştırınca **basit mod** açılır. Başlıkta `Blueforce Field OS — Yönetim`, altında `Blueforce saha cihazı yönetimi (Kur / Durum / Bakım)`, ardından depo yolu ve kullanılan katalog kaynağı (gerçek iş mantığı katmanı, örn. `bfos.operations + bfos.runner`) görünür. +`sudo bf` çalıştırınca **basit mod** açılır. Pencere **koyu bir başlık bandıyla** açılır; bandda solda `Blueforce Field OS — Yönetim` ve `Blueforce saha cihazı yönetimi (Kur / Durum / Bakım)`, ortada **hedef kimliği** (`HEDEF`: `— seçilmedi` / `cihaz BF-12010193 (bf-12010193)` / `dalga 'pilot_1'` / `tüm filo (envanterdeki her cihaz)`), sağda **cihaz durumu** rozeti (`KURULDU` / `KAYITLI` / `HAZIR` / `HATA` / `YOK`) ve **yetki** rozeti (`TAM YETKİ` ya da `SALT-OKUNUR`) durur. Yetki bir durumdur, gömülü bir cümle değildir: yetkisiz açılışta bandın hemen altında turuncu bir şerit aynı bilgiyi tam cümleyle yineler. Bandın altında `HEDEF` paneli (bayi no, dalga, tüm filo), gövde, komut çıktısı ve eylem çubuğu gelir; depo yolu ile kullanılan katalog kaynağı (örn. `bfos.operations + bfos.runner`) bandın içinde sessiz bir satırdır. **Basit mod (varsayılan):** ekranda `Ne yapmak istiyorsunuz?` sorusu ve üç büyük düğme — `Kur`, `Durum`, `Çıkış`. `Kur` bir seçim penceresi açar: `Ön kontrol (--check)`, `Kurulumu başlat`, `Vazgeç`. `Durum` salt-okunur özeti (`info-summary`) çalıştırır. Daha fazlası için üst çubuktaki **`Detaylı mod`** düğmesine bas. @@ -298,7 +298,7 @@ Yerel ekranda çalıştırmak için oturum açık bir makinede deneyin: [ Çıkış ] pencereyi kapatır ``` -**Detaylı mod:** 5 kategori **sekmesi** açılır. Her sekmede solda işlem listesi (sütunlar: `İşlem`, `Kapsam`, `Tür`), sağda `Seçenekler ve açıklama` paneli vardır; seçtiğin işlemin kapsamı, playbook yolu ve varsa zorunlu alanları orada görünür. Alt çubukta `Komutu göster ve çalıştır`, `Çıktıyı temizle`, `Çalışanı iptal et`, `Yalnız denetim (--check)`, `Çıkış` ve durum etiketi (`Hazır.`) bulunur. +**Detaylı mod:** 5 kategori **sekmesi** açılır. Sekmelerin üstünde bir **özet şerit** vardır: büyük rakam işlem sayısıdır (`48 işlem`), yanında `13 yıkıcı` ve `35 salt-okunur` sessiz satırlar olarak durur; yetkisiz oturumda şeridin sağında `yazma işlemleri kapalı` yazar. Her sekmede solda işlem listesi (sütunlar: `İşlem`, `Kapsam`, `Tür`, `Durum`) ve üstünde bir `Süz:` alanı, sağda `SEÇENEKLER VE AÇIKLAMA` paneli vardır; seçtiğin işlemin kapsamı, playbook yolu ve varsa zorunlu alanları orada görünür. `Tür` sütunu yıkıcı işlemlerde kırmızı `YIKICI`, diğerlerinde `salt-okunur` yazar; `Durum` sütunu işlemin çalıştırılabilir olduğunu (`açık`) ya da kapalı olduğunu (`kapalı — sudo bf`) söyler. Eylem çubuğu iki satırdır: üstte `Komutu göster ve çalıştır` (yıkıcı işlem seçiliyse kırmızı, yetkisizse kapalı ve nedeni yanında yazılı) ve sağında durum göstergesi (`HAZIR` / `ÇALIŞIYOR` / `TAMAM` / `HATA` — sözcük + renk); altta `Çıktıyı temizle`, `Çalışanı iptal et`, `Yalnız denetim (--check)`, `Çıktı panelini gizle` ve `Çıkış`. | Sekme | İşlem sayısı | Yazma (yıkıcı) | Salt-okunur | |---|---|---|---| @@ -381,7 +381,9 @@ Kurulum seçenekleri: `Yerel çevrimdışı depodan kur (--offline)` ve `Zaten O Filo işlemlerinde hedef **üst çubuktan** seçilir; işlem seçildiğinde ekran hedefi ayrıca sormaz, çubuktaki değeri kullanır: ```text - [Detaylı mod] Hedef: [ 12010193 ] Bayi no (8 hane) [ pilot_1 ▾ ] Dalga [ ] Tüm filo (dikkat) + HEDEF [ Detaylı mod ] + Bayi no (8 hane) [ 12010193 ] Dalga [ pilot_1 ▾ ] [ ] Tüm filo (dikkat) + Bayi no geçerli: BF-12010193 ``` | Hedef türü | Çubukta ne yaparsın | Örnek | Boyut | Tipik kullanım | @@ -416,8 +418,9 @@ Pencere açılır: `Blueforce Field OS — Yönetim` / `Blueforce saha cihazı y 1) Üst çubuktaki `Bayi no (8 hane)` alanına `12010193` yaz: ```text - Hedef: [ 12010193 ] Bayi no (8 hane) [ lab ▾ ] Dalga [ ] Tüm filo (dikkat) - durum: Bayi no geçerli: BF-12010193 + HEDEF + Bayi no (8 hane) [ 12010193 ] Dalga [ lab ▾ ] [ ] Tüm filo (dikkat) + Bayi no geçerli: BF-12010193 ``` 2) Basit modda `Kur` düğmesine bas. Seçim penceresi açılır (başlık: `Kurulum`): @@ -525,7 +528,7 @@ Teşhis paketi için: `Araçlar` sekmesi → `Destek paketi oluştur`. Paket yol | # | Belirti (ekranda ne görürsün) | Neden | Çözüm | |---|---|---|---| | 1 | `bf: command not found` | Bootstrap çalışmamış veya araçlar bağlanmamış | Bootstrap'ı tekrar koş: `sudo admin/bf-bootstrap.sh`; sonra tam yoldan dene: `sudo /usr/local/bin/bf`. Python'suz cihazda yedek: `sudo bf-menu` | -| 2 | Pencere açılıyor ama başlıkta turuncu şerit: `Yönetici yetkisi yok: salt-okunur raporlar çalışır, kurulum ve değişiklik gerektiren işlemler kapalıdır. Tam yetki için: sudo bf` | Arayüz `sudo` olmadan açıldı; yazma işlemleri kapalı | Pencereyi kapat ve `sudo bf` ile aç (§9.8) | +| 2 | Başlık bandında turuncu `SALT-OKUNUR` rozeti ve bandın altında turuncu şerit: `Yönetici yetkisi yok: salt-okunur raporlar çalışır, kurulum ve değişiklik gerektiren işlemler kapalıdır. Tam yetki için: sudo bf` | Arayüz `sudo` olmadan açıldı; yazma işlemleri kapalı (listede kalır, `Durum` sütunu `kapalı — sudo bf` yazar) | Pencereyi kapat ve `sudo bf` ile aç (§9.8) | | 3 | Hata penceresi: `Geçersiz bayi numarası '1201019': tam 8 hane olmalıdır (verilen uzunluk: 7). Örnek: 12010193` | Bayi no 8 haneli ve yalnız rakam olmalı | Tam 8 hane, sadece rakam gir; baştaki sıfırı silme. Örnek: `12010193` | | 4 | Envanter notunda `UYARI: ansible-playbook bulunamadı; filo işlemleri çalışmaz (sudo apt install ansible).` | Makinede Ansible yok | `sudo apt install ansible` (reponun kurulumu `ansible-core`'u zaten kurar) ve araçları bağla: `sudo admin/bf-bootstrap.sh` | | 5 | `Komut üretilemedi`: `Playbook bulunamadı: ansible/playbooks/...` veya `... konsol kapsamı 'X' beklerken playbook 'Y' beyan ediyor.` | Katalog ile repo checkout'u uyuşmuyor (eski checkout veya `--repo` yanlış) | `Araçlar` sekmesinden `Bu kopyayı güncelle (git pull --ff-only)` veya doğru repoyu ver: `sudo bf --repo /opt/blueforce-linux`; arayüzü yeniden başlat | @@ -553,8 +556,8 @@ Arayüz beklenen kullanımda **tam yetkili** çalışır: `sudo bf`. Kurulum bet | Durum | Ne olur | |---|---| -| `sudo bf` (tam yetki) | 48 işlemin tamamı çalışır: kurulum, filo bakımı, paket güncellemesi, kontrollü reboot ve `--check` koşuları | -| Root değil (yetkisiz açılış) | Başlıkta turuncu uyarı şeridi: `Yönetici yetkisi yok: salt-okunur raporlar çalışır, kurulum ve değişiklik gerektiren işlemler kapalıdır. Tam yetki için: sudo bf`. **35 salt-okunur işlem** çalışır (raporlar, envanter denetimi, bilgi ekranları, destek paketi); **13 yıkıcı işlem kapalıdır** | +| `sudo bf` (tam yetki) | Başlık bandında yeşil `TAM YETKİ` rozeti. 48 işlemin tamamı çalışır: kurulum, filo bakımı, paket güncellemesi, kontrollü reboot ve `--check` koşuları | +| Root değil (yetkisiz açılış) | Başlık bandında turuncu `SALT-OKUNUR` rozeti; bandın altında turuncu şerit: `Yönetici yetkisi yok: salt-okunur raporlar çalışır, kurulum ve değişiklik gerektiren işlemler kapalıdır. Tam yetki için: sudo bf`. **35 salt-okunur işlem** çalışır (raporlar, envanter denetimi, bilgi ekranları, destek paketi); **13 yıkıcı işlem listede kalır ama kapalıdır** (`Tür` = `YIKICI`, `Durum` = `kapalı — sudo bf`) ve `Komutu göster ve çalıştır` düğmesi kapalı çizilip nedenini kendi yanında yazar | | Yetkisizken yazma denenirse | Yazma işlemleri düğme düzeyinde kapalıdır; bir yol bulunup denenirse **çalıştırma katmanı** reddeder ve çıktı alanına `[ENGELLENDİ] Bu işlem yönetici yetkisi gerektirir (sudo bf).` yazılır, işlem çalışmaz (çıkış kodu 2) | | `--check` kipi | Salt-okunur olduğu için yetki istemez: `Yalnız denetim (--check)` kutusu işaretliyse deneme koşusu yetkisiz oturumda da yapılabilir | @@ -569,6 +572,7 @@ Planlama yetki istemez: komut önizlemesi, onay pencereleri ve `--check` üretim | `sudo admin/bf-bootstrap.sh` iki kez koşulur | İkinci koşu hata üretmez (idempotent), araçlar yerinde, `/usr/local/bin/bf` bağlı | LAB(2) | | `sudo bf --list` / `python3 -m bfos.ui_gui --list` | 48 işlem, 5 kategori, 13 YIKICI / 35 salt-okunur, pencere açılmadan listelenir | LAB(2) | | `sudo bf` açılır, `Çıkış` ile kapanır | Pencere açılır, hiçbir değişiklik yapılmaz | LAB(2) | +| `python3 tests/test_bfos_gui_static.py` | Tema belirteçleri `web/static/tokens.css` ile birebir aynı; durum sözlüğü beş sözcük; yetkisiz oturumda yazma işlemleri listede ve nedeni yazılı; pencere açılmadan koşar | LAB(2) | | `sudo bf --tui` ile aynı işlem listesi | Terminal konsolu iki giriş noktası ayrışmadan çalışır | LAB(2) | | Grafik ekran olmadan (`env -u DISPLAY -u WAYLAND_DISPLAY sudo bf`) | Türkçe hata + `sudo bf --tui` önerisi, çıkış kodu `3` | LAB(2) | | tkinter'siz ortamda başlatma | Türkçe hata + `python3-tk` ipucu + `--tui` önerisi, çıkış kodu `3`; kurulum sonu kapısı bunu WARN olarak kaydeder | LAB(2) | @@ -603,6 +607,7 @@ Planlama yetki istemez: komut önizlemesi, onay pencereleri ve `--check` üretim - [x] Filo işlemleri açık hedefle; wave-scoped filo akışı dalga dalga ilerliyor; production için `production` yazma zorunlu. - [x] Katalog, playbook kapsamıyla uyuşmazsa çalıştırmayı reddediyor (kapsam playbook dosyasından okunur). - [x] Yetki modeli: `sudo bf` tam yetki; yetkisiz açılışta yazma işlemleri kapalı ve çalıştırma katmanı `[ENGELLENDİ]` ile reddediyor. +- [x] Pencere `docs/DESIGN.md` yönergesine göre çiziliyor: koyu başlık bandı (ürün adı + hedef kimliği + cihaz durumu + yetki), beyaz paneller, `KURULDU`/`KAYITLI`/`HAZIR`/`HATA`/`YOK` rozetleri (sözcük + renk), tek çıkış kontrolü; renk ve boşluk değerleri `bfos/ui_theme.py` üzerinden `web/static/tokens.css`'ten geliyor ve statik testle karşılaştırılıyor. - [x] Grafik ekran/tkinter yokluğunda Türkçe hata + `sudo bf --tui` önerisi ve çıkış kodu 3. - [x] Şifre üretimi varsayılan **kapalı**; yalnız `bf-creds --generate` ile üretiliyor; parola ekrana/loga gitmiyor. - [x] `Cihaz işlemleri` içinde onaysız güncelleme seçeneği **yok**; `Bekleyen güvenlik güncellemeleri` yalnız rapor. diff --git a/tests/test_bfos_gui_static.py b/tests/test_bfos_gui_static.py index 67e5164..189bc8f 100644 --- a/tests/test_bfos_gui_static.py +++ b/tests/test_bfos_gui_static.py @@ -10,7 +10,9 @@ tkinter'siz calisan yardimcilari ve backend sozlesmesini dogrular: * headless (SSH) tespiti ve tkinter eksikligi mesaji, * hedef/kapi kurallari: release -> tek cihaz yok, production -> token, * --check kipi, salt-okunur rapor plani, - * komut calistirma: canli satir akisi + cikis kodu (gercek subprocess). + * komut calistirma: canli satir akisi + cikis kodu (gercek subprocess), + * tasarim sozlesmesi: tema token'lari web/static/tokens.css ile birebir ayni, + durum sozlugu (KURULDU/KAYITLI/HAZIR/HATA/YOK) ve yetki kapisi gorunurlugu. Calistirma: python3 tests/test_bfos_gui_static.py -v @@ -21,7 +23,9 @@ from __future__ import annotations import contextlib import importlib.util import io +import json import os +import re import shutil import sys import tempfile @@ -774,5 +778,322 @@ class TestCatalogueBackendFallback(StaticCase): self.assertFalse(plan.destructive) +# --------------------------------------------------------------------------- +# Tasarim sozlesmesi (docs/DESIGN.md + web/static/tokens.css) +# --------------------------------------------------------------------------- +def _srgb_channel(value: float) -> float: + return value / 12.92 if value <= 0.03928 else ((value + 0.055) / 1.055) ** 2.4 + + +def relative_luminance(colour: str) -> float: + """WCAG bagil parlaklik (hex token rengi).""" + r, g, b = (int(colour[index:index + 2], 16) / 255 for index in (1, 3, 5)) + return 0.2126 * _srgb_channel(r) + 0.7152 * _srgb_channel(g) + 0.0722 * _srgb_channel(b) + + +def contrast_ratio(first: str, second: str) -> float: + """Iki token rengi arasindaki WCAG kontrast orani.""" + a, b = relative_luminance(first), relative_luminance(second) + lighter, darker = max(a, b), min(a, b) + return (lighter + 0.05) / (darker + 0.05) + + +class TestThemeMirrorsTokensCss(StaticCase): + """bfos/ui_theme.py, web/static/tokens.css'in BIREBIR aynasi olmali. + + Iki yuzey (cihaz konsolu + merkez panel) ayni sozlugu konusur; bir tarafta + degistirilip digerinde unutulan bir token sessiz bir ayrisma olurdu. Bu + yuzden aynalama testtir (docs/DESIGN.md, "Rules"). + """ + + CSS_PATH = REPO_ROOT / "web" / "static" / "tokens.css" + + #: Tk'de karsiligi olmayan token'lar (golge, yazi tipi listesi, satir + #: yuksekligi orani, yuvarlaklik): konsol bunlari kullanmaz, ama CSS'te + #: kalirlar cunku merkez panel kullanir. + NOT_MIRRORED = ( + "--font-ui", "--font-data", + "--leading-tight", "--leading-normal", + "--shadow-sm", "--shadow-md", + "--radius", "--radius-lg", + ) + + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + cls.theme = ui.theme + cls.css = cls._parse_tokens(cls.CSS_PATH.read_text(encoding="utf-8")) + + @staticmethod + def _parse_tokens(text: str) -> dict: + """`--ad: deger;` ciftlerini toplar, var(--x) referanslarini cozer.""" + raw = { + match.group(1): match.group(2).strip() + for match in re.finditer(r"(--[a-z0-9-]+)\s*:\s*([^;]+);", text) + } + resolved = {} + for name, value in raw.items(): + reference = re.fullmatch(r"var\((--[a-z0-9-]+)\)", value) + resolved[name] = raw.get(reference.group(1), value) if reference else value + return resolved + + def test_the_mirror_source_exists(self) -> None: + self.assertTrue(self.CSS_PATH.is_file(), str(self.CSS_PATH)) + self.assertTrue(self.css, "tokens.css icinde token bulunamadi") + + def test_every_mirrored_value_equals_the_css(self) -> None: + for name, value in self.theme.ALL_TOKENS.items(): + with self.subTest(token=name): + self.assertIn(name, self.css, f"{name} tokens.css'te yok") + css_value = self.css[name] + if css_value.endswith("px"): + css_value = css_value[:-2] + self.assertEqual(value, css_value.strip(), + f"{name}: ui_theme.py ile tokens.css ayrismis") + + def test_the_colour_palette_is_fully_mirrored(self) -> None: + """CSS'teki her RENK tokeninin Python karsiligi olmali (fazla renk = hata).""" + css_colours = {name for name, value in self.css.items() if value.startswith("#")} + python_colours = {name for name, value in self.theme.TOKENS.items() + if value.startswith("#")} + self.assertEqual(css_colours, python_colours) + self.assertTrue(css_colours) + + def test_state_and_severity_pairs_have_readable_contrast(self) -> None: + """Durum renkleri gunesli atolyede okunmali; rozet sozcugu de yaninda.""" + self.assertGreaterEqual(contrast_ratio(self.theme.TEXT, self.theme.BG_APP), 7.0) + self.assertGreaterEqual(contrast_ratio(self.theme.TEXT, self.theme.BG_PANEL), 7.0) + self.assertGreaterEqual(contrast_ratio(self.theme.TEXT_MUTED, + self.theme.BG_PANEL), 4.5) + self.assertGreaterEqual(contrast_ratio(self.theme.TEXT_FAINT, + self.theme.BG_PANEL), 3.0) + self.assertGreaterEqual(contrast_ratio(self.theme.TEXT_ON_DARK, + self.theme.BG_HEADER), 7.0) + self.assertGreaterEqual(contrast_ratio(self.theme.TEXT_ON_ACCENT, + self.theme.ACCENT), 4.5) + # En dusuk cift "yesil zemin + yesil yazi" (4.42:1); esik bunun altina inmez. + for key, word in self.theme.STATE_WORDS.items(): + with self.subTest(state=word): + self.assertGreaterEqual( + contrast_ratio(self.theme.STATE_TEXT_COLOUR[key], + self.theme.STATE_FILL[key]), 4.4) + for severity in self.theme.SEVERITIES: + with self.subTest(severity=severity): + self.assertGreaterEqual( + contrast_ratio(self.theme.SEVERITY_TEXT[severity], + self.theme.SEVERITY_FILL[severity]), 4.4) + + def test_fonts_resolve_to_platform_families_without_downloads(self) -> None: + """Webfont yok: aday listesi platformda VAR OLAN bir yuzeye duser.""" + self.assertTrue(self.theme.FONT_UI_CANDIDATES) + self.assertTrue(self.theme.FONT_DATA_CANDIDATES) + self.assertTrue(self.theme.FONT_UI_FALLBACK) + self.assertTrue(self.theme.FONT_DATA_FALLBACK) + for family in (list(self.theme.FONT_UI_CANDIDATES) + + list(self.theme.FONT_DATA_CANDIDATES)): + with self.subTest(family=family): + self.assertNotIn("/", family) + self.assertNotIn(".ttf", family.lower()) + self.assertNotIn("http", family.lower()) + + def test_px_tokens_convert_to_tk_point_sizes(self) -> None: + sizes = (self.theme.SIZE_XS, self.theme.SIZE_SM, self.theme.SIZE_MD, + self.theme.SIZE_LG, self.theme.SIZE_XL, self.theme.SIZE_2XL) + for size in sizes: + with self.subTest(size=size): + self.assertLess(self.theme.point_size(size), size) + self.assertGreaterEqual(self.theme.point_size(size), 7) + # Siralama korunur: buyuk token daha buyuk puan uretir (esitlik yok). + points = [self.theme.point_size(size) for size in sizes] + self.assertEqual(points, sorted(points)) + self.assertEqual(len(set(points)), len(points)) + + def test_the_space_rhythm_is_the_committed_one(self) -> None: + self.assertEqual( + [self.theme.SP_1, self.theme.SP_2, self.theme.SP_3, self.theme.SP_4, + self.theme.SP_5, self.theme.SP_6, self.theme.SP_7], + [4, 8, 12, 16, 24, 32, 48], + ) + self.assertEqual(self.theme.ROW_H, 44) + self.assertEqual(self.theme.HEADER_H, 56) + + +class TestStateVocabulary(StaticCase): + """Durum sozlugu: TEK sozcuk, renk tek basina anlam tasimaz.""" + + def test_the_five_words_are_exactly_the_committed_ones(self) -> None: + self.assertEqual(ui.DEVICE_STATES, + ("KURULDU", "KAYITLI", "HAZIR", "HATA", "YOK")) + self.assertEqual(set(ui.DEVICE_STATES), set(ui.theme.STATE_WORDS.values())) + + def test_every_word_has_its_own_badge_style(self) -> None: + styles = [ui.state_badge_style(word) for word in ui.DEVICE_STATES] + self.assertEqual(len(set(styles)), len(styles), styles) + for style in styles: + self.assertTrue(style.endswith(".Badge.TLabel"), style) + for key, style in ui.theme.STATE_BADGE_STYLE.items(): + with self.subTest(state=key): + self.assertEqual(style, + ui.state_badge_style(ui.theme.STATE_WORDS[key])) + + def test_every_word_has_its_own_text_colour(self) -> None: + self.assertEqual(len(set(ui.theme.STATE_TEXT_COLOUR.values())), + len(ui.DEVICE_STATES)) + + def test_badge_fills_come_from_the_token_set(self) -> None: + """Dolgu renkleri token kumesinden gelir; uydurulmus renk yok.""" + allowed = set(ui.theme.TOKENS.values()) | set(ui.theme.SEVERITY_FILL.values()) + for key, fill in ui.theme.STATE_FILL.items(): + with self.subTest(state=key): + self.assertIn(fill, allowed) + self.assertIn(ui.theme.STATE_TEXT_COLOUR[key], allowed) + + def test_provisioned_is_not_the_action_accent(self) -> None: + """KURULDU ile secili satir ayni maviye boyanamaz (tokens.css).""" + self.assertNotEqual(ui.theme.STATE_TEXT_COLOUR["provisioned"], + ui.theme.ACCENT) + self.assertNotEqual(ui.theme.STATE_FILL["provisioned"], + ui.theme.ACCENT_SOFT) + + def test_unknown_word_falls_back_to_a_neutral_badge(self) -> None: + self.assertEqual(ui.state_badge_style("BOYLE-BIR-DURUM-YOK"), + ui.theme.STATE_BADGE_STYLE["absent"]) + + def test_the_state_machine_maps_to_one_word(self) -> None: + cases = ( + ({}, "YOK"), + ({"phase": "PROVISIONED_OFFLINE"}, "KURULDU"), + ({"phase": "PROVISIONED_OFFLINE", + "enrollment_status": "ENROLLED"}, "KAYITLI"), + ({"fleet_status": "READY"}, "HAZIR"), + ({"phase": "FAILED"}, "HATA"), + ({"enrollment_status": "NOT_ENROLLED"}, "KURULDU"), + ) + for values, expected in cases: + with self.subTest(values=values): + self.assertEqual(ui.state_word_from_values(values), expected) + + def test_missing_or_broken_state_file_is_the_absent_state(self) -> None: + self.assertEqual(ui.device_state_word("/yok/boyle/bir/state.json"), "YOK") + self.assertEqual(ui.read_state_values("/yok/boyle/bir/state.json"), {}) + with tempfile.TemporaryDirectory() as tmp: + broken = Path(tmp) / "state.json" + broken.write_text("{bu json degil", encoding="utf-8") + self.assertEqual(ui.device_state_word(str(broken)), "YOK") + + def test_state_json_is_read_through_a_whitelist(self) -> None: + """Jeton ve anahtar alanlari ekrana/loga ASLA okunmaz (K-14).""" + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "state.json" + path.write_text(json.dumps({ + "phase": "READY", + "device_id": "BF-12010193", + "enrollment_token": "SEKRET-JETON", + "private_key": "-----BEGIN PRIVATE KEY-----", + }), encoding="utf-8") + values = ui.read_state_values(str(path)) + self.assertEqual(ui.device_state_word(str(path)), "HAZIR") + self.assertIn("device_id", values) + self.assertNotIn("enrollment_token", values) + self.assertNotIn("private_key", values) + self.assertNotIn("SEKRET-JETON", json.dumps(values)) + self.assertNotIn("BEGIN PRIVATE KEY", json.dumps(values)) + + +class TestPrivilegeIsVisible(StaticCase): + """Yetki bir durumdur: yazma islemleri gizlenmez, KAPATILIR ve nedeni yazar.""" + + def test_privilege_levels_have_wording_and_a_badge(self) -> None: + self.assertEqual(ui.privilege_level(True), ui.PRIVILEGE_FULL) + self.assertEqual(ui.privilege_level(False), ui.PRIVILEGE_READ_ONLY) + self.assertEqual("SALT-OKUNUR", ui.privilege_word(ui.PRIVILEGE_READ_ONLY)) + self.assertEqual("TAM YETKİ", ui.privilege_word(ui.PRIVILEGE_FULL)) + self.assertEqual(set(ui.theme.PRIVILEGE_BADGE_STYLE), + {ui.PRIVILEGE_FULL, ui.PRIVILEGE_READ_ONLY}) + + def test_unprivileged_writes_stay_in_the_list_with_a_reason(self) -> None: + writes = [op for op in self.backend.list_operations() if op.needs_root] + reports = [op for op in self.backend.list_operations() if not op.needs_root] + self.assertTrue(writes, "katalogda yazma islemi yok; kapi test edilemez") + original = os.geteuid + try: + os.geteuid = lambda: 1000 + for op in writes: + with self.subTest(operation=op.id): + self.assertEqual(ui.write_blocked_reason(op), + ui.NON_ROOT_DISABLED_HINT) + self.assertTrue(ui.operation_is_write(op)) + for op in reports: + with self.subTest(operation=op.id): + self.assertIsNone(ui.write_blocked_reason(op)) + self.assertFalse(ui.operation_is_write(op)) + os.geteuid = lambda: 0 + for op in writes: + with self.subTest(operation=op.id): + self.assertIsNone(ui.write_blocked_reason(op)) + finally: + os.geteuid = original + + def test_the_blocked_reason_names_the_command_to_get_privileges(self) -> None: + self.assertIn("sudo bf", ui.NON_ROOT_DISABLED_HINT) + self.assertIn("yönetici", ui.NON_ROOT_DISABLED_HINT.lower()) + + +class TestConsoleLayoutContract(StaticCase): + """Olculen sorunlar geri gelmesin: kaynak duzeyinde baglayici kurallar.""" + + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + cls.source = UI_GUI_PATH.read_text(encoding="utf-8") + + def test_the_view_has_no_hardcoded_colour(self) -> None: + """Renk token'dir, literal degil (docs/DESIGN.md kural 1).""" + self.assertEqual(re.findall(r"#[0-9a-fA-F]{3,8}\b", self.source), []) + + def test_there_is_exactly_one_exit_control(self) -> None: + """'Cik' tek kontrol: ya basit modda ya alt cubukta, ayni anda degil.""" + self.assertEqual(self.source.count('text="Çıkış"'), 1) + self.assertIn("Çıkış", ui.SIMPLE_MODE_BUTTONS) + for needle in ("self.exit_button.pack(side=\"right\")", + "self.exit_button.pack_forget()"): + with self.subTest(needle=needle): + self.assertIn(needle, self.source) + + def test_the_header_carries_the_privilege_element(self) -> None: + """Yetki gomulu bir cumle degil, basliktaki gorunur bir rozet.""" + for needle in ("PRIVILEGE_BADGE_STYLE", "self.privilege_badge", + 'text="Yetki"', "ROOT_REQUIRED_NOTICE", + 'style=self.styles["warn_band"]'): + with self.subTest(needle=needle): + self.assertIn(needle, self.source) + + def test_the_header_carries_the_device_state_and_target(self) -> None: + for needle in ("self.state_badge", "self.header_target", + "device_state_word()", "state_badge_style("): + with self.subTest(needle=needle): + self.assertIn(needle, self.source) + + def test_write_operations_are_disabled_not_hidden(self) -> None: + for needle in ('state="disabled"', "BLOCKED_LABEL", "AVAILABLE_LABEL", + "write_blocked_reason"): + with self.subTest(needle=needle): + self.assertIn(needle, self.source) + + def test_the_status_strip_is_a_state_pill_not_a_plain_label(self) -> None: + self.assertIn("theme.severity_style", self.source) + self.assertNotIn('text="Hazır."', self.source) + + def test_the_output_area_is_a_read_only_well(self) -> None: + for needle in ('state="disabled"', "BG_SUNKEN", "data_font", + 'cursor="arrow"'): + with self.subTest(needle=needle): + self.assertIn(needle, self.source) + + def test_the_view_reads_its_values_from_the_theme_module(self) -> None: + self.assertIn("ui_theme as theme", self.source) + self.assertTrue((REPO_ROOT / "bfos" / "ui_theme.py").is_file()) + + if __name__ == "__main__": unittest.main(verbosity=2)