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

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.
19 These windows are opened by the splitter handle context-menu item
20 'Open Window'.
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)}"
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
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)
165 def pl(n):
166 return 's' if n > 1 else ''
168 def di(s):
169 return {
170 'Above': 'above',
171 'Below': 'below',
172 'Left': 'left of',
173 'Right': 'right of',
174 }[s]
176 # Insert.
178 def insert_callback(index=index):
179 splitter.insert(index)
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 ):
201 def remove_callback(i=i, index=index):
202 splitter.remove(index, i)
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.
209 def swap_callback(index=index):
210 splitter.swap(index)
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:
221 def split_callback(i=i, index=index, splitter=splitter):
222 splitter.split(index, i)
224 self.add_item(
225 split_callback, menu, f"Split {lr[i]} {split_dir}")
226 for i in 0, 1:
228 def mark_callback(i=i, index=index):
229 splitter.mark(index, i)
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]):
237 def swap_mark_callback(i=i, index=index, splitter=splitter):
238 splitter.swap_with_marked(index, i)
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 ):
250 def add_callback(i=i, splitter=splitter):
251 splitter.add(i)
253 self.add_item(add_callback, menu, f"Add {ab[i]}")
254 # Rotate All.
255 self.add_item(splitter.rotate, menu, 'Toggle split direction')
257 def rotate_only_this(index=index):
258 splitter.rotateOne(index)
260 self.add_item(rotate_only_this, menu, 'Toggle split/dir. just this')
261 # equalize panes
263 def eq(splitter=splitter.top()):
264 splitter.equalize_sizes(recurse=True)
266 self.add_item(eq, menu, 'Equalize all')
267 # (un)zoom pane
269 def zoom(splitter=splitter.top()):
270 splitter.zoom_toggle()
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():
297 def cb(id_=id_):
298 splitter.open_window(action=id_)
300 self.add_item(cb, submenu, title)
301 submenu = menu.addMenu('Debug')
302 act = QAction("Print splitter layout", self)
304 def print_layout_c(checked, splitter=splitter):
305 layout = splitter.top().get_layout()
306 g.printObj(layout)
308 act.triggered.connect(print_layout_c)
309 submenu.addAction(act)
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
319 def cb(checked, id_=id_):
320 splitter.context_cb(id_, index)
322 act = QAction(title, self)
323 act.triggered.connect(cb)
324 menu.addAction(act)
326 for provider in splitter.root.providers:
327 if hasattr(provider, 'ns_context'):
328 load_items(menu, provider.ns_context())
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)
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
342 :param QMouseEvent event: mouse event
343 """
344 self.splitter()._splitter_clicked(self, event, release=False, double=False)
346 def mouseReleaseEvent(self, event):
347 """mouse event - mouse pressed on splitter handle,
348 pass info. up to splitter
350 :param QMouseEvent event: mouse event
351 """
352 self.splitter()._splitter_clicked(self, event, release=True, double=False)
354 def mouseDoubleClickEvent(self, event):
355 """mouse event - mouse pressed on splitter handle,
356 pass info. up to splitter
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)
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)}"
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
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()
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
517 # find the layout containing widget_id
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():
567 def cb(checked, id_=id_):
568 self.place_provided(id_, index)
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)
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)
631 This is the handle's context in the NestedSplitter, not the
632 handle's context menu.
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.
737 NestedSplitter tests for the presence of the following methods on
738 the registered things, and calls them when needed if they exist.
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)
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.
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.
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):
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
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.
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
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
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.
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