aboutsummaryrefslogtreecommitdiffstats
path: root/sb_builder/mainwindow.py
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-20 12:13:00 +0200
committerDanilo M. <danix@danix.xyz>2026-10-20 12:13:00 +0200
commitd4cae7a87fd30341b5a936aa848e1fdc019a379e (patch)
tree4a8a21966595468046f1b39b345a49a03c76f25e /sb_builder/mainwindow.py
parentc8306d6c87a40bb800241cccd90831e3893550b5 (diff)
downloadsb-builder-d4cae7a87fd30341b5a936aa848e1fdc019a379e.tar.gz
sb-builder-d4cae7a87fd30341b5a936aa848e1fdc019a379e.zip
One frame for every tab: ribbons of actions, next steps as dialogs
Tabs are permanent and load on first show. The ribbon shows the current tab's actions in workflow order, a small ribbon under it the view controls; inapplicable actions are disabled with the reason as tooltip. Starting a job stays on the tab; the Jobs tab reads (running), then shows the end status. When a preview this session started ends, its log opens with Run it (publish box) or Apply shadowing; Not now loses nothing, Run it in Jobs reopens it. New hint moves to the Packages ribbon and acts on the ticked packages without a hint. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'sb_builder/mainwindow.py')
-rw-r--r--sb_builder/mainwindow.py174
1 files changed, 98 insertions, 76 deletions
diff --git a/sb_builder/mainwindow.py b/sb_builder/mainwindow.py
index eeac61a..c6ea02e 100644
--- a/sb_builder/mainwindow.py
+++ b/sb_builder/mainwindow.py
@@ -21,26 +21,24 @@ import time
from PyQt6.QtCore import QMetaType, QObject, QSize, Qt, QTimer, pyqtSignal, pyqtSlot
from PyQt6.QtDBus import QDBusArgument, QDBusConnection, QDBusInterface, QDBusMessage
from PyQt6.QtGui import QIcon, QKeySequence, QShortcut
-from PyQt6.QtWidgets import (QLabel, QMainWindow, QMessageBox, QPushButton, QStyle, QTabBar,
- QTabWidget, QToolBar)
+from PyQt6.QtWidgets import (QLabel, QMainWindow, QMessageBox, QPushButton, QStyle, QTabWidget,
+ QToolBar, QToolButton)
from . import VERSION, config
from .dashboard import Dashboard
from .fmt import job_title
from .hints_tab import HINT_JOBS, HintResultDialog, HintsTab
-from .jobs import JobsTab
+from .jobs import JobsTab, NextStep, is_preview
from .packages import PackagesTab
from .results import ResultsTab
from .remote import DOWN, MISSING
NOTIFY = "org.freedesktop.Notifications"
NOTIFY_PATH = "/org/freedesktop/Notifications"
-# ribbon entries: label, freedesktop icon name, Qt fallback icon
-RIBBON = (("Dashboard", "go-home", QStyle.StandardPixmap.SP_DirHomeIcon),
- ("Packages", "package-x-generic", QStyle.StandardPixmap.SP_FileDialogDetailedView),
- ("Hints", "document-edit", QStyle.StandardPixmap.SP_FileDialogContentsView),
- ("Results", "view-list-text", QStyle.StandardPixmap.SP_MessageBoxWarning),
- ("Jobs", "system-run", QStyle.StandardPixmap.SP_MediaPlay))
+# Jobs tab icon: running, then the end status until the tab is shown
+BADGE = {"running": ("system-run", QStyle.StandardPixmap.SP_MediaPlay),
+ "ok": ("emblem-ok", QStyle.StandardPixmap.SP_DialogApplyButton),
+ "bad": ("dialog-error", QStyle.StandardPixmap.SP_MessageBoxCritical)}
# tabs as a distinct, centered row; colors from the palette so the theme applies
TAB_STYLE = """
QTabWidget::tab-bar { alignment: center; }
@@ -86,11 +84,10 @@ class MainWindow(QMainWindow):
self.cfg, self.remote = cfg, remote
self.notifier = notifier or Notifier(self)
self.notifier.clicked.connect(self.open_job)
- self.jobs_tab = None
- self.packages_tab = None
- self.results_tab = None
- self.hints_tab = None
self.hint_dialogs = [] # open HintResultDialogs, kept alive while shown
+ self.next_steps = {} # job id -> its open NextStep dialog
+ self.mine = set() # ids of the jobs this session started: only they get next steps
+ self.badge = None # Jobs tab icon state: None, "running", "ok", "bad"
self.seen = None # job id -> status at the last poll; None before the first
self.online = None # None until the first answer
self.overlays = []
@@ -99,17 +96,29 @@ class MainWindow(QMainWindow):
self.running_id = None
self.setWindowTitle("sb-builder")
- self.tabs = QTabWidget(tabsClosable=True)
+ self.tabs = QTabWidget()
self.tabs.setStyleSheet(TAB_STYLE)
- self.tabs.tabCloseRequested.connect(self._close_tab)
- self.tabs.currentChanged.connect(lambda _i: self.poll_stats())
self.dashboard = Dashboard()
- self.dashboard.refresh_btn.clicked.connect(self.poll_stats)
+ self.dashboard.refresh.triggered.connect(self.poll_stats)
self.dashboard.tiles["Packages"].link(self.open_packages)
self.dashboard.jobs.link(self.open_jobs)
self.dashboard.result_clicked.connect(self.open_results)
- self.tabs.addTab(self.dashboard, "Dashboard")
- self.tabs.tabBar().setTabButton(0, QTabBar.ButtonPosition.RightSide, None)
+ self.jobs_tab = JobsTab(self.remote)
+ self.jobs_tab.refresh_requested.connect(self.poll_jobs)
+ self.jobs_tab.started.connect(self.mine.add)
+ self.jobs_tab.preview_requested.connect(self.show_next_step)
+ self.packages_tab = PackagesTab(self.remote)
+ self.packages_tab.start_requested.connect(lambda args: self.jobs_tab.start(*args))
+ self.packages_tab.hints_requested.connect(self.open_hints)
+ self.hints_tab = HintsTab(self.remote, self.cfg)
+ self.hints_tab.start_requested.connect(lambda args: self.jobs_tab.start(*args))
+ self.hints_tab.result_ready.connect(self.show_hint_result)
+ self.results_tab = ResultsTab(self.remote)
+ # permanent tabs (UI guidelines); each loads on first show
+ for tab, label in ((self.dashboard, "Dashboard"), (self.packages_tab, "Packages"),
+ (self.hints_tab, "Hints"), (self.results_tab, "Results"),
+ (self.jobs_tab, "Jobs")):
+ self.tabs.addTab(tab, label)
self.setCentralWidget(self.tabs)
file_menu = self.menuBar().addMenu("&File")
@@ -118,17 +127,18 @@ class MainWindow(QMainWindow):
# window-wide: QTabWidget's own Ctrl+PgUp/PgDn needs focus on the tabs
self.tab_next = QShortcut(QKeySequence("Ctrl+PgDown"), self, lambda: self._cycle_tab(1))
self.tab_prev = QShortcut(QKeySequence("Ctrl+PgUp"), self, lambda: self._cycle_tab(-1))
- self.ribbon = QToolBar("Screens", movable=False, floatable=False)
+ # main ribbon: the current tab's actions; small ribbon under it: its view controls
+ self.ribbon = QToolBar("Actions", movable=False, floatable=False)
self.ribbon.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextUnderIcon)
self.ribbon.setIconSize(QSize(32, 32))
self.addToolBar(Qt.ToolBarArea.TopToolBarArea, self.ribbon)
- slots = {"Dashboard": lambda: self.tabs.setCurrentWidget(self.dashboard),
- "Packages": self.open_packages, "Hints": lambda: self.open_hints(),
- "Results": lambda: self.open_results(),
- "Jobs": self.open_jobs}
- for label, name, fallback in RIBBON:
- icon = QIcon.fromTheme(name, self.style().standardIcon(fallback))
- self.ribbon.addAction(icon, label, slots[label])
+ self.addToolBarBreak()
+ self.view_bar = QToolBar("View", movable=False, floatable=False)
+ self.view_bar.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon)
+ self.view_bar.setIconSize(QSize(16, 16))
+ self.addToolBar(Qt.ToolBarArea.TopToolBarArea, self.view_bar)
+ self.tabs.currentChanged.connect(self._tab_changed)
+ self._tab_changed()
self.conn = QLabel("● connecting")
self.key = QLabel()
@@ -179,9 +189,12 @@ class MainWindow(QMainWindow):
jobs = data["jobs"]
self._notify_ends(jobs)
self.dashboard.show_jobs(jobs)
- if self.jobs_tab:
- self.jobs_tab.set_jobs(jobs)
+ self.jobs_tab.set_jobs(jobs)
run = next((j for j in jobs if j["status"] == "running"), None)
+ if run:
+ self._badge("running")
+ elif self.badge == "running":
+ self._badge(None) # ended unseen (first poll, reconnect): nothing to report
self.running_id = run and run["id"]
self.job_btn.setVisible(run is not None)
if run:
@@ -196,8 +209,7 @@ class MainWindow(QMainWindow):
if err:
return self._failed(err)
self.overlays = data.get("overlays", [])
- if self.jobs_tab:
- self.jobs_tab.set_overlays(self.overlays)
+ self.jobs_tab.set_overlays(self.overlays)
if data["version"] != VERSION:
self.conn.setText(f"● helper {data['version']}, GUI {VERSION}: run ./install.sh --remote")
@@ -285,24 +297,57 @@ class MainWindow(QMainWindow):
event = END_EVENT.get(j["status"])
if event and prev.get(j["id"], "running") != j["status"]:
self.notify(event, job_title(j), j["id"])
+ if self.tabs.currentWidget() is not self.jobs_tab:
+ self._badge("ok" if j["status"] == "ok" else "bad")
if j["kind"] in ("slackrepo", "publish"): # build state changed
self._reload(self.packages_tab, self.results_tab)
if j["kind"] in HINT_JOBS and j["status"] in ("ok", "failed"):
self._reload(self.packages_tab, self.hints_tab)
+ if j["id"] not in self.mine:
+ continue # started elsewhere or before this session: news, not a next step
+ if j["kind"] in HINT_JOBS and j["status"] in ("ok", "failed"):
self.remote.call(lambda d, err: d and self.show_hint_result(d), "job-result", j["id"])
+ elif is_preview(j):
+ self.show_next_step(j, auto=True)
@staticmethod
def _reload(*tabs):
for tab in tabs:
- if tab:
+ if tab.loaded: # a tab not shown yet loads fresh when it is
tab.reload()
+ def show_next_step(self, job, auto=False):
+ """The preview's next step as a dialog; auto: a shadowing preview without a plan shows none."""
+ if job["id"] in self.next_steps:
+ self.next_steps[job["id"]].raise_()
+ return
+ d = NextStep(self.remote, job, self.cfg["builds"]["publish"], self)
+ d.run.connect(lambda args: (self.jobs_tab.start_signed if args[0] == "slackrepo"
+ else self.jobs_tab.start)(*args))
+ d.finished.connect(lambda _r: self.next_steps.pop(job["id"], None))
+ self.next_steps[job["id"]] = d
+ if d.shadow and auto:
+ d.loaded.connect(lambda plan: d.show() if plan else d.reject())
+ else:
+ d.show()
+
+ def _badge(self, state):
+ self.badge = state
+ i = self.tabs.indexOf(self.jobs_tab)
+ # in the text, not a color: TAB_STYLE's tab color would override setTabTextColor
+ self.tabs.setTabText(i, "Jobs (running)" if state == "running" else "Jobs")
+ if state is None:
+ self.tabs.setTabIcon(i, QIcon())
+ return
+ name, fallback = BADGE[state]
+ self.tabs.setTabIcon(i, QIcon.fromTheme(name, self.style().standardIcon(fallback)))
+
def show_hint_result(self, result):
if not result["ok"] and not result["failed"]:
return # nothing changed, nothing failed: no news
d = HintResultDialog(self.remote, result, self)
d.build_requested.connect(
- lambda pkgs: self.open_jobs().start("slackrepo", "update", "--preview", *pkgs))
+ lambda pkgs: self.jobs_tab.start("slackrepo", "update", "--preview", *pkgs))
d.changed.connect(lambda: self._reload(self.packages_tab, self.hints_tab))
d.finished.connect(lambda _r: self.hint_dialogs.remove(d))
self.hint_dialogs.append(d)
@@ -310,49 +355,42 @@ class MainWindow(QMainWindow):
# ── tabs ────────────────────────────────────────────────────────────────
- def open_jobs(self):
- if self.jobs_tab is None:
- self.jobs_tab = JobsTab(self.remote, self.cfg["builds"]["publish"])
- self.jobs_tab.refresh_requested.connect(self.poll_jobs)
- self.jobs_tab.set_overlays(self.overlays)
- self.tabs.addTab(self.jobs_tab, "Jobs")
- self.poll_jobs()
- self.tabs.setCurrentWidget(self.jobs_tab)
- return self.jobs_tab
+ def _tab_changed(self, _i=None):
+ tab = self.tabs.currentWidget()
+ for bar, acts in ((self.ribbon, tab.actions), (self.view_bar, tab.view_actions)):
+ bar.clear()
+ bar.addActions(acts)
+ for a in acts:
+ if a.menu(): # a menu action opens its menu on a plain click
+ bar.widgetForAction(a).setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
+ self.view_bar.setVisible(bool(tab.view_actions))
+ if not getattr(tab, "loaded", True):
+ tab.reload()
+ if tab is self.jobs_tab and self.badge != "running":
+ self._badge(None)
+ self.poll_stats()
def _cycle_tab(self, step):
self.tabs.setCurrentIndex((self.tabs.currentIndex() + step) % self.tabs.count())
+ def open_jobs(self):
+ self.tabs.setCurrentWidget(self.jobs_tab)
+ return self.jobs_tab
+
def open_packages(self):
- if self.packages_tab is None:
- self.packages_tab = PackagesTab(self.remote)
- self.packages_tab.start_requested.connect(lambda args: self.open_jobs().start(*args))
- self.packages_tab.hints_requested.connect(self.open_hints)
- self.tabs.addTab(self.packages_tab, "Packages")
- self.packages_tab.reload()
self.tabs.setCurrentWidget(self.packages_tab)
return self.packages_tab
def open_hints(self, name=None):
- if self.hints_tab is None:
- self.hints_tab = HintsTab(self.remote, self.cfg)
- self.hints_tab.start_requested.connect(lambda args: self.open_jobs().start(*args))
- self.hints_tab.result_ready.connect(self.show_hint_result)
- self.tabs.addTab(self.hints_tab, "Hints")
- self.hints_tab.reload()
+ self.tabs.setCurrentWidget(self.hints_tab)
if name:
self.hints_tab.select(name)
- self.tabs.setCurrentWidget(self.hints_tab)
return self.hints_tab
def open_results(self, result=None):
- if self.results_tab is None:
- self.results_tab = ResultsTab(self.remote)
- self.tabs.addTab(self.results_tab, "Results")
- self.results_tab.reload()
+ self.tabs.setCurrentWidget(self.results_tab)
if result:
self.results_tab.filter_to(result)
- self.tabs.setCurrentWidget(self.results_tab)
return self.results_tab
def open_job(self, jid):
@@ -362,22 +400,6 @@ class MainWindow(QMainWindow):
if jid:
self.open_jobs().select(jid)
- def _close_tab(self, i):
- w = self.tabs.widget(i)
- if w is self.dashboard:
- return
- self.tabs.removeTab(i)
- if w is self.jobs_tab:
- self.jobs_tab = None
- w.stop()
- if w is self.packages_tab:
- self.packages_tab = None
- if w is self.results_tab:
- self.results_tab = None
- if w is self.hints_tab:
- self.hints_tab = None
- w.deleteLater()
-
def reload_config(self):
cfg, err = config.load()
if err: