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

1""" 

2名前付きの色の登録簿(名前1つに色1つ)。同じ物質をどのプロジェクトでも同じ色で描くためのもの。 

3配色パレット(core/color_palettes.py、順序付きの色のリスト)と混ぜると、パレット管理の 

4「アクティブなパレット」の意味が壊れるので、QSettings のキーから別にしている。 

5""" 

6import json 

7import re 

8from typing import Any 

9 

10# 並び順がポップアップの表示順なので、辞書ではなく JSON の配列で保存する。 

11NAMED_COLORS_SETTINGS_KEY = "named_colors_json" 

12 

13MAX_NAME_LENGTH = 60 

14POPUP_LIMIT = 5 # これを超えた分は「すべての登録色...」から選ぶ 

15 

16_HEX_RE = re.compile(r'^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$') 

17 

18 

19class NamedColorError(ValueError): 

20 """メッセージはそのまま利用者に表示する。""" 

21 

22 

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 

32 

33 

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 

41 

42 

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 [] 

54 

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 

70 

71 

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

75 

76 

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 

83 

84 

85# 編集関数はどれも、引数のリストを変えずに新しいリストを返す。 

86 

87 

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}] 

95 

96 

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 

108 

109 

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 

116 

117 

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