Coverage for C:\leo.repo\leo-editor\leo\plugins\nested_splitter.py: 14%

Shortcuts on this page

r m x   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

681 statements  

1#@+leo-ver=5-thin 

2#@+node:ekr.20110605121601.17954: * @file ../plugins/nested_splitter.py 

3"""Nested splitter classes.""" 

4from leo.core import leoGlobals as g 

5from leo.core.leoQt import isQt6, Qt, QtCore, QtGui, QtWidgets 

6from leo.core.leoQt import ContextMenuPolicy, Orientation, QAction 

7# pylint: disable=cell-var-from-loop 

8#@+others 

9#@+node:ekr.20110605121601.17956: ** init 

10def init(): 

11 # Allow this to be imported as a plugin, 

12 # but it should never be necessary to do so. 

13 return True 

14#@+node:tbrown.20120418121002.25711: ** class NestedSplitterTopLevel (QWidget) 

15class NestedSplitterTopLevel(QtWidgets.QWidget): # type:ignore 

16 """A QWidget to wrap a NestedSplitter to allow it to live in a top 

17 level window and handle close events properly. 

18 

19 These windows are opened by the splitter handle context-menu item 

20 'Open Window'. 

21 

22 The NestedSplitter itself can't be the top-level widget/window, 

23 because it assumes it can wrap itself in another NestedSplitter 

24 when the user wants to "Add Above/Below/Left/Right". I.e. wrap 

25 a vertical nested splitter in a horizontal nested splitter, or 

26 visa versa. Parent->SplitterOne becomes Parent->SplitterTwo->SplitterOne, 

27 where parent is either Leo's main window's QWidget 'centralwidget', 

28 or one of these NestedSplitterTopLevel "window frames". 

29 """ 

30 #@+others 

31 #@+node:tbrown.20120418121002.25713: *3* __init__ 

32 def __init__(self, *args, **kargs): 

33 """Init. taking note of the FreeLayoutController which owns this""" 

34 self.owner = kargs['owner'] 

35 del kargs['owner'] 

36 window_title = kargs.get('window_title') 

37 del kargs['window_title'] 

38 super().__init__(*args, **kargs) 

39 if window_title: 

40 self.setWindowTitle(window_title) 

41 #@+node:tbrown.20120418121002.25714: *3* closeEvent (NestedSplitterTopLevel) 

42 def closeEvent(self, event): 

43 """A top-level NestedSplitter window has been closed, check all the 

44 panes for widgets which must be preserved, and move any found 

45 back into the main splitter.""" 

46 widget = self.findChild(NestedSplitter) 

47 # top level NestedSplitter in window being closed 

48 other_top = self.owner.top() 

49 # top level NestedSplitter in main splitter 

50 # adapted from NestedSplitter.remove() 

51 count = widget.count() 

52 all_ok = True 

53 to_close = [] 

54 # get list of widgets to close so index based access isn't 

55 # derailed by closing widgets in the same loop 

56 for splitter in widget.self_and_descendants(): 

57 for i in range(splitter.count() - 1, -1, -1): 

58 to_close.append(splitter.widget(i)) 

59 for w in to_close: 

60 all_ok &= (widget.close_or_keep(w, other_top=other_top) is not False) 

61 # it should always be ok to close the window, because it should always 

62 # be possible to move widgets which must be preserved back to the 

63 # main splitter, but if not, keep this window open 

64 if all_ok or count <= 0: 

65 self.owner.closing(self) 

66 else: 

67 event.ignore() 

68 #@-others 

69#@+node:ekr.20110605121601.17959: ** class NestedSplitterChoice (QWidget) 

70class NestedSplitterChoice(QtWidgets.QWidget): # type:ignore 

71 """When a new pane is opened in a nested splitter layout, this widget 

72 presents a button, labled 'Action', which provides a popup menu 

73 for the user to select what to do in the new pane""" 

74 #@+others 

75 #@+node:ekr.20110605121601.17960: *3* __init__ (NestedSplitterChoice) 

76 def __init__(self, parent=None): 

77 """ctor for NestedSplitterChoice class.""" 

78 super().__init__(parent) 

79 self.setLayout(QtWidgets.QVBoxLayout()) 

80 button = QtWidgets.QPushButton("Action", self) # EKR: 2011/03/15 

81 self.layout().addWidget(button) 

82 button.setContextMenuPolicy(ContextMenuPolicy.CustomContextMenu) 

83 button.customContextMenuRequested.connect( 

84 lambda pnt: self.parent().choice_menu(self, 

85 button.mapToParent(pnt))) 

86 button.clicked.connect(lambda: self.parent().choice_menu(self, button.pos())) 

87 #@-others 

88#@+node:ekr.20110605121601.17961: ** class NestedSplitterHandle (QSplitterHandle) 

89class NestedSplitterHandle(QtWidgets.QSplitterHandle): # type:ignore 

90 """Show the context menu on a NestedSplitter splitter-handle to access 

91 NestedSplitter's special features""" 

92 #@+others 

93 #@+node:ekr.20110605121601.17962: *3* nsh.__init__ 

94 def __init__(self, owner): 

95 """Ctor for NestedSplitterHandle class.""" 

96 super().__init__(owner.orientation(), owner) 

97 # Confusing! 

98 # self.setStyleSheet("background-color: green;") 

99 self.setContextMenuPolicy(ContextMenuPolicy.CustomContextMenu) 

100 self.customContextMenuRequested.connect(self.splitter_menu) 

101 #@+node:ekr.20110605121601.17963: *3* nsh.__repr__ 

102 def __repr__(self): 

103 return f"(NestedSplitterHandle) at: {id(self)}" 

104 

105 __str__ = __repr__ 

106 #@+node:ekr.20110605121601.17964: *3* nsh.add_item 

107 def add_item(self, func, menu, name, tooltip=None): 

108 """helper for splitter_menu menu building""" 

109 act = QAction(name, self) 

110 act.setObjectName(name.lower().replace(' ', '-')) 

111 act.triggered.connect(lambda checked: func()) 

112 if tooltip: 

113 act.setToolTip(tooltip) 

114 menu.addAction(act) 

115 #@+node:tbrown.20131130134908.27340: *3* nsh.show_tip 

116 def show_tip(self, action): 

117 """show_tip - show a tooltip, calculate the box in which 

118 the pointer must stay for the tip to remain visible 

119 

120 :Parameters: 

121 - `self`: this handle 

122 - `action`: action triggering event to display 

123 """ 

124 if action.toolTip() == action.text(): 

125 tip = "" 

126 else: 

127 tip = action.toolTip() 

128 pos = QtGui.QCursor.pos() 

129 x = pos.x() 

130 y = pos.y() 

131 rect = QtCore.QRect(x - 5, y - 5, x + 5, y + 5) 

132 if hasattr(action, 'parentWidget'): # 2021/07/17. 

133 parent = action.parentWidget() 

134 else: 

135 return 

136 if not parent: 

137 g.trace('===== no parent =====') 

138 return 

139 QtWidgets.QToolTip.showText(pos, tip, parent, rect) 

140 #@+node:ekr.20110605121601.17965: *3* nsh.splitter_menu 

141 def splitter_menu(self, pos): 

142 """build the context menu for NestedSplitter""" 

143 splitter = self.splitter() 

144 if not splitter.enabled: 

145 g.trace('splitter not enabled') 

146 return 

147 index = splitter.indexOf(self) 

148 # get three pairs 

149 widget, neighbour, count = splitter.handle_context(index) 

150 lr = 'Left', 'Right' 

151 ab = 'Above', 'Below' 

152 split_dir = 'Vertically' 

153 if self.orientation() == Orientation.Vertical: 

154 lr, ab = ab, lr 

155 split_dir = 'Horizontally' 

156 # blue/orange - color-blind friendly 

157 color = '#729fcf', '#f57900' 

158 sheet = [] 

159 for i in 0, 1: 

160 sheet.append(widget[i].styleSheet()) 

161 widget[i].setStyleSheet(sheet[-1] + f"\nborder: 2px solid {color[i]};") 

162 menu = QtWidgets.QMenu() 

163 menu.hovered.connect(self.show_tip) 

164 

165 def pl(n): 

166 return 's' if n > 1 else '' 

167 

168 def di(s): 

169 return { 

170 'Above': 'above', 

171 'Below': 'below', 

172 'Left': 'left of', 

173 'Right': 'right of', 

174 }[s] 

175 

176 # Insert. 

177 

178 def insert_callback(index=index): 

179 splitter.insert(index) 

180 

181 self.add_item(insert_callback, menu, 'Insert', 

182 "Insert an empty pane here") 

183 # Remove, +0/-1 reversed, we need to test the one that remains 

184 # First see if a parent has more than two splits 

185 # (we could be a sole surviving child). 

186 max_parent_splits = 0 

187 up = splitter.parent() 

188 while isinstance(up, NestedSplitter): 

189 max_parent_splits = max(max_parent_splits, up.count()) 

190 up = up.parent() 

191 if max_parent_splits >= 2: 

192 break # two is enough 

193 for i in 0, 1: 

194 # keep = splitter.widget(index) 

195 # cull = splitter.widget(index - 1) 

196 if (max_parent_splits >= 2 or # more splits upstream 

197 splitter.count() > 2 or # 3+ splits here, or 2+ downstream 

198 neighbour[not i] and neighbour[not i].max_count() >= 2 

199 ): 

200 

201 def remove_callback(i=i, index=index): 

202 splitter.remove(index, i) 

203 

204 self.add_item(remove_callback, menu, 

205 f"Remove {count[i]:d} {lr[i]}", 

206 f"Remove the {count[i]} pane{pl(count[i])} {di(lr[i])} here") 

207 # Swap. 

208 

209 def swap_callback(index=index): 

210 splitter.swap(index) 

211 

212 self.add_item(swap_callback, menu, 

213 f"Swap {count[0]:d} {lr[0]} {count[1]:d} {lr[1]}", 

214 f"Swap the {count[0]:d} pane{pl(count[0])} {di(lr[0])} here " 

215 f"with the {count[1]:d} pane{pl(count[1])} {di(lr[1])} here" 

216 ) 

217 # Split: only if not already split. 

218 for i in 0, 1: 

219 if not neighbour[i] or neighbour[i].count() == 1: 

220 

221 def split_callback(i=i, index=index, splitter=splitter): 

222 splitter.split(index, i) 

223 

224 self.add_item( 

225 split_callback, menu, f"Split {lr[i]} {split_dir}") 

226 for i in 0, 1: 

227 

228 def mark_callback(i=i, index=index): 

229 splitter.mark(index, i) 

230 

231 self.add_item(mark_callback, menu, f"Mark {count[i]:d} {lr[i]}") 

232 # Swap With Marked. 

233 if splitter.root.marked: 

234 for i in 0, 1: 

235 if not splitter.invalid_swap(widget[i], splitter.root.marked[2]): 

236 

237 def swap_mark_callback(i=i, index=index, splitter=splitter): 

238 splitter.swap_with_marked(index, i) 

239 

240 self.add_item(swap_mark_callback, menu, 

241 f"Swap {count[i]:d} {lr[i]} With Marked") 

242 # Add. 

243 for i in 0, 1: 

244 if ( 

245 not isinstance(splitter.parent(), NestedSplitter) or 

246 splitter.parent().indexOf(splitter) == 

247 [0, splitter.parent().count() - 1][i] 

248 ): 

249 

250 def add_callback(i=i, splitter=splitter): 

251 splitter.add(i) 

252 

253 self.add_item(add_callback, menu, f"Add {ab[i]}") 

254 # Rotate All. 

255 self.add_item(splitter.rotate, menu, 'Toggle split direction') 

256 

257 def rotate_only_this(index=index): 

258 splitter.rotateOne(index) 

259 

260 self.add_item(rotate_only_this, menu, 'Toggle split/dir. just this') 

261 # equalize panes 

262 

263 def eq(splitter=splitter.top()): 

264 splitter.equalize_sizes(recurse=True) 

265 

266 self.add_item(eq, menu, 'Equalize all') 

267 # (un)zoom pane 

268 

269 def zoom(splitter=splitter.top()): 

270 splitter.zoom_toggle() 

271 

272 self.add_item( 

273 zoom, 

274 menu, 

275 ('Un' if splitter.root.zoomed else '') + 'Zoom pane' 

276 ) 

277 # open window 

278 if splitter.top().parent().__class__ != NestedSplitterTopLevel: 

279 # don't open windows from windows, only from main splitter 

280 # so owner is not a window which might close. Could instead 

281 # set owner to main splitter explicitly. Not sure how right now. 

282 submenu = menu.addMenu('Open window') 

283 if 1: 

284 # pylint: disable=unnecessary-lambda 

285 self.add_item(lambda: splitter.open_window(), submenu, "Empty") 

286 # adapted from choice_menu() 

287 if (splitter.root.marked and 

288 splitter.top().max_count() > 1 

289 ): 

290 self.add_item( 

291 lambda: splitter.open_window(action="_move_marked_there"), 

292 submenu, "Move marked there") 

293 for provider in splitter.root.providers: 

294 if hasattr(provider, 'ns_provides'): 

295 for title, id_ in provider.ns_provides(): 

296 

297 def cb(id_=id_): 

298 splitter.open_window(action=id_) 

299 

300 self.add_item(cb, submenu, title) 

301 submenu = menu.addMenu('Debug') 

302 act = QAction("Print splitter layout", self) 

303 

304 def print_layout_c(checked, splitter=splitter): 

305 layout = splitter.top().get_layout() 

306 g.printObj(layout) 

307 

308 act.triggered.connect(print_layout_c) 

309 submenu.addAction(act) 

310 

311 def load_items(menu, items): 

312 for i in items: 

313 if isinstance(i, dict): 

314 for k in i: 

315 load_items(menu.addMenu(k), i[k]) 

316 else: 

317 title, id_ = i 

318 

319 def cb(checked, id_=id_): 

320 splitter.context_cb(id_, index) 

321 

322 act = QAction(title, self) 

323 act.triggered.connect(cb) 

324 menu.addAction(act) 

325 

326 for provider in splitter.root.providers: 

327 if hasattr(provider, 'ns_context'): 

328 load_items(menu, provider.ns_context()) 

329 

330 # point = pos.toPoint() if isQt6 else pos # Qt6 documentation is wrong. 

331 point = pos 

332 global_point = self.mapToGlobal(point) 

333 menu.exec_(global_point) 

334 

335 for i in 0, 1: 

336 widget[i].setStyleSheet(sheet[i]) 

337 #@+node:tbnorth.20160510091151.1: *3* nsh.mouseEvents 

338 def mousePressEvent(self, event): 

339 """mouse event - mouse pressed on splitter handle, 

340 pass info. up to splitter 

341 

342 :param QMouseEvent event: mouse event 

343 """ 

344 self.splitter()._splitter_clicked(self, event, release=False, double=False) 

345 

346 def mouseReleaseEvent(self, event): 

347 """mouse event - mouse pressed on splitter handle, 

348 pass info. up to splitter 

349 

350 :param QMouseEvent event: mouse event 

351 """ 

352 self.splitter()._splitter_clicked(self, event, release=True, double=False) 

353 

354 def mouseDoubleClickEvent(self, event): 

355 """mouse event - mouse pressed on splitter handle, 

356 pass info. up to splitter 

357 

358 :param QMouseEvent event: mouse event 

359 """ 

360 self.splitter()._splitter_clicked(self, event, release=True, double=True) 

361 #@-others 

362#@+node:ekr.20110605121601.17966: ** class NestedSplitter (QSplitter) 

363class NestedSplitter(QtWidgets.QSplitter): # type:ignore 

364 # Allow special behavior to be turned of at import stage. 

365 # useful if other code must run to set up callbacks, that other code can re-enable. 

366 enabled = True 

367 other_orientation = { 

368 Orientation.Vertical: Orientation.Horizontal, 

369 Orientation.Horizontal: Orientation.Vertical, 

370 } 

371 # a regular signal, but you can't use its .connect() directly, 

372 # use splitterClicked_connect() 

373 _splitterClickedSignal = QtCore.pyqtSignal( 

374 QtWidgets.QSplitter, 

375 QtWidgets.QSplitterHandle, 

376 QtGui.QMouseEvent, 

377 bool, 

378 bool 

379 ) 

380 #@+others 

381 #@+node:ekr.20110605121601.17967: *3* ns.__init__ 

382 def __init__(self, parent=None, orientation=None, root=None): 

383 """Ctor for NestedSplitter class.""" 

384 if orientation is None: 

385 orientation = Orientation.Horizontal 

386 super().__init__(orientation, parent) 

387 # This creates a NestedSplitterHandle. 

388 if root is None: 

389 root = self.top(local=True) 

390 if root == self: 

391 root.marked = None # Tuple: self,index,side-1,widget 

392 root.providers = [] 

393 root.holders = {} 

394 root.windows = [] 

395 root._main = self.parent() # holder of the main splitter 

396 # list of top level NestedSplitter windows opened from 'Open Window' 

397 # splitter handle context menu 

398 root.zoomed = False 

399 # 

400 # NestedSplitter is a kind of meta-widget, in that it manages 

401 # panes across multiple actual splitters, even windows. 

402 # So to create a signal for a click on splitter handle, we 

403 # need to propagate the .connect() call across all the 

404 # actual splitters, current and future 

405 root._splitterClickedArgs = [] # save for future added splitters 

406 for args in root._splitterClickedArgs: 

407 # apply any .connect() calls that occured earlier 

408 self._splitterClickedSignal.connect(*args) 

409 

410 self.root = root 

411 #@+node:ekr.20110605121601.17968: *3* ns.__repr__ 

412 def __repr__(self): 

413 # parent = self.parent() 

414 # name = parent and parent.objectName() or '<no parent>' 

415 name = self.objectName() or '<no name>' 

416 return f"(NestedSplitter) {name} at {id(self)}" 

417 

418 __str__ = __repr__ 

419 #@+node:ekr.20110605121601.17969: *3* ns.overrides of QSplitter methods 

420 #@+node:ekr.20110605121601.17970: *4* ns.createHandle 

421 def createHandle(self, *args, **kargs): 

422 return NestedSplitterHandle(self) 

423 #@+node:tbrown.20110729101912.30820: *4* ns.childEvent 

424 def childEvent(self, event): 

425 """If a panel client is closed not by us, there may be zero 

426 splitter handles left, so add an Action button 

427 

428 unless it was the last panel in a separate window, in which 

429 case close the window""" 

430 QtWidgets.QSplitter.childEvent(self, event) 

431 if not event.removed(): 

432 return 

433 local_top = self.top(local=True) 

434 # if only non-placeholder pane in a top level window deletes 

435 # itself, delete the window 

436 if (isinstance(local_top.parent(), NestedSplitterTopLevel) and 

437 local_top.count() == 1 and # one left, could be placeholder 

438 isinstance(local_top.widget(0), NestedSplitterChoice) # is placeholder 

439 ): 

440 local_top.parent().deleteLater() 

441 return 

442 # don't leave a one widget splitter 

443 if self.count() == 1 and local_top != self: 

444 self.parent().addWidget(self.widget(0)) 

445 self.deleteLater() 

446 parent = self.parentWidget() 

447 if parent: 

448 layout = parent.layout() # QLayout, not a NestedSplitter 

449 else: 

450 layout = None 

451 if self.count() == 1 and self.top(local=True) == self: 

452 if self.max_count() <= 1 or not layout: 

453 # maintain at least two items 

454 self.insert(0) 

455 # shrink the added button 

456 self.setSizes([0] + self.sizes()[1:]) 

457 else: 

458 # replace ourselves in out parent's layout with our child 

459 pos = layout.indexOf(self) 

460 child = self.widget(0) 

461 layout.insertWidget(pos, child) 

462 pos = layout.indexOf(self) 

463 layout.takeAt(pos) 

464 self.setParent(None) 

465 #@+node:ekr.20110605121601.17971: *3* ns.add 

466 def add(self, side, w=None): 

467 """wrap a horizontal splitter in a vertical splitter, or 

468 visa versa""" 

469 orientation = self.other_orientation[self.orientation()] 

470 layout = self.parent().layout() 

471 if isinstance(self.parent(), NestedSplitter): 

472 # don't add new splitter if not needed, i.e. we're the 

473 # only child of a previously more populated splitter 

474 if w is None: 

475 w = NestedSplitterChoice(self.parent()) 

476 self.parent().insertWidget(self.parent().indexOf(self) + side, w) 

477 # in this case, where the parent is a one child, no handle splitter, 

478 # the (prior to this invisible) orientation may be wrong 

479 # can't reproduce this now, but this guard is harmless 

480 self.parent().setOrientation(orientation) 

481 elif layout: 

482 new = NestedSplitter(None, orientation=orientation, root=self.root) 

483 # parent set by layout.insertWidget() below 

484 old = self 

485 pos = layout.indexOf(old) 

486 new.addWidget(old) 

487 if w is None: 

488 w = NestedSplitterChoice(new) 

489 new.insertWidget(side, w) 

490 layout.insertWidget(pos, new) 

491 else: 

492 # fail - parent is not NestedSplitter and has no layout 

493 pass 

494 #@+node:tbrown.20110621120042.22675: *3* ns.add_adjacent 

495 def add_adjacent(self, what, widget_id, side='right-of'): 

496 """add a widget relative to another already present widget""" 

497 horizontal, vertical = Orientation.Horizontal, Orientation.Vertical 

498 layout = self.top().get_layout() 

499 

500 def hunter(layout, id_): 

501 """Recursively look for this widget""" 

502 for n, i in enumerate(layout['content']): 

503 if (i == id_ or 

504 (isinstance(i, QtWidgets.QWidget) and 

505 (i.objectName() == id_ or i.__class__.__name__ == id_) 

506 ) 

507 ): 

508 return layout, n 

509 if not isinstance(i, QtWidgets.QWidget): 

510 # then it must be a layout dict 

511 x = hunter(i, id_) 

512 if x: 

513 return x 

514 return None 

515 

516 # find the layout containing widget_id 

517 

518 l = hunter(layout, widget_id) 

519 if l is None: 

520 return False 

521 # pylint: disable=unpacking-non-sequence 

522 layout, pos = l 

523 orient = layout['orientation'] 

524 if (orient == horizontal and side in ('right-of', 'left-of') or 

525 orient == vertical and side in ('above', 'below') 

526 ): 

527 # easy case, just insert the new thing, what, 

528 # either side of old, in existng splitter 

529 if side in ('right-of', 'below'): 

530 pos += 1 

531 layout['splitter'].insert(pos, what) 

532 else: 

533 # hard case, need to replace old with a new splitter 

534 if side in ('right-of', 'left-of'): 

535 ns = NestedSplitter(orientation=horizontal, root=self.root) 

536 else: 

537 ns = NestedSplitter(orientation=vertical, root=self.root) 

538 old = layout['content'][pos] 

539 if not isinstance(old, QtWidgets.QWidget): # see get_layout() 

540 old = layout['splitter'] 

541 # put new thing, what, in new splitter, no impact on anything else 

542 ns.insert(0, what) 

543 # then swap the new splitter with the old content 

544 layout['splitter'].replace_widget_at_index(pos, ns) 

545 # now put the old content in the new splitter, 

546 # doing this sooner would mess up the index (pos) 

547 ns.insert(0 if side in ('right-of', 'below') else 1, old) 

548 return True 

549 #@+node:ekr.20110605121601.17972: *3* ns.choice_menu 

550 def choice_menu(self, button, pos): 

551 """build menu on Action button""" 

552 menu = QtWidgets.QMenu(self.top()) # #1995 

553 index = self.indexOf(button) 

554 if (self.root.marked and 

555 not self.invalid_swap(button, self.root.marked[3]) and 

556 self.top().max_count() > 2 

557 ): 

558 act = QAction("Move marked here", self) 

559 act.triggered.connect( 

560 lambda checked: self.replace_widget(button, self.root.marked[3])) 

561 menu.addAction(act) 

562 for provider in self.root.providers: 

563 if hasattr(provider, 'ns_provides'): 

564 for title, id_ in provider.ns_provides(): 

565 

566 def cb(checked, id_=id_): 

567 self.place_provided(id_, index) 

568 

569 act = QAction(title, self) 

570 act.triggered.connect(cb) 

571 menu.addAction(act) 

572 if menu.isEmpty(): 

573 act = QAction("Nothing marked, and no options", self) 

574 menu.addAction(act) 

575 

576 point = button.pos() 

577 global_point = button.mapToGlobal(point) 

578 menu.exec_(global_point) 

579 #@+node:tbrown.20120418121002.25712: *3* ns.closing 

580 def closing(self, window): 

581 """forget a top-level additional layout which was closed""" 

582 self.windows.remove(window) 

583 #@+node:tbrown.20110628083641.11723: *3* ns.place_provided 

584 def place_provided(self, id_, index): 

585 """replace Action button with provided widget""" 

586 provided = self.get_provided(id_) 

587 if provided is None: 

588 return 

589 self.replace_widget_at_index(index, provided) 

590 self.top().prune_empty() 

591 # user can set up one widget pane plus one Action pane, then move the 

592 # widget into the action pane, level 1 pane and no handles 

593 if self.top().max_count() < 2: 

594 print('Adding Action widget to maintain at least one handle') 

595 self.top().insert(0, NestedSplitterChoice(self.top())) 

596 #@+node:tbrown.20110628083641.11729: *3* ns.context_cb 

597 def context_cb(self, id_, index): 

598 """find a provider to provide a context menu service, and do it""" 

599 for provider in self.root.providers: 

600 if hasattr(provider, 'ns_do_context'): 

601 provided = provider.ns_do_context(id_, self, index) 

602 if provided: 

603 break 

604 #@+node:ekr.20110605121601.17973: *3* ns.contains 

605 def contains(self, widget): 

606 """check if widget is a descendent of self""" 

607 for i in range(self.count()): 

608 if widget == self.widget(i): 

609 return True 

610 if isinstance(self.widget(i), NestedSplitter): 

611 if self.widget(i).contains(widget): 

612 return True 

613 return False 

614 #@+node:tbrown.20120418121002.25439: *3* ns.find_child 

615 def find_child(self, child_class, child_name=None): 

616 """Like QObject.findChild, except search self.top() 

617 *AND* each window in self.root.windows 

618 """ 

619 child = self.top().findChild(child_class, child_name) 

620 if not child: 

621 for window in self.root.windows: 

622 child = window.findChild(child_class, child_name) 

623 if child: 

624 break 

625 return child 

626 #@+node:ekr.20110605121601.17974: *3* ns.handle_context 

627 def handle_context(self, index): 

628 """for a handle, return (widget, neighbour, count) 

629 

630 This is the handle's context in the NestedSplitter, not the 

631 handle's context menu. 

632 

633 widget 

634 the pair of widgets either side of the handle 

635 neighbour 

636 the pair of NestedSplitters either side of the handle, or None 

637 if the neighbours are not NestedSplitters, i.e. 

638 [ns0, ns1] or [None, ns1] or [ns0, None] or [None, None] 

639 count 

640 the pair of nested counts of widgets / spliters around the handle 

641 """ 

642 widget = [self.widget(index - 1), self.widget(index)] 

643 neighbour = [(i if isinstance(i, NestedSplitter) else None) for i in widget] 

644 count = [] 

645 for i in 0, 1: 

646 if neighbour[i]: 

647 l = [ii.count() for ii in neighbour[i].self_and_descendants()] 

648 n = sum(l) - len(l) + 1 # count leaves, not splitters 

649 count.append(n) 

650 else: 

651 count.append(1) 

652 return widget, neighbour, count 

653 #@+node:tbrown.20110621120042.22920: *3* ns.equalize_sizes 

654 def equalize_sizes(self, recurse=False): 

655 """make all pane sizes equal""" 

656 if not self.count(): 

657 return 

658 for i in range(self.count()): 

659 self.widget(i).setHidden(False) 

660 size = sum(self.sizes()) / self.count() 

661 self.setSizes([int(size)] * self.count()) # #2281 

662 if recurse: 

663 for i in range(self.count()): 

664 if isinstance(self.widget(i), NestedSplitter): 

665 self.widget(i).equalize_sizes(recurse=True) 

666 #@+node:ekr.20110605121601.17975: *3* ns.insert (NestedSplitter) 

667 def insert(self, index, w=None): 

668 """insert a pane with a widget or, when w==None, Action button""" 

669 if w is None: # do NOT use 'not w', fails in PyQt 4.8 

670 w = NestedSplitterChoice(self) 

671 # A QWidget, with self as parent. 

672 # This creates the menu. 

673 self.insertWidget(index, w) 

674 self.equalize_sizes() 

675 return w 

676 #@+node:ekr.20110605121601.17976: *3* ns.invalid_swap 

677 def invalid_swap(self, w0, w1): 

678 """check for swap violating hierachy""" 

679 return ( 

680 w0 == w1 or 

681 isinstance(w0, NestedSplitter) and w0.contains(w1) or 

682 isinstance(w1, NestedSplitter) and w1.contains(w0)) 

683 #@+node:ekr.20110605121601.17977: *3* ns.mark 

684 def mark(self, index, side): 

685 """mark a widget for later swapping""" 

686 self.root.marked = (self, index, side - 1, self.widget(index + side - 1)) 

687 #@+node:ekr.20110605121601.17978: *3* ns.max_count 

688 def max_count(self): 

689 """find max widgets in this and child splitters""" 

690 counts = [] 

691 count = 0 

692 for i in range(self.count()): 

693 count += 1 

694 if isinstance(self.widget(i), NestedSplitter): 

695 counts.append(self.widget(i).max_count()) 

696 counts.append(count) 

697 return max(counts) 

698 #@+node:tbrown.20120418121002.25438: *3* ns.open_window 

699 def open_window(self, action=None): 

700 """open a top-level window, a TopLevelFreeLayout instance, to hold a 

701 free-layout in addition to the one in the outline's main window""" 

702 ns = NestedSplitter(root=self.root) 

703 window = NestedSplitterTopLevel( 

704 owner=self.root, window_title=ns.get_title(action)) 

705 hbox = QtWidgets.QHBoxLayout() 

706 window.setLayout(hbox) 

707 hbox.setContentsMargins(0, 0, 0, 0) 

708 window.resize(400, 300) 

709 hbox.addWidget(ns) 

710 # NestedSplitters must have two widgets so the handle carrying 

711 # the all important context menu exists 

712 ns.addWidget(NestedSplitterChoice(ns)) 

713 button = NestedSplitterChoice(ns) 

714 ns.addWidget(button) 

715 if action == '_move_marked_there': 

716 ns.replace_widget(button, ns.root.marked[3]) 

717 elif action is not None: 

718 ns.place_provided(action, 1) 

719 ns.setSizes([0, 1]) # but hide one initially 

720 self.root.windows.append(window) 

721 # copy the main main window's stylesheet to new window 

722 w = self.root # this is a Qt Widget, class NestedSplitter 

723 sheets = [] 

724 while w: 

725 s = w.styleSheet() 

726 if s: 

727 sheets.append(str(s)) 

728 w = w.parent() 

729 sheets.reverse() 

730 ns.setStyleSheet('\n'.join(sheets)) 

731 window.show() 

732 #@+node:tbrown.20110627201141.11744: *3* ns.register_provider 

733 def register_provider(self, provider): 

734 """Register something which provides some of the ns_* methods. 

735 

736 NestedSplitter tests for the presence of the following methods on 

737 the registered things, and calls them when needed if they exist. 

738 

739 ns_provides() 

740 should return a list of ('Item name', '__item_id') strings, 

741 'Item name' is displayed in the Action button menu, and 

742 '__item_id' is used in ns_provide(). 

743 ns_provide(id_) 

744 should return the widget to replace the Action button based on 

745 id_, or None if the called thing is not the provider for this id_ 

746 ns_context() 

747 should return a list of ('Item name', '__item_id') strings, 

748 'Item name' is displayed in the splitter handle context-menu, and 

749 '__item_id' is used in ns_do_context(). May also return a dict, 

750 in which case each key is used as a sub-menu title, whose menu 

751 items are the corresponding dict value, a list of tuples as above. 

752 dicts and tuples may be interspersed in lists. 

753 ns_do_context() 

754 should do something based on id_ and return True, or return False 

755 if the called thing is not the provider for this id_ 

756 ns_provider_id() 

757 return a string identifying the provider (at class or instance level), 

758 any providers with the same id will be removed before a new one is 

759 added 

760 """ 

761 # drop any providers with the same id 

762 if hasattr(provider, 'ns_provider_id'): 

763 id_ = provider.ns_provider_id() 

764 cull = [] 

765 for i in self.root.providers: 

766 if (hasattr(i, 'ns_provider_id') and 

767 i.ns_provider_id() == id_ 

768 ): 

769 cull.append(i) 

770 for i in cull: 

771 self.root.providers.remove(i) 

772 self.root.providers.append(provider) 

773 #@+node:ekr.20110605121601.17980: *3* ns.remove & helper 

774 def remove(self, index, side): 

775 widget = self.widget(index + side - 1) 

776 # clear marked if it's going to be deleted 

777 if (self.root.marked and (self.root.marked[3] == widget or 

778 isinstance(self.root.marked[3], NestedSplitter) and 

779 self.root.marked[3].contains(widget)) 

780 ): 

781 self.root.marked = None 

782 # send close signal to all children 

783 if isinstance(widget, NestedSplitter): 

784 count = widget.count() 

785 all_ok = True 

786 for splitter in widget.self_and_descendants(): 

787 for i in range(splitter.count() - 1, -1, -1): 

788 all_ok &= (self.close_or_keep(splitter.widget(i)) is not False) 

789 if all_ok or count <= 0: 

790 widget.setParent(None) 

791 else: 

792 self.close_or_keep(widget) 

793 #@+node:ekr.20110605121601.17981: *4* ns.close_or_keep 

794 def close_or_keep(self, widget, other_top=None): 

795 """when called from a closing secondary window, self.top() would 

796 be the top splitter in the closing window, and we need the client 

797 to specify the top of the primary window for us, in other_top""" 

798 if widget is None: 

799 return True 

800 for k in self.root.holders: 

801 if hasattr(widget, k): 

802 holder = self.root.holders[k] 

803 if holder == 'TOP': 

804 holder = other_top or self.top() 

805 if hasattr(holder, "addTab"): 

806 holder.addTab(widget, getattr(widget, k)) 

807 else: 

808 holder.addWidget(widget) 

809 return True 

810 if widget.close(): 

811 widget.setParent(None) 

812 return True 

813 return False 

814 #@+node:ekr.20110605121601.17982: *3* ns.replace_widget & replace_widget_at_index 

815 def replace_widget(self, old, new): 

816 "Swap the provided widgets in place" "" 

817 sizes = self.sizes() 

818 new.setParent(None) 

819 self.insertWidget(self.indexOf(old), new) 

820 self.close_or_keep(old) 

821 new.show() 

822 self.setSizes(sizes) 

823 

824 def replace_widget_at_index(self, index, new): 

825 """Replace the widget at index with w.""" 

826 sizes = self.sizes() 

827 old = self.widget(index) 

828 if old != new: 

829 new.setParent(None) 

830 self.insertWidget(index, new) 

831 self.close_or_keep(old) 

832 new.show() 

833 self.setSizes(sizes) 

834 #@+node:ekr.20110605121601.17983: *3* ns.rotate 

835 def rotate(self, descending=False): 

836 """Change orientation - current rotates entire hierachy, doing less 

837 is visually confusing because you end up with nested splitters with 

838 the same orientation - avoiding that would mean doing rotation by 

839 inserting out widgets into our ancestors, etc. 

840 """ 

841 for i in self.top().self_and_descendants(): 

842 if i.orientation() == Orientation.Vertical: 

843 i.setOrientation(Orientation.Horizontal) 

844 else: 

845 i.setOrientation(Orientation.Vertical) 

846 #@+node:vitalije.20170713085342.1: *3* ns.rotateOne 

847 def rotateOne(self, index): 

848 """Change orientation - only of splithandle at index.""" 

849 psp = self.parent() 

850 if self.count() == 2 and isinstance(psp, NestedSplitter): 

851 i = psp.indexOf(self) 

852 sizes = psp.sizes() 

853 [a, b] = self.sizes() 

854 s = sizes[i] 

855 s1 = a * s / (a + b) 

856 s2 = b * s / (a + b) 

857 sizes[i : i + 1] = [s1, s2] 

858 prev = self.widget(0) 

859 next = self.widget(1) 

860 psp.insertWidget(i, prev) 

861 psp.insertWidget(i + 1, next) 

862 psp.setSizes(sizes) 

863 assert psp.widget(i + 2) is self 

864 psp.remove(i + 3, 0) 

865 psp.setSizes(sizes) 

866 elif self is self.root and self.count() == 2: 

867 self.rotate() 

868 elif self.count() == 2: 

869 self.setOrientation(self.other_orientation[self.orientation()]) 

870 else: 

871 orientation = self.other_orientation[self.orientation()] 

872 prev = self.widget(index - 1) 

873 next = self.widget(index) 

874 if None in (prev, next): 

875 return 

876 sizes = self.sizes() 

877 s1, s2 = sizes[index - 1 : index + 1] 

878 sizes[index - 1 : index + 1] = [s1 + s2] 

879 newsp = NestedSplitter(self, orientation=orientation, root=self.root) 

880 newsp.addWidget(prev) 

881 newsp.addWidget(next) 

882 self.insertWidget(index - 1, newsp) 

883 prev.setHidden(False) 

884 next.setHidden(False) 

885 newsp.setSizes([s1, s2]) 

886 self.setSizes(sizes) 

887 #@+node:ekr.20110605121601.17984: *3* ns.self_and_descendants 

888 def self_and_descendants(self): 

889 """Yield self and all **NestedSplitter** descendants""" 

890 for i in range(self.count()): 

891 if isinstance(self.widget(i), NestedSplitter): 

892 for w in self.widget(i).self_and_descendants(): 

893 yield w 

894 yield self 

895 #@+node:ekr.20110605121601.17985: *3* ns.split (NestedSplitter) 

896 def split(self, index, side, w=None, name=None): 

897 """replace the adjacent widget with a NestedSplitter containing 

898 the widget and an Action button""" 

899 sizes = self.sizes() 

900 old = self.widget(index + side - 1) 

901 #X old_name = old and old.objectName() or '<no name>' 

902 #X splitter_name = self.objectName() or '<no name>' 

903 if w is None: 

904 w = NestedSplitterChoice(self) 

905 if isinstance(old, NestedSplitter): 

906 old.addWidget(w) 

907 old.equalize_sizes() 

908 #X index = old.indexOf(w) 

909 #X return old,index # For viewrendered plugin. 

910 else: 

911 orientation = self.other_orientation[self.orientation()] 

912 new = NestedSplitter(self, orientation=orientation, root=self.root) 

913 #X if name: new.setObjectName(name) 

914 self.insertWidget(index + side - 1, new) 

915 new.addWidget(old) 

916 new.addWidget(w) 

917 new.equalize_sizes() 

918 #X index = new.indexOf(w) 

919 #X return new,index # For viewrendered plugin. 

920 self.setSizes(sizes) 

921 #@+node:ekr.20110605121601.17986: *3* ns.swap 

922 def swap(self, index): 

923 """swap widgets either side of a handle""" 

924 self.insertWidget(index - 1, self.widget(index)) 

925 #@+node:ekr.20110605121601.17987: *3* ns.swap_with_marked 

926 def swap_with_marked(self, index, side): 

927 # pylint: disable=unpacking-non-sequence 

928 osplitter, oidx, oside, ow = self.root.marked 

929 idx = index + side - 1 

930 # convert from handle index to widget index 

931 # 1 already subtracted from oside in mark() 

932 w = self.widget(idx) 

933 if self.invalid_swap(w, ow): 

934 return 

935 self.insertWidget(idx, ow) 

936 osplitter.insertWidget(oidx, w) 

937 self.root.marked = self, self.indexOf(ow), 0, ow 

938 self.equalize_sizes() 

939 osplitter.equalize_sizes() 

940 #@+node:ekr.20110605121601.17988: *3* ns.top 

941 def top(self, local=False): 

942 """find top (outer) widget, which is not necessarily root""" 

943 if local: 

944 top = self 

945 while isinstance(top.parent(), NestedSplitter): 

946 top = top.parent() 

947 else: 

948 top = self.root._main.findChild(NestedSplitter) 

949 return top 

950 #@+node:ekr.20110605121601.17989: *3* ns.get_layout 

951 def get_layout(self): 

952 """ 

953 Return a dict describing the layout. 

954 

955 Usually you would call ns.top().get_layout() 

956 """ 

957 ans = { 

958 'content': [], 

959 'orientation': self.orientation(), 

960 'sizes': self.sizes(), 

961 'splitter': self, 

962 } 

963 for i in range(self.count()): 

964 w = self.widget(i) 

965 if isinstance(w, NestedSplitter): 

966 ans['content'].append(w.get_layout()) 

967 else: 

968 ans['content'].append(w) 

969 return ans 

970 #@+node:tbrown.20110628083641.11733: *3* ns.get_saveable_layout 

971 def get_saveable_layout(self): 

972 """ 

973 Return the dict for saveable layouts. 

974 

975 The content entry for non-NestedSplitter items is the provider ID 

976 string for the item, or 'UNKNOWN', and the splitter entry is omitted. 

977 """ 

978 ans = { 

979 'content': [], 

980 'orientation': 1 if self.orientation() == Orientation.Horizontal else 2, 

981 'sizes': self.sizes(), 

982 } 

983 for i in range(self.count()): 

984 w = self.widget(i) 

985 if isinstance(w, NestedSplitter): 

986 ans['content'].append(w.get_saveable_layout()) 

987 else: 

988 ans['content'].append(getattr(w, '_ns_id', 'UNKNOWN')) 

989 return ans 

990 #@+node:ekr.20160416083415.1: *3* ns.get_splitter_by_name 

991 def get_splitter_by_name(self, name): 

992 """Return the splitter with the given objectName().""" 

993 if self.objectName() == name: 

994 return self 

995 for i in range(self.count()): 

996 w = self.widget(i) 

997 # Recursively test w and its descendants. 

998 if isinstance(w, NestedSplitter): 

999 w2 = w.get_splitter_by_name(name) 

1000 if w2: 

1001 return w2 

1002 return None 

1003 #@+node:tbrown.20110628083641.21154: *3* ns.load_layout 

1004 def load_layout(self, c, layout, level=0): 

1005 

1006 trace = 'layouts' in g.app.debug 

1007 if trace: 

1008 g.trace('level', level) 

1009 tag = f"layout: {c.shortFileName()}" 

1010 g.printObj(layout, tag=tag) 

1011 if isQt6: 

1012 if layout['orientation'] == 1: 

1013 self.setOrientation(Orientation.Horizontal) 

1014 else: 

1015 self.setOrientation(Orientation.Vertical) 

1016 else: 

1017 self.setOrientation(layout['orientation']) 

1018 found = 0 

1019 if level == 0: 

1020 for i in self.self_and_descendants(): 

1021 for n in range(i.count()): 

1022 i.widget(n)._in_layout = False 

1023 for content_layout in layout['content']: 

1024 if isinstance(content_layout, dict): 

1025 new = NestedSplitter(root=self.root, parent=self) 

1026 new._in_layout = True 

1027 self.insert(found, new) 

1028 found += 1 

1029 new.load_layout(c, content_layout, level + 1) 

1030 else: 

1031 provided = self.get_provided(content_layout) 

1032 if provided: 

1033 self.insert(found, provided) 

1034 provided._in_layout = True 

1035 found += 1 

1036 else: 

1037 print(f"No provider for {content_layout}") 

1038 self.prune_empty() 

1039 if self.count() != len(layout['sizes']): 

1040 not_in_layout = set() 

1041 for i in self.self_and_descendants(): 

1042 for n in range(i.count()): 

1043 c = i.widget(n) 

1044 if not (hasattr(c, '_in_layout') and c._in_layout): 

1045 not_in_layout.add(c) 

1046 for i in not_in_layout: 

1047 self.close_or_keep(i) 

1048 self.prune_empty() 

1049 if self.count() == len(layout['sizes']): 

1050 self.setSizes(layout['sizes']) 

1051 else: 

1052 print( 

1053 f"Wrong pane count at level {level:d}, " 

1054 f"count:{self.count():d}, " 

1055 f"sizes:{len(layout['sizes']):d}") 

1056 self.equalize_sizes() 

1057 #@+node:tbrown.20110628083641.21156: *3* ns.prune_empty 

1058 def prune_empty(self): 

1059 for i in range(self.count() - 1, -1, -1): 

1060 w = self.widget(i) 

1061 if isinstance(w, NestedSplitter): 

1062 if w.max_count() == 0: 

1063 w.setParent(None) 

1064 # w.deleteLater() 

1065 #@+node:tbrown.20110628083641.21155: *3* ns.get_provided 

1066 def find_by_id(self, id_): 

1067 for s in self.self_and_descendants(): 

1068 for i in range(s.count()): 

1069 if getattr(s.widget(i), '_ns_id', None) == id_: 

1070 return s.widget(i) 

1071 return None 

1072 

1073 def get_provided(self, id_): 

1074 """IMPORTANT: nested_splitter should set the _ns_id attribute *only* 

1075 if the provider doesn't do it itself. That allows the provider to 

1076 encode state information in the id. 

1077 

1078 Also IMPORTANT: nested_splitter should call all providers for each id_, not 

1079 just providers which previously advertised the id_. E.g. a provider which 

1080 advertises leo_bookmarks_show may also be the correct provider for 

1081 leo_bookmarks_show:4532.234 - let the providers decide in ns_provide(). 

1082 """ 

1083 for provider in self.root.providers: 

1084 if hasattr(provider, 'ns_provide'): 

1085 provided = provider.ns_provide(id_) 

1086 if provided: 

1087 if provided == 'USE_EXISTING': 

1088 # provider claiming responsibility, and saying 

1089 # we already have it, i.e. it's a singleton 

1090 w = self.top().find_by_id(id_) 

1091 if w: 

1092 if not hasattr(w, '_ns_id'): 

1093 # IMPORTANT: see docstring 

1094 w._ns_id = id_ 

1095 return w 

1096 else: 

1097 if not hasattr(provided, '_ns_id'): 

1098 # IMPORTANT: see docstring 

1099 provided._ns_id = id_ 

1100 return provided 

1101 return None 

1102 

1103 #@+node:ekr.20200917063155.1: *3* ns.get_title 

1104 def get_title(self, id_): 

1105 """Like get_provided(), but just gets a title for a window 

1106 """ 

1107 if id_ is None: 

1108 return "Leo widget window" 

1109 for provider in self.root.providers: 

1110 if hasattr(provider, 'ns_title'): 

1111 provided = provider.ns_title(id_) 

1112 if provided: 

1113 return provided 

1114 return "Leo unnamed window" 

1115 #@+node:tbrown.20140522153032.32656: *3* ns.zoom_toggle 

1116 def zoom_toggle(self, local=False): 

1117 """zoom_toggle - (Un)zoom current pane to be only expanded pane 

1118 

1119 :param bool local: just zoom pane within its own splitter 

1120 """ 

1121 if self.root.zoomed: 

1122 for ns in self.top().self_and_descendants(): 

1123 if hasattr(ns, '_unzoom'): 

1124 # this splitter could have been added since 

1125 ns.setSizes(ns._unzoom) 

1126 else: 

1127 focused = Qt.QApplication.focusWidget() 

1128 parents = [] 

1129 parent = focused 

1130 while parent: 

1131 parents.append(parent) 

1132 parent = parent.parent() 

1133 if not focused: 

1134 g.es("Not zoomed, and no focus") 

1135 for ns in (self if local else self.top()).self_and_descendants(): 

1136 # FIXME - shouldn't be doing this across windows 

1137 ns._unzoom = ns.sizes() 

1138 for i in range(ns.count()): 

1139 w = ns.widget(i) 

1140 if w in parents: 

1141 sizes = [0] * len(ns._unzoom) 

1142 sizes[i] = sum(ns._unzoom) 

1143 ns.setSizes(sizes) 

1144 break 

1145 self.root.zoomed = not self.root.zoomed 

1146 #@+node:tbnorth.20160510092439.1: *3* ns._splitter_clicked 

1147 def _splitter_clicked(self, handle, event, release, double): 

1148 """_splitter_clicked - coordinate propagation of signals 

1149 for clicks on handles. Turned out not to need any particular 

1150 coordination, handles could call self._splitterClickedSignal.emit 

1151 directly, but design wise this is a useful control point. 

1152 

1153 :param QSplitterHandle handle: handle that was clicked 

1154 :param QMouseEvent event: click event 

1155 :param bool release: was it a release event 

1156 :param bool double: was it a double click event 

1157 """ 

1158 self._splitterClickedSignal.emit(self, handle, event, release, double) 

1159 #@+node:tbnorth.20160510123445.1: *3* splitterClicked_connect 

1160 def splitterClicked_connect(self, *args): 

1161 """Apply .connect() args to all actual splitters, 

1162 and store for application to future splitters. 

1163 """ 

1164 self.root._splitterClickedArgs.append(args) 

1165 for splitter in self.top().self_and_descendants(): 

1166 splitter._splitterClickedSignal.connect(*args) 

1167 #@-others 

1168#@-others 

1169#@@language python 

1170#@@tabwidth -4 

1171#@@pagewidth 70 

1172#@-leo