Hide keyboard shortcuts

Hot-keys on this page

r m x p   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

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 enabled = True 

365 # allow special behavior to be turned of at import stage 

366 # useful if other code must run to set up callbacks, that 

367 # other code can re-enable 

368 other_orientation = { 

369 Orientation.Vertical: Orientation.Horizontal, 

370 Orientation.Horizontal: Orientation.Vertical, 

371 } 

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

373 # use splitterClicked_connect() 

374 _splitterClickedSignal = QtCore.pyqtSignal( 

375 QtWidgets.QSplitter, 

376 QtWidgets.QSplitterHandle, 

377 QtGui.QMouseEvent, 

378 bool, 

379 bool 

380 ) 

381 #@+others 

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

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

384 """Ctor for NestedSplitter class.""" 

385 if orientation is None: 

386 orientation = Orientation.Horizontal 

387 super().__init__(orientation, parent) 

388 # This creates a NestedSplitterHandle. 

389 if root is None: 

390 root = self.top(local=True) 

391 if root == self: 

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

393 root.providers = [] 

394 root.holders = {} 

395 root.windows = [] 

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

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

398 # splitter handle context menu 

399 root.zoomed = False 

400 # 

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

402 # panes across multiple actual splitters, even windows. 

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

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

405 # actual splitters, current and future 

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

407 for args in root._splitterClickedArgs: 

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

409 self._splitterClickedSignal.connect(*args) 

410 

411 self.root = root 

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

413 def __repr__(self): 

414 # parent = self.parent() 

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

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

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

418 

419 __str__ = __repr__ 

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

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

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

423 return NestedSplitterHandle(self) 

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

425 def childEvent(self, event): 

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

427 splitter handles left, so add an Action button 

428 

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

430 case close the window""" 

431 QtWidgets.QSplitter.childEvent(self, event) 

432 if not event.removed(): 

433 return 

434 local_top = self.top(local=True) 

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

436 # itself, delete the window 

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

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

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

440 ): 

441 local_top.parent().deleteLater() 

442 return 

443 # don't leave a one widget splitter 

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

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

446 self.deleteLater() 

447 parent = self.parentWidget() 

448 if parent: 

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

450 else: 

451 layout = None 

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

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

454 # maintain at least two items 

455 self.insert(0) 

456 # shrink the added button 

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

458 else: 

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

460 pos = layout.indexOf(self) 

461 child = self.widget(0) 

462 layout.insertWidget(pos, child) 

463 pos = layout.indexOf(self) 

464 layout.takeAt(pos) 

465 self.setParent(None) 

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

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

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

469 visa versa""" 

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

471 layout = self.parent().layout() 

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

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

474 # only child of a previously more populated splitter 

475 if w is None: 

476 w = NestedSplitterChoice(self.parent()) 

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

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

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

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

481 self.parent().setOrientation(orientation) 

482 elif layout: 

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

484 # parent set by layout.insertWidget() below 

485 old = self 

486 pos = layout.indexOf(old) 

487 new.addWidget(old) 

488 if w is None: 

489 w = NestedSplitterChoice(new) 

490 new.insertWidget(side, w) 

491 layout.insertWidget(pos, new) 

492 else: 

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

494 pass 

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

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

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

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

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

500 

501 def hunter(layout, id_): 

502 """Recursively look for this widget""" 

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

504 if (i == id_ or 

505 (isinstance(i, QtWidgets.QWidget) and 

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

507 ) 

508 ): 

509 return layout, n 

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

511 # then it must be a layout dict 

512 x = hunter(i, id_) 

513 if x: 

514 return x 

515 return None 

516 

517 # find the layout containing widget_id 

518 

519 l = hunter(layout, widget_id) 

520 if l is None: 

521 return False 

522 # pylint: disable=unpacking-non-sequence 

523 layout, pos = l 

524 orient = layout['orientation'] 

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

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

527 ): 

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

529 # either side of old, in existng splitter 

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

531 pos += 1 

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

533 else: 

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

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

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

537 else: 

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

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

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

541 old = layout['splitter'] 

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

543 ns.insert(0, what) 

544 # then swap the new splitter with the old content 

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

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

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

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

549 return True 

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

551 def choice_menu(self, button, pos): 

552 """build menu on Action button""" 

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

554 index = self.indexOf(button) 

555 if (self.root.marked and 

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

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

558 ): 

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

560 act.triggered.connect( 

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

562 menu.addAction(act) 

563 for provider in self.root.providers: 

564 if hasattr(provider, 'ns_provides'): 

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

566 

567 def cb(checked, id_=id_): 

568 self.place_provided(id_, index) 

569 

570 act = QAction(title, self) 

571 act.triggered.connect(cb) 

572 menu.addAction(act) 

573 if menu.isEmpty(): 

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

575 menu.addAction(act) 

576 

577 point = button.pos() 

578 global_point = button.mapToGlobal(point) 

579 menu.exec_(global_point) 

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

581 def closing(self, window): 

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

583 self.windows.remove(window) 

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

585 def place_provided(self, id_, index): 

586 """replace Action button with provided widget""" 

587 provided = self.get_provided(id_) 

588 if provided is None: 

589 return 

590 self.replace_widget_at_index(index, provided) 

591 self.top().prune_empty() 

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

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

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

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

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

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

598 def context_cb(self, id_, index): 

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

600 for provider in self.root.providers: 

601 if hasattr(provider, 'ns_do_context'): 

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

603 if provided: 

604 break 

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

606 def contains(self, widget): 

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

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

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

610 return True 

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

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

613 return True 

614 return False 

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

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

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

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

619 """ 

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

621 if not child: 

622 for window in self.root.windows: 

623 child = window.findChild(child_class, child_name) 

624 if child: 

625 break 

626 return child 

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

628 def handle_context(self, index): 

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

630 

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

632 handle's context menu. 

633 

634 widget 

635 the pair of widgets either side of the handle 

636 neighbour 

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

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

639 [ns0, ns1] or [None, ns1] or [ns0, None] or [None, None] 

640 count 

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

642 """ 

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

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

645 count = [] 

646 for i in 0, 1: 

647 if neighbour[i]: 

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

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

650 count.append(n) 

651 else: 

652 count.append(1) 

653 return widget, neighbour, count 

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

655 def equalize_sizes(self, recurse=False): 

656 """make all pane sizes equal""" 

657 if not self.count(): 

658 return 

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

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

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

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

663 if recurse: 

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

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

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

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

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

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

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

671 w = NestedSplitterChoice(self) 

672 # A QWidget, with self as parent. 

673 # This creates the menu. 

674 self.insertWidget(index, w) 

675 self.equalize_sizes() 

676 return w 

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

678 def invalid_swap(self, w0, w1): 

679 """check for swap violating hierachy""" 

680 return ( 

681 w0 == w1 or 

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

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

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

685 def mark(self, index, side): 

686 """mark a widget for later swapping""" 

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

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

689 def max_count(self): 

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

691 counts = [] 

692 count = 0 

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

694 count += 1 

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

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

697 counts.append(count) 

698 return max(counts) 

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

700 def open_window(self, action=None): 

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

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

703 ns = NestedSplitter(root=self.root) 

704 window = NestedSplitterTopLevel( 

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

706 hbox = QtWidgets.QHBoxLayout() 

707 window.setLayout(hbox) 

708 hbox.setContentsMargins(0, 0, 0, 0) 

709 window.resize(400, 300) 

710 hbox.addWidget(ns) 

711 # NestedSplitters must have two widgets so the handle carrying 

712 # the all important context menu exists 

713 ns.addWidget(NestedSplitterChoice(ns)) 

714 button = NestedSplitterChoice(ns) 

715 ns.addWidget(button) 

716 if action == '_move_marked_there': 

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

718 elif action is not None: 

719 ns.place_provided(action, 1) 

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

721 self.root.windows.append(window) 

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

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

724 sheets = [] 

725 while w: 

726 s = w.styleSheet() 

727 if s: 

728 sheets.append(str(s)) 

729 w = w.parent() 

730 sheets.reverse() 

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

732 window.show() 

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

734 def register_provider(self, provider): 

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

736 

737 NestedSplitter tests for the presence of the following methods on 

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

739 

740 ns_provides() 

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

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

743 '__item_id' is used in ns_provide(). 

744 ns_provide(id_) 

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

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

747 ns_context() 

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

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

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

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

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

753 dicts and tuples may be interspersed in lists. 

754 ns_do_context() 

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

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

757 ns_provider_id() 

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

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

760 added 

761 """ 

762 # drop any providers with the same id 

763 if hasattr(provider, 'ns_provider_id'): 

764 id_ = provider.ns_provider_id() 

765 cull = [] 

766 for i in self.root.providers: 

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

768 i.ns_provider_id() == id_ 

769 ): 

770 cull.append(i) 

771 for i in cull: 

772 self.root.providers.remove(i) 

773 self.root.providers.append(provider) 

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

775 def remove(self, index, side): 

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

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

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

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

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

781 ): 

782 self.root.marked = None 

783 # send close signal to all children 

784 if isinstance(widget, NestedSplitter): 

785 count = widget.count() 

786 all_ok = True 

787 for splitter in widget.self_and_descendants(): 

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

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

790 if all_ok or count <= 0: 

791 widget.setParent(None) 

792 else: 

793 self.close_or_keep(widget) 

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

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

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

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

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

799 if widget is None: 

800 return True 

801 for k in self.root.holders: 

802 if hasattr(widget, k): 

803 holder = self.root.holders[k] 

804 if holder == 'TOP': 

805 holder = other_top or self.top() 

806 if hasattr(holder, "addTab"): 

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

808 else: 

809 holder.addWidget(widget) 

810 return True 

811 if widget.close(): 

812 widget.setParent(None) 

813 return True 

814 return False 

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

816 def replace_widget(self, old, new): 

817 "Swap the provided widgets in place" "" 

818 sizes = self.sizes() 

819 new.setParent(None) 

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

821 self.close_or_keep(old) 

822 new.show() 

823 self.setSizes(sizes) 

824 

825 def replace_widget_at_index(self, index, new): 

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

827 sizes = self.sizes() 

828 old = self.widget(index) 

829 if old != new: 

830 new.setParent(None) 

831 self.insertWidget(index, new) 

832 self.close_or_keep(old) 

833 new.show() 

834 self.setSizes(sizes) 

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

836 def rotate(self, descending=False): 

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

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

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

840 inserting out widgets into our ancestors, etc. 

841 """ 

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

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

844 i.setOrientation(Orientation.Horizontal) 

845 else: 

846 i.setOrientation(Orientation.Vertical) 

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

848 def rotateOne(self, index): 

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

850 psp = self.parent() 

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

852 i = psp.indexOf(self) 

853 sizes = psp.sizes() 

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

855 s = sizes[i] 

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

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

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

859 prev = self.widget(0) 

860 next = self.widget(1) 

861 psp.insertWidget(i, prev) 

862 psp.insertWidget(i + 1, next) 

863 psp.setSizes(sizes) 

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

865 psp.remove(i + 3, 0) 

866 psp.setSizes(sizes) 

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

868 self.rotate() 

869 elif self.count() == 2: 

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

871 else: 

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

873 prev = self.widget(index - 1) 

874 next = self.widget(index) 

875 if None in (prev, next): 

876 return 

877 sizes = self.sizes() 

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

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

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

881 newsp.addWidget(prev) 

882 newsp.addWidget(next) 

883 self.insertWidget(index - 1, newsp) 

884 prev.setHidden(False) 

885 next.setHidden(False) 

886 newsp.setSizes([s1, s2]) 

887 self.setSizes(sizes) 

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

889 def self_and_descendants(self): 

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

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

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

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

894 yield w 

895 yield self 

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

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

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

899 the widget and an Action button""" 

900 sizes = self.sizes() 

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

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

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

904 if w is None: 

905 w = NestedSplitterChoice(self) 

906 if isinstance(old, NestedSplitter): 

907 old.addWidget(w) 

908 old.equalize_sizes() 

909 #X index = old.indexOf(w) 

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

911 else: 

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

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

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

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

916 new.addWidget(old) 

917 new.addWidget(w) 

918 new.equalize_sizes() 

919 #X index = new.indexOf(w) 

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

921 self.setSizes(sizes) 

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

923 def swap(self, index): 

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

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

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

927 def swap_with_marked(self, index, side): 

928 # pylint: disable=unpacking-non-sequence 

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

930 idx = index + side - 1 

931 # convert from handle index to widget index 

932 # 1 already subtracted from oside in mark() 

933 w = self.widget(idx) 

934 if self.invalid_swap(w, ow): 

935 return 

936 self.insertWidget(idx, ow) 

937 osplitter.insertWidget(oidx, w) 

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

939 self.equalize_sizes() 

940 osplitter.equalize_sizes() 

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

942 def top(self, local=False): 

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

944 if local: 

945 top = self 

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

947 top = top.parent() 

948 else: 

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

950 return top 

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

952 def get_layout(self): 

953 """ 

954 Return a dict describing the layout. 

955 

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

957 """ 

958 ans = { 

959 'content': [], 

960 'orientation': self.orientation(), 

961 'sizes': self.sizes(), 

962 'splitter': self, 

963 } 

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

965 w = self.widget(i) 

966 if isinstance(w, NestedSplitter): 

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

968 else: 

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

970 return ans 

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

972 def get_saveable_layout(self): 

973 """ 

974 Return the dict for saveable layouts. 

975 

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

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

978 """ 

979 ans = { 

980 'content': [], 

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

982 'sizes': self.sizes(), 

983 } 

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

985 w = self.widget(i) 

986 if isinstance(w, NestedSplitter): 

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

988 else: 

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

990 return ans 

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

992 def get_splitter_by_name(self, name): 

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

994 if self.objectName() == name: 

995 return self 

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

997 w = self.widget(i) 

998 # Recursively test w and its descendants. 

999 if isinstance(w, NestedSplitter): 

1000 w2 = w.get_splitter_by_name(name) 

1001 if w2: 

1002 return w2 

1003 return None 

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

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

1006 

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

1008 if trace: 

1009 g.trace('level', level) 

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

1011 g.printObj(layout, tag=tag) 

1012 if isQt6: 

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

1014 self.setOrientation(Orientation.Horizontal) 

1015 else: 

1016 self.setOrientation(Orientation.Vertical) 

1017 else: 

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

1019 found = 0 

1020 if level == 0: 

1021 for i in self.self_and_descendants(): 

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

1023 i.widget(n)._in_layout = False 

1024 for content_layout in layout['content']: 

1025 if isinstance(content_layout, dict): 

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

1027 new._in_layout = True 

1028 self.insert(found, new) 

1029 found += 1 

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

1031 else: 

1032 provided = self.get_provided(content_layout) 

1033 if provided: 

1034 self.insert(found, provided) 

1035 provided._in_layout = True 

1036 found += 1 

1037 else: 

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

1039 self.prune_empty() 

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

1041 not_in_layout = set() 

1042 for i in self.self_and_descendants(): 

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

1044 c = i.widget(n) 

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

1046 not_in_layout.add(c) 

1047 for i in not_in_layout: 

1048 self.close_or_keep(i) 

1049 self.prune_empty() 

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

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

1052 else: 

1053 print( 

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

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

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

1057 self.equalize_sizes() 

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

1059 def prune_empty(self): 

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

1061 w = self.widget(i) 

1062 if isinstance(w, NestedSplitter): 

1063 if w.max_count() == 0: 

1064 w.setParent(None) 

1065 # w.deleteLater() 

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

1067 def find_by_id(self, id_): 

1068 for s in self.self_and_descendants(): 

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

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

1071 return s.widget(i) 

1072 return None 

1073 

1074 def get_provided(self, id_): 

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

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

1077 encode state information in the id. 

1078 

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

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

1081 advertises leo_bookmarks_show may also be the correct provider for 

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

1083 """ 

1084 for provider in self.root.providers: 

1085 if hasattr(provider, 'ns_provide'): 

1086 provided = provider.ns_provide(id_) 

1087 if provided: 

1088 if provided == 'USE_EXISTING': 

1089 # provider claiming responsibility, and saying 

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

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

1092 if w: 

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

1094 # IMPORTANT: see docstring 

1095 w._ns_id = id_ 

1096 return w 

1097 else: 

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

1099 # IMPORTANT: see docstring 

1100 provided._ns_id = id_ 

1101 return provided 

1102 return None 

1103 

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

1105 def get_title(self, id_): 

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

1107 """ 

1108 if id_ is None: 

1109 return "Leo widget window" 

1110 for provider in self.root.providers: 

1111 if hasattr(provider, 'ns_title'): 

1112 provided = provider.ns_title(id_) 

1113 if provided: 

1114 return provided 

1115 return "Leo unnamed window" 

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

1117 def zoom_toggle(self, local=False): 

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

1119 

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

1121 """ 

1122 if self.root.zoomed: 

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

1124 if hasattr(ns, '_unzoom'): 

1125 # this splitter could have been added since 

1126 ns.setSizes(ns._unzoom) 

1127 else: 

1128 focused = Qt.QApplication.focusWidget() 

1129 parents = [] 

1130 parent = focused 

1131 while parent: 

1132 parents.append(parent) 

1133 parent = parent.parent() 

1134 if not focused: 

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

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

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

1138 ns._unzoom = ns.sizes() 

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

1140 w = ns.widget(i) 

1141 if w in parents: 

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

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

1144 ns.setSizes(sizes) 

1145 break 

1146 self.root.zoomed = not self.root.zoomed 

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

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

1149 """_splitter_clicked - coordinate propagation of signals 

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

1151 coordination, handles could call self._splitterClickedSignal.emit 

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

1153 

1154 :param QSplitterHandle handle: handle that was clicked 

1155 :param QMouseEvent event: click event 

1156 :param bool release: was it a release event 

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

1158 """ 

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

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

1161 def splitterClicked_connect(self, *args): 

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

1163 and store for application to future splitters. 

1164 """ 

1165 self.root._splitterClickedArgs.append(args) 

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

1167 splitter._splitterClickedSignal.connect(*args) 

1168 #@-others 

1169#@-others 

1170#@@language python 

1171#@@tabwidth -4 

1172#@@pagewidth 70 

1173#@-leo