Coverage for graphica/core/named_colors.py: 96.7%
89 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"""
2名前付きの色の登録簿(名前1つに色1つ)。同じ物質をどのプロジェクトでも同じ色で描くためのもの。
3配色パレット(core/color_palettes.py、順序付きの色のリスト)と混ぜると、パレット管理の
4「アクティブなパレット」の意味が壊れるので、QSettings のキーから別にしている。
5"""
6import json
7import re
8from typing import Any
10# 並び順がポップアップの表示順なので、辞書ではなく JSON の配列で保存する。
11NAMED_COLORS_SETTINGS_KEY = "named_colors_json"
13MAX_NAME_LENGTH = 60
14POPUP_LIMIT = 5 # これを超えた分は「すべての登録色...」から選ぶ
16_HEX_RE = re.compile(r'^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$')
19class NamedColorError(ValueError):
20 """メッセージはそのまま利用者に表示する。"""
23def normalize_color(value: Any) -> str:
24 """`#rgb` / `#rrggbb` を小文字の `#rrggbb` にする。QColor を使わないのは Qt 非依存にするため。"""
25 text = (value or "").strip()
26 if not _HEX_RE.match(text):
27 raise NamedColorError(f"色コードが不正です: {value!r}(例: #1f77b4)")
28 text = text.lower()
29 if len(text) == 4:
30 text = "#" + "".join(ch * 2 for ch in text[1:])
31 return text
34def normalize_name(value: Any) -> str:
35 name = (value or "").strip()
36 if not name:
37 raise NamedColorError("登録名を入力してください。")
38 if len(name) > MAX_NAME_LENGTH:
39 raise NamedColorError(f"登録名が長すぎます({MAX_NAME_LENGTH}文字まで)。")
40 return name
43def load_named_colors(settings: Any) -> list[dict[str, str]]:
44 """壊れたエントリは1件ずつ捨てる。設定が壊れても起動できなくなるよりは、登録が一部消える方がよい。"""
45 raw = settings.value(NAMED_COLORS_SETTINGS_KEY, "")
46 if not raw:
47 return []
48 try:
49 data = json.loads(raw) if isinstance(raw, str) else raw
50 except (ValueError, TypeError):
51 return []
52 if not isinstance(data, list):
53 return []
55 entries = []
56 seen = set()
57 for item in data:
58 if not isinstance(item, dict):
59 continue
60 try:
61 name = normalize_name(item.get("name"))
62 color = normalize_color(item.get("color"))
63 except NamedColorError:
64 continue
65 if name in seen:
66 continue
67 seen.add(name)
68 entries.append({"name": name, "color": color})
69 return entries
72def save_named_colors(settings: Any, entries: list[dict[str, str]]) -> None:
73 payload = [{"name": e["name"], "color": e["color"]} for e in entries]
74 settings.setValue(NAMED_COLORS_SETTINGS_KEY, json.dumps(payload, ensure_ascii=False))
77def find_index_by_name(entries: list[dict[str, str]], name: str) -> int:
78 target = (name or "").strip()
79 for index, entry in enumerate(entries):
80 if entry["name"] == target:
81 return index
82 return -1
85# 編集関数はどれも、引数のリストを変えずに新しいリストを返す。
88def add_named_color(entries: list[dict[str, str]], name: object, color: object) -> list[dict[str, str]]:
89 # 名前と色が1対1でないと、名前から色が決まらないので重複は禁止。
90 name = normalize_name(name)
91 color = normalize_color(color)
92 if find_index_by_name(entries, name) != -1:
93 raise NamedColorError(f"「{name}」は既に登録されています。")
94 return list(entries) + [{"name": name, "color": color}]
97def update_named_color(entries: list[dict[str, str]], index: int, name: object, color: object) -> list[dict[str, str]]:
98 if not 0 <= index < len(entries): 98 ↛ 99line 98 didn't jump to line 99 because the condition on line 98 was never true
99 raise NamedColorError("編集対象が選択されていません。")
100 name = normalize_name(name)
101 color = normalize_color(color)
102 existing = find_index_by_name(entries, name)
103 if existing != -1 and existing != index:
104 raise NamedColorError(f"「{name}」は既に登録されています。")
105 updated = list(entries)
106 updated[index] = {"name": name, "color": color}
107 return updated
110def remove_named_color(entries: list[dict[str, str]], index: int) -> list[dict[str, str]]:
111 if not 0 <= index < len(entries):
112 raise NamedColorError("削除対象が選択されていません。")
113 updated = list(entries)
114 del updated[index]
115 return updated
118def move_named_color(entries: list[dict[str, str]], index: int, offset: int) -> list[dict[str, str]]:
119 if not 0 <= index < len(entries): 119 ↛ 120line 119 didn't jump to line 120 because the condition on line 119 was never true
120 raise NamedColorError("移動対象が選択されていません。")
121 new_index = index + offset
122 if not 0 <= new_index < len(entries):
123 return list(entries)
124 updated = list(entries)
125 updated.insert(new_index, updated.pop(index))
126 return updated