Coverage for C:\leo.repo\leo-editor\leo\plugins\free_layout.py: 19%
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
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(
177 "WARNING: @data free-layout-layout node is not " "under an active @settings node")
178 c.redraw()
179 #@+node:ekr.20160424035257.1: *3* flc.get_main_splitter
180 def get_main_splitter(self, w=None):
181 """
182 Return the splitter the main splitter, or None. The main splitter is a
183 NestedSplitter that contains the body pane.
185 Yes, the user could delete the secondary splitter but if so, there is
186 not much we can do here.
187 """
188 top = self.get_top_splitter()
189 if top:
190 w = top.find_child(QtWidgets.QWidget, "bodyFrame")
191 while w:
192 if isinstance(w, NestedSplitter):
193 return w
194 w = w.parent()
195 return None
196 #@+node:ekr.20160424035254.1: *3* flc.get_secondary_splitter
197 def get_secondary_splitter(self):
198 """
199 Return the secondary splitter, if it exists. The secondary splitter
200 contains the outline pane.
202 Yes, the user could delete the outline pane, but if so, there is not
203 much we can do here.
204 """
205 top = self.get_top_splitter()
206 if top:
207 w = top.find_child(QtWidgets.QWidget, 'outlineFrame')
208 while w:
209 if isinstance(w, NestedSplitter):
210 return w
211 w = w.parent()
212 return None
213 #@+node:tbrown.20110621120042.22914: *3* flc.get_top_splitter
214 def get_top_splitter(self):
215 """Return the top splitter of c.frame.top."""
216 # Careful: we could be unit testing.
217 f = self.c.frame
218 if hasattr(f, 'top') and f.top:
219 child = f.top.findChild(NestedSplitter)
220 return child and child.top()
221 return None
222 #@+node:ekr.20120419095424.9927: *3* flc.loadLayouts (sets wrap=True)
223 def loadLayouts(self, tag, keys, reloading=False):
224 """loadLayouts - Load the outline's layout
226 :Parameters:
227 - `tag`: from hook event
228 - `keys`: from hook event
229 - `reloading`: True if this is not the initial load, see below
231 When called from the `after-create-leo-frame2` hook this defaults
232 to False. When called from the `resotre-layout` command, this is set
233 True, and the layout the outline had *when first loaded* is restored.
234 Useful if you want to temporarily switch to a different layout and then
235 back, without having to remember the original layouts name.
236 """
237 trace = 'layouts' in g.app.debug
238 c = self.c
239 if not (g.app and g.app.db):
240 return # Can happen when running from the Leo bridge.
241 if c != keys.get('c'):
242 return
243 d = g.app.db.get('ns_layouts') or {}
244 if trace:
245 g.trace(tag)
246 g.printObj(keys, tag="keys")
247 layout = c.config.getData("free-layout-layout")
248 if layout:
249 layout = json.loads('\n'.join(layout))
250 name = c.db.get('_ns_layout')
251 if name:
252 if reloading:
253 name = c.free_layout.original_layout
254 c.db['_ns_layout'] = name
255 else:
256 c.free_layout.original_layout = name
257 if layout:
258 g.es("NOTE: embedded layout in @settings/@data free-layout-layout "
259 "overrides saved layout " + name)
260 else:
261 layout = d.get(name)
262 # EKR: Create commands that will load each layout.
263 if d:
264 for name in sorted(d.keys()):
266 # pylint: disable=cell-var-from-loop
268 def func(event):
269 layout = d.get(name)
270 if layout:
271 c.free_layout.get_top_splitter().load_layout(c, layout)
272 else:
273 g.trace('no layout', name)
275 name_s = name.strip().lower().replace(' ', '-')
276 commandName = f"free-layout-load-{name_s}"
277 c.k.registerCommand(commandName, func)
278 # Careful: we could be unit testing or in the Leo bridge.
279 if layout:
280 splitter = c.free_layout.get_top_splitter()
281 if splitter:
282 splitter.load_layout(c, layout)
283 #@+node:tbrown.20110628083641.11730: *3* flc.ns_context
284 def ns_context(self):
285 ans: List[Any] = [
286 ('Embed layout', '_fl_embed_layout'),
287 ('Save layout', '_fl_save_layout'),
288 ]
289 d = g.app.db.get('ns_layouts', {})
290 if d:
291 ans.append({'Load layout': [(k, '_fl_load_layout:' + k) for k in d]})
292 ans.append({'Delete layout': [(k, '_fl_delete_layout:' + k) for k in d]})
293 ans.append(('Forget layout', '_fl_forget_layout:'))
294 ans.append(('Restore initial layout', '_fl_restore_layout:'))
295 ans.append(('Restore default layout', '_fl_restore_default:'))
296 ans.append(('Help for this menu', '_fl_help:'))
297 return ans
298 #@+node:tbrown.20110628083641.11732: *3* flc.ns_do_context
299 def ns_do_context(self, id_, splitter, index):
301 c = self.c
302 if id_.startswith('_fl_embed_layout'):
303 self.embed()
304 return True
305 if id_.startswith('_fl_restore_default'):
306 self.get_top_splitter().load_layout(c, layout=self.default_layout)
307 if id_.startswith('_fl_help'):
308 self.c.putHelpFor(__doc__)
309 # g.handleUrl("http://leoeditor.com/")
310 return True
311 if id_ == '_fl_save_layout':
312 if self.c.config.getData("free-layout-layout"):
313 g.es("WARNING: embedded layout in")
314 g.es("@settings/@data free-layout-layout")
315 g.es("will override saved layout")
316 layout = self.get_top_splitter().get_saveable_layout()
317 name = g.app.gui.runAskOkCancelStringDialog(self.c,
318 title="Save layout",
319 message="Name for layout?",
320 )
321 if name:
322 self.c.db['_ns_layout'] = name
323 d = g.app.db.get('ns_layouts', {})
324 d[name] = layout
325 # make sure g.app.db's __set_item__ is hit so it knows to save
326 g.app.db['ns_layouts'] = d
327 return True
328 if id_.startswith('_fl_load_layout:'):
329 if self.c.config.getData("free-layout-layout"):
330 g.es("WARNING: embedded layout in")
331 g.es("@settings/@data free-layout-layout")
332 g.es("will override saved layout")
333 name = id_.split(':', 1)[1]
334 self.c.db['_ns_layout'] = name
335 layout = g.app.db['ns_layouts'][name]
336 self.get_top_splitter().load_layout(c, layout)
337 return True
338 if id_.startswith('_fl_delete_layout:'):
339 name = id_.split(':', 1)[1]
340 if ('yes' == g.app.gui.runAskYesNoCancelDialog(c,
341 "Really delete Layout?",
342 f"Really permanently delete the layout '{name}'?")
343 ):
344 d = g.app.db.get('ns_layouts', {})
345 del d[name]
346 # make sure g.app.db's __set_item__ is hit so it knows to save
347 g.app.db['ns_layouts'] = d
348 if '_ns_layout' in self.c.db:
349 del self.c.db['_ns_layout']
350 return True
351 if id_.startswith('_fl_forget_layout:'):
352 if '_ns_layout' in self.c.db:
353 del self.c.db['_ns_layout']
354 return True
355 if id_.startswith('_fl_restore_layout:'):
356 self.loadLayouts("reload", {'c': self.c}, reloading=True)
357 return True
358 return False
359 #@+node:tbrown.20110628083641.11724: *3* flc.ns_provide
360 def ns_provide(self, id_):
361 if id_.startswith('_leo_tab:'):
362 id_ = id_.split(':', 1)[1]
363 top = self.get_top_splitter()
364 logTabWidget = top.find_child(QtWidgets.QWidget, "logTabWidget")
365 for n in range(logTabWidget.count()):
366 if logTabWidget.tabText(n) == id_:
367 w = logTabWidget.widget(n)
368 w.setHidden(False)
369 w._is_from_tab = logTabWidget.tabText(n)
370 w.setMinimumSize(20, 20)
371 return w
372 # didn't find it, maybe it's already in a splitter
373 return 'USE_EXISTING'
374 if id_.startswith('_leo_pane:'):
375 id_ = id_.split(':', 1)[1]
376 w = self.get_top_splitter().find_child(QtWidgets.QWidget, id_)
377 if w:
378 w.setHidden(False) # may be from Tab holder
379 w.setMinimumSize(20, 20)
380 return w
381 return None
382 #@+node:tbrown.20110627201141.11745: *3* flc.ns_provides
383 def ns_provides(self):
384 ans = []
385 # list of things in tab widget
386 logTabWidget = self.get_top_splitter(
387 ).find_child(QtWidgets.QWidget, "logTabWidget")
388 for n in range(logTabWidget.count()):
389 text = str(logTabWidget.tabText(n))
390 if text in ('Body', 'Tree'):
391 continue # handled below
392 if text == 'Log':
393 # if Leo can't find Log in tab pane, it creates another
394 continue
395 ans.append((text, '_leo_tab:' + text))
396 ans.append(('Tree', '_leo_pane:outlineFrame'))
397 ans.append(('Body', '_leo_pane:bodyFrame'))
398 ans.append(('Tab pane', '_leo_pane:logFrame'))
399 return ans
400 #@+node:tbnorth.20160510122413.1: *3* flc.splitter_clicked
401 def splitter_clicked(self, splitter, handle, event, release, double):
402 """
403 splitter_clicked - middle click release will zoom adjacent
404 body / tree panes
406 :param NestedSplitter splitter: splitter containing clicked handle
407 :param NestedSplitterHandle handle: clicked handle
408 :param QMouseEvent event: mouse event for click
409 :param bool release: was it a Press or Release event
410 :param bool double: was it a double click event
411 """
412 if not release or event.button() != MouseButton.MiddleButton:
413 return
414 if splitter.root.zoomed: # unzoom if *any* handle clicked
415 splitter.zoom_toggle()
416 return
417 before = splitter.widget(splitter.indexOf(handle) - 1)
418 after = splitter.widget(splitter.indexOf(handle))
419 for pane in before, after:
420 if pane.objectName() == 'bodyFrame':
421 pane.setFocus()
422 splitter.zoom_toggle()
423 return
424 if pane.objectName() == 'outlineFrame':
425 pane.setFocus()
426 splitter.zoom_toggle(local=True)
427 return
428 #@-others
429#@+node:ekr.20160416065221.1: ** commands: free_layout.py
430#@+node:tbrown.20140524112944.32658: *3* @g.command free-layout-context-menu
431@g.command('free-layout-context-menu')
432def free_layout_context_menu(event):
433 """
434 Open free layout's context menu, using the first divider of the top
435 splitter for context.
436 """
437 c = event.get('c')
438 splitter = c.free_layout.get_top_splitter()
439 handle = splitter.handle(1)
440 handle.splitter_menu(handle.rect().topLeft())
441#@+node:tbrown.20130403081644.25265: *3* @g.command free-layout-restore
442@g.command('free-layout-restore')
443def free_layout_restore(event):
444 """
445 Restore layout outline had when it was loaded.
446 """
447 c = event.get('c')
448 c.free_layout.loadLayouts('reload', {'c': c}, reloading=True)
449#@+node:tbrown.20131111194858.29876: *3* @g.command free-layout-load
450@g.command('free-layout-load')
451def free_layout_load(event):
452 """Load layout from menu."""
453 c = event.get('c')
454 if not c:
455 return
456 d = g.app.db.get('ns_layouts', {})
457 menu = QtWidgets.QMenu(c.frame.top)
458 for k in d:
459 menu.addAction(k)
460 pos = c.frame.top.window().frameGeometry().center()
461 action = menu.exec_(pos)
462 if action is None:
463 return
464 name = str(action.text())
465 c.db['_ns_layout'] = name
466 # layout = g.app.db['ns_layouts'][name]
467 layouts = g.app.db.get('ns_layouts', {})
468 layout = layouts.get(name)
469 if layout:
470 c.free_layout.get_top_splitter().load_layout(c, layout)
471#@+node:tbrown.20140522153032.32658: *3* @g.command free-layout-zoom
472@g.command('free-layout-zoom')
473def free_layout_zoom(event):
474 """(un)zoom the current pane."""
475 c = event.get('c')
476 c.free_layout.get_top_splitter().zoom_toggle()
477#@+node:ekr.20160327060009.1: *3* free_layout:register_provider
478def register_provider(c, provider_instance):
479 """Register the provider instance with the top splitter."""
480 # Careful: c.free_layout may not exist during unit testing.
481 if c and hasattr(c, 'free_layout'):
482 splitter = c.free_layout.get_top_splitter()
483 if splitter:
484 splitter.register_provider(provider_instance)
485#@-others
486#@@language python
487#@@tabwidth -4
488#@@pagewidth 70
489#@-leo