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

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