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

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("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. 

183 

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. 

200 

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 

224 

225 :Parameters: 

226 - `tag`: from hook event 

227 - `keys`: from hook event 

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

229 

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

264 

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

266 

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) 

273 

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

299 

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 

404 

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