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

1"""データセットの並び。描画の順(project.datasets)・一覧のツリー・保存用のフォルダ構造(dataset_group_tree)の 3 つと、 

2それらを合わせる規則をここに集める。 

3 

4今の規則(変えるときはここだけ): 

5- 追加はツリーでは指定したフォルダの末尾。描画順は、最上位なら末尾、フォルダの中ならツリーで直前にあるデータセットの 

6 すぐ後(一覧と描画の重なりをそろえる。K-18)。保存済みの並びは変えないので、開いたプロジェクトの見た目は変わらない。 

7- ドラッグの並べ替えのあとでだけ、描画順をツリーの順(深さ優先)に合わせる。同じフォルダの中の並べ替えだけ Undo できる。 

8- 保存用のフォルダ構造は保存・比較の直前にツリーから作り、読み込みではそこからツリーを作り直す。 

9- 削除の Undo は、描画順の全体と各項目の親・位置を控えて戻す。 

10""" 

11from PySide6.QtCore import Qt 

12 

13_DATASET_ROLE = Qt.ItemDataRole.UserRole 

14 

15 

16def item_dataset(item): 

17 """ツリーの項目のデータセット。フォルダなら None。""" 

18 return item.data(0, _DATASET_ROLE) 

19 

20 

21class DatasetOrder: 

22 

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 

28 

29 @property 

30 def tree(self): 

31 return self._tree() 

32 

33 @property 

34 def datasets(self): 

35 return self._project.datasets 

36 

37 # --- 探す --- 

38 

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 

45 

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 

57 

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 

63 

64 def current_dataset(self): 

65 item = self.tree.currentItem() 

66 if item is None: 

67 return None 

68 return item_dataset(item) 

69 

70 def selected_datasets(self): 

71 return [ds for item in self.tree.selectedItems() if (ds := item_dataset(item)) is not None] 

72 

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 

79 

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 

92 

93 # --- 足す --- 

94 

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 

101 

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) 

115 

116 def add_folder(self, name, parent_item=None): 

117 from PySide6.QtWidgets import QTreeWidgetItem 

118 

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 

127 

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) 

137 

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) 

146 

147 # --- 消す --- 

148 

149 def removal(self, top_level_items): 

150 """項目(データセットかフォルダ)を消す手順と戻す手順。消すものが無ければ None。 

151 

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 

165 

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

173 

174 datasets_before = list(self._project.datasets) 

175 

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) 

193 

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) 

206 

207 return remove, restore, removed_datasets 

208 

209 # --- 描画順とツリーの順 --- 

210 

211 def tree_order(self): 

212 """ツリーの表示順(深さ優先)に並べたデータセット。""" 

213 return [item_dataset(item) for item in self.dataset_items()] 

214 

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 

222 

223 tree.blockSignals(True) 

224 

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

228 

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 

237 

238 for _ in range(source.childCount()): 

239 source.takeChild(0) 

240 for item in new_children: 

241 source.addChild(item) 

242 

243 for item in new_children: 

244 if item_dataset(item) is None: 

245 sort_children(item) 

246 

247 sort_children(None) 

248 

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) 

256 

257 # --- 保存用のフォルダ構造 --- 

258 

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

273 

274 def rebuild_tree(self): 

275 """保存用のフォルダ構造からツリーを作り直す(読み込みのあと)。""" 

276 tree = self.tree 

277 tree.clear() 

278 

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) 

286 

287 build(self._project.dataset_group_tree, None) 

288 

289 # --- 検索 --- 

290 

291 def filter_by_name(self, text): 

292 """名前に検索文字列を含むデータセットだけを出す。フォルダは中に1つでも当たりがあれば出す。""" 

293 query = text.strip().lower() 

294 

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 

307 

308 root = self.tree.invisibleRootItem() 

309 for i in range(root.childCount()): 

310 apply_filter(root.child(i))