Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions negpy/desktop/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
78 changes: 78 additions & 0 deletions negpy/desktop/view/styles/theme.py
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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)))
16 changes: 15 additions & 1 deletion negpy/desktop/view/widgets/preferences_dialog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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()
Expand Down
1 change: 1 addition & 0 deletions negpy/translations/USER_GUIDE.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions negpy/translations/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,7 @@
"Bit depth": "ビット深度",
"Bits per channel in the scanned file": "スキャンされたファイルのチャネルあたりのビット数",
"Black": "ブラック",
"Black (Default)": "ブラック(デフォルト)",
"Black Point": "黒点",
"Black Point Compensation": "黒点補正",
"Black Point Trim": "ブラックポイントトリム",
Expand Down Expand Up @@ -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": "色範囲クリップアップ",
Expand Down Expand 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}",
Expand Down
3 changes: 3 additions & 0 deletions negpy/translations/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,7 @@
"Bit depth": "비트 심도",
"Bits per channel in the scanned file": "스캔한 파일의 채널당 비트",
"Black": "블랙",
"Black (Default)": "블랙(기본값)",
"Black Point": "블랙 포인트",
"Black Point Compensation": "블랙 포인트 보상",
"Black Point Trim": "블랙 포인트 트림",
Expand Down Expand Up @@ -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": "색상 범위 클립 업",
Expand Down Expand 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}",
Expand Down
3 changes: 3 additions & 0 deletions negpy/translations/zh_CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,7 @@
"Bit depth": "位深",
"Bits per channel in the scanned file": "扫描文件每个通道的位数",
"Black": "黑色",
"Black (Default)": "黑色(默认)",
"Black Point": "黑点",
"Black Point Compensation": "黑点补偿",
"Black Point Trim": "黑点微调",
Expand Down Expand Up @@ -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": "增大颜色范围截断",
Expand Down Expand 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}",
Expand Down
50 changes: 50 additions & 0 deletions tests/test_ui_theme.py
Original file line number Diff line number Diff line change
@@ -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}