From d4cae7a87fd30341b5a936aa848e1fdc019a379e Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Tue, 20 Oct 2026 12:13:00 +0200 Subject: 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 --- sb_builder/dashboard.py | 9 +-- sb_builder/hints_tab.py | 81 ++++++++++++---------- sb_builder/jobs.py | 154 +++++++++++++++++++++++++++-------------- sb_builder/mainwindow.py | 174 ++++++++++++++++++++++++++--------------------- sb_builder/packages.py | 108 +++++++++++++---------------- sb_builder/results.py | 22 +++--- sb_builder/ui.py | 46 +++++++++++++ 7 files changed, 352 insertions(+), 242 deletions(-) create mode 100644 sb_builder/ui.py (limited to 'sb_builder') diff --git a/sb_builder/dashboard.py b/sb_builder/dashboard.py index 5dee453..3096fb8 100644 --- a/sb_builder/dashboard.py +++ b/sb_builder/dashboard.py @@ -22,10 +22,11 @@ import time from PyQt6.QtCore import QEvent, QPointF, QRectF, Qt, pyqtSignal from PyQt6.QtGui import QColor, QFont, QPainter, QPalette -from PyQt6.QtWidgets import (QFrame, QGridLayout, QHBoxLayout, QLabel, QPushButton, - QToolTip, QVBoxLayout, QWidget) +from PyQt6.QtWidgets import (QFrame, QGridLayout, QHBoxLayout, QLabel, QToolTip, QVBoxLayout, + QWidget) from .fmt import ICON, NA, fmt_age, fmt_bytes, fmt_duration, fmt_time, job_title +from .ui import SP, action # reserved status colors (dataviz reference palette); always shown with a text label STATUS = {"ok": "#0ca30c", "aborted": "#fab219", "bad": "#ec835a", "failed": "#d03b3b", @@ -288,11 +289,11 @@ class Dashboard(QWidget): self.jobs.body.addStretch() self.stale = muted(QLabel()) - self.refresh_btn = QPushButton("Refresh") + self.refresh = action(self, "Refresh", "view-refresh", SP.SP_BrowserReload) + self.actions, self.view_actions = [self.refresh], [] top = QHBoxLayout() top.addWidget(self.stale) top.addStretch() - top.addWidget(self.refresh_btn) grid = QGridLayout() grid.setSpacing(12) diff --git a/sb_builder/hints_tab.py b/sb_builder/hints_tab.py index b78ec69..feba94d 100644 --- a/sb_builder/hints_tab.py +++ b/sb_builder/hints_tab.py @@ -21,13 +21,14 @@ import os from PyQt6.QtCore import Qt, pyqtSignal from PyQt6.QtGui import QColor, QFontDatabase, QKeySequence, QShortcut, QSyntaxHighlighter -from PyQt6.QtWidgets import (QCheckBox, QDialog, QHBoxLayout, QLabel, QLineEdit, QListWidget, +from PyQt6.QtWidgets import (QDialog, QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem, QMessageBox, QPlainTextEdit, QPushButton, QSplitter, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget) from . import upstream from .dashboard import STATUS from .packages import chip, load_rows +from .ui import SP, action, enable STATES = ("outdated", "current", "no stanza", "not checked", "nvchecker error") STATE_COLOR = {"outdated": STATUS["aborted"], "current": STATUS["ok"], @@ -59,9 +60,8 @@ class HintsTab(QWidget): self.chips[name].toggled.connect(self.refilter) chips.addWidget(self.chips[name]) chips.addStretch() - self.refresh_btn = QPushButton("Refresh") - self.refresh_btn.clicked.connect(self.reload) - chips.addWidget(self.refresh_btn) + self.status = QLabel() + chips.addWidget(self.status) self.table = QTableWidget(0, len(COLUMNS)) self.table.setHorizontalHeaderLabels(COLUMNS) @@ -74,37 +74,36 @@ class HintsTab(QWidget): self.table.setColumnWidth(col, width) self.table.cellClicked.connect(self._toggle) - self.status = QLabel() - self.check_btn = QPushButton("Check upstream") - self.check_btn.clicked.connect(self.check) - self.update_btn = QPushButton("Update selected") - self.update_btn.clicked.connect(self.update_selected) - self.no_dl = QCheckBox("no download") - self.no_dl.setToolTip('Write DOWNLOAD="no": slackrepo uses the source already in its cache') - bar = QHBoxLayout() - bar.addWidget(self.status) - self.keys = [] - for label, on, key in (("Select visible", True, "Ctrl+A"), ("Clear", False, "Ctrl+Shift+A")): - b = QPushButton(label, toolTip=key) - b.clicked.connect(lambda _c, on=on: self.check_rows(on)) - bar.addWidget(b) - # in the search field Ctrl+A still selects its text: QLineEdit overrides it - self.keys.append(QShortcut(QKeySequence(key), self, lambda on=on: self.check_rows(on), - context=Qt.ShortcutContext.WidgetWithChildrenShortcut)) - bar.addStretch() - for w in (self.check_btn, self.update_btn, self.no_dl): - bar.addWidget(w) - delete = QPushButton("Delete hint") - delete.clicked.connect(self.delete) - bar.addWidget(delete) + self.act_check = action(self, "Check upstream", "system-search", + SP.SP_FileDialogContentsView, self.check) + self.act_update = action(self, "Update selected", "document-save", SP.SP_DialogSaveButton, + self.update_selected) + self.act_delete = action(self, "Delete hint", "edit-delete", SP.SP_TrashIcon, self.delete) + self.actions = [self.act_check, self.act_update, self.act_delete] + self.no_dl = action(self, "No download", "network-offline", SP.SP_DriveNetIcon, checkable=True) + self.no_dl.setToolTip('Update selected writes DOWNLOAD="no": slackrepo uses the source ' + "already in its cache") + self.view_actions = [ + action(self, "Select visible", "edit-select-all", SP.SP_DialogYesButton, + lambda: self.check_rows(True), shortcut="Ctrl+A"), + action(self, "Clear", "edit-clear", SP.SP_DialogResetButton, + lambda: self.check_rows(False), shortcut="Ctrl+Shift+A"), + self.no_dl, + action(self, "Refresh", "view-refresh", SP.SP_BrowserReload, self.reload)] + # in the search field Ctrl+A still selects its text: QLineEdit overrides it + self.keys = [QShortcut(QKeySequence(key), self, lambda on=on: self.check_rows(on), + context=Qt.ShortcutContext.WidgetWithChildrenShortcut) + for key, on in (("Ctrl+A", True), ("Ctrl+Shift+A", False))] + self.loaded = False layout = QVBoxLayout(self) layout.addWidget(self.query) layout.addLayout(chips) - layout.addWidget(self.table) - layout.addLayout(bar) + layout.addWidget(self.table, 1) + self.refilter() def reload(self): + self.loaded = True self.status.setText("loading…") self.remote.call(self._loaded, "inventory") @@ -152,8 +151,12 @@ class HintsTab(QWidget): hide = (on and state not in on) or text not in name.lower() self.table.setRowHidden(i, bool(hide)) shown += not hide - n = len(self.checked()) - self.status.setText(f"{shown} shown · {n} selected" + (f" · {self.failure}" if self.failure else "")) + ticked = set(self.checked()) + self.status.setText(f"{shown} shown · {len(ticked)} selected" + + (f" · {self.failure}" if self.failure else "")) + enable(self.act_update, None if self.pairs() else "tick outdated packages after Check upstream") + enable(self.act_delete, None if any(r["hint_path"] for r in self.rows if r["name"] in ticked) + else "none of the ticked packages has a hint") def _toggle(self, row, _col): """A click anywhere on a row, its box included, ticks or unticks it.""" @@ -187,20 +190,24 @@ class HintsTab(QWidget): return def check(self): - self.check_btn.setEnabled(False) + enable(self.act_check, "checking…") self.status.setText("checking upstream…") self.checker.run(os.path.expanduser(self.cfg["hints"]["nvchecker_keyfile"]), self.stanzas) def _checked(self, versions, errors, failure): - self.check_btn.setEnabled(True) + enable(self.act_check, None) self.versions, self.errors, self.failure = versions, errors, failure self._fill(check_outdated=True) - def update_selected(self): + def pairs(self): + """pkg=version of the ticked packages Check upstream found a version for.""" versions = self.versions or {} - pairs = [f"{n}={versions[n]}" for n in self.checked() if n in versions] - if pairs: - self.start_requested.emit(["hint-update", *pairs] + (["--no-dl"] if self.no_dl.isChecked() else [])) + return [f"{n}={versions[n]}" for n in self.checked() if n in versions] + + def update_selected(self): + if self.pairs(): + self.start_requested.emit(["hint-update", *self.pairs()] + + (["--no-dl"] if self.no_dl.isChecked() else [])) def confirm(self, title, text): return QMessageBox.question(self, title, text) == QMessageBox.StandardButton.Yes diff --git a/sb_builder/jobs.py b/sb_builder/jobs.py index b413597..111d804 100644 --- a/sb_builder/jobs.py +++ b/sb_builder/jobs.py @@ -14,18 +14,19 @@ # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. -"""Jobs tab: job list, live log through job-follow, start and kill.""" +"""Jobs tab: job list, live log through job-follow, start and kill; the next-step dialog.""" import codecs import re from PyQt6.QtCore import Qt, pyqtSignal from PyQt6.QtGui import QFontDatabase, QTextCursor -from PyQt6.QtWidgets import (QCheckBox, QHBoxLayout, QInputDialog, QLineEdit, QListWidget, - QListWidgetItem, QMenu, QMessageBox, QPlainTextEdit, QPushButton, - QSplitter, QToolButton, QVBoxLayout, QWidget) +from PyQt6.QtWidgets import (QCheckBox, QDialog, QHBoxLayout, QInputDialog, QLineEdit, + QListWidget, QListWidgetItem, QMenu, QMessageBox, QPlainTextEdit, + QPushButton, QSplitter, QVBoxLayout, QWidget) -from .fmt import describe +from .fmt import describe, job_title +from .ui import SP, action, enable ANSI = re.compile(rb"\x1b\[[0-9;?]*[A-Za-z]") SHADOW_PLAN = re.compile(r"^(un)?shadow: ", re.M) @@ -33,10 +34,88 @@ ACTIVE = ("starting", "running") BAD = ("failed", "killed", "lost") +def is_preview(j): + """A finished preview whose next step is a real run: slackrepo --preview, sbpull/sbshadow --no.""" + return j["status"] == "ok" and ( + (j["kind"] == "slackrepo" and "--preview" in j["args"]) + or (j["kind"] in ("sbpull", "sbshadow") and "--no" in j["args"])) + + +class NextStep(QDialog): + """After a preview: its whole log, then Run it (or Apply shadowing) / Not now. + + run(job-start args) asks for the real run; the caller starts it. For a + shadowing preview, loaded(has_plan) tells whether there is anything to apply. + """ + + run = pyqtSignal(list) + loaded = pyqtSignal(bool) + + def __init__(self, remote, job, publish, parent=None): + super().__init__(parent) + self.job = job + self.shadow = job["kind"] != "slackrepo" + self.setWindowTitle("Preview: " + job_title(dict(job, args=[ + a for a in job["args"] if a not in ("--preview", "--no")]))) + self.resize(900, 600) + self.decoder = codecs.getincrementaldecoder("utf-8")(errors="replace") + self.log = QPlainTextEdit(readOnly=True) + self.log.setFont(QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont)) + self.publish = QCheckBox("publish", checked=publish, visible=not self.shadow) + self.publish.setToolTip("Push to the mirror when the build ends; unticked, use Publish later") + self.run_btn = QPushButton("Apply shadowing" if self.shadow else "Run it", default=True) + self.run_btn.clicked.connect(self._run) + later = QPushButton("Not now") + later.clicked.connect(self.reject) + bar = QHBoxLayout() + bar.addWidget(self.publish) + bar.addStretch() + bar.addWidget(later) + bar.addWidget(self.run_btn) + layout = QVBoxLayout(self) + layout.addWidget(self.log) + layout.addLayout(bar) + self.proc = p = remote.follow(job["id"], 0) # the job has ended: the stream closes by itself + p.readyReadStandardOutput.connect(self._chunk) + p.finished.connect(self._ended) + + def _chunk(self): + data = bytes(self.proc.readAllStandardOutput()) + self.log.appendPlainText(self.decoder.decode(ANSI.sub(b"", data)).rstrip("\n")) + + def _ended(self, *_): + self._chunk() + self.proc.deleteLater() + if not self.has_plan(): + self.run_btn.setEnabled(False) + self.run_btn.setToolTip("no shadow: or unshadow: lines, nothing to apply") + self.loaded.emit(self.has_plan()) + + def has_plan(self): + return not self.shadow or bool(SHADOW_PLAN.search(self.log.toPlainText())) + + def confirm(self, question): + return QMessageBox.question(self, "Confirm", question) == QMessageBox.StandardButton.Yes + + def _run(self): + if self.shadow: + if not self.confirm("Apply the shadowing plan to the repo (sbshadow --yes)?"): + return + self.run.emit(["sbshadow", "--yes"]) + else: + args = [a for a in self.job["args"] if a != "--preview"] + if not self.publish.isChecked(): + args.insert(1, "--no-publish") + self.run.emit(["slackrepo", *args]) + self.accept() + + class JobsTab(QWidget): refresh_requested = pyqtSignal() + started = pyqtSignal(str) # id of a job this GUI started + preview_requested = pyqtSignal(dict) # a finished preview to take to its next step - def __init__(self, remote, publish=True, parent=None): + def __init__(self, remote, parent=None): super().__init__(parent) self.remote = remote self.pending = () @@ -46,25 +125,17 @@ class JobsTab(QWidget): self.proc = None self.decoder = codecs.getincrementaldecoder("utf-8")(errors="replace") - self.start_menu = QMenu(self) - start = QToolButton(text="Start", popupMode=QToolButton.ToolButtonPopupMode.InstantPopup) - start.setMenu(self.start_menu) - self.apply_btn = QPushButton("Apply shadowing") - self.apply_btn.clicked.connect(lambda: self.confirm_start( - "Apply the shadowing plan to the repo (sbshadow --yes)?", "sbshadow", "--yes")) - self.apply_btn.hide() - self.publish_box = QCheckBox("publish", checked=publish) - self.publish_box.setToolTip("Push to the mirror when the build ends; " - "unticked, use Publish later") - self.run_btn = QPushButton("Run it") - self.run_btn.clicked.connect(self.run_preview) - self.kill_btn = QPushButton("Kill") - self.kill_btn.clicked.connect(self.kill) - bar = QHBoxLayout() - for w in (start, self.apply_btn, self.run_btn, self.publish_box): - bar.addWidget(w) - bar.addStretch() - bar.addWidget(self.kill_btn) + self.pull_menu = QMenu(self) + self.act_pull = action(self, "Pull overlay", "folder-download", SP.SP_ArrowDown) + self.act_pull.setMenu(self.pull_menu) + self.act_run = action(self, "Run it", "media-playback-start", SP.SP_MediaPlay, + lambda: self.preview_requested.emit(self.jobs[self.current])) + self.act_kill = action(self, "Kill", "process-stop", SP.SP_BrowserStop, self.kill) + self.actions = [self.act_pull, + action(self, "Check shadowing", "edit-find", SP.SP_FileDialogContentsView, + lambda: self.start("sbshadow", "--no")), + self.act_run, self.act_kill] + self.view_actions = [] self.list = QListWidget() self.list.currentItemChanged.connect(self._on_select) @@ -76,19 +147,14 @@ class JobsTab(QWidget): split.addWidget(self.log) split.setSizes([300, 700]) layout = QVBoxLayout(self) - layout.addLayout(bar) layout.addWidget(split) self.set_overlays([]) def set_overlays(self, overlays): - m = self.start_menu - m.clear() + self.pull_menu.clear() for o in overlays: - m.addAction(f"sbpull {o} (preview)", lambda o=o: self.start("sbpull", o, "--no")) - m.addSeparator() - m.addAction("sbshadow (preview)", lambda: self.start("sbshadow", "--no")) - m.addAction("sbshadow (apply)…", lambda: self.confirm_start( - "Run sbshadow --yes on the repo?", "sbshadow", "--yes")) + self.pull_menu.addAction(f"{o} (preview)", lambda o=o: self.start("sbpull", o, "--no")) + enable(self.act_pull, None if overlays else "no overlays known yet") def set_jobs(self, jobs): self.jobs = {j["id"]: j for j in jobs} @@ -140,23 +206,12 @@ class JobsTab(QWidget): self.pending = args self.remote.call(self._started, "job-start", *args) - def confirm_start(self, question, *args): - if QMessageBox.question(self, "Confirm", question) == QMessageBox.StandardButton.Yes: - self.start(*args) - def kill(self): if QMessageBox.question(self, "Kill job", f"Send SIGTERM to {self.current}?") \ == QMessageBox.StandardButton.Yes: self.remote.call(lambda _d, err: err and QMessageBox.warning(self, "Kill failed", err), "job-kill", self.current) - def run_preview(self): - """Start the previewed slackrepo run for real.""" - args = [a for a in self.jobs[self.current]["args"] if a != "--preview"] - if not self.publish_box.isChecked(): - args.insert(1, "--no-publish") - self.start_signed("slackrepo", *args) - def ask_passphrase(self, retry): text, ok = QInputDialog.getText( self, "Signing key", ("Wrong passphrase, try again." if retry else @@ -206,6 +261,7 @@ class JobsTab(QWidget): self.select(data["busy"]) return self.select(data["id"]) + self.started.emit(data["id"]) self.refresh_requested.emit() def _on_select(self, item, _prev): @@ -243,11 +299,5 @@ class JobsTab(QWidget): def _update_buttons(self): j = self.jobs.get(self.current) - self.kill_btn.setEnabled(bool(j) and j["status"] == "running") - self.apply_btn.setVisible( - bool(j) and j["status"] == "ok" and "--no" in j["args"] - and bool(SHADOW_PLAN.search(self.log.toPlainText()))) - preview = bool(j) and j["kind"] == "slackrepo" and j["status"] == "ok" \ - and "--preview" in j["args"] - self.run_btn.setVisible(preview) - self.publish_box.setVisible(preview) + enable(self.act_kill, None if j and j["status"] == "running" else "select a running job") + enable(self.act_run, None if j and is_preview(j) else "select a finished preview") 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: diff --git a/sb_builder/packages.py b/sb_builder/packages.py index 64032a2..30215e0 100644 --- a/sb_builder/packages.py +++ b/sb_builder/packages.py @@ -20,13 +20,13 @@ import html from PyQt6.QtCore import QAbstractTableModel, QModelIndex, QSortFilterProxyModel, Qt, pyqtSignal from PyQt6.QtGui import QColor, QKeySequence, QShortcut -from PyQt6.QtWidgets import (QCheckBox, QDialog, QDialogButtonBox, QFormLayout, QHBoxLayout, - QLabel, QLineEdit, QMessageBox, QPushButton, QScrollArea, QSplitter, - QTableView, QVBoxLayout, QWidget) +from PyQt6.QtWidgets import (QHBoxLayout, QLabel, QLineEdit, QMessageBox, QPushButton, QScrollArea, + QSplitter, QTableView, QVBoxLayout, QWidget) from . import hints as hintlib from .dashboard import STATUS from .fmt import NA, fmt_time +from .ui import SP, action, enable OVERLAYS = ("personal", "pentesting") STATUSES = ("ok", "failed", "never", "outdated") @@ -230,9 +230,8 @@ class PackagesTab(QWidget): chips.addWidget(self.chips[name]) chips.addSpacing(16) chips.addStretch() - self.refresh_btn = QPushButton("Refresh") - self.refresh_btn.clicked.connect(self.reload) - chips.addWidget(self.refresh_btn) + self.count = QLabel() + chips.addWidget(self.count) self.table = QTableView(sortingEnabled=True) self.table.setModel(self.proxy) @@ -248,18 +247,14 @@ class PackagesTab(QWidget): alignment=Qt.AlignmentFlag.AlignTop) self.detail.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) self.detail.setContentsMargins(12, 8, 12, 8) - self.new_hint_btn = QPushButton("New hint") - self.new_hint_btn.clicked.connect(self.new_hint) - self.open_hint_btn = QPushButton("Open in Hints") - self.open_hint_btn.clicked.connect(lambda: self.hints_requested.emit(self.current()["name"])) - detail_bar = QHBoxLayout() - detail_bar.addWidget(self.new_hint_btn) - detail_bar.addWidget(self.open_hint_btn) - detail_bar.addStretch() + # navigation, not an action: a link in the pane (UI guidelines) + self.open_hint_btn = QLabel('Open in Hints') + self.open_hint_btn.setContentsMargins(12, 0, 12, 8) + self.open_hint_btn.linkActivated.connect(lambda _l: self.hints_requested.emit(self.current()["name"])) pane = QWidget() pane_layout = QVBoxLayout(pane) pane_layout.addWidget(self.detail) - pane_layout.addLayout(detail_bar) + pane_layout.addWidget(self.open_hint_btn) pane_layout.addStretch() scroll = QScrollArea(widgetResizable=True) scroll.setWidget(pane) @@ -269,37 +264,38 @@ class PackagesTab(QWidget): split.addWidget(scroll) split.setSizes([700, 400]) - self.count = QLabel() - bar = QHBoxLayout() - bar.addWidget(self.count) - self.keys = [] - for label, slot, key in (("Select visible", self.select_visible, "Ctrl+A"), - ("Clear", self.clear, "Ctrl+Shift+A")): - b = QPushButton(label, toolTip=key) - b.clicked.connect(slot) - bar.addWidget(b) - # in the search field Ctrl+A still selects its text: QLineEdit overrides it - self.keys.append(QShortcut(QKeySequence(key), self, slot, - context=Qt.ShortcutContext.WidgetWithChildrenShortcut)) - bar.addStretch() - for action in ("build", "update", "rebuild", "remove"): - b = QPushButton(action.capitalize()) - b.clicked.connect(lambda _c, a=action: self.act(a)) - bar.addWidget(b) - for label, slot in (("Update all", self.update_all), ("Publish", self.publish)): - b = QPushButton(label) - b.clicked.connect(slot) - bar.addWidget(b) + self.act_build = { + a: action(self, a.capitalize(), icon, fb, lambda a=a: self.act(a)) + for a, icon, fb in (("build", "run-build", SP.SP_MediaPlay), + ("update", "system-software-update", SP.SP_BrowserReload), + ("rebuild", "view-refresh", SP.SP_BrowserReload), + ("remove", "edit-delete", SP.SP_TrashIcon))} + self.act_new_hint = action(self, "New hint", "document-new", SP.SP_FileIcon, self.new_hint) + self.actions = [*self.act_build.values(), + action(self, "Update all", "system-upgrade", SP.SP_ArrowUp, self.update_all), + self.act_new_hint, + action(self, "Publish", "document-send", SP.SP_DialogSaveButton, self.publish)] + self.view_actions = [ + action(self, "Select visible", "edit-select-all", SP.SP_DialogYesButton, + self.select_visible, shortcut="Ctrl+A"), + action(self, "Clear", "edit-clear", SP.SP_DialogResetButton, self.clear, + shortcut="Ctrl+Shift+A"), + action(self, "Refresh", "view-refresh", SP.SP_BrowserReload, self.reload)] + # in the search field Ctrl+A still selects its text: QLineEdit overrides it + self.keys = [QShortcut(QKeySequence(key), self, slot, + context=Qt.ShortcutContext.WidgetWithChildrenShortcut) + for key, slot in (("Ctrl+A", self.select_visible), ("Ctrl+Shift+A", self.clear))] + self.loaded = False layout = QVBoxLayout(self) layout.addWidget(self.query) layout.addLayout(chips) - layout.addWidget(split) - layout.addLayout(bar) + layout.addWidget(split, 1) self.model.dataChanged.connect(lambda *_: self.update_count()) self.update_count() def reload(self): + self.loaded = True self.count.setText("loading…") self.remote.call(self._loaded, "inventory") @@ -317,6 +313,15 @@ class PackagesTab(QWidget): def update_count(self): self.count.setText(f"{self.proxy.rowCount():,} shown · {len(self.model.checked)} selected") + for a in self.act_build.values(): + enable(a, None if self.model.checked else "tick packages first") + enable(self.act_new_hint, None if self.without_hint() else + "none of the ticked packages is without a hint") + + def without_hint(self): + """Ticked packages that have no hint, sorted.""" + return sorted(r["name"] for r in self.model.rows + if r["name"] in self.model.checked and not r["hint_path"]) def _toggle(self, index): """A click anywhere on a row ticks or unticks it.""" @@ -360,30 +365,9 @@ class PackagesTab(QWidget): def show_detail(self, current, _previous): row = current.data(Qt.ItemDataRole.UserRole) if current.isValid() else None self.detail.setText(detail_html(row) if row else "") - self.new_hint_btn.setVisible(bool(row) and not row["hint_path"]) self.open_hint_btn.setVisible(bool(row) and bool(row["hint_path"])) - def ask_new_hint(self, name): - """(version or "", no download) for a new hint, None on cancel.""" - d = QDialog(self, windowTitle=f"New hint: {name}") - version = QLineEdit(placeholderText="the .info version") - no_dl = QCheckBox('no download (DOWNLOAD="no")') - buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok - | QDialogButtonBox.StandardButton.Cancel) - buttons.accepted.connect(d.accept) - buttons.rejected.connect(d.reject) - form = QFormLayout(d) - form.addRow("version", version) - form.addRow(no_dl) - form.addRow(buttons) - if d.exec() != QDialog.DialogCode.Accepted: - return None - return version.text().strip(), no_dl.isChecked() - def new_hint(self): - row = self.current() - answer = row and self.ask_new_hint(row["name"]) - if answer: - version, no_dl = answer - self.start_requested.emit(["hint-new", row["name"], *([version] if version else []), - *(["--no-dl"] if no_dl else [])]) + names = self.without_hint() + if names: + self.start_requested.emit(["hint-new", *names]) diff --git a/sb_builder/results.py b/sb_builder/results.py index 1fc6b9a..21fdcc3 100644 --- a/sb_builder/results.py +++ b/sb_builder/results.py @@ -21,12 +21,13 @@ import re from PyQt6.QtCore import Qt from PyQt6.QtGui import QColor, QFontDatabase, QTextCursor -from PyQt6.QtWidgets import (QDialog, QHBoxLayout, QLabel, QPlainTextEdit, QPushButton, - QSplitter, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget) +from PyQt6.QtWidgets import (QDialog, QHBoxLayout, QLabel, QPlainTextEdit, QSplitter, + QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget) from .dashboard import STATUS from .fmt import NA, fmt_time from .packages import chip +from .ui import SP, action, enable KINDS = ("ok", "failed", "bad", "aborted", "removed") ANSI = re.compile(r"\x1b\[[0-9;?]*[A-Za-z]") @@ -75,9 +76,10 @@ class ResultsTab(QWidget): bar.addStretch() self.count = QLabel() bar.addWidget(self.count) - refresh = QPushButton("Refresh") - refresh.clicked.connect(self.reload) - bar.addWidget(refresh) + self.act_log = action(self, "Show build log", "text-x-generic", SP.SP_FileIcon, self.show_log) + self.actions = [self.act_log] + self.view_actions = [action(self, "Refresh", "view-refresh", SP.SP_BrowserReload, self.reload)] + self.loaded = False self.table = QTableWidget(0, 4, sortingEnabled=True) self.table.setHorizontalHeaderLabels(["result", "package", "built version", "attempt"]) @@ -94,13 +96,9 @@ class ResultsTab(QWidget): self.detail = QLabel(textFormat=Qt.TextFormat.RichText, wordWrap=True, alignment=Qt.AlignmentFlag.AlignTop) self.detail.setContentsMargins(12, 8, 12, 8) - self.log_btn = QPushButton("Show build log") - self.log_btn.clicked.connect(self.show_log) - self.log_btn.setEnabled(False) side = QWidget() col = QVBoxLayout(side) col.addWidget(self.detail) - col.addWidget(self.log_btn) col.addStretch() split = QSplitter() split.addWidget(self.table) @@ -109,9 +107,11 @@ class ResultsTab(QWidget): layout = QVBoxLayout(self) layout.addLayout(bar) - layout.addWidget(split) + layout.addWidget(split, 1) + self.show_detail() def reload(self): + self.loaded = True self.count.setText("loading…") self.remote.call(self._loaded, "results") @@ -166,7 +166,7 @@ class ResultsTab(QWidget): def show_detail(self): row = self.current() - self.log_btn.setEnabled(bool(row and row[4])) + enable(self.act_log, None if row and row[4] else "select a result with a log") if not row: self.detail.setText("") return diff --git a/sb_builder/ui.py b/sb_builder/ui.py new file mode 100644 index 0000000..087d1a7 --- /dev/null +++ b/sb_builder/ui.py @@ -0,0 +1,46 @@ +# sb-builder - ribbon actions +# Copyright (C) 2026 Danilo M. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +"""Ribbon actions shared by the tabs (UI guidelines in AGENTS.md). + +Each tab owns two lists of QActions: `actions` for the main ribbon (what it +does, in workflow order) and `view_actions` for the small one (selection and +view controls). The main window shows the current tab's lists. +""" + +from PyQt6.QtGui import QAction, QIcon +from PyQt6.QtWidgets import QStyle + + +def action(parent, text, icon, fallback, slot=None, checkable=False, shortcut=None): + """A ribbon action: freedesktop icon with a QStyle fallback.""" + a = QAction(QIcon.fromTheme(icon, parent.style().standardIcon(fallback)), text, parent, + checkable=checkable) + if slot: + a.triggered.connect(lambda _checked=False: slot()) + a.setData(shortcut) # shown in the tooltip; the tab's QShortcut does the work + enable(a, None) + return a + + +def enable(a, reason): + """Enable a, or disable it with reason as its tooltip: disabled, never hidden.""" + a.setEnabled(not reason) + tip = a.text() + (f" ({a.data()})" if a.data() else "") + a.setToolTip(f"{tip}: {reason}" if reason else tip) + + +SP = QStyle.StandardPixmap -- cgit v1.2.3