Coverage for C:\leo.repo\leo-editor\leo\plugins\qt_gui.py: 18%
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1#@+leo-ver=5-thin
2#@+node:ekr.20140907085654.18699: * @file ../plugins/qt_gui.py
3"""This file contains the gui wrapper for Qt: g.app.gui."""
4# pylint: disable=import-error
5#@+<< imports >>
6#@+node:ekr.20140918102920.17891: ** << imports >> (qt_gui.py)
7import datetime
8import functools
9import re
10import sys
11import textwrap
12from typing import List
14from leo.core import leoColor
15from leo.core import leoGlobals as g
16from leo.core import leoGui
17from leo.core.leoQt import isQt5, isQt6, Qsci, QtConst, QtCore, QtGui, QtWidgets
18from leo.core.leoQt import ButtonRole, DialogCode, Icon, Information, Policy
19from leo.core.leoQt import Shadow, Shape, StandardButton, Weight, WindowType
20 # This import causes pylint to fail on this file and on leoBridge.py.
21 # The failure is in astroid: raw_building.py.
22from leo.plugins import qt_events
23from leo.plugins import qt_frame
24from leo.plugins import qt_idle_time
25from leo.plugins import qt_text
26# This defines the commands defined by @g.command.
27from leo.plugins import qt_commands
28assert qt_commands
29#@-<< imports >>
30#@+others
31#@+node:ekr.20110605121601.18134: ** init (qt_gui.py)
32def init():
34 if g.unitTesting: # Not Ok for unit testing!
35 return False
36 if not QtCore:
37 return False
38 if g.app.gui:
39 return g.app.gui.guiName() == 'qt'
40 g.app.gui = LeoQtGui()
41 g.app.gui.finishCreate()
42 g.plugin_signon(__name__)
43 return True
44#@+node:ekr.20140907085654.18700: ** class LeoQtGui(leoGui.LeoGui)
45class LeoQtGui(leoGui.LeoGui):
46 """A class implementing Leo's Qt gui."""
47 #@+others
48 #@+node:ekr.20110605121601.18477: *3* qt_gui.__init__ (sets qtApp)
49 def __init__(self):
50 """Ctor for LeoQtGui class."""
51 super().__init__('qt') # Initialize the base class.
52 self.active = True
53 self.consoleOnly = False # Console is separate from the log.
54 self.iconimages = {}
55 self.globalFindDialog = None
56 self.idleTimeClass = qt_idle_time.IdleTime
57 self.insert_char_flag = False # A flag for eventFilter.
58 self.mGuiName = 'qt'
59 self.main_window = None # The *singleton* QMainWindow.
60 self.plainTextWidget = qt_text.PlainTextWrapper
61 self.show_tips_flag = False # #2390: Can't be inited in reload_settings.
62 self.styleSheetManagerClass = StyleSheetManager
63 # Be aware of the systems native colors, fonts, etc.
64 QtWidgets.QApplication.setDesktopSettingsAware(True)
65 # Create objects...
66 self.qtApp = QtWidgets.QApplication(sys.argv)
67 self.reloadSettings()
68 self.appIcon = self.getIconImage('leoapp32.png')
70 # Define various classes key stokes.
71 #@+<< define FKeys >>
72 #@+node:ekr.20180419110303.1: *4* << define FKeys >>
73 self.FKeys = [
74 'F1', 'F2', 'F3', 'F4', 'F5', 'F6', 'F7', 'F8', 'F9', 'F10', 'F11', 'F12']
75 # These do not generate keystrokes on MacOs.
76 #@-<< define FKeys >>
77 #@+<< define ignoreChars >>
78 #@+node:ekr.20180419105250.1: *4* << define ignoreChars >>
79 # Always ignore these characters
80 self.ignoreChars = [
81 # These are in ks.special characters.
82 # They should *not* be ignored.
83 # 'Left', 'Right', 'Up', 'Down',
84 # 'Next', 'Prior',
85 # 'Home', 'End',
86 # 'Delete', 'Escape',
87 # 'BackSpace', 'Linefeed', 'Return', 'Tab',
88 # F-Keys are also ok.
89 # 'F1','F2','F3','F4','F5','F6','F7','F8','F9','F10','F11','F12',
90 'KP_0', 'KP_1', 'KP_2', 'KP_3', 'KP_4', 'KP_5', 'KP_6', 'KP_7', 'KP_8', 'KP_9',
91 'KP_Multiply, KP_Separator,KP_Space, KP_Subtract, KP_Tab',
92 'KP_F1', 'KP_F2', 'KP_F3', 'KP_F4',
93 'KP_Add', 'KP_Decimal', 'KP_Divide', 'KP_Enter', 'KP_Equal',
94 # Keypad chars should be have been converted to other keys.
95 # Users should just bind to the corresponding normal keys.
96 'CapsLock', 'Caps_Lock',
97 'NumLock', 'Num_Lock',
98 'ScrollLock',
99 'Alt_L', 'Alt_R',
100 'Control_L', 'Control_R',
101 'Meta_L', 'Meta_R',
102 'Shift_L', 'Shift_R',
103 'Win_L', 'Win_R',
104 # Clearly, these should never be generated.
105 'Break', 'Pause', 'Sys_Req',
106 # These are real keys, but they don't mean anything.
107 'Begin', 'Clear',
108 # Don't know what these are.
109 ]
110 #@-<< define ignoreChars >>
111 #@+<< define specialChars >>
112 #@+node:ekr.20180419081404.1: *4* << define specialChars >>
113 # Keys whose names must never be inserted into text.
114 self.specialChars = [
115 # These are *not* special keys.
116 # 'BackSpace', 'Linefeed', 'Return', 'Tab',
117 'Left', 'Right', 'Up', 'Down',
118 # Arrow keys
119 'Next', 'Prior',
120 # Page up/down keys.
121 'Home', 'End',
122 # Home end keys.
123 'Delete', 'Escape',
124 # Others.
125 'Enter', 'Insert', 'Ins',
126 # These should only work if bound.
127 'Menu',
128 # #901.
129 'PgUp', 'PgDn',
130 # #868.
131 ]
132 #@-<< define specialChars >>
133 # Put up the splash screen()
134 if (g.app.use_splash_screen and
135 not g.app.batchMode and
136 not g.app.silentMode and
137 not g.unitTesting
138 ):
139 self.splashScreen = self.createSplashScreen()
140 # qtFrame.finishCreate does all the other work.
141 self.frameFactory = qt_frame.TabbedFrameFactory()
143 def reloadSettings(self):
144 pass # Note: self.c does not exist.
145 #@+node:ekr.20110605121601.18484: *3* qt_gui.destroySelf (calls qtApp.quit)
146 def destroySelf(self):
148 QtCore.pyqtRemoveInputHook()
149 if 'shutdown' in g.app.debug:
150 g.pr('LeoQtGui.destroySelf: calling qtApp.Quit')
151 self.qtApp.quit()
152 #@+node:ekr.20110605121601.18485: *3* qt_gui.Clipboard
153 #@+node:ekr.20160917125946.1: *4* qt_gui.replaceClipboardWith
154 def replaceClipboardWith(self, s):
155 """Replace the clipboard with the string s."""
156 cb = self.qtApp.clipboard()
157 if cb:
158 # cb.clear() # unnecessary, breaks on some Qt versions
159 s = g.toUnicode(s)
160 QtWidgets.QApplication.processEvents()
161 # Fix #241: QMimeData object error
162 cb.setText(s)
163 QtWidgets.QApplication.processEvents()
164 else:
165 g.trace('no clipboard!')
166 #@+node:ekr.20160917125948.1: *4* qt_gui.getTextFromClipboard
167 def getTextFromClipboard(self):
168 """Get a unicode string from the clipboard."""
169 cb = self.qtApp.clipboard()
170 if cb:
171 QtWidgets.QApplication.processEvents()
172 return cb.text()
173 g.trace('no clipboard!')
174 return ''
175 #@+node:ekr.20160917130023.1: *4* qt_gui.setClipboardSelection
176 def setClipboardSelection(self, s):
177 """
178 Set the clipboard selection to s.
179 There are problems with PyQt5.
180 """
181 if isQt5 or isQt6:
182 # Alas, returning s reopens #218.
183 return
184 if s:
185 # This code generates a harmless, but annoying warning on PyQt5.
186 cb = self.qtApp.clipboard()
187 cb.setText(s, mode=cb.Selection)
188 #@+node:ekr.20110605121601.18487: *3* qt_gui.Dialogs & panels
189 #@+node:ekr.20110605121601.18488: *4* qt_gui.alert
190 def alert(self, c, message):
191 if g.unitTesting:
192 return
193 dialog = QtWidgets.QMessageBox(None)
194 dialog.setWindowTitle('Alert')
195 dialog.setText(message)
196 dialog.setIcon(Icon.Warning)
197 dialog.addButton('Ok', ButtonRole.YesRole)
198 try:
199 c.in_qt_dialog = True
200 dialog.raise_()
201 dialog.exec_()
202 finally:
203 c.in_qt_dialog = False
204 #@+node:ekr.20110605121601.18489: *4* qt_gui.makeFilter
205 def makeFilter(self, filetypes):
206 """Return the Qt-style dialog filter from filetypes list."""
207 filters = ['%s (%s)' % (z) for z in filetypes]
208 # Careful: the second %s is *not* replaced.
209 return ';;'.join(filters)
210 #@+node:ekr.20150615211522.1: *4* qt_gui.openFindDialog & helper
211 def openFindDialog(self, c):
212 if g.unitTesting:
213 return
214 dialog = self.globalFindDialog
215 if not dialog:
216 dialog = self.createFindDialog(c)
217 self.globalFindDialog = dialog
218 # Fix #516: Do the following only once...
219 if c:
220 dialog.setStyleSheet(c.active_stylesheet)
221 # Set the commander's FindTabManager.
222 assert g.app.globalFindTabManager
223 c.ftm = g.app.globalFindTabManager
224 fn = c.shortFileName() or 'Untitled'
225 else:
226 fn = 'Untitled'
227 dialog.setWindowTitle(f"Find in {fn}")
228 if c:
229 c.inCommand = False
230 if dialog.isVisible():
231 # The order is important, and tricky.
232 dialog.focusWidget()
233 dialog.show()
234 dialog.raise_()
235 dialog.activateWindow()
236 else:
237 dialog.show()
238 dialog.exec_()
239 #@+node:ekr.20150619053138.1: *5* qt_gui.createFindDialog
240 def createFindDialog(self, c):
241 """Create and init a non-modal Find dialog."""
242 if c:
243 g.app.globalFindTabManager = c.findCommands.ftm
244 top = c and c.frame.top # top is the DynamicWindow class.
245 w = top.findTab
246 dialog = QtWidgets.QDialog()
247 # Fix #516: Hide the dialog. Never delete it.
249 def closeEvent(event):
250 event.ignore()
251 dialog.hide()
253 dialog.closeEvent = closeEvent
254 layout = QtWidgets.QVBoxLayout(dialog)
255 layout.addWidget(w)
256 self.attachLeoIcon(dialog)
257 dialog.setLayout(layout)
258 if c:
259 c.styleSheetManager.set_style_sheets(w=dialog)
260 g.app.gui.setFilter(c, dialog, dialog, 'find-dialog')
261 # This makes most standard bindings available.
262 dialog.setModal(False)
263 return dialog
264 #@+node:ekr.20110605121601.18492: *4* qt_gui.panels
265 def createComparePanel(self, c):
266 """Create a qt color picker panel."""
267 return None # This window is optional.
269 def createFindTab(self, c, parentFrame):
270 """Create a qt find tab in the indicated frame."""
271 pass # Now done in dw.createFindTab.
273 def createLeoFrame(self, c, title):
274 """Create a new Leo frame."""
275 return qt_frame.LeoQtFrame(c, title, gui=self)
277 def createSpellTab(self, c, spellHandler, tabName):
278 if g.unitTesting:
279 return None
280 return qt_frame.LeoQtSpellTab(c, spellHandler, tabName)
281 #@+node:ekr.20110605121601.18493: *4* qt_gui.runAboutLeoDialog
282 def runAboutLeoDialog(self, c, version, theCopyright, url, email):
283 """Create and run a qt About Leo dialog."""
284 if g.unitTesting:
285 return
286 dialog = QtWidgets.QMessageBox(c and c.frame.top)
287 dialog.setText(f"{version}\n{theCopyright}\n{url}\n{email}")
288 dialog.setIcon(Icon.Information)
289 yes = dialog.addButton('Ok', ButtonRole.YesRole)
290 dialog.setDefaultButton(yes)
291 try:
292 c.in_qt_dialog = True
293 dialog.raise_()
294 dialog.exec_()
295 finally:
296 c.in_qt_dialog = False
297 #@+node:ekr.20110605121601.18496: *4* qt_gui.runAskDateTimeDialog
298 def runAskDateTimeDialog(self, c, title,
299 message='Select Date/Time',
300 init=None,
301 step_min=None
302 ):
303 """Create and run a qt date/time selection dialog.
305 init - a datetime, default now
306 step_min - a dict, keys are QtWidgets.QDateTimeEdit Sections, like
307 QtWidgets.QDateTimeEdit.MinuteSection, and values are integers,
308 the minimum amount that section of the date/time changes
309 when you roll the mouse wheel.
311 E.g. (5 minute increments in minute field):
313 g.app.gui.runAskDateTimeDialog(c, 'When?',
314 message="When is it?",
315 step_min={QtWidgets.QDateTimeEdit.MinuteSection: 5})
317 """
318 #@+<< define date/time classes >>
319 #@+node:ekr.20211005103909.1: *5* << define date/time classes >>
322 class DateTimeEditStepped(QtWidgets.QDateTimeEdit): # type:ignore
323 """QDateTimeEdit which allows you to set minimum steps on fields, e.g.
324 DateTimeEditStepped(parent, {QtWidgets.QDateTimeEdit.MinuteSection: 5})
325 for a minimum 5 minute increment on the minute field.
326 """
328 def __init__(self, parent=None, init=None, step_min=None):
329 if step_min is None:
330 step_min = {}
331 self.step_min = step_min
332 if init:
333 super().__init__(init, parent)
334 else:
335 super().__init__(parent)
337 def stepBy(self, step):
338 cs = self.currentSection()
339 if cs in self.step_min and abs(step) < self.step_min[cs]:
340 step = self.step_min[cs] if step > 0 else -self.step_min[cs]
341 QtWidgets.QDateTimeEdit.stepBy(self, step)
344 class Calendar(QtWidgets.QDialog): # type:ignore
346 def __init__(self,
347 parent=None,
348 message='Select Date/Time',
349 init=None,
350 step_min=None
351 ):
352 if step_min is None:
353 step_min = {}
354 super().__init__(parent)
355 layout = QtWidgets.QVBoxLayout()
356 self.setLayout(layout)
357 layout.addWidget(QtWidgets.QLabel(message))
358 self.dt = DateTimeEditStepped(init=init, step_min=step_min)
359 self.dt.setCalendarPopup(True)
360 layout.addWidget(self.dt)
361 buttonBox = QtWidgets.QDialogButtonBox(StandardButton.Ok | StandardButton.Cancel)
362 layout.addWidget(buttonBox)
363 buttonBox.accepted.connect(self.accept)
364 buttonBox.rejected.connect(self.reject)
366 #@-<< define date/time classes >>
367 if g.unitTesting:
368 return None
369 if step_min is None:
370 step_min = {}
371 if not init:
372 init = datetime.datetime.now()
373 dialog = Calendar(c and c.frame.top, message=message, init=init, step_min=step_min)
374 if c:
375 dialog.setStyleSheet(c.active_stylesheet)
376 dialog.setWindowTitle(title)
377 try:
378 c.in_qt_dialog = True
379 dialog.raise_()
380 val = dialog.exec() if isQt6 else dialog.exec_()
381 finally:
382 c.in_qt_dialog = False
383 else:
384 dialog.setWindowTitle(title)
385 dialog.raise_()
386 val = dialog.exec() if isQt6 else dialog.exec_()
387 if val == DialogCode.Accepted:
388 return dialog.dt.dateTime().toPyDateTime()
389 return None
390 #@+node:ekr.20110605121601.18494: *4* qt_gui.runAskLeoIDDialog (not used)
391 def runAskLeoIDDialog(self):
392 """Create and run a dialog to get g.app.LeoID."""
393 if g.unitTesting:
394 return None
395 message = (
396 "leoID.txt not found\n\n" +
397 "Please enter an id that identifies you uniquely.\n" +
398 "Your cvs/bzr login name is a good choice.\n\n" +
399 "Leo uses this id to uniquely identify nodes.\n\n" +
400 "Your id must contain only letters and numbers\n" +
401 "and must be at least 3 characters in length.")
402 parent = None
403 title = 'Enter Leo id'
404 s, ok = QtWidgets.QInputDialog.getText(parent, title, message)
405 return s
406 #@+node:ekr.20110605121601.18491: *4* qt_gui.runAskOkCancelNumberDialog
407 def runAskOkCancelNumberDialog(
408 self, c, title, message, cancelButtonText=None, okButtonText=None):
409 """Create and run askOkCancelNumber dialog ."""
410 if g.unitTesting:
411 return None
412 # n,ok = QtWidgets.QInputDialog.getDouble(None,title,message)
413 dialog = QtWidgets.QInputDialog()
414 if c:
415 dialog.setStyleSheet(c.active_stylesheet)
416 dialog.setWindowTitle(title)
417 dialog.setLabelText(message)
418 if cancelButtonText:
419 dialog.setCancelButtonText(cancelButtonText)
420 if okButtonText:
421 dialog.setOkButtonText(okButtonText)
422 self.attachLeoIcon(dialog)
423 dialog.raise_()
424 ok = dialog.exec_()
425 n = dialog.textValue()
426 try:
427 n = float(n)
428 except ValueError:
429 n = None
430 return n if ok else None
431 #@+node:ekr.20110605121601.18490: *4* qt_gui.runAskOkCancelStringDialog
432 def runAskOkCancelStringDialog(self, c, title, message, cancelButtonText=None,
433 okButtonText=None, default="", wide=False):
434 """Create and run askOkCancelString dialog.
436 wide - edit a long string
437 """
438 if g.unitTesting:
439 return None
440 dialog = QtWidgets.QInputDialog()
441 if c:
442 dialog.setStyleSheet(c.active_stylesheet)
443 dialog.setWindowTitle(title)
444 dialog.setLabelText(message)
445 dialog.setTextValue(default)
446 if wide:
447 dialog.resize(int(g.windows()[0].get_window_info()[0] * .9), 100)
448 if cancelButtonText:
449 dialog.setCancelButtonText(cancelButtonText)
450 if okButtonText:
451 dialog.setOkButtonText(okButtonText)
452 self.attachLeoIcon(dialog)
453 dialog.raise_()
454 ok = dialog.exec_()
455 return str(dialog.textValue()) if ok else None
456 #@+node:ekr.20110605121601.18495: *4* qt_gui.runAskOkDialog
457 def runAskOkDialog(self, c, title, message=None, text="Ok"):
458 """Create and run a qt askOK dialog ."""
459 if g.unitTesting:
460 return
461 dialog = QtWidgets.QMessageBox(c and c.frame.top)
462 stylesheet = getattr(c, 'active_stylesheet', None)
463 if stylesheet:
464 dialog.setStyleSheet(stylesheet)
465 dialog.setWindowTitle(title)
466 if message:
467 dialog.setText(message)
468 dialog.setIcon(Information.Information)
469 dialog.addButton(text, ButtonRole.YesRole)
470 try:
471 c.in_qt_dialog = True
472 dialog.raise_()
473 dialog.exec_()
474 finally:
475 c.in_qt_dialog = False
477 #@+node:ekr.20110605121601.18497: *4* qt_gui.runAskYesNoCancelDialog
478 def runAskYesNoCancelDialog(self, c, title,
479 message=None,
480 yesMessage="&Yes",
481 noMessage="&No",
482 yesToAllMessage=None,
483 defaultButton="Yes",
484 cancelMessage=None,
485 ):
486 """
487 Create and run an askYesNo dialog.
489 Return one of ('yes', 'no', 'cancel', 'yes-to-all').
491 """
492 if g.unitTesting:
493 return None
494 dialog = QtWidgets.QMessageBox(c and c.frame.top)
495 stylesheet = getattr(c, 'active_stylesheet', None)
496 if stylesheet:
497 dialog.setStyleSheet(stylesheet)
498 if message:
499 dialog.setText(message)
500 dialog.setIcon(Information.Warning)
501 dialog.setWindowTitle(title)
502 # Creation order determines returned value.
503 yes = dialog.addButton(yesMessage, ButtonRole.YesRole)
504 no = dialog.addButton(noMessage, ButtonRole.NoRole)
505 cancel = dialog.addButton(cancelMessage or 'Cancel', ButtonRole.RejectRole)
506 if yesToAllMessage:
507 dialog.addButton(yesToAllMessage, ButtonRole.YesRole)
508 if defaultButton == "Yes":
509 dialog.setDefaultButton(yes)
510 elif defaultButton == "No":
511 dialog.setDefaultButton(no)
512 else:
513 dialog.setDefaultButton(cancel)
514 try:
515 c.in_qt_dialog = True
516 dialog.raise_() # #2246.
517 val = dialog.exec() if isQt6 else dialog.exec_()
518 finally:
519 c.in_qt_dialog = False
520 # val is the same as the creation order.
521 # Tested with both Qt6 and Qt5.
522 return {
523 0: 'yes', 1: 'no', 2: 'cancel', 3: 'yes-to-all',
524 }.get(val, 'cancel')
525 #@+node:ekr.20110605121601.18498: *4* qt_gui.runAskYesNoDialog
526 def runAskYesNoDialog(self, c, title, message=None, yes_all=False, no_all=False):
527 """
528 Create and run an askYesNo dialog.
529 Return one of ('yes','yes-all','no','no-all')
531 :Parameters:
532 - `c`: commander
533 - `title`: dialog title
534 - `message`: dialog message
535 - `yes_all`: bool - show YesToAll button
536 - `no_all`: bool - show NoToAll button
537 """
538 if g.unitTesting:
539 return None
540 dialog = QtWidgets.QMessageBox(c and c.frame.top)
541 # Creation order determines returned value.
542 yes = dialog.addButton('Yes', ButtonRole.YesRole)
543 dialog.addButton('No', ButtonRole.NoRole)
544 # dialog.addButton('Cancel', ButtonRole.RejectRole)
545 if yes_all:
546 dialog.addButton('Yes To All', ButtonRole.YesRole)
547 if no_all:
548 dialog.addButton('No To All', ButtonRole.NoRole)
549 if c:
550 dialog.setStyleSheet(c.active_stylesheet)
551 dialog.setWindowTitle(title)
552 if message:
553 dialog.setText(message)
554 dialog.setIcon(Information.Warning)
555 dialog.setDefaultButton(yes)
556 if c:
557 try:
558 c.in_qt_dialog = True
559 dialog.raise_()
560 val = dialog.exec() if isQt6 else dialog.exec_()
561 finally:
562 c.in_qt_dialog = False
563 else:
564 dialog.raise_()
565 val = dialog.exec() if isQt6 else dialog.exec_()
566 # val is the same as the creation order.
567 # Tested with both Qt6 and Qt5.
568 return {
569 # Buglet: This assumes both yes-all and no-all buttons are active.
570 0: 'yes', 1: 'no', 2: 'cancel', 3: 'yes-all', 4: 'no-all',
571 }.get(val, 'cancel')
572 #@+node:ekr.20110605121601.18499: *4* qt_gui.runOpenDirectoryDialog
573 def runOpenDirectoryDialog(self, title, startdir):
574 """Create and run an Qt open directory dialog ."""
575 if g.unitTesting:
576 return None
577 dialog = QtWidgets.QFileDialog()
578 self.attachLeoIcon(dialog)
579 return dialog.getExistingDirectory(None, title, startdir)
580 #@+node:ekr.20110605121601.18500: *4* qt_gui.runOpenFileDialog
581 def runOpenFileDialog(self, c,
582 title,
583 filetypes,
584 defaultextension='',
585 multiple=False,
586 startpath=None,
587 ):
588 """
589 Create and run an Qt open file dialog.
590 """
591 # pylint: disable=arguments-differ
592 if g.unitTesting:
593 return ''
595 # 2018/03/14: Bug fixes:
596 # - Use init_dialog_folder only if a path is not given
597 # - *Never* Use os.curdir by default!
598 if not startpath:
599 # Returns c.last_dir or os.curdir
600 startpath = g.init_dialog_folder(c, c and c.p, use_at_path=True)
601 filter_ = self.makeFilter(filetypes)
602 dialog = QtWidgets.QFileDialog()
603 if c:
604 dialog.setStyleSheet(c.active_stylesheet)
605 self.attachLeoIcon(dialog)
606 func = dialog.getOpenFileNames if multiple else dialog.getOpenFileName
607 if c:
608 try:
609 c.in_qt_dialog = True
610 val = func(parent=None, caption=title, directory=startpath, filter=filter_)
611 finally:
612 c.in_qt_dialog = False
613 else:
614 val = func(parent=None, caption=title, directory=startpath, filter=filter_)
615 if isQt5 or isQt6: # This is a *Py*Qt change rather than a Qt change
616 val, junk_selected_filter = val
617 if multiple:
618 files = [g.os_path_normslashes(s) for s in val]
619 if c and files:
620 c.last_dir = g.os_path_dirname(files[-1])
621 return files
622 s = g.os_path_normslashes(val)
623 if c and s:
624 c.last_dir = g.os_path_dirname(s)
625 return s
626 #@+node:ekr.20110605121601.18501: *4* qt_gui.runPropertiesDialog
627 def runPropertiesDialog(self,
628 title='Properties',
629 data=None,
630 callback=None,
631 buttons=None
632 ):
633 """Dispay a modal TkPropertiesDialog"""
634 if not g.unitTesting:
635 g.warning('Properties menu not supported for Qt gui')
636 return 'Cancel', {}
637 #@+node:ekr.20110605121601.18502: *4* qt_gui.runSaveFileDialog
638 def runSaveFileDialog(
639 self, c, title='Save', filetypes=None, defaultextension=''):
640 """Create and run an Qt save file dialog ."""
641 if g.unitTesting:
642 return ''
643 dialog = QtWidgets.QFileDialog()
644 if c:
645 dialog.setStyleSheet(c.active_stylesheet)
646 self.attachLeoIcon(dialog)
647 try:
648 c.in_qt_dialog = True
649 obj = dialog.getSaveFileName(
650 None, # parent
651 title,
652 # os.curdir,
653 g.init_dialog_folder(c, c.p, use_at_path=True),
654 self.makeFilter(filetypes or []),
655 )
656 finally:
657 c.in_qt_dialog = False
658 else:
659 self.attachLeoIcon(dialog)
660 obj = dialog.getSaveFileName(
661 None, # parent
662 title,
663 # os.curdir,
664 g.init_dialog_folder(None, None, use_at_path=True),
665 self.makeFilter(filetypes or []),
666 )
667 # Bizarre: PyQt5 version can return a tuple!
668 s = obj[0] if isinstance(obj, (list, tuple)) else obj
669 s = s or ''
670 if c and s:
671 c.last_dir = g.os_path_dirname(s)
672 return s
673 #@+node:ekr.20110605121601.18503: *4* qt_gui.runScrolledMessageDialog
674 def runScrolledMessageDialog(self,
675 short_title='',
676 title='Message',
677 label='',
678 msg='',
679 c=None, **keys
680 ):
681 if g.unitTesting:
682 return None
684 def send():
685 return g.doHook('scrolledMessage',
686 short_title=short_title, title=title,
687 label=label, msg=msg, c=c, **keys)
689 if not c or not c.exists:
690 #@+<< no c error>>
691 #@+node:ekr.20110605121601.18504: *5* << no c error>>
692 g.es_print_error('%s\n%s\n\t%s' % (
693 "The qt plugin requires calls to g.app.gui.scrolledMessageDialog to include 'c'",
694 "as a keyword argument",
695 g.callers()
696 ))
697 #@-<< no c error>>
698 else:
699 retval = send()
700 if retval:
701 return retval
702 #@+<< load viewrendered plugin >>
703 #@+node:ekr.20110605121601.18505: *5* << load viewrendered plugin >>
704 pc = g.app.pluginsController
705 # Load viewrendered (and call vr.onCreate) *only* if not already loaded.
706 if (
707 not pc.isLoaded('viewrendered.py')
708 and not pc.isLoaded('viewrendered3.py')
709 ):
710 vr = pc.loadOnePlugin('viewrendered.py')
711 if vr:
712 g.blue('viewrendered plugin loaded.')
713 vr.onCreate('tag', {'c': c})
714 #@-<< load viewrendered plugin >>
715 retval = send()
716 if retval:
717 return retval
718 #@+<< no dialog error >>
719 #@+node:ekr.20110605121601.18506: *5* << no dialog error >>
720 g.es_print_error(
721 f'No handler for the "scrolledMessage" hook.\n\t{g.callers()}')
722 #@-<< no dialog error >>
723 #@+<< emergency fallback >>
724 #@+node:ekr.20110605121601.18507: *5* << emergency fallback >>
725 dialog = QtWidgets.QMessageBox(None)
726 dialog.setWindowFlags(WindowType.Dialog)
727 # That is, not a fixed size dialog.
728 dialog.setWindowTitle(title)
729 if msg:
730 dialog.setText(msg)
731 dialog.setIcon(Icon.Information)
732 dialog.addButton('Ok', ButtonRole.YesRole)
733 try:
734 c.in_qt_dialog = True
735 if isQt6:
736 dialog.exec()
737 else:
738 dialog.exec_()
739 finally:
740 c.in_qt_dialog = False
741 #@-<< emergency fallback >>
742 #@+node:ekr.20110607182447.16456: *3* qt_gui.Event handlers
743 #@+node:ekr.20190824094650.1: *4* qt_gui.close_event
744 def close_event(self, event):
746 if g.app.sessionManager and g.app.loaded_session:
747 g.app.sessionManager.save_snapshot()
748 for c in g.app.commanders():
749 allow = c.exists and g.app.closeLeoWindow(c.frame)
750 if not allow:
751 event.ignore()
752 return
753 event.accept()
754 #@+node:ekr.20110605121601.18481: *4* qt_gui.onDeactiveEvent
755 # deactivated_name = ''
757 deactivated_widget = None
759 def onDeactivateEvent(self, event, c, obj, tag):
760 """
761 Gracefully deactivate the Leo window.
762 Called several times for each window activation.
763 """
764 w = self.get_focus()
765 w_name = w and w.objectName()
766 if 'focus' in g.app.debug:
767 g.trace(repr(w_name))
768 self.active = False # Used only by c.idle_focus_helper.
769 # Careful: never save headline widgets.
770 if w_name == 'headline':
771 self.deactivated_widget = c.frame.tree.treeWidget
772 else:
773 self.deactivated_widget = w if w_name else None
774 # Causes problems elsewhere...
775 # if c.exists and not self.deactivated_name:
776 # self.deactivated_name = self.widget_name(self.get_focus())
777 # self.active = False
778 # c.k.keyboardQuit(setFocus=False)
779 g.doHook('deactivate', c=c, p=c.p, v=c.p, event=event)
780 #@+node:ekr.20110605121601.18480: *4* qt_gui.onActivateEvent
781 # Called from eventFilter
783 def onActivateEvent(self, event, c, obj, tag):
784 """
785 Restore the focus when the Leo window is activated.
786 Called several times for each window activation.
787 """
788 trace = 'focus' in g.app.debug
789 w = self.get_focus() or self.deactivated_widget
790 self.deactivated_widget = None
791 w_name = w and w.objectName()
792 # Fix #270: Vim keys don't always work after double Alt+Tab.
793 # Fix #359: Leo hangs in LeoQtEventFilter.eventFilter
794 # #1273: add teest on c.vim_mode.
795 if c.exists and c.vim_mode and c.vimCommands and not self.active and not g.app.killed:
796 c.vimCommands.on_activate()
797 self.active = True # Used only by c.idle_focus_helper.
798 if g.isMac:
799 pass # Fix #757: MacOS: replace-then-find does not work in headlines.
800 else:
801 # Leo 5.6: Recover from missing focus.
802 # c.idle_focus_handler can't do this.
803 if w and w_name in ('log-widget', 'richTextEdit', 'treeWidget'):
804 # Restore focus **only** to body or tree
805 if trace:
806 g.trace('==>', w_name)
807 c.widgetWantsFocusNow(w)
808 else:
809 if trace:
810 g.trace(repr(w_name), '==> BODY')
811 c.bodyWantsFocusNow()
812 # Cause problems elsewhere.
813 # if c.exists and self.deactivated_name:
814 # self.active = True
815 # w_name = self.deactivated_name
816 # self.deactivated_name = None
817 # if c.p.v:
818 # c.p.v.restoreCursorAndScroll()
819 # if w_name.startswith('tree') or w_name.startswith('head'):
820 # c.treeWantsFocusNow()
821 # else:
822 # c.bodyWantsFocusNow()
823 g.doHook('activate', c=c, p=c.p, v=c.p, event=event)
824 #@+node:ekr.20130921043420.21175: *4* qt_gui.setFilter
825 def setFilter(self, c, obj, w, tag):
826 """
827 Create an event filter in obj.
828 w is a wrapper object, not necessarily a QWidget.
829 """
830 # w's type is in (DynamicWindow,QMinibufferWrapper,LeoQtLog,LeoQtTree,
831 # QTextEditWrapper,LeoQTextBrowser,LeoQuickSearchWidget,cleoQtUI)
832 assert isinstance(obj, QtWidgets.QWidget), obj
833 theFilter = qt_events.LeoQtEventFilter(c, w=w, tag=tag)
834 obj.installEventFilter(theFilter)
835 w.ev_filter = theFilter # Set the official ivar in w.
836 #@+node:ekr.20110605121601.18508: *3* qt_gui.Focus
837 #@+node:ekr.20190601055031.1: *4* qt_gui.ensure_commander_visible
838 def ensure_commander_visible(self, c1):
839 """
840 Check to see if c.frame is in a tabbed ui, and if so, make sure
841 the tab is visible
842 """
843 # pylint: disable=arguments-differ
844 #
845 # START: copy from Code-->Startup & external files-->
846 # @file runLeo.py -->run & helpers-->doPostPluginsInit & helpers (runLeo.py)
847 # For the qt gui, select the first-loaded tab.
848 if 'focus' in g.app.debug:
849 g.trace(c1)
850 if hasattr(g.app.gui, 'frameFactory'):
851 factory = g.app.gui.frameFactory
852 if factory and hasattr(factory, 'setTabForCommander'):
853 c = c1
854 factory.setTabForCommander(c)
855 c.bodyWantsFocusNow()
856 # END: copy
857 #@+node:ekr.20190601054958.1: *4* qt_gui.get_focus
858 def get_focus(self, c=None, raw=False, at_idle=False):
859 """Returns the widget that has focus."""
860 # pylint: disable=arguments-differ
861 trace = 'focus' in g.app.debug
862 trace_idle = False
863 trace = trace and (trace_idle or not at_idle)
864 app = QtWidgets.QApplication
865 w = app.focusWidget()
866 if w and not raw and isinstance(w, qt_text.LeoQTextBrowser):
867 has_w = getattr(w, 'leo_wrapper', None)
868 if has_w:
869 if trace:
870 g.trace(w)
871 elif c:
872 # Kludge: DynamicWindow creates the body pane
873 # with wrapper = None, so return the LeoQtBody.
874 w = c.frame.body
875 if trace:
876 name = w.objectName() if hasattr(w, 'objectName') else w.__class__.__name__
877 g.trace('(LeoQtGui)', name)
878 return w
879 #@+node:ekr.20190601054959.1: *4* qt_gui.set_focus
880 def set_focus(self, c, w):
881 """Put the focus on the widget."""
882 # pylint: disable=arguments-differ
883 if not w:
884 return
885 if getattr(w, 'widget', None):
886 if not isinstance(w, QtWidgets.QWidget):
887 # w should be a wrapper.
888 w = w.widget
889 if 'focus' in g.app.debug:
890 name = w.objectName() if hasattr(w, 'objectName') else w.__class__.__name__
891 g.trace('(LeoQtGui)', name)
892 w.setFocus()
893 #@+node:ekr.20110605121601.18510: *3* qt_gui.getFontFromParams
894 size_warnings: List[str] = []
896 def getFontFromParams(self, family, size, slant, weight, defaultSize=12):
897 """Required to handle syntax coloring."""
898 if isinstance(size, str):
899 if size.endswith('pt'):
900 size = size[:-2].strip()
901 elif size.endswith('px'):
902 if size not in self.size_warnings:
903 self.size_warnings.append(size)
904 g.es(f"px ignored in font setting: {size}")
905 size = size[:-2].strip()
906 try:
907 size = int(size)
908 except Exception:
909 size = 0
910 if size < 1:
911 size = defaultSize
912 d = {
913 'black': Weight.Black,
914 'bold': Weight.Bold,
915 'demibold': Weight.DemiBold,
916 'light': Weight.Light,
917 'normal': Weight.Normal,
918 }
919 weight_val = d.get(weight.lower(), Weight.Normal)
920 italic = slant == 'italic'
921 if not family:
922 family = g.app.config.defaultFontFamily
923 if not family:
924 family = 'DejaVu Sans Mono'
925 try:
926 font = QtGui.QFont(family, size, weight_val, italic)
927 if sys.platform.startswith('linux'):
928 font.setHintingPreference(font.PreferFullHinting)
929 # g.es(font,font.hintingPreference())
930 return font
931 except Exception:
932 g.es("exception setting font", g.callers(4))
933 g.es(
934 f"family: {family}\n"
935 f" size: {size}\n"
936 f" slant: {slant}\n"
937 f"weight: {weight}")
938 # g.es_exception() # This just confuses people.
939 return g.app.config.defaultFont
940 #@+node:ekr.20110605121601.18511: *3* qt_gui.getFullVersion
941 def getFullVersion(self, c=None):
942 """Return the PyQt version (for signon)"""
943 try:
944 qtLevel = f"version {QtCore.qVersion()}"
945 except Exception:
946 # g.es_exception()
947 qtLevel = '<qtLevel>'
948 return f"PyQt {qtLevel}"
949 #@+node:ekr.20110605121601.18514: *3* qt_gui.Icons
950 #@+node:ekr.20110605121601.18515: *4* qt_gui.attachLeoIcon
951 def attachLeoIcon(self, window):
952 """Attach a Leo icon to the window."""
953 #icon = self.getIconImage('leoApp.ico')
954 if self.appIcon:
955 window.setWindowIcon(self.appIcon)
956 #@+node:ekr.20110605121601.18516: *4* qt_gui.getIconImage
957 def getIconImage(self, name):
958 """Load the icon and return it."""
959 # Return the image from the cache if possible.
960 if name in self.iconimages:
961 image = self.iconimages.get(name)
962 return image
963 try:
964 iconsDir = g.os_path_join(g.app.loadDir, "..", "Icons")
965 homeIconsDir = g.os_path_join(g.app.homeLeoDir, "Icons")
966 for theDir in (homeIconsDir, iconsDir):
967 fullname = g.os_path_finalize_join(theDir, name)
968 if g.os_path_exists(fullname):
969 if 0: # Not needed: use QTreeWidget.setIconsize.
970 pixmap = QtGui.QPixmap()
971 pixmap.load(fullname)
972 image = QtGui.QIcon(pixmap)
973 else:
974 image = QtGui.QIcon(fullname)
975 self.iconimages[name] = image
976 return image
977 # No image found.
978 return None
979 except Exception:
980 g.es_print("exception loading:", fullname)
981 g.es_exception()
982 return None
983 #@+node:ekr.20110605121601.18517: *4* qt_gui.getImageImage
984 @functools.lru_cache(maxsize=128)
985 def getImageImage(self, name):
986 """Load the image in file named `name` and return it."""
987 fullname = self.getImageFinder(name)
988 try:
989 pixmap = QtGui.QPixmap()
990 pixmap.load(fullname)
991 return pixmap
992 except Exception:
993 g.es("exception loading:", name)
994 g.es_exception()
995 return None
996 #@+node:tbrown.20130316075512.28478: *4* qt_gui.getImageFinder
997 dump_given = False
998 @functools.lru_cache(maxsize=128)
999 def getImageFinder(self, name):
1000 """Theme aware image (icon) path searching."""
1001 trace = 'themes' in g.app.debug
1002 exists = g.os_path_exists
1003 getString = g.app.config.getString
1005 def dump(var, val):
1006 print(f"{var:20}: {val}")
1008 join = g.os_path_join
1009 #
1010 # "Just works" for --theme and theme .leo files *provided* that
1011 # theme .leo files actually contain these settings!
1012 #
1013 theme_name1 = getString('color-theme')
1014 theme_name2 = getString('theme-name')
1015 roots = [
1016 g.os_path_join(g.computeHomeDir(), '.leo'),
1017 g.computeLeoDir(),
1018 ]
1019 theme_subs = [
1020 "themes/{theme}/Icons",
1021 "themes/{theme}",
1022 "Icons/{theme}",
1023 ]
1024 bare_subs = ["Icons", "."]
1025 # "." for icons referred to as Icons/blah/blah.png
1026 paths = []
1027 for theme_name in (theme_name1, theme_name2):
1028 for root in roots:
1029 for sub in theme_subs:
1030 paths.append(join(root, sub.format(theme=theme_name)))
1031 for root in roots:
1032 for sub in bare_subs:
1033 paths.append(join(root, sub))
1034 table = [z for z in paths if exists(z)]
1035 for base_dir in table:
1036 path = join(base_dir, name)
1037 if exists(path):
1038 if trace:
1039 g.trace(f"Found {name} in {base_dir}")
1040 return path
1041 # if trace: g.trace(name, 'not in', base_dir)
1042 if trace:
1043 g.trace('not found:', name)
1044 return None
1045 #@+node:ekr.20110605121601.18518: *4* qt_gui.getTreeImage
1046 @functools.lru_cache(maxsize=128)
1047 def getTreeImage(self, c, path):
1048 image = QtGui.QPixmap(path)
1049 if image.height() > 0 and image.width() > 0:
1050 return image, image.height()
1051 return None, None
1052 #@+node:ekr.20131007055150.17608: *3* qt_gui.insertKeyEvent
1053 def insertKeyEvent(self, event, i):
1054 """Insert the key given by event in location i of widget event.w."""
1055 assert isinstance(event, leoGui.LeoKeyEvent)
1056 qevent = event.event
1057 assert isinstance(qevent, QtGui.QKeyEvent)
1058 qw = getattr(event.w, 'widget', None)
1059 if qw and isinstance(qw, QtWidgets.QTextEdit):
1060 if 1:
1061 # Assume that qevent.text() *is* the desired text.
1062 # This means we don't have to hack eventFilter.
1063 qw.insertPlainText(qevent.text())
1064 else:
1065 # Make no such assumption.
1066 # We would like to use qevent to insert the character,
1067 # but this would invoke eventFilter again!
1068 # So set this flag for eventFilter, which will
1069 # return False, indicating that the widget must handle
1070 # qevent, which *presumably* is the best that can be done.
1071 g.app.gui.insert_char_flag = True
1072 #@+node:ekr.20190819072045.1: *3* qt_gui.make_main_window
1073 def make_main_window(self):
1074 """Make the *singleton* QMainWindow."""
1075 window = QtWidgets.QMainWindow()
1076 window.setObjectName('LeoGlobalMainWindow')
1077 # Calling window.show() here causes flash.
1078 self.attachLeoIcon(window)
1079 # Monkey-patch
1080 window.closeEvent = self.close_event # Use self: g.app.gui does not exist yet.
1081 self.runAtIdle(self.set_main_window_style_sheet) # No StyleSheetManager exists yet.
1082 return window
1084 def set_main_window_style_sheet(self):
1085 """Style the main window, using the first .leo file."""
1086 commanders = g.app.commanders()
1087 if commanders:
1088 c = commanders[0]
1089 ssm = c.styleSheetManager
1090 ssm.set_style_sheets(w=self.main_window)
1091 self.main_window.setWindowTitle(c.frame.title) # #1506.
1092 else:
1093 g.trace("No open commanders!")
1094 #@+node:ekr.20110605121601.18528: *3* qt_gui.makeScriptButton
1095 def makeScriptButton(self, c,
1096 args=None,
1097 p=None, # A node containing the script.
1098 script=None, # The script itself.
1099 buttonText=None,
1100 balloonText='Script Button',
1101 shortcut=None, bg='LightSteelBlue1',
1102 define_g=True, define_name='__main__', silent=False, # Passed on to c.executeScript.
1103 ):
1104 """
1105 Create a script button for the script in node p.
1106 The button's text defaults to p.headString."""
1107 k = c.k
1108 if p and not buttonText:
1109 buttonText = p.h.strip()
1110 if not buttonText:
1111 buttonText = 'Unnamed Script Button'
1112 #@+<< create the button b >>
1113 #@+node:ekr.20110605121601.18529: *4* << create the button b >>
1114 iconBar = c.frame.getIconBarObject()
1115 b = iconBar.add(text=buttonText)
1116 #@-<< create the button b >>
1117 #@+<< define the callbacks for b >>
1118 #@+node:ekr.20110605121601.18530: *4* << define the callbacks for b >>
1119 def deleteButtonCallback(event=None, b=b, c=c):
1120 if b:
1121 b.pack_forget()
1122 c.bodyWantsFocus()
1124 def executeScriptCallback(event=None,
1125 b=b,
1126 c=c,
1127 buttonText=buttonText,
1128 p=p and p.copy(),
1129 script=script
1130 ):
1131 if c.disableCommandsMessage:
1132 g.blue('', c.disableCommandsMessage)
1133 else:
1134 g.app.scriptDict = {'script_gnx': p.gnx}
1135 c.executeScript(args=args, p=p, script=script,
1136 define_g=define_g, define_name=define_name, silent=silent)
1137 # Remove the button if the script asks to be removed.
1138 if g.app.scriptDict.get('removeMe'):
1139 g.es('removing', f"'{buttonText}'", 'button at its request')
1140 b.pack_forget()
1141 # Do not assume the script will want to remain in this commander.
1143 #@-<< define the callbacks for b >>
1145 b.configure(command=executeScriptCallback)
1146 if shortcut:
1147 #@+<< bind the shortcut to executeScriptCallback >>
1148 #@+node:ekr.20110605121601.18531: *4* << bind the shortcut to executeScriptCallback >>
1149 # In qt_gui.makeScriptButton.
1150 func = executeScriptCallback
1151 if shortcut:
1152 shortcut = g.KeyStroke(shortcut)
1153 ok = k.bindKey('button', shortcut, func, buttonText)
1154 if ok:
1155 g.blue('bound @button', buttonText, 'to', shortcut)
1156 #@-<< bind the shortcut to executeScriptCallback >>
1157 #@+<< create press-buttonText-button command >>
1158 #@+node:ekr.20110605121601.18532: *4* << create press-buttonText-button command >> qt_gui.makeScriptButton
1159 # #1121. Like sc.cleanButtonText
1160 buttonCommandName = f"press-{buttonText.replace(' ', '-').strip('-')}-button"
1161 #
1162 # This will use any shortcut defined in an @shortcuts node.
1163 k.registerCommand(buttonCommandName, executeScriptCallback, pane='button')
1164 #@-<< create press-buttonText-button command >>
1165 #@+node:ekr.20170612065255.1: *3* qt_gui.put_help
1166 def put_help(self, c, s, short_title=''):
1167 """Put the help command."""
1168 s = textwrap.dedent(s.rstrip())
1169 if s.startswith('<') and not s.startswith('<<'):
1170 pass # how to do selective replace??
1171 pc = g.app.pluginsController
1172 table = (
1173 'viewrendered3.py',
1174 'viewrendered.py',
1175 )
1176 for name in table:
1177 if pc.isLoaded(name):
1178 vr = pc.loadOnePlugin(name)
1179 break
1180 else:
1181 vr = pc.loadOnePlugin('viewrendered.py')
1182 if vr:
1183 kw = {
1184 'c': c,
1185 'flags': 'rst',
1186 'kind': 'rst',
1187 'label': '',
1188 'msg': s,
1189 'name': 'Apropos',
1190 'short_title': short_title,
1191 'title': ''}
1192 vr.show_scrolled_message(tag='Apropos', kw=kw)
1193 c.bodyWantsFocus()
1194 if g.unitTesting:
1195 vr.close_rendering_pane(event={'c': c})
1196 elif g.unitTesting:
1197 pass
1198 else:
1199 g.es(s)
1200 return vr # For unit tests
1201 #@+node:ekr.20110605121601.18521: *3* qt_gui.runAtIdle
1202 def runAtIdle(self, aFunc):
1203 """This can not be called in some contexts."""
1204 QtCore.QTimer.singleShot(0, aFunc)
1205 #@+node:ekr.20110605121601.18483: *3* qt_gui.runMainLoop & runWithIpythonKernel
1206 #@+node:ekr.20130930062914.16000: *4* qt_gui.runMainLoop
1207 def runMainLoop(self):
1208 """Start the Qt main loop."""
1209 try: # #2127: A crash here hard-crashes Leo: There is no main loop!
1210 g.app.gui.dismiss_splash_screen()
1211 c = g.app.log and g.app.log.c
1212 if c and c.config.getBool('show-tips', default=False):
1213 g.app.gui.show_tips(c)
1214 except Exception:
1215 g.es_exception()
1216 if self.script:
1217 log = g.app.log
1218 if log:
1219 g.pr('Start of batch script...\n')
1220 log.c.executeScript(script=self.script)
1221 g.pr('End of batch script')
1222 else:
1223 g.pr('no log, no commander for executeScript in LeoQtGui.runMainLoop')
1224 elif g.app.useIpython and g.app.ipython_inited:
1225 self.runWithIpythonKernel()
1226 else:
1227 # This can be alarming when using Python's -i option.
1228 if isQt6:
1229 sys.exit(self.qtApp.exec())
1230 else:
1231 sys.exit(self.qtApp.exec_())
1232 #@+node:ekr.20130930062914.16001: *4* qt_gui.runWithIpythonKernel (commands)
1233 def runWithIpythonKernel(self):
1234 """Init Leo to run in an IPython shell."""
1235 try:
1236 from leo.core import leoIPython
1237 g.app.ipk = leoIPython.InternalIPKernel()
1238 g.app.ipk.run()
1239 except Exception:
1240 g.es_exception()
1241 print('can not init leo.core.leoIPython.py')
1242 sys.exit(1)
1243 #@+node:ekr.20200304125716.1: *3* qt_gui.onContextMenu
1244 def onContextMenu(self, c, w, point):
1245 """LeoQtGui: Common context menu handling."""
1246 # #1286.
1247 handlers = g.tree_popup_handlers
1248 menu = QtWidgets.QMenu(c.frame.top) # #1995.
1249 menuPos = w.mapToGlobal(point)
1250 if not handlers:
1251 menu.addAction("No popup handlers")
1252 p = c.p.copy()
1253 done = set()
1254 for handler in handlers:
1255 # every handler has to add it's QActions by itself
1256 if handler in done:
1257 # do not run the same handler twice
1258 continue
1259 try:
1260 handler(c, p, menu)
1261 except Exception:
1262 g.es_print('Exception executing right-click handler')
1263 g.es_exception()
1264 menu.popup(menuPos)
1265 self._contextmenu = menu
1266 #@+node:ekr.20190822174038.1: *3* qt_gui.set_top_geometry
1267 already_sized = False
1269 def set_top_geometry(self, w, h, x, y):
1270 """Set the geometry of the main window."""
1271 if 'size' in g.app.debug:
1272 g.trace('(qt_gui) already_sized', self.already_sized, w, h, x, y)
1273 if not self.already_sized:
1274 self.already_sized = True
1275 self.main_window.setGeometry(QtCore.QRect(x, y, w, h))
1276 #@+node:ekr.20180117053546.1: *3* qt_gui.show_tips & helpers
1277 @g.command('show-tips')
1278 def show_next_tip(self, event=None):
1279 c = g.app.log and g.app.log.c
1280 if c:
1281 g.app.gui.show_tips(c)
1283 #@+<< define DialogWithCheckBox >>
1284 #@+node:ekr.20220123052350.1: *4* << define DialogWithCheckBox >>
1285 class DialogWithCheckBox(QtWidgets.QMessageBox): # type:ignore
1287 def __init__(self, controller, checked, tip):
1288 super().__init__()
1289 c = g.app.log.c
1290 self.leo_checked = True
1291 self.setObjectName('TipMessageBox')
1292 self.setIcon(Icon.Information) # #2127.
1293 # self.setMinimumSize(5000, 4000)
1294 # Doesn't work.
1295 # Prevent the dialog from jumping around when
1296 # selecting multiple tips.
1297 self.setWindowTitle('Leo Tips')
1298 self.setText(repr(tip))
1299 self.next_tip_button = self.addButton('Show Next Tip', ButtonRole.ActionRole) # #2127
1300 self.addButton('Ok', ButtonRole.YesRole) # #2127.
1301 c.styleSheetManager.set_style_sheets(w=self)
1302 # Workaround #693.
1303 layout = self.layout()
1304 cb = QtWidgets.QCheckBox()
1305 cb.setObjectName('TipCheckbox')
1306 cb.setText('Show Tip On Startup')
1307 state = QtConst.CheckState.Checked if checked else QtConst.CheckState.Unchecked # #2383
1308 cb.setCheckState(state) # #2127.
1309 cb.stateChanged.connect(controller.onClick)
1310 layout.addWidget(cb, 4, 0, -1, -1)
1311 if 0: # Does not work well.
1312 sizePolicy = QtWidgets.QSizePolicy
1313 vSpacer = QtWidgets.QSpacerItem(
1314 200, 200, sizePolicy.Minimum, sizePolicy.Expanding)
1315 layout.addItem(vSpacer)
1316 #@-<< define DialogWithCheckBox >>
1318 def show_tips(self, c):
1319 if g.unitTesting:
1320 return
1321 from leo.core import leoTips
1322 tm = leoTips.TipManager()
1323 self.show_tips_flag = c.config.getBool('show-tips', default=False) # 2390.
1324 while True: # QMessageBox is always a modal dialog.
1325 tip = tm.get_next_tip()
1326 m = self.DialogWithCheckBox(controller=self, checked=self.show_tips_flag, tip=tip)
1327 try:
1328 c.in_qt_dialog = True
1329 m.exec_()
1330 finally:
1331 c.in_qt_dialog = False
1332 b = m.clickedButton()
1333 if b != m.next_tip_button:
1334 break
1336 #@+node:ekr.20180117080131.1: *4* onButton (not used)
1337 def onButton(self, m):
1338 m.hide()
1339 #@+node:ekr.20180117073603.1: *4* onClick
1340 def onClick(self, state):
1341 c = g.app.log.c
1342 self.show_tips_flag = bool(state)
1343 if c: # #2390: The setting *has* changed.
1344 c.config.setUserSetting('@bool show-tips', self.show_tips_flag)
1345 c.redraw() # #2390: Show the change immediately.
1346 #@+node:ekr.20180127103142.1: *4* onNext (not used)
1347 def onNext(self, *args, **keys):
1348 g.trace(args, keys)
1349 return True
1350 #@+node:ekr.20111215193352.10220: *3* qt_gui.Splash Screen
1351 #@+node:ekr.20110605121601.18479: *4* qt_gui.createSplashScreen
1352 def createSplashScreen(self):
1353 """Put up a splash screen with the Leo logo."""
1354 splash = None
1355 if sys.platform.startswith('win'):
1356 table = ('SplashScreen.jpg', 'SplashScreen.png', 'SplashScreen.ico')
1357 else:
1358 table = ('SplashScreen.xpm',)
1359 for name in table:
1360 fn = g.os_path_finalize_join(g.app.loadDir, '..', 'Icons', name)
1361 if g.os_path_exists(fn):
1362 pm = QtGui.QPixmap(fn)
1363 if not pm.isNull():
1364 splash = QtWidgets.QSplashScreen(pm, WindowType.WindowStaysOnTopHint)
1365 splash.show()
1366 # This sleep is required to do the repaint.
1367 QtCore.QThread.msleep(10)
1368 splash.repaint()
1369 break
1370 return splash
1371 #@+node:ekr.20110613103140.16424: *4* qt_gui.dismiss_splash_screen
1372 def dismiss_splash_screen(self):
1374 gui = self
1375 # Warning: closing the splash screen must be done in the main thread!
1376 if g.unitTesting:
1377 return
1378 if gui.splashScreen:
1379 gui.splashScreen.hide()
1380 # gui.splashScreen.deleteLater()
1381 gui.splashScreen = None
1382 #@+node:ekr.20140825042850.18411: *3* qt_gui.Utils...
1383 #@+node:ekr.20110605121601.18522: *4* qt_gui.isTextWidget/Wrapper
1384 def isTextWidget(self, w):
1385 """Return True if w is some kind of Qt text widget."""
1386 if Qsci:
1387 return isinstance(w, (Qsci.QsciScintilla, QtWidgets.QTextEdit)), w
1388 return isinstance(w, QtWidgets.QTextEdit), w
1390 def isTextWrapper(self, w):
1391 """Return True if w is a Text widget suitable for text-oriented commands."""
1392 if w is None:
1393 return False
1394 if isinstance(w, (g.NullObject, g.TracingNullObject)):
1395 return True
1396 return getattr(w, 'supportsHighLevelInterface', None)
1397 #@+node:ekr.20110605121601.18527: *4* qt_gui.widget_name
1398 def widget_name(self, w):
1399 # First try the widget's getName method.
1400 if not 'w':
1401 name = '<no widget>'
1402 elif hasattr(w, 'getName'):
1403 name = w.getName()
1404 elif hasattr(w, 'objectName'):
1405 name = str(w.objectName())
1406 elif hasattr(w, '_name'):
1407 name = w._name
1408 else:
1409 name = repr(w)
1410 return name
1411 #@+node:ekr.20111027083744.16532: *4* qt_gui.enableSignalDebugging
1412 if isQt5:
1413 # pylint: disable=no-name-in-module
1414 # To do: https://doc.qt.io/qt-5/qsignalspy.html
1415 from PyQt5.QtTest import QSignalSpy
1416 assert QSignalSpy
1417 elif isQt6:
1418 # pylint: disable=c-extension-no-member,no-name-in-module
1419 import PyQt6.QtTest as QtTest
1420 # mypy complains about assigning to a type.
1421 QSignalSpy = QtTest.QSignalSpy # type:ignore
1422 assert QSignalSpy
1423 else:
1424 # enableSignalDebugging(emitCall=foo) and spy your signals until you're sick to your stomach.
1425 _oldConnect = QtCore.QObject.connect
1426 _oldDisconnect = QtCore.QObject.disconnect
1427 _oldEmit = QtCore.QObject.emit
1429 def _wrapConnect(self, callableObject):
1430 """Returns a wrapped call to the old version of QtCore.QObject.connect"""
1432 @staticmethod # type:ignore
1433 def call(*args):
1434 callableObject(*args)
1435 self._oldConnect(*args)
1437 return call
1439 def _wrapDisconnect(self, callableObject):
1440 """Returns a wrapped call to the old version of QtCore.QObject.disconnect"""
1442 @staticmethod # type:ignore
1443 def call(*args):
1444 callableObject(*args)
1445 self._oldDisconnect(*args)
1447 return call
1449 def enableSignalDebugging(self, **kwargs):
1450 """Call this to enable Qt Signal debugging. This will trap all
1451 connect, and disconnect calls."""
1452 f = lambda * args: None
1453 connectCall = kwargs.get('connectCall', f)
1454 disconnectCall = kwargs.get('disconnectCall', f)
1455 emitCall = kwargs.get('emitCall', f)
1457 def printIt(msg):
1459 def call(*args):
1460 print(msg, args)
1462 return call
1464 # Monkey-patch.
1466 QtCore.QObject.connect = self._wrapConnect(connectCall)
1467 QtCore.QObject.disconnect = self._wrapDisconnect(disconnectCall)
1469 def new_emit(self, *args):
1470 emitCall(self, *args)
1471 self._oldEmit(self, *args)
1473 QtCore.QObject.emit = new_emit
1474 #@+node:ekr.20190819091957.1: *3* qt_gui.Widgets...
1475 #@+node:ekr.20190819094016.1: *4* qt_gui.createButton
1476 def createButton(self, parent, name, label):
1477 w = QtWidgets.QPushButton(parent)
1478 w.setObjectName(name)
1479 w.setText(label)
1480 return w
1481 #@+node:ekr.20190819091122.1: *4* qt_gui.createFrame
1482 def createFrame(self, parent, name,
1483 hPolicy=None, vPolicy=None,
1484 lineWidth=1,
1485 shadow=None,
1486 shape=None,
1487 ):
1488 """Create a Qt Frame."""
1489 if shadow is None:
1490 shadow = Shadow.Plain
1491 if shape is None:
1492 shape = Shape.NoFrame
1493 #
1494 w = QtWidgets.QFrame(parent)
1495 self.setSizePolicy(w, kind1=hPolicy, kind2=vPolicy)
1496 w.setFrameShape(shape)
1497 w.setFrameShadow(shadow)
1498 w.setLineWidth(lineWidth)
1499 w.setObjectName(name)
1500 return w
1501 #@+node:ekr.20190819091851.1: *4* qt_gui.createGrid
1502 def createGrid(self, parent, name, margin=0, spacing=0):
1503 w = QtWidgets.QGridLayout(parent)
1504 w.setContentsMargins(QtCore.QMargins(margin, margin, margin, margin))
1505 w.setSpacing(spacing)
1506 w.setObjectName(name)
1507 return w
1508 #@+node:ekr.20190819093830.1: *4* qt_gui.createHLayout & createVLayout
1509 def createHLayout(self, parent, name, margin=0, spacing=0):
1510 hLayout = QtWidgets.QHBoxLayout(parent)
1511 hLayout.setObjectName(name)
1512 hLayout.setSpacing(spacing)
1513 hLayout.setContentsMargins(QtCore.QMargins(0, 0, 0, 0))
1514 return hLayout
1516 def createVLayout(self, parent, name, margin=0, spacing=0):
1517 vLayout = QtWidgets.QVBoxLayout(parent)
1518 vLayout.setObjectName(name)
1519 vLayout.setSpacing(spacing)
1520 vLayout.setContentsMargins(QtCore.QMargins(0, 0, 0, 0))
1521 return vLayout
1522 #@+node:ekr.20190819094302.1: *4* qt_gui.createLabel
1523 def createLabel(self, parent, name, label):
1524 w = QtWidgets.QLabel(parent)
1525 w.setObjectName(name)
1526 w.setText(label)
1527 return w
1528 #@+node:ekr.20190819092523.1: *4* qt_gui.createTabWidget
1529 def createTabWidget(self, parent, name, hPolicy=None, vPolicy=None):
1530 w = QtWidgets.QTabWidget(parent)
1531 self.setSizePolicy(w, kind1=hPolicy, kind2=vPolicy)
1532 w.setObjectName(name)
1533 return w
1534 #@+node:ekr.20190819091214.1: *4* qt_gui.setSizePolicy
1535 def setSizePolicy(self, widget, kind1=None, kind2=None):
1536 if kind1 is None:
1537 kind1 = Policy.Ignored
1538 if kind2 is None:
1539 kind2 = Policy.Ignored
1540 sizePolicy = QtWidgets.QSizePolicy(kind1, kind2)
1541 sizePolicy.setHorizontalStretch(0)
1542 sizePolicy.setVerticalStretch(0)
1543 sizePolicy.setHeightForWidth(widget.sizePolicy().hasHeightForWidth())
1544 widget.setSizePolicy(sizePolicy)
1545 #@-others
1546#@+node:tbrown.20150724090431.1: ** class StyleClassManager
1547class StyleClassManager:
1548 style_sclass_property = 'style_class' # name of QObject property for styling
1549 #@+others
1550 #@+node:tbrown.20150724090431.2: *3* update_view
1551 def update_view(self, w):
1552 """update_view - Make Qt apply w's style
1554 :param QWidgit w: widgit to style
1555 """
1557 w.setStyleSheet("/* */") # forces visual update
1558 #@+node:tbrown.20150724090431.3: *3* add_sclass
1559 def add_sclass(self, w, prop):
1560 """Add style class or list of classes prop to QWidget w"""
1561 if not prop:
1562 return
1563 props = self.sclasses(w)
1564 if isinstance(prop, str):
1565 props.append(prop)
1566 else:
1567 props.extend(prop)
1569 self.set_sclasses(w, props)
1570 #@+node:tbrown.20150724090431.4: *3* clear_sclasses
1571 def clear_sclasses(self, w):
1572 """Remove all style classes from QWidget w"""
1573 w.setProperty(self.style_sclass_property, '')
1574 #@+node:tbrown.20150724090431.5: *3* has_sclass
1575 def has_sclass(self, w, prop):
1576 """Check for style class or list of classes prop on QWidget w"""
1577 if not prop:
1578 return None
1579 props = self.sclasses(w)
1580 if isinstance(prop, str):
1581 ans = [prop in props]
1582 else:
1583 ans = [i in props for i in prop]
1584 return all(ans)
1585 #@+node:tbrown.20150724090431.6: *3* remove_sclass
1586 def remove_sclass(self, w, prop):
1587 """Remove style class or list of classes prop from QWidget w"""
1588 if not prop:
1589 return
1590 props = self.sclasses(w)
1591 if isinstance(prop, str):
1592 props = [i for i in props if i != prop]
1593 else:
1594 props = [i for i in props if i not in prop]
1596 self.set_sclasses(w, props)
1597 #@+node:tbrown.20150724090431.7: *3* sclass_tests
1598 def sclass_tests(self):
1599 """Test style class property manipulation functions"""
1601 # pylint: disable=len-as-condition
1604 class Test_W:
1605 """simple standin for QWidget for testing"""
1607 def __init__(self):
1608 self.x = ''
1610 def property(self, name, default=None):
1611 return self.x or default
1613 def setProperty(self, name, value):
1614 self.x = value
1616 w = Test_W()
1618 assert not self.has_sclass(w, 'nonesuch')
1619 assert not self.has_sclass(w, ['nonesuch'])
1620 assert not self.has_sclass(w, ['nonesuch', 'either'])
1621 assert len(self.sclasses(w)) == 0
1623 self.add_sclass(w, 'test')
1625 assert not self.has_sclass(w, 'nonesuch')
1626 assert self.has_sclass(w, 'test')
1627 assert self.has_sclass(w, ['test'])
1628 assert not self.has_sclass(w, ['test', 'either'])
1629 assert len(self.sclasses(w)) == 1
1631 self.add_sclass(w, 'test')
1632 assert len(self.sclasses(w)) == 1
1633 self.add_sclass(w, ['test', 'test', 'other'])
1634 assert len(self.sclasses(w)) == 2
1635 assert self.has_sclass(w, 'test')
1636 assert self.has_sclass(w, 'other')
1637 assert self.has_sclass(w, ['test', 'other', 'test'])
1638 assert not self.has_sclass(w, ['test', 'other', 'nonesuch'])
1640 self.remove_sclass(w, ['other', 'nothere'])
1641 assert self.has_sclass(w, 'test')
1642 assert not self.has_sclass(w, 'other')
1643 assert len(self.sclasses(w)) == 1
1645 self.toggle_sclass(w, 'third')
1646 assert len(self.sclasses(w)) == 2
1647 assert self.has_sclass(w, ['test', 'third'])
1648 self.toggle_sclass(w, 'third')
1649 assert len(self.sclasses(w)) == 1
1650 assert not self.has_sclass(w, ['test', 'third'])
1652 self.clear_sclasses(w)
1653 assert len(self.sclasses(w)) == 0
1654 assert not self.has_sclass(w, 'test')
1655 #@+node:tbrown.20150724090431.8: *3* sclasses
1656 def sclasses(self, w):
1657 """return list of style classes for QWidget w"""
1658 return str(w.property(self.style_sclass_property) or '').split()
1659 #@+node:tbrown.20150724090431.9: *3* set_sclasses
1660 def set_sclasses(self, w, classes):
1661 """Set style classes for QWidget w to list in classes"""
1662 w.setProperty(self.style_sclass_property, f" {' '.join(set(classes))} ")
1663 #@+node:tbrown.20150724090431.10: *3* toggle_sclass
1664 def toggle_sclass(self, w, prop):
1665 """Toggle style class or list of classes prop on QWidget w"""
1666 if not prop:
1667 return
1668 props = set(self.sclasses(w))
1670 if isinstance(prop, str):
1671 prop = set([prop])
1672 else:
1673 prop = set(prop)
1675 current = props.intersection(prop)
1676 props.update(prop)
1677 props = props.difference(current)
1679 self.set_sclasses(w, props)
1680 #@-others
1681#@+node:ekr.20140913054442.17860: ** class StyleSheetManager
1682class StyleSheetManager:
1683 """A class to manage (reload) Qt style sheets."""
1684 #@+others
1685 #@+node:ekr.20180316091829.1: *3* ssm.Birth
1686 #@+node:ekr.20140912110338.19371: *4* ssm.__init__
1687 def __init__(self, c, safe=False):
1688 """Ctor the ReloadStyle class."""
1689 self.c = c
1690 self.color_db = leoColor.leo_color_database
1691 self.safe = safe
1692 self.settings_p = g.findNodeAnywhere(c, '@settings')
1693 self.mng = StyleClassManager()
1694 # This warning is inappropriate in some contexts.
1695 # if not self.settings_p:
1696 # g.es("No '@settings' node found in outline. See:")
1697 # g.es("http://leoeditor.com/tutorial-basics.html#configuring-leo")
1698 #@+node:ekr.20170222051716.1: *4* ssm.reload_settings
1699 def reload_settings(self, sheet=None):
1700 """
1701 Recompute and apply the stylesheet.
1702 Called automatically by the reload-settings commands.
1703 """
1704 if not sheet:
1705 sheet = self.get_style_sheet_from_settings()
1706 if sheet:
1707 w = self.get_master_widget()
1708 w.setStyleSheet(sheet)
1709 # self.c.redraw()
1711 reloadSettings = reload_settings
1712 #@+node:ekr.20180316091500.1: *3* ssm.Paths...
1713 #@+node:ekr.20180316065346.1: *4* ssm.compute_icon_directories
1714 def compute_icon_directories(self):
1715 """
1716 Return a list of *existing* directories that could contain theme-related icons.
1717 """
1718 exists = g.os_path_exists
1719 home = g.app.homeDir
1720 join = g.os_path_finalize_join
1721 leo = join(g.app.loadDir, '..')
1722 table = [
1723 join(home, '.leo', 'Icons'),
1724 # join(home, '.leo'),
1725 join(leo, 'themes', 'Icons'),
1726 join(leo, 'themes'),
1727 join(leo, 'Icons'),
1728 ]
1729 table = [z for z in table if exists(z)]
1730 for directory in self.compute_theme_directories():
1731 if directory not in table:
1732 table.append(directory)
1733 directory2 = join(directory, 'Icons')
1734 if directory2 not in table:
1735 table.append(directory2)
1736 return [g.os_path_normslashes(z) for z in table if g.os_path_exists(z)]
1737 #@+node:ekr.20180315101238.1: *4* ssm.compute_theme_directories
1738 def compute_theme_directories(self):
1739 """
1740 Return a list of *existing* directories that could contain theme .leo files.
1741 """
1742 lm = g.app.loadManager
1743 table = lm.computeThemeDirectories()[:]
1744 directory = g.os_path_normslashes(g.app.theme_directory)
1745 if directory and directory not in table:
1746 table.insert(0, directory)
1747 return table
1748 # All entries are known to exist and have normalized slashes.
1749 #@+node:ekr.20170307083738.1: *4* ssm.find_icon_path
1750 def find_icon_path(self, setting):
1751 """Return the path to the open/close indicator icon."""
1752 c = self.c
1753 s = c.config.getString(setting)
1754 if not s:
1755 return None # Not an error.
1756 for directory in self.compute_icon_directories():
1757 path = g.os_path_finalize_join(directory, s)
1758 if g.os_path_exists(path):
1759 return path
1760 g.es_print('no icon found for:', setting)
1761 return None
1762 #@+node:ekr.20180316091920.1: *3* ssm.Settings
1763 #@+node:ekr.20110605121601.18176: *4* ssm.default_style_sheet
1764 def default_style_sheet(self):
1765 """Return a reasonable default style sheet."""
1766 # Valid color names: http://www.w3.org/TR/SVG/types.html#ColorKeywords
1767 g.trace('===== using default style sheet =====')
1768 return '''\
1770 /* A QWidget: supports only background attributes.*/
1771 QSplitter::handle {
1772 background-color: #CAE1FF; /* Leo's traditional lightSteelBlue1 */
1773 }
1774 QSplitter {
1775 border-color: white;
1776 background-color: white;
1777 border-width: 3px;
1778 border-style: solid;
1779 }
1780 QTreeWidget {
1781 background-color: #ffffec; /* Leo's traditional tree color */
1782 }
1783 QsciScintilla {
1784 background-color: pink;
1785 }
1786 '''
1787 #@+node:ekr.20140916170549.19551: *4* ssm.get_data
1788 def get_data(self, setting):
1789 """Return the value of the @data node for the setting."""
1790 c = self.c
1791 return c.config.getData(setting, strip_comments=False, strip_data=False) or []
1792 #@+node:ekr.20140916170549.19552: *4* ssm.get_style_sheet_from_settings
1793 def get_style_sheet_from_settings(self):
1794 """
1795 Scan for themes or @data qt-gui-plugin-style-sheet nodes.
1796 Return the text of the relevant node.
1797 """
1798 aList1 = self.get_data('qt-gui-plugin-style-sheet')
1799 aList2 = self.get_data('qt-gui-user-style-sheet')
1800 if aList2:
1801 aList1.extend(aList2)
1802 sheet = ''.join(aList1)
1803 sheet = self.expand_css_constants(sheet)
1804 return sheet
1805 #@+node:ekr.20140915194122.19476: *4* ssm.print_style_sheet
1806 def print_style_sheet(self):
1807 """Show the top-level style sheet."""
1808 w = self.get_master_widget()
1809 sheet = w.styleSheet()
1810 print(f"style sheet for: {w}...\n\n{sheet}")
1811 #@+node:ekr.20110605121601.18175: *4* ssm.set_style_sheets
1812 def set_style_sheets(self, all=True, top=None, w=None):
1813 """Set the master style sheet for all widgets using config settings."""
1814 if g.app.loadedThemes:
1815 return
1816 c = self.c
1817 if top is None:
1818 top = c.frame.top
1819 selectors = ['qt-gui-plugin-style-sheet']
1820 if all:
1821 selectors.append('qt-gui-user-style-sheet')
1822 sheets = []
1823 for name in selectors:
1824 # don't strip `#selector_name { ...` type syntax
1825 sheet = c.config.getData(name, strip_comments=False)
1826 if sheet:
1827 if '\n' in sheet[0]:
1828 sheet = ''.join(sheet)
1829 else:
1830 sheet = '\n'.join(sheet)
1831 if sheet and sheet.strip():
1832 line0 = f"\n/* ===== From {name} ===== */\n\n"
1833 sheet = line0 + sheet
1834 sheets.append(sheet)
1835 if sheets:
1836 sheet = "\n".join(sheets)
1837 # store *before* expanding, so later expansions get new zoom
1838 c.active_stylesheet = sheet
1839 sheet = self.expand_css_constants(sheet)
1840 if not sheet:
1841 sheet = self.default_style_sheet()
1842 if w is None:
1843 w = self.get_master_widget(top)
1844 w.setStyleSheet(sheet)
1845 #@+node:ekr.20180316091943.1: *3* ssm.Stylesheet
1846 # Computations on stylesheets themeselves.
1847 #@+node:ekr.20140915062551.19510: *4* ssm.expand_css_constants & helpers
1848 css_warning_given = False
1850 def expand_css_constants(self, sheet, settingsDict=None):
1851 """Expand @ settings into their corresponding constants."""
1852 c = self.c
1853 trace = 'zoom' in g.app.debug
1854 # Warn once if the stylesheet uses old style style-sheet comment
1855 if settingsDict is None:
1856 settingsDict = c.config.settingsDict # A TypedDict.
1857 if 0:
1858 g.trace('===== settingsDict...')
1859 for key in settingsDict.keys():
1860 print(f"{key:40}: {settingsDict.get(key)}")
1861 constants, deltas = self.adjust_sizes(settingsDict)
1862 if trace:
1863 print('')
1864 g.trace(f"zoom constants: {constants}")
1865 g.printObj(deltas, tag='zoom deltas') # A defaultdict
1866 sheet = self.replace_indicator_constants(sheet)
1867 for pass_n in range(10):
1868 to_do = self.find_constants_referenced(sheet)
1869 if not to_do:
1870 break
1871 old_sheet = sheet
1872 sheet = self.do_pass(constants, deltas, settingsDict, sheet, to_do)
1873 if sheet == old_sheet:
1874 break
1875 else:
1876 g.trace('Too many iterations')
1877 if to_do:
1878 g.trace('Unresolved @constants')
1879 g.printObj(to_do)
1880 sheet = self.resolve_urls(sheet)
1881 sheet = sheet.replace('\\\n', '') # join lines ending in \
1882 return sheet
1883 #@+node:ekr.20150617085045.1: *5* ssm.adjust_sizes
1884 def adjust_sizes(self, settingsDict):
1885 """Adjust constants to reflect c._style_deltas."""
1886 c = self.c
1887 constants = {}
1888 deltas = c._style_deltas
1889 for delta in c._style_deltas:
1890 # adjust @font-size-body by font_size_delta
1891 # easily extendable to @font-size-*
1892 val = c.config.getString(delta)
1893 passes = 10
1894 while passes and val and val.startswith('@'):
1895 key = g.app.config.canonicalizeSettingName(val[1:])
1896 val = settingsDict.get(key)
1897 if val:
1898 val = val.val
1899 passes -= 1
1900 if deltas[delta] and (val is not None):
1901 size = ''.join(i for i in val if i in '01234567890.')
1902 units = ''.join(i for i in val if i not in '01234567890.')
1903 size = max(1, float(size) + deltas[delta])
1904 constants['@' + delta] = f"{size}{units}"
1905 return constants, deltas
1906 #@+node:ekr.20180316093159.1: *5* ssm.do_pass
1907 def do_pass(self, constants, deltas, settingsDict, sheet, to_do):
1909 to_do.sort(key=len, reverse=True)
1910 for const in to_do:
1911 value = None
1912 if const in constants:
1913 # This constant is about to be removed.
1914 value = constants[const]
1915 if const[1:] not in deltas and not self.css_warning_given:
1916 self.css_warning_given = True
1917 g.es_print(f"'{const}' from style-sheet comment definition, ")
1918 g.es_print("please use regular @string / @color type @settings.")
1919 else:
1920 # lowercase, without '@','-','_', etc.
1921 key = g.app.config.canonicalizeSettingName(const[1:])
1922 value = settingsDict.get(key)
1923 if value is not None:
1924 # New in Leo 5.5: Do NOT add comments here.
1925 # They RUIN style sheets if they appear in a nested comment!
1926 # value = '%s /* %s */' % (value.val, key)
1927 value = value.val
1928 elif key in self.color_db:
1929 # New in Leo 5.5: Do NOT add comments here.
1930 # They RUIN style sheets if they appear in a nested comment!
1931 value = self.color_db.get(key)
1932 if value:
1933 # Partial fix for #780.
1934 try:
1935 # Don't replace shorter constants occuring in larger.
1936 sheet = re.sub(
1937 const + "(?![-A-Za-z0-9_])",
1938 value,
1939 sheet,
1940 )
1941 except Exception:
1942 g.es_print('Exception handling style sheet')
1943 g.es_print(sheet)
1944 g.es_exception()
1945 else:
1946 pass
1947 # tricky, might be an undefined identifier, but it might
1948 # also be a @foo in a /* comment */, where it's harmless.
1949 # So rely on whoever calls .setStyleSheet() to do the right thing.
1950 return sheet
1951 #@+node:tbrown.20131120093739.27085: *5* ssm.find_constants_referenced
1952 def find_constants_referenced(self, text):
1953 """find_constants - Return a list of constants referenced in the supplied text,
1954 constants match::
1956 @[A-Za-z_][-A-Za-z0-9_]*
1957 i.e. @foo_1-5
1959 :Parameters:
1960 - `text`: text to search
1961 """
1962 aList = sorted(set(re.findall(r"@[A-Za-z_][-A-Za-z0-9_]*", text)))
1963 # Exempt references to Leo constructs.
1964 for s in ('@button', '@constants', '@data', '@language'):
1965 if s in aList:
1966 aList.remove(s)
1967 return aList
1968 #@+node:ekr.20150617090104.1: *5* ssm.replace_indicator_constants
1969 def replace_indicator_constants(self, sheet):
1970 """
1971 In the stylesheet, replace (if they exist)::
1973 image: @tree-image-closed
1974 image: @tree-image-open
1976 by::
1978 url(path/closed.png)
1979 url(path/open.png)
1981 path can be relative to ~ or to leo/Icons.
1983 Assuming that ~/myIcons/closed.png exists, either of these will work::
1985 @string tree-image-closed = nodes-dark/triangles/closed.png
1986 @string tree-image-closed = myIcons/closed.png
1988 Return the updated stylesheet.
1989 """
1990 close_path = self.find_icon_path('tree-image-closed')
1991 open_path = self.find_icon_path('tree-image-open')
1992 # Make all substitutions in the stylesheet.
1993 table = (
1994 (open_path, re.compile(r'\bimage:\s*@tree-image-open', re.IGNORECASE)),
1995 (close_path, re.compile(r'\bimage:\s*@tree-image-closed', re.IGNORECASE)),
1996 # (open_path, re.compile(r'\bimage:\s*at-tree-image-open', re.IGNORECASE)),
1997 # (close_path, re.compile(r'\bimage:\s*at-tree-image-closed', re.IGNORECASE)),
1998 )
1999 for path, pattern in table:
2000 for mo in pattern.finditer(sheet):
2001 old = mo.group(0)
2002 new = f"image: url({path})"
2003 sheet = sheet.replace(old, new)
2004 return sheet
2005 #@+node:ekr.20180320054305.1: *5* ssm.resolve_urls
2006 def resolve_urls(self, sheet):
2007 """Resolve all relative url's so they use absolute paths."""
2008 trace = 'themes' in g.app.debug
2009 pattern = re.compile(r'url\((.*)\)')
2010 join = g.os_path_finalize_join
2011 directories = self.compute_icon_directories()
2012 paths_traced = False
2013 if trace:
2014 paths_traced = True
2015 g.trace('Search paths...')
2016 g.printObj(directories)
2017 # Pass 1: Find all replacements without changing the sheet.
2018 replacements = []
2019 for mo in pattern.finditer(sheet):
2020 url = mo.group(1)
2021 if url.startswith(':/'):
2022 url = url[2:]
2023 elif g.os_path_isabs(url):
2024 if trace:
2025 g.trace('ABS:', url)
2026 continue
2027 for directory in directories:
2028 path = join(directory, url)
2029 if g.os_path_exists(path):
2030 if trace:
2031 g.trace(f"{url:35} ==> {path}")
2032 old = mo.group(0)
2033 new = f"url({path})"
2034 replacements.append((old, new),)
2035 break
2036 else:
2037 g.trace(f"{url:35} ==> NOT FOUND")
2038 if not paths_traced:
2039 paths_traced = True
2040 g.trace('Search paths...')
2041 g.printObj(directories)
2042 # Pass 2: Now we can safely make the replacements.
2043 for old, new in reversed(replacements):
2044 sheet = sheet.replace(old, new)
2045 return sheet
2046 #@+node:ekr.20140912110338.19372: *4* ssm.munge
2047 def munge(self, stylesheet):
2048 """
2049 Return the stylesheet without extra whitespace.
2051 To avoid false mismatches, this should approximate what Qt does.
2052 To avoid false matches, this should not munge too much.
2053 """
2054 s = ''.join([s.lstrip().replace(' ', ' ').replace(' \n', '\n')
2055 for s in g.splitLines(stylesheet)])
2056 return s.rstrip()
2057 # Don't care about ending newline.
2058 #@+node:tom.20220310224019.1: *4* ssm.rescale_sizes
2059 def rescale_sizes(self, sheet, factor):
2060 """
2061 #@+<< docstring >>
2062 #@+node:tom.20220310224918.1: *5* << docstring >>
2063 Rescale all pt or px sizes in CSS stylesheet or Leo theme.
2065 Sheets can have either "logical" or "actual" sizes.
2066 "Logical" sizes are ones like "@font-family-base = 10.6pt".
2067 "Actual" sizes are the ones in the "qt-gui-plugin-style-sheet" subtree.
2068 They look like "font-size: 11pt;"
2070 In Qt stylesheets, only sizes in pt or px are honored, so
2071 those are the only ones changed by this method. Padding,
2072 margin, etc. sizes will be changed as well as font sizes.
2074 Sizes do not have to be integers (e.g., 10.5 pt). Qt honors
2075 non-integer point sizes, with at least a 0.5pt granularity.
2076 It's currently unknown how non-integer px sizes are handled.
2078 No size will be scaled down to less than 1.
2080 ARGUMENTS
2081 sheet -- a CSS stylesheet or a Leo theme as a string. The Leo
2082 theme file should be read as a string before being passed
2083 to this method. If a Leo theme, the output will be a
2084 well-formed Leo outline.
2086 scale -- the scaling factor as a float or integer. For example,
2087 a scale of 1.5 will increase all the sizes by a factor of 1.5.
2089 RETURNS
2090 the modified sheet as a string.
2092 #@-<< docstring >>
2093 """
2094 RE = r'([=:])[ ]*([.1234567890]+)(p[tx])'
2096 def scale(matchobj, scale=factor):
2097 prefix = matchobj.group(1)
2098 sz = matchobj.group(2)
2099 units = matchobj.group(3)
2100 try:
2101 scaled = max(float(sz) * factor, 1)
2102 except Exception as e:
2103 g.es('ssm.rescale_fonts:', e)
2104 return None
2105 return f'{prefix} {scaled:.1f}{units}'
2107 newsheet = re.sub(RE, scale, sheet)
2108 return newsheet
2109 #@+node:ekr.20180316092116.1: *3* ssm.Widgets
2110 #@+node:ekr.20140913054442.19390: *4* ssm.get_master_widget
2111 def get_master_widget(self, top=None):
2112 """
2113 Carefully return the master widget.
2114 c.frame.top is a DynamicWindow.
2115 """
2116 if top is None:
2117 top = self.c.frame.top
2118 master = top.leo_master or top
2119 return master
2120 #@+node:ekr.20140913054442.19391: *4* ssm.set selected_style_sheet
2121 def set_selected_style_sheet(self):
2122 """For manual testing: update the stylesheet using c.p.b."""
2123 if not g.unitTesting:
2124 c = self.c
2125 sheet = c.p.b
2126 sheet = self.expand_css_constants(sheet)
2127 w = self.get_master_widget(c.frame.top)
2128 w.setStyleSheet(sheet)
2129 #@-others
2130#@-others
2131#@@language python
2132#@@tabwidth -4
2133#@@pagewidth 70
2134#@-leo