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:
co-authored by
Claude Opus 5
parent
a0cef768c9
commit
77976405b1
@@ -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".
|
||||
|
||||
@@ -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())
|
||||
@@ -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
@@ -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"), "")
|
||||
|
||||
Reference in New Issue
Block a user