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

1"""テーマカラー(利用者が選ぶ1色)から、UI の強調に使う色を計算する。Qt に依存しない。 

2 

3選んだ色の色相と鮮やかさは保ち、明るさだけを動かして背景とのコントラスト比(WCAG 2)を確保する。 

4ライトでは暗く、ダークでは明るくする。設定には選んだ色だけを保存し、この計算は毎回行う。 

5""" 

6import colorsys 

7import math 

8import re 

9 

10DEFAULT_THEME_COLOR = "#0F4C5C" # アプリアイコンの背景色 

11 

12THEME_COLOR_PRESETS = ( 

13 ("アイコン", "#0F4C5C"), 

14 ("ティール", "#1F7A80"), 

15 ("インディゴ", "#4F46E5"), 

16 ("ブルー", "#2563EB"), 

17 ("パープル", "#7C3AED"), 

18 ("ローズ", "#E11D48"), 

19 ("オレンジ", "#F97316"), 

20 ("イエロー", "#FACC15"), 

21) 

22 

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 

32 

33_LIGHTNESS_STEP = 0.01 

34_HEX_RE = re.compile(r"^#?([0-9a-fA-F]{6})$") 

35 

36 

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() 

43 

44 

45def theme_color_or_default(value) -> str: 

46 """設定から読んだ値。壊れていたら既定の色にする(設定ファイルの傷で起動できなくならないように)。""" 

47 try: 

48 return normalize_hex(value) 

49 except ValueError: 

50 return DEFAULT_THEME_COLOR 

51 

52 

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)) 

56 

57 

58def _round_half_up(value: float) -> int: 

59 # 見本(JavaScript の Math.round)と同じ丸め。Python の round() は偶数への丸めで、端で1ずれる 

60 return int(math.floor(value + 0.5)) 

61 

62 

63def _to_hex(rgb) -> str: 

64 return "#" + "".join(f"{max(0, min(255, _round_half_up(v))):02X}" for v in rgb) 

65 

66 

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 

73 

74 

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) 

78 

79 

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 

91 

92 

93def derive_accent(theme_color: str, background: str, dark: bool) -> dict: 

94 """選んだ色と、その色を置く背景(パネルの面)から、強調の4色を作る。 

95 

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 }