From 85d0b396e3e3cd371b57022384d62276f38edf53 Mon Sep 17 00:00:00 2001 From: ykcheungab Date: Sun, 18 Oct 2026 14:39:13 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=20Studio=20=E5=8A=9B?= =?UTF-8?q?=E6=9B=B2=E7=BA=BF=E5=8E=9F=E7=82=B9=E3=80=81=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E4=B8=8D=E5=8F=AF=E7=94=A8=E6=8F=90=E7=A4=BA=E4=B8=8E=E7=A6=81?= =?UTF-8?q?=E7=94=A8=E6=8C=89=E9=92=AE=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FN 轴视图留 8% 余量使 0 刻度与静止曲线离开边框;实时视图在轨道上直接写出运行不可用的原因;禁用按钮改为虚线无填充,复选框加勾,单选框选中不再变方,并放大勾选框、微调框箭头、滚动条与菜单行以便触屏操作。另修 ui_check 桩 worker 签名与英文回放提示。在 Linux 离屏环境对比未改动的 HEAD 验证:studio 失败集合为 HEAD 的子集,ui_check 结果一致。 --- apps/studio/config/locales/en_US.json | 7 +- apps/studio/config/locales/zh_CN.json | 5 +- apps/studio/src/orisys_studio/app_window.py | 7 +- .../src/orisys_studio/ui/force_plots.py | 29 ++++- apps/studio/src/orisys_studio/ui/toolbar.py | 38 +++++- apps/studio/tests/test_force_plots_display.py | 50 ++++++-- apps/studio/tests/test_ui_smoke.py | 19 ++- apps/studio/tools/ui_check.py | 5 +- shared/src/orisys_app_shared/theme/qss.py | 115 ++++++++++++++---- 9 files changed, 226 insertions(+), 49 deletions(-) diff --git a/apps/studio/config/locales/en_US.json b/apps/studio/config/locales/en_US.json index 2881620..07c3169 100644 --- a/apps/studio/config/locales/en_US.json +++ b/apps/studio/config/locales/en_US.json @@ -128,8 +128,7 @@ "cancel_label": "■ Cancel", "cancel_tip": "Abort the connection (always available while connecting, shortcut Esc)", "run_unavailable": "Not available yet", - "run_disabled_video": "Select a video file first", - "run_video_in_replay": "Play a video from the History replay view (Play and the chart cursor)", + "run_video_in_replay": "Live uses a camera: press Use camera", "run_disabled_sensor": "Run is not available for this sensor type", "playback": "▶ Play", "playback_pause": "⏸ Pause", @@ -227,8 +226,8 @@ "field_depth": "depth", "recompute_pipeline": "Recomputed from the recording (not the recorded value)", "recompute_pipeline_tip": "This frame is what the current algorithm version computes for that footage — not the measurement that was recorded at the time.", - "replay_empty": "No video open\nChoose “Recorded video…” above", - "arrow_empty": "No image yet\nPick a video or camera, then press Run", + "replay_empty": "No video open\nPress “Choose a recorded video…” above", + "arrow_empty": "No image yet\nConnect a camera, then press Run above\nOpen recorded videos in Replay", "arrow_empty_tip": "The deformation arrow image appears here while running or previewing", "arrow_render_failed": "Flow arrows could not be drawn for this frame\nTry another view, or reload the video", "arrow_capture_unavailable": "The captured frame is not available for this overlay\nThe raw camera view is still one selection away", diff --git a/apps/studio/config/locales/zh_CN.json b/apps/studio/config/locales/zh_CN.json index d652cac..c1bf971 100644 --- a/apps/studio/config/locales/zh_CN.json +++ b/apps/studio/config/locales/zh_CN.json @@ -128,8 +128,7 @@ "cancel_label": "■ 断开", "cancel_tip": "中断连接(连接卡住时随时可用,快捷键 Esc)", "run_unavailable": "当前不可运行", - "run_disabled_video": "请先选择一个视频文件", - "run_video_in_replay": "请在「历史回放」视图里播放视频(用播放与曲线游标)", + "run_video_in_replay": "实时视图只用摄像机:请点「使用摄像机」", "run_disabled_sensor": "此传感器类型暂不支持运行", "playback": "▶ 播放", "playback_pause": "⏸ 暂停", @@ -228,7 +227,7 @@ "recompute_pipeline": "由录像重算(非记录值)", "recompute_pipeline_tip": "这一帧是当前算法版本对录像重新计算得到的,不是当初记录下来的测量值。", "replay_empty": "尚未打开录像\n点击顶部「选择录制视频…」", - "arrow_empty": "尚未采集图像\n请选择视频或摄像机后点击「运行」", + "arrow_empty": "尚未采集图像\n连接摄像机后点击上方「运行」\n录好的视频请到「回放」中打开", "arrow_empty_tip": "运行或预览后在此显示形变箭头图", "arrow_render_failed": "本帧无法绘制形变箭头\n请换一种视图,或重新加载视频", "arrow_capture_unavailable": "当前无法取得采集图像用于叠加\n原始相机画面仍可在同一选择器中查看", diff --git a/apps/studio/src/orisys_studio/app_window.py b/apps/studio/src/orisys_studio/app_window.py index 6d63437..636a76c 100644 --- a/apps/studio/src/orisys_studio/app_window.py +++ b/apps/studio/src/orisys_studio/app_window.py @@ -1344,12 +1344,9 @@ class StudioMainWindow(QMainWindow): elif self.toolbar.source_mode() == "video": # Live acquires from a camera. A video file is played from the History # replay view (Play and the chart cursor), not by starting a chain here. + # Choosing a file would not help here, so the reason never asks for one. source_ok = False - reason = ( - t("toolbar.run_video_in_replay") - if self.toolbar.video_path() - else t("toolbar.run_disabled_video") - ) + reason = t("toolbar.run_video_in_replay") self.toolbar.set_run_tooltip_reason(reason) # While a session exists the transport stays enabled — pausing and resuming # are the whole point of it being checked, not an exception to an idle rule. diff --git a/apps/studio/src/orisys_studio/ui/force_plots.py b/apps/studio/src/orisys_studio/ui/force_plots.py index deaeab1..2b4c7a7 100644 --- a/apps/studio/src/orisys_studio/ui/force_plots.py +++ b/apps/studio/src/orisys_studio/ui/force_plots.py @@ -60,6 +60,28 @@ CURSOR_READOUT_S = 0.5 #: counts as a *selection* rather than the default "whole session" fit. 1% of the #: session absorbs the float noise of a fit without hiding a real zoom. SELECTION_EPSILON = 0.01 +#: Share of a fixed axis span left free beyond each configured bound. Without it +#: the bound labels (FN's "0") sit on the plot frame and are not drawn, and a +#: resting FN curve is painted on the border it shares with the FX subplot. 8 % +#: is about half a label's height on the smallest subplot ui_check measures. +AXIS_MARGIN_FRACTION = 0.08 + + +def _display_range(lower: float, upper: float) -> tuple[float, float]: + """The view range that shows ``lower…upper`` with a margin on both sides. + + The configured range — and the ticks — stay exactly what the operator set; + only the drawn view is wider. A margin never brings zero into view when the + configured range excludes it, so the zero line keeps meaning "zero is here". + """ + pad = (float(upper) - float(lower)) * AXIS_MARGIN_FRACTION + view_lower = lower - pad + if lower > 0.0 >= view_lower: + view_lower = lower + view_upper = upper + pad + if upper < 0.0 <= view_upper: + view_upper = upper + return view_lower, view_upper def _window_brush(theme: str): @@ -316,6 +338,9 @@ class ForcePlotsPanel(QFrame): for i, _name in enumerate(CHANNEL_LABELS): plot = self.plot_widget.addPlot(row=i, col=0) plot.showGrid(x=True, y=True, alpha=GRID_ALPHA) + # A bound label (FN's 0) may reach past the axis end by a few pixels on + # a short subplot; drawing it is better than losing the origin. + plot.getAxis("left").setStyle(hideOverlappingLabels=False) # Panning and zooming are always available: a chart that ignores the # mouse until it is already browsing could never *enter* browsing by # being dragged. Y stays locked to the configured limits. @@ -719,7 +744,7 @@ class ForcePlotsPanel(QFrame): self.plots[i].setXRange(*state.x_range, padding=0.0) if use_fixed: ymin, ymax = self._fixed_y_range(i) - self.plots[i].setYRange(ymin, ymax, padding=0.0) + self.plots[i].setYRange(*_display_range(ymin, ymax), padding=0.0) self.plots[i].getAxis("left").setTicks( [_nice_ticks(ymin, ymax, count=5)] ) @@ -1363,7 +1388,7 @@ class ForcePlotsPanel(QFrame): for i, plot in enumerate(self.plots): if use_fixed: ymin, ymax = self._fixed_y_range(i) - plot.setYRange(ymin, ymax, padding=0.0) + plot.setYRange(*_display_range(ymin, ymax), padding=0.0) plot.getAxis("left").setTicks([_nice_ticks(ymin, ymax, count=5)]) else: plot.getAxis("left").setTicks(None) diff --git a/apps/studio/src/orisys_studio/ui/toolbar.py b/apps/studio/src/orisys_studio/ui/toolbar.py index 87d51fb..6126fcd 100644 --- a/apps/studio/src/orisys_studio/ui/toolbar.py +++ b/apps/studio/src/orisys_studio/ui/toolbar.py @@ -181,6 +181,16 @@ class ToolbarWidget(QWidget): self.data_state = ShrinkableLabel() self.data_state.setObjectName("panelMeta") self.data_state.setVisible(False) + # Why the transport cannot start, as text on the rail. A tooltip alone was + # the only place this was said, and a touch screen has no hover to show it. + # Ignored horizontally, so it only ever gets the rail's spare width and + # never squeezes a button; hidden when empty, so it costs no spacing. + self.run_hint = ShrinkableLabel() + self.run_hint.setObjectName("panelMeta") + self.run_hint.setSizePolicy( + QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred + ) + self.run_hint.setVisible(False) self.record_chip = RecordStatusChip(theme=self._theme) # --- capture source (one of the two groups is visible) ------------- @@ -288,7 +298,9 @@ class ToolbarWidget(QWidget): rail.addWidget(self.source_summary) rail.addWidget(self.calibration_label) rail.addWidget(self.data_state) - rail.addStretch(1) + # Shown, the hint takes the spare width (stretch 1); hidden, the spacer does. + rail.addWidget(self.run_hint, 1) + rail.addStretch(0) rail.addWidget(self.record_chip) rail.addWidget(_rail(self.btn_record)) rail.addWidget(_rail(self.btn_run)) @@ -358,6 +370,7 @@ class ToolbarWidget(QWidget): def set_theme(self, theme: str) -> None: self._theme = str(theme) self.record_chip.set_theme(self._theme) + self._apply_run_hint() def set_sensor_summary(self, text: str) -> None: """The rail's sensor button text (``BOX · box/sensor.json``).""" @@ -432,6 +445,7 @@ class ToolbarWidget(QWidget): # The reset is the one rail control whose *target* moves with the view (see # ``reset_target``), so its label is refreshed with the other two. self._refresh_reset_label() + self._apply_run_hint() def run_role(self) -> str: return self._run_role @@ -557,11 +571,33 @@ class ToolbarWidget(QWidget): self._run_available = bool(enabled) self.btn_run.setEnabled(bool(enabled)) self._apply_run_tooltip() + self._apply_run_hint() def set_run_tooltip_reason(self, reason: str) -> None: """Explain *why* Run is unavailable (shown in the button tooltip).""" self._run_tooltip_reason = str(reason or "") self._apply_run_tooltip() + self._apply_run_hint() + + def _apply_run_hint(self) -> None: + """Show the reason on the rail while acquisition cannot start. + + Only the *blocking* reason is shown: an advisory one (a bare video plays + with empty charts) stays in the tooltip, so the rail is not a second + status bar. The replay view is left out on purpose: its empty 2D pane + already says which button opens a recording, and the extra rail item + would cost the width the replay rail does not have at 1280 px. + """ + blocked = not self._run_available and self._run_role != "playback" + text = self._run_tooltip_reason if blocked else "" + self.run_hint.setText(text) + self.run_hint.setToolTip(text) + self.run_hint.setVisible(bool(text)) + if text: + self.run_hint.setStyleSheet( + f"QLabel#panelMeta {{ color: {color(self._theme, 'warning')};" + " background: transparent; }" + ) def set_run_phase(self, phase: str) -> None: """Track the session phase. diff --git a/apps/studio/tests/test_force_plots_display.py b/apps/studio/tests/test_force_plots_display.py index 998a5c4..bc56c41 100644 --- a/apps/studio/tests/test_force_plots_display.py +++ b/apps/studio/tests/test_force_plots_display.py @@ -4,7 +4,7 @@ from __future__ import annotations import numpy as np from orisys_app_shared.i18n import t -from orisys_studio.ui.force_plots import ForcePlotsPanel, _nice_ticks +from orisys_studio.ui.force_plots import ForcePlotsPanel, _display_range, _nice_ticks def _panel(unit: str = "raw") -> ForcePlotsPanel: @@ -23,6 +23,12 @@ def _ticks(panel: ForcePlotsPanel, channel: int) -> list[float]: return [float(value) for value, _text in levels[0]] +def _view(panel: ForcePlotsPanel, channel: int) -> tuple[float, float]: + """The Y range the subplot actually draws.""" + low, high = panel.plots[channel].getViewBox().viewRange()[1] + return float(low), float(high) + + def _curve(panel: ForcePlotsPanel, channel: int) -> np.ndarray: _times, values = panel.curves[channel].getData() return np.asarray(values, dtype=float) @@ -82,14 +88,15 @@ def test_the_configured_ranges_reach_every_axis(qt_app): ) panel.set_fixed_newton(True) - # The panel starts in raw, so the raw limits are the ones on the axes. - assert panel.plots[0].getViewBox().viewRange()[1] == [-100000.0, 1000000.0] - assert panel.plots[1].getViewBox().viewRange()[1] == [-500000.0, 500000.0] + # The panel starts in raw, so the raw limits are the ones on the axes (drawn + # with a small margin beyond each bound; see ``_display_range``). + assert _view(panel, 0) == _display_range(-100000.0, 1000000.0) + assert _view(panel, 1) == _display_range(-500000.0, 500000.0) panel.set_units("N") - assert panel.plots[0].getViewBox().viewRange()[1] == [-5.0, 15.0] - assert panel.plots[1].getViewBox().viewRange()[1] == [-3.0, 3.0] - assert panel.plots[2].getViewBox().viewRange()[1] == [-3.0, 3.0] + assert _view(panel, 0) == _display_range(-5.0, 15.0) + assert _view(panel, 1) == _display_range(-3.0, 3.0) + assert _view(panel, 2) == _display_range(-3.0, 3.0) assert panel._fixed_y_range(0) == (-5.0, 15.0) assert panel._fixed_y_range(1) == (-3.0, 3.0) # A range that starts below zero carries an explicit 0 tick: that is the @@ -164,3 +171,32 @@ def test_the_default_ranges_are_the_ones_they_always_were(qt_app): (15.0, "15"), (20.0, "20"), ] + + +def test_the_default_fn_origin_is_drawn_off_the_frame(qt_app): + """FN starts at 0, yet its 0 label and resting curve stay off the plot border. + + With the view ending exactly at 0 the label was not drawn and an idle FN + curve lay on the frame shared with FX — the customer's "缺少原点" report. + """ + for unit in ("raw", "N"): + panel = ForcePlotsPanel(theme="dark") + panel.set_units(unit) + panel.set_fixed_newton(True) + ymin, ymax = panel._fixed_y_range(0) + assert ymin == 0.0, "the configured FN floor stays 0" + view_min, view_max = _view(panel, 0) + assert view_min < 0.0 < view_max, unit + assert view_max > ymax, unit + assert 0.0 in _ticks(panel, 0), unit + assert panel.zero_lines[0].isVisibleTo(panel.plots[0].getViewBox()), unit + + +def test_a_margin_never_brings_an_excluded_zero_into_view(): + # A margin that stays on its own side of zero is kept... + assert _display_range(100.0, 1100.0) == (20.0, 1180.0) + # ...one that would cross zero stops at the configured bound. + assert _display_range(10.0, 1010.0) == (10.0, 1090.0) + assert _display_range(-1010.0, -10.0) == (-1090.0, -10.0) + # A range that already holds zero gets the full margin on both sides. + assert _display_range(0.0, 20.0) == (-1.6, 21.6) diff --git a/apps/studio/tests/test_ui_smoke.py b/apps/studio/tests/test_ui_smoke.py index bffad05..6cdfa65 100644 --- a/apps/studio/tests/test_ui_smoke.py +++ b/apps/studio/tests/test_ui_smoke.py @@ -1576,6 +1576,8 @@ def test_live_run_refuses_a_video_source(make_window, monkeypatch): window._set_source_mode("video") QApplication.processEvents() assert not window.toolbar.btn_run.isEnabled(), "Run is offered for a video file in live" + # The reason is on the rail as text, not only in a tooltip (no hover on touch). + assert window.toolbar.run_hint.text() == t("toolbar.run_video_in_replay") called: list[str] = [] monkeypatch.setattr( "orisys_app_shared.acquisition.sdk_worker.orisys_importable", @@ -3138,6 +3140,19 @@ def test_force_threshold_is_on_for_every_sensor_by_default(make_window): _finish(window, stuck) +def test_the_run_hint_clears_once_run_is_available(make_window): + from PySide6.QtWidgets import QApplication - - + window = make_window(sensor_type="finger", source_mode="camera") + try: + window.brand_bar.btn_live.click() + QApplication.processEvents() + window._set_source_mode("video") + QApplication.processEvents() + assert window.toolbar.run_hint.text() + window._set_source_mode("camera") + QApplication.processEvents() + assert window.toolbar.btn_run.isEnabled() + assert window.toolbar.run_hint.text() == "" + finally: + window.close() diff --git a/apps/studio/tools/ui_check.py b/apps/studio/tools/ui_check.py index 8801be3..de3ffe3 100644 --- a/apps/studio/tools/ui_check.py +++ b/apps/studio/tools/ui_check.py @@ -298,7 +298,10 @@ class _StuckWorker(RecordSinkMixin): mono_t = float(getattr(packet, "mono_t", 0.0) or 0.0) self.on_reset_applied(frame_id, mono_t, 1) - def start(self, _source, *, config_ref=None) -> None: + def start( + self, _source, *, config_ref=None, cal_path=None, device_id=None + ) -> None: + # Same keywords as the real workers' ``start``; the stub ignores them. self._release.clear() self._thread = threading.Thread(target=self._hang, daemon=True) self._thread.start() diff --git a/shared/src/orisys_app_shared/theme/qss.py b/shared/src/orisys_app_shared/theme/qss.py index d1796cf..165d775 100644 --- a/shared/src/orisys_app_shared/theme/qss.py +++ b/shared/src/orisys_app_shared/theme/qss.py @@ -69,6 +69,51 @@ def _paint_arrow(kind: str, fill: str) -> str: return "" +@lru_cache(maxsize=8) +def _paint_check(fill: str) -> str: + """Draw a tick PNG for a checked checkbox; return its path ('' on failure). + + A checked box used to be a solid square, which at 15 px reads as "disabled" + or "indeterminate" rather than "on". + """ + try: + from PySide6.QtCore import QPointF, Qt + from PySide6.QtGui import QColor, QImage, QPainter, QPainterPath, QPen + except Exception: # pragma: no cover - headless import failure + return "" + try: + size = 36 # drawn at 2x the indicator so the tick stays crisp when scaled + safe = fill.strip().lstrip("#").replace("/", "") + path = _arrow_icon_cache_dir() / f"check_{safe}.png" + if path.is_file(): + return str(path) + image = QImage(size, size, QImage.Format.Format_ARGB32) + image.fill(Qt.GlobalColor.transparent) + painter = QPainter(image) + painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) + pen = QPen(QColor(fill), size * 0.13) + pen.setCapStyle(Qt.PenCapStyle.RoundCap) + pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin) + painter.setPen(pen) + tick = QPainterPath(QPointF(size * 0.22, size * 0.52)) + tick.lineTo(QPointF(size * 0.42, size * 0.72)) + tick.lineTo(QPointF(size * 0.78, size * 0.30)) + painter.drawPath(tick) + painter.end() + if not image.save(str(path), "PNG"): + return "" + return str(path) + except Exception: # pragma: no cover - never break theming + return "" + + +def _check_image(fill: str) -> str: + path = _paint_check(fill) + if not path: + return "none" + return 'url("' + path.replace("\\", "/") + '")' + + def _arrow_image(kind: str, fill: str, size: int = 14) -> str: path = _paint_arrow(kind, fill) if not path: @@ -86,6 +131,8 @@ def build_qss(theme: str = "dark") -> str: arrow_up = _arrow_image("up", dim) arrow_down_disabled = _arrow_image("down", muted) arrow_up_disabled = _arrow_image("up", muted) + check_mark = _check_image(accent_text) + check_mark_disabled = _check_image(muted) return f""" QWidget {{ background-color: {c['window_bg']}; @@ -117,7 +164,7 @@ QMenu {{ padding: 4px; }} QMenu::item {{ - padding: 6px 18px; + padding: 8px 20px; border-radius: 4px; }} QMenu::item:selected {{ @@ -219,10 +266,13 @@ QPushButton:pressed {{ QPushButton:focus {{ border-color: {c['focus_ring']}; }} +/* Disabled = no fill and a dashed outline. A filled disabled button was the + same colour as an enabled neutral one, so a rail of unavailable actions + read as a rail of available ones. */ QPushButton:disabled {{ color: {c['text_muted']}; - background-color: {c['window_bg']}; - border: 1px solid {c['separator']}; + background-color: transparent; + border: 1px dashed {c['input_border']}; }} QPushButton#primaryButton {{ background-color: {c['accent']}; @@ -241,9 +291,9 @@ QPushButton#primaryButton:checked {{ border-color: {c['accent']}; }} QPushButton#primaryButton:disabled {{ - background-color: {c['panel_border']}; + background-color: transparent; color: {c['text_muted']}; - border-color: {c['panel_border']}; + border: 1px dashed {c['input_border']}; }} QPushButton#dangerButton {{ background-color: {c['danger_fill']}; @@ -262,9 +312,9 @@ QPushButton#dangerButton:checked {{ border-color: {c['danger_fill']}; }} QPushButton#dangerButton:disabled {{ - background-color: {c['panel_border']}; + background-color: transparent; color: {c['text_muted']}; - border-color: {c['panel_border']}; + border: 1px dashed {c['input_border']}; }} QPushButton#ghostButton {{ background-color: transparent; @@ -310,8 +360,8 @@ QPushButton#dangerOutline:checked {{ }} QPushButton#dangerOutline:disabled {{ color: {c['text_muted']}; - background-color: {c['window_bg']}; - border: 1px solid {c['separator']}; + background-color: transparent; + border: 1px dashed {c['input_border']}; }} QPushButton[compact="true"] {{ padding: 6px 9px; @@ -428,9 +478,12 @@ QComboBox QAbstractItemView {{ selection-background-color: {c['menu_hover']}; selection-color: {c['text_primary']}; }} +QComboBox QAbstractItemView::item {{ + min-height: 30px; +}} QAbstractSpinBox::up-button, QAbstractSpinBox::down-button {{ subcontrol-origin: border; - width: 18px; + width: 22px; background: transparent; border: none; }} @@ -457,15 +510,17 @@ QAbstractSpinBox::down-arrow:disabled {{ image: {arrow_down_disabled}; }} /* ---------- Check / radio ---------- */ +/* 18 px boxes and a taller row: the label is part of the hit area, and the + whole row has to be easy to hit with a finger. */ QCheckBox, QRadioButton {{ background: transparent; color: {c['text_primary']}; - spacing: 7px; - padding: 2px 0; + spacing: 8px; + padding: 4px 0; }} QCheckBox::indicator, QRadioButton::indicator {{ - width: 15px; - height: 15px; + width: 18px; + height: 18px; background-color: {c['input_bg']}; border: 1px solid {c['input_border']}; }} @@ -473,23 +528,34 @@ QCheckBox::indicator {{ border-radius: 4px; }} QRadioButton::indicator {{ - border-radius: 8px; + border-radius: 10px; }} QCheckBox::indicator:hover, QRadioButton::indicator:hover {{ border-color: {c['accent_hover']}; }} -QCheckBox::indicator:checked, QRadioButton::indicator:checked {{ +QCheckBox::indicator:checked {{ background-color: {c['accent']}; - border: 4px solid {c['accent']}; + border: 1px solid {c['accent']}; + image: {check_mark}; }} +/* QSS borders add to ``width``: a 5 px ring on an 18 px box grew the checked + radio to 28 px and turned the circle into a rounded square. Shrinking the + content keeps the outer size (20 px) equal to the unchecked one. */ QRadioButton::indicator:checked {{ + width: 10px; + height: 10px; background-color: {c['input_bg']}; - border: 4px solid {c['accent']}; + border: 5px solid {c['accent']}; }} QCheckBox::indicator:disabled, QRadioButton::indicator:disabled {{ background-color: {c['window_bg']}; border-color: {c['separator']}; }} +QCheckBox::indicator:checked:disabled {{ + background-color: {c['separator']}; + border-color: {c['separator']}; + image: {check_mark_disabled}; +}} /* ---------- Progress ---------- */ QProgressBar {{ background-color: {c['input_bg']}; @@ -549,26 +615,26 @@ QTableCornerButton::section {{ /* ---------- Scrollbars ---------- */ QScrollBar:vertical {{ background: {c['scrollbar_bg']}; - width: 10px; + width: 12px; margin: 0; border: none; }} QScrollBar:horizontal {{ background: {c['scrollbar_bg']}; - height: 10px; + height: 12px; margin: 0; border: none; }} QScrollBar::handle:vertical {{ background: {c['scrollbar_handle']}; - min-height: 28px; - border-radius: 5px; + min-height: 32px; + border-radius: 6px; margin: 2px; }} QScrollBar::handle:horizontal {{ background: {c['scrollbar_handle']}; - min-width: 28px; - border-radius: 5px; + min-width: 32px; + border-radius: 6px; margin: 2px; }} QScrollBar::handle:vertical:hover, QScrollBar::handle:horizontal:hover {{ @@ -707,3 +773,4 @@ def color(theme: str, key: str, default: str = "#888888") -> str: def clear_theme_cache() -> None: load_colors.cache_clear() _paint_arrow.cache_clear() + _paint_check.cache_clear()