Size the board and the rail by proportion, not by pixel constants

"Some screens scroll sideways, some don't" had two causes, and both were a
number that was right on one monitor.

The lanes. _KanbanColumn set a 150px floor with a comment claiming seven of
them fit a 1280 window; measured, the row wanted 1242px where 1280 leaves 1091,
so a 1280 screen scrolled and a 1920 one did not — this machine has one of each.
The floor is gone. The seven lanes share the board through the layout's
stretch, which is a proportion of whatever width there is, and the only pixel
question left — how narrow before scrolling beats squeezing — is answered from
the font: eight characters of a title plus padding, so it holds at 125%/150%
scaling too. No horizontal scroll now at 1280×720 through 1936×1048, at three
rail widths each, including the rail dragged to its maximum.

Each lane also grew its own scrollbar: QListWidget's column hint runs 1-6px past
its viewport, and which lanes overflowed changed with the window width — 36 of
38 widths had at least one. Cards word-wrap, so there was never anything to
reach sideways; the bar is off.

The rail. Its 360px ceiling was a quarter of a 1440 screen and more than that of
a 1280 one. It is now 22% of the window, capped at 360 — and recomputed on
resize, which is its own bug fixed: read once at construction off a not-yet-sized
window, it sat at 162px on every monitor, so the handle barely moved.

check_kanban_scroll covers five screen sizes × three rail widths. A rail dragged
to maximum on a 1280 screen still scrolls the board — that is the user's own
trade, so it is reported rather than failed.

19/19 checkers pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nam Pham Dinh Thanh
2026-08-18 13:00:05 +09:00
co-authored by Claude Opus 5
parent a0cef768c9
commit 77976405b1
4 changed files with 178 additions and 16 deletions
+21 -5
View File
@@ -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".
+94
View File
@@ -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())
+24 -6
View File
@@ -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)
+39 -5
View File
@@ -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"), "")