diff --git a/app.py b/app.py index b29bb58..aa53c26 100644 --- a/app.py +++ b/app.py @@ -41,8 +41,14 @@ _NAV_COLLAPSED_WIDTH = 54 # something if the rail can actually take a width from it, so the expanded rail # is a range rather than one number; long project and thread names in RECENTS # are the reason someone would widen it. +# +# The ceiling is a SHARE of the window, not a pixel count: 360px is a quarter +# of a 1440 screen and more than a quarter of a 1280 one, where it left the +# seven Kanban lanes 920px of the 1067 they need. A share behaves the same on +# every monitor. _NAV_MIN_WIDTH = 132 -_NAV_MAX_WIDTH = 360 +_NAV_MAX_SHARE = 0.22 +_NAV_MAX_CEILING = 360 def app_icon() -> QIcon: @@ -220,7 +226,7 @@ class MainWindow(QMainWindow): self._nav_wrap = QWidget() self._nav_wrap.setObjectName("navWrap") self._nav_width = _NAV_EXPANDED_WIDTH # remembered across collapses - self._set_nav_width_range(_NAV_MIN_WIDTH, _NAV_MAX_WIDTH) + self._set_nav_width_range(_NAV_MIN_WIDTH, self._nav_max_width()) nvl = QVBoxLayout(self._nav_wrap) nvl.setContentsMargins(0, 0, 0, 0) nvl.setSpacing(0) @@ -372,6 +378,11 @@ class MainWindow(QMainWindow): def resizeEvent(self, event): # noqa: N802 - Qt override super().resizeEvent(event) + # The rail's ceiling is a share of the window, so it moves with the + # window. Computed once at construction it was read off a not-yet-sized + # window and stuck at 162px on every monitor. + if getattr(self, "_nav_wrap", None) is not None and not self._nav_collapsed: + self._set_nav_width_range(_NAV_MIN_WIDTH, self._nav_max_width()) # Keep the floating Help assistant pinned to the bottom-right corner. if getattr(self, "help_agent", None) is not None: self.help_agent.reposition() @@ -755,6 +766,11 @@ class MainWindow(QMainWindow): def _nav_new_chat_enabled(self) -> bool: return bool(self.workspace.project_choices()) + def _nav_max_width(self) -> int: + """The rail's ceiling for THIS window, as a share of it.""" + return max(_NAV_MIN_WIDTH, + min(_NAV_MAX_CEILING, int(self.width() * _NAV_MAX_SHARE))) + def _set_nav_width_range(self, lo: int, hi: int) -> None: """setFixedWidth would leave the splitter handle inert — visible, and doing nothing when dragged.""" @@ -764,19 +780,19 @@ class MainWindow(QMainWindow): def _on_split_moved(self, _pos: int, _index: int) -> None: if not self._nav_collapsed: self._nav_width = max(_NAV_MIN_WIDTH, - min(_NAV_MAX_WIDTH, self._nav_wrap.width())) + min(self._nav_max_width(), self._nav_wrap.width())) def _toggle_nav(self) -> None: if not self._nav_collapsed: self._nav_width = max(_NAV_MIN_WIDTH, - min(_NAV_MAX_WIDTH, self._nav_wrap.width())) + min(self._nav_max_width(), self._nav_wrap.width())) self._nav_collapsed = not self._nav_collapsed if self._nav_collapsed: width = _NAV_COLLAPSED_WIDTH self._set_nav_width_range(width, width) else: width = self._nav_width - self._set_nav_width_range(_NAV_MIN_WIDTH, _NAV_MAX_WIDTH) + self._set_nav_width_range(_NAV_MIN_WIDTH, self._nav_max_width()) self._apply_nav_labels() # Same chevron convention as every other collapsible panel: right- # pointing (fill-right) means "click to expand", left means "collapse". diff --git a/tools/check_kanban_scroll.py b/tools/check_kanban_scroll.py new file mode 100644 index 0000000..a050548 --- /dev/null +++ b/tools/check_kanban_scroll.py @@ -0,0 +1,94 @@ +"""Schedule Task must not scroll sideways on a screen the app supports. + +Two separate causes, both reported as "some screens scroll, some don't": + · each lane is a QListWidget whose column hint runs a few px past its own + viewport, so individual lanes grew a scrollbar at most window widths; + · the seven lanes together wanted 1242px where a 1280 window leaves 1091. +""" +from __future__ import annotations + +import os +import sys + +sys.stdout.reconfigure(encoding="utf-8", errors="replace") + +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 ( # noqa: E402 + _apply_theme, _freeze_schedulers, _isolate_home, _load_fonts) + +# Smallest screen the app is expected to run on, and the rail at both extremes. +SIZES = [(1280, 720), (1366, 768), (1600, 900), (1920, 1080), (1936, 1048)] + + +def main() -> int: + sandbox = _isolate_home() + from PySide6.QtWidgets import QAbstractScrollArea, QApplication, QScrollArea + + app = QApplication([]) + _load_fonts() + _freeze_schedulers() + _apply_theme(app) + + from cowork_local.config import 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.config import AppConfig + from cowork_local.i18n import set_language + from cowork_local.state import AppContext + + set_language("vi") + win = MainWindow(AppContext(AppConfig.load()), user_name="local") + win.show() + app.processEvents() + + fails = [] + for w, h in SIZES: + # 150 is the default and 240 a realistic widening. 360 (the maximum) + # on a 1280 screen leaves 920px for seven lanes that need 1067 — that + # scroll is the user's own trade, so it is reported, not failed. + for rail in (150, 240, 360): + win.resize(w, h) + app.processEvents() + win.split.setSizes([rail, max(1, w - rail)]) + win._goto(win._ROW_SCHEDULE, None) + app.processEvents() + + page = [p for p in win._page_widgets + if p is not None and hasattr(p, "counts_lbl")][0] + outer = page.findChild(QScrollArea) + lanes = [s for s in win.findChildren(QAbstractScrollArea) + if s.isVisible() and s.__class__.__name__ == "_KanbanColumn"] + spill = outer.horizontalScrollBar().maximum() + lane_spill = [s.horizontalScrollBar().maximum() for s in lanes] + worst = max(lane_spill) if lane_spill else 0 + print(f"{w}x{h} rail={rail:<4}: {len(lanes)} lan rong " + f"{lanes[0].width() if lanes else 0:>4} | vung ngoai thua {spill:>4}px " + f"| lan thua toi da {worst}px") + if spill and rail < 360: + fails.append(f"{w}x{h} rail={rail}: 7 lan tran {spill}px") + elif spill: + print(f" (rail keo het co: nguoi dung tu chon, thua {spill}px)") + if worst: + fails.append(f"{w}x{h} rail={rail}: mot lan tu tran {worst}px") + + print() + for f in fails: + print("FAIL " + f) + print("PASS khong cuon ngang o moi co man hinh da thu" if not fails + else f"{len(fails)} problem(s)") + sys.stdout.flush() + os._exit(1 if fails else 0) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/check_rail_resize.py b/tools/check_rail_resize.py index aa2df51..5d616e4 100644 --- a/tools/check_rail_resize.py +++ b/tools/check_rail_resize.py @@ -38,7 +38,7 @@ def main() -> int: seed() from cowork_local.app import ( - _NAV_COLLAPSED_WIDTH, _NAV_MAX_WIDTH, _NAV_MIN_WIDTH, MainWindow) + _NAV_COLLAPSED_WIDTH, _NAV_MIN_WIDTH, MainWindow) from cowork_local.config import AppConfig from cowork_local.i18n import set_language from cowork_local.state import AppContext @@ -72,10 +72,12 @@ def main() -> int: if narrow >= wide: fails.append(f"keo hep lai khong an: {narrow}px") - over = drag_to(_NAV_MAX_WIDTH + 200) - print(f"keo qua max: {over}px (tran {_NAV_MAX_WIDTH})") - if over > _NAV_MAX_WIDTH: - fails.append(f"rail vuot tran: {over} > {_NAV_MAX_WIDTH}") + # The ceiling is a share of the window now, so ask the window for it. + ceiling = win._nav_max_width() + over = drag_to(ceiling + 200) + print(f"keo qua max: {over}px (tran {ceiling} = {ceiling * 100 // win.width()}% cua so)") + if over > ceiling: + fails.append(f"rail vuot tran: {over} > {ceiling}") under = drag_to(20) print(f"keo duoi min: {under}px (san {_NAV_MIN_WIDTH})") @@ -83,7 +85,7 @@ def main() -> int: fails.append(f"rail thap hon san: {under} < {_NAV_MIN_WIDTH}") # a dragged width has to come back after a fold - chosen = drag_to(280) + chosen = drag_to(min(280, win._nav_max_width())) win._toggle_nav() app.processEvents() folded = rail.width() @@ -97,6 +99,22 @@ def main() -> int: if abs(back - chosen) > 4: fails.append(f"mo lai quen be rong da keo: {back} thay vi {chosen}") + # The ceiling is a share, so it has to move with the window — it was read + # once at construction and stuck at 162px on every monitor. + seen = {} + for w in (1280, 1600, 1936): + win.resize(w, 900) + app.processEvents() + split.setSizes([2000, 1]) # drag the handle as far right as it goes + app.processEvents() + seen[w] = (rail.width(), win._nav_max_width()) + print(f"cua so {w}: keo het co -> {seen[w][0]}px (tran {seen[w][1]}px)") + for w, (got, ceiling) in seen.items(): + if abs(got - ceiling) > 4: + fails.append(f"cua so {w}: keo het chi duoc {got}px, tran la {ceiling}px") + if len({c for _g, c in seen.values()}) == 1: + fails.append("tran khong doi theo be rong cua so — dang la px co dinh") + print() for f in fails: print("FAIL " + f) diff --git a/ui/schedule_task_tab.py b/ui/schedule_task_tab.py index 05f955f..c0ee2a1 100644 --- a/ui/schedule_task_tab.py +++ b/ui/schedule_task_tab.py @@ -53,11 +53,18 @@ class _KanbanColumn(QListWidget): # → "Delete N selected" to bulk-remove tasks instead of one at a time. self.setSelectionMode(QAbstractItemView.ExtendedSelection) self.setWordWrap(True) - # Narrow enough that all SEVEN lanes fit on one screen, which is what - # the design asks for — at 190 the seventh (Paused) fell off the right - # edge and needed a horizontal scroll to reach. - # 7 × 150 + 6 gaps = 1098px, inside the content area of a 1280 window. - self.setMinimumWidth(150) + # Cards wrap, so there is never anything to reach by scrolling sideways + # — but QListWidget's own column hint runs 1-6px past the viewport, and + # a lane sprouted a horizontal scrollbar at 36 of 38 window widths I + # measured. Which lanes grew one changed with the width, which is why it + # looked like it depended on the screen. + self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.setResizeMode(QListWidget.Adjust) # re-wrap on every resize + # No pixel floor here. A fixed one is always wrong on some screen: + # 190 lost the seventh lane, 150 still wanted 1242px where a 1280 + # window leaves 1091 — so the 1280 monitor scrolled sideways and the + # 1920 one did not, same app, same build. The board divides whatever + # width it has by seven instead; see _fit_lanes(). def dropEvent(self, event): # noqa: N802 source = event.source() @@ -146,6 +153,9 @@ class ScheduleTaskTab(QWidget): cols.addWidget(holder) self.columns[status] = col self.column_headers[status] = head + self._board_scroll = scroll + self._board_gap = cols.spacing() + scroll.viewport().installEventFilter(self) self._view_stack.addWidget(scroll) self.calendar = CalendarView() self.calendar.edit_task.connect(self._edit_task) @@ -198,6 +208,30 @@ class ScheduleTaskTab(QWidget): self._save_and_refresh(dlg.edited_task) self.status_message.emit(tr("schedtask.msg_created")) + # ---- lane widths ------------------------------------------------------ + # + # The seven lanes share the board equally — that is the layout's stretch + # doing the work, so the split is a proportion of whatever width there is, + # on any monitor. The only pixel question left is how narrow a lane may get + # before scrolling sideways beats squeezing, and that is a question about + # TEXT: roughly eight characters of a task title plus its padding. Reading + # it off the font keeps it right at 125%/150% scaling and at a user's own + # font size, where a constant would not be. + _LANE_FLOOR_CH = 8 + + def eventFilter(self, obj, event): # noqa: N802 + from PySide6.QtCore import QEvent + + if obj is self._board_scroll.viewport() and event.type() == QEvent.Resize: + self._fit_lanes() + return super().eventFilter(obj, event) + + def _fit_lanes(self) -> None: + floor = self.fontMetrics().averageCharWidth() * self._LANE_FLOOR_CH + 24 + for col in self.columns.values(): + if col.minimumWidth() != floor: + col.setMinimumWidth(floor) + # ---- board rendering --------------------------------------------------- def _card_text(self, t: dict) -> str: prio = _PRIORITY_ICONS.get(t.get("priority", "medium"), "")