Feature/fsg gamma team ui fix #3

Merged
gitea-admin merged 41 commits from feature/FSG_GammaTeam_UI_Fix into main 2026-08-20 12:12:59 +00:00
12 changed files with 253 additions and 15 deletions
Showing only changes of commit b6dee044c9 - Show all commits
+30 -2
View File
@@ -1013,6 +1013,11 @@ class MainWindow(QMainWindow):
self.help_agent.apply_theme() # chat body follows theme (header stays fixed) self.help_agent.apply_theme() # chat body follows theme (header stays fixed)
# ---- sizing ------------------------------------------------------ # ---- 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: def _fit_to_screen(self, want_w: int, want_h: int) -> None:
screen = self.screen() or QGuiApplication.primaryScreen() screen = self.screen() or QGuiApplication.primaryScreen()
avail = screen.availableGeometry() if screen else None avail = screen.availableGeometry() if screen else None
@@ -1020,8 +1025,12 @@ class MainWindow(QMainWindow):
self.resize(want_w, want_h) self.resize(want_w, want_h)
return return
margin = 60 margin = 60
w = min(want_w, avail.width() - margin) # Take a share of the screen, never less than the asked-for size and
h = min(want_h, avail.height() - margin) # 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 # minimum must never exceed what the screen can show
self.setMinimumSize(min(820, avail.width() - margin), min(520, avail.height() - margin)) self.setMinimumSize(min(820, avail.width() - margin), min(520, avail.height() - margin))
self.resize(max(w, 1), max(h, 1)) self.resize(max(w, 1), max(h, 1))
@@ -1029,6 +1038,25 @@ class MainWindow(QMainWindow):
frame.moveCenter(avail.center()) frame.moveCenter(avail.center())
self.move(frame.topLeft()) 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 --------------------------------------------------- # ---- lifecycle ---------------------------------------------------
def closeEvent(self, event) -> None: # noqa: N802 def closeEvent(self, event) -> None: # noqa: N802
keep = (self.tray is not None keep = (self.tray is not None
+8 -1
View File
@@ -191,4 +191,11 @@ def main() -> int:
if __name__ == "__main__": 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)
+8 -1
View File
@@ -199,4 +199,11 @@ def main() -> int:
if __name__ == "__main__": 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)
+8 -1
View File
@@ -353,4 +353,11 @@ def main() -> int:
if __name__ == "__main__": 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)
+8 -1
View File
@@ -127,4 +127,11 @@ def main() -> int:
if __name__ == "__main__": 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)
+8 -1
View File
@@ -191,4 +191,11 @@ def main() -> int:
if __name__ == "__main__": 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)
+130
View File
@@ -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)
+8 -1
View File
@@ -298,4 +298,11 @@ def main() -> int:
if __name__ == "__main__": 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)
+8 -1
View File
@@ -113,4 +113,11 @@ def main() -> int:
if __name__ == "__main__": 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)
+1 -1
View File
@@ -966,7 +966,7 @@ class Co4ETab(QWidget):
super().showEvent(e) super().showEvent(e)
self._narrow_guard.attach() 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 """Fold the step-config panel on a narrow window, restore it when there
is room again. is room again.
+13 -4
View File
@@ -16,8 +16,8 @@ from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QAbstractItemView, QComboBox, QDialog, QDialogButtonBox, QHBoxLayout, QAbstractItemView, QComboBox, QDialog, QDialogButtonBox, QHBoxLayout,
QLabel, QLineEdit, QListWidget, QListWidgetItem, QMenu, QMessageBox, QLabel, QLineEdit, QListWidget, QListWidgetItem, QMenu, QMessageBox,
QPlainTextEdit, QPushButton, QScrollArea, QStackedWidget, QTabBar, QPlainTextEdit, QPushButton, QScrollArea, QSizePolicy, QStackedWidget,
QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, QTabBar, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget,
) )
from ..core import tasks as taskrepo from ..core import tasks as taskrepo
@@ -89,6 +89,13 @@ class ScheduleTaskTab(QWidget):
self._title.setStyleSheet("font-weight:700; font-size:15px;") self._title.setStyleSheet("font-weight:700; font-size:15px;")
self.counts_lbl = QLabel("") self.counts_lbl = QLabel("")
self.counts_lbl.setObjectName("hint") 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 = QPushButton()
self.add_btn.setIcon(icon("plus")) self.add_btn.setIcon(icon("plus"))
self.add_btn.setObjectName("primary") self.add_btn.setObjectName("primary")
@@ -241,8 +248,10 @@ class ScheduleTaskTab(QWidget):
empty = QListWidgetItem(tr("schedtask.no_tasks")) empty = QListWidgetItem(tr("schedtask.no_tasks"))
empty.setFlags(Qt.NoItemFlags) empty.setFlags(Qt.NoItemFlags)
col.addItem(empty) col.addItem(empty)
self.counts_lbl.setText(" ".join( summary = " ".join(
f"{tr(f'schedtask.status.{s}')}: {counts[s]}" for s in STATUSES if counts[s])) 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) self.calendar.set_tasks(all_tasks)
# ---- actions -------------------------------------------------------- # ---- actions --------------------------------------------------------
+23 -1
View File
@@ -159,6 +159,21 @@ def guard_wheel(root: QWidget) -> None:
w.installEventFilter(_wheel_guard) 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): class _NarrowGuard(QObject):
"""Calls back when the WINDOW crosses a width threshold. """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: if win is not None and win is not self._owner and win is not self._window:
win.installEventFilter(self) win.installEventFilter(self)
self._window = win 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() self.check()
def eventFilter(self, obj, ev): # noqa: N802 - Qt override def eventFilter(self, obj, ev): # noqa: N802 - Qt override
@@ -194,7 +214,9 @@ class _NarrowGuard(QObject):
def check(self) -> None: def check(self) -> None:
win = self._owner.window() win = self._owner.window()
width = win.width() if win is not None else self._owner.width() 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: if narrow == self._auto:
return return
self._auto = narrow self._auto = narrow