Coverage for graphica/gui/datasets/colors.py: 90.9%

142 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-12 16:38 +0000

1"""データセットの色: 色の変更、自動配色、名前付きの色の適用、配色パレットの管理。""" 

2import json 

3import logging 

4import matplotlib as mpl 

5from PySide6.QtWidgets import (QDialog) 

6 

7from graphica.core.i18n import tr 

8from graphica.gui import notify 

9from graphica.core.color_palettes import BUILTIN_PALETTES, default_color_cycle, known_palette_names 

10from graphica.core.named_colors import POPUP_LIMIT, load_named_colors 

11from graphica.gui import app_settings 

12from graphica.gui.dialogs import (ColorPaletteDialog) 

13 

14logger = logging.getLogger(__name__) 

15 

16 

17# カラーマップからの自動配色で選ばせる候補(順序のある系列に向く、知覚的に均一なもの中心)。 

18RECOMMENDED_COLORMAPS = ['viridis', 'plasma', 'cividis', 'coolwarm', 'turbo', 'rainbow'] 

19 

20 

21class ColorController: 

22 """データセットの色と配色パレット。""" 

23 

24 def __init__(self, host): 

25 self._host = host 

26 

27 def on_color_changed(self, new_color): 

28 """色欄で選んだ色を選択中のデータセットすべてに当てる(Undo 1回分)。""" 

29 selected_datasets = self._host.selected_datasets() 

30 if not selected_datasets: 

31 return 

32 

33 is_batch = len(selected_datasets) > 1 

34 if is_batch: 

35 self._host.undo_stack.beginMacro(f"データセットの色を一括変更 ({len(selected_datasets)}件)") 

36 for dataset in selected_datasets: 

37 self._host.push_property_change( 

38 dataset, 

39 {'color': dataset.color}, 

40 {'color': new_color}, 

41 description="データセットの色変更" 

42 ) 

43 if is_batch: 

44 self._host.undo_stack.endMacro() 

45 

46 def on_gradient_color2_changed(self, new_color): 

47 """グラデーションの終端色を選択中のデータセットすべてに当てる(Undo 1回分)。""" 

48 selected_datasets = self._host.selected_datasets() 

49 if not selected_datasets: 

50 return 

51 

52 is_batch = len(selected_datasets) > 1 

53 if is_batch: 53 ↛ 54line 53 didn't jump to line 54 because the condition on line 53 was never true

54 self._host.undo_stack.beginMacro(f"グラデーション終端色を一括変更 ({len(selected_datasets)}件)") 

55 for dataset in selected_datasets: 

56 self._host.push_property_change( 

57 dataset, 

58 {'gradient_color2': dataset.gradient_color2}, 

59 {'gradient_color2': new_color}, 

60 description="グラデーション終端色の変更" 

61 ) 

62 if is_batch: 62 ↛ 63line 62 didn't jump to line 63 because the condition on line 62 was never true

63 self._host.undo_stack.endMacro() 

64 

65 def auto_assign_colors(self): 

66 """選択中のデータセットに、今のパレットの色を順に割り当てる。""" 

67 selected_datasets = self._host.selected_datasets() 

68 if not selected_datasets: 

69 return 

70 

71 color_cycle = self._host.active_color_cycle() 

72 

73 is_batch = len(selected_datasets) > 1 

74 if is_batch: 

75 self._host.undo_stack.beginMacro(f"配色の自動割り当て ({len(selected_datasets)}件)") 

76 for i, dataset in enumerate(selected_datasets): 

77 new_color = color_cycle[i % len(color_cycle)] 

78 self._host.push_property_change( 

79 dataset, 

80 {'color': dataset.color}, 

81 {'color': new_color}, 

82 description="配色の自動割り当て" 

83 ) 

84 if is_batch: 

85 self._host.undo_stack.endMacro() 

86 

87 def populate_named_color_menu(self): 

88 """ 

89 「登録した色を適用」の中身を作り直す(登録はいつでも変わるので開くたびに)。 

90 登録が無いときは、空のメニューではなく無効な案内項目を1つ出す。 

91 """ 

92 menu = getattr(self._host.parent_widget, '_named_color_apply_menu', None) 

93 if menu is None: 93 ↛ 94line 93 didn't jump to line 94 because the condition on line 93 was never true

94 return 

95 menu.clear() 

96 entries = load_named_colors(self._host.settings) 

97 if not entries: 

98 empty_action = menu.addAction(tr("(登録がありません)")) 

99 empty_action.setEnabled(False) 

100 return 

101 # 色欄のポップアップと同じく、並べるのは先頭 POPUP_LIMIT 件まで。 

102 for entry in entries[:POPUP_LIMIT]: 

103 action = menu.addAction( 

104 _named_color_menu_icon(entry["color"]), 

105 f'{entry["name"]} {entry["color"]}') 

106 action.triggered.connect( 

107 lambda checked=False, c=entry["color"], n=entry["name"]: 

108 self.apply_named_color_to_selection(c, n)) 

109 if len(entries) > POPUP_LIMIT: 

110 more_action = menu.addAction(f"すべての登録色... ({len(entries)}件)") 

111 more_action.triggered.connect(self.apply_named_color_from_list) 

112 

113 def apply_named_color_from_list(self): 

114 """登録が POPUP_LIMIT 件を超えたときに、検索欄付きの一覧から選んで適用する。""" 

115 from graphica.gui.dialogs import NamedColorPickerDialog 

116 dialog = NamedColorPickerDialog(self._host.settings, self._host.parent_widget, title="登録色を適用") 

117 if dialog.exec() != dialog.DialogCode.Accepted: 

118 return 

119 entry = dialog.selected_entry() 

120 if entry: 

121 self.apply_named_color_to_selection(entry["color"], entry["name"]) 

122 

123 def apply_named_color_to_selection(self, color, name): 

124 """登録した色を選択中のデータセットに当てる(Undo 1回分)。""" 

125 selected_datasets = self._host.selected_datasets() 

126 if not selected_datasets: 

127 self._host.show_status("データセットを選択してください。", 4000) 

128 return 

129 

130 # 変わらないものを先に除く。空のマクロでも Qt は Undo を1回分積んでしまう。 

131 targets = [ds for ds in selected_datasets if ds.color != color] 

132 if not targets: 

133 self._host.show_status(f"選択中のデータセットは既に「{name}」の色です。", 4000) 

134 return 

135 

136 is_batch = len(targets) > 1 

137 if is_batch: 137 ↛ 139line 137 didn't jump to line 139 because the condition on line 137 was always true

138 self._host.undo_stack.beginMacro(f"「{name}」の色を適用 ({len(targets)}件)") 

139 for dataset in targets: 

140 self._host.push_property_change( 

141 dataset, 

142 {'color': dataset.color}, 

143 {'color': color}, 

144 description=f"「{name}」の色を適用", 

145 ) 

146 if is_batch: 146 ↛ exitline 146 didn't return from function 'apply_named_color_to_selection' because the condition on line 146 was always true

147 self._host.undo_stack.endMacro() 

148 

149 def auto_assign_colors_from_colormap(self): 

150 """選択中のデータセットに、カラーマップから等間隔に取った色を割り当てる(順序のある系列向け)。""" 

151 selected_datasets = self._host.selected_datasets() 

152 if not selected_datasets: 

153 return 

154 

155 cmap_name, ok = notify.get_item( 

156 self._host.parent_widget, "カラーマップから自動配色", "使用するカラーマップを選択してください:", 

157 RECOMMENDED_COLORMAPS, 0, False 

158 ) 

159 if not ok: 

160 return 

161 

162 cmap = mpl.colormaps[cmap_name] 

163 n = len(selected_datasets) 

164 # 1件ならカラーマップの中央の色 

165 positions = [0.5] if n == 1 else [i / (n - 1) for i in range(n)] 

166 new_colors = [mpl.colors.to_hex(cmap(p)) for p in positions] 

167 

168 is_batch = n > 1 

169 if is_batch: 

170 self._host.undo_stack.beginMacro(f"カラーマップからの配色 ({n}件)") 

171 for dataset, new_color in zip(selected_datasets, new_colors): 

172 self._host.push_property_change( 

173 dataset, {'color': dataset.color}, {'color': new_color}, 

174 description="カラーマップからの配色" 

175 ) 

176 if is_batch: 

177 self._host.undo_stack.endMacro() 

178 

179 def load_palettes(self): 

180 raw = app_settings.CUSTOM_COLOR_PALETTES.read(self._host.settings) 

181 if not raw: 

182 return {} 

183 try: 

184 return json.loads(raw) 

185 except (TypeError, ValueError): 

186 logger.warning("カスタム配色パレットの読み込みに失敗しました。空として扱います。") 

187 return {} 

188 

189 def save_palettes(self, palettes: dict): 

190 app_settings.CUSTOM_COLOR_PALETTES.write(self._host.settings, json.dumps(palettes)) 

191 

192 def active_color_cycle(self): 

193 """今のパレットの色。未設定や空なら matplotlib の既定の色の並び。""" 

194 active_name = app_settings.ACTIVE_COLOR_PALETTE.read( 

195 self._host.settings, default=ColorPaletteDialog.DEFAULT_PALETTE_NAME) 

196 if active_name in BUILTIN_PALETTES: 

197 return BUILTIN_PALETTES[active_name] 

198 palettes = self.load_palettes() 

199 if active_name != ColorPaletteDialog.DEFAULT_PALETTE_NAME and palettes.get(active_name): 

200 return palettes[active_name] 

201 return default_color_cycle() 

202 

203 def set_active_palette(self, name): 

204 if name not in known_palette_names(self.load_palettes()): 

205 raise ValueError(f"配色パレット「{name}」はありません。") 

206 app_settings.ACTIVE_COLOR_PALETTE.write(self._host.settings, name) 

207 

208 def manage_palettes(self): 

209 palettes = self.load_palettes() 

210 active_name = app_settings.ACTIVE_COLOR_PALETTE.read( 

211 self._host.settings, default=ColorPaletteDialog.DEFAULT_PALETTE_NAME) 

212 

213 dialog = ColorPaletteDialog(palettes, active_name, self._host.parent_widget) 

214 if dialog.exec() == QDialog.DialogCode.Accepted: 

215 new_palettes, new_active_name = dialog.get_result() 

216 self.save_palettes(new_palettes) 

217 app_settings.ACTIVE_COLOR_PALETTE.write(self._host.settings, new_active_name) 

218 

219 

220def _named_color_menu_icon(color_name, size=16): 

221 """「登録した色を適用」メニューの色見本(色欄のポップアップと同じ描き方)。""" 

222 from graphica.gui.color_picker_widget import _color_icon 

223 return _color_icon(color_name, size=size)