diff --git a/app.py b/app.py index a9e9869..a9bb76e 100644 --- a/app.py +++ b/app.py @@ -1013,6 +1013,11 @@ class MainWindow(QMainWindow): self.help_agent.apply_theme() # chat body follows theme (header stays fixed) # ---- sizing ------------------------------------------------------ + # Share of the available screen the window takes when it has room to. Fixed + # pixels do not travel: 1180×760 fills a laptop and looks lost on a 4K + # panel. `want_*` stays the floor so a small screen behaves as before. + _SCREEN_SHARE_W, _SCREEN_SHARE_H = 0.80, 0.85 + def _fit_to_screen(self, want_w: int, want_h: int) -> None: screen = self.screen() or QGuiApplication.primaryScreen() avail = screen.availableGeometry() if screen else None @@ -1020,8 +1025,12 @@ class MainWindow(QMainWindow): self.resize(want_w, want_h) return margin = 60 - w = min(want_w, avail.width() - margin) - h = min(want_h, avail.height() - margin) + # Take a share of the screen, never less than the asked-for size and + # never more than the screen can show. + w = min(max(want_w, int(avail.width() * self._SCREEN_SHARE_W)), + avail.width() - margin) + h = min(max(want_h, int(avail.height() * self._SCREEN_SHARE_H)), + avail.height() - margin) # minimum must never exceed what the screen can show self.setMinimumSize(min(820, avail.width() - margin), min(520, avail.height() - margin)) self.resize(max(w, 1), max(h, 1)) @@ -1029,6 +1038,25 @@ class MainWindow(QMainWindow): frame.moveCenter(avail.center()) self.move(frame.topLeft()) + def moveEvent(self, event): # noqa: N802 - Qt override + super().moveEvent(event) + # Dragged to another monitor: its work area (and scaling) may differ, so + # the floating assistant re-pins and the panes re-decide if they fit. + self._on_screen_maybe_changed() + + def _on_screen_maybe_changed(self) -> None: + screen = self.screen() + if screen is getattr(self, "_last_screen", None): + return + self._last_screen = screen + avail = screen.availableGeometry() if screen else None + if avail is not None: + self.setMinimumSize(min(820, avail.width() - 60), + min(520, avail.height() - 60)) + if getattr(self, "help_agent", None) is not None: + self._update_dock_guard() + self.help_agent.reposition() + # ---- lifecycle --------------------------------------------------- def closeEvent(self, event) -> None: # noqa: N802 keep = (self.tray is not None diff --git a/tools/check_co4e.py b/tools/check_co4e.py index c956284..4864bce 100644 --- a/tools/check_co4e.py +++ b/tools/check_co4e.py @@ -191,4 +191,11 @@ def main() -> int: if __name__ == "__main__": - raise SystemExit(main()) + _rc = main() + # Qt (WebEngine especially) crashes during interpreter teardown with + # 0xC0000409 AFTER the work is done, which would mask the real result — + # and check_probes_bite reads these exit codes to decide whether a probe + # caught its mutation. Leave immediately with the verdict instead. + sys.stdout.flush() + sys.stderr.flush() + os._exit(_rc) diff --git a/tools/check_controls_alive.py b/tools/check_controls_alive.py index 31ead91..6f4cb81 100644 --- a/tools/check_controls_alive.py +++ b/tools/check_controls_alive.py @@ -199,4 +199,11 @@ def main() -> int: if __name__ == "__main__": - raise SystemExit(main()) + _rc = main() + # Qt (WebEngine especially) crashes during interpreter teardown with + # 0xC0000409 AFTER the work is done, which would mask the real result — + # and check_probes_bite reads these exit codes to decide whether a probe + # caught its mutation. Leave immediately with the verdict instead. + sys.stdout.flush() + sys.stderr.flush() + os._exit(_rc) diff --git a/tools/check_design_parity.py b/tools/check_design_parity.py index cf07c08..d97419b 100644 --- a/tools/check_design_parity.py +++ b/tools/check_design_parity.py @@ -353,4 +353,11 @@ def main() -> int: if __name__ == "__main__": - raise SystemExit(main()) + _rc = main() + # Qt (WebEngine especially) crashes during interpreter teardown with + # 0xC0000409 AFTER the work is done, which would mask the real result — + # and check_probes_bite reads these exit codes to decide whether a probe + # caught its mutation. Leave immediately with the verdict instead. + sys.stdout.flush() + sys.stderr.flush() + os._exit(_rc) diff --git a/tools/check_dialogs.py b/tools/check_dialogs.py index 247c01a..72be70f 100644 --- a/tools/check_dialogs.py +++ b/tools/check_dialogs.py @@ -127,4 +127,11 @@ def main() -> int: if __name__ == "__main__": - raise SystemExit(main()) + _rc = main() + # Qt (WebEngine especially) crashes during interpreter teardown with + # 0xC0000409 AFTER the work is done, which would mask the real result — + # and check_probes_bite reads these exit codes to decide whether a probe + # caught its mutation. Leave immediately with the verdict instead. + sys.stdout.flush() + sys.stderr.flush() + os._exit(_rc) diff --git a/tools/check_layout_geometry.py b/tools/check_layout_geometry.py index 52e1305..9df01e3 100644 --- a/tools/check_layout_geometry.py +++ b/tools/check_layout_geometry.py @@ -191,4 +191,11 @@ def main() -> int: if __name__ == "__main__": - raise SystemExit(main()) + _rc = main() + # Qt (WebEngine especially) crashes during interpreter teardown with + # 0xC0000409 AFTER the work is done, which would mask the real result — + # and check_probes_bite reads these exit codes to decide whether a probe + # caught its mutation. Leave immediately with the verdict instead. + sys.stdout.flush() + sys.stderr.flush() + os._exit(_rc) diff --git a/tools/check_multi_screen.py b/tools/check_multi_screen.py new file mode 100644 index 0000000..f159b19 --- /dev/null +++ b/tools/check_multi_screen.py @@ -0,0 +1,130 @@ +"""Does the layout adapt across screen sizes AND display scalings? + +Two things change between machines, and only one of them is width: + + * the screen is bigger or smaller — more or fewer pixels to lay out in; + * the display scale is 100/125/150% — the SAME number of logical pixels + holds less, because every label and margin is taller. + +A breakpoint written as a raw pixel number only holds on the machine it was +tuned on. This walks a grid of (window size × font scale) and, for each cell, +checks that no screen is clipped and that the panes folded when they had to. + +Run: python tools/check_multi_screen.py +""" +from __future__ import annotations + +import os +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO.parent)) +sys.path.insert(0, str(Path(__file__).resolve().parent)) +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from capture_screens import _freeze_schedulers, _isolate_home, _load_fonts # noqa: E402 + +# Real-world panels, from a small laptop up to 4K-at-150%-effective. +SIZES = [(1280, 720), (1366, 768), (1600, 900), (1920, 1080), (2560, 1440)] +# 9pt ≈ 100%, 11pt ≈ 125%, 14pt ≈ 150% of the design baseline. +POINTS = [9, 11, 14] + + +def main() -> int: + sandbox = _isolate_home() + from PySide6.QtGui import QFont + from PySide6.QtWidgets import QApplication + + app = QApplication([]) + _load_fonts() + _freeze_schedulers() + + from cowork_local.config import AppConfig, CONFIG_DIR + assert str(sandbox) in str(CONFIG_DIR), f"isolation failed: {CONFIG_DIR}" + + from seed_demo_data import seed + seed() + + from cowork_local.app import MainWindow + from cowork_local.i18n import set_language + from cowork_local.state import AppContext + from cowork_local.ui.widgets import ui_scale + + set_language("vi") + fails: list[str] = [] + print(f"{'co chu':>7} {'cua so':>11} {'thang do':>9} {'man bi bo':>10} panel da gap") + print("-" * 86) + + for pt in POINTS: + f = QFont(app.font()) + f.setPointSize(pt) + app.setFont(f) + win = MainWindow(AppContext(AppConfig.load()), user_name="local") + win.show() + for _ in range(6): + app.processEvents() + ws = win.workspace + dests = [("Project", win._ROW_WORKSPACE, ws._project_tab_idx), + ("Cowork", win._ROW_WORKSPACE, ws._cowork_tab_idx), + ("Co4E", win._ROW_WORKSPACE, ws._co4e_tab_idx), + ("Folder", win._ROW_WORKSPACE, ws._folder_tab_idx), + ("GraphRAG", win._ROW_WORKSPACE, ws._graphrag_tab_idx), + ("Schedule", win._ROW_SCHEDULE, None), + ("Dashboard", win._ROW_DASHBOARD, None), + ("Monitoring", win._ROW_MONITORING, None)] + + for w, h in SIZES: + win.resize(w, h) + for _ in range(6): + app.processEvents() + clipped = [] + for name, page, sub in dests: + win._goto(page, sub) + for _ in range(4): + app.processEvents() + widget = win._page_widgets[page] + if widget.minimumSizeHint().width() > widget.width() + 1: + clipped.append(name) + folded = [] + if getattr(ws, "_is_narrow", False): + folded.append("pane Project/History") + import cowork_local.ui.co4e_tab as co4e_mod + c4 = win.findChildren(co4e_mod.Co4ETab)[0] + if c4._config_collapsed: + folded.append("panel cau hinh Co4E") + scale = ui_scale(win) + print(f"{pt:>5}pt {w:>5}x{h:<5} {scale:>8.2f} " + f"{(', '.join(clipped) or 'khong'):>10} {', '.join(folded) or '-'}") + if clipped: + fails.append(f"{pt}pt {w}x{h}: bi bo — {clipped}") + # The window must never demand more than the smallest panel we support. + need = win.minimumSizeHint().width() + if need > SIZES[0][0]: + fails.append(f"{pt}pt: cua so doi toi thieu {need}px, " + f"rong hon man nho nhat ({SIZES[0][0]}px)") + print(f"{'':>7} {'':>11} {'':>9} cua so doi toi thieu: {need}px") + win.close() + del win + for _ in range(3): + app.processEvents() + + print() + if fails: + print("*** KHONG THICH UNG DUOC ***") + for x in fails: + print(" " + x) + return 1 + print("KET QUA: bo cuc thich ung o moi co man hinh va muc phong chu da thu") + return 0 + + +if __name__ == "__main__": + _rc = main() + # Qt (WebEngine especially) crashes during interpreter teardown with + # 0xC0000409 AFTER the work is done, which would mask the real result — + # and check_probes_bite reads these exit codes to decide whether a probe + # caught its mutation. Leave immediately with the verdict instead. + sys.stdout.flush() + sys.stderr.flush() + os._exit(_rc) diff --git a/tools/check_nav.py b/tools/check_nav.py index 6f83e20..330b7aa 100644 --- a/tools/check_nav.py +++ b/tools/check_nav.py @@ -298,4 +298,11 @@ def main() -> int: if __name__ == "__main__": - raise SystemExit(main()) + _rc = main() + # Qt (WebEngine especially) crashes during interpreter teardown with + # 0xC0000409 AFTER the work is done, which would mask the real result — + # and check_probes_bite reads these exit codes to decide whether a probe + # caught its mutation. Leave immediately with the verdict instead. + sys.stdout.flush() + sys.stderr.flush() + os._exit(_rc) diff --git a/tools/check_no_hscroll.py b/tools/check_no_hscroll.py index 6e92c26..1d68c96 100644 --- a/tools/check_no_hscroll.py +++ b/tools/check_no_hscroll.py @@ -113,4 +113,11 @@ def main() -> int: if __name__ == "__main__": - raise SystemExit(main()) + _rc = main() + # Qt (WebEngine especially) crashes during interpreter teardown with + # 0xC0000409 AFTER the work is done, which would mask the real result — + # and check_probes_bite reads these exit codes to decide whether a probe + # caught its mutation. Leave immediately with the verdict instead. + sys.stdout.flush() + sys.stderr.flush() + os._exit(_rc) diff --git a/ui/co4e_tab.py b/ui/co4e_tab.py index 2b013f2..2b6c142 100644 --- a/ui/co4e_tab.py +++ b/ui/co4e_tab.py @@ -966,7 +966,7 @@ class Co4ETab(QWidget): super().showEvent(e) self._narrow_guard.attach() - def _apply_narrow_layout(self, narrow: bool) -> None: + def _apply_narrow_layout(self, narrow: bool) -> None: # noqa: D401 """Fold the step-config panel on a narrow window, restore it when there is room again. diff --git a/ui/schedule_task_tab.py b/ui/schedule_task_tab.py index 8d888d4..05f955f 100644 --- a/ui/schedule_task_tab.py +++ b/ui/schedule_task_tab.py @@ -16,8 +16,8 @@ from PySide6.QtCore import Qt, Signal from PySide6.QtWidgets import ( QAbstractItemView, QComboBox, QDialog, QDialogButtonBox, QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem, QMenu, QMessageBox, - QPlainTextEdit, QPushButton, QScrollArea, QStackedWidget, QTabBar, - QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, + QPlainTextEdit, QPushButton, QScrollArea, QSizePolicy, QStackedWidget, + QTabBar, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, ) from ..core import tasks as taskrepo @@ -89,6 +89,13 @@ class ScheduleTaskTab(QWidget): self._title.setStyleSheet("font-weight:700; font-size:15px;") self.counts_lbl = QLabel("") self.counts_lbl.setObjectName("hint") + # A one-line summary of every lane's count. Left to size itself it + # reported a sizeHint wide enough to set the MINIMUM width of the whole + # screen — 1285px at 150% scaling, which then became the window's + # minimum and stopped the app fitting a 1280px laptop. It is a summary, + # and the same numbers are on each lane header, so it gives way first. + self.counts_lbl.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Preferred) + self.counts_lbl.setMinimumWidth(0) self.add_btn = QPushButton() self.add_btn.setIcon(icon("plus")) self.add_btn.setObjectName("primary") @@ -241,8 +248,10 @@ class ScheduleTaskTab(QWidget): empty = QListWidgetItem(tr("schedtask.no_tasks")) empty.setFlags(Qt.NoItemFlags) col.addItem(empty) - self.counts_lbl.setText(" ".join( - f"{tr(f'schedtask.status.{s}')}: {counts[s]}" for s in STATUSES if counts[s])) + summary = " ".join( + f"{tr(f'schedtask.status.{s}')}: {counts[s]}" for s in STATUSES if counts[s]) + self.counts_lbl.setText(summary) + self.counts_lbl.setToolTip(summary) # full text stays reachable if clipped self.calendar.set_tasks(all_tasks) # ---- actions -------------------------------------------------------- diff --git a/ui/widgets.py b/ui/widgets.py index db1f8f2..35c80ac 100644 --- a/ui/widgets.py +++ b/ui/widgets.py @@ -159,6 +159,21 @@ def guard_wheel(root: QWidget) -> None: w.installEventFilter(_wheel_guard) +def ui_scale(widget: QWidget) -> float: + """How much bigger this machine draws things than the design baseline. + + Breakpoints written as raw pixels only hold on the screen they were tuned + on. At 125%/150% display scaling Qt still reports logical pixels, but every + label, button and margin is taller — so the same layout needs MORE logical + width before it stops being cramped. Font height is the honest proxy for + that: it moves with the display scale and with a user's font-size choice, + both of which change how much fits. + + 1.0 at the 15px line height the layouts were measured against. + """ + return max(0.75, min(2.5, widget.fontMetrics().height() / 15.0)) + + class _NarrowGuard(QObject): """Calls back when the WINDOW crosses a width threshold. @@ -184,6 +199,11 @@ class _NarrowGuard(QObject): if win is not None and win is not self._owner and win is not self._window: win.installEventFilter(self) self._window = win + # Dragging the window to a monitor with different scaling changes + # how much fits without changing its width, so re-decide then too. + handle = win.windowHandle() + if handle is not None: + handle.screenChanged.connect(lambda *_a: self.check()) self.check() def eventFilter(self, obj, ev): # noqa: N802 - Qt override @@ -194,7 +214,9 @@ class _NarrowGuard(QObject): def check(self) -> None: win = self._owner.window() width = win.width() if win is not None else self._owner.width() - narrow = width < self._threshold + # The threshold is written for the baseline scale and grows with the + # machine's — see ui_scale(). + narrow = width < self._threshold * ui_scale(self._owner) if narrow == self._auto: return self._auto = narrow