Coverage for C:\Repos\leo-editor\leo\plugins\free_layout.py: 19%
275 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.20120419093256.10048: * @file ../plugins/free_layout.py
3#@+<< docstring >>
4#@+node:ekr.20110319161401.14467: ** << docstring >> (free_layout.py)
5"""
6Free layout
7===========
9Adds flexible panel layout through context menus on the handles between panels.
11Uses NestedSplitter, a more intelligent QSplitter, from leo.plugins.nested_splitter
13Requires Qt.
15Commands (bindable with @settings-->@keys-->@shortcuts):
17free-layout-load
18 Open context menu for loading a different layout,
19 convenient keyboard shortcut target.
20free-layout-restore
21 Use the layout this outline had when it was opened.
22free-layout-zoom
23 Zoom or unzoom the current pane
25"""
26#@-<< docstring >>
27# Written by Terry Brown.
28#@+<< imports >>
29#@+node:tbrown.20110203111907.5520: ** << imports >> (free_layout.py)
30import json
31from typing import Any, List
32from leo.core import leoGlobals as g
33#
34# Qt imports. May fail from the bridge.
35try: # #1973
36 from leo.core.leoQt import QtWidgets
37 from leo.core.leoQt import MouseButton
38 from leo.plugins.nested_splitter import NestedSplitter # NestedSplitterChoice
39except Exception:
40 QtWidgets = None # type:ignore
41 MouseButton = None # type:ignore
42 NestedSplitter = None # type:ignore
43#
44# Do not call g.assertUi('qt') here. It's too early in the load process.
45#@-<< imports >>
46#@+others
47#@+node:tbrown.20110203111907.5521: ** free_layout:init
48def init():
49 """Return True if the free_layout plugin can be loaded."""
50 return bool(NestedSplitter and g.app.gui.guiName() == "qt")
51#@+node:ekr.20110318080425.14389: ** class FreeLayoutController
52class FreeLayoutController:
54 #@+<< FreeLayoutController docstring >>
55 #@+node:ekr.20201013042712.1: *3* << FreeLayoutController docstring >>
56 """Glue between Leo and the NestedSplitter gui widget. All Leo aware
57 code should be in here, none in NestedSplitter.
59 *ALSO* implements the provider interface for NestedSplitter, in
60 ns_provides, ns_provide, ns_context, ns_do_context, which
61 NestedSplitter uses as callbacks to populate splitter-handle context-menu
62 and the empty pane Action button menu:
64 see nested_splitter.py-->class%20NestedSplitter%20(QSplitter)-->register_provider
66 ns_provides
67 tell NestedSplitter which Action button items we can provide
68 ns_provide
69 provide the advertised service when an Action button item we
70 advertised is selected
71 ns_context
72 tell NestedSplitter which splitter-handle context-menu items
73 we can provide
74 ns_do_context
75 provide the advertised service when a splitter-handle context-menu
76 item we advertised is selected
77 """
78 #@-<< FreeLayoutController docstring >>
79 #@+<< define default_layout >>
80 #@+node:ekr.20201013042741.1: *3* << define default_layout >>
81 default_layout = {
82 'content': [
83 {
84 'content': [
85 '_leo_pane:outlineFrame',
86 '_leo_pane:logFrame',
87 ],
88 'orientation': 1,
89 'sizes': [509, 275],
90 },
91 '_leo_pane:bodyFrame',
92 ],
93 'orientation': 2,
94 'sizes': [216, 216],
95 }
96 #@-<< define default_layout >>
98 #@+others
99 #@+node:ekr.20110318080425.14390: *3* flc.ctor
100 def __init__(self, c):
101 """Ctor for FreeLayoutController class."""
102 self.c = c
103 g.registerHandler('after-create-leo-frame', self.init)
104 # Plugins must be loaded first to provide their widgets in panels etc.
105 g.registerHandler('after-create-leo-frame2', self.loadLayouts)
106 #@+node:tbrown.20110203111907.5522: *3* flc.init
107 def init(self, tag, keys):
108 """Attach to an outline and
110 - add tags to widgets to indicate that they're essential
111 (tree, body, log-window-tabs) and
113 - tag the log-window-tabs widget as the place to put widgets
114 from free-laout panes which are closed
116 - register this FreeLayoutController as a provider of menu items
117 for NestedSplitter
118 """
119 c = self.c
120 if not NestedSplitter:
121 return
122 if c != keys.get('c'):
123 return
124 # Careful: we could be unit testing.
125 splitter = self.get_top_splitter() # A NestedSplitter.
126 if not splitter:
127 return
128 # by default NestedSplitter's context menus are disabled, needed
129 # once to globally enable them
130 NestedSplitter.enabled = True
131 # when NestedSplitter disposes of children, it will either close
132 # them, or move them to another designated widget. Here we set
133 # up two designated widgets
134 logTabWidget = splitter.findChild(QtWidgets.QWidget, "logTabWidget")
135 splitter.root.holders['_is_from_tab'] = logTabWidget
136 splitter.root.holders['_is_permanent'] = 'TOP'
137 # allow body and tree widgets to be "removed" to tabs on the log tab panel
138 bodyWidget = splitter.findChild(QtWidgets.QFrame, "bodyFrame")
139 bodyWidget._is_from_tab = "Body"
140 treeWidget = splitter.findChild(QtWidgets.QFrame, "outlineFrame")
141 treeWidget._is_from_tab = "Tree"
142 # also the other tabs will have _is_from_tab set on them by the
143 # offer_tabs menu callback above
144 # if the log tab panel is removed, move it back to the top splitter
145 logWidget = splitter.findChild(QtWidgets.QFrame, "logFrame")
146 logWidget._is_permanent = True
147 # tag core Leo components (see ns_provides)
148 splitter.findChild(
149 QtWidgets.QWidget, "outlineFrame")._ns_id = '_leo_pane:outlineFrame'
150 splitter.findChild(QtWidgets.QWidget, "logFrame")._ns_id = '_leo_pane:logFrame'
151 splitter.findChild(QtWidgets.QWidget, "bodyFrame")._ns_id = '_leo_pane:bodyFrame'
152 splitter.register_provider(self)
153 splitter.splitterClicked_connect(self.splitter_clicked)
154 #@+node:tbrown.20120119080604.22982: *3* flc.embed
155 def embed(self):
156 """called from ns_do_context - embed layout in outline's
157 @settings, an alternative to the Load/Save named layout system
158 """
159 # Careful: we could be unit testing.
160 top_splitter = self.get_top_splitter()
161 if not top_splitter:
162 return
163 c = self.c
164 layout = top_splitter.get_saveable_layout()
165 nd = g.findNodeAnywhere(c, "@data free-layout-layout")
166 if not nd:
167 settings = g.findNodeAnywhere(c, "@settings")
168 if not settings:
169 settings = c.rootPosition().insertAfter()
170 settings.h = "@settings" # type:ignore
171 nd = settings.insertAsNthChild(0)
172 nd.h = "@data free-layout-layout"
173 nd.b = json.dumps(layout, indent=4)
174 nd = nd.parent()
175 if not nd or nd.h != "@settings":
176 g.es("WARNING: @data free-layout-layout node is not under an active @settings node")
177 c.redraw()
178 #@+node:ekr.20160424035257.1: *3* flc.get_main_splitter
179 def get_main_splitter(self, w=None):
180 """
181 Return the splitter the main splitter, or None. The main splitter is a
182 NestedSplitter that contains the body pane.
184 Yes, the user could delete the secondary splitter but if so, there is
185 not much we can do here.
186 """
187 top = self.get_top_splitter()
188 if top:
189 w = top.find_child(QtWidgets.QWidget, "bodyFrame")
190 while w:
191 if isinstance(w, NestedSplitter):
192 return w
193 w = w.parent()
194 return None
195 #@+node:ekr.20160424035254.1: *3* flc.get_secondary_splitter
196 def get_secondary_splitter(self):
197 """
198 Return the secondary splitter, if it exists. The secondary splitter
199 contains the outline pane.
201 Yes, the user could delete the outline pane, but if so, there is not
202 much we can do here.
203 """
204 top = self.get_top_splitter()
205 if top:
206 w = top.find_child(QtWidgets.QWidget, 'outlineFrame')
207 while w:
208 if isinstance(w, NestedSplitter):
209 return w
210 w = w.parent()
211 return None
212 #@+node:tbrown.20110621120042.22914: *3* flc.get_top_splitter
213 def get_top_splitter(self):
214 """Return the top splitter of c.frame.top."""
215 # Careful: we could be unit testing.
216 f = self.c.frame
217 if hasattr(f, 'top') and f.top:
218 child = f.top.findChild(NestedSplitter)
219 return child and child.top()
220 return None
221 #@+node:ekr.20120419095424.9927: *3* flc.loadLayouts (sets wrap=True)
222 def loadLayouts(self, tag, keys, reloading=False):
223 """loadLayouts - Load the outline's layout
225 :Parameters:
226 - `tag`: from hook event
227 - `keys`: from hook event
228 - `reloading`: True if this is not the initial load, see below
230 When called from the `after-create-leo-frame2` hook this defaults
231 to False. When called from the `resotre-layout` command, this is set
232 True, and the layout the outline had *when first loaded* is restored.
233 Useful if you want to temporarily switch to a different layout and then
234 back, without having to remember the original layouts name.
235 """
236 trace = 'layouts' in g.app.debug
237 c = self.c
238 if not (g.app and g.app.db):
239 return # Can happen when running from the Leo bridge.
240 if c != keys.get('c'):
241 return
242 d = g.app.db.get('ns_layouts') or {}
243 if trace:
244 g.trace(tag)
245 g.printObj(keys, tag="keys")
246 layout = c.config.getData("free-layout-layout")
247 if layout:
248 layout = json.loads('\n'.join(layout))
249 name = c.db.get('_ns_layout')
250 if name:
251 if reloading:
252 name = c.free_layout.original_layout
253 c.db['_ns_layout'] = name
254 else:
255 c.free_layout.original_layout = name
256 if layout:
257 g.es("NOTE: embedded layout in @settings/@data free-layout-layout "
258 "overrides saved layout " + name)
259 else:
260 layout = d.get(name)
261 # EKR: Create commands that will load each layout.
262 if d:
263 for name in sorted(d.keys()):
265 # pylint: disable=cell-var-from-loop
267 def func(event):
268 layout = d.get(name)
269 if layout:
270 c.free_layout.get_top_splitter().load_layout(c, layout)
271 else:
272 g.trace('no layout', name)
274 name_s = name.strip().lower().replace(' ', '-')
275 commandName = f"free-layout-load-{name_s}"
276 c.k.registerCommand(commandName, func)
277 # Careful: we could be unit testing or in the Leo bridge.
278 if layout:
279 splitter = c.free_layout.get_top_splitter()
280 if splitter:
281 splitter.load_layout(c, layout)
282 #@+node:tbrown.20110628083641.11730: *3* flc.ns_context
283 def ns_context(self):
284 ans: List[Any] = [
285 ('Embed layout', '_fl_embed_layout'),
286 ('Save layout', '_fl_save_layout'),
287 ]
288 d = g.app.db.get('ns_layouts', {})
289 if d:
290 ans.append({'Load layout': [(k, '_fl_load_layout:' + k) for k in d]})
291 ans.append({'Delete layout': [(k, '_fl_delete_layout:' + k) for k in d]})
292 ans.append(('Forget layout', '_fl_forget_layout:'))
293 ans.append(('Restore initial layout', '_fl_restore_layout:'))
294 ans.append(('Restore default layout', '_fl_restore_default:'))
295 ans.append(('Help for this menu', '_fl_help:'))
296 return ans
297 #@+node:tbrown.20110628083641.11732: *3* flc.ns_do_context
298 def ns_do_context(self, id_, splitter, index):
300 c = self.c
301 if id_.startswith('_fl_embed_layout'):
302 self.embed()
303 return True
304 if id_.startswith('_fl_restore_default'):
305 self.get_top_splitter().load_layout(c, layout=self.default_layout)
306 if id_.startswith('_fl_help'):
307 self.c.putHelpFor(__doc__)
308 # g.handleUrl("http://leoeditor.com/")
309 return True
310 if id_ == '_fl_save_layout':
311 if self.c.config.getData("free-layout-layout"):
312 g.es("WARNING: embedded layout in")
313 g.es("@settings/@data free-layout-layout")
314 g.es("will override saved layout")
315 layout = self.get_top_splitter().get_saveable_layout()
316 name = g.app.gui.runAskOkCancelStringDialog(self.c,
317 title="Save layout",
318 message="Name for layout?",
319 )
320 if name:
321 self.c.db['_ns_layout'] = name
322 d = g.app.db.get('ns_layouts', {})
323 d[name] = layout
324 # make sure g.app.db's __set_item__ is hit so it knows to save
325 g.app.db['ns_layouts'] = d
326 return True
327 if id_.startswith('_fl_load_layout:'):
328 if self.c.config.getData("free-layout-layout"):
329 g.es("WARNING: embedded layout in")
330 g.es("@settings/@data free-layout-layout")
331 g.es("will override saved layout")
332 name = id_.split(':', 1)[1]
333 self.c.db['_ns_layout'] = name
334 layout = g.app.db['ns_layouts'][name]
335 self.get_top_splitter().load_layout(c, layout)
336 return True
337 if id_.startswith('_fl_delete_layout:'):
338 name = id_.split(':', 1)[1]
339 if ('yes' == g.app.gui.runAskYesNoCancelDialog(c,
340 "Really delete Layout?",
341 f"Really permanently delete the layout '{name}'?")
342 ):
343 d = g.app.db.get('ns_layouts', {})
344 del d[name]
345 # make sure g.app.db's __set_item__ is hit so it knows to save
346 g.app.db['ns_layouts'] = d
347 if '_ns_layout' in self.c.db:
348 del self.c.db['_ns_layout']
349 return True
350 if id_.startswith('_fl_forget_layout:'):
351 if '_ns_layout' in self.c.db:
352 del self.c.db['_ns_layout']
353 return True
354 if id_.startswith('_fl_restore_layout:'):
355 self.loadLayouts("reload", {'c': self.c}, reloading=True)
356 return True
357 return False
358 #@+node:tbrown.20110628083641.11724: *3* flc.ns_provide
359 def ns_provide(self, id_):
360 if id_.startswith('_leo_tab:'):
361 id_ = id_.split(':', 1)[1]
362 top = self.get_top_splitter()
363 logTabWidget = top.find_child(QtWidgets.QWidget, "logTabWidget")
364 for n in range(logTabWidget.count()):
365 if logTabWidget.tabText(n) == id_:
366 w = logTabWidget.widget(n)
367 w.setHidden(False)
368 w._is_from_tab = logTabWidget.tabText(n)
369 w.setMinimumSize(20, 20)
370 return w
371 # didn't find it, maybe it's already in a splitter
372 return 'USE_EXISTING'
373 if id_.startswith('_leo_pane:'):
374 id_ = id_.split(':', 1)[1]
375 w = self.get_top_splitter().find_child(QtWidgets.QWidget, id_)
376 if w:
377 w.setHidden(False) # may be from Tab holder
378 w.setMinimumSize(20, 20)
379 return w
380 return None
381 #@+node:tbrown.20110627201141.11745: *3* flc.ns_provides
382 def ns_provides(self):
383 ans = []
384 # list of things in tab widget
385 logTabWidget = self.get_top_splitter(
386 ).find_child(QtWidgets.QWidget, "logTabWidget")
387 for n in range(logTabWidget.count()):
388 text = str(logTabWidget.tabText(n))
389 if text in ('Body', 'Tree'):
390 continue # handled below
391 if text == 'Log':
392 # if Leo can't find Log in tab pane, it creates another
393 continue
394 ans.append((text, '_leo_tab:' + text))
395 ans.append(('Tree', '_leo_pane:outlineFrame'))
396 ans.append(('Body', '_leo_pane:bodyFrame'))
397 ans.append(('Tab pane', '_leo_pane:logFrame'))
398 return ans
399 #@+node:tbnorth.20160510122413.1: *3* flc.splitter_clicked
400 def splitter_clicked(self, splitter, handle, event, release, double):
401 """
402 splitter_clicked - middle click release will zoom adjacent
403 body / tree panes
405 :param NestedSplitter splitter: splitter containing clicked handle
406 :param NestedSplitterHandle handle: clicked handle
407 :param QMouseEvent event: mouse event for click
408 :param bool release: was it a Press or Release event
409 :param bool double: was it a double click event
410 """
411 if not release or event.button() != MouseButton.MiddleButton:
412 return
413 if splitter.root.zoomed: # unzoom if *any* handle clicked
414 splitter.zoom_toggle()
415 return
416 before = splitter.widget(splitter.indexOf(handle) - 1)
417 after = splitter.widget(splitter.indexOf(handle))
418 for pane in before, after:
419 if pane.objectName() == 'bodyFrame':
420 pane.setFocus()
421 splitter.zoom_toggle()
422 return
423 if pane.objectName() == 'outlineFrame':
424 pane.setFocus()
425 splitter.zoom_toggle(local=True)
426 return
427 #@-others
428#@+node:ekr.20160416065221.1: ** commands: free_layout.py
429#@+node:tbrown.20140524112944.32658: *3* @g.command free-layout-context-menu
430@g.command('free-layout-context-menu')
431def free_layout_context_menu(event):
432 """
433 Open free layout's context menu, using the first divider of the top
434 splitter for context.
435 """
436 c = event.get('c')
437 splitter = c.free_layout.get_top_splitter()
438 handle = splitter.handle(1)
439 handle.splitter_menu(handle.rect().topLeft())
440#@+node:tbrown.20130403081644.25265: *3* @g.command free-layout-restore
441@g.command('free-layout-restore')
442def free_layout_restore(event):
443 """
444 Restore layout outline had when it was loaded.
445 """
446 c = event.get('c')
447 c.free_layout.loadLayouts('reload', {'c': c}, reloading=True)
448#@+node:tbrown.20131111194858.29876: *3* @g.command free-layout-load
449@g.command('free-layout-load')
450def free_layout_load(event):
451 """Load layout from menu."""
452 c = event.get('c')
453 if not c:
454 return
455 d = g.app.db.get('ns_layouts', {})
456 menu = QtWidgets.QMenu(c.frame.top)
457 for k in d:
458 menu.addAction(k)
459 pos = c.frame.top.window().frameGeometry().center()
460 action = menu.exec_(pos)
461 if action is None:
462 return
463 name = str(action.text())
464 c.db['_ns_layout'] = name
465 # layout = g.app.db['ns_layouts'][name]
466 layouts = g.app.db.get('ns_layouts', {})
467 layout = layouts.get(name)
468 if layout:
469 c.free_layout.get_top_splitter().load_layout(c, layout)
470#@+node:tbrown.20140522153032.32658: *3* @g.command free-layout-zoom
471@g.command('free-layout-zoom')
472def free_layout_zoom(event):
473 """(un)zoom the current pane."""
474 c = event.get('c')
475 c.free_layout.get_top_splitter().zoom_toggle()
476#@+node:ekr.20160327060009.1: *3* free_layout:register_provider
477def register_provider(c, provider_instance):
478 """Register the provider instance with the top splitter."""
479 # Careful: c.free_layout may not exist during unit testing.
480 if c and hasattr(c, 'free_layout'):
481 splitter = c.free_layout.get_top_splitter()
482 if splitter:
483 splitter.register_provider(provider_instance)
484#@-others
485#@@language python
486#@@tabwidth -4
487#@@pagewidth 70
488#@-leo