Coverage for graphica/gui/datasets/order.py: 95.7%
209 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"""データセットの並び。描画の順(project.datasets)・一覧のツリー・保存用のフォルダ構造(dataset_group_tree)の 3 つと、
2それらを合わせる規則をここに集める。
4今の規則(変えるときはここだけ):
5- 追加はツリーでは指定したフォルダの末尾。描画順は、最上位なら末尾、フォルダの中ならツリーで直前にあるデータセットの
6 すぐ後(一覧と描画の重なりをそろえる。K-18)。保存済みの並びは変えないので、開いたプロジェクトの見た目は変わらない。
7- ドラッグの並べ替えのあとでだけ、描画順をツリーの順(深さ優先)に合わせる。同じフォルダの中の並べ替えだけ Undo できる。
8- 保存用のフォルダ構造は保存・比較の直前にツリーから作り、読み込みではそこからツリーを作り直す。
9- 削除の Undo は、描画順の全体と各項目の親・位置を控えて戻す。
10"""
11from PySide6.QtCore import Qt
13_DATASET_ROLE = Qt.ItemDataRole.UserRole
16def item_dataset(item):
17 """ツリーの項目のデータセット。フォルダなら None。"""
18 return item.data(0, _DATASET_ROLE)
21class DatasetOrder:
23 def __init__(self, project, tree, make_dataset_item):
24 # ツリーは組み立ての途中で差し替わるので、使うたびに取り出す
25 self._project = project
26 self._tree = tree
27 self._make_dataset_item = make_dataset_item
29 @property
30 def tree(self):
31 return self._tree()
33 @property
34 def datasets(self):
35 return self._project.datasets
37 # --- 探す ---
39 def find_row(self, dataset):
40 """描画順の位置。Dataset の == は DataFrame を比べて例外になるので、同一性(is)で探す。"""
41 for i, ds in enumerate(self._project.datasets):
42 if ds is dataset:
43 return i
44 return -1
46 def dataset_items(self, parent_item=None):
47 """データセットの葉を表示順(深さ優先)に返す。"""
48 items = []
49 source = self.tree.invisibleRootItem() if parent_item is None else parent_item
50 for i in range(source.childCount()):
51 child = source.child(i)
52 if item_dataset(child) is not None:
53 items.append(child)
54 else:
55 items.extend(self.dataset_items(child))
56 return items
58 def item_for(self, dataset):
59 for item in self.dataset_items():
60 if item_dataset(item) is dataset:
61 return item
62 return None
64 def current_dataset(self):
65 item = self.tree.currentItem()
66 if item is None:
67 return None
68 return item_dataset(item)
70 def selected_datasets(self):
71 return [ds for item in self.tree.selectedItems() if (ds := item_dataset(item)) is not None]
73 def target_folder(self):
74 """選択中の項目がフォルダならそれ(新しいデータセットをその中に入れる)。"""
75 current_item = self.tree.currentItem()
76 if current_item is not None and item_dataset(current_item) is None:
77 return current_item
78 return None
80 @staticmethod
81 def top_level_items(items):
82 """ほかの選択項目の子孫でないものだけ(フォルダと中身が両方選ばれていても二重に扱わないため)。"""
83 item_ids = {id(it) for it in items}
84 result = []
85 for item in items:
86 ancestor = item.parent()
87 while ancestor is not None and id(ancestor) not in item_ids:
88 ancestor = ancestor.parent()
89 if ancestor is None:
90 result.append(item)
91 return result
93 # --- 足す ---
95 def append(self, dataset, parent_folder=None):
96 """ツリーのフォルダの末尾と、それに合う描画順の位置に足す。"""
97 item = self._make_dataset_item(dataset, parent_folder)
98 position = len(self._project.datasets) if parent_folder is None else self._draw_position_for(item)
99 self._project.datasets.insert(position, dataset)
100 return item
102 def _draw_position_for(self, item):
103 """ツリーで直前にあるデータセットの描画順のすぐ後。前に無ければ直後のものの前、どちらも無ければ末尾。"""
104 leaves = self.dataset_items()
105 index = next(i for i, leaf in enumerate(leaves) if leaf is item)
106 for leaf in reversed(leaves[:index]):
107 row = self.find_row(item_dataset(leaf))
108 if row != -1: 108 ↛ 106line 108 didn't jump to line 106 because the condition on line 108 was always true
109 return row + 1
110 for leaf in leaves[index + 1:]:
111 row = self.find_row(item_dataset(leaf))
112 if row != -1: 112 ↛ 110line 112 didn't jump to line 110 because the condition on line 112 was always true
113 return row
114 return len(self._project.datasets)
116 def add_folder(self, name, parent_item=None):
117 from PySide6.QtWidgets import QTreeWidgetItem
119 item = QTreeWidgetItem([name])
120 item.setData(0, _DATASET_ROLE, None)
121 if parent_item is not None:
122 parent_item.addChild(item)
123 else:
124 self.tree.addTopLevelItem(item)
125 item.setExpanded(True)
126 return item
128 def detach_item(self, item):
129 """ツリーから外す(親の子か最上位かで外し方が違う)。"""
130 parent = item.parent()
131 if parent is not None:
132 parent.removeChild(item)
133 else:
134 index = self.tree.indexOfTopLevelItem(item)
135 if index != -1: 135 ↛ exitline 135 didn't return from function 'detach_item' because the condition on line 135 was always true
136 self.tree.takeTopLevelItem(index)
138 def remove_added(self, dataset):
139 """append() の取り消し。"""
140 row = self.find_row(dataset)
141 if row != -1: 141 ↛ 143line 141 didn't jump to line 143 because the condition on line 141 was always true
142 del self._project.datasets[row]
143 item = self.item_for(dataset)
144 if item is not None: 144 ↛ exitline 144 didn't return from function 'remove_added' because the condition on line 144 was always true
145 self.detach_item(item)
147 # --- 消す ---
149 def removal(self, top_level_items):
150 """項目(データセットかフォルダ)を消す手順と戻す手順。消すものが無ければ None。
152 戻すために、描画順の全体と各項目の親・位置を控える。外した項目はこの手順が持つので、同じものを差し戻せる。
153 戻り値は (消す手順, 戻す手順, 消えるデータセット)。
154 """
155 tree = self.tree
156 snapshots = [] # [(item, parent_item_or_None, index_in_parent), ...]
157 for item in top_level_items:
158 parent = item.parent()
159 index = parent.indexOfChild(item) if parent is not None else tree.indexOfTopLevelItem(item)
160 if index == -1: 160 ↛ 161line 160 didn't jump to line 161 because the condition on line 160 was never true
161 continue
162 snapshots.append((item, parent, index))
163 if not snapshots:
164 return None
166 removed_datasets = []
167 for item, _parent, _index in snapshots:
168 dataset = item_dataset(item)
169 if dataset is not None:
170 removed_datasets.append(dataset)
171 else:
172 removed_datasets.extend(item_dataset(leaf) for leaf in self.dataset_items(item))
174 datasets_before = list(self._project.datasets)
176 def remove():
177 # 操作中に currentItemChanged が出ないように
178 tree.blockSignals(True)
179 try:
180 rows = sorted({row for ds in removed_datasets if (row := self.find_row(ds)) != -1}, reverse=True)
181 for row in rows:
182 del self._project.datasets[row]
183 # 同じ親の中でずれないよう、後ろから外す
184 for item, parent, _index in sorted(snapshots, key=lambda s: s[2], reverse=True):
185 if parent is not None:
186 parent.removeChild(item)
187 else:
188 index = tree.indexOfTopLevelItem(item)
189 if index != -1: 189 ↛ 184line 189 didn't jump to line 184 because the condition on line 189 was always true
190 tree.takeTopLevelItem(index)
191 finally:
192 tree.blockSignals(False)
194 def restore():
195 tree.blockSignals(True)
196 try:
197 for item, parent, index in sorted(snapshots, key=lambda s: s[2]):
198 if parent is not None:
199 parent.insertChild(min(index, parent.childCount()), item)
200 else:
201 tree.insertTopLevelItem(min(index, tree.topLevelItemCount()), item)
202 # リストの中身だけ差し替える(描画順も戻る)
203 self._project.datasets[:] = datasets_before
204 finally:
205 tree.blockSignals(False)
207 return remove, restore, removed_datasets
209 # --- 描画順とツリーの順 ---
211 def tree_order(self):
212 """ツリーの表示順(深さ優先)に並べたデータセット。"""
213 return [item_dataset(item) for item in self.dataset_items()]
215 def sort_tree_to_draw_order(self):
216 """フォルダの中の並びを描画順に合わせる(並べ替えの Undo/Redo 用)。フォルダ自体は動かさない。"""
217 tree = self.tree
218 order_index = {id(ds): i for i, ds in enumerate(self._project.datasets)}
219 selected_ids = {id(item_dataset(item)) for item in tree.selectedItems()}
220 current_item = tree.currentItem()
221 current_dataset = item_dataset(current_item) if current_item else None
223 tree.blockSignals(True)
225 def sort_children(parent_item):
226 source = tree.invisibleRootItem() if parent_item is None else parent_item
227 children = [source.child(i) for i in range(source.childCount())]
229 dataset_positions = [i for i, c in enumerate(children) if item_dataset(c) is not None]
230 dataset_items_sorted = sorted(
231 (children[i] for i in dataset_positions),
232 key=lambda it: order_index.get(id(item_dataset(it)), 0)
233 )
234 new_children = list(children)
235 for pos, item in zip(dataset_positions, dataset_items_sorted):
236 new_children[pos] = item
238 for _ in range(source.childCount()):
239 source.takeChild(0)
240 for item in new_children:
241 source.addChild(item)
243 for item in new_children:
244 if item_dataset(item) is None:
245 sort_children(item)
247 sort_children(None)
249 for item in self.dataset_items():
250 ds = item_dataset(item)
251 if id(ds) in selected_ids: 251 ↛ 252line 251 didn't jump to line 252 because the condition on line 251 was never true
252 item.setSelected(True)
253 if ds is current_dataset: 253 ↛ 254line 253 didn't jump to line 254 because the condition on line 253 was never true
254 tree.setCurrentItem(item)
255 tree.blockSignals(False)
257 # --- 保存用のフォルダ構造 ---
259 def group_tree(self):
260 """ツリーから保存用のフォルダ構造を作る。"""
261 def walk(parent_item):
262 children = []
263 source = self.tree.invisibleRootItem() if parent_item is None else parent_item
264 for i in range(source.childCount()):
265 child = source.child(i)
266 dataset = item_dataset(child)
267 if dataset is not None:
268 children.append({'dataset': dataset})
269 else:
270 children.append({'name': child.text(0), 'children': walk(child)})
271 return children
272 return {'name': '', 'children': walk(None)}
274 def rebuild_tree(self):
275 """保存用のフォルダ構造からツリーを作り直す(読み込みのあと)。"""
276 tree = self.tree
277 tree.clear()
279 def build(node, parent_item):
280 for child_node in node.get('children', []):
281 if 'dataset' in child_node:
282 self._make_dataset_item(child_node['dataset'], parent_item)
283 else:
284 folder_item = self.add_folder(child_node.get('name', 'フォルダ'), parent_item)
285 build(child_node, folder_item)
287 build(self._project.dataset_group_tree, None)
289 # --- 検索 ---
291 def filter_by_name(self, text):
292 """名前に検索文字列を含むデータセットだけを出す。フォルダは中に1つでも当たりがあれば出す。"""
293 query = text.strip().lower()
295 def apply_filter(item):
296 dataset = item_dataset(item)
297 if dataset is not None:
298 visible = (not query) or (query in dataset.name.lower())
299 item.setHidden(not visible)
300 return visible
301 any_child_visible = False
302 for i in range(item.childCount()):
303 if apply_filter(item.child(i)):
304 any_child_visible = True
305 item.setHidden(bool(query) and not any_child_visible)
306 return any_child_visible or not query
308 root = self.tree.invisibleRootItem()
309 for i in range(root.childCount()):
310 apply_filter(root.child(i))