Coverage for C:\Repos\leo-editor\leo\plugins\qt_gui.py: 19%

1267 statements  

« prev     ^ index     » next       coverage.py v6.4, created at 2022-05-24 10:21 -0500

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 qt_gui.py >> 

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

7import datetime 

8import functools 

9import re 

10import sys 

11import textwrap 

12from typing import Any, Callable, Dict, List, Optional, Tuple, Union 

13from typing import TYPE_CHECKING 

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 

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

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

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

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 qt_gui.py >> 

30#@+<< type aliases qt_gui.py >> 

31#@+node:ekr.20220415183421.1: ** << type aliases qt_gui.py >> 

32if TYPE_CHECKING: # Always False at runtime. 

33 from leo.core.leoCommands import Commands as Cmdr 

34 from leo.core.leoNodes import Position as Pos 

35else: 

36 Cmdr = Any 

37 Pos = Any 

38Event = Any 

39Widget = Any 

40Wrapper = Any 

41 

42#@-<< type aliases qt_gui.py >> 

43#@+others 

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

45def init() -> bool: 

46 

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

48 return False 

49 if not QtCore: 

50 return False 

51 if g.app.gui: 

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

53 g.app.gui = LeoQtGui() 

54 g.app.gui.finishCreate() 

55 g.plugin_signon(__name__) 

56 return True 

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

58class LeoQtGui(leoGui.LeoGui): 

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

60 #@+others 

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

62 def __init__(self) -> None: 

63 """Ctor for LeoQtGui class.""" 

64 super().__init__('qt') # Initialize the base class. 

65 self.active = True 

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

67 self.iconimages: Dict = {} 

68 self.globalFindDialog: Widget = None 

69 self.idleTimeClass: Any = qt_idle_time.IdleTime 

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

71 self.mGuiName = 'qt' 

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

73 self.plainTextWidget: Any = qt_text.PlainTextWrapper 

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

75 self.styleSheetManagerClass = StyleSheetManager 

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

77 QtWidgets.QApplication.setDesktopSettingsAware(True) 

78 # Create objects... 

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

80 self.reloadSettings() 

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

82 

83 # Define various classes key stokes. 

84 #@+<< define FKeys >> 

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

86 self.FKeys = [ 

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

88 # These do not generate keystrokes on MacOs. 

89 #@-<< define FKeys >> 

90 #@+<< define ignoreChars >> 

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

92 # Always ignore these characters 

93 self.ignoreChars = [ 

94 # These are in ks.special characters. 

95 # They should *not* be ignored. 

96 # 'Left', 'Right', 'Up', 'Down', 

97 # 'Next', 'Prior', 

98 # 'Home', 'End', 

99 # 'Delete', 'Escape', 

100 # 'BackSpace', 'Linefeed', 'Return', 'Tab', 

101 # F-Keys are also ok. 

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

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

104 'KP_Multiply, KP_Separator,KP_Space, KP_Subtract, KP_Tab', 

105 'KP_F1', 'KP_F2', 'KP_F3', 'KP_F4', 

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

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

108 'KP_Add', 'KP_Decimal', 'KP_Divide', 'KP_Enter', 'KP_Equal', 

109 'CapsLock', 'Caps_Lock', 

110 'NumLock', 'Num_Lock', 

111 'ScrollLock', 

112 'Alt_L', 'Alt_R', 

113 'Control_L', 'Control_R', 

114 'Meta_L', 'Meta_R', 

115 'Shift_L', 'Shift_R', 

116 'Win_L', 'Win_R', # Clearly, these should never be generated. 

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

118 'Break', 'Pause', 'Sys_Req', 

119 'Begin', 'Clear', # Don't know what these are. 

120 ] 

121 #@-<< define ignoreChars >> 

122 #@+<< define specialChars >> 

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

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

125 self.specialChars = [ 

126 # These are *not* special keys. 

127 # 'BackSpace', 'Linefeed', 'Return', 'Tab', 

128 'Left', 'Right', 'Up', 'Down', # Arrow keys 

129 'Next', 'Prior', # Page up/down keys. 

130 'Home', 'End', # Home end keys. 

131 'Delete', 'Escape', # Others. 

132 'Enter', 'Insert', 'Ins', # These should only work if bound. 

133 'Menu', # #901. 

134 'PgUp', 'PgDn', # #868. 

135 ] 

136 #@-<< define specialChars >> 

137 # Put up the splash screen() 

138 if (g.app.use_splash_screen and 

139 not g.app.batchMode and 

140 not g.app.silentMode and 

141 not g.unitTesting 

142 ): 

143 self.splashScreen = self.createSplashScreen() 

144 # qtFrame.finishCreate does all the other work. 

145 self.frameFactory = qt_frame.TabbedFrameFactory() 

146 

147 def reloadSettings(self) -> None: 

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

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

150 def destroySelf(self) -> None: 

151 

152 QtCore.pyqtRemoveInputHook() 

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

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

155 self.qtApp.quit() 

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

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

158 def replaceClipboardWith(self, s: str) -> None: 

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

160 cb = self.qtApp.clipboard() 

161 if cb: 

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

163 s = g.toUnicode(s) 

164 QtWidgets.QApplication.processEvents() 

165 # Fix #241: QMimeData object error 

166 cb.setText(s) 

167 QtWidgets.QApplication.processEvents() 

168 else: 

169 g.trace('no clipboard!') 

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

171 def getTextFromClipboard(self) -> str: 

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

173 cb = self.qtApp.clipboard() 

174 if cb: 

175 QtWidgets.QApplication.processEvents() 

176 return cb.text() 

177 g.trace('no clipboard!') 

178 return '' 

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

180 def setClipboardSelection(self, s: str) -> None: 

181 """ 

182 Set the clipboard selection to s. 

183 There are problems with PyQt5. 

184 """ 

185 if isQt5 or isQt6: 

186 # Alas, returning s reopens #218. 

187 return 

188 if s: 

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

190 cb = self.qtApp.clipboard() 

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

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

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

194 def alert(self, c: Cmdr, message: str) -> None: 

195 if g.unitTesting: 

196 return 

197 dialog = QtWidgets.QMessageBox(None) 

198 dialog.setWindowTitle('Alert') 

199 dialog.setText(message) 

200 dialog.setIcon(Icon.Warning) 

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

202 try: 

203 c.in_qt_dialog = True 

204 dialog.raise_() 

205 dialog.exec_() 

206 finally: 

207 c.in_qt_dialog = False 

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

209 def makeFilter(self, filetypes: List[str]) -> str: 

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

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

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

213 return ';;'.join(filters) 

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

215 def openFindDialog(self, c: Cmdr) -> None: 

216 if g.unitTesting: 

217 return 

218 dialog = self.globalFindDialog 

219 if not dialog: 

220 dialog = self.createFindDialog(c) 

221 self.globalFindDialog = dialog 

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

223 if c: 

224 dialog.setStyleSheet(c.active_stylesheet) 

225 # Set the commander's FindTabManager. 

226 assert g.app.globalFindTabManager 

227 c.ftm = g.app.globalFindTabManager 

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

229 else: 

230 fn = 'Untitled' 

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

232 if c: 

233 c.inCommand = False 

234 if dialog.isVisible(): 

235 # The order is important, and tricky. 

236 dialog.focusWidget() 

237 dialog.show() 

238 dialog.raise_() 

239 dialog.activateWindow() 

240 else: 

241 dialog.show() 

242 dialog.exec_() 

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

244 def createFindDialog(self, c: Cmdr) -> Widget: 

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

246 if c: 

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

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

249 w = top.findTab # type:ignore 

250 dialog = QtWidgets.QDialog() 

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

252 

253 def closeEvent(event: Event) -> None: 

254 event.ignore() 

255 dialog.hide() 

256 

257 dialog.closeEvent = closeEvent 

258 layout = QtWidgets.QVBoxLayout(dialog) 

259 layout.addWidget(w) 

260 self.attachLeoIcon(dialog) 

261 dialog.setLayout(layout) 

262 if c: 

263 c.styleSheetManager.set_style_sheets(w=dialog) 

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

265 # This makes most standard bindings available. 

266 dialog.setModal(False) 

267 return dialog 

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

269 def createComparePanel(self, c: Cmdr) -> None: 

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

271 pass # This window is optional. 

272 

273 def createFindTab(self, c: Cmdr, parentFrame: Widget) -> None: 

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

275 pass # Now done in dw.createFindTab. 

276 

277 def createLeoFrame(self, c: Cmdr, title: str) -> Wrapper: 

278 """Create a new Leo frame.""" 

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

280 

281 def createSpellTab(self, c: Cmdr, spellHandler: Any, tabName: str) -> Wrapper: 

282 if g.unitTesting: 

283 return None 

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

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

286 def runAboutLeoDialog(self, 

287 c: Cmdr, version: str, theCopyright: str, url: str, email: str, 

288 ) -> None: 

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

290 if g.unitTesting: 

291 return 

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

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

294 dialog.setIcon(Icon.Information) 

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

296 dialog.setDefaultButton(yes) 

297 try: 

298 c.in_qt_dialog = True 

299 dialog.raise_() 

300 dialog.exec_() 

301 finally: 

302 c.in_qt_dialog = False 

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

304 def runAskDateTimeDialog( 

305 self, 

306 c: Cmdr, 

307 title: str, 

308 message: str='Select Date/Time', 

309 init: Any=None, 

310 step_min: Dict=None, 

311 ) -> None: 

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

313 

314 init - a datetime, default now 

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

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

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

318 when you roll the mouse wheel. 

319 

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

321 

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

323 message="When is it?", 

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

325 

326 """ 

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

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

329 

330 

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

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

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

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

335 """ 

336 

337 def __init__(self, parent: Widget=None, init: Any=None, step_min: Any=None) -> None: 

338 if step_min is None: 

339 step_min = {} 

340 self.step_min = step_min 

341 if init: 

342 super().__init__(init, parent) 

343 else: 

344 super().__init__(parent) 

345 

346 def stepBy(self, step: int) -> None: 

347 cs = self.currentSection() 

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

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

350 QtWidgets.QDateTimeEdit.stepBy(self, step) 

351 

352 

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

354 

355 def __init__( 

356 self, 

357 parent: Wrapper=None, 

358 message: str='Select Date/Time', 

359 init: Any=None, 

360 step_min: Dict=None, 

361 ) -> None: 

362 if step_min is None: 

363 step_min = {} 

364 super().__init__(parent) 

365 layout = QtWidgets.QVBoxLayout() 

366 self.setLayout(layout) 

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

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

369 self.dt.setCalendarPopup(True) 

370 layout.addWidget(self.dt) 

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

372 layout.addWidget(buttonBox) 

373 buttonBox.accepted.connect(self.accept) 

374 buttonBox.rejected.connect(self.reject) 

375 

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

377 if g.unitTesting: 

378 return None 

379 if step_min is None: 

380 step_min = {} 

381 if not init: 

382 init = datetime.datetime.now() 

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

384 if c: 

385 dialog.setStyleSheet(c.active_stylesheet) 

386 dialog.setWindowTitle(title) 

387 try: 

388 c.in_qt_dialog = True 

389 dialog.raise_() 

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

391 finally: 

392 c.in_qt_dialog = False 

393 else: 

394 dialog.setWindowTitle(title) 

395 dialog.raise_() 

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

397 if val == DialogCode.Accepted: 

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

399 return None 

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

401 def runAskLeoIDDialog(self) -> Optional[str]: 

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

403 if g.unitTesting: 

404 return None 

405 message = ( 

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

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

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

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

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

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

412 parent = None 

413 title = 'Enter Leo id' 

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

415 return s 

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

417 def runAskOkCancelNumberDialog(self, 

418 c: Cmdr, title: str, message: str, cancelButtonText: str=None, okButtonText: str=None, 

419 ) -> Optional[int]: 

420 """Create and run askOkCancelNumber dialog .""" 

421 if g.unitTesting: 

422 return None 

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

424 dialog = QtWidgets.QInputDialog() 

425 if c: 

426 dialog.setStyleSheet(c.active_stylesheet) 

427 dialog.setWindowTitle(title) 

428 dialog.setLabelText(message) 

429 if cancelButtonText: 

430 dialog.setCancelButtonText(cancelButtonText) 

431 if okButtonText: 

432 dialog.setOkButtonText(okButtonText) 

433 self.attachLeoIcon(dialog) 

434 dialog.raise_() 

435 ok = dialog.exec_() 

436 n = dialog.textValue() 

437 try: 

438 n = float(n) 

439 except ValueError: 

440 n = None 

441 return n if ok else None 

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

443 def runAskOkCancelStringDialog( 

444 self, 

445 c: Cmdr, 

446 title: str, 

447 message: str, 

448 cancelButtonText: str=None, 

449 okButtonText: str=None, 

450 default: str="", 

451 wide: bool=False, 

452 ) -> Optional[str]: 

453 """Create and run askOkCancelString dialog. 

454 

455 wide - edit a long string 

456 """ 

457 if g.unitTesting: 

458 return None 

459 dialog = QtWidgets.QInputDialog() 

460 if c: 

461 dialog.setStyleSheet(c.active_stylesheet) 

462 dialog.setWindowTitle(title) 

463 dialog.setLabelText(message) 

464 dialog.setTextValue(default) 

465 if wide: 

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

467 if cancelButtonText: 

468 dialog.setCancelButtonText(cancelButtonText) 

469 if okButtonText: 

470 dialog.setOkButtonText(okButtonText) 

471 self.attachLeoIcon(dialog) 

472 dialog.raise_() 

473 ok = dialog.exec_() 

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

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

476 def runAskOkDialog(self, c: Cmdr, title: str, message: str=None, text: str="Ok") -> None: 

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

478 if g.unitTesting: 

479 return 

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

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

482 if stylesheet: 

483 dialog.setStyleSheet(stylesheet) 

484 dialog.setWindowTitle(title) 

485 if message: 

486 dialog.setText(message) 

487 dialog.setIcon(Information.Information) 

488 dialog.addButton(text, ButtonRole.YesRole) 

489 try: 

490 c.in_qt_dialog = True 

491 dialog.raise_() 

492 dialog.exec_() 

493 finally: 

494 c.in_qt_dialog = False 

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

496 def runAskYesNoCancelDialog( 

497 self, 

498 c: Cmdr, 

499 title: str, 

500 message: str=None, 

501 yesMessage: str="&Yes", 

502 noMessage: str="&No", 

503 yesToAllMessage: str=None, 

504 defaultButton: str="Yes", 

505 cancelMessage: str=None, 

506 ) -> str: 

507 """ 

508 Create and run an askYesNo dialog. 

509 

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

511 

512 """ 

513 if g.unitTesting: 

514 return None 

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

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

517 if stylesheet: 

518 dialog.setStyleSheet(stylesheet) 

519 if message: 

520 dialog.setText(message) 

521 dialog.setIcon(Information.Warning) 

522 dialog.setWindowTitle(title) 

523 # Creation order determines returned value. 

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

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

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

527 if yesToAllMessage: 

528 dialog.addButton(yesToAllMessage, ButtonRole.YesRole) 

529 if defaultButton == "Yes": 

530 dialog.setDefaultButton(yes) 

531 elif defaultButton == "No": 

532 dialog.setDefaultButton(no) 

533 else: 

534 dialog.setDefaultButton(cancel) 

535 try: 

536 c.in_qt_dialog = True 

537 dialog.raise_() # #2246. 

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

539 finally: 

540 c.in_qt_dialog = False 

541 # val is the same as the creation order. 

542 # Tested with both Qt6 and Qt5. 

543 return { 

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

545 }.get(val, 'cancel') 

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

547 def runAskYesNoDialog(self, 

548 c: Cmdr, title: str, message: str=None, yes_all: bool=False, no_all: bool=False, 

549 ) -> str: 

550 """ 

551 Create and run an askYesNo dialog. 

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

553 

554 :Parameters: 

555 - `c`: commander 

556 - `title`: dialog title 

557 - `message`: dialog message 

558 - `yes_all`: bool - show YesToAll button 

559 - `no_all`: bool - show NoToAll button 

560 """ 

561 if g.unitTesting: 

562 return None 

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

564 # Creation order determines returned value. 

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

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

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

568 if yes_all: 

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

570 if no_all: 

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

572 if c: 

573 dialog.setStyleSheet(c.active_stylesheet) 

574 dialog.setWindowTitle(title) 

575 if message: 

576 dialog.setText(message) 

577 dialog.setIcon(Information.Warning) 

578 dialog.setDefaultButton(yes) 

579 if c: 

580 try: 

581 c.in_qt_dialog = True 

582 dialog.raise_() 

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

584 finally: 

585 c.in_qt_dialog = False 

586 else: 

587 dialog.raise_() 

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

589 # val is the same as the creation order. 

590 # Tested with both Qt6 and Qt5. 

591 return { 

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

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

594 }.get(val, 'cancel') 

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

596 def runOpenDirectoryDialog(self, title: str, startdir: str) -> Optional[str]: 

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

598 if g.unitTesting: 

599 return None 

600 dialog = QtWidgets.QFileDialog() 

601 self.attachLeoIcon(dialog) 

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

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

604 def runOpenFileDialog( 

605 self, 

606 c: Cmdr, 

607 title: str, 

608 filetypes: List[str], 

609 defaultextension: str='', 

610 multiple: bool=False, 

611 startpath: str=None, 

612 ) -> Union[List[str], str]: # Return type depends on the evil multiple keyword. 

613 """ 

614 Create and run an Qt open file dialog. 

615 """ 

616 # pylint: disable=arguments-differ 

617 if g.unitTesting: 

618 return '' 

619 

620 # 2018/03/14: Bug fixes: 

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

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

623 if not startpath: 

624 # Returns c.last_dir or os.curdir 

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

626 filter_ = self.makeFilter(filetypes) 

627 dialog = QtWidgets.QFileDialog() 

628 if c: 

629 dialog.setStyleSheet(c.active_stylesheet) 

630 self.attachLeoIcon(dialog) 

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

632 if c: 

633 try: 

634 c.in_qt_dialog = True 

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

636 finally: 

637 c.in_qt_dialog = False 

638 else: 

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

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

641 val, junk_selected_filter = val 

642 if multiple: 

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

644 if c and files: 

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

646 # A consequence of the evil "multiple" kwarg. 

647 return files # type:ignore 

648 s = g.os_path_normslashes(val) 

649 if c and s: 

650 c.last_dir = g.os_path_dirname(s) 

651 return s 

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

653 def runPropertiesDialog( 

654 self, 

655 title: str='Properties', 

656 data: Any=None, 

657 callback: Callable=None, 

658 buttons: Any=None, 

659 ) -> Tuple[str, Dict]: 

660 """Dispay a modal TkPropertiesDialog""" 

661 if not g.unitTesting: 

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

663 return 'Cancel', {} 

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

665 def runSaveFileDialog(self, 

666 c: Cmdr, title: str='Save', filetypes: List[str]=None, defaultextension: str='', 

667 ) -> str: 

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

669 if g.unitTesting: 

670 return '' 

671 dialog = QtWidgets.QFileDialog() 

672 if c: 

673 dialog.setStyleSheet(c.active_stylesheet) 

674 self.attachLeoIcon(dialog) 

675 try: 

676 c.in_qt_dialog = True 

677 obj = dialog.getSaveFileName( 

678 None, # parent 

679 title, 

680 # os.curdir, 

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

682 self.makeFilter(filetypes or []), 

683 ) 

684 finally: 

685 c.in_qt_dialog = False 

686 else: 

687 self.attachLeoIcon(dialog) 

688 obj = dialog.getSaveFileName( 

689 None, # parent 

690 title, 

691 # os.curdir, 

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

693 self.makeFilter(filetypes or []), 

694 ) 

695 # Bizarre: PyQt5 version can return a tuple! 

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

697 s = s or '' 

698 if c and s: 

699 c.last_dir = g.os_path_dirname(s) 

700 return s 

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

702 def runScrolledMessageDialog( 

703 self, 

704 short_title: str='', 

705 title: str='Message', 

706 label: str='', 

707 msg: str='', 

708 c: Cmdr=None, 

709 **keys: Any, 

710 ) -> None: 

711 if g.unitTesting: 

712 return None 

713 

714 def send() -> Any: 

715 return g.doHook('scrolledMessage', 

716 short_title=short_title, title=title, 

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

718 

719 if not c or not c.exists: 

720 #@+<< no c error>> 

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

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

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

724 "as a keyword argument", 

725 g.callers() 

726 )) 

727 #@-<< no c error>> 

728 else: 

729 retval = send() 

730 if retval: 

731 return retval 

732 #@+<< load viewrendered plugin >> 

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

734 pc = g.app.pluginsController 

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

736 if ( 

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

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

739 ): 

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

741 if vr: 

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

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

744 #@-<< load viewrendered plugin >> 

745 retval = send() 

746 if retval: 

747 return retval 

748 #@+<< no dialog error >> 

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

750 g.es_print_error( 

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

752 #@-<< no dialog error >> 

753 #@+<< emergency fallback >> 

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

755 dialog = QtWidgets.QMessageBox(None) 

756 # That is, not a fixed size dialog. 

757 dialog.setWindowFlags(WindowType.Dialog) 

758 dialog.setWindowTitle(title) 

759 if msg: 

760 dialog.setText(msg) 

761 dialog.setIcon(Icon.Information) 

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

763 try: 

764 c.in_qt_dialog = True 

765 if isQt6: 

766 dialog.exec() 

767 else: 

768 dialog.exec_() 

769 finally: 

770 c.in_qt_dialog = False 

771 #@-<< emergency fallback >> 

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

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

774 def close_event(self, event: Event) -> None: 

775 

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

777 g.app.sessionManager.save_snapshot() 

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

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

780 if not allow: 

781 event.ignore() 

782 return 

783 event.accept() 

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

785 # deactivated_name = '' 

786 

787 deactivated_widget = None 

788 

789 def onDeactivateEvent(self, event: Event, c: Cmdr, obj: Any, tag: str) -> None: 

790 """ 

791 Gracefully deactivate the Leo window. 

792 Called several times for each window activation. 

793 """ 

794 w = self.get_focus() 

795 w_name = w and w.objectName() 

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

797 g.trace(repr(w_name)) 

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

799 # Careful: never save headline widgets. 

800 if w_name == 'headline': 

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

802 else: 

803 self.deactivated_widget = w if w_name else None 

804 # Causes problems elsewhere... 

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

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

807 # self.active = False 

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

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

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

811 # Called from eventFilter 

812 

813 def onActivateEvent(self, event: Event, c: Cmdr, obj: Any, tag: str) -> None: 

814 """ 

815 Restore the focus when the Leo window is activated. 

816 Called several times for each window activation. 

817 """ 

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

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

820 self.deactivated_widget = None 

821 w_name = w and w.objectName() 

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

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

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

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

826 c.vimCommands.on_activate() 

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

828 if g.isMac: 

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

830 else: 

831 # Leo 5.6: Recover from missing focus. 

832 # c.idle_focus_handler can't do this. 

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

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

835 if trace: 

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

837 c.widgetWantsFocusNow(w) 

838 else: 

839 if trace: 

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

841 c.bodyWantsFocusNow() 

842 # Cause problems elsewhere. 

843 # if c.exists and self.deactivated_name: 

844 # self.active = True 

845 # w_name = self.deactivated_name 

846 # self.deactivated_name = None 

847 # if c.p.v: 

848 # c.p.v.restoreCursorAndScroll() 

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

850 # c.treeWantsFocusNow() 

851 # else: 

852 # c.bodyWantsFocusNow() 

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

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

855 def setFilter(self, c: Cmdr, obj: Any, w: Wrapper, tag: str) -> None: 

856 """ 

857 Create an event filter in obj. 

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

859 """ 

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

861 # QTextEditWrapper,LeoQTextBrowser,LeoQuickSearchWidget,cleoQtUI) 

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

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

864 obj.installEventFilter(theFilter) 

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

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

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

868 def ensure_commander_visible(self, c1: Cmdr) -> None: 

869 """ 

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

871 the tab is visible 

872 """ 

873 # pylint: disable=arguments-differ 

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

875 g.trace(c1) 

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

877 factory = g.app.gui.frameFactory 

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

879 c = c1 

880 factory.setTabForCommander(c) 

881 c.bodyWantsFocusNow() 

882 #@+node:ekr.20190601054958.1: *4* qt_gui.get_focus (no longer used) 

883 def get_focus(self, c: Cmdr=None, raw: bool=False, at_idle: bool=False) -> Widget: 

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

885 # pylint: disable=arguments-differ 

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

887 trace_idle = False 

888 trace = trace and (trace_idle or not at_idle) 

889 app = QtWidgets.QApplication 

890 w = app.focusWidget() 

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

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

893 if has_w: 

894 if trace: 

895 g.trace(w) 

896 elif c: 

897 # Kludge: DynamicWindow creates the body pane 

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

899 w = c.frame.body 

900 if trace: 

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

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

903 return w 

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

905 def set_focus(self, c: Cmdr, w: Wrapper) -> None: 

906 """Put the focus on the widget.""" 

907 # pylint: disable=arguments-differ 

908 if not w: 

909 return 

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

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

912 # w should be a wrapper. 

913 w = w.widget 

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

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

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

917 w.setFocus() 

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

919 size_warnings: List[str] = [] 

920 

921 def getFontFromParams(self, 

922 family: str, size: str, slant: str, weight: str, defaultSize: int=12, 

923 ) -> Any: 

924 """Required to handle syntax coloring.""" 

925 if isinstance(size, str): 

926 if size.endswith('pt'): 

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

928 elif size.endswith('px'): 

929 if size not in self.size_warnings: 

930 self.size_warnings.append(size) 

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

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

933 try: 

934 i_size = int(size) 

935 except Exception: 

936 i_size = 0 

937 if i_size < 1: 

938 i_size = defaultSize 

939 d = { 

940 'black': Weight.Black, 

941 'bold': Weight.Bold, 

942 'demibold': Weight.DemiBold, 

943 'light': Weight.Light, 

944 'normal': Weight.Normal, 

945 } 

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

947 italic = slant == 'italic' 

948 if not family: 

949 family = g.app.config.defaultFontFamily 

950 if not family: 

951 family = 'DejaVu Sans Mono' 

952 try: 

953 font = QtGui.QFont(family, i_size, weight_val, italic) 

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

955 font.setHintingPreference(font.PreferFullHinting) 

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

957 return font 

958 except Exception: 

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

960 g.es( 

961 f"family: {family}\n" 

962 f" size: {i_size}\n" 

963 f" slant: {slant}\n" 

964 f"weight: {weight}") 

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

966 return g.app.config.defaultFont 

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

968 def getFullVersion(self, c: Cmdr=None) -> str: 

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

970 try: 

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

972 except Exception: 

973 # g.es_exception() 

974 qtLevel = '<qtLevel>' 

975 return f"PyQt {qtLevel}" 

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

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

978 def attachLeoIcon(self, window: Any) -> None: 

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

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

981 if self.appIcon: 

982 window.setWindowIcon(self.appIcon) 

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

984 def getIconImage(self, name: str) -> Any: 

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

986 # Return the image from the cache if possible. 

987 if name in self.iconimages: 

988 image = self.iconimages.get(name) 

989 return image 

990 try: 

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

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

993 for theDir in (homeIconsDir, iconsDir): 

994 fullname = g.os_path_finalize_join(theDir, name) 

995 if g.os_path_exists(fullname): 

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

997 pixmap = QtGui.QPixmap() 

998 pixmap.load(fullname) 

999 image = QtGui.QIcon(pixmap) 

1000 else: 

1001 image = QtGui.QIcon(fullname) 

1002 self.iconimages[name] = image 

1003 return image 

1004 # No image found. 

1005 return None 

1006 except Exception: 

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

1008 g.es_exception() 

1009 return None 

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

1011 @functools.lru_cache(maxsize=128) 

1012 def getImageImage(self, name: str) -> Any: 

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

1014 fullname = self.getImageFinder(name) 

1015 try: 

1016 pixmap = QtGui.QPixmap() 

1017 pixmap.load(fullname) 

1018 return pixmap 

1019 except Exception: 

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

1021 g.es_exception() 

1022 return None 

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

1024 dump_given = False 

1025 @functools.lru_cache(maxsize=128) 

1026 def getImageFinder(self, name: str) -> Optional[str]: 

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

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

1029 exists = g.os_path_exists 

1030 getString = g.app.config.getString 

1031 

1032 def dump(var: str, val: str) -> None: 

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

1034 

1035 join = g.os_path_join 

1036 # 

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

1038 # theme .leo files actually contain these settings! 

1039 # 

1040 theme_name1 = getString('color-theme') 

1041 theme_name2 = getString('theme-name') 

1042 roots = [ 

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

1044 g.computeLeoDir(), 

1045 ] 

1046 theme_subs = [ 

1047 "themes/{theme}/Icons", 

1048 "themes/{theme}", 

1049 "Icons/{theme}", 

1050 ] 

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

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

1053 paths = [] 

1054 for theme_name in (theme_name1, theme_name2): 

1055 for root in roots: 

1056 for sub in theme_subs: 

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

1058 for root in roots: 

1059 for sub in bare_subs: 

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

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

1062 for base_dir in table: 

1063 path = join(base_dir, name) 

1064 if exists(path): 

1065 if trace: 

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

1067 return path 

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

1069 if trace: 

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

1071 return None 

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

1073 @functools.lru_cache(maxsize=128) 

1074 def getTreeImage(self, c: Cmdr, path: str) -> Tuple[Any, int]: 

1075 image = QtGui.QPixmap(path) 

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

1077 return image, image.height() 

1078 return None, None 

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

1080 def insertKeyEvent(self, event: Event, i: int) -> None: 

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

1082 assert isinstance(event, leoGui.LeoKeyEvent) 

1083 qevent = event.event 

1084 assert isinstance(qevent, QtGui.QKeyEvent) 

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

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

1087 if 1: 

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

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

1090 qw.insertPlainText(qevent.text()) 

1091 else: 

1092 # Make no such assumption. 

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

1094 # but this would invoke eventFilter again! 

1095 # So set this flag for eventFilter, which will 

1096 # return False, indicating that the widget must handle 

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

1098 g.app.gui.insert_char_flag = True 

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

1100 def make_main_window(self) -> None: 

1101 """Make the *singleton* QMainWindow.""" 

1102 window = QtWidgets.QMainWindow() 

1103 window.setObjectName('LeoGlobalMainWindow') 

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

1105 self.attachLeoIcon(window) 

1106 # Monkey-patch 

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

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

1109 return window 

1110 

1111 def set_main_window_style_sheet(self) -> None: 

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

1113 commanders = g.app.commanders() 

1114 if commanders: 

1115 c = commanders[0] 

1116 ssm = c.styleSheetManager 

1117 ssm.set_style_sheets(w=self.main_window) 

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

1119 else: 

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

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

1122 def makeScriptButton( 

1123 self, 

1124 c: Cmdr, 

1125 args: Any=None, 

1126 p: Pos=None, # A node containing the script. 

1127 script: str=None, # The script itself. 

1128 buttonText: str=None, 

1129 balloonText: str='Script Button', 

1130 shortcut: str=None, 

1131 bg: str='LightSteelBlue1', 

1132 define_g: bool=True, 

1133 define_name: str='__main__', 

1134 silent: bool=False, # Passed on to c.executeScript. 

1135 ) -> None: 

1136 """ 

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

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

1139 k = c.k 

1140 if p and not buttonText: 

1141 buttonText = p.h.strip() 

1142 if not buttonText: 

1143 buttonText = 'Unnamed Script Button' 

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

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

1146 iconBar = c.frame.getIconBarObject() 

1147 b = iconBar.add(text=buttonText) 

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

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

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

1151 def deleteButtonCallback(event: Event=None, b: Widget=b, c: Cmdr=c) -> None: 

1152 if b: 

1153 b.pack_forget() 

1154 c.bodyWantsFocus() 

1155 

1156 def executeScriptCallback( 

1157 event: Event=None, 

1158 b: Widget=b, 

1159 c: Cmdr=c, 

1160 buttonText: str=buttonText, 

1161 p: Pos=p and p.copy(), 

1162 script: str=script 

1163 ) -> None: 

1164 if c.disableCommandsMessage: 

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

1166 else: 

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

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

1169 define_g=define_g, define_name=define_name, silent=silent) 

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

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

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

1173 b.pack_forget() 

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

1175 

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

1177 

1178 b.configure(command=executeScriptCallback) 

1179 if shortcut: 

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

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

1182 # In qt_gui.makeScriptButton. 

1183 func = executeScriptCallback 

1184 if shortcut: 

1185 shortcut = g.KeyStroke(shortcut) # type:ignore 

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

1187 if ok: 

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

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

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

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

1192 # #1121. Like sc.cleanButtonText 

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

1194 # 

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

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

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

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

1199 def onContextMenu(self, c: Cmdr, w: Wrapper, point: Any) -> None: 

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

1201 # #1286. 

1202 handlers = g.tree_popup_handlers 

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

1204 menuPos = w.mapToGlobal(point) 

1205 if not handlers: 

1206 menu.addAction("No popup handlers") 

1207 p = c.p.copy() 

1208 done: set[Callable] = set() 

1209 for handler in handlers: 

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

1211 if handler in done: 

1212 # do not run the same handler twice 

1213 continue 

1214 try: 

1215 handler(c, p, menu) 

1216 done.add(handler) 

1217 except Exception: 

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

1219 g.es_exception() 

1220 menu.popup(menuPos) 

1221 self._contextmenu = menu 

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

1223 def put_help(self, c: Cmdr, s: str, short_title: str='') -> Any: 

1224 """Put the help command.""" 

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

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

1227 pass # how to do selective replace?? 

1228 pc = g.app.pluginsController 

1229 table = ( 

1230 'viewrendered3.py', 

1231 'viewrendered.py', 

1232 ) 

1233 for name in table: 

1234 if pc.isLoaded(name): 

1235 vr = pc.loadOnePlugin(name) 

1236 break 

1237 else: 

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

1239 if vr: 

1240 kw = { 

1241 'c': c, 

1242 'flags': 'rst', 

1243 'kind': 'rst', 

1244 'label': '', 

1245 'msg': s, 

1246 'name': 'Apropos', 

1247 'short_title': short_title, 

1248 'title': ''} 

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

1250 c.bodyWantsFocus() 

1251 if g.unitTesting: 

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

1253 elif g.unitTesting: 

1254 pass 

1255 else: 

1256 g.es(s) 

1257 return vr # For unit tests 

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

1259 def runAtIdle(self, aFunc: Callable) -> None: 

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

1261 QtCore.QTimer.singleShot(0, aFunc) 

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

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

1264 def runMainLoop(self) -> None: 

1265 """Start the Qt main loop.""" 

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

1267 g.app.gui.dismiss_splash_screen() 

1268 c = g.app.log and g.app.log.c 

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

1270 g.app.gui.show_tips(c) 

1271 except Exception: 

1272 g.es_exception() 

1273 if self.script: 

1274 log = g.app.log 

1275 if log: 

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

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

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

1279 else: 

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

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

1282 self.runWithIpythonKernel() 

1283 else: 

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

1285 if isQt6: 

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

1287 else: 

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

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

1290 def runWithIpythonKernel(self) -> None: 

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

1292 try: 

1293 from leo.core import leoIPython 

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

1295 g.app.ipk.run() 

1296 except Exception: 

1297 g.es_exception() 

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

1299 sys.exit(1) 

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

1301 already_sized = False 

1302 

1303 def set_top_geometry(self, w: int, h: int, x: int, y: int) -> None: 

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

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

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

1307 if not self.already_sized: 

1308 self.already_sized = True 

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

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

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

1312 def show_next_tip(self, event: Event=None) -> None: 

1313 c = g.app.log and g.app.log.c 

1314 if c: 

1315 g.app.gui.show_tips(c) 

1316 

1317 #@+<< define DialogWithCheckBox >> 

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

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

1320 

1321 def __init__(self, controller: Any, checked: bool, tip: str) -> None: 

1322 super().__init__() 

1323 c = g.app.log.c 

1324 self.leo_checked = True 

1325 self.setObjectName('TipMessageBox') 

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

1327 # self.setMinimumSize(5000, 4000) 

1328 # Doesn't work. 

1329 # Prevent the dialog from jumping around when 

1330 # selecting multiple tips. 

1331 self.setWindowTitle('Leo Tips') 

1332 self.setText(repr(tip)) 

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

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

1335 c.styleSheetManager.set_style_sheets(w=self) 

1336 # Workaround #693. 

1337 layout = self.layout() 

1338 cb = QtWidgets.QCheckBox() 

1339 cb.setObjectName('TipCheckbox') 

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

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

1342 cb.setCheckState(state) # #2127. 

1343 cb.stateChanged.connect(controller.onClick) 

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

1345 if 0: # Does not work well. 

1346 sizePolicy = QtWidgets.QSizePolicy 

1347 vSpacer = QtWidgets.QSpacerItem( 

1348 200, 200, sizePolicy.Minimum, sizePolicy.Expanding) 

1349 layout.addItem(vSpacer) 

1350 #@-<< define DialogWithCheckBox >> 

1351 

1352 def show_tips(self, c: Cmdr) -> None: 

1353 if g.unitTesting: 

1354 return 

1355 from leo.core import leoTips 

1356 tm = leoTips.TipManager() 

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

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

1359 tip = tm.get_next_tip() 

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

1361 try: 

1362 c.in_qt_dialog = True 

1363 m.exec_() 

1364 finally: 

1365 c.in_qt_dialog = False 

1366 b = m.clickedButton() 

1367 if b != m.next_tip_button: 

1368 break 

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

1370 def onButton(self, m: Any) -> None: 

1371 m.hide() 

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

1373 def onClick(self, state: str) -> None: 

1374 c = g.app.log.c 

1375 self.show_tips_flag = bool(state) 

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

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

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

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

1380 def onNext(self, *args: Any, **keys: Any) -> bool: 

1381 g.trace(args, keys) 

1382 return True 

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

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

1385 def createSplashScreen(self) -> Widget: 

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

1387 splash = None 

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

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

1390 else: 

1391 table = ('SplashScreen.xpm',) 

1392 for name in table: 

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

1394 if g.os_path_exists(fn): 

1395 pm = QtGui.QPixmap(fn) 

1396 if not pm.isNull(): 

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

1398 splash.show() 

1399 # This sleep is required to do the repaint. 

1400 QtCore.QThread.msleep(10) 

1401 splash.repaint() 

1402 break 

1403 return splash 

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

1405 def dismiss_splash_screen(self) -> None: 

1406 

1407 gui = self 

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

1409 if g.unitTesting: 

1410 return 

1411 if gui.splashScreen: 

1412 gui.splashScreen.hide() 

1413 # gui.splashScreen.deleteLater() 

1414 gui.splashScreen = None 

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

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

1417 def isTextWidget(self, w: Wrapper) -> bool: 

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

1419 if Qsci: 

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

1421 return isinstance(w, QtWidgets.QTextEdit) 

1422 

1423 def isTextWrapper(self, w: Wrapper) -> bool: 

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

1425 if w is None: 

1426 return False 

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

1428 return True 

1429 return bool(getattr(w, 'supportsHighLevelInterface', None)) 

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

1431 def widget_name(self, w: Wrapper) -> str: 

1432 # First try the widget's getName method. 

1433 if not 'w': 

1434 name = '<no widget>' 

1435 elif hasattr(w, 'getName'): 

1436 name = w.getName() 

1437 elif hasattr(w, 'objectName'): 

1438 name = str(w.objectName()) 

1439 elif hasattr(w, '_name'): 

1440 name = w._name 

1441 else: 

1442 name = repr(w) 

1443 return name 

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

1445 if isQt5: 

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

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

1448 from PyQt5.QtTest import QSignalSpy 

1449 assert QSignalSpy 

1450 elif isQt6: 

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

1452 import PyQt6.QtTest as QtTest 

1453 # mypy complains about assigning to a type. 

1454 QSignalSpy = QtTest.QSignalSpy # type:ignore 

1455 assert QSignalSpy 

1456 else: 

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

1458 _oldConnect = QtCore.QObject.connect 

1459 _oldDisconnect = QtCore.QObject.disconnect 

1460 _oldEmit = QtCore.QObject.emit 

1461 

1462 def _wrapConnect(self, callableObject: Callable) -> Callable: 

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

1464 

1465 @staticmethod # type:ignore 

1466 def call(*args: Any) -> None: 

1467 callableObject(*args) 

1468 self._oldConnect(*args) 

1469 

1470 return call 

1471 

1472 def _wrapDisconnect(self, callableObject: Callable) -> Callable: 

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

1474 

1475 @staticmethod # type:ignore 

1476 def call(*args: Any) -> None: 

1477 callableObject(*args) 

1478 self._oldDisconnect(*args) 

1479 

1480 return call 

1481 

1482 def enableSignalDebugging(self, **kwargs: Any) -> None: 

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

1484 connect, and disconnect calls.""" 

1485 f = lambda * args: None 

1486 connectCall: Callable = kwargs.get('connectCall', f) 

1487 disconnectCall: Callable = kwargs.get('disconnectCall', f) 

1488 emitCall: Callable = kwargs.get('emitCall', f) 

1489 

1490 def printIt(msg: str) -> Callable: 

1491 

1492 def call(*args: Any) -> None: 

1493 print(msg, args) 

1494 

1495 return call 

1496 

1497 # Monkey-patch. 

1498 

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

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

1501 

1502 def new_emit(self, *args: Any) -> None: # type:ignore 

1503 emitCall(self, *args) 

1504 self._oldEmit(self, *args) 

1505 

1506 QtCore.QObject.emit = new_emit 

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

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

1509 def createButton(self, parent: Widget, name: str, label: str) -> Widget: 

1510 w = QtWidgets.QPushButton(parent) 

1511 w.setObjectName(name) 

1512 w.setText(label) 

1513 return w 

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

1515 def createFrame( 

1516 self, 

1517 parent: Widget, 

1518 name: str, 

1519 hPolicy: Any=None, 

1520 vPolicy: Any=None, 

1521 lineWidth: int=1, 

1522 shadow: Any=None, 

1523 shape: Any=None, 

1524 ) -> Widget: 

1525 """Create a Qt Frame.""" 

1526 if shadow is None: 

1527 shadow = Shadow.Plain 

1528 if shape is None: 

1529 shape = Shape.NoFrame 

1530 # 

1531 w = QtWidgets.QFrame(parent) 

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

1533 w.setFrameShape(shape) 

1534 w.setFrameShadow(shadow) 

1535 w.setLineWidth(lineWidth) 

1536 w.setObjectName(name) 

1537 return w 

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

1539 def createGrid(self, parent: Widget, name: str, margin: int=0, spacing: int=0) -> Widget: 

1540 w = QtWidgets.QGridLayout(parent) 

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

1542 w.setSpacing(spacing) 

1543 w.setObjectName(name) 

1544 return w 

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

1546 def createHLayout(self, parent: Widget, name: str, margin: int=0, spacing: int=0) -> Any: 

1547 hLayout = QtWidgets.QHBoxLayout(parent) 

1548 hLayout.setObjectName(name) 

1549 hLayout.setSpacing(spacing) 

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

1551 return hLayout 

1552 

1553 def createVLayout(self, parent: Widget, name: str, margin: int=0, spacing: int=0) -> Any: 

1554 vLayout = QtWidgets.QVBoxLayout(parent) 

1555 vLayout.setObjectName(name) 

1556 vLayout.setSpacing(spacing) 

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

1558 return vLayout 

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

1560 def createLabel(self, parent: Widget, name: str, label: str) -> Widget: 

1561 w = QtWidgets.QLabel(parent) 

1562 w.setObjectName(name) 

1563 w.setText(label) 

1564 return w 

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

1566 def createTabWidget(self, 

1567 parent: Widget, name: str, hPolicy: Any=None, vPolicy: Any=None, 

1568 ) -> Widget: 

1569 w = QtWidgets.QTabWidget(parent) 

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

1571 w.setObjectName(name) 

1572 return w 

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

1574 def setSizePolicy(self, 

1575 widget: Widget, kind1: Any=None, kind2: Any=None, 

1576 ) -> None: 

1577 if kind1 is None: 

1578 kind1 = Policy.Ignored 

1579 if kind2 is None: 

1580 kind2 = Policy.Ignored 

1581 sizePolicy = QtWidgets.QSizePolicy(kind1, kind2) 

1582 sizePolicy.setHorizontalStretch(0) 

1583 sizePolicy.setVerticalStretch(0) 

1584 sizePolicy.setHeightForWidth(widget.sizePolicy().hasHeightForWidth()) 

1585 widget.setSizePolicy(sizePolicy) 

1586 #@-others 

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

1588class StyleClassManager: 

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

1590 #@+others 

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

1592 def update_view(self, w: Wrapper) -> None: 

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

1594 

1595 :param QWidgit w: widgit to style 

1596 """ 

1597 

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

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

1600 def add_sclass(self, w: Wrapper, prop: Any) -> None: 

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

1602 if not prop: 

1603 return 

1604 props = self.sclasses(w) 

1605 if isinstance(prop, str): 

1606 props.append(prop) 

1607 else: 

1608 props.extend(prop) 

1609 self.set_sclasses(w, props) 

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

1611 def clear_sclasses(self, w: Wrapper) -> None: 

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

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

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

1615 def has_sclass(self, w: Wrapper, prop: Any) -> bool: 

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

1617 if not prop: 

1618 return None 

1619 props = self.sclasses(w) 

1620 if isinstance(prop, str): 

1621 ans = [prop in props] 

1622 else: 

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

1624 return all(ans) 

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

1626 def remove_sclass(self, w: Wrapper, prop: Any) -> None: 

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

1628 if not prop: 

1629 return 

1630 props = self.sclasses(w) 

1631 if isinstance(prop, str): 

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

1633 else: 

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

1635 

1636 self.set_sclasses(w, props) 

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

1638 def sclass_tests(self) -> None: 

1639 """Test style class property manipulation functions""" 

1640 # pylint: disable=len-as-condition 

1641 

1642 class Test_W: 

1643 """simple standin for QWidget for testing""" 

1644 

1645 def __init__(self) -> None: 

1646 self.x = '' 

1647 

1648 def property(self, name: str, default: str=None) -> Any: 

1649 return self.x or default 

1650 

1651 def setProperty(self, name: str, value: Any) -> None: 

1652 self.x = value 

1653 

1654 w = Test_W() 

1655 

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

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

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

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

1660 

1661 self.add_sclass(w, 'test') 

1662 

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

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

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

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

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

1668 

1669 self.add_sclass(w, 'test') 

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

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

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

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

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

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

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

1677 

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

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

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

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

1682 

1683 self.toggle_sclass(w, 'third') 

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

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

1686 self.toggle_sclass(w, 'third') 

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

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

1689 

1690 self.clear_sclasses(w) 

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

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

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

1694 def sclasses(self, w: Wrapper) -> List[str]: 

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

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

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

1698 def set_sclasses(self, w: Wrapper, classes: Any) -> None: 

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

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

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

1702 def toggle_sclass(self, w: Wrapper, prop: Any) -> None: 

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

1704 if not prop: 

1705 return 

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

1707 if isinstance(prop, str): 

1708 prop = set([prop]) 

1709 else: 

1710 prop = set(prop) 

1711 current = props.intersection(prop) 

1712 props.update(prop) 

1713 props = props.difference(current) 

1714 self.set_sclasses(w, props) 

1715 #@-others 

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

1717class StyleSheetManager: 

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

1719 #@+others 

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

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

1722 def __init__(self, c: Cmdr, safe: bool=False) -> None: 

1723 """Ctor the ReloadStyle class.""" 

1724 self.c = c 

1725 self.color_db = leoColor.leo_color_database 

1726 self.safe = safe 

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

1728 self.mng = StyleClassManager() 

1729 # This warning is inappropriate in some contexts. 

1730 # if not self.settings_p: 

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

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

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

1734 def reload_settings(self, sheet: str=None) -> None: 

1735 """ 

1736 Recompute and apply the stylesheet. 

1737 Called automatically by the reload-settings commands. 

1738 """ 

1739 if not sheet: 

1740 sheet = self.get_style_sheet_from_settings() 

1741 if sheet: 

1742 w = self.get_master_widget() 

1743 w.setStyleSheet(sheet) 

1744 # self.c.redraw() 

1745 

1746 reloadSettings = reload_settings 

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

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

1749 def compute_icon_directories(self) -> List[str]: 

1750 """ 

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

1752 """ 

1753 exists = g.os_path_exists 

1754 home = g.app.homeDir 

1755 join = g.os_path_finalize_join 

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

1757 table = [ 

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

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

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

1761 join(leo, 'themes'), 

1762 join(leo, 'Icons'), 

1763 ] 

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

1765 for directory in self.compute_theme_directories(): 

1766 if directory not in table: 

1767 table.append(directory) 

1768 directory2 = join(directory, 'Icons') 

1769 if directory2 not in table: 

1770 table.append(directory2) 

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

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

1773 def compute_theme_directories(self) -> List[str]: 

1774 """ 

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

1776 """ 

1777 lm = g.app.loadManager 

1778 table = lm.computeThemeDirectories()[:] 

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

1780 if directory and directory not in table: 

1781 table.insert(0, directory) 

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

1783 return table 

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

1785 def find_icon_path(self, setting: str) -> Optional[str]: 

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

1787 c = self.c 

1788 s = c.config.getString(setting) 

1789 if not s: 

1790 return None # Not an error. 

1791 for directory in self.compute_icon_directories(): 

1792 path = g.os_path_finalize_join(directory, s) 

1793 if g.os_path_exists(path): 

1794 return path 

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

1796 return None 

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

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

1799 def default_style_sheet(self) -> str: 

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

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

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

1803 return '''\ 

1804 

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

1806 QSplitter::handle { 

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

1808 } 

1809 QSplitter { 

1810 border-color: white; 

1811 background-color: white; 

1812 border-width: 3px; 

1813 border-style: solid; 

1814 } 

1815 QTreeWidget { 

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

1817 } 

1818 QsciScintilla { 

1819 background-color: pink; 

1820 } 

1821 ''' 

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

1823 def get_data(self, setting: str) -> List: 

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

1825 c = self.c 

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

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

1828 def get_style_sheet_from_settings(self) -> str: 

1829 """ 

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

1831 Return the text of the relevant node. 

1832 """ 

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

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

1835 if aList2: 

1836 aList1.extend(aList2) 

1837 sheet = ''.join(aList1) 

1838 sheet = self.expand_css_constants(sheet) 

1839 return sheet 

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

1841 def print_style_sheet(self) -> None: 

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

1843 w = self.get_master_widget() 

1844 sheet = w.styleSheet() 

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

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

1847 def set_style_sheets(self, all: bool=True, top: Widget=None, w: Wrapper=None) -> None: 

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

1849 if g.app.loadedThemes: 

1850 return 

1851 c = self.c 

1852 if top is None: 

1853 top = c.frame.top 

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

1855 if all: 

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

1857 sheets = [] 

1858 for name in selectors: 

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

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

1861 if sheet: 

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

1863 sheet = ''.join(sheet) 

1864 else: 

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

1866 if sheet and sheet.strip(): 

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

1868 sheet = line0 + sheet 

1869 sheets.append(sheet) 

1870 if sheets: 

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

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

1873 c.active_stylesheet = sheet 

1874 sheet = self.expand_css_constants(sheet) 

1875 if not sheet: 

1876 sheet = self.default_style_sheet() 

1877 if w is None: 

1878 w = self.get_master_widget(top) 

1879 w.setStyleSheet(sheet) 

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

1881 # Computations on stylesheets themeselves. 

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

1883 css_warning_given = False 

1884 

1885 def expand_css_constants(self, sheet: str, settingsDict: Dict=None) -> str: 

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

1887 c = self.c 

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

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

1890 if settingsDict is None: 

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

1892 if 0: 

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

1894 for key in settingsDict.keys(): 

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

1896 constants, deltas = self.adjust_sizes(settingsDict) 

1897 if trace: 

1898 print('') 

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

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

1901 sheet = self.replace_indicator_constants(sheet) 

1902 for pass_n in range(10): 

1903 to_do = self.find_constants_referenced(sheet) 

1904 if not to_do: 

1905 break 

1906 old_sheet = sheet 

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

1908 if sheet == old_sheet: 

1909 break 

1910 else: 

1911 g.trace('Too many iterations') 

1912 if to_do: 

1913 g.trace('Unresolved @constants') 

1914 g.printObj(to_do) 

1915 sheet = self.resolve_urls(sheet) 

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

1917 return sheet 

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

1919 def adjust_sizes(self, settingsDict: Dict) -> Tuple[Dict, Any]: 

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

1921 c = self.c 

1922 constants = {} 

1923 deltas = c._style_deltas 

1924 for delta in c._style_deltas: 

1925 # adjust @font-size-body by font_size_delta 

1926 # easily extendable to @font-size-* 

1927 val = c.config.getString(delta) 

1928 passes = 10 

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

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

1931 val = settingsDict.get(key) 

1932 if val: 

1933 val = val.val 

1934 passes -= 1 

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

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

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

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

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

1940 return constants, deltas 

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

1942 def do_pass(self, 

1943 constants: Dict, 

1944 deltas: List[str], 

1945 settingsDict: Dict[str, Any], 

1946 sheet: str, 

1947 to_do: List[str], 

1948 ) -> str: 

1949 

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

1951 for const in to_do: 

1952 value = None 

1953 if const in constants: 

1954 # This constant is about to be removed. 

1955 value = constants[const] 

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

1957 self.css_warning_given = True 

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

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

1960 else: 

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

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

1963 value = settingsDict.get(key) 

1964 if value is not None: 

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

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

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

1968 value = value.val 

1969 elif key in self.color_db: 

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

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

1972 value = self.color_db.get(key) 

1973 if value: 

1974 # Partial fix for #780. 

1975 try: 

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

1977 sheet = re.sub( 

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

1979 value, 

1980 sheet, 

1981 ) 

1982 except Exception: 

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

1984 g.es_print(sheet) 

1985 g.es_exception() 

1986 else: 

1987 pass 

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

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

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

1991 return sheet 

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

1993 def find_constants_referenced(self, text: str) -> List[str]: 

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

1995 constants match:: 

1996 

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

1998 i.e. @foo_1-5 

1999 

2000 :Parameters: 

2001 - `text`: text to search 

2002 """ 

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

2004 # Exempt references to Leo constructs. 

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

2006 if s in aList: 

2007 aList.remove(s) 

2008 return aList 

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

2010 def replace_indicator_constants(self, sheet: str) -> str: 

2011 """ 

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

2013 

2014 image: @tree-image-closed 

2015 image: @tree-image-open 

2016 

2017 by:: 

2018 

2019 url(path/closed.png) 

2020 url(path/open.png) 

2021 

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

2023 

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

2025 

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

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

2028 

2029 Return the updated stylesheet. 

2030 """ 

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

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

2033 # Make all substitutions in the stylesheet. 

2034 table = ( 

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

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

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

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

2039 ) 

2040 for path, pattern in table: 

2041 for mo in pattern.finditer(sheet): 

2042 old = mo.group(0) 

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

2044 sheet = sheet.replace(old, new) 

2045 return sheet 

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

2047 def resolve_urls(self, sheet: str) -> str: 

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

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

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

2051 join = g.os_path_finalize_join 

2052 directories = self.compute_icon_directories() 

2053 paths_traced = False 

2054 if trace: 

2055 paths_traced = True 

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

2057 g.printObj(directories) 

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

2059 replacements = [] 

2060 for mo in pattern.finditer(sheet): 

2061 url = mo.group(1) 

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

2063 url = url[2:] 

2064 elif g.os_path_isabs(url): 

2065 if trace: 

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

2067 continue 

2068 for directory in directories: 

2069 path = join(directory, url) 

2070 if g.os_path_exists(path): 

2071 if trace: 

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

2073 old = mo.group(0) 

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

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

2076 break 

2077 else: 

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

2079 if not paths_traced: 

2080 paths_traced = True 

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

2082 g.printObj(directories) 

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

2084 for old, new in reversed(replacements): 

2085 sheet = sheet.replace(old, new) 

2086 return sheet 

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

2088 def munge(self, stylesheet: str) -> str: 

2089 """ 

2090 Return the stylesheet without extra whitespace. 

2091 

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

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

2094 """ 

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

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

2097 return s.rstrip() # Don't care about ending newline. 

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

2099 def rescale_sizes(self, sheet: str, factor: float) -> str: 

2100 """ 

2101 #@+<< docstring >> 

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

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

2104 

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

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

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

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

2109 

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

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

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

2113 

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

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

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

2117 

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

2119 

2120 ARGUMENTS 

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

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

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

2124 well-formed Leo outline. 

2125 

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

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

2128 

2129 RETURNS 

2130 the modified sheet as a string. 

2131 

2132 #@-<< docstring >> 

2133 """ 

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

2135 

2136 def scale(matchobj: re.Match, scale: float=factor) -> str: 

2137 prefix = matchobj.group(1) 

2138 sz = matchobj.group(2) 

2139 units = matchobj.group(3) 

2140 try: 

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

2142 except Exception as e: 

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

2144 return None 

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

2146 

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

2148 return newsheet 

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

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

2151 def get_master_widget(self, top: Any=None) -> Widget: 

2152 """ 

2153 Carefully return the master widget. 

2154 c.frame.top is a DynamicWindow. 

2155 """ 

2156 if top is None: 

2157 top = self.c.frame.top 

2158 master = top.leo_master or top 

2159 return master 

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

2161 def set_selected_style_sheet(self) -> None: 

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

2163 if not g.unitTesting: 

2164 c = self.c 

2165 sheet = c.p.b 

2166 sheet = self.expand_css_constants(sheet) 

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

2168 w.setStyleSheet(sheet) 

2169 #@-others 

2170#@-others 

2171#@@language python 

2172#@@tabwidth -4 

2173#@@pagewidth 70 

2174#@-leo