Hide keyboard shortcuts

Hot-keys on this page

r m x p   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.20140907085654.18699: * @file ../plugins/qt_gui.py 

3"""This file contains the gui wrapper for Qt: g.app.gui.""" 

4# pylint: disable=import-error 

5#@+<< imports >> 

6#@+node:ekr.20140918102920.17891: ** << imports >> (qt_gui.py) 

7import datetime 

8import functools 

9import re 

10import sys 

11import textwrap 

12from typing import Dict, List 

13 

14from leo.core import leoColor 

15from leo.core import leoGlobals as g 

16from leo.core import leoGui 

17from leo.core.leoQt import isQt5, isQt6, Qsci, QtConst, QtCore, QtGui, QtWidgets 

18from leo.core.leoQt import ButtonRole, DialogCode, Icon, Information, Policy 

19from leo.core.leoQt import Shadow, Shape, StandardButton, Weight, WindowType 

20 # This import causes pylint to fail on this file and on leoBridge.py. 

21 # The failure is in astroid: raw_building.py. 

22from leo.plugins import qt_events 

23from leo.plugins import qt_frame 

24from leo.plugins import qt_idle_time 

25from leo.plugins import qt_text 

26# This defines the commands defined by @g.command. 

27from leo.plugins import qt_commands 

28assert qt_commands 

29#@-<< imports >> 

30#@+others 

31#@+node:ekr.20110605121601.18134: ** init (qt_gui.py) 

32def init(): 

33 

34 if g.unitTesting: # Not Ok for unit testing! 

35 return False 

36 if not QtCore: 

37 return False 

38 if g.app.gui: 

39 return g.app.gui.guiName() == 'qt' 

40 g.app.gui = LeoQtGui() 

41 g.app.gui.finishCreate() 

42 g.plugin_signon(__name__) 

43 return True 

44#@+node:ekr.20140907085654.18700: ** class LeoQtGui(leoGui.LeoGui) 

45class LeoQtGui(leoGui.LeoGui): 

46 """A class implementing Leo's Qt gui.""" 

47 #@+others 

48 #@+node:ekr.20110605121601.18477: *3* qt_gui.__init__ (sets qtApp) 

49 def __init__(self): 

50 """Ctor for LeoQtGui class.""" 

51 super().__init__('qt') 

52 # Initialize the base class. 

53 self.active = True 

54 self.consoleOnly = False # Console is separate from the log. 

55 self.iconimages = {} 

56 self.globalFindDialog = None 

57 self.idleTimeClass = qt_idle_time.IdleTime 

58 self.insert_char_flag = False # A flag for eventFilter. 

59 self.mGuiName = 'qt' 

60 self.main_window = None # The *singleton* QMainWindow. 

61 self.plainTextWidget = qt_text.PlainTextWrapper 

62 self.show_tips_flag = False # #2390: Can't be inited in reload_settings. 

63 self.styleSheetManagerClass = StyleSheetManager 

64 # Be aware of the systems native colors, fonts, etc. 

65 QtWidgets.QApplication.setDesktopSettingsAware(True) 

66 # Create objects... 

67 self.qtApp = QtWidgets.QApplication(sys.argv) 

68 self.reloadSettings() 

69 self.appIcon = self.getIconImage('leoapp32.png') 

70 # 

71 # Define various classes key stokes. 

72 #@+<< define FKeys >> 

73 #@+node:ekr.20180419110303.1: *4* << define FKeys >> 

74 self.FKeys = [ 

75 'F1', 'F2', 'F3', 'F4', 'F5', 'F6', 'F7', 'F8', 'F9', 'F10', 'F11', 'F12'] 

76 # These do not generate keystrokes on MacOs. 

77 #@-<< define FKeys >> 

78 #@+<< define ignoreChars >> 

79 #@+node:ekr.20180419105250.1: *4* << define ignoreChars >> 

80 # Always ignore these characters 

81 self.ignoreChars = [ 

82 # These are in ks.special characters. 

83 # They should *not* be ignored. 

84 # 'Left', 'Right', 'Up', 'Down', 

85 # 'Next', 'Prior', 

86 # 'Home', 'End', 

87 # 'Delete', 'Escape', 

88 # 'BackSpace', 'Linefeed', 'Return', 'Tab', 

89 # F-Keys are also ok. 

90 # 'F1','F2','F3','F4','F5','F6','F7','F8','F9','F10','F11','F12', 

91 'KP_0', 'KP_1', 'KP_2', 'KP_3', 'KP_4', 'KP_5', 'KP_6', 'KP_7', 'KP_8', 'KP_9', 

92 'KP_Multiply, KP_Separator,KP_Space, KP_Subtract, KP_Tab', 

93 'KP_F1', 'KP_F2', 'KP_F3', 'KP_F4', 

94 'KP_Add', 'KP_Decimal', 'KP_Divide', 'KP_Enter', 'KP_Equal', 

95 # Keypad chars should be have been converted to other keys. 

96 # Users should just bind to the corresponding normal keys. 

97 'CapsLock', 'Caps_Lock', 

98 'NumLock', 'Num_Lock', 

99 'ScrollLock', 

100 'Alt_L', 'Alt_R', 

101 'Control_L', 'Control_R', 

102 'Meta_L', 'Meta_R', 

103 'Shift_L', 'Shift_R', 

104 'Win_L', 'Win_R', 

105 # Clearly, these should never be generated. 

106 'Break', 'Pause', 'Sys_Req', 

107 # These are real keys, but they don't mean anything. 

108 'Begin', 'Clear', 

109 # Don't know what these are. 

110 ] 

111 #@-<< define ignoreChars >> 

112 #@+<< define specialChars >> 

113 #@+node:ekr.20180419081404.1: *4* << define specialChars >> 

114 # Keys whose names must never be inserted into text. 

115 self.specialChars = [ 

116 # These are *not* special keys. 

117 # 'BackSpace', 'Linefeed', 'Return', 'Tab', 

118 'Left', 'Right', 'Up', 'Down', 

119 # Arrow keys 

120 'Next', 'Prior', 

121 # Page up/down keys. 

122 'Home', 'End', 

123 # Home end keys. 

124 'Delete', 'Escape', 

125 # Others. 

126 'Enter', 'Insert', 'Ins', 

127 # These should only work if bound. 

128 'Menu', 

129 # #901. 

130 'PgUp', 'PgDn', 

131 # #868. 

132 ] 

133 #@-<< define specialChars >> 

134 # Put up the splash screen() 

135 if (g.app.use_splash_screen and 

136 not g.app.batchMode and 

137 not g.app.silentMode and 

138 not g.unitTesting 

139 ): 

140 self.splashScreen = self.createSplashScreen() 

141 self.frameFactory = qt_frame.TabbedFrameFactory() 

142 # qtFrame.finishCreate does all the other work. 

143 

144 def reloadSettings(self): 

145 pass # Note: self.c does not exist. 

146 #@+node:ekr.20110605121601.18484: *3* qt_gui.destroySelf (calls qtApp.quit) 

147 def destroySelf(self): 

148 

149 QtCore.pyqtRemoveInputHook() 

150 if 'shutdown' in g.app.debug: 

151 g.pr('LeoQtGui.destroySelf: calling qtApp.Quit') 

152 self.qtApp.quit() 

153 #@+node:ekr.20110605121601.18485: *3* qt_gui.Clipboard 

154 #@+node:ekr.20160917125946.1: *4* qt_gui.replaceClipboardWith 

155 def replaceClipboardWith(self, s): 

156 """Replace the clipboard with the string s.""" 

157 cb = self.qtApp.clipboard() 

158 if cb: 

159 # cb.clear() # unnecessary, breaks on some Qt versions 

160 s = g.toUnicode(s) 

161 QtWidgets.QApplication.processEvents() 

162 # Fix #241: QMimeData object error 

163 cb.setText(s) 

164 QtWidgets.QApplication.processEvents() 

165 else: 

166 g.trace('no clipboard!') 

167 #@+node:ekr.20160917125948.1: *4* qt_gui.getTextFromClipboard 

168 def getTextFromClipboard(self): 

169 """Get a unicode string from the clipboard.""" 

170 cb = self.qtApp.clipboard() 

171 if cb: 

172 QtWidgets.QApplication.processEvents() 

173 return cb.text() 

174 g.trace('no clipboard!') 

175 return '' 

176 #@+node:ekr.20160917130023.1: *4* qt_gui.setClipboardSelection 

177 def setClipboardSelection(self, s): 

178 """ 

179 Set the clipboard selection to s. 

180 There are problems with PyQt5. 

181 """ 

182 if isQt5 or isQt6: 

183 # Alas, returning s reopens #218. 

184 return 

185 if s: 

186 # This code generates a harmless, but annoying warning on PyQt5. 

187 cb = self.qtApp.clipboard() 

188 cb.setText(s, mode=cb.Selection) 

189 #@+node:ekr.20110605121601.18487: *3* qt_gui.Dialogs & panels 

190 #@+node:ekr.20110605121601.18488: *4* qt_gui.alert 

191 def alert(self, c, message): 

192 if g.unitTesting: 

193 return 

194 dialog = QtWidgets.QMessageBox(None) 

195 dialog.setWindowTitle('Alert') 

196 dialog.setText(message) 

197 dialog.setIcon(Icon.Warning) 

198 dialog.addButton('Ok', ButtonRole.YesRole) 

199 try: 

200 c.in_qt_dialog = True 

201 dialog.raise_() 

202 dialog.exec_() 

203 finally: 

204 c.in_qt_dialog = False 

205 #@+node:ekr.20110605121601.18489: *4* qt_gui.makeFilter 

206 def makeFilter(self, filetypes): 

207 """Return the Qt-style dialog filter from filetypes list.""" 

208 filters = ['%s (%s)' % (z) for z in filetypes] 

209 # Careful: the second %s is *not* replaced. 

210 return ';;'.join(filters) 

211 #@+node:ekr.20150615211522.1: *4* qt_gui.openFindDialog & helper 

212 def openFindDialog(self, c): 

213 if g.unitTesting: 

214 return 

215 dialog = self.globalFindDialog 

216 if not dialog: 

217 dialog = self.createFindDialog(c) 

218 self.globalFindDialog = dialog 

219 # Fix #516: Do the following only once... 

220 if c: 

221 dialog.setStyleSheet(c.active_stylesheet) 

222 # Set the commander's FindTabManager. 

223 assert g.app.globalFindTabManager 

224 c.ftm = g.app.globalFindTabManager 

225 fn = c.shortFileName() or 'Untitled' 

226 else: 

227 fn = 'Untitled' 

228 dialog.setWindowTitle(f"Find in {fn}") 

229 if c: 

230 c.inCommand = False 

231 if dialog.isVisible(): 

232 # The order is important, and tricky. 

233 dialog.focusWidget() 

234 dialog.show() 

235 dialog.raise_() 

236 dialog.activateWindow() 

237 else: 

238 dialog.show() 

239 dialog.exec_() 

240 #@+node:ekr.20150619053138.1: *5* qt_gui.createFindDialog 

241 def createFindDialog(self, c): 

242 """Create and init a non-modal Find dialog.""" 

243 if c: 

244 g.app.globalFindTabManager = c.findCommands.ftm 

245 top = c and c.frame.top # top is the DynamicWindow class. 

246 w = top.findTab 

247 dialog = QtWidgets.QDialog() 

248 # Fix #516: Hide the dialog. Never delete it. 

249 

250 def closeEvent(event): 

251 event.ignore() 

252 dialog.hide() 

253 

254 dialog.closeEvent = closeEvent 

255 layout = QtWidgets.QVBoxLayout(dialog) 

256 layout.addWidget(w) 

257 self.attachLeoIcon(dialog) 

258 dialog.setLayout(layout) 

259 if c: 

260 c.styleSheetManager.set_style_sheets(w=dialog) 

261 g.app.gui.setFilter(c, dialog, dialog, 'find-dialog') 

262 # This makes most standard bindings available. 

263 dialog.setModal(False) 

264 return dialog 

265 #@+node:ekr.20110605121601.18492: *4* qt_gui.panels 

266 def createComparePanel(self, c): 

267 """Create a qt color picker panel.""" 

268 return None # This window is optional. 

269 

270 def createFindTab(self, c, parentFrame): 

271 """Create a qt find tab in the indicated frame.""" 

272 pass # Now done in dw.createFindTab. 

273 

274 def createLeoFrame(self, c, title): 

275 """Create a new Leo frame.""" 

276 return qt_frame.LeoQtFrame(c, title, gui=self) 

277 

278 def createSpellTab(self, c, spellHandler, tabName): 

279 if g.unitTesting: 

280 return None 

281 return qt_frame.LeoQtSpellTab(c, spellHandler, tabName) 

282 #@+node:ekr.20110605121601.18493: *4* qt_gui.runAboutLeoDialog 

283 def runAboutLeoDialog(self, c, version, theCopyright, url, email): 

284 """Create and run a qt About Leo dialog.""" 

285 if g.unitTesting: 

286 return 

287 dialog = QtWidgets.QMessageBox(c and c.frame.top) 

288 dialog.setText(f"{version}\n{theCopyright}\n{url}\n{email}") 

289 dialog.setIcon(Icon.Information) 

290 yes = dialog.addButton('Ok', ButtonRole.YesRole) 

291 dialog.setDefaultButton(yes) 

292 try: 

293 c.in_qt_dialog = True 

294 dialog.raise_() 

295 dialog.exec_() 

296 finally: 

297 c.in_qt_dialog = False 

298 #@+node:ekr.20110605121601.18496: *4* qt_gui.runAskDateTimeDialog 

299 def runAskDateTimeDialog(self, c, title, 

300 message='Select Date/Time', 

301 init=None, 

302 step_min=None 

303 ): 

304 """Create and run a qt date/time selection dialog. 

305 

306 init - a datetime, default now 

307 step_min - a dict, keys are QtWidgets.QDateTimeEdit Sections, like 

308 QtWidgets.QDateTimeEdit.MinuteSection, and values are integers, 

309 the minimum amount that section of the date/time changes 

310 when you roll the mouse wheel. 

311 

312 E.g. (5 minute increments in minute field): 

313 

314 g.app.gui.runAskDateTimeDialog(c, 'When?', 

315 message="When is it?", 

316 step_min={QtWidgets.QDateTimeEdit.MinuteSection: 5}) 

317 

318 """ 

319 #@+<< define date/time classes >> 

320 #@+node:ekr.20211005103909.1: *5* << define date/time classes >> 

321 

322 

323 class DateTimeEditStepped(QtWidgets.QDateTimeEdit): # type:ignore 

324 """QDateTimeEdit which allows you to set minimum steps on fields, e.g. 

325 DateTimeEditStepped(parent, {QtWidgets.QDateTimeEdit.MinuteSection: 5}) 

326 for a minimum 5 minute increment on the minute field. 

327 """ 

328 

329 def __init__(self, parent=None, init=None, step_min=None): 

330 if step_min is None: 

331 step_min = {} 

332 self.step_min = step_min 

333 if init: 

334 super().__init__(init, parent) 

335 else: 

336 super().__init__(parent) 

337 

338 def stepBy(self, step): 

339 cs = self.currentSection() 

340 if cs in self.step_min and abs(step) < self.step_min[cs]: 

341 step = self.step_min[cs] if step > 0 else -self.step_min[cs] 

342 QtWidgets.QDateTimeEdit.stepBy(self, step) 

343 

344 

345 class Calendar(QtWidgets.QDialog): # type:ignore 

346 

347 def __init__(self, 

348 parent=None, 

349 message='Select Date/Time', 

350 init=None, 

351 step_min=None 

352 ): 

353 if step_min is None: 

354 step_min = {} 

355 super().__init__(parent) 

356 layout = QtWidgets.QVBoxLayout() 

357 self.setLayout(layout) 

358 layout.addWidget(QtWidgets.QLabel(message)) 

359 self.dt = DateTimeEditStepped(init=init, step_min=step_min) 

360 self.dt.setCalendarPopup(True) 

361 layout.addWidget(self.dt) 

362 buttonBox = QtWidgets.QDialogButtonBox(StandardButton.Ok | StandardButton.Cancel) 

363 layout.addWidget(buttonBox) 

364 buttonBox.accepted.connect(self.accept) 

365 buttonBox.rejected.connect(self.reject) 

366 

367 #@-<< define date/time classes >> 

368 if g.unitTesting: 

369 return None 

370 if step_min is None: 

371 step_min = {} 

372 if not init: 

373 init = datetime.datetime.now() 

374 dialog = Calendar(c and c.frame.top, message=message, init=init, step_min=step_min) 

375 if c: 

376 dialog.setStyleSheet(c.active_stylesheet) 

377 dialog.setWindowTitle(title) 

378 try: 

379 c.in_qt_dialog = True 

380 dialog.raise_() 

381 val = dialog.exec() if isQt6 else dialog.exec_() 

382 finally: 

383 c.in_qt_dialog = False 

384 else: 

385 dialog.setWindowTitle(title) 

386 dialog.raise_() 

387 val = dialog.exec() if isQt6 else dialog.exec_() 

388 if val == DialogCode.Accepted: 

389 return dialog.dt.dateTime().toPyDateTime() 

390 return None 

391 #@+node:ekr.20110605121601.18494: *4* qt_gui.runAskLeoIDDialog (not used) 

392 def runAskLeoIDDialog(self): 

393 """Create and run a dialog to get g.app.LeoID.""" 

394 if g.unitTesting: 

395 return None 

396 message = ( 

397 "leoID.txt not found\n\n" + 

398 "Please enter an id that identifies you uniquely.\n" + 

399 "Your cvs/bzr login name is a good choice.\n\n" + 

400 "Leo uses this id to uniquely identify nodes.\n\n" + 

401 "Your id must contain only letters and numbers\n" + 

402 "and must be at least 3 characters in length.") 

403 parent = None 

404 title = 'Enter Leo id' 

405 s, ok = QtWidgets.QInputDialog.getText(parent, title, message) 

406 return s 

407 #@+node:ekr.20110605121601.18491: *4* qt_gui.runAskOkCancelNumberDialog 

408 def runAskOkCancelNumberDialog( 

409 self, c, title, message, cancelButtonText=None, okButtonText=None): 

410 """Create and run askOkCancelNumber dialog .""" 

411 if g.unitTesting: 

412 return None 

413 # n,ok = QtWidgets.QInputDialog.getDouble(None,title,message) 

414 dialog = QtWidgets.QInputDialog() 

415 if c: 

416 dialog.setStyleSheet(c.active_stylesheet) 

417 dialog.setWindowTitle(title) 

418 dialog.setLabelText(message) 

419 if cancelButtonText: 

420 dialog.setCancelButtonText(cancelButtonText) 

421 if okButtonText: 

422 dialog.setOkButtonText(okButtonText) 

423 self.attachLeoIcon(dialog) 

424 dialog.raise_() 

425 ok = dialog.exec_() 

426 n = dialog.textValue() 

427 try: 

428 n = float(n) 

429 except ValueError: 

430 n = None 

431 return n if ok else None 

432 #@+node:ekr.20110605121601.18490: *4* qt_gui.runAskOkCancelStringDialog 

433 def runAskOkCancelStringDialog(self, c, title, message, cancelButtonText=None, 

434 okButtonText=None, default="", wide=False): 

435 """Create and run askOkCancelString dialog. 

436 

437 wide - edit a long string 

438 """ 

439 if g.unitTesting: 

440 return None 

441 dialog = QtWidgets.QInputDialog() 

442 if c: 

443 dialog.setStyleSheet(c.active_stylesheet) 

444 dialog.setWindowTitle(title) 

445 dialog.setLabelText(message) 

446 dialog.setTextValue(default) 

447 if wide: 

448 dialog.resize(int(g.windows()[0].get_window_info()[0] * .9), 100) 

449 if cancelButtonText: 

450 dialog.setCancelButtonText(cancelButtonText) 

451 if okButtonText: 

452 dialog.setOkButtonText(okButtonText) 

453 self.attachLeoIcon(dialog) 

454 dialog.raise_() 

455 ok = dialog.exec_() 

456 return str(dialog.textValue()) if ok else None 

457 #@+node:ekr.20110605121601.18495: *4* qt_gui.runAskOkDialog 

458 def runAskOkDialog(self, c, title, message=None, text="Ok"): 

459 """Create and run a qt askOK dialog .""" 

460 if g.unitTesting: 

461 return 

462 dialog = QtWidgets.QMessageBox(c and c.frame.top) 

463 stylesheet = getattr(c, 'active_stylesheet', None) 

464 if stylesheet: 

465 dialog.setStyleSheet(stylesheet) 

466 dialog.setWindowTitle(title) 

467 if message: 

468 dialog.setText(message) 

469 dialog.setIcon(Information.Information) 

470 dialog.addButton(text, ButtonRole.YesRole) 

471 try: 

472 c.in_qt_dialog = True 

473 dialog.raise_() 

474 dialog.exec_() 

475 finally: 

476 c.in_qt_dialog = False 

477 

478 #@+node:ekr.20110605121601.18497: *4* qt_gui.runAskYesNoCancelDialog 

479 def runAskYesNoCancelDialog(self, c, title, 

480 message=None, 

481 yesMessage="&Yes", 

482 noMessage="&No", 

483 yesToAllMessage=None, 

484 defaultButton="Yes", 

485 cancelMessage=None, 

486 ): 

487 """ 

488 Create and run an askYesNo dialog. 

489 

490 Return one of ('yes', 'no', 'cancel', 'yes-to-all'). 

491 

492 """ 

493 if g.unitTesting: 

494 return None 

495 dialog = QtWidgets.QMessageBox(c and c.frame.top) 

496 stylesheet = getattr(c, 'active_stylesheet', None) 

497 if stylesheet: 

498 dialog.setStyleSheet(stylesheet) 

499 if message: 

500 dialog.setText(message) 

501 dialog.setIcon(Information.Warning) 

502 dialog.setWindowTitle(title) 

503 # Creation order determines returned value. 

504 yes = dialog.addButton(yesMessage, ButtonRole.YesRole) 

505 no = dialog.addButton(noMessage, ButtonRole.NoRole) 

506 cancel = dialog.addButton(cancelMessage or 'Cancel', ButtonRole.RejectRole) 

507 if yesToAllMessage: 

508 dialog.addButton(yesToAllMessage, ButtonRole.YesRole) 

509 if defaultButton == "Yes": 

510 dialog.setDefaultButton(yes) 

511 elif defaultButton == "No": 

512 dialog.setDefaultButton(no) 

513 else: 

514 dialog.setDefaultButton(cancel) 

515 try: 

516 c.in_qt_dialog = True 

517 dialog.raise_() # #2246. 

518 val = dialog.exec() if isQt6 else dialog.exec_() 

519 finally: 

520 c.in_qt_dialog = False 

521 # val is the same as the creation order. 

522 # Tested with both Qt6 and Qt5. 

523 return { 

524 0: 'yes', 1: 'no', 2: 'cancel', 3: 'yes-to-all', 

525 }.get(val, 'cancel') 

526 #@+node:ekr.20110605121601.18498: *4* qt_gui.runAskYesNoDialog 

527 def runAskYesNoDialog(self, c, title, message=None, yes_all=False, no_all=False): 

528 """ 

529 Create and run an askYesNo dialog. 

530 Return one of ('yes','yes-all','no','no-all') 

531 

532 :Parameters: 

533 - `c`: commander 

534 - `title`: dialog title 

535 - `message`: dialog message 

536 - `yes_all`: bool - show YesToAll button 

537 - `no_all`: bool - show NoToAll button 

538 """ 

539 if g.unitTesting: 

540 return None 

541 dialog = QtWidgets.QMessageBox(c and c.frame.top) 

542 # Creation order determines returned value. 

543 yes = dialog.addButton('Yes', ButtonRole.YesRole) 

544 dialog.addButton('No', ButtonRole.NoRole) 

545 # dialog.addButton('Cancel', ButtonRole.RejectRole) 

546 if yes_all: 

547 dialog.addButton('Yes To All', ButtonRole.YesRole) 

548 if no_all: 

549 dialog.addButton('No To All', ButtonRole.NoRole) 

550 if c: 

551 dialog.setStyleSheet(c.active_stylesheet) 

552 dialog.setWindowTitle(title) 

553 if message: 

554 dialog.setText(message) 

555 dialog.setIcon(Information.Warning) 

556 dialog.setDefaultButton(yes) 

557 if c: 

558 try: 

559 c.in_qt_dialog = True 

560 dialog.raise_() 

561 val = dialog.exec() if isQt6 else dialog.exec_() 

562 finally: 

563 c.in_qt_dialog = False 

564 else: 

565 dialog.raise_() 

566 val = dialog.exec() if isQt6 else dialog.exec_() 

567 # val is the same as the creation order. 

568 # Tested with both Qt6 and Qt5. 

569 return { 

570 # Buglet: This assumes both yes-all and no-all buttons are active. 

571 0: 'yes', 1: 'no', 2: 'cancel', 3: 'yes-all', 4: 'no-all', 

572 }.get(val, 'cancel') 

573 #@+node:ekr.20110605121601.18499: *4* qt_gui.runOpenDirectoryDialog 

574 def runOpenDirectoryDialog(self, title, startdir): 

575 """Create and run an Qt open directory dialog .""" 

576 if g.unitTesting: 

577 return None 

578 dialog = QtWidgets.QFileDialog() 

579 self.attachLeoIcon(dialog) 

580 return dialog.getExistingDirectory(None, title, startdir) 

581 #@+node:ekr.20110605121601.18500: *4* qt_gui.runOpenFileDialog 

582 def runOpenFileDialog(self, c, 

583 title, 

584 filetypes, 

585 defaultextension='', 

586 multiple=False, 

587 startpath=None, 

588 ): 

589 """ 

590 Create and run an Qt open file dialog. 

591 """ 

592 # pylint: disable=arguments-differ 

593 if g.unitTesting: 

594 return '' 

595 # 

596 # 2018/03/14: Bug fixes: 

597 # - Use init_dialog_folder only if a path is not given 

598 # - *Never* Use os.curdir by default! 

599 if not startpath: 

600 startpath = g.init_dialog_folder(c, c and c.p, use_at_path=True) 

601 # Returns c.last_dir or os.curdir 

602 filter_ = self.makeFilter(filetypes) 

603 dialog = QtWidgets.QFileDialog() 

604 if c: 

605 dialog.setStyleSheet(c.active_stylesheet) 

606 self.attachLeoIcon(dialog) 

607 func = dialog.getOpenFileNames if multiple else dialog.getOpenFileName 

608 if c: 

609 try: 

610 c.in_qt_dialog = True 

611 val = func(parent=None, caption=title, directory=startpath, filter=filter_) 

612 finally: 

613 c.in_qt_dialog = False 

614 else: 

615 val = func(parent=None, caption=title, directory=startpath, filter=filter_) 

616 if isQt5 or isQt6: # this is a *Py*Qt change rather than a Qt change 

617 val, junk_selected_filter = val 

618 if multiple: 

619 files = [g.os_path_normslashes(s) for s in val] 

620 if c and files: 

621 c.last_dir = g.os_path_dirname(files[-1]) 

622 return files 

623 s = g.os_path_normslashes(val) 

624 if c and s: 

625 c.last_dir = g.os_path_dirname(s) 

626 return s 

627 #@+node:ekr.20110605121601.18501: *4* qt_gui.runPropertiesDialog 

628 def runPropertiesDialog(self, 

629 title='Properties', 

630 data=None, 

631 callback=None, 

632 buttons=None 

633 ): 

634 """Dispay a modal TkPropertiesDialog""" 

635 if not g.unitTesting: 

636 g.warning('Properties menu not supported for Qt gui') 

637 return 'Cancel', {} 

638 #@+node:ekr.20110605121601.18502: *4* qt_gui.runSaveFileDialog 

639 def runSaveFileDialog( 

640 self, c, title='Save', filetypes=None, defaultextension=''): 

641 """Create and run an Qt save file dialog .""" 

642 if g.unitTesting: 

643 return '' 

644 dialog = QtWidgets.QFileDialog() 

645 if c: 

646 dialog.setStyleSheet(c.active_stylesheet) 

647 self.attachLeoIcon(dialog) 

648 try: 

649 c.in_qt_dialog = True 

650 obj = dialog.getSaveFileName( 

651 None, # parent 

652 title, 

653 # os.curdir, 

654 g.init_dialog_folder(c, c.p, use_at_path=True), 

655 self.makeFilter(filetypes or []), 

656 ) 

657 finally: 

658 c.in_qt_dialog = False 

659 else: 

660 self.attachLeoIcon(dialog) 

661 obj = dialog.getSaveFileName( 

662 None, # parent 

663 title, 

664 # os.curdir, 

665 g.init_dialog_folder(None, None, use_at_path=True), 

666 self.makeFilter(filetypes or []), 

667 ) 

668 # Bizarre: PyQt5 version can return a tuple! 

669 s = obj[0] if isinstance(obj, (list, tuple)) else obj 

670 s = s or '' 

671 if c and s: 

672 c.last_dir = g.os_path_dirname(s) 

673 return s 

674 #@+node:ekr.20110605121601.18503: *4* qt_gui.runScrolledMessageDialog 

675 def runScrolledMessageDialog(self, 

676 short_title='', 

677 title='Message', 

678 label='', 

679 msg='', 

680 c=None, **keys 

681 ): 

682 if g.unitTesting: 

683 return None 

684 

685 def send(): 

686 return g.doHook('scrolledMessage', 

687 short_title=short_title, title=title, 

688 label=label, msg=msg, c=c, **keys) 

689 

690 if not c or not c.exists: 

691 #@+<< no c error>> 

692 #@+node:ekr.20110605121601.18504: *5* << no c error>> 

693 g.es_print_error('%s\n%s\n\t%s' % ( 

694 "The qt plugin requires calls to g.app.gui.scrolledMessageDialog to include 'c'", 

695 "as a keyword argument", 

696 g.callers() 

697 )) 

698 #@-<< no c error>> 

699 else: 

700 retval = send() 

701 if retval: 

702 return retval 

703 #@+<< load viewrendered plugin >> 

704 #@+node:ekr.20110605121601.18505: *5* << load viewrendered plugin >> 

705 pc = g.app.pluginsController 

706 # Load viewrendered (and call vr.onCreate) *only* if not already loaded. 

707 if ( 

708 not pc.isLoaded('viewrendered.py') 

709 and not pc.isLoaded('viewrendered3.py') 

710 ): 

711 vr = pc.loadOnePlugin('viewrendered.py') 

712 if vr: 

713 g.blue('viewrendered plugin loaded.') 

714 vr.onCreate('tag', {'c': c}) 

715 #@-<< load viewrendered plugin >> 

716 retval = send() 

717 if retval: 

718 return retval 

719 #@+<< no dialog error >> 

720 #@+node:ekr.20110605121601.18506: *5* << no dialog error >> 

721 g.es_print_error( 

722 f'No handler for the "scrolledMessage" hook.\n\t{g.callers()}') 

723 #@-<< no dialog error >> 

724 #@+<< emergency fallback >> 

725 #@+node:ekr.20110605121601.18507: *5* << emergency fallback >> 

726 dialog = QtWidgets.QMessageBox(None) 

727 dialog.setWindowFlags(WindowType.Dialog) 

728 # That is, not a fixed size dialog. 

729 dialog.setWindowTitle(title) 

730 if msg: 

731 dialog.setText(msg) 

732 dialog.setIcon(Icon.Information) 

733 dialog.addButton('Ok', ButtonRole.YesRole) 

734 try: 

735 c.in_qt_dialog = True 

736 if isQt6: 

737 dialog.exec() 

738 else: 

739 dialog.exec_() 

740 finally: 

741 c.in_qt_dialog = False 

742 #@-<< emergency fallback >> 

743 #@+node:ekr.20110607182447.16456: *3* qt_gui.Event handlers 

744 #@+node:ekr.20190824094650.1: *4* qt_gui.close_event 

745 def close_event(self, event): 

746 

747 noclose = False 

748 if g.app.sessionManager and g.app.loaded_session: 

749 g.app.sessionManager.save_snapshot() 

750 for c in g.app.commanders(): 

751 allow = c.exists and g.app.closeLeoWindow(c.frame) 

752 if not allow: 

753 noclose = True 

754 if noclose: 

755 event.ignore() 

756 else: 

757 event.accept() 

758 #@+node:ekr.20110605121601.18481: *4* qt_gui.onDeactiveEvent 

759 # deactivated_name = '' 

760 

761 deactivated_widget = None 

762 

763 def onDeactivateEvent(self, event, c, obj, tag): 

764 """ 

765 Gracefully deactivate the Leo window. 

766 Called several times for each window activation. 

767 """ 

768 w = self.get_focus() 

769 w_name = w and w.objectName() 

770 if 'focus' in g.app.debug: 

771 g.trace(repr(w_name)) 

772 self.active = False 

773 # Used only by c.idle_focus_helper. 

774 # 

775 # Careful: never save headline widgets. 

776 if w_name == 'headline': 

777 self.deactivated_widget = c.frame.tree.treeWidget 

778 else: 

779 self.deactivated_widget = w if w_name else None 

780 # 

781 # Causes problems elsewhere... 

782 # if c.exists and not self.deactivated_name: 

783 # self.deactivated_name = self.widget_name(self.get_focus()) 

784 # self.active = False 

785 # c.k.keyboardQuit(setFocus=False) 

786 g.doHook('deactivate', c=c, p=c.p, v=c.p, event=event) 

787 #@+node:ekr.20110605121601.18480: *4* qt_gui.onActivateEvent 

788 # Called from eventFilter 

789 

790 def onActivateEvent(self, event, c, obj, tag): 

791 """ 

792 Restore the focus when the Leo window is activated. 

793 Called several times for each window activation. 

794 """ 

795 trace = 'focus' in g.app.debug 

796 w = self.get_focus() or self.deactivated_widget 

797 self.deactivated_widget = None 

798 w_name = w and w.objectName() 

799 # Fix #270: Vim keys don't always work after double Alt+Tab. 

800 # Fix #359: Leo hangs in LeoQtEventFilter.eventFilter 

801 # #1273: add teest on c.vim_mode. 

802 if c.exists and c.vim_mode and c.vimCommands and not self.active and not g.app.killed: 

803 c.vimCommands.on_activate() 

804 self.active = True 

805 # Used only by c.idle_focus_helper. 

806 if g.isMac: 

807 pass # Fix #757: MacOS: replace-then-find does not work in headlines. 

808 else: 

809 # Leo 5.6: Recover from missing focus. 

810 # c.idle_focus_handler can't do this. 

811 if w and w_name in ('log-widget', 'richTextEdit', 'treeWidget'): 

812 # Restore focus **only** to body or tree 

813 if trace: 

814 g.trace('==>', w_name) 

815 c.widgetWantsFocusNow(w) 

816 else: 

817 if trace: 

818 g.trace(repr(w_name), '==> BODY') 

819 c.bodyWantsFocusNow() 

820 # Cause problems elsewhere. 

821 # if c.exists and self.deactivated_name: 

822 # self.active = True 

823 # w_name = self.deactivated_name 

824 # self.deactivated_name = None 

825 # if c.p.v: 

826 # c.p.v.restoreCursorAndScroll() 

827 # if w_name.startswith('tree') or w_name.startswith('head'): 

828 # c.treeWantsFocusNow() 

829 # else: 

830 # c.bodyWantsFocusNow() 

831 g.doHook('activate', c=c, p=c.p, v=c.p, event=event) 

832 #@+node:ekr.20130921043420.21175: *4* qt_gui.setFilter 

833 # w's type is in (DynamicWindow,QMinibufferWrapper,LeoQtLog,LeoQtTree, 

834 # QTextEditWrapper,LeoQTextBrowser,LeoQuickSearchWidget,cleoQtUI) 

835 

836 def setFilter(self, c, obj, w, tag): 

837 """ 

838 Create an event filter in obj. 

839 w is a wrapper object, not necessarily a QWidget. 

840 """ 

841 # gui = self 

842 assert isinstance(obj, QtWidgets.QWidget), obj 

843 theFilter = qt_events.LeoQtEventFilter(c, w=w, tag=tag) 

844 obj.installEventFilter(theFilter) 

845 w.ev_filter = theFilter 

846 # Set the official ivar in w. 

847 #@+node:ekr.20110605121601.18508: *3* qt_gui.Focus 

848 #@+node:ekr.20190601055031.1: *4* qt_gui.ensure_commander_visible 

849 def ensure_commander_visible(self, c1): 

850 """ 

851 Check to see if c.frame is in a tabbed ui, and if so, make sure 

852 the tab is visible 

853 """ 

854 # pylint: disable=arguments-differ 

855 # 

856 # START: copy from Code-->Startup & external files--> 

857 # @file runLeo.py -->run & helpers-->doPostPluginsInit & helpers (runLeo.py) 

858 # For the qt gui, select the first-loaded tab. 

859 if 'focus' in g.app.debug: 

860 g.trace(c1) 

861 if hasattr(g.app.gui, 'frameFactory'): 

862 factory = g.app.gui.frameFactory 

863 if factory and hasattr(factory, 'setTabForCommander'): 

864 c = c1 

865 factory.setTabForCommander(c) 

866 c.bodyWantsFocusNow() 

867 # END: copy 

868 #@+node:ekr.20190601054958.1: *4* qt_gui.get_focus 

869 def get_focus(self, c=None, raw=False, at_idle=False): 

870 """Returns the widget that has focus.""" 

871 # pylint: disable=arguments-differ 

872 trace = 'focus' in g.app.debug 

873 trace_idle = False 

874 trace = trace and (trace_idle or not at_idle) 

875 app = QtWidgets.QApplication 

876 w = app.focusWidget() 

877 if w and not raw and isinstance(w, qt_text.LeoQTextBrowser): 

878 has_w = getattr(w, 'leo_wrapper', None) 

879 if has_w: 

880 if trace: 

881 g.trace(w) 

882 elif c: 

883 # Kludge: DynamicWindow creates the body pane 

884 # with wrapper = None, so return the LeoQtBody. 

885 w = c.frame.body 

886 if trace: 

887 name = w.objectName() if hasattr(w, 'objectName') else w.__class__.__name__ 

888 g.trace('(LeoQtGui)', name) 

889 return w 

890 #@+node:ekr.20190601054959.1: *4* qt_gui.set_focus 

891 def set_focus(self, c, w): 

892 """Put the focus on the widget.""" 

893 # pylint: disable=arguments-differ 

894 if not w: 

895 return 

896 if getattr(w, 'widget', None): 

897 if not isinstance(w, QtWidgets.QWidget): 

898 # w should be a wrapper. 

899 w = w.widget 

900 if 'focus' in g.app.debug: 

901 name = w.objectName() if hasattr(w, 'objectName') else w.__class__.__name__ 

902 g.trace('(LeoQtGui)', name) 

903 w.setFocus() 

904 #@+node:ekr.20110605121601.18510: *3* qt_gui.getFontFromParams 

905 size_warnings: List[str] = [] 

906 

907 def getFontFromParams(self, family, size, slant, weight, defaultSize=12): 

908 """Required to handle syntax coloring.""" 

909 if isinstance(size, str): 

910 if size.endswith('pt'): 

911 size = size[:-2].strip() 

912 elif size.endswith('px'): 

913 if size not in self.size_warnings: 

914 self.size_warnings.append(size) 

915 g.es(f"px ignored in font setting: {size}") 

916 size = size[:-2].strip() 

917 try: 

918 size = int(size) 

919 except Exception: 

920 size = 0 

921 if size < 1: 

922 size = defaultSize 

923 d = { 

924 'black': Weight.Black, 

925 'bold': Weight.Bold, 

926 'demibold': Weight.DemiBold, 

927 'light': Weight.Light, 

928 'normal': Weight.Normal, 

929 } 

930 weight_val = d.get(weight.lower(), Weight.Normal) 

931 italic = slant == 'italic' 

932 if not family: 

933 family = g.app.config.defaultFontFamily 

934 if not family: 

935 family = 'DejaVu Sans Mono' 

936 try: 

937 font = QtGui.QFont(family, size, weight_val, italic) 

938 if sys.platform.startswith('linux'): 

939 font.setHintingPreference(font.PreferFullHinting) 

940 # g.es(font,font.hintingPreference()) 

941 return font 

942 except Exception: 

943 g.es("exception setting font", g.callers(4)) 

944 g.es( 

945 f"family: {family}\n" 

946 f" size: {size}\n" 

947 f" slant: {slant}\n" 

948 f"weight: {weight}") 

949 # g.es_exception() # This just confuses people. 

950 return g.app.config.defaultFont 

951 #@+node:ekr.20110605121601.18511: *3* qt_gui.getFullVersion 

952 def getFullVersion(self, c=None): 

953 """Return the PyQt version (for signon)""" 

954 try: 

955 qtLevel = f"version {QtCore.QT_VERSION_STR}" 

956 except Exception: 

957 # g.es_exception() 

958 qtLevel = '<qtLevel>' 

959 return f"PyQt {qtLevel}" 

960 #@+node:ekr.20110605121601.18514: *3* qt_gui.Icons 

961 #@+node:ekr.20110605121601.18515: *4* qt_gui.attachLeoIcon 

962 def attachLeoIcon(self, window): 

963 """Attach a Leo icon to the window.""" 

964 #icon = self.getIconImage('leoApp.ico') 

965 if self.appIcon: 

966 window.setWindowIcon(self.appIcon) 

967 #@+node:ekr.20110605121601.18516: *4* qt_gui.getIconImage 

968 def getIconImage(self, name): 

969 """Load the icon and return it.""" 

970 # Return the image from the cache if possible. 

971 if name in self.iconimages: 

972 image = self.iconimages.get(name) 

973 return image 

974 try: 

975 iconsDir = g.os_path_join(g.app.loadDir, "..", "Icons") 

976 homeIconsDir = g.os_path_join(g.app.homeLeoDir, "Icons") 

977 for theDir in (homeIconsDir, iconsDir): 

978 fullname = g.os_path_finalize_join(theDir, name) 

979 if g.os_path_exists(fullname): 

980 if 0: # Not needed: use QTreeWidget.setIconsize. 

981 pixmap = QtGui.QPixmap() 

982 pixmap.load(fullname) 

983 image = QtGui.QIcon(pixmap) 

984 else: 

985 image = QtGui.QIcon(fullname) 

986 self.iconimages[name] = image 

987 return image 

988 # No image found. 

989 return None 

990 except Exception: 

991 g.es_print("exception loading:", fullname) 

992 g.es_exception() 

993 return None 

994 #@+node:ekr.20110605121601.18517: *4* qt_gui.getImageImage 

995 @functools.lru_cache(maxsize=128) 

996 def getImageImage(self, name): 

997 """Load the image in file named `name` and return it.""" 

998 fullname = self.getImageFinder(name) 

999 try: 

1000 pixmap = QtGui.QPixmap() 

1001 pixmap.load(fullname) 

1002 return pixmap 

1003 except Exception: 

1004 g.es("exception loading:", name) 

1005 g.es_exception() 

1006 return None 

1007 #@+node:tbrown.20130316075512.28478: *4* qt_gui.getImageFinder 

1008 dump_given = False 

1009 @functools.lru_cache(maxsize=128) 

1010 def getImageFinder(self, name): 

1011 """Theme aware image (icon) path searching.""" 

1012 trace = 'themes' in g.app.debug 

1013 exists = g.os_path_exists 

1014 getString = g.app.config.getString 

1015 

1016 def dump(var, val): 

1017 print(f"{var:20}: {val}") 

1018 

1019 join = g.os_path_join 

1020 # 

1021 # "Just works" for --theme and theme .leo files *provided* that 

1022 # theme .leo files actually contain these settings! 

1023 # 

1024 theme_name1 = getString('color-theme') 

1025 theme_name2 = getString('theme-name') 

1026 roots = [ 

1027 g.os_path_join(g.computeHomeDir(), '.leo'), 

1028 g.computeLeoDir(), 

1029 ] 

1030 theme_subs = [ 

1031 "themes/{theme}/Icons", 

1032 "themes/{theme}", 

1033 "Icons/{theme}", 

1034 ] 

1035 bare_subs = ["Icons", "."] 

1036 # "." for icons referred to as Icons/blah/blah.png 

1037 paths = [] 

1038 for theme_name in (theme_name1, theme_name2): 

1039 for root in roots: 

1040 for sub in theme_subs: 

1041 paths.append(join(root, sub.format(theme=theme_name))) 

1042 for root in roots: 

1043 for sub in bare_subs: 

1044 paths.append(join(root, sub)) 

1045 table = [z for z in paths if exists(z)] 

1046 for base_dir in table: 

1047 path = join(base_dir, name) 

1048 if exists(path): 

1049 if trace: 

1050 g.trace(f"Found {name} in {base_dir}") 

1051 return path 

1052 # if trace: g.trace(name, 'not in', base_dir) 

1053 if trace: 

1054 g.trace('not found:', name) 

1055 return None 

1056 #@+node:ekr.20110605121601.18518: *4* qt_gui.getTreeImage 

1057 @functools.lru_cache(maxsize=128) 

1058 def getTreeImage(self, c, path): 

1059 image = QtGui.QPixmap(path) 

1060 if image.height() > 0 and image.width() > 0: 

1061 return image, image.height() 

1062 return None, None 

1063 #@+node:ekr.20131007055150.17608: *3* qt_gui.insertKeyEvent 

1064 def insertKeyEvent(self, event, i): 

1065 """Insert the key given by event in location i of widget event.w.""" 

1066 assert isinstance(event, leoGui.LeoKeyEvent) 

1067 qevent = event.event 

1068 assert isinstance(qevent, QtGui.QKeyEvent) 

1069 qw = getattr(event.w, 'widget', None) 

1070 if qw and isinstance(qw, QtWidgets.QTextEdit): 

1071 if 1: 

1072 # Assume that qevent.text() *is* the desired text. 

1073 # This means we don't have to hack eventFilter. 

1074 qw.insertPlainText(qevent.text()) 

1075 else: 

1076 # Make no such assumption. 

1077 # We would like to use qevent to insert the character, 

1078 # but this would invoke eventFilter again! 

1079 # So set this flag for eventFilter, which will 

1080 # return False, indicating that the widget must handle 

1081 # qevent, which *presumably* is the best that can be done. 

1082 g.app.gui.insert_char_flag = True 

1083 #@+node:ekr.20190819072045.1: *3* qt_gui.make_main_window 

1084 def make_main_window(self): 

1085 """Make the *singleton* QMainWindow.""" 

1086 window = QtWidgets.QMainWindow() 

1087 window.setObjectName('LeoGlobalMainWindow') 

1088 # Calling window.show() here causes flash. 

1089 self.attachLeoIcon(window) 

1090 # Monkey-patch 

1091 window.closeEvent = self.close_event 

1092 # Use self: g.app.gui does not exist yet. 

1093 self.runAtIdle(self.set_main_window_style_sheet) 

1094 # No StyleSheetManager exists yet. 

1095 return window 

1096 

1097 def set_main_window_style_sheet(self): 

1098 """Style the main window, using the first .leo file.""" 

1099 commanders = g.app.commanders() 

1100 if commanders: 

1101 c = commanders[0] 

1102 ssm = c.styleSheetManager 

1103 ssm.set_style_sheets(w=self.main_window) 

1104 self.main_window.setWindowTitle(c.frame.title) # #1506. 

1105 else: 

1106 g.trace("No open commanders!") 

1107 #@+node:ekr.20110605121601.18528: *3* qt_gui.makeScriptButton 

1108 def makeScriptButton(self, c, 

1109 args=None, 

1110 p=None, # A node containing the script. 

1111 script=None, # The script itself. 

1112 buttonText=None, 

1113 balloonText='Script Button', 

1114 shortcut=None, bg='LightSteelBlue1', 

1115 define_g=True, define_name='__main__', silent=False, # Passed on to c.executeScript. 

1116 ): 

1117 """ 

1118 Create a script button for the script in node p. 

1119 The button's text defaults to p.headString.""" 

1120 k = c.k 

1121 if p and not buttonText: 

1122 buttonText = p.h.strip() 

1123 if not buttonText: 

1124 buttonText = 'Unnamed Script Button' 

1125 #@+<< create the button b >> 

1126 #@+node:ekr.20110605121601.18529: *4* << create the button b >> 

1127 iconBar = c.frame.getIconBarObject() 

1128 b = iconBar.add(text=buttonText) 

1129 #@-<< create the button b >> 

1130 #@+<< define the callbacks for b >> 

1131 #@+node:ekr.20110605121601.18530: *4* << define the callbacks for b >> 

1132 def deleteButtonCallback(event=None, b=b, c=c): 

1133 if b: 

1134 b.pack_forget() 

1135 c.bodyWantsFocus() 

1136 

1137 def executeScriptCallback(event=None, 

1138 b=b, 

1139 c=c, 

1140 buttonText=buttonText, 

1141 p=p and p.copy(), 

1142 script=script 

1143 ): 

1144 if c.disableCommandsMessage: 

1145 g.blue('', c.disableCommandsMessage) 

1146 else: 

1147 g.app.scriptDict = {'script_gnx': p.gnx} 

1148 c.executeScript(args=args, p=p, script=script, 

1149 define_g=define_g, define_name=define_name, silent=silent) 

1150 # Remove the button if the script asks to be removed. 

1151 if g.app.scriptDict.get('removeMe'): 

1152 g.es('removing', f"'{buttonText}'", 'button at its request') 

1153 b.pack_forget() 

1154 # Do not assume the script will want to remain in this commander. 

1155 

1156 #@-<< define the callbacks for b >> 

1157 

1158 b.configure(command=executeScriptCallback) 

1159 if shortcut: 

1160 #@+<< bind the shortcut to executeScriptCallback >> 

1161 #@+node:ekr.20110605121601.18531: *4* << bind the shortcut to executeScriptCallback >> 

1162 # In qt_gui.makeScriptButton. 

1163 func = executeScriptCallback 

1164 if shortcut: 

1165 shortcut = g.KeyStroke(shortcut) 

1166 ok = k.bindKey('button', shortcut, func, buttonText) 

1167 if ok: 

1168 g.blue('bound @button', buttonText, 'to', shortcut) 

1169 #@-<< bind the shortcut to executeScriptCallback >> 

1170 #@+<< create press-buttonText-button command >> 

1171 #@+node:ekr.20110605121601.18532: *4* << create press-buttonText-button command >> qt_gui.makeScriptButton 

1172 # #1121. Like sc.cleanButtonText 

1173 buttonCommandName = f"press-{buttonText.replace(' ', '-').strip('-')}-button" 

1174 # 

1175 # This will use any shortcut defined in an @shortcuts node. 

1176 k.registerCommand(buttonCommandName, executeScriptCallback, pane='button') 

1177 #@-<< create press-buttonText-button command >> 

1178 #@+node:ekr.20170612065255.1: *3* qt_gui.put_help 

1179 def put_help(self, c, s, short_title=''): 

1180 """Put the help command.""" 

1181 s = textwrap.dedent(s.rstrip()) 

1182 if s.startswith('<') and not s.startswith('<<'): 

1183 pass # how to do selective replace?? 

1184 pc = g.app.pluginsController 

1185 table = ( 

1186 'viewrendered3.py', 

1187 'viewrendered.py', 

1188 ) 

1189 for name in table: 

1190 if pc.isLoaded(name): 

1191 vr = pc.loadOnePlugin(name) 

1192 break 

1193 else: 

1194 vr = pc.loadOnePlugin('viewrendered.py') 

1195 if vr: 

1196 kw = { 

1197 'c': c, 

1198 'flags': 'rst', 

1199 'kind': 'rst', 

1200 'label': '', 

1201 'msg': s, 

1202 'name': 'Apropos', 

1203 'short_title': short_title, 

1204 'title': ''} 

1205 vr.show_scrolled_message(tag='Apropos', kw=kw) 

1206 c.bodyWantsFocus() 

1207 if g.unitTesting: 

1208 vr.close_rendering_pane(event={'c': c}) 

1209 elif g.unitTesting: 

1210 pass 

1211 else: 

1212 g.es(s) 

1213 return vr # For unit tests 

1214 #@+node:ekr.20110605121601.18521: *3* qt_gui.runAtIdle 

1215 def runAtIdle(self, aFunc): 

1216 """This can not be called in some contexts.""" 

1217 QtCore.QTimer.singleShot(0, aFunc) 

1218 #@+node:ekr.20110605121601.18483: *3* qt_gui.runMainLoop & runWithIpythonKernel 

1219 #@+node:ekr.20130930062914.16000: *4* qt_gui.runMainLoop 

1220 def runMainLoop(self): 

1221 """Start the Qt main loop.""" 

1222 try: # #2127: A crash here hard-crashes Leo: There is no main loop! 

1223 g.app.gui.dismiss_splash_screen() 

1224 c = g.app.log and g.app.log.c 

1225 if c and c.config.getBool('show-tips', default=False): 

1226 g.app.gui.show_tips(c) 

1227 except Exception: 

1228 g.es_exception() 

1229 if self.script: 

1230 log = g.app.log 

1231 if log: 

1232 g.pr('Start of batch script...\n') 

1233 log.c.executeScript(script=self.script) 

1234 g.pr('End of batch script') 

1235 else: 

1236 g.pr('no log, no commander for executeScript in LeoQtGui.runMainLoop') 

1237 elif g.app.useIpython and g.app.ipython_inited: 

1238 self.runWithIpythonKernel() 

1239 else: 

1240 # This can be alarming when using Python's -i option. 

1241 if isQt6: 

1242 sys.exit(self.qtApp.exec()) 

1243 else: 

1244 sys.exit(self.qtApp.exec_()) 

1245 #@+node:ekr.20130930062914.16001: *4* qt_gui.runWithIpythonKernel (commands) 

1246 def runWithIpythonKernel(self): 

1247 """Init Leo to run in an IPython shell.""" 

1248 try: 

1249 from leo.core import leoIPython 

1250 g.app.ipk = leoIPython.InternalIPKernel() 

1251 g.app.ipk.run() 

1252 except Exception: 

1253 g.es_exception() 

1254 print('can not init leo.core.leoIPython.py') 

1255 sys.exit(1) 

1256 #@+node:ekr.20200304125716.1: *3* qt_gui.onContextMenu 

1257 def onContextMenu(self, c, w, point): 

1258 """LeoQtGui: Common context menu handling.""" 

1259 # #1286. 

1260 handlers = g.tree_popup_handlers 

1261 menu = QtWidgets.QMenu(c.frame.top) # #1995. 

1262 menuPos = w.mapToGlobal(point) 

1263 if not handlers: 

1264 menu.addAction("No popup handlers") 

1265 p = c.p.copy() 

1266 done = set() 

1267 for handler in handlers: 

1268 # every handler has to add it's QActions by itself 

1269 if handler in done: 

1270 # do not run the same handler twice 

1271 continue 

1272 try: 

1273 handler(c, p, menu) 

1274 except Exception: 

1275 g.es_print('Exception executing right-click handler') 

1276 g.es_exception() 

1277 menu.popup(menuPos) 

1278 self._contextmenu = menu 

1279 #@+node:ekr.20190822174038.1: *3* qt_gui.set_top_geometry 

1280 already_sized = False 

1281 

1282 def set_top_geometry(self, w, h, x, y): 

1283 """Set the geometry of the main window.""" 

1284 if 'size' in g.app.debug: 

1285 g.trace('(qt_gui) already_sized', self.already_sized, w, h, x, y) 

1286 if not self.already_sized: 

1287 self.already_sized = True 

1288 self.main_window.setGeometry(QtCore.QRect(x, y, w, h)) 

1289 #@+node:ekr.20180117053546.1: *3* qt_gui.show_tips & helpers 

1290 @g.command('show-tips') 

1291 def show_next_tip(self, event=None): 

1292 c = g.app.log and g.app.log.c 

1293 if c: 

1294 g.app.gui.show_tips(c) 

1295 

1296 #@+<< define DialogWithCheckBox >> 

1297 #@+node:ekr.20220123052350.1: *4* << define DialogWithCheckBox >> 

1298 class DialogWithCheckBox(QtWidgets.QMessageBox): # type:ignore 

1299 

1300 def __init__(self, controller, checked, tip): 

1301 super().__init__() 

1302 c = g.app.log.c 

1303 self.leo_checked = True 

1304 self.setObjectName('TipMessageBox') 

1305 self.setIcon(Icon.Information) # #2127. 

1306 # self.setMinimumSize(5000, 4000) 

1307 # Doesn't work. 

1308 # Prevent the dialog from jumping around when 

1309 # selecting multiple tips. 

1310 self.setWindowTitle('Leo Tips') 

1311 self.setText(repr(tip)) 

1312 self.next_tip_button = self.addButton('Show Next Tip', ButtonRole.ActionRole) # #2127 

1313 self.addButton('Ok', ButtonRole.YesRole) # #2127. 

1314 c.styleSheetManager.set_style_sheets(w=self) 

1315 # Workaround #693. 

1316 layout = self.layout() 

1317 cb = QtWidgets.QCheckBox() 

1318 cb.setObjectName('TipCheckbox') 

1319 cb.setText('Show Tip On Startup') 

1320 state = QtConst.CheckState.Checked if checked else QtConst.CheckState.Unchecked # #2383 

1321 cb.setCheckState(state) # #2127. 

1322 cb.stateChanged.connect(controller.onClick) 

1323 layout.addWidget(cb, 4, 0, -1, -1) 

1324 if 0: # Does not work well. 

1325 sizePolicy = QtWidgets.QSizePolicy 

1326 vSpacer = QtWidgets.QSpacerItem( 

1327 200, 200, sizePolicy.Minimum, sizePolicy.Expanding) 

1328 layout.addItem(vSpacer) 

1329 #@-<< define DialogWithCheckBox >> 

1330 

1331 def show_tips(self, c): 

1332 if g.unitTesting: 

1333 return 

1334 from leo.core import leoTips 

1335 tm = leoTips.TipManager() 

1336 self.show_tips_flag = c.config.getBool('show-tips', default=False) # 2390. 

1337 while True: # QMessageBox is always a modal dialog. 

1338 tip = tm.get_next_tip() 

1339 m = self.DialogWithCheckBox(controller=self, checked=self.show_tips_flag, tip=tip) 

1340 try: 

1341 c.in_qt_dialog = True 

1342 m.exec_() 

1343 finally: 

1344 c.in_qt_dialog = False 

1345 b = m.clickedButton() 

1346 if b != m.next_tip_button: 

1347 break 

1348 

1349 #@+node:ekr.20180117080131.1: *4* onButton (not used) 

1350 def onButton(self, m): 

1351 m.hide() 

1352 #@+node:ekr.20180117073603.1: *4* onClick 

1353 def onClick(self, state): 

1354 c = g.app.log.c 

1355 self.show_tips_flag = bool(state) 

1356 if c: # #2390: The setting *has* changed. 

1357 c.config.setUserSetting('@bool show-tips', self.show_tips_flag) 

1358 c.redraw() # #2390: Show the change immediately. 

1359 #@+node:ekr.20180127103142.1: *4* onNext (not used) 

1360 def onNext(self, *args, **keys): 

1361 g.trace(args, keys) 

1362 return True 

1363 #@+node:ekr.20111215193352.10220: *3* qt_gui.Splash Screen 

1364 #@+node:ekr.20110605121601.18479: *4* qt_gui.createSplashScreen 

1365 def createSplashScreen(self): 

1366 """Put up a splash screen with the Leo logo.""" 

1367 splash = None 

1368 if sys.platform.startswith('win'): 

1369 table = ('SplashScreen.jpg', 'SplashScreen.png', 'SplashScreen.ico') 

1370 else: 

1371 table = ('SplashScreen.xpm',) 

1372 for name in table: 

1373 fn = g.os_path_finalize_join(g.app.loadDir, '..', 'Icons', name) 

1374 if g.os_path_exists(fn): 

1375 pm = QtGui.QPixmap(fn) 

1376 if not pm.isNull(): 

1377 splash = QtWidgets.QSplashScreen(pm, WindowType.WindowStaysOnTopHint) 

1378 splash.show() 

1379 # This sleep is required to do the repaint. 

1380 QtCore.QThread.msleep(10) 

1381 splash.repaint() 

1382 break 

1383 return splash 

1384 #@+node:ekr.20110613103140.16424: *4* qt_gui.dismiss_splash_screen 

1385 def dismiss_splash_screen(self): 

1386 

1387 gui = self 

1388 # Warning: closing the splash screen must be done in the main thread! 

1389 if g.unitTesting: 

1390 return 

1391 if gui.splashScreen: 

1392 gui.splashScreen.hide() 

1393 # gui.splashScreen.deleteLater() 

1394 gui.splashScreen = None 

1395 #@+node:ekr.20140825042850.18411: *3* qt_gui.Utils... 

1396 #@+node:ekr.20110605121601.18522: *4* qt_gui.isTextWidget/Wrapper 

1397 def isTextWidget(self, w): 

1398 """Return True if w is some kind of Qt text widget.""" 

1399 if Qsci: 

1400 return isinstance(w, (Qsci.QsciScintilla, QtWidgets.QTextEdit)), w 

1401 return isinstance(w, QtWidgets.QTextEdit), w 

1402 

1403 def isTextWrapper(self, w): 

1404 """Return True if w is a Text widget suitable for text-oriented commands.""" 

1405 if w is None: 

1406 return False 

1407 if isinstance(w, (g.NullObject, g.TracingNullObject)): 

1408 return True 

1409 return getattr(w, 'supportsHighLevelInterface', None) 

1410 #@+node:ekr.20110605121601.18527: *4* qt_gui.widget_name 

1411 def widget_name(self, w): 

1412 # First try the widget's getName method. 

1413 if not 'w': 

1414 name = '<no widget>' 

1415 elif hasattr(w, 'getName'): 

1416 name = w.getName() 

1417 elif hasattr(w, 'objectName'): 

1418 name = str(w.objectName()) 

1419 elif hasattr(w, '_name'): 

1420 name = w._name 

1421 else: 

1422 name = repr(w) 

1423 return name 

1424 #@+node:ekr.20111027083744.16532: *4* qt_gui.enableSignalDebugging 

1425 if isQt5: 

1426 # pylint: disable=no-name-in-module 

1427 # To do: https://doc.qt.io/qt-5/qsignalspy.html 

1428 from PyQt5.QtTest import QSignalSpy 

1429 assert QSignalSpy 

1430 elif isQt6: 

1431 # pylint: disable=c-extension-no-member,no-name-in-module 

1432 import PyQt6.QtTest as QtTest 

1433 # mypy complains about assigning to a type. 

1434 QSignalSpy = QtTest.QSignalSpy # type:ignore 

1435 assert QSignalSpy 

1436 else: 

1437 # enableSignalDebugging(emitCall=foo) and spy your signals until you're sick to your stomach. 

1438 _oldConnect = QtCore.QObject.connect 

1439 _oldDisconnect = QtCore.QObject.disconnect 

1440 _oldEmit = QtCore.QObject.emit 

1441 

1442 def _wrapConnect(self, callableObject): 

1443 """Returns a wrapped call to the old version of QtCore.QObject.connect""" 

1444 

1445 @staticmethod # type:ignore 

1446 def call(*args): 

1447 callableObject(*args) 

1448 self._oldConnect(*args) 

1449 

1450 return call 

1451 

1452 def _wrapDisconnect(self, callableObject): 

1453 """Returns a wrapped call to the old version of QtCore.QObject.disconnect""" 

1454 

1455 @staticmethod # type:ignore 

1456 def call(*args): 

1457 callableObject(*args) 

1458 self._oldDisconnect(*args) 

1459 

1460 return call 

1461 

1462 def enableSignalDebugging(self, **kwargs): 

1463 """Call this to enable Qt Signal debugging. This will trap all 

1464 connect, and disconnect calls.""" 

1465 f = lambda * args: None 

1466 connectCall = kwargs.get('connectCall', f) 

1467 disconnectCall = kwargs.get('disconnectCall', f) 

1468 emitCall = kwargs.get('emitCall', f) 

1469 

1470 def printIt(msg): 

1471 

1472 def call(*args): 

1473 print(msg, args) 

1474 

1475 return call 

1476 

1477 # Monkey-patch. 

1478 

1479 QtCore.QObject.connect = self._wrapConnect(connectCall) 

1480 QtCore.QObject.disconnect = self._wrapDisconnect(disconnectCall) 

1481 

1482 def new_emit(self, *args): 

1483 emitCall(self, *args) 

1484 self._oldEmit(self, *args) 

1485 

1486 QtCore.QObject.emit = new_emit 

1487 #@+node:ekr.20190819091957.1: *3* qt_gui.Widgets... 

1488 #@+node:ekr.20190819094016.1: *4* qt_gui.createButton 

1489 def createButton(self, parent, name, label): 

1490 w = QtWidgets.QPushButton(parent) 

1491 w.setObjectName(name) 

1492 w.setText(label) 

1493 return w 

1494 #@+node:ekr.20190819091122.1: *4* qt_gui.createFrame 

1495 def createFrame(self, parent, name, 

1496 hPolicy=None, vPolicy=None, 

1497 lineWidth=1, 

1498 shadow=None, 

1499 shape=None, 

1500 ): 

1501 """Create a Qt Frame.""" 

1502 if shadow is None: 

1503 shadow = Shadow.Plain 

1504 if shape is None: 

1505 shape = Shape.NoFrame 

1506 # 

1507 w = QtWidgets.QFrame(parent) 

1508 self.setSizePolicy(w, kind1=hPolicy, kind2=vPolicy) 

1509 w.setFrameShape(shape) 

1510 w.setFrameShadow(shadow) 

1511 w.setLineWidth(lineWidth) 

1512 w.setObjectName(name) 

1513 return w 

1514 #@+node:ekr.20190819091851.1: *4* qt_gui.createGrid 

1515 def createGrid(self, parent, name, margin=0, spacing=0): 

1516 w = QtWidgets.QGridLayout(parent) 

1517 w.setContentsMargins(QtCore.QMargins(margin, margin, margin, margin)) 

1518 w.setSpacing(spacing) 

1519 w.setObjectName(name) 

1520 return w 

1521 #@+node:ekr.20190819093830.1: *4* qt_gui.createHLayout & createVLayout 

1522 def createHLayout(self, parent, name, margin=0, spacing=0): 

1523 hLayout = QtWidgets.QHBoxLayout(parent) 

1524 hLayout.setObjectName(name) 

1525 hLayout.setSpacing(spacing) 

1526 hLayout.setContentsMargins(QtCore.QMargins(0, 0, 0, 0)) 

1527 return hLayout 

1528 

1529 def createVLayout(self, parent, name, margin=0, spacing=0): 

1530 vLayout = QtWidgets.QVBoxLayout(parent) 

1531 vLayout.setObjectName(name) 

1532 vLayout.setSpacing(spacing) 

1533 vLayout.setContentsMargins(QtCore.QMargins(0, 0, 0, 0)) 

1534 return vLayout 

1535 #@+node:ekr.20190819094302.1: *4* qt_gui.createLabel 

1536 def createLabel(self, parent, name, label): 

1537 w = QtWidgets.QLabel(parent) 

1538 w.setObjectName(name) 

1539 w.setText(label) 

1540 return w 

1541 #@+node:ekr.20190819092523.1: *4* qt_gui.createTabWidget 

1542 def createTabWidget(self, parent, name, hPolicy=None, vPolicy=None): 

1543 w = QtWidgets.QTabWidget(parent) 

1544 self.setSizePolicy(w, kind1=hPolicy, kind2=vPolicy) 

1545 w.setObjectName(name) 

1546 return w 

1547 #@+node:ekr.20190819091214.1: *4* qt_gui.setSizePolicy 

1548 def setSizePolicy(self, widget, kind1=None, kind2=None): 

1549 if kind1 is None: 

1550 kind1 = Policy.Ignored 

1551 if kind2 is None: 

1552 kind2 = Policy.Ignored 

1553 sizePolicy = QtWidgets.QSizePolicy(kind1, kind2) 

1554 sizePolicy.setHorizontalStretch(0) 

1555 sizePolicy.setVerticalStretch(0) 

1556 sizePolicy.setHeightForWidth(widget.sizePolicy().hasHeightForWidth()) 

1557 widget.setSizePolicy(sizePolicy) 

1558 #@-others 

1559#@+node:tbrown.20150724090431.1: ** class StyleClassManager 

1560class StyleClassManager: 

1561 style_sclass_property = 'style_class' # name of QObject property for styling 

1562 #@+others 

1563 #@+node:tbrown.20150724090431.2: *3* update_view 

1564 def update_view(self, w): 

1565 """update_view - Make Qt apply w's style 

1566 

1567 :param QWidgit w: widgit to style 

1568 """ 

1569 

1570 w.setStyleSheet("/* */") # forces visual update 

1571 #@+node:tbrown.20150724090431.3: *3* add_sclass 

1572 def add_sclass(self, w, prop): 

1573 """Add style class or list of classes prop to QWidget w""" 

1574 if not prop: 

1575 return 

1576 props = self.sclasses(w) 

1577 if isinstance(prop, str): 

1578 props.append(prop) 

1579 else: 

1580 props.extend(prop) 

1581 

1582 self.set_sclasses(w, props) 

1583 #@+node:tbrown.20150724090431.4: *3* clear_sclasses 

1584 def clear_sclasses(self, w): 

1585 """Remove all style classes from QWidget w""" 

1586 w.setProperty(self.style_sclass_property, '') 

1587 #@+node:tbrown.20150724090431.5: *3* has_sclass 

1588 def has_sclass(self, w, prop): 

1589 """Check for style class or list of classes prop on QWidget w""" 

1590 if not prop: 

1591 return None 

1592 props = self.sclasses(w) 

1593 if isinstance(prop, str): 

1594 ans = [prop in props] 

1595 else: 

1596 ans = [i in props for i in prop] 

1597 return all(ans) 

1598 #@+node:tbrown.20150724090431.6: *3* remove_sclass 

1599 def remove_sclass(self, w, prop): 

1600 """Remove style class or list of classes prop from QWidget w""" 

1601 if not prop: 

1602 return 

1603 props = self.sclasses(w) 

1604 if isinstance(prop, str): 

1605 props = [i for i in props if i != prop] 

1606 else: 

1607 props = [i for i in props if i not in prop] 

1608 

1609 self.set_sclasses(w, props) 

1610 #@+node:tbrown.20150724090431.7: *3* sclass_tests 

1611 def sclass_tests(self): 

1612 """Test style class property manipulation functions""" 

1613 

1614 # pylint: disable=len-as-condition 

1615 

1616 

1617 class Test_W: 

1618 """simple standin for QWidget for testing""" 

1619 

1620 def __init__(self): 

1621 self.x = '' 

1622 

1623 def property(self, name, default=None): 

1624 return self.x or default 

1625 

1626 def setProperty(self, name, value): 

1627 self.x = value 

1628 

1629 w = Test_W() 

1630 

1631 assert not self.has_sclass(w, 'nonesuch') 

1632 assert not self.has_sclass(w, ['nonesuch']) 

1633 assert not self.has_sclass(w, ['nonesuch', 'either']) 

1634 assert len(self.sclasses(w)) == 0 

1635 

1636 self.add_sclass(w, 'test') 

1637 

1638 assert not self.has_sclass(w, 'nonesuch') 

1639 assert self.has_sclass(w, 'test') 

1640 assert self.has_sclass(w, ['test']) 

1641 assert not self.has_sclass(w, ['test', 'either']) 

1642 assert len(self.sclasses(w)) == 1 

1643 

1644 self.add_sclass(w, 'test') 

1645 assert len(self.sclasses(w)) == 1 

1646 self.add_sclass(w, ['test', 'test', 'other']) 

1647 assert len(self.sclasses(w)) == 2 

1648 assert self.has_sclass(w, 'test') 

1649 assert self.has_sclass(w, 'other') 

1650 assert self.has_sclass(w, ['test', 'other', 'test']) 

1651 assert not self.has_sclass(w, ['test', 'other', 'nonesuch']) 

1652 

1653 self.remove_sclass(w, ['other', 'nothere']) 

1654 assert self.has_sclass(w, 'test') 

1655 assert not self.has_sclass(w, 'other') 

1656 assert len(self.sclasses(w)) == 1 

1657 

1658 self.toggle_sclass(w, 'third') 

1659 assert len(self.sclasses(w)) == 2 

1660 assert self.has_sclass(w, ['test', 'third']) 

1661 self.toggle_sclass(w, 'third') 

1662 assert len(self.sclasses(w)) == 1 

1663 assert not self.has_sclass(w, ['test', 'third']) 

1664 

1665 self.clear_sclasses(w) 

1666 assert len(self.sclasses(w)) == 0 

1667 assert not self.has_sclass(w, 'test') 

1668 #@+node:tbrown.20150724090431.8: *3* sclasses 

1669 def sclasses(self, w): 

1670 """return list of style classes for QWidget w""" 

1671 return str(w.property(self.style_sclass_property) or '').split() 

1672 #@+node:tbrown.20150724090431.9: *3* set_sclasses 

1673 def set_sclasses(self, w, classes): 

1674 """Set style classes for QWidget w to list in classes""" 

1675 w.setProperty(self.style_sclass_property, f" {' '.join(set(classes))} ") 

1676 #@+node:tbrown.20150724090431.10: *3* toggle_sclass 

1677 def toggle_sclass(self, w, prop): 

1678 """Toggle style class or list of classes prop on QWidget w""" 

1679 if not prop: 

1680 return 

1681 props = set(self.sclasses(w)) 

1682 

1683 if isinstance(prop, str): 

1684 prop = set([prop]) 

1685 else: 

1686 prop = set(prop) 

1687 

1688 current = props.intersection(prop) 

1689 props.update(prop) 

1690 props = props.difference(current) 

1691 

1692 self.set_sclasses(w, props) 

1693 #@-others 

1694#@+node:ekr.20140913054442.17860: ** class StyleSheetManager 

1695class StyleSheetManager: 

1696 """A class to manage (reload) Qt style sheets.""" 

1697 #@+others 

1698 #@+node:ekr.20180316091829.1: *3* ssm.Birth 

1699 #@+node:ekr.20140912110338.19371: *4* ssm.__init__ 

1700 def __init__(self, c, safe=False): 

1701 """Ctor the ReloadStyle class.""" 

1702 self.c = c 

1703 self.color_db = leoColor.leo_color_database 

1704 self.safe = safe 

1705 self.settings_p = g.findNodeAnywhere(c, '@settings') 

1706 self.mng = StyleClassManager() 

1707 # This warning is inappropriate in some contexts. 

1708 # if not self.settings_p: 

1709 # g.es("No '@settings' node found in outline. See:") 

1710 # g.es("http://leoeditor.com/tutorial-basics.html#configuring-leo") 

1711 #@+node:ekr.20170222051716.1: *4* ssm.reload_settings 

1712 def reload_settings(self, sheet=None): 

1713 """ 

1714 Recompute and apply the stylesheet. 

1715 Called automatically by the reload-settings commands. 

1716 """ 

1717 if not sheet: 

1718 sheet = self.get_style_sheet_from_settings() 

1719 if sheet: 

1720 w = self.get_master_widget() 

1721 w.setStyleSheet(sheet) 

1722 # self.c.redraw() 

1723 

1724 reloadSettings = reload_settings 

1725 #@+node:ekr.20180316091500.1: *3* ssm.Paths... 

1726 #@+node:ekr.20180316065346.1: *4* ssm.compute_icon_directories 

1727 def compute_icon_directories(self): 

1728 """ 

1729 Return a list of *existing* directories that could contain theme-related icons. 

1730 """ 

1731 exists = g.os_path_exists 

1732 home = g.app.homeDir 

1733 join = g.os_path_finalize_join 

1734 leo = join(g.app.loadDir, '..') 

1735 table = [ 

1736 join(home, '.leo', 'Icons'), 

1737 # join(home, '.leo'), 

1738 join(leo, 'themes', 'Icons'), 

1739 join(leo, 'themes'), 

1740 join(leo, 'Icons'), 

1741 ] 

1742 table = [z for z in table if exists(z)] 

1743 for directory in self.compute_theme_directories(): 

1744 if directory not in table: 

1745 table.append(directory) 

1746 directory2 = join(directory, 'Icons') 

1747 if directory2 not in table: 

1748 table.append(directory2) 

1749 return [g.os_path_normslashes(z) for z in table if g.os_path_exists(z)] 

1750 #@+node:ekr.20180315101238.1: *4* ssm.compute_theme_directories 

1751 def compute_theme_directories(self): 

1752 """ 

1753 Return a list of *existing* directories that could contain theme .leo files. 

1754 """ 

1755 lm = g.app.loadManager 

1756 table = lm.computeThemeDirectories()[:] 

1757 directory = g.os_path_normslashes(g.app.theme_directory) 

1758 if directory and directory not in table: 

1759 table.insert(0, directory) 

1760 return table 

1761 # All entries are known to exist and have normalized slashes. 

1762 #@+node:ekr.20170307083738.1: *4* ssm.find_icon_path 

1763 def find_icon_path(self, setting): 

1764 """Return the path to the open/close indicator icon.""" 

1765 c = self.c 

1766 s = c.config.getString(setting) 

1767 if not s: 

1768 return None # Not an error. 

1769 for directory in self.compute_icon_directories(): 

1770 path = g.os_path_finalize_join(directory, s) 

1771 if g.os_path_exists(path): 

1772 return path 

1773 g.es_print('no icon found for:', setting) 

1774 return None 

1775 #@+node:ekr.20180316091920.1: *3* ssm.Settings 

1776 #@+node:ekr.20110605121601.18176: *4* ssm.default_style_sheet 

1777 def default_style_sheet(self): 

1778 """Return a reasonable default style sheet.""" 

1779 # Valid color names: http://www.w3.org/TR/SVG/types.html#ColorKeywords 

1780 g.trace('===== using default style sheet =====') 

1781 return '''\ 

1782 

1783 /* A QWidget: supports only background attributes.*/ 

1784 QSplitter::handle { 

1785 background-color: #CAE1FF; /* Leo's traditional lightSteelBlue1 */ 

1786 } 

1787 QSplitter { 

1788 border-color: white; 

1789 background-color: white; 

1790 border-width: 3px; 

1791 border-style: solid; 

1792 } 

1793 QTreeWidget { 

1794 background-color: #ffffec; /* Leo's traditional tree color */ 

1795 } 

1796 QsciScintilla { 

1797 background-color: pink; 

1798 } 

1799 ''' 

1800 #@+node:ekr.20140916170549.19551: *4* ssm.get_data 

1801 def get_data(self, setting): 

1802 """Return the value of the @data node for the setting.""" 

1803 c = self.c 

1804 return c.config.getData(setting, strip_comments=False, strip_data=False) or [] 

1805 #@+node:ekr.20140916170549.19552: *4* ssm.get_style_sheet_from_settings 

1806 def get_style_sheet_from_settings(self): 

1807 """ 

1808 Scan for themes or @data qt-gui-plugin-style-sheet nodes. 

1809 Return the text of the relevant node. 

1810 """ 

1811 aList1 = self.get_data('qt-gui-plugin-style-sheet') 

1812 aList2 = self.get_data('qt-gui-user-style-sheet') 

1813 if aList2: 

1814 aList1.extend(aList2) 

1815 sheet = ''.join(aList1) 

1816 sheet = self.expand_css_constants(sheet) 

1817 return sheet 

1818 #@+node:ekr.20140915194122.19476: *4* ssm.print_style_sheet 

1819 def print_style_sheet(self): 

1820 """Show the top-level style sheet.""" 

1821 w = self.get_master_widget() 

1822 sheet = w.styleSheet() 

1823 print(f"style sheet for: {w}...\n\n{sheet}") 

1824 #@+node:ekr.20110605121601.18175: *4* ssm.set_style_sheets 

1825 def set_style_sheets(self, all=True, top=None, w=None): 

1826 """Set the master style sheet for all widgets using config settings.""" 

1827 if g.app.loadedThemes: 

1828 return 

1829 c = self.c 

1830 if top is None: 

1831 top = c.frame.top 

1832 selectors = ['qt-gui-plugin-style-sheet'] 

1833 if all: 

1834 selectors.append('qt-gui-user-style-sheet') 

1835 sheets = [] 

1836 for name in selectors: 

1837 sheet = c.config.getData(name, strip_comments=False) 

1838 # don't strip `#selector_name { ...` type syntax 

1839 if sheet: 

1840 if '\n' in sheet[0]: 

1841 sheet = ''.join(sheet) 

1842 else: 

1843 sheet = '\n'.join(sheet) 

1844 if sheet and sheet.strip(): 

1845 line0 = f"\n/* ===== From {name} ===== */\n\n" 

1846 sheet = line0 + sheet 

1847 sheets.append(sheet) 

1848 if sheets: 

1849 sheet = "\n".join(sheets) 

1850 # store *before* expanding, so later expansions get new zoom 

1851 c.active_stylesheet = sheet 

1852 sheet = self.expand_css_constants(sheet) 

1853 if not sheet: 

1854 sheet = self.default_style_sheet() 

1855 if w is None: 

1856 w = self.get_master_widget(top) 

1857 w.setStyleSheet(sheet) 

1858 #@+node:ekr.20180316091943.1: *3* ssm.Stylesheet 

1859 # Computations on stylesheets themeselves. 

1860 #@+node:ekr.20140915062551.19510: *4* ssm.expand_css_constants & helpers 

1861 css_warning_given = False 

1862 

1863 def expand_css_constants(self, sheet, settingsDict=None): 

1864 """Expand @ settings into their corresponding constants.""" 

1865 c = self.c 

1866 trace = 'zoom' in g.app.debug 

1867 # Warn once if the stylesheet uses old style style-sheet comment 

1868 if settingsDict is None: 

1869 settingsDict = c.config.settingsDict # A TypedDict. 

1870 if 0: 

1871 g.trace('===== settingsDict...') 

1872 for key in settingsDict.keys(): 

1873 print(f"{key:40}: {settingsDict.get(key)}") 

1874 constants, deltas = self.adjust_sizes(settingsDict) 

1875 if trace: 

1876 print('') 

1877 g.trace(f"zoom constants: {constants}") 

1878 g.printObj(deltas, tag='zoom deltas') # A defaultdict 

1879 sheet = self.replace_indicator_constants(sheet) 

1880 for pass_n in range(10): 

1881 to_do = self.find_constants_referenced(sheet) 

1882 if not to_do: 

1883 break 

1884 old_sheet = sheet 

1885 sheet = self.do_pass(constants, deltas, settingsDict, sheet, to_do) 

1886 if sheet == old_sheet: 

1887 break 

1888 else: 

1889 g.trace('Too many iterations') 

1890 if to_do: 

1891 g.trace('Unresolved @constants') 

1892 g.printObj(to_do) 

1893 sheet = self.resolve_urls(sheet) 

1894 sheet = sheet.replace('\\\n', '') # join lines ending in \ 

1895 return sheet 

1896 #@+node:ekr.20150617085045.1: *5* ssm.adjust_sizes 

1897 def adjust_sizes(self, settingsDict): 

1898 """Adjust constants to reflect c._style_deltas.""" 

1899 c = self.c 

1900 constants = {} # old: self.find_constants_defined(sheet) 

1901 deltas = c._style_deltas 

1902 for delta in c._style_deltas: 

1903 # adjust @font-size-body by font_size_delta 

1904 # easily extendable to @font-size-* 

1905 val = c.config.getString(delta) 

1906 passes = 10 

1907 while passes and val and val.startswith('@'): 

1908 key = g.app.config.canonicalizeSettingName(val[1:]) 

1909 val = settingsDict.get(key) 

1910 if val: 

1911 val = val.val 

1912 passes -= 1 

1913 if deltas[delta] and (val is not None): 

1914 size = ''.join(i for i in val if i in '01234567890.') 

1915 units = ''.join(i for i in val if i not in '01234567890.') 

1916 size = max(1, int(size) + deltas[delta]) 

1917 constants['@' + delta] = f"{size}{units}" 

1918 return constants, deltas 

1919 #@+node:ekr.20180316093159.1: *5* ssm.do_pass 

1920 def do_pass(self, constants, deltas, settingsDict, sheet, to_do): 

1921 

1922 to_do.sort(key=len, reverse=True) 

1923 for const in to_do: 

1924 value = None 

1925 if const in constants: 

1926 # This constant is about to be removed. 

1927 value = constants[const] 

1928 if const[1:] not in deltas and not self.css_warning_given: 

1929 self.css_warning_given = True 

1930 g.es_print(f"'{const}' from style-sheet comment definition, ") 

1931 g.es_print("please use regular @string / @color type @settings.") 

1932 else: 

1933 key = g.app.config.canonicalizeSettingName(const[1:]) 

1934 # lowercase, without '@','-','_', etc. 

1935 value = settingsDict.get(key) 

1936 if value is not None: 

1937 # New in Leo 5.5: Do NOT add comments here. 

1938 # They RUIN style sheets if they appear in a nested comment! 

1939 # value = '%s /* %s */' % (value.val, key) 

1940 value = value.val 

1941 elif key in self.color_db: 

1942 # New in Leo 5.5: Do NOT add comments here. 

1943 # They RUIN style sheets if they appear in a nested comment! 

1944 value = self.color_db.get(key) 

1945 # value = '%s /* %s */' % (value, key) 

1946 if value: 

1947 # Partial fix for #780. 

1948 try: 

1949 sheet = re.sub( 

1950 const + "(?![-A-Za-z0-9_])", 

1951 # don't replace shorter constants occuring in larger 

1952 value, 

1953 sheet, 

1954 ) 

1955 except Exception: 

1956 g.es_print('Exception handling style sheet') 

1957 g.es_print(sheet) 

1958 g.es_exception() 

1959 else: 

1960 pass 

1961 # tricky, might be an undefined identifier, but it might 

1962 # also be a @foo in a /* comment */, where it's harmless. 

1963 # So rely on whoever calls .setStyleSheet() to do the right thing. 

1964 return sheet 

1965 #@+node:tbrown.20131120093739.27085: *5* ssm.find_constants_referenced 

1966 def find_constants_referenced(self, text): 

1967 """find_constants - Return a list of constants referenced in the supplied text, 

1968 constants match:: 

1969 

1970 @[A-Za-z_][-A-Za-z0-9_]* 

1971 i.e. @foo_1-5 

1972 

1973 :Parameters: 

1974 - `text`: text to search 

1975 """ 

1976 aList = sorted(set(re.findall(r"@[A-Za-z_][-A-Za-z0-9_]*", text))) 

1977 # Exempt references to Leo constructs. 

1978 for s in ('@button', '@constants', '@data', '@language'): 

1979 if s in aList: 

1980 aList.remove(s) 

1981 return aList 

1982 #@+node:tbrown.20130411121812.28335: *5* ssm.find_constants_defined (no longer used) 

1983 def find_constants_defined(self, text): 

1984 r"""find_constants - Return a dict of constants defined in the supplied text. 

1985 

1986 NOTE: this supports a legacy way of specifying @<identifiers>, regular 

1987 @string and @color settings should be used instead, so calling this 

1988 wouldn't be needed. expand_css_constants() issues a warning when 

1989 @<identifiers> are found in the output of this method. 

1990 

1991 Constants match:: 

1992 

1993 ^\s*(@[A-Za-z_][-A-Za-z0-9_]*)\s*=\s*(.*)$ 

1994 i.e. 

1995 @foo_1-5=a 

1996 @foo_1-5 = a more here 

1997 

1998 :Parameters: 

1999 - `text`: text to search 

2000 """ 

2001 pattern = re.compile(r"^\s*(@[A-Za-z_][-A-Za-z0-9_]*)\s*=\s*(.*)$") 

2002 ans: Dict[str, str] = {} 

2003 text = text.replace('\\\n', '') # merge lines ending in \ 

2004 for line in text.split('\n'): 

2005 test = pattern.match(line) 

2006 if test: 

2007 ans.update([test.groups()]) # type:ignore # Mysterious 

2008 # constants may refer to other constants, de-reference here 

2009 change = True 

2010 level = 0 

2011 while change and level < 10: 

2012 level += 1 

2013 change = False 

2014 for k in ans: 

2015 # pylint: disable=unnecessary-lambda 

2016 # process longest first so @solarized-base0 is not replaced 

2017 # when it's part of @solarized-base03 

2018 for o in sorted(ans, key=lambda x: len(x), reverse=True): 

2019 if o in ans[k]: 

2020 change = True 

2021 ans[k] = ans[k].replace(o, ans[o]) 

2022 if level == 10: 

2023 print("Ten levels of recursion processing styles, abandoned.") 

2024 g.es("Ten levels of recursion processing styles, abandoned.") 

2025 return ans 

2026 #@+node:ekr.20150617090104.1: *5* ssm.replace_indicator_constants 

2027 def replace_indicator_constants(self, sheet): 

2028 """ 

2029 In the stylesheet, replace (if they exist):: 

2030 

2031 image: @tree-image-closed 

2032 image: @tree-image-open 

2033 

2034 by:: 

2035 

2036 url(path/closed.png) 

2037 url(path/open.png) 

2038 

2039 path can be relative to ~ or to leo/Icons. 

2040 

2041 Assuming that ~/myIcons/closed.png exists, either of these will work:: 

2042 

2043 @string tree-image-closed = nodes-dark/triangles/closed.png 

2044 @string tree-image-closed = myIcons/closed.png 

2045 

2046 Return the updated stylesheet. 

2047 """ 

2048 close_path = self.find_icon_path('tree-image-closed') 

2049 open_path = self.find_icon_path('tree-image-open') 

2050 # Make all substitutions in the stylesheet. 

2051 table = ( 

2052 (open_path, re.compile(r'\bimage:\s*@tree-image-open', re.IGNORECASE)), 

2053 (close_path, re.compile(r'\bimage:\s*@tree-image-closed', re.IGNORECASE)), 

2054 # (open_path, re.compile(r'\bimage:\s*at-tree-image-open', re.IGNORECASE)), 

2055 # (close_path, re.compile(r'\bimage:\s*at-tree-image-closed', re.IGNORECASE)), 

2056 ) 

2057 for path, pattern in table: 

2058 for mo in pattern.finditer(sheet): 

2059 old = mo.group(0) 

2060 new = f"image: url({path})" 

2061 sheet = sheet.replace(old, new) 

2062 return sheet 

2063 #@+node:ekr.20180320054305.1: *5* ssm.resolve_urls 

2064 def resolve_urls(self, sheet): 

2065 """Resolve all relative url's so they use absolute paths.""" 

2066 trace = 'themes' in g.app.debug 

2067 pattern = re.compile(r'url\((.*)\)') 

2068 join = g.os_path_finalize_join 

2069 directories = self.compute_icon_directories() 

2070 paths_traced = False 

2071 if trace: 

2072 paths_traced = True 

2073 g.trace('Search paths...') 

2074 g.printObj(directories) 

2075 # Pass 1: Find all replacements without changing the sheet. 

2076 replacements = [] 

2077 for mo in pattern.finditer(sheet): 

2078 url = mo.group(1) 

2079 if url.startswith(':/'): 

2080 url = url[2:] 

2081 elif g.os_path_isabs(url): 

2082 if trace: 

2083 g.trace('ABS:', url) 

2084 continue 

2085 for directory in directories: 

2086 path = join(directory, url) 

2087 if g.os_path_exists(path): 

2088 if trace: 

2089 g.trace(f"{url:35} ==> {path}") 

2090 old = mo.group(0) 

2091 new = f"url({path})" 

2092 replacements.append((old, new),) 

2093 break 

2094 else: 

2095 g.trace(f"{url:35} ==> NOT FOUND") 

2096 if not paths_traced: 

2097 paths_traced = True 

2098 g.trace('Search paths...') 

2099 g.printObj(directories) 

2100 # Pass 2: Now we can safely make the replacements. 

2101 for old, new in reversed(replacements): 

2102 sheet = sheet.replace(old, new) 

2103 return sheet 

2104 #@+node:ekr.20140912110338.19372: *4* ssm.munge 

2105 def munge(self, stylesheet): 

2106 """ 

2107 Return the stylesheet without extra whitespace. 

2108 

2109 To avoid false mismatches, this should approximate what Qt does. 

2110 To avoid false matches, this should not munge too much. 

2111 """ 

2112 s = ''.join([s.lstrip().replace(' ', ' ').replace(' \n', '\n') 

2113 for s in g.splitLines(stylesheet)]) 

2114 return s.rstrip() 

2115 # Don't care about ending newline. 

2116 #@+node:ekr.20180317062556.1: *3* sss.Theme files 

2117 #@+node:ekr.20180316092116.1: *3* ssm.Widgets 

2118 #@+node:ekr.20140913054442.19390: *4* ssm.get_master_widget 

2119 def get_master_widget(self, top=None): 

2120 """ 

2121 Carefully return the master widget. 

2122 c.frame.top is a DynamicWindow. 

2123 """ 

2124 if top is None: 

2125 top = self.c.frame.top 

2126 master = top.leo_master or top 

2127 return master 

2128 #@+node:ekr.20140913054442.19391: *4* ssm.set selected_style_sheet 

2129 def set_selected_style_sheet(self): 

2130 """For manual testing: update the stylesheet using c.p.b.""" 

2131 if not g.unitTesting: 

2132 c = self.c 

2133 sheet = c.p.b 

2134 sheet = self.expand_css_constants(sheet) 

2135 w = self.get_master_widget(c.frame.top) 

2136 w.setStyleSheet(sheet) 

2137 #@-others 

2138#@-others 

2139#@@language python 

2140#@@tabwidth -4 

2141#@@pagewidth 70 

2142#@-leo