Coverage for graphica/gui/theme_color.py: 98.5%
61 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-12 16:38 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-12 16:38 +0000
1"""テーマカラー(利用者が選ぶ1色)から、UI の強調に使う色を計算する。Qt に依存しない。
3選んだ色の色相と鮮やかさは保ち、明るさだけを動かして背景とのコントラスト比(WCAG 2)を確保する。
4ライトでは暗く、ダークでは明るくする。設定には選んだ色だけを保存し、この計算は毎回行う。
5"""
6import colorsys
7import math
8import re
10DEFAULT_THEME_COLOR = "#0F4C5C" # アプリアイコンの背景色
12THEME_COLOR_PRESETS = (
13 ("アイコン", "#0F4C5C"),
14 ("ティール", "#1F7A80"),
15 ("インディゴ", "#4F46E5"),
16 ("ブルー", "#2563EB"),
17 ("パープル", "#7C3AED"),
18 ("ローズ", "#E11D48"),
19 ("オレンジ", "#F97316"),
20 ("イエロー", "#FACC15"),
21)
23# 強調の色と、強調の色で書く文字に求めるコントラスト比
24ACCENT_MIN_CONTRAST = 4.5
25LIGHT_TEXT_MIN_CONTRAST = 4.5
26DARK_TEXT_MIN_CONTRAST = 7.0
27# 塗りの上の文字の候補。白と、ほぼ黒
28ON_ACCENT_LIGHT = "#FFFFFF"
29ON_ACCENT_DARK = "#0C0E11"
30ACCENT_SOFT_ALPHA_LIGHT = 0.12
31ACCENT_SOFT_ALPHA_DARK = 0.18
33_LIGHTNESS_STEP = 0.01
34_HEX_RE = re.compile(r"^#?([0-9a-fA-F]{6})$")
37def normalize_hex(color: str) -> str:
38 """'#rrggbb' の大文字にそろえる。形が違えば ValueError。"""
39 match = _HEX_RE.match(color.strip()) if isinstance(color, str) else None
40 if not match:
41 raise ValueError(f"テーマカラーは #RRGGBB の形で指定する: {color!r}")
42 return "#" + match.group(1).upper()
45def theme_color_or_default(value) -> str:
46 """設定から読んだ値。壊れていたら既定の色にする(設定ファイルの傷で起動できなくならないように)。"""
47 try:
48 return normalize_hex(value)
49 except ValueError:
50 return DEFAULT_THEME_COLOR
53def _to_rgb(hex_color: str) -> tuple:
54 value = normalize_hex(hex_color)[1:]
55 return tuple(int(value[i:i + 2], 16) for i in (0, 2, 4))
58def _round_half_up(value: float) -> int:
59 # 見本(JavaScript の Math.round)と同じ丸め。Python の round() は偶数への丸めで、端で1ずれる
60 return int(math.floor(value + 0.5))
63def _to_hex(rgb) -> str:
64 return "#" + "".join(f"{max(0, min(255, _round_half_up(v))):02X}" for v in rgb)
67def relative_luminance(rgb) -> float:
68 def channel(v):
69 v = v / 255
70 return v / 12.92 if v <= 0.03928 else ((v + 0.055) / 1.055) ** 2.4
71 r, g, b = (channel(v) for v in rgb)
72 return 0.2126 * r + 0.7152 * g + 0.0722 * b
75def contrast_ratio(rgb_a, rgb_b) -> float:
76 a, b = relative_luminance(rgb_a), relative_luminance(rgb_b)
77 return (max(a, b) + 0.05) / (min(a, b) + 0.05)
80def _until_contrast(rgb, background, target, direction):
81 """明るさだけを少しずつ動かし、background との比が target に届いた色(丸めた RGB)を返す。"""
82 h, lightness, s = colorsys.rgb_to_hls(*(v / 255 for v in rgb))
83 current = tuple(int(v) for v in rgb)
84 # 判定は丸めた後の色で行う(丸める前に届いていても、丸めで比が 4.4956 のように割ることがある)
85 for _ in range(100): 85 ↛ 90line 85 didn't jump to line 90 because the loop on line 85 didn't complete
86 if contrast_ratio(current, background) >= target:
87 break
88 lightness = max(0.0, min(1.0, lightness + direction * _LIGHTNESS_STEP))
89 current = tuple(_round_half_up(v * 255) for v in colorsys.hls_to_rgb(h, lightness, s))
90 return current
93def derive_accent(theme_color: str, background: str, dark: bool) -> dict:
94 """選んだ色と、その色を置く背景(パネルの面)から、強調の4色を作る。
96 Returns:
97 accent(塗り・枠)、accent_fg(強調の色で書く文字)、on_accent(塗りの上の文字)、
98 accent_soft(選択の背景。QSS の rgba(...))、adjusted(accent が選んだ色から変わったか)。
99 """
100 chosen = normalize_hex(theme_color)
101 bg = _to_rgb(background)
102 direction = 1 if dark else -1
103 accent = _until_contrast(_to_rgb(chosen), bg, ACCENT_MIN_CONTRAST, direction)
104 text_target = DARK_TEXT_MIN_CONTRAST if dark else LIGHT_TEXT_MIN_CONTRAST
105 accent_fg = _until_contrast(accent, bg, text_target, direction)
106 on_light, on_dark = _to_rgb(ON_ACCENT_LIGHT), _to_rgb(ON_ACCENT_DARK)
107 on_accent = (ON_ACCENT_LIGHT if contrast_ratio(accent, on_light) >= contrast_ratio(accent, on_dark)
108 else ON_ACCENT_DARK)
109 alpha = ACCENT_SOFT_ALPHA_DARK if dark else ACCENT_SOFT_ALPHA_LIGHT
110 accent_hex = _to_hex(accent)
111 return {
112 "accent": accent_hex,
113 "accent_fg": _to_hex(accent_fg),
114 "on_accent": on_accent,
115 "accent_soft": f"rgba({accent[0]}, {accent[1]}, {accent[2]}, {alpha})",
116 "adjusted": accent_hex != chosen,
117 }