Let the splitter handle beside the rail actually resize it

The rail was setFixedWidth, so the handle QSplitter draws between it and the
content was inert — it looked draggable and did nothing when dragged.

The expanded rail is now a range (132–360px) instead of one number, so the
handle has something to give. Collapsing still pins it at 54px exactly, and a
width dragged to is remembered across the fold rather than snapping back to the
150px default. Long project names and RECENTS threads are what a wider rail
buys.

check_rail_resize drives the splitter the way the handle does and asserts the
rail follows, clamps at both ends, folds to 54px and comes back to the chosen
width. Restoring setFixedWidth fails it — the first attempt at that mutation
passed, because setMaximumWidth on the next line quietly undid it.

16/16 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 11:55:35 +09:00
co-authored by Claude Opus 5
parent bcdfe43c7c
commit d37a0c1578
2 changed files with 139 additions and 3 deletions
+29 -3
View File
@@ -37,6 +37,12 @@ ASSETS = Path(__file__).resolve().parent / "assets"
# shows icon-only (still fully clickable, just narrower). # shows icon-only (still fully clickable, just narrower).
_NAV_EXPANDED_WIDTH = 150 _NAV_EXPANDED_WIDTH = 150
_NAV_COLLAPSED_WIDTH = 54 _NAV_COLLAPSED_WIDTH = 54
# The splitter between rail and content draws a drag handle. It only means
# 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.
_NAV_MIN_WIDTH = 132
_NAV_MAX_WIDTH = 360
def app_icon() -> QIcon: def app_icon() -> QIcon:
@@ -213,7 +219,8 @@ class MainWindow(QMainWindow):
self._collapse_right_icon = collapse_right_icon self._collapse_right_icon = collapse_right_icon
self._nav_wrap = QWidget() self._nav_wrap = QWidget()
self._nav_wrap.setObjectName("navWrap") self._nav_wrap.setObjectName("navWrap")
self._nav_wrap.setFixedWidth(_NAV_EXPANDED_WIDTH) self._nav_width = _NAV_EXPANDED_WIDTH # remembered across collapses
self._set_nav_width_range(_NAV_MIN_WIDTH, _NAV_MAX_WIDTH)
nvl = QVBoxLayout(self._nav_wrap) nvl = QVBoxLayout(self._nav_wrap)
nvl.setContentsMargins(0, 0, 0, 0) nvl.setContentsMargins(0, 0, 0, 0)
nvl.setSpacing(0) nvl.setSpacing(0)
@@ -326,6 +333,7 @@ class MainWindow(QMainWindow):
self.split.setStretchFactor(0, 0) self.split.setStretchFactor(0, 0)
self.split.setStretchFactor(1, 1) self.split.setStretchFactor(1, 1)
self.split.setSizes([_NAV_EXPANDED_WIDTH, 1000]) self.split.setSizes([_NAV_EXPANDED_WIDTH, 1000])
self.split.splitterMoved.connect(self._on_split_moved)
self.setCentralWidget(self.split) self.setCentralWidget(self.split)
# Landing stays Workspace ▸ Project, exactly as before. Go through _goto # Landing stays Workspace ▸ Project, exactly as before. Go through _goto
# so the page is actually shown — selecting the row alone only moves the # so the page is actually shown — selecting the row alone only moves the
@@ -747,10 +755,28 @@ class MainWindow(QMainWindow):
def _nav_new_chat_enabled(self) -> bool: def _nav_new_chat_enabled(self) -> bool:
return bool(self.workspace.project_choices()) return bool(self.workspace.project_choices())
def _set_nav_width_range(self, lo: int, hi: int) -> None:
"""setFixedWidth would leave the splitter handle inert — visible, and
doing nothing when dragged."""
self._nav_wrap.setMinimumWidth(lo)
self._nav_wrap.setMaximumWidth(hi)
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()))
def _toggle_nav(self) -> None: 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()))
self._nav_collapsed = not self._nav_collapsed self._nav_collapsed = not self._nav_collapsed
width = _NAV_COLLAPSED_WIDTH if self._nav_collapsed else _NAV_EXPANDED_WIDTH if self._nav_collapsed:
self._nav_wrap.setFixedWidth(width) 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._apply_nav_labels() self._apply_nav_labels()
# Same chevron convention as every other collapsible panel: right- # Same chevron convention as every other collapsible panel: right-
# pointing (fill-right) means "click to expand", left means "collapse". # pointing (fill-right) means "click to expand", left means "collapse".
+110
View File
@@ -0,0 +1,110 @@
"""The splitter handle beside the rail has to actually move the rail.
setFixedWidth left it drawn but inert: it looked draggable and did nothing.
Also checks that a width the user drags to survives a collapse/expand, and
that collapsing still pins the rail at 54px.
"""
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)
def main() -> int:
sandbox = _isolate_home()
from PySide6.QtWidgets import QApplication
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 (
_NAV_COLLAPSED_WIDTH, _NAV_MAX_WIDTH, _NAV_MIN_WIDTH, 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.resize(1400, 900)
win.show()
app.processEvents()
fails = []
rail, split = win._nav_wrap, win.split
def drag_to(px):
"""What the splitter does when the handle is dragged."""
total = sum(split.sizes())
split.setSizes([px, max(1, total - px)])
app.processEvents()
win._on_split_moved(px, 1)
app.processEvents()
return rail.width()
start = rail.width()
wide = drag_to(300)
print(f"keo rong : {start} -> {wide}px")
if wide <= start:
fails.append(f"keo tay nam ra 300px ma rail van {wide}px")
narrow = drag_to(_NAV_MIN_WIDTH)
print(f"keo hep : {wide} -> {narrow}px")
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}")
under = drag_to(20)
print(f"keo duoi min: {under}px (san {_NAV_MIN_WIDTH})")
if under < _NAV_MIN_WIDTH:
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)
win._toggle_nav()
app.processEvents()
folded = rail.width()
print(f"thu gon : {folded}px")
if folded != _NAV_COLLAPSED_WIDTH:
fails.append(f"thu gon phai la {_NAV_COLLAPSED_WIDTH}px, dang {folded}px")
win._toggle_nav()
app.processEvents()
back = rail.width()
print(f"mo lai : {back}px (da chon {chosen}px)")
if abs(back - chosen) > 4:
fails.append(f"mo lai quen be rong da keo: {back} thay vi {chosen}")
print()
for f in fails:
print("FAIL " + f)
print("PASS tay nam keo duoc, nho be rong qua lan gap" 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())