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
« 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)
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)
14logger = logging.getLogger(__name__)
17# カラーマップからの自動配色で選ばせる候補(順序のある系列に向く、知覚的に均一なもの中心)。
18RECOMMENDED_COLORMAPS = ['viridis', 'plasma', 'cividis', 'coolwarm', 'turbo', 'rainbow']
21class ColorController:
22 """データセットの色と配色パレット。"""
24 def __init__(self, host):
25 self._host = host
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
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()
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
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()
65 def auto_assign_colors(self):
66 """選択中のデータセットに、今のパレットの色を順に割り当てる。"""
67 selected_datasets = self._host.selected_datasets()
68 if not selected_datasets:
69 return
71 color_cycle = self._host.active_color_cycle()
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()
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)
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"])
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
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
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()
149 def auto_assign_colors_from_colormap(self):
150 """選択中のデータセットに、カラーマップから等間隔に取った色を割り当てる(順序のある系列向け)。"""
151 selected_datasets = self._host.selected_datasets()
152 if not selected_datasets:
153 return
155 cmap_name, ok = notify.get_item(
156 self._host.parent_widget, "カラーマップから自動配色", "使用するカラーマップを選択してください:",
157 RECOMMENDED_COLORMAPS, 0, False
158 )
159 if not ok:
160 return
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]
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()
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 {}
189 def save_palettes(self, palettes: dict):
190 app_settings.CUSTOM_COLOR_PALETTES.write(self._host.settings, json.dumps(palettes))
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()
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)
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)
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)
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)