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
« 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
42#@-<< type aliases qt_gui.py >>
43#@+others
44#@+node:ekr.20110605121601.18134: ** init (qt_gui.py)
45def init() -> bool:
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')
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()
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:
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.
253 def closeEvent(event: Event) -> None:
254 event.ignore()
255 dialog.hide()
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.
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.
277 def createLeoFrame(self, c: Cmdr, title: str) -> Wrapper:
278 """Create a new Leo frame."""
279 return qt_frame.LeoQtFrame(c, title, gui=self)
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.
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.
320 E.g. (5 minute increments in minute field):
322 g.app.gui.runAskDateTimeDialog(c, 'When?',
323 message="When is it?",
324 step_min={QtWidgets.QDateTimeEdit.MinuteSection: 5})
326 """
327 #@+<< define date/time classes >>
328 #@+node:ekr.20211005103909.1: *5* << define date/time classes >>
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 """
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)
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)
353 class Calendar(QtWidgets.QDialog): # type:ignore
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)
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.
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.
510 Return one of ('yes', 'no', 'cancel', 'yes-to-all').
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')
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 ''
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
714 def send() -> Any:
715 return g.doHook('scrolledMessage',
716 short_title=short_title, title=title,
717 label=label, msg=msg, c=c, **keys)
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:
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 = ''
787 deactivated_widget = None
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
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] = []
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
1032 def dump(var: str, val: str) -> None:
1033 print(f"{var:20}: {val}")
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
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()
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.
1176 #@-<< define the callbacks for b >>
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
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)
1317 #@+<< define DialogWithCheckBox >>
1318 #@+node:ekr.20220123052350.1: *4* << define DialogWithCheckBox >>
1319 class DialogWithCheckBox(QtWidgets.QMessageBox): # type:ignore
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 >>
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:
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)
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
1462 def _wrapConnect(self, callableObject: Callable) -> Callable:
1463 """Returns a wrapped call to the old version of QtCore.QObject.connect"""
1465 @staticmethod # type:ignore
1466 def call(*args: Any) -> None:
1467 callableObject(*args)
1468 self._oldConnect(*args)
1470 return call
1472 def _wrapDisconnect(self, callableObject: Callable) -> Callable:
1473 """Returns a wrapped call to the old version of QtCore.QObject.disconnect"""
1475 @staticmethod # type:ignore
1476 def call(*args: Any) -> None:
1477 callableObject(*args)
1478 self._oldDisconnect(*args)
1480 return call
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)
1490 def printIt(msg: str) -> Callable:
1492 def call(*args: Any) -> None:
1493 print(msg, args)
1495 return call
1497 # Monkey-patch.
1499 QtCore.QObject.connect = self._wrapConnect(connectCall)
1500 QtCore.QObject.disconnect = self._wrapDisconnect(disconnectCall)
1502 def new_emit(self, *args: Any) -> None: # type:ignore
1503 emitCall(self, *args)
1504 self._oldEmit(self, *args)
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
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
1595 :param QWidgit w: widgit to style
1596 """
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]
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
1642 class Test_W:
1643 """simple standin for QWidget for testing"""
1645 def __init__(self) -> None:
1646 self.x = ''
1648 def property(self, name: str, default: str=None) -> Any:
1649 return self.x or default
1651 def setProperty(self, name: str, value: Any) -> None:
1652 self.x = value
1654 w = Test_W()
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
1661 self.add_sclass(w, 'test')
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
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'])
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
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'])
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()
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 '''\
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
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:
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::
1997 @[A-Za-z_][-A-Za-z0-9_]*
1998 i.e. @foo_1-5
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)::
2014 image: @tree-image-closed
2015 image: @tree-image-open
2017 by::
2019 url(path/closed.png)
2020 url(path/open.png)
2022 path can be relative to ~ or to leo/Icons.
2024 Assuming that ~/myIcons/closed.png exists, either of these will work::
2026 @string tree-image-closed = nodes-dark/triangles/closed.png
2027 @string tree-image-closed = myIcons/closed.png
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.
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.
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;"
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.
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.
2118 No size will be scaled down to less than 1.
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.
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.
2129 RETURNS
2130 the modified sheet as a string.
2132 #@-<< docstring >>
2133 """
2134 RE = r'([=:])[ ]*([.1234567890]+)(p[tx])'
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}'
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