Files
cowork-local/ui/segmented_control.py
T
anhtnm1andClaude Opus 5 be5c5747a9 refactor: gom i18n/ và theme/ thành gói, gộp requirements về một file
Thư mục gốc: 22 file .py -> 7
--------------------------------
13 file "thành phần" nằm rải rác ngay ngoài thư mục gốc, mỗi file chỉ có ĐÚNG
MỘT nơi import — chính cái hub của nó:

    i18n.py + 10 file i18n_*.py    ->  i18n/__init__.py  + i18n/*.py
    theme.py + 3 file theme_*.py   ->  theme/__init__.py + theme/*.py

Đổi hub thành `__init__.py` nên 78 chỗ `from ..i18n import tr` và 24 chỗ
`from ..theme import current_palette` KHÔNG phải sửa một dòng nào. Git nhận ra
11/15 file là đổi tên thuần, 0 dòng thay đổi; 4 file còn lại chỉ sửa đúng dòng
import và mấy tham chiếu tên file trong docstring.

Đối chiếu với bản trước khi gom, cùng một phép băm:

    số khoá i18n  1431 -> 1431      hash STRINGS  a06cc34b... (trùng)
    QSS dark                        hash          7bb230a4... (trùng)
    QSS light                       hash          884f73ce... (trùng)

`check_loc.py` phải khai thêm "i18n", "theme" vào DEFAULT_TARGET_DIRS: chúng
từng được quét theo diện "module nằm ở thư mục gốc", gom vào gói rồi thì không
khai là lặng lẽ tuột khỏi tầm quét.

Bánh cóc `ui/widgets.py` siết 505 -> 466 sau khi tách SegmentedControl — nợ cũ
co lại thì con số phải co theo, không thì bánh cóc đứng yên mãi ở mức cũ.

Một file requirements
---------------------
Xoá `requirements-test.txt`. Nó chỉ có `pytest` + `pydantic`, nhưng 64/108 file
test dựng widget thật và 20 file trong đó import PySide6 thẳng ở đầu file không
có bảo vệ — nên CI cài mỗi file kia thì pytest chết ngay lúc thu thập test chứ
không phải "vài test bị bỏ qua". Hai file cho một danh sách gần trùng nhau chỉ
tạo thêm một chỗ để lệch phiên bản, và `pydantic` đã bị chép ở cả hai.

CI đổi sang cài `requirements.txt`. Người dùng cuối cài thừa pytest vài MB.

Kèm theo: `install.bat` bỏ cờ `--dev` (không còn gì để cài thêm). Khối `if`
rỗng còn sót lại làm cmd.exe báo "( was unexpected at this time" và script chết
ngay sau bước cài thư viện — đã gỡ hẳn.

859 test xanh · 4/4 cổng CASAN · check_design_parity 32/32 ·
check_layout_geometry trùng từng byte với bản trước refactor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 11:25:27 +09:00

135 lines
6.0 KiB
Python

"""Dải nút chọn một trong nhiều — tách khỏi ``ui/widgets.py``.
Thay ``QComboBox`` ở những chỗ chỉ có hai đến bốn lựa chọn và người dùng nên
thấy hết cùng lúc: ngôn ngữ và giao diện trong Cài đặt. Mở một danh sách xổ
xuống chỉ để biết trong đó có gì là một cú bấm thừa.
Tách ra vì hai lẽ. Một, ``ui/widgets.py`` đã chạm đúng trần nợ cũ của cổng LOC
nên không nhận thêm được dòng nào. Hai, chỗ này có một luật riêng đáng đứng
một mình: bề rộng nút phải chừa sẵn cho chữ IN ĐẬM — xem
:meth:`SegmentedControl._reserve_bold_width`.
"""
from __future__ import annotations
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QFont, QFontMetrics
from PySide6.QtWidgets import QHBoxLayout, QPushButton, QWidget
class SegmentedControl(QWidget):
"""Two-to-four choices shown side by side instead of hidden in a drop-list.
Exposes the slice of the QComboBox API this app's settings code uses
(addItem / findData / currentData / setCurrentIndex / currentIndexChanged),
so it drops into an existing form without touching the save/load paths.
"""
currentIndexChanged = Signal(int)
#: Độ đậm mà ``theme/qss.py`` áp cho nút đang chọn
#: (``QPushButton#segItem:checked { font-weight: 600 }``). Đổi ở QSS thì
#: phải đổi cả ở đây, nếu không chữ lại bị cắt.
_CHECKED_WEIGHT = QFont.DemiBold
def __init__(self, parent=None):
"""Dải nút chọn một trong nhiều — thay ``QComboBox`` khi chỉ có vài lựa chọn và
nên thấy hết cùng lúc.
"""
super().__init__(parent)
self._data: list = []
self._buttons: list = []
self._current = -1
lay = QHBoxLayout(self)
lay.setContentsMargins(0, 0, 0, 0)
lay.setSpacing(0)
self._lay = lay
lay.addStretch(1)
def addItem(self, text: str, data=None) -> None: # noqa: N802 - Qt-style name
"""Thêm một lựa chọn kèm dữ liệu đi kèm."""
btn = QPushButton(text)
btn.setObjectName("segItem")
btn.setCheckable(True)
btn.setCursor(Qt.PointingHandCursor)
index = len(self._buttons)
btn.clicked.connect(lambda _c=False, i=index: self.setCurrentIndex(i))
self._lay.insertWidget(index, btn)
self._buttons.append(btn)
self._data.append(data)
self._reserve_bold_width(btn)
if self._current < 0:
self.setCurrentIndex(0)
@staticmethod
def _reserve_bold_width(btn: QPushButton) -> None:
"""Chừa sẵn bề rộng cho chữ khi nút được chọn và bị in đậm.
``QPushButton`` tính ``sizeHint()`` theo phông ĐANG dùng, tức phông
thường. Nhưng QSS lại đặt ``font-weight: 600`` cho nút đang chọn, và
chữ đậm rộng hơn chữ thường — nên đúng lúc một mục được chọn thì nó
không còn đủ chỗ và Qt cắt bớt chữ đi.
Nhãn càng dài, thiếu càng nhiều: đo trên bản 30/08 thì "Tiếng Việt"
thiếu 2px, "English" 2px, còn "Tự động (theo hệ thống)" thiếu tới 7px.
Tiếng Việt lộ rõ nhất vì nó vừa là nhãn dài vừa có dấu, và với người
dùng tiếng Việt thì nó luôn là mục ĐANG được chọn.
Cách đo: lấy phần khung (viền + padding do QSS quy định) bằng cách trừ
bề rộng chữ khỏi ``sizeHint()``, rồi cộng lại bề rộng của chính chữ ấy
ở độ đậm khi được chọn. Không viết cứng con số padding nào — QSS đổi
thì phép đo tự theo.
"""
btn.ensurePolished()
text = btn.text()
normal = btn.font()
chrome = btn.sizeHint().width() - QFontMetrics(normal).horizontalAdvance(text)
bold = QFont(normal)
bold.setWeight(SegmentedControl._CHECKED_WEIGHT)
btn.setMinimumWidth(chrome + QFontMetrics(bold).horizontalAdvance(text))
def findData(self, value) -> int: # noqa: N802
"""Chỉ số của lựa chọn mang dữ liệu ``value``; -1 nếu không có."""
return self._data.index(value) if value in self._data else -1
def currentData(self): # noqa: N802
"""Dữ liệu của lựa chọn đang chọn; ``None`` nếu chưa chọn gì."""
return self._data[self._current] if 0 <= self._current < len(self._data) else None
def currentIndex(self) -> int: # noqa: N802
"""Chỉ số lựa chọn đang chọn; -1 nếu chưa chọn gì."""
return self._current
def count(self) -> int:
"""Số lựa chọn đang có."""
return len(self._buttons)
def setItemText(self, index: int, text: str) -> None: # noqa: N802
"""Đổi nhãn một lựa chọn (dùng khi đổi ngôn ngữ).
Tính lại bề rộng tối thiểu: nhãn mới dài ngắn khác nhau, giữ nguyên số
cũ thì hoặc cắt chữ hoặc chừa một khoảng trống vô cớ.
"""
if 0 <= index < len(self._buttons):
btn = self._buttons[index]
btn.setText(text)
btn.setMinimumWidth(0)
self._reserve_bold_width(btn)
def setCurrentIndex(self, index: int) -> None: # noqa: N802
"""Chọn một mục và phát tín hiệu đổi.
Chỉ số không hợp lệ hoặc trùng mục đang chọn thì chỉ đồng bộ lại trạng thái
nút, không phát tín hiệu — tránh vòng lặp khi chỗ gọi lại đặt lại chỉ số.
"""
if not (0 <= index < len(self._buttons)) or index == self._current:
for i, b in enumerate(self._buttons):
b.setChecked(i == self._current)
return
self._current = index
for i, b in enumerate(self._buttons):
b.setChecked(i == index)
self.currentIndexChanged.emit(index)
__all__ = ["SegmentedControl"]