From 940aedb53c526ed44e6c3fddac13a5ef40781185 Mon Sep 17 00:00:00 2001 From: Ralph Scherer Date: Fri, 23 Oct 2026 18:48:46 +0200 Subject: [PATCH] feat(ui): add Mid Gray and Light Gray UI themes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/USER_GUIDE.md | 1 + negpy/desktop/main.py | 4 + negpy/desktop/view/styles/theme.py | 78 +++++++++++++++++++ .../view/widgets/preferences_dialog.py | 16 +++- negpy/translations/USER_GUIDE.en.md | 1 + negpy/translations/ja.json | 3 + negpy/translations/ko.json | 3 + negpy/translations/zh_CN.json | 3 + tests/test_ui_theme.py | 50 ++++++++++++ 9 files changed, 158 insertions(+), 1 deletion(-) create mode 100644 tests/test_ui_theme.py diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index a6ed2ac79..994c708f9 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -826,6 +826,7 @@ Shows the whole strip, to place and pick the frames before the scan. **Apply Fra * **Language**: English, Simplified Chinese, Japanese or Korean, after a restart. * **UI scale**: 80% to 120%, after a restart. +* **UI theme**: **Black** (default), **Mid Gray** or **Light Gray** for the app's panels and chrome, after a restart. * **Canvas background**: from black to white. Mid gray is best for judging a print. * **Sticky Zoom**: keeps the zoom when you change frames. **Reverse Scroll Zoom**: scroll up zooms out. * **Shortcut Hints** (on at first): after you use a control with the mouse, a toast on the canvas shows its keyboard shortcut, once per session. diff --git a/negpy/desktop/main.py b/negpy/desktop/main.py index 728c54217..02bcd13c2 100644 --- a/negpy/desktop/main.py +++ b/negpy/desktop/main.py @@ -332,6 +332,10 @@ def main() -> None: if scale != 1.0 and "QT_SCALE_FACTOR" not in os.environ: os.environ["QT_SCALE_FACTOR"] = f"{scale:.2f}" + from negpy.desktop.view.styles.theme import apply_theme + + apply_theme(str(repo.get_global_setting("ui_theme", "black") or "black")) + # Global attributes for Windows stability if sys.platform == "win32": QCoreApplication = getattr(sys.modules["PyQt6.QtCore"], "QCoreApplication") diff --git a/negpy/desktop/view/styles/theme.py b/negpy/desktop/view/styles/theme.py index ecc082403..5d38aa1b3 100644 --- a/negpy/desktop/view/styles/theme.py +++ b/negpy/desktop/view/styles/theme.py @@ -1,6 +1,9 @@ +import dataclasses from dataclasses import dataclass, field from typing import Dict +from negpy.kernel.system.i18n import mark + @dataclass(frozen=True) class ThemeConfig: @@ -157,3 +160,78 @@ class ThemeConfig: def scene_color(ordinal: int) -> str: return SCENE_COLORS[(ordinal - 1) % len(SCENE_COLORS)] + + +# Preferences > Interface > UI Theme. Every "surfaces, darkest first" background and border +# lightened by the same offset that turns bg_dark into a photographic middle gray (Light Gray), so the +# depth ordering between panel, card and header is unchanged. The grays used for secondary +# and hint copy go white, since they read too close to the new light-gray surfaces otherwise; +# text_muted stays put, since it is documented as deliberately low-contrast (disabled only). +# header_veil/_hover and surface_overlay/_hover are translucent washes over a card's chart +# and over the canvas, not flat token reads, so each needs its own override beside bg_header. +_LIGHT_GRAY_OVERRIDES: Dict[str, str] = { + "bg_dark": "#808080", + "bg_panel": "#808080", + "bg_input": "#7D7D7D", + "bg_card": "#858585", + "bg_header": "#898989", + "bg_selected": "#959595", + "bg_hover": "#999999", + "bg_menu_selected": "#A1A1A1", + "border_input": "#8D8D8D", + "border_primary": "#999999", + "border_color": "#A6A6A6", + "border_hover": "#ADADAD", + "border_indicator": "#B5B5B5", + "slider_disabled": "#C8C8C8", + "surface_overlay": "rgba(128,128,128,0.88)", + "surface_overlay_hover": "rgba(149,149,149,0.88)", + "header_veil": "rgba(141,141,141,0.55)", + "header_veil_hover": "rgba(149,149,149,0.62)", + "text_secondary": "#FFFFFF", + "text_hint": "#FFFFFF", + "text_unit": "#FFFFFF", +} + +# Same lightening, anchored to canvas_bg_mid_grey (the "Mid Gray" canvas swatch). Still dark +# enough that the existing text grays stay readable, so none of them need the white override above. +_MID_GRAY_OVERRIDES: Dict[str, str] = { + "bg_dark": "#404040", + "bg_panel": "#404040", + "bg_input": "#3D3D3D", + "bg_card": "#454545", + "bg_header": "#494949", + "bg_selected": "#555555", + "bg_hover": "#595959", + "bg_menu_selected": "#616161", + "border_input": "#4D4D4D", + "border_primary": "#595959", + "border_color": "#666666", + "border_hover": "#6D6D6D", + "border_indicator": "#757575", + "slider_disabled": "#888888", + "surface_overlay": "rgba(64,64,64,0.88)", + "surface_overlay_hover": "rgba(85,85,85,0.88)", + "header_veil": "rgba(77,77,77,0.55)", + "header_veil_hover": "rgba(85,85,85,0.62)", +} + +UI_THEMES: tuple[tuple[str, str], ...] = ( + ("black", mark("Black (Default)")), + ("mid_gray", mark("Mid Gray")), + ("light_gray", mark("Light Gray")), +) +_THEME_OVERRIDES: Dict[str, Dict[str, str]] = { + "mid_gray": _MID_GRAY_OVERRIDES, + "light_gray": _LIGHT_GRAY_OVERRIDES, +} +_DEFAULT_THEME = ThemeConfig() + + +def apply_theme(key: str) -> None: + """Patches the shared THEME instance in place so every module that already holds a + reference to it (imported at their own load time) picks up the chosen palette. Read once + at startup; switching requires a restart to repaint every baked stylesheet and icon.""" + overrides = _THEME_OVERRIDES.get(key, {}) + for f in dataclasses.fields(THEME): + object.__setattr__(THEME, f.name, overrides.get(f.name, getattr(_DEFAULT_THEME, f.name))) diff --git a/negpy/desktop/view/widgets/preferences_dialog.py b/negpy/desktop/view/widgets/preferences_dialog.py index 7321546cb..c26829e42 100644 --- a/negpy/desktop/view/widgets/preferences_dialog.py +++ b/negpy/desktop/view/widgets/preferences_dialog.py @@ -19,7 +19,7 @@ from negpy.desktop.controller import AppController from negpy.desktop.view.styles.color_vision import PALETTES from negpy.desktop.view.styles.templates import field_label, hint_label, labeled_action, labeled_toggle, pin_dialog_default -from negpy.desktop.view.styles.theme import THEME +from negpy.desktop.view.styles.theme import THEME, UI_THEMES from negpy.desktop.view.widgets.collapsible import CollapsibleSection from negpy.desktop.view.widgets.dialog_geometry import remember_dialog_geometry from negpy.desktop.view.widgets.semantic_download_dialog import ClipDownloadDialog @@ -244,6 +244,16 @@ def _build_interface(self) -> QWidget: grid.addWidget(self.scale_combo, row, 1) row += 1 + grid.addWidget(field_label(tr("UI theme")), row, 0) + self.theme_combo = StepCombo() + for key, label in UI_THEMES: + self.theme_combo.addItem(tr(label), key) + stored_theme = str(self.repo.get_global_setting("ui_theme", "black") or "black") + self.theme_combo.setCurrentIndex(max(self.theme_combo.findData(stored_theme), 0)) + self.theme_combo.currentIndexChanged.connect(self._on_ui_theme_changed) + self.theme_combo.setToolTip(tr("Color of the app's panels and chrome")) + grid.addWidget(self.theme_combo, row, 1) + row += 1 grid.addWidget(field_label(tr("Canvas background")), row, 0) self.canvas_combo = StepCombo() # Darkest to lightest; the item data is the saved index, which is not in that order. @@ -484,6 +494,10 @@ def _on_ui_scale_changed(self, index: int) -> None: self.repo.save_global_setting("ui_scale", float(self.scale_combo.itemData(index))) self._mark_restart() + def _on_ui_theme_changed(self, index: int) -> None: + self.repo.save_global_setting("ui_theme", str(self.theme_combo.itemData(index))) + self._mark_restart() + def _on_language_changed(self, index: int) -> None: self.repo.save_global_setting("ui_language", self.language_combo.currentData()) self._mark_restart() diff --git a/negpy/translations/USER_GUIDE.en.md b/negpy/translations/USER_GUIDE.en.md index a6ed2ac79..994c708f9 100644 --- a/negpy/translations/USER_GUIDE.en.md +++ b/negpy/translations/USER_GUIDE.en.md @@ -826,6 +826,7 @@ Shows the whole strip, to place and pick the frames before the scan. **Apply Fra * **Language**: English, Simplified Chinese, Japanese or Korean, after a restart. * **UI scale**: 80% to 120%, after a restart. +* **UI theme**: **Black** (default), **Mid Gray** or **Light Gray** for the app's panels and chrome, after a restart. * **Canvas background**: from black to white. Mid gray is best for judging a print. * **Sticky Zoom**: keeps the zoom when you change frames. **Reverse Scroll Zoom**: scroll up zooms out. * **Shortcut Hints** (on at first): after you use a control with the mouse, a toast on the canvas shows its keyboard shortcut, once per session. diff --git a/negpy/translations/ja.json b/negpy/translations/ja.json index 5336fb76e..f5fc25106 100644 --- a/negpy/translations/ja.json +++ b/negpy/translations/ja.json @@ -265,6 +265,7 @@ "Bit depth": "ビット深度", "Bits per channel in the scanned file": "スキャンされたファイルのチャネルあたりのビット数", "Black": "ブラック", + "Black (Default)": "ブラック(デフォルト)", "Black Point": "黒点", "Black Point Compensation": "黒点補正", "Black Point Trim": "ブラックポイントトリム", @@ -510,6 +511,7 @@ "Color added to the highlights.": "ハイライトに加える色。", "Color added to the shadows.": "シャドウに加える色。", "Color negatives only: use the roll's gray balance for Cast Removal, so frames under one light match.": "カラーネガのみ: 色かぶり除去にロールのグレーバランスを使い、同じ光で撮ったフレームをそろえます。", + "Color of the app's panels and chrome": "アプリのパネルとインターフェースの色", "Color or black and white, negative or slide": "カラーか白黒か、ネガかスライドか", "Color range clip down": "色範囲クリップダウン", "Color range clip up": "色範囲クリップアップ", @@ -2747,6 +2749,7 @@ "Type a search first, e.g. film:portra": "先に検索条件を入力してください。例:film:portra", "Type a slider, card or action, or another editor's word for it: contrast, white balance.": "スライダー、カード、操作、または他の編集ソフトでの呼び名(コントラスト、ホワイトバランスなど)を入力します。", "UI scale": "UIスケール", + "UI theme": "UIテーマ", "USE AVERAGE": "平均を使用", "UV exposure time: more makes the print bluer, and less makes it paler.": "UV 露光時間: 多いほど青く、少ないほど淡くなります。", "Unavailable: {reason}": "利用不可: {reason}", diff --git a/negpy/translations/ko.json b/negpy/translations/ko.json index e81c8b5d3..71d71b400 100644 --- a/negpy/translations/ko.json +++ b/negpy/translations/ko.json @@ -265,6 +265,7 @@ "Bit depth": "비트 심도", "Bits per channel in the scanned file": "스캔한 파일의 채널당 비트", "Black": "블랙", + "Black (Default)": "블랙(기본값)", "Black Point": "블랙 포인트", "Black Point Compensation": "블랙 포인트 보상", "Black Point Trim": "블랙 포인트 트림", @@ -510,6 +511,7 @@ "Color added to the highlights.": "밝은 영역에 더하는 색입니다.", "Color added to the shadows.": "어두운 영역에 더하는 색입니다.", "Color negatives only: use the roll's gray balance for Cast Removal, so frames under one light match.": "컬러 네거티브 전용: 색 편향 제거에 롤의 회색 균형을 써서 같은 빛에서 찍은 프레임을 맞춥니다.", + "Color of the app's panels and chrome": "앱 패널과 인터페이스의 색상", "Color or black and white, negative or slide": "컬러 또는 흑백, 네거티브 또는 슬라이드", "Color range clip down": "색상 범위 클립 다운", "Color range clip up": "색상 범위 클립 업", @@ -2747,6 +2749,7 @@ "Type a search first, e.g. film:portra": "먼저 검색 조건을 입력하세요. 예: film:portra", "Type a slider, card or action, or another editor's word for it: contrast, white balance.": "슬라이더, 카드, 동작 이름이나 다른 편집 앱에서 쓰는 말(대비, 화이트 밸런스 등)을 입력하세요.", "UI scale": "UI 배율", + "UI theme": "UI 테마", "USE AVERAGE": "평균 사용", "UV exposure time: more makes the print bluer, and less makes it paler.": "UV 노출 시간: 많을수록 더 파랗고, 적을수록 더 옅어집니다.", "Unavailable: {reason}": "사용할 수 없음: {reason}", diff --git a/negpy/translations/zh_CN.json b/negpy/translations/zh_CN.json index b5a86d6ec..ef728b3af 100644 --- a/negpy/translations/zh_CN.json +++ b/negpy/translations/zh_CN.json @@ -265,6 +265,7 @@ "Bit depth": "位深", "Bits per channel in the scanned file": "扫描文件每个通道的位数", "Black": "黑色", + "Black (Default)": "黑色(默认)", "Black Point": "黑点", "Black Point Compensation": "黑点补偿", "Black Point Trim": "黑点微调", @@ -510,6 +511,7 @@ "Color added to the highlights.": "加到高光的颜色。", "Color added to the shadows.": "加到暗部的颜色。", "Color negatives only: use the roll's gray balance for Cast Removal, so frames under one light match.": "仅限彩色负片:去色偏使用整卷的灰平衡,让同一光线下的各帧一致。", + "Color of the app's panels and chrome": "应用面板和界面的颜色", "Color or black and white, negative or slide": "彩色或黑白,负片或反转片", "Color range clip down": "减小颜色范围截断", "Color range clip up": "增大颜色范围截断", @@ -2747,6 +2749,7 @@ "Type a search first, e.g. film:portra": "请先输入搜索内容,例如 film:portra", "Type a slider, card or action, or another editor's word for it: contrast, white balance.": "输入滑块、卡片或操作名称,也可使用反差、白平衡等常用词。", "UI scale": "界面缩放", + "UI theme": "界面主题", "USE AVERAGE": "使用平均值", "UV exposure time: more makes the print bluer, and less makes it paler.": "紫外曝光时间:越多越蓝,越少越浅。", "Unavailable: {reason}": "不可用:{reason}", diff --git a/tests/test_ui_theme.py b/tests/test_ui_theme.py new file mode 100644 index 000000000..6d26355b8 --- /dev/null +++ b/tests/test_ui_theme.py @@ -0,0 +1,50 @@ +import dataclasses + +import pytest + +from negpy.desktop.view.styles import theme +from negpy.desktop.view.styles.theme import THEME, UI_THEMES, ThemeConfig, apply_theme + + +@pytest.fixture(autouse=True) +def _restore_default_theme(): + yield + apply_theme("black") + + +def test_black_leaves_every_token_at_its_default(): + apply_theme("mid_gray") + apply_theme("black") + assert dataclasses.asdict(THEME) == dataclasses.asdict(ThemeConfig()) + + +def test_an_unknown_key_falls_back_to_black(): + apply_theme("light_gray") + apply_theme("no_such_theme") + assert dataclasses.asdict(THEME) == dataclasses.asdict(ThemeConfig()) + + +@pytest.mark.parametrize("key", ["mid_gray", "light_gray"]) +def test_a_gray_theme_retints_the_surfaces_in_place(key: str): + before = THEME + apply_theme(key) + assert THEME is before + assert THEME.bg_dark != ThemeConfig().bg_dark + assert THEME.bg_dark == theme._THEME_OVERRIDES[key]["bg_dark"] + + +def test_switching_themes_does_not_leak_the_previous_override(): + apply_theme("light_gray") + assert THEME.text_secondary == "#FFFFFF" + apply_theme("mid_gray") + assert THEME.text_secondary == ThemeConfig().text_secondary + + +def test_every_override_names_a_real_token(): + tokens = {f.name for f in dataclasses.fields(ThemeConfig)} + for key, overrides in theme._THEME_OVERRIDES.items(): + assert set(overrides) <= tokens, key + + +def test_every_listed_theme_resolves(): + assert {key for key, _ in UI_THEMES} == {"black", *theme._THEME_OVERRIDES}