Coverage for C:\leo.repo\leo-editor\leo\plugins\qt_gui.py: 18%

Shortcuts on this page

r m x   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

1257 statements  

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 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') # Initialize the base class. 

52 self.active = True 

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

54 self.iconimages = {} 

55 self.globalFindDialog = None 

56 self.idleTimeClass = qt_idle_time.IdleTime 

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

58 self.mGuiName = 'qt' 

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

60 self.plainTextWidget = qt_text.PlainTextWrapper 

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

62 self.styleSheetManagerClass = StyleSheetManager 

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

64 QtWidgets.QApplication.setDesktopSettingsAware(True) 

65 # Create objects... 

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

67 self.reloadSettings() 

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

69 

70 # Define various classes key stokes. 

71 #@+<< define FKeys >> 

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

73 self.FKeys = [ 

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

75 # These do not generate keystrokes on MacOs. 

76 #@-<< define FKeys >> 

77 #@+<< define ignoreChars >> 

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

79 # Always ignore these characters 

80 self.ignoreChars = [ 

81 # These are in ks.special characters. 

82 # They should *not* be ignored. 

83 # 'Left', 'Right', 'Up', 'Down', 

84 # 'Next', 'Prior', 

85 # 'Home', 'End', 

86 # 'Delete', 'Escape', 

87 # 'BackSpace', 'Linefeed', 'Return', 'Tab', 

88 # F-Keys are also ok. 

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

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

91 'KP_Multiply, KP_Separator,KP_Space, KP_Subtract, KP_Tab', 

92 'KP_F1', 'KP_F2', 'KP_F3', 'KP_F4', 

93 'KP_Add', 'KP_Decimal', 'KP_Divide', 'KP_Enter', 'KP_Equal', 

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

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

96 'CapsLock', 'Caps_Lock', 

97 'NumLock', 'Num_Lock', 

98 'ScrollLock', 

99 'Alt_L', 'Alt_R', 

100 'Control_L', 'Control_R', 

101 'Meta_L', 'Meta_R', 

102 'Shift_L', 'Shift_R', 

103 'Win_L', 'Win_R', 

104 # Clearly, these should never be generated. 

105 'Break', 'Pause', 'Sys_Req', 

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

107 'Begin', 'Clear', 

108 # Don't know what these are. 

109 ] 

110 #@-<< define ignoreChars >> 

111 #@+<< define specialChars >> 

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

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

114 self.specialChars = [ 

115 # These are *not* special keys. 

116 # 'BackSpace', 'Linefeed', 'Return', 'Tab', 

117 'Left', 'Right', 'Up', 'Down', 

118 # Arrow keys 

119 'Next', 'Prior', 

120 # Page up/down keys. 

121 'Home', 'End', 

122 # Home end keys. 

123 'Delete', 'Escape', 

124 # Others. 

125 'Enter', 'Insert', 'Ins', 

126 # These should only work if bound. 

127 'Menu', 

128 # #901. 

129 'PgUp', 'PgDn', 

130 # #868. 

131 ] 

132 #@-<< define specialChars >> 

133 # Put up the splash screen() 

134 if (g.app.use_splash_screen and 

135 not g.app.batchMode and 

136 not g.app.silentMode and 

137 not g.unitTesting 

138 ): 

139 self.splashScreen = self.createSplashScreen() 

140 # qtFrame.finishCreate does all the other work. 

141 self.frameFactory = qt_frame.TabbedFrameFactory() 

142 

143 def reloadSettings(self): 

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

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

146 def destroySelf(self): 

147 

148 QtCore.pyqtRemoveInputHook() 

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

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

151 self.qtApp.quit() 

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

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

154 def replaceClipboardWith(self, s): 

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

156 cb = self.qtApp.clipboard() 

157 if cb: 

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

159 s = g.toUnicode(s) 

160 QtWidgets.QApplication.processEvents() 

161 # Fix #241: QMimeData object error 

162 cb.setText(s) 

163 QtWidgets.QApplication.processEvents() 

164 else: 

165 g.trace('no clipboard!') 

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

167 def getTextFromClipboard(self): 

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

169 cb = self.qtApp.clipboard() 

170 if cb: 

171 QtWidgets.QApplication.processEvents() 

172 return cb.text() 

173 g.trace('no clipboard!') 

174 return '' 

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

176 def setClipboardSelection(self, s): 

177 """ 

178 Set the clipboard selection to s. 

179 There are problems with PyQt5. 

180 """ 

181 if isQt5 or isQt6: 

182 # Alas, returning s reopens #218. 

183 return 

184 if s: 

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

186 cb = self.qtApp.clipboard() 

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

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

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

190 def alert(self, c, message): 

191 if g.unitTesting: 

192 return 

193 dialog = QtWidgets.QMessageBox(None) 

194 dialog.setWindowTitle('Alert') 

195 dialog.setText(message) 

196 dialog.setIcon(Icon.Warning) 

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

198 try: 

199 c.in_qt_dialog = True 

200 dialog.raise_() 

201 dialog.exec_() 

202 finally: 

203 c.in_qt_dialog = False 

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

205 def makeFilter(self, filetypes): 

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

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

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

209 return ';;'.join(filters) 

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

211 def openFindDialog(self, c): 

212 if g.unitTesting: 

213 return 

214 dialog = self.globalFindDialog 

215 if not dialog: 

216 dialog = self.createFindDialog(c) 

217 self.globalFindDialog = dialog 

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

219 if c: 

220 dialog.setStyleSheet(c.active_stylesheet) 

221 # Set the commander's FindTabManager. 

222 assert g.app.globalFindTabManager 

223 c.ftm = g.app.globalFindTabManager 

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

225 else: 

226 fn = 'Untitled' 

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

228 if c: 

229 c.inCommand = False 

230 if dialog.isVisible(): 

231 # The order is important, and tricky. 

232 dialog.focusWidget() 

233 dialog.show() 

234 dialog.raise_() 

235 dialog.activateWindow() 

236 else: 

237 dialog.show() 

238 dialog.exec_() 

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

240 def createFindDialog(self, c): 

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

242 if c: 

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

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

245 w = top.findTab 

246 dialog = QtWidgets.QDialog() 

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

248 

249 def closeEvent(event): 

250 event.ignore() 

251 dialog.hide() 

252 

253 dialog.closeEvent = closeEvent 

254 layout = QtWidgets.QVBoxLayout(dialog) 

255 layout.addWidget(w) 

256 self.attachLeoIcon(dialog) 

257 dialog.setLayout(layout) 

258 if c: 

259 c.styleSheetManager.set_style_sheets(w=dialog) 

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

261 # This makes most standard bindings available. 

262 dialog.setModal(False) 

263 return dialog 

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

265 def createComparePanel(self, c): 

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

267 return None # This window is optional. 

268 

269 def createFindTab(self, c, parentFrame): 

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

271 pass # Now done in dw.createFindTab. 

272 

273 def createLeoFrame(self, c, title): 

274 """Create a new Leo frame.""" 

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

276 

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

278 if g.unitTesting: 

279 return None 

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

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

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

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

284 if g.unitTesting: 

285 return 

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

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

288 dialog.setIcon(Icon.Information) 

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

290 dialog.setDefaultButton(yes) 

291 try: 

292 c.in_qt_dialog = True 

293 dialog.raise_() 

294 dialog.exec_() 

295 finally: 

296 c.in_qt_dialog = False 

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

298 def runAskDateTimeDialog(self, c, title, 

299 message='Select Date/Time', 

300 init=None, 

301 step_min=None 

302 ): 

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

304 

305 init - a datetime, default now 

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

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

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

309 when you roll the mouse wheel. 

310 

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

312 

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

314 message="When is it?", 

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

316 

317 """ 

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

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

320 

321 

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

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

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

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

326 """ 

327 

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

329 if step_min is None: 

330 step_min = {} 

331 self.step_min = step_min 

332 if init: 

333 super().__init__(init, parent) 

334 else: 

335 super().__init__(parent) 

336 

337 def stepBy(self, step): 

338 cs = self.currentSection() 

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

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

341 QtWidgets.QDateTimeEdit.stepBy(self, step) 

342 

343 

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

345 

346 def __init__(self, 

347 parent=None, 

348 message='Select Date/Time', 

349 init=None, 

350 step_min=None 

351 ): 

352 if step_min is None: 

353 step_min = {} 

354 super().__init__(parent) 

355 layout = QtWidgets.QVBoxLayout() 

356 self.setLayout(layout) 

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

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

359 self.dt.setCalendarPopup(True) 

360 layout.addWidget(self.dt) 

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

362 layout.addWidget(buttonBox) 

363 buttonBox.accepted.connect(self.accept) 

364 buttonBox.rejected.connect(self.reject) 

365 

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

367 if g.unitTesting: 

368 return None 

369 if step_min is None: 

370 step_min = {} 

371 if not init: 

372 init = datetime.datetime.now() 

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

374 if c: 

375 dialog.setStyleSheet(c.active_stylesheet) 

376 dialog.setWindowTitle(title) 

377 try: 

378 c.in_qt_dialog = True 

379 dialog.raise_() 

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

381 finally: 

382 c.in_qt_dialog = False 

383 else: 

384 dialog.setWindowTitle(title) 

385 dialog.raise_() 

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

387 if val == DialogCode.Accepted: 

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

389 return None 

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

391 def runAskLeoIDDialog(self): 

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

393 if g.unitTesting: 

394 return None 

395 message = ( 

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

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

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

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

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

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

402 parent = None 

403 title = 'Enter Leo id' 

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

405 return s 

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

407 def runAskOkCancelNumberDialog( 

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

409 """Create and run askOkCancelNumber dialog .""" 

410 if g.unitTesting: 

411 return None 

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

413 dialog = QtWidgets.QInputDialog() 

414 if c: 

415 dialog.setStyleSheet(c.active_stylesheet) 

416 dialog.setWindowTitle(title) 

417 dialog.setLabelText(message) 

418 if cancelButtonText: 

419 dialog.setCancelButtonText(cancelButtonText) 

420 if okButtonText: 

421 dialog.setOkButtonText(okButtonText) 

422 self.attachLeoIcon(dialog) 

423 dialog.raise_() 

424 ok = dialog.exec_() 

425 n = dialog.textValue() 

426 try: 

427 n = float(n) 

428 except ValueError: 

429 n = None 

430 return n if ok else None 

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

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

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

434 """Create and run askOkCancelString dialog. 

435 

436 wide - edit a long string 

437 """ 

438 if g.unitTesting: 

439 return None 

440 dialog = QtWidgets.QInputDialog() 

441 if c: 

442 dialog.setStyleSheet(c.active_stylesheet) 

443 dialog.setWindowTitle(title) 

444 dialog.setLabelText(message) 

445 dialog.setTextValue(default) 

446 if wide: 

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

448 if cancelButtonText: 

449 dialog.setCancelButtonText(cancelButtonText) 

450 if okButtonText: 

451 dialog.setOkButtonText(okButtonText) 

452 self.attachLeoIcon(dialog) 

453 dialog.raise_() 

454 ok = dialog.exec_() 

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

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

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

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

459 if g.unitTesting: 

460 return 

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

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

463 if stylesheet: 

464 dialog.setStyleSheet(stylesheet) 

465 dialog.setWindowTitle(title) 

466 if message: 

467 dialog.setText(message) 

468 dialog.setIcon(Information.Information) 

469 dialog.addButton(text, ButtonRole.YesRole) 

470 try: 

471 c.in_qt_dialog = True 

472 dialog.raise_() 

473 dialog.exec_() 

474 finally: 

475 c.in_qt_dialog = False 

476 

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

478 def runAskYesNoCancelDialog(self, c, title, 

479 message=None, 

480 yesMessage="&Yes", 

481 noMessage="&No", 

482 yesToAllMessage=None, 

483 defaultButton="Yes", 

484 cancelMessage=None, 

485 ): 

486 """ 

487 Create and run an askYesNo dialog. 

488 

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

490 

491 """ 

492 if g.unitTesting: 

493 return None 

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

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

496 if stylesheet: 

497 dialog.setStyleSheet(stylesheet) 

498 if message: 

499 dialog.setText(message) 

500 dialog.setIcon(Information.Warning) 

501 dialog.setWindowTitle(title) 

502 # Creation order determines returned value. 

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

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

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

506 if yesToAllMessage: 

507 dialog.addButton(yesToAllMessage, ButtonRole.YesRole) 

508 if defaultButton == "Yes": 

509 dialog.setDefaultButton(yes) 

510 elif defaultButton == "No": 

511 dialog.setDefaultButton(no) 

512 else: 

513 dialog.setDefaultButton(cancel) 

514 try: 

515 c.in_qt_dialog = True 

516 dialog.raise_() # #2246. 

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

518 finally: 

519 c.in_qt_dialog = False 

520 # val is the same as the creation order. 

521 # Tested with both Qt6 and Qt5. 

522 return { 

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

524 }.get(val, 'cancel') 

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

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

527 """ 

528 Create and run an askYesNo dialog. 

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

530 

531 :Parameters: 

532 - `c`: commander 

533 - `title`: dialog title 

534 - `message`: dialog message 

535 - `yes_all`: bool - show YesToAll button 

536 - `no_all`: bool - show NoToAll button 

537 """ 

538 if g.unitTesting: 

539 return None 

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

541 # Creation order determines returned value. 

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

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

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

545 if yes_all: 

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

547 if no_all: 

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

549 if c: 

550 dialog.setStyleSheet(c.active_stylesheet) 

551 dialog.setWindowTitle(title) 

552 if message: 

553 dialog.setText(message) 

554 dialog.setIcon(Information.Warning) 

555 dialog.setDefaultButton(yes) 

556 if c: 

557 try: 

558 c.in_qt_dialog = True 

559 dialog.raise_() 

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

561 finally: 

562 c.in_qt_dialog = False 

563 else: 

564 dialog.raise_() 

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

566 # val is the same as the creation order. 

567 # Tested with both Qt6 and Qt5. 

568 return { 

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

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

571 }.get(val, 'cancel') 

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

573 def runOpenDirectoryDialog(self, title, startdir): 

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

575 if g.unitTesting: 

576 return None 

577 dialog = QtWidgets.QFileDialog() 

578 self.attachLeoIcon(dialog) 

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

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

581 def runOpenFileDialog(self, c, 

582 title, 

583 filetypes, 

584 defaultextension='', 

585 multiple=False, 

586 startpath=None, 

587 ): 

588 """ 

589 Create and run an Qt open file dialog. 

590 """ 

591 # pylint: disable=arguments-differ 

592 if g.unitTesting: 

593 return '' 

594 

595 # 2018/03/14: Bug fixes: 

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

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

598 if not startpath: 

599 # Returns c.last_dir or os.curdir 

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

601 filter_ = self.makeFilter(filetypes) 

602 dialog = QtWidgets.QFileDialog() 

603 if c: 

604 dialog.setStyleSheet(c.active_stylesheet) 

605 self.attachLeoIcon(dialog) 

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

607 if c: 

608 try: 

609 c.in_qt_dialog = True 

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

611 finally: 

612 c.in_qt_dialog = False 

613 else: 

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

615 if isQt5 or isQt6: # This is a *Py*Qt change rather than a Qt change 

616 val, junk_selected_filter = val 

617 if multiple: 

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

619 if c and files: 

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

621 return files 

622 s = g.os_path_normslashes(val) 

623 if c and s: 

624 c.last_dir = g.os_path_dirname(s) 

625 return s 

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

627 def runPropertiesDialog(self, 

628 title='Properties', 

629 data=None, 

630 callback=None, 

631 buttons=None 

632 ): 

633 """Dispay a modal TkPropertiesDialog""" 

634 if not g.unitTesting: 

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

636 return 'Cancel', {} 

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

638 def runSaveFileDialog( 

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

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

641 if g.unitTesting: 

642 return '' 

643 dialog = QtWidgets.QFileDialog() 

644 if c: 

645 dialog.setStyleSheet(c.active_stylesheet) 

646 self.attachLeoIcon(dialog) 

647 try: 

648 c.in_qt_dialog = True 

649 obj = dialog.getSaveFileName( 

650 None, # parent 

651 title, 

652 # os.curdir, 

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

654 self.makeFilter(filetypes or []), 

655 ) 

656 finally: 

657 c.in_qt_dialog = False 

658 else: 

659 self.attachLeoIcon(dialog) 

660 obj = dialog.getSaveFileName( 

661 None, # parent 

662 title, 

663 # os.curdir, 

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

665 self.makeFilter(filetypes or []), 

666 ) 

667 # Bizarre: PyQt5 version can return a tuple! 

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

669 s = s or '' 

670 if c and s: 

671 c.last_dir = g.os_path_dirname(s) 

672 return s 

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

674 def runScrolledMessageDialog(self, 

675 short_title='', 

676 title='Message', 

677 label='', 

678 msg='', 

679 c=None, **keys 

680 ): 

681 if g.unitTesting: 

682 return None 

683 

684 def send(): 

685 return g.doHook('scrolledMessage', 

686 short_title=short_title, title=title, 

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

688 

689 if not c or not c.exists: 

690 #@+<< no c error>> 

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

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

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

694 "as a keyword argument", 

695 g.callers() 

696 )) 

697 #@-<< no c error>> 

698 else: 

699 retval = send() 

700 if retval: 

701 return retval 

702 #@+<< load viewrendered plugin >> 

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

704 pc = g.app.pluginsController 

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

706 if ( 

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

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

709 ): 

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

711 if vr: 

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

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

714 #@-<< load viewrendered plugin >> 

715 retval = send() 

716 if retval: 

717 return retval 

718 #@+<< no dialog error >> 

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

720 g.es_print_error( 

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

722 #@-<< no dialog error >> 

723 #@+<< emergency fallback >> 

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

725 dialog = QtWidgets.QMessageBox(None) 

726 dialog.setWindowFlags(WindowType.Dialog) 

727 # That is, not a fixed size dialog. 

728 dialog.setWindowTitle(title) 

729 if msg: 

730 dialog.setText(msg) 

731 dialog.setIcon(Icon.Information) 

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

733 try: 

734 c.in_qt_dialog = True 

735 if isQt6: 

736 dialog.exec() 

737 else: 

738 dialog.exec_() 

739 finally: 

740 c.in_qt_dialog = False 

741 #@-<< emergency fallback >> 

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

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

744 def close_event(self, event): 

745 

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

747 g.app.sessionManager.save_snapshot() 

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

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

750 if not allow: 

751 event.ignore() 

752 return 

753 event.accept() 

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

755 # deactivated_name = '' 

756 

757 deactivated_widget = None 

758 

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

760 """ 

761 Gracefully deactivate the Leo window. 

762 Called several times for each window activation. 

763 """ 

764 w = self.get_focus() 

765 w_name = w and w.objectName() 

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

767 g.trace(repr(w_name)) 

768 self.active = False # Used only by c.idle_focus_helper. 

769 # Careful: never save headline widgets. 

770 if w_name == 'headline': 

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

772 else: 

773 self.deactivated_widget = w if w_name else None 

774 # Causes problems elsewhere... 

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

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

777 # self.active = False 

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

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

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

781 # Called from eventFilter 

782 

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

784 """ 

785 Restore the focus when the Leo window is activated. 

786 Called several times for each window activation. 

787 """ 

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

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

790 self.deactivated_widget = None 

791 w_name = w and w.objectName() 

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

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

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

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

796 c.vimCommands.on_activate() 

797 self.active = True # Used only by c.idle_focus_helper. 

798 if g.isMac: 

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

800 else: 

801 # Leo 5.6: Recover from missing focus. 

802 # c.idle_focus_handler can't do this. 

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

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

805 if trace: 

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

807 c.widgetWantsFocusNow(w) 

808 else: 

809 if trace: 

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

811 c.bodyWantsFocusNow() 

812 # Cause problems elsewhere. 

813 # if c.exists and self.deactivated_name: 

814 # self.active = True 

815 # w_name = self.deactivated_name 

816 # self.deactivated_name = None 

817 # if c.p.v: 

818 # c.p.v.restoreCursorAndScroll() 

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

820 # c.treeWantsFocusNow() 

821 # else: 

822 # c.bodyWantsFocusNow() 

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

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

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

826 """ 

827 Create an event filter in obj. 

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

829 """ 

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

831 # QTextEditWrapper,LeoQTextBrowser,LeoQuickSearchWidget,cleoQtUI) 

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

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

834 obj.installEventFilter(theFilter) 

835 w.ev_filter = theFilter # Set the official ivar in w. 

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

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

838 def ensure_commander_visible(self, c1): 

839 """ 

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

841 the tab is visible 

842 """ 

843 # pylint: disable=arguments-differ 

844 # 

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

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

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

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

849 g.trace(c1) 

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

851 factory = g.app.gui.frameFactory 

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

853 c = c1 

854 factory.setTabForCommander(c) 

855 c.bodyWantsFocusNow() 

856 # END: copy 

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

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

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

860 # pylint: disable=arguments-differ 

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

862 trace_idle = False 

863 trace = trace and (trace_idle or not at_idle) 

864 app = QtWidgets.QApplication 

865 w = app.focusWidget() 

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

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

868 if has_w: 

869 if trace: 

870 g.trace(w) 

871 elif c: 

872 # Kludge: DynamicWindow creates the body pane 

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

874 w = c.frame.body 

875 if trace: 

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

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

878 return w 

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

880 def set_focus(self, c, w): 

881 """Put the focus on the widget.""" 

882 # pylint: disable=arguments-differ 

883 if not w: 

884 return 

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

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

887 # w should be a wrapper. 

888 w = w.widget 

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

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

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

892 w.setFocus() 

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

894 size_warnings: List[str] = [] 

895 

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

897 """Required to handle syntax coloring.""" 

898 if isinstance(size, str): 

899 if size.endswith('pt'): 

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

901 elif size.endswith('px'): 

902 if size not in self.size_warnings: 

903 self.size_warnings.append(size) 

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

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

906 try: 

907 size = int(size) 

908 except Exception: 

909 size = 0 

910 if size < 1: 

911 size = defaultSize 

912 d = { 

913 'black': Weight.Black, 

914 'bold': Weight.Bold, 

915 'demibold': Weight.DemiBold, 

916 'light': Weight.Light, 

917 'normal': Weight.Normal, 

918 } 

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

920 italic = slant == 'italic' 

921 if not family: 

922 family = g.app.config.defaultFontFamily 

923 if not family: 

924 family = 'DejaVu Sans Mono' 

925 try: 

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

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

928 font.setHintingPreference(font.PreferFullHinting) 

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

930 return font 

931 except Exception: 

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

933 g.es( 

934 f"family: {family}\n" 

935 f" size: {size}\n" 

936 f" slant: {slant}\n" 

937 f"weight: {weight}") 

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

939 return g.app.config.defaultFont 

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

941 def getFullVersion(self, c=None): 

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

943 try: 

944 qtLevel = f"version {QtCore.qVersion()}" 

945 except Exception: 

946 # g.es_exception() 

947 qtLevel = '<qtLevel>' 

948 return f"PyQt {qtLevel}" 

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

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

951 def attachLeoIcon(self, window): 

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

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

954 if self.appIcon: 

955 window.setWindowIcon(self.appIcon) 

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

957 def getIconImage(self, name): 

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

959 # Return the image from the cache if possible. 

960 if name in self.iconimages: 

961 image = self.iconimages.get(name) 

962 return image 

963 try: 

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

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

966 for theDir in (homeIconsDir, iconsDir): 

967 fullname = g.os_path_finalize_join(theDir, name) 

968 if g.os_path_exists(fullname): 

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

970 pixmap = QtGui.QPixmap() 

971 pixmap.load(fullname) 

972 image = QtGui.QIcon(pixmap) 

973 else: 

974 image = QtGui.QIcon(fullname) 

975 self.iconimages[name] = image 

976 return image 

977 # No image found. 

978 return None 

979 except Exception: 

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

981 g.es_exception() 

982 return None 

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

984 @functools.lru_cache(maxsize=128) 

985 def getImageImage(self, name): 

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

987 fullname = self.getImageFinder(name) 

988 try: 

989 pixmap = QtGui.QPixmap() 

990 pixmap.load(fullname) 

991 return pixmap 

992 except Exception: 

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

994 g.es_exception() 

995 return None 

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

997 dump_given = False 

998 @functools.lru_cache(maxsize=128) 

999 def getImageFinder(self, name): 

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

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

1002 exists = g.os_path_exists 

1003 getString = g.app.config.getString 

1004 

1005 def dump(var, val): 

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

1007 

1008 join = g.os_path_join 

1009 # 

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

1011 # theme .leo files actually contain these settings! 

1012 # 

1013 theme_name1 = getString('color-theme') 

1014 theme_name2 = getString('theme-name') 

1015 roots = [ 

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

1017 g.computeLeoDir(), 

1018 ] 

1019 theme_subs = [ 

1020 "themes/{theme}/Icons", 

1021 "themes/{theme}", 

1022 "Icons/{theme}", 

1023 ] 

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

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

1026 paths = [] 

1027 for theme_name in (theme_name1, theme_name2): 

1028 for root in roots: 

1029 for sub in theme_subs: 

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

1031 for root in roots: 

1032 for sub in bare_subs: 

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

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

1035 for base_dir in table: 

1036 path = join(base_dir, name) 

1037 if exists(path): 

1038 if trace: 

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

1040 return path 

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

1042 if trace: 

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

1044 return None 

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

1046 @functools.lru_cache(maxsize=128) 

1047 def getTreeImage(self, c, path): 

1048 image = QtGui.QPixmap(path) 

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

1050 return image, image.height() 

1051 return None, None 

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

1053 def insertKeyEvent(self, event, i): 

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

1055 assert isinstance(event, leoGui.LeoKeyEvent) 

1056 qevent = event.event 

1057 assert isinstance(qevent, QtGui.QKeyEvent) 

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

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

1060 if 1: 

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

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

1063 qw.insertPlainText(qevent.text()) 

1064 else: 

1065 # Make no such assumption. 

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

1067 # but this would invoke eventFilter again! 

1068 # So set this flag for eventFilter, which will 

1069 # return False, indicating that the widget must handle 

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

1071 g.app.gui.insert_char_flag = True 

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

1073 def make_main_window(self): 

1074 """Make the *singleton* QMainWindow.""" 

1075 window = QtWidgets.QMainWindow() 

1076 window.setObjectName('LeoGlobalMainWindow') 

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

1078 self.attachLeoIcon(window) 

1079 # Monkey-patch 

1080 window.closeEvent = self.close_event # Use self: g.app.gui does not exist yet. 

1081 self.runAtIdle(self.set_main_window_style_sheet) # No StyleSheetManager exists yet. 

1082 return window 

1083 

1084 def set_main_window_style_sheet(self): 

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

1086 commanders = g.app.commanders() 

1087 if commanders: 

1088 c = commanders[0] 

1089 ssm = c.styleSheetManager 

1090 ssm.set_style_sheets(w=self.main_window) 

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

1092 else: 

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

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

1095 def makeScriptButton(self, c, 

1096 args=None, 

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

1098 script=None, # The script itself. 

1099 buttonText=None, 

1100 balloonText='Script Button', 

1101 shortcut=None, bg='LightSteelBlue1', 

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

1103 ): 

1104 """ 

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

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

1107 k = c.k 

1108 if p and not buttonText: 

1109 buttonText = p.h.strip() 

1110 if not buttonText: 

1111 buttonText = 'Unnamed Script Button' 

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

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

1114 iconBar = c.frame.getIconBarObject() 

1115 b = iconBar.add(text=buttonText) 

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

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

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

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

1120 if b: 

1121 b.pack_forget() 

1122 c.bodyWantsFocus() 

1123 

1124 def executeScriptCallback(event=None, 

1125 b=b, 

1126 c=c, 

1127 buttonText=buttonText, 

1128 p=p and p.copy(), 

1129 script=script 

1130 ): 

1131 if c.disableCommandsMessage: 

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

1133 else: 

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

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

1136 define_g=define_g, define_name=define_name, silent=silent) 

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

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

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

1140 b.pack_forget() 

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

1142 

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

1144 

1145 b.configure(command=executeScriptCallback) 

1146 if shortcut: 

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

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

1149 # In qt_gui.makeScriptButton. 

1150 func = executeScriptCallback 

1151 if shortcut: 

1152 shortcut = g.KeyStroke(shortcut) 

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

1154 if ok: 

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

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

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

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

1159 # #1121. Like sc.cleanButtonText 

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

1161 # 

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

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

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

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

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

1167 """Put the help command.""" 

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

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

1170 pass # how to do selective replace?? 

1171 pc = g.app.pluginsController 

1172 table = ( 

1173 'viewrendered3.py', 

1174 'viewrendered.py', 

1175 ) 

1176 for name in table: 

1177 if pc.isLoaded(name): 

1178 vr = pc.loadOnePlugin(name) 

1179 break 

1180 else: 

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

1182 if vr: 

1183 kw = { 

1184 'c': c, 

1185 'flags': 'rst', 

1186 'kind': 'rst', 

1187 'label': '', 

1188 'msg': s, 

1189 'name': 'Apropos', 

1190 'short_title': short_title, 

1191 'title': ''} 

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

1193 c.bodyWantsFocus() 

1194 if g.unitTesting: 

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

1196 elif g.unitTesting: 

1197 pass 

1198 else: 

1199 g.es(s) 

1200 return vr # For unit tests 

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

1202 def runAtIdle(self, aFunc): 

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

1204 QtCore.QTimer.singleShot(0, aFunc) 

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

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

1207 def runMainLoop(self): 

1208 """Start the Qt main loop.""" 

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

1210 g.app.gui.dismiss_splash_screen() 

1211 c = g.app.log and g.app.log.c 

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

1213 g.app.gui.show_tips(c) 

1214 except Exception: 

1215 g.es_exception() 

1216 if self.script: 

1217 log = g.app.log 

1218 if log: 

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

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

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

1222 else: 

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

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

1225 self.runWithIpythonKernel() 

1226 else: 

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

1228 if isQt6: 

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

1230 else: 

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

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

1233 def runWithIpythonKernel(self): 

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

1235 try: 

1236 from leo.core import leoIPython 

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

1238 g.app.ipk.run() 

1239 except Exception: 

1240 g.es_exception() 

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

1242 sys.exit(1) 

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

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

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

1246 # #1286. 

1247 handlers = g.tree_popup_handlers 

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

1249 menuPos = w.mapToGlobal(point) 

1250 if not handlers: 

1251 menu.addAction("No popup handlers") 

1252 p = c.p.copy() 

1253 done = set() 

1254 for handler in handlers: 

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

1256 if handler in done: 

1257 # do not run the same handler twice 

1258 continue 

1259 try: 

1260 handler(c, p, menu) 

1261 except Exception: 

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

1263 g.es_exception() 

1264 menu.popup(menuPos) 

1265 self._contextmenu = menu 

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

1267 already_sized = False 

1268 

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

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

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

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

1273 if not self.already_sized: 

1274 self.already_sized = True 

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

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

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

1278 def show_next_tip(self, event=None): 

1279 c = g.app.log and g.app.log.c 

1280 if c: 

1281 g.app.gui.show_tips(c) 

1282 

1283 #@+<< define DialogWithCheckBox >> 

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

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

1286 

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

1288 super().__init__() 

1289 c = g.app.log.c 

1290 self.leo_checked = True 

1291 self.setObjectName('TipMessageBox') 

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

1293 # self.setMinimumSize(5000, 4000) 

1294 # Doesn't work. 

1295 # Prevent the dialog from jumping around when 

1296 # selecting multiple tips. 

1297 self.setWindowTitle('Leo Tips') 

1298 self.setText(repr(tip)) 

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

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

1301 c.styleSheetManager.set_style_sheets(w=self) 

1302 # Workaround #693. 

1303 layout = self.layout() 

1304 cb = QtWidgets.QCheckBox() 

1305 cb.setObjectName('TipCheckbox') 

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

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

1308 cb.setCheckState(state) # #2127. 

1309 cb.stateChanged.connect(controller.onClick) 

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

1311 if 0: # Does not work well. 

1312 sizePolicy = QtWidgets.QSizePolicy 

1313 vSpacer = QtWidgets.QSpacerItem( 

1314 200, 200, sizePolicy.Minimum, sizePolicy.Expanding) 

1315 layout.addItem(vSpacer) 

1316 #@-<< define DialogWithCheckBox >> 

1317 

1318 def show_tips(self, c): 

1319 if g.unitTesting: 

1320 return 

1321 from leo.core import leoTips 

1322 tm = leoTips.TipManager() 

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

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

1325 tip = tm.get_next_tip() 

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

1327 try: 

1328 c.in_qt_dialog = True 

1329 m.exec_() 

1330 finally: 

1331 c.in_qt_dialog = False 

1332 b = m.clickedButton() 

1333 if b != m.next_tip_button: 

1334 break 

1335 

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

1337 def onButton(self, m): 

1338 m.hide() 

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

1340 def onClick(self, state): 

1341 c = g.app.log.c 

1342 self.show_tips_flag = bool(state) 

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

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

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

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

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

1348 g.trace(args, keys) 

1349 return True 

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

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

1352 def createSplashScreen(self): 

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

1354 splash = None 

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

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

1357 else: 

1358 table = ('SplashScreen.xpm',) 

1359 for name in table: 

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

1361 if g.os_path_exists(fn): 

1362 pm = QtGui.QPixmap(fn) 

1363 if not pm.isNull(): 

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

1365 splash.show() 

1366 # This sleep is required to do the repaint. 

1367 QtCore.QThread.msleep(10) 

1368 splash.repaint() 

1369 break 

1370 return splash 

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

1372 def dismiss_splash_screen(self): 

1373 

1374 gui = self 

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

1376 if g.unitTesting: 

1377 return 

1378 if gui.splashScreen: 

1379 gui.splashScreen.hide() 

1380 # gui.splashScreen.deleteLater() 

1381 gui.splashScreen = None 

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

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

1384 def isTextWidget(self, w): 

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

1386 if Qsci: 

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

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

1389 

1390 def isTextWrapper(self, w): 

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

1392 if w is None: 

1393 return False 

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

1395 return True 

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

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

1398 def widget_name(self, w): 

1399 # First try the widget's getName method. 

1400 if not 'w': 

1401 name = '<no widget>' 

1402 elif hasattr(w, 'getName'): 

1403 name = w.getName() 

1404 elif hasattr(w, 'objectName'): 

1405 name = str(w.objectName()) 

1406 elif hasattr(w, '_name'): 

1407 name = w._name 

1408 else: 

1409 name = repr(w) 

1410 return name 

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

1412 if isQt5: 

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

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

1415 from PyQt5.QtTest import QSignalSpy 

1416 assert QSignalSpy 

1417 elif isQt6: 

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

1419 import PyQt6.QtTest as QtTest 

1420 # mypy complains about assigning to a type. 

1421 QSignalSpy = QtTest.QSignalSpy # type:ignore 

1422 assert QSignalSpy 

1423 else: 

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

1425 _oldConnect = QtCore.QObject.connect 

1426 _oldDisconnect = QtCore.QObject.disconnect 

1427 _oldEmit = QtCore.QObject.emit 

1428 

1429 def _wrapConnect(self, callableObject): 

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

1431 

1432 @staticmethod # type:ignore 

1433 def call(*args): 

1434 callableObject(*args) 

1435 self._oldConnect(*args) 

1436 

1437 return call 

1438 

1439 def _wrapDisconnect(self, callableObject): 

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

1441 

1442 @staticmethod # type:ignore 

1443 def call(*args): 

1444 callableObject(*args) 

1445 self._oldDisconnect(*args) 

1446 

1447 return call 

1448 

1449 def enableSignalDebugging(self, **kwargs): 

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

1451 connect, and disconnect calls.""" 

1452 f = lambda * args: None 

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

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

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

1456 

1457 def printIt(msg): 

1458 

1459 def call(*args): 

1460 print(msg, args) 

1461 

1462 return call 

1463 

1464 # Monkey-patch. 

1465 

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

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

1468 

1469 def new_emit(self, *args): 

1470 emitCall(self, *args) 

1471 self._oldEmit(self, *args) 

1472 

1473 QtCore.QObject.emit = new_emit 

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

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

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

1477 w = QtWidgets.QPushButton(parent) 

1478 w.setObjectName(name) 

1479 w.setText(label) 

1480 return w 

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

1482 def createFrame(self, parent, name, 

1483 hPolicy=None, vPolicy=None, 

1484 lineWidth=1, 

1485 shadow=None, 

1486 shape=None, 

1487 ): 

1488 """Create a Qt Frame.""" 

1489 if shadow is None: 

1490 shadow = Shadow.Plain 

1491 if shape is None: 

1492 shape = Shape.NoFrame 

1493 # 

1494 w = QtWidgets.QFrame(parent) 

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

1496 w.setFrameShape(shape) 

1497 w.setFrameShadow(shadow) 

1498 w.setLineWidth(lineWidth) 

1499 w.setObjectName(name) 

1500 return w 

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

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

1503 w = QtWidgets.QGridLayout(parent) 

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

1505 w.setSpacing(spacing) 

1506 w.setObjectName(name) 

1507 return w 

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

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

1510 hLayout = QtWidgets.QHBoxLayout(parent) 

1511 hLayout.setObjectName(name) 

1512 hLayout.setSpacing(spacing) 

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

1514 return hLayout 

1515 

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

1517 vLayout = QtWidgets.QVBoxLayout(parent) 

1518 vLayout.setObjectName(name) 

1519 vLayout.setSpacing(spacing) 

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

1521 return vLayout 

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

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

1524 w = QtWidgets.QLabel(parent) 

1525 w.setObjectName(name) 

1526 w.setText(label) 

1527 return w 

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

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

1530 w = QtWidgets.QTabWidget(parent) 

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

1532 w.setObjectName(name) 

1533 return w 

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

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

1536 if kind1 is None: 

1537 kind1 = Policy.Ignored 

1538 if kind2 is None: 

1539 kind2 = Policy.Ignored 

1540 sizePolicy = QtWidgets.QSizePolicy(kind1, kind2) 

1541 sizePolicy.setHorizontalStretch(0) 

1542 sizePolicy.setVerticalStretch(0) 

1543 sizePolicy.setHeightForWidth(widget.sizePolicy().hasHeightForWidth()) 

1544 widget.setSizePolicy(sizePolicy) 

1545 #@-others 

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

1547class StyleClassManager: 

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

1549 #@+others 

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

1551 def update_view(self, w): 

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

1553 

1554 :param QWidgit w: widgit to style 

1555 """ 

1556 

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

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

1559 def add_sclass(self, w, prop): 

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

1561 if not prop: 

1562 return 

1563 props = self.sclasses(w) 

1564 if isinstance(prop, str): 

1565 props.append(prop) 

1566 else: 

1567 props.extend(prop) 

1568 

1569 self.set_sclasses(w, props) 

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

1571 def clear_sclasses(self, w): 

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

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

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

1575 def has_sclass(self, w, prop): 

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

1577 if not prop: 

1578 return None 

1579 props = self.sclasses(w) 

1580 if isinstance(prop, str): 

1581 ans = [prop in props] 

1582 else: 

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

1584 return all(ans) 

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

1586 def remove_sclass(self, w, prop): 

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

1588 if not prop: 

1589 return 

1590 props = self.sclasses(w) 

1591 if isinstance(prop, str): 

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

1593 else: 

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

1595 

1596 self.set_sclasses(w, props) 

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

1598 def sclass_tests(self): 

1599 """Test style class property manipulation functions""" 

1600 

1601 # pylint: disable=len-as-condition 

1602 

1603 

1604 class Test_W: 

1605 """simple standin for QWidget for testing""" 

1606 

1607 def __init__(self): 

1608 self.x = '' 

1609 

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

1611 return self.x or default 

1612 

1613 def setProperty(self, name, value): 

1614 self.x = value 

1615 

1616 w = Test_W() 

1617 

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

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

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

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

1622 

1623 self.add_sclass(w, 'test') 

1624 

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

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

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

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

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

1630 

1631 self.add_sclass(w, 'test') 

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

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

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

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

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

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

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

1639 

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

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

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

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

1644 

1645 self.toggle_sclass(w, 'third') 

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

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

1648 self.toggle_sclass(w, 'third') 

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

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

1651 

1652 self.clear_sclasses(w) 

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

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

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

1656 def sclasses(self, w): 

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

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

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

1660 def set_sclasses(self, w, classes): 

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

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

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

1664 def toggle_sclass(self, w, prop): 

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

1666 if not prop: 

1667 return 

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

1669 

1670 if isinstance(prop, str): 

1671 prop = set([prop]) 

1672 else: 

1673 prop = set(prop) 

1674 

1675 current = props.intersection(prop) 

1676 props.update(prop) 

1677 props = props.difference(current) 

1678 

1679 self.set_sclasses(w, props) 

1680 #@-others 

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

1682class StyleSheetManager: 

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

1684 #@+others 

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

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

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

1688 """Ctor the ReloadStyle class.""" 

1689 self.c = c 

1690 self.color_db = leoColor.leo_color_database 

1691 self.safe = safe 

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

1693 self.mng = StyleClassManager() 

1694 # This warning is inappropriate in some contexts. 

1695 # if not self.settings_p: 

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

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

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

1699 def reload_settings(self, sheet=None): 

1700 """ 

1701 Recompute and apply the stylesheet. 

1702 Called automatically by the reload-settings commands. 

1703 """ 

1704 if not sheet: 

1705 sheet = self.get_style_sheet_from_settings() 

1706 if sheet: 

1707 w = self.get_master_widget() 

1708 w.setStyleSheet(sheet) 

1709 # self.c.redraw() 

1710 

1711 reloadSettings = reload_settings 

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

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

1714 def compute_icon_directories(self): 

1715 """ 

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

1717 """ 

1718 exists = g.os_path_exists 

1719 home = g.app.homeDir 

1720 join = g.os_path_finalize_join 

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

1722 table = [ 

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

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

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

1726 join(leo, 'themes'), 

1727 join(leo, 'Icons'), 

1728 ] 

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

1730 for directory in self.compute_theme_directories(): 

1731 if directory not in table: 

1732 table.append(directory) 

1733 directory2 = join(directory, 'Icons') 

1734 if directory2 not in table: 

1735 table.append(directory2) 

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

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

1738 def compute_theme_directories(self): 

1739 """ 

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

1741 """ 

1742 lm = g.app.loadManager 

1743 table = lm.computeThemeDirectories()[:] 

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

1745 if directory and directory not in table: 

1746 table.insert(0, directory) 

1747 return table 

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

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

1750 def find_icon_path(self, setting): 

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

1752 c = self.c 

1753 s = c.config.getString(setting) 

1754 if not s: 

1755 return None # Not an error. 

1756 for directory in self.compute_icon_directories(): 

1757 path = g.os_path_finalize_join(directory, s) 

1758 if g.os_path_exists(path): 

1759 return path 

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

1761 return None 

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

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

1764 def default_style_sheet(self): 

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

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

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

1768 return '''\ 

1769 

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

1771 QSplitter::handle { 

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

1773 } 

1774 QSplitter { 

1775 border-color: white; 

1776 background-color: white; 

1777 border-width: 3px; 

1778 border-style: solid; 

1779 } 

1780 QTreeWidget { 

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

1782 } 

1783 QsciScintilla { 

1784 background-color: pink; 

1785 } 

1786 ''' 

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

1788 def get_data(self, setting): 

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

1790 c = self.c 

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

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

1793 def get_style_sheet_from_settings(self): 

1794 """ 

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

1796 Return the text of the relevant node. 

1797 """ 

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

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

1800 if aList2: 

1801 aList1.extend(aList2) 

1802 sheet = ''.join(aList1) 

1803 sheet = self.expand_css_constants(sheet) 

1804 return sheet 

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

1806 def print_style_sheet(self): 

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

1808 w = self.get_master_widget() 

1809 sheet = w.styleSheet() 

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

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

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

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

1814 if g.app.loadedThemes: 

1815 return 

1816 c = self.c 

1817 if top is None: 

1818 top = c.frame.top 

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

1820 if all: 

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

1822 sheets = [] 

1823 for name in selectors: 

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

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

1826 if sheet: 

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

1828 sheet = ''.join(sheet) 

1829 else: 

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

1831 if sheet and sheet.strip(): 

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

1833 sheet = line0 + sheet 

1834 sheets.append(sheet) 

1835 if sheets: 

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

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

1838 c.active_stylesheet = sheet 

1839 sheet = self.expand_css_constants(sheet) 

1840 if not sheet: 

1841 sheet = self.default_style_sheet() 

1842 if w is None: 

1843 w = self.get_master_widget(top) 

1844 w.setStyleSheet(sheet) 

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

1846 # Computations on stylesheets themeselves. 

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

1848 css_warning_given = False 

1849 

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

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

1852 c = self.c 

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

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

1855 if settingsDict is None: 

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

1857 if 0: 

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

1859 for key in settingsDict.keys(): 

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

1861 constants, deltas = self.adjust_sizes(settingsDict) 

1862 if trace: 

1863 print('') 

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

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

1866 sheet = self.replace_indicator_constants(sheet) 

1867 for pass_n in range(10): 

1868 to_do = self.find_constants_referenced(sheet) 

1869 if not to_do: 

1870 break 

1871 old_sheet = sheet 

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

1873 if sheet == old_sheet: 

1874 break 

1875 else: 

1876 g.trace('Too many iterations') 

1877 if to_do: 

1878 g.trace('Unresolved @constants') 

1879 g.printObj(to_do) 

1880 sheet = self.resolve_urls(sheet) 

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

1882 return sheet 

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

1884 def adjust_sizes(self, settingsDict): 

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

1886 c = self.c 

1887 constants = {} 

1888 deltas = c._style_deltas 

1889 for delta in c._style_deltas: 

1890 # adjust @font-size-body by font_size_delta 

1891 # easily extendable to @font-size-* 

1892 val = c.config.getString(delta) 

1893 passes = 10 

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

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

1896 val = settingsDict.get(key) 

1897 if val: 

1898 val = val.val 

1899 passes -= 1 

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

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

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

1903 size = max(1, float(size) + deltas[delta]) 

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

1905 return constants, deltas 

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

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

1908 

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

1910 for const in to_do: 

1911 value = None 

1912 if const in constants: 

1913 # This constant is about to be removed. 

1914 value = constants[const] 

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

1916 self.css_warning_given = True 

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

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

1919 else: 

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

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

1922 value = settingsDict.get(key) 

1923 if value is not None: 

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

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

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

1927 value = value.val 

1928 elif key in self.color_db: 

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

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

1931 value = self.color_db.get(key) 

1932 if value: 

1933 # Partial fix for #780. 

1934 try: 

1935 # Don't replace shorter constants occuring in larger. 

1936 sheet = re.sub( 

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

1938 value, 

1939 sheet, 

1940 ) 

1941 except Exception: 

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

1943 g.es_print(sheet) 

1944 g.es_exception() 

1945 else: 

1946 pass 

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

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

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

1950 return sheet 

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

1952 def find_constants_referenced(self, text): 

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

1954 constants match:: 

1955 

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

1957 i.e. @foo_1-5 

1958 

1959 :Parameters: 

1960 - `text`: text to search 

1961 """ 

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

1963 # Exempt references to Leo constructs. 

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

1965 if s in aList: 

1966 aList.remove(s) 

1967 return aList 

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

1969 def replace_indicator_constants(self, sheet): 

1970 """ 

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

1972 

1973 image: @tree-image-closed 

1974 image: @tree-image-open 

1975 

1976 by:: 

1977 

1978 url(path/closed.png) 

1979 url(path/open.png) 

1980 

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

1982 

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

1984 

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

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

1987 

1988 Return the updated stylesheet. 

1989 """ 

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

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

1992 # Make all substitutions in the stylesheet. 

1993 table = ( 

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

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

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

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

1998 ) 

1999 for path, pattern in table: 

2000 for mo in pattern.finditer(sheet): 

2001 old = mo.group(0) 

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

2003 sheet = sheet.replace(old, new) 

2004 return sheet 

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

2006 def resolve_urls(self, sheet): 

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

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

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

2010 join = g.os_path_finalize_join 

2011 directories = self.compute_icon_directories() 

2012 paths_traced = False 

2013 if trace: 

2014 paths_traced = True 

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

2016 g.printObj(directories) 

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

2018 replacements = [] 

2019 for mo in pattern.finditer(sheet): 

2020 url = mo.group(1) 

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

2022 url = url[2:] 

2023 elif g.os_path_isabs(url): 

2024 if trace: 

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

2026 continue 

2027 for directory in directories: 

2028 path = join(directory, url) 

2029 if g.os_path_exists(path): 

2030 if trace: 

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

2032 old = mo.group(0) 

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

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

2035 break 

2036 else: 

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

2038 if not paths_traced: 

2039 paths_traced = True 

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

2041 g.printObj(directories) 

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

2043 for old, new in reversed(replacements): 

2044 sheet = sheet.replace(old, new) 

2045 return sheet 

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

2047 def munge(self, stylesheet): 

2048 """ 

2049 Return the stylesheet without extra whitespace. 

2050 

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

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

2053 """ 

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

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

2056 return s.rstrip() 

2057 # Don't care about ending newline. 

2058 #@+node:tom.20220310224019.1: *4* ssm.rescale_sizes 

2059 def rescale_sizes(self, sheet, factor): 

2060 """ 

2061 #@+<< docstring >> 

2062 #@+node:tom.20220310224918.1: *5* << docstring >> 

2063 Rescale all pt or px sizes in CSS stylesheet or Leo theme. 

2064 

2065 Sheets can have either "logical" or "actual" sizes.  

2066 "Logical" sizes are ones like "@font-family-base = 10.6pt". 

2067 "Actual" sizes are the ones in the "qt-gui-plugin-style-sheet" subtree. 

2068 They look like "font-size: 11pt;" 

2069 

2070 In Qt stylesheets, only sizes in pt or px are honored, so 

2071 those are the only ones changed by this method. Padding, 

2072 margin, etc. sizes will be changed as well as font sizes. 

2073 

2074 Sizes do not have to be integers (e.g., 10.5 pt). Qt honors 

2075 non-integer point sizes, with at least a 0.5pt granularity. 

2076 It's currently unknown how non-integer px sizes are handled. 

2077 

2078 No size will be scaled down to less than 1. 

2079 

2080 ARGUMENTS 

2081 sheet -- a CSS stylesheet or a Leo theme as a string. The Leo 

2082 theme file should be read as a string before being passed 

2083 to this method. If a Leo theme, the output will be a 

2084 well-formed Leo outline. 

2085 

2086 scale -- the scaling factor as a float or integer. For example, 

2087 a scale of 1.5 will increase all the sizes by a factor of 1.5. 

2088 

2089 RETURNS 

2090 the modified sheet as a string. 

2091 

2092 #@-<< docstring >> 

2093 """ 

2094 RE = r'([=:])[ ]*([.1234567890]+)(p[tx])' 

2095 

2096 def scale(matchobj, scale=factor): 

2097 prefix = matchobj.group(1) 

2098 sz = matchobj.group(2) 

2099 units = matchobj.group(3) 

2100 try: 

2101 scaled = max(float(sz) * factor, 1) 

2102 except Exception as e: 

2103 g.es('ssm.rescale_fonts:', e) 

2104 return None 

2105 return f'{prefix} {scaled:.1f}{units}' 

2106 

2107 newsheet = re.sub(RE, scale, sheet) 

2108 return newsheet 

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

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

2111 def get_master_widget(self, top=None): 

2112 """ 

2113 Carefully return the master widget. 

2114 c.frame.top is a DynamicWindow. 

2115 """ 

2116 if top is None: 

2117 top = self.c.frame.top 

2118 master = top.leo_master or top 

2119 return master 

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

2121 def set_selected_style_sheet(self): 

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

2123 if not g.unitTesting: 

2124 c = self.c 

2125 sheet = c.p.b 

2126 sheet = self.expand_css_constants(sheet) 

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

2128 w.setStyleSheet(sheet) 

2129 #@-others 

2130#@-others 

2131#@@language python 

2132#@@tabwidth -4 

2133#@@pagewidth 70 

2134#@-leo