Coverage for C:\Repos\leo-editor\leo\plugins\nested_splitter.py: 14%
681 statements
« prev ^ index » next coverage.py v6.4, created at 2022-05-24 10:21 -0500
« prev ^ index » next coverage.py v6.4, created at 2022-05-24 10:21 -0500
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 # 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 # This creates a NestedSplitterHandle.
387 super().__init__(orientation, parent)
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)
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)}"
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
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()
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
516 # find the layout containing widget_id
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():
566 def cb(checked, id_=id_):
567 self.place_provided(id_, index)
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)
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)
630 This is the handle's context in the NestedSplitter, not the
631 handle's context menu.
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.
736 NestedSplitter tests for the presence of the following methods on
737 the registered things, and calls them when needed if they exist.
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)
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.
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.
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):
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
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.
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
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
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.
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