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

275 statements  

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

8 

9Adds flexible panel layout through context menus on the handles between panels. 

10 

11Uses NestedSplitter, a more intelligent QSplitter, from leo.plugins.nested_splitter 

12 

13Requires Qt. 

14 

15Commands (bindable with @settings-->@keys-->@shortcuts): 

16 

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 

24 

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: 

53 

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. 

58 

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: 

63 

64 see nested_splitter.py-->class%20NestedSplitter%20(QSplitter)-->register_provider 

65 

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 >> 

97 

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 

109 

110 - add tags to widgets to indicate that they're essential 

111 (tree, body, log-window-tabs) and 

112 

113 - tag the log-window-tabs widget as the place to put widgets 

114 from free-laout panes which are closed 

115 

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. 

184 

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. 

201 

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 

225 

226 :Parameters: 

227 - `tag`: from hook event 

228 - `keys`: from hook event 

229 - `reloading`: True if this is not the initial load, see below 

230 

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()): 

265 

266 # pylint: disable=cell-var-from-loop 

267 

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) 

274 

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): 

300 

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 

405 

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