Coverage for C:\Repos\leo-editor\leo\plugins\qt_text.py: 23%
1211 statements
« prev ^ index » next coverage.py v6.4, created at 2022-05-24 10:21 -0500
« prev ^ index » next coverage.py v6.4, created at 2022-05-24 10:21 -0500
1# -*- coding: utf-8 -*-
2#@+leo-ver=5-thin
3#@+node:ekr.20140831085423.18598: * @file ../plugins/qt_text.py
4#@@first
5"""Text classes for the Qt version of Leo"""
6#@+<< imports qt_text.py >>
7#@+node:ekr.20220416085845.1: ** << imports qt_text.py >>
8import time
9assert time
10from typing import Any, Callable, Dict, List, Tuple
11from typing import TYPE_CHECKING
12from leo.core import leoGlobals as g
13from leo.core.leoQt import isQt6, QtCore, QtGui, Qsci, QtWidgets
14from leo.core.leoQt import ContextMenuPolicy, Key, KeyboardModifier, Modifier
15from leo.core.leoQt import MouseButton, MoveMode, MoveOperation
16from leo.core.leoQt import Shadow, Shape, SliderAction, SolidLine, WindowType, WrapMode
18#@-<< imports qt_text.py >>
19#@+<< type aliases qt_text.py >>
20#@+node:ekr.20220416085945.1: ** << type aliases qt_text.py >>
21if TYPE_CHECKING: # Always False at runtime.
22 from leo.core.leoCommands import Commands as Cmdr
23 # from leo.core.leoNodes import Position as Pos
24else:
25 Cmdr = Any
27Event = Any
28Index = Any # For now, really Union[int, str], but that creates type-checking problems.
29Widget = Any
30Wrapper = Any
31#@-<< type aliases qt_text.py >>
33FullWidthSelection = 0x06000 # works for both Qt5 and Qt6
34QColor = QtGui.QColor
35QFontMetrics = QtGui.QFontMetrics
37#@+others
38#@+node:ekr.20191001084541.1: ** zoom commands
39#@+node:tbrown.20130411145310.18857: *3* @g.command("zoom-in")
40@g.command("zoom-in")
41def zoom_in(event: Event=None, delta: int=1) -> None:
42 """increase body font size by one
44 @font-size-body must be present in the stylesheet
45 """
46 zoom_helper(event, delta=1)
47#@+node:ekr.20191001084646.1: *3* @g.command("zoom-out")
48@g.command("zoom-out")
49def zoom_out(event: Event=None) -> None:
50 """decrease body font size by one
52 @font-size-body must be present in the stylesheet
53 """
54 # zoom_in(event=event, delta=-1)
55 zoom_helper(event=event, delta=-1)
56#@+node:ekr.20191001084612.1: *3* zoom_helper
57def zoom_helper(event: Event, delta: int) -> None:
58 """
59 Common helper for zoom commands.
60 """
61 c = event.get('c')
62 if not c:
63 return
64 if not c.config.getBool('allow-text-zoom', default=True):
65 if 'zoom' in g.app.debug:
66 g.trace('text zoom disabled')
67 return
68 wrapper = c.frame.body.wrapper
69 #
70 # For performance, don't call c.styleSheetManager.reload_style_sheets().
71 # Apply to body widget directly
72 c._style_deltas['font-size-body'] += delta
73 ssm = c.styleSheetManager
74 sheet = ssm.expand_css_constants(c.active_stylesheet)
75 wrapper.widget.setStyleSheet(sheet)
76 #
77 # #490: Honor language-specific settings.
78 colorizer = getattr(c.frame.body, 'colorizer', None)
79 if not colorizer:
80 return
81 c.zoom_delta = delta
82 colorizer.configure_fonts()
83 wrapper.setAllText(wrapper.getAllText()) # Recolor everything.
84#@+node:tom.20210904233317.1: ** Show Hilite Settings command
85# Add item to known "help-for" commands
86hilite_doc = r'''
87Changing The Current Line Highlighting Color
88--------------------------------------------
90The highlight color will be computed based on the Leo theme in effect, unless the `line-highlight-color` setting is set to a non-blank string.
92The setting will always override the color computation. If the setting is changed, after the settings are reloaded the new color will take effect the next time the cursor is moved.
94Settings for Current Line Highlighting
95---------------------------------------
96\@bool highlight-body-line -- if True, highlight current line.
98\@string line-highlight-color -- override highlight color with css value.
99Valid values are standard css color names like `lightgrey`, and css rgb values like `#1234ad`.
100'''
102@g.command('help-for-highlight-current-line')
103def helpForLineHighlight(self: Any, event: Event=None) -> None:
104 """Displays Settings used by current line highlighter."""
105 self.c.putHelpFor(hilite_doc)
107#@+node:tom.20220424002954.1: ** Show Right Margin Settings command
108# Add item to known "help-for" commands
109rmargin_doc = r'''
110Right Margin Guidelines
111-------------------------
113A vertical guideline may optionally shown at the right margin of the
114body editor. The guideline will be shown at
1161. The column value of an @pagewidth directive in effect; or
1172. The column value given by the setting ``@int rguide-col = <col>``; or
1183. Column 80.
120The guideline will be shown if the setting ``@bool show-rmargin-guide``
121is ``True``.
123The color of the guideline is set based on the current text color.
124'''
126@g.command('help-for-right-margin-guide')
127def helpForRMarginGuides(self, event=None):
128 """Displays settings used by right margin guide lines."""
129 self.c.putHelpFor(rmargin_doc)
131#@+node:ekr.20140901062324.18719: ** class QTextMixin
132class QTextMixin:
133 """A minimal mixin class for QTextEditWrapper and QScintillaWrapper classes."""
134 #@+others
135 #@+node:ekr.20140901062324.18732: *3* qtm.ctor & helper
136 def __init__(self, c: Cmdr=None) -> None:
137 """Ctor for QTextMixin class"""
138 self.c = c
139 self.changingText = False # A lockout for onTextChanged.
140 self.enabled = True
141 # A flag for k.masterKeyHandler and isTextWrapper.
142 self.supportsHighLevelInterface = True
143 self.tags: Dict[str, str] = {}
144 self.permanent = True # False if selecting the minibuffer will make the widget go away.
145 self.useScintilla = False # This is used!
146 self.virtualInsertPoint = None
147 if c:
148 self.injectIvars(c)
149 #@+node:ekr.20140901062324.18721: *4* qtm.injectIvars
150 def injectIvars(self, c: Cmdr) -> Widget:
151 """Inject standard leo ivars into the QTextEdit or QsciScintilla widget."""
152 w = self
153 w.leo_p = c.p.copy() if c.p else None
154 w.leo_active = True
155 # New in Leo 4.4.4 final: inject the scrollbar items into the text widget.
156 w.leo_bodyBar = None
157 w.leo_bodyXBar = None
158 w.leo_chapter = None
159 w.leo_frame = None
160 w.leo_name = '1'
161 w.leo_label = None
162 return w
163 #@+node:ekr.20140901062324.18825: *3* qtm.getName
164 def getName(self) -> str:
165 return self.name # Essential.
166 #@+node:ekr.20140901122110.18733: *3* qtm.Event handlers
167 # These are independent of the kind of Qt widget.
168 #@+node:ekr.20140901062324.18716: *4* qtm.onCursorPositionChanged
169 def onCursorPositionChanged(self, event: Event=None) -> None:
171 c = self.c
172 name = c.widget_name(self)
173 # Apparently, this does not cause problems
174 # because it generates no events in the body pane.
175 if not name.startswith('body'):
176 return
177 if hasattr(c.frame, 'statusLine'):
178 c.frame.statusLine.update()
179 #@+node:ekr.20140901062324.18714: *4* qtm.onTextChanged
180 def onTextChanged(self) -> None:
181 """
182 Update Leo after the body has been changed.
184 tree.tree_select_lockout is True during the entire selection process.
185 """
186 # Important: usually w.changingText is True.
187 # This method very seldom does anything.
188 w = self
189 c, p = self.c, self.c.p
190 tree = c.frame.tree
191 if w.changingText:
192 return
193 if tree.tree_select_lockout:
194 g.trace('*** LOCKOUT', g.callers())
195 return
196 if not p:
197 return
198 newInsert = w.getInsertPoint()
199 newSel = w.getSelectionRange()
200 newText = w.getAllText() # Converts to unicode.
201 # Get the previous values from the VNode.
202 oldText = p.b
203 if oldText == newText:
204 # This can happen as the result of undo.
205 # g.error('*** unexpected non-change')
206 return
207 i, j = p.v.selectionStart, p.v.selectionLength
208 oldSel = (i, i + j)
209 c.undoer.doTyping(p, 'Typing', oldText, newText,
210 oldSel=oldSel, oldYview=None, newInsert=newInsert, newSel=newSel)
211 #@+node:ekr.20140901122110.18734: *3* qtm.Generic high-level interface
212 # These call only wrapper methods.
213 #@+node:ekr.20140902181058.18645: *4* qtm.Enable/disable
214 def disable(self) -> None:
215 self.enabled = False
217 def enable(self, enabled: bool=True) -> None:
218 self.enabled = enabled
219 #@+node:ekr.20140902181058.18644: *4* qtm.Clipboard
220 def clipboard_append(self, s: str) -> None:
221 s1 = g.app.gui.getTextFromClipboard()
222 g.app.gui.replaceClipboardWith(s1 + s)
224 def clipboard_clear(self) -> None:
225 g.app.gui.replaceClipboardWith('')
226 #@+node:ekr.20140901062324.18698: *4* qtm.setFocus
227 def setFocus(self) -> None:
228 """QTextMixin"""
229 if 'focus' in g.app.debug:
230 print('BaseQTextWrapper.setFocus', self.widget)
231 # Call the base class
232 assert isinstance(self.widget, (
233 QtWidgets.QTextBrowser,
234 QtWidgets.QLineEdit,
235 QtWidgets.QTextEdit,
236 Qsci and Qsci.QsciScintilla,
237 )), self.widget
238 QtWidgets.QTextBrowser.setFocus(self.widget)
239 #@+node:ekr.20140901062324.18717: *4* qtm.Generic text
240 #@+node:ekr.20140901062324.18703: *5* qtm.appendText
241 def appendText(self, s: str) -> None:
242 """QTextMixin"""
243 s2 = self.getAllText()
244 self.setAllText(s2 + s)
245 self.setInsertPoint(len(s2))
246 #@+node:ekr.20140901141402.18706: *5* qtm.delete
247 def delete(self, i: Index, j: Index=None) -> None:
248 """QTextMixin"""
249 i = self.toPythonIndex(i)
250 if j is None:
251 j = i + 1
252 j = self.toPythonIndex(j)
253 # This allows subclasses to use this base class method.
254 if i > j:
255 i, j = j, i
256 s = self.getAllText()
257 self.setAllText(s[:i] + s[j:])
258 # Bug fix: Significant in external tests.
259 self.setSelectionRange(i, i, insert=i)
260 #@+node:ekr.20140901062324.18827: *5* qtm.deleteTextSelection
261 def deleteTextSelection(self) -> None:
262 """QTextMixin"""
263 i, j = self.getSelectionRange()
264 self.delete(i, j)
265 #@+node:ekr.20110605121601.18102: *5* qtm.get
266 def get(self, i: int, j: int=None) -> str:
267 """QTextMixin"""
268 # 2012/04/12: fix the following two bugs by using the vanilla code:
269 # https://bugs.launchpad.net/leo-editor/+bug/979142
270 # https://bugs.launchpad.net/leo-editor/+bug/971166
271 s = self.getAllText()
272 i = self.toPythonIndex(i)
273 j = self.toPythonIndex(j)
274 return s[i:j]
275 #@+node:ekr.20140901062324.18704: *5* qtm.getLastPosition & getLength
276 def getLastPosition(self, s: str=None) -> int:
277 """QTextMixin"""
278 return len(self.getAllText()) if s is None else len(s)
280 def getLength(self, s: str=None) -> int:
281 """QTextMixin"""
282 return len(self.getAllText()) if s is None else len(s)
283 #@+node:ekr.20140901062324.18705: *5* qtm.getSelectedText
284 def getSelectedText(self) -> str:
285 """QTextMixin"""
286 i, j = self.getSelectionRange()
287 if i == j:
288 return ''
289 s = self.getAllText()
290 return s[i:j]
291 #@+node:ekr.20140901141402.18702: *5* qtm.insert
292 def insert(self, i: Index, s: str) -> int:
293 """QTextMixin"""
294 s2 = self.getAllText()
295 i = self.toPythonIndex(i)
296 self.setAllText(s2[:i] + s + s2[i:])
297 self.setInsertPoint(i + len(s))
298 return i
299 #@+node:ekr.20140902084950.18634: *5* qtm.seeInsertPoint
300 def seeInsertPoint(self) -> None:
301 """Ensure the insert point is visible."""
302 # getInsertPoint defined in client classes.
303 self.see(self.getInsertPoint())
304 #@+node:ekr.20140902135648.18668: *5* qtm.selectAllText
305 def selectAllText(self, s: str=None) -> None:
306 """QTextMixin."""
307 self.setSelectionRange(0, self.getLength(s))
308 #@+node:ekr.20140901141402.18710: *5* qtm.toPythonIndex
309 def toPythonIndex(self, index: Index, s: str=None) -> int:
310 """QTextMixin"""
311 if s is None:
312 s = self.getAllText()
313 i = g.toPythonIndex(s, index)
314 return i
315 #@+node:ekr.20140901141402.18704: *5* qtm.toPythonIndexRowCol
316 def toPythonIndexRowCol(self, index: int) -> Tuple[int, int, int]:
317 """QTextMixin"""
318 s = self.getAllText()
319 i = self.toPythonIndex(index)
320 row, col = g.convertPythonIndexToRowCol(s, i)
321 return i, row, col
322 #@+node:ekr.20140901062324.18729: *4* qtm.rememberSelectionAndScroll
323 def rememberSelectionAndScroll(self) -> None:
325 w = self
326 v = self.c.p.v # Always accurate.
327 v.insertSpot = w.getInsertPoint()
328 i, j = w.getSelectionRange()
329 if i > j:
330 i, j = j, i
331 assert i <= j
332 v.selectionStart = i
333 v.selectionLength = j - i
334 v.scrollBarSpot = w.getYScrollPosition()
335 #@-others
336#@+node:ekr.20110605121601.18058: ** class QLineEditWrapper(QTextMixin)
337class QLineEditWrapper(QTextMixin):
338 """
339 A class to wrap QLineEdit widgets.
341 The QHeadlineWrapper class is a subclass that merely
342 redefines the do-nothing check method here.
343 """
344 #@+others
345 #@+node:ekr.20110605121601.18060: *3* qlew.Birth
346 def __init__(self, widget: Widget, name: str, c: Cmdr=None) -> None:
347 """Ctor for QLineEditWrapper class."""
348 super().__init__(c)
349 self.widget = widget
350 self.name = name
351 self.baseClassName = 'QLineEditWrapper'
353 def __repr__(self) -> str:
354 return f"<QLineEditWrapper: widget: {self.widget}"
356 __str__ = __repr__
357 #@+node:ekr.20140901191541.18599: *3* qlew.check
358 def check(self) -> bool:
359 """
360 QLineEditWrapper.
361 """
362 return True
363 #@+node:ekr.20110605121601.18118: *3* qlew.Widget-specific overrides
364 #@+node:ekr.20110605121601.18120: *4* qlew.getAllText
365 def getAllText(self) -> str:
366 """QHeadlineWrapper."""
367 if self.check():
368 w = self.widget
369 return w.text()
370 return ''
371 #@+node:ekr.20110605121601.18121: *4* qlew.getInsertPoint
372 def getInsertPoint(self) -> int:
373 """QHeadlineWrapper."""
374 if self.check():
375 return self.widget.cursorPosition()
376 return 0
377 #@+node:ekr.20110605121601.18122: *4* qlew.getSelectionRange
378 def getSelectionRange(self, sort: bool=True) -> Tuple[int, int]:
379 """QHeadlineWrapper."""
380 w = self.widget
381 if self.check():
382 if w.hasSelectedText():
383 i = w.selectionStart()
384 s = w.selectedText()
385 j = i + len(s)
386 else:
387 i = j = w.cursorPosition()
388 return i, j
389 return 0, 0
390 #@+node:ekr.20210104122029.1: *4* qlew.getYScrollPosition
391 def getYScrollPosition(self) -> int:
392 return 0 # #1801.
393 #@+node:ekr.20110605121601.18123: *4* qlew.hasSelection
394 def hasSelection(self) -> bool:
395 """QHeadlineWrapper."""
396 if self.check():
397 return self.widget.hasSelectedText()
398 return False
399 #@+node:ekr.20110605121601.18124: *4* qlew.see & seeInsertPoint
400 def see(self, i: int) -> None:
401 """QHeadlineWrapper."""
403 pass
405 def seeInsertPoint(self) -> None:
406 """QHeadlineWrapper."""
407 pass
408 #@+node:ekr.20110605121601.18125: *4* qlew.setAllText
409 def setAllText(self, s: str) -> None:
410 """Set all text of a Qt headline widget."""
411 if self.check():
412 w = self.widget
413 w.setText(s)
414 #@+node:ekr.20110605121601.18128: *4* qlew.setFocus
415 def setFocus(self) -> None:
416 """QHeadlineWrapper."""
417 if self.check():
418 g.app.gui.set_focus(self.c, self.widget)
419 #@+node:ekr.20110605121601.18129: *4* qlew.setInsertPoint
420 def setInsertPoint(self, i: int, s: str=None) -> None:
421 """QHeadlineWrapper."""
422 if not self.check():
423 return
424 w = self.widget
425 if s is None:
426 s = w.text()
427 i = self.toPythonIndex(i)
428 i = max(0, min(i, len(s)))
429 w.setCursorPosition(i)
430 #@+node:ekr.20110605121601.18130: *4* qlew.setSelectionRange
431 def setSelectionRange(self,
432 i: Index, j: Index, insert: Index=None, s: str=None,
433 ) -> None:
434 """QHeadlineWrapper."""
435 if not self.check():
436 return
437 w = self.widget
438 if i > j:
439 i, j = j, i
440 if s is None:
441 s = w.text()
442 n = len(s)
443 i = self.toPythonIndex(i)
444 j = self.toPythonIndex(j)
445 i = max(0, min(i, n))
446 j = max(0, min(j, n))
447 if insert is None:
448 insert = j
449 else:
450 insert = self.toPythonIndex(insert)
451 insert = max(0, min(insert, n))
452 if i == j:
453 w.setCursorPosition(i)
454 else:
455 length = j - i
456 # Set selection is a QLineEditMethod
457 if insert < j:
458 w.setSelection(j, -length)
459 else:
460 w.setSelection(i, length)
461 # setSelectionRangeHelper = setSelectionRange
462 #@-others
463#@+node:ekr.20150403094619.1: ** class LeoLineTextWidget(QFrame)
464class LeoLineTextWidget(QtWidgets.QFrame): # type:ignore
465 """
466 A QFrame supporting gutter line numbers.
468 This class *has* a QTextEdit.
469 """
470 #@+others
471 #@+node:ekr.20150403094706.9: *3* __init__(LeoLineTextWidget)
472 def __init__(self, c: Cmdr, e: Any, *args: Any) -> None:
473 """Ctor for LineTextWidget."""
474 super().__init__(*args)
475 self.c = c
476 Raised = Shadow.Raised if isQt6 else self.StyledPanel
477 NoFrame = Shape.NoFrame if isQt6 else self.NoFrame
478 self.setFrameStyle(Raised)
479 self.edit = e # A QTextEdit
480 e.setFrameStyle(NoFrame)
481 # e.setAcceptRichText(False)
482 self.number_bar = NumberBar(c, e)
483 hbox = QtWidgets.QHBoxLayout(self)
484 hbox.setSpacing(0)
485 hbox.setContentsMargins(0, 0, 0, 0)
486 hbox.addWidget(self.number_bar)
487 hbox.addWidget(e)
488 e.installEventFilter(self)
489 e.viewport().installEventFilter(self)
490 #@+node:ekr.20150403094706.10: *3* eventFilter
491 def eventFilter(self, obj: Any, event: Event) -> Any:
492 """
493 Update the line numbers for all events on the text edit and the viewport.
494 This is easier than connecting all necessary signals.
495 """
496 if obj in (self.edit, self.edit.viewport()):
497 self.number_bar.update()
498 return False
499 return QtWidgets.QFrame.eventFilter(obj, event)
500 #@-others
501#@+node:ekr.20110605121601.18005: ** class LeoQTextBrowser (QtWidgets.QTextBrowser)
502if QtWidgets:
505 class LeoQTextBrowser(QtWidgets.QTextBrowser): # type:ignore
506 """A subclass of QTextBrowser that overrides the mouse event handlers."""
507 #@+others
508 #@+node:ekr.20110605121601.18006: *3* lqtb.ctor
509 def __init__(self, parent: Widget, c: Cmdr, wrapper: Wrapper) -> None:
510 """ctor for LeoQTextBrowser class."""
511 for attr in ('leo_c', 'leo_wrapper',):
512 assert not hasattr(QtWidgets.QTextBrowser, attr), attr
513 self.leo_c = c
514 self.leo_s = '' # The cached text.
515 self.leo_wrapper = wrapper
516 self.htmlFlag = True
517 super().__init__(parent)
518 self.setCursorWidth(c.config.getInt('qt-cursor-width') or 1)
520 # Connect event handlers...
521 if 0: # Not a good idea: it will complicate delayed loading of body text.
522 # #1286
523 self.textChanged.connect(self.onTextChanged)
524 self.cursorPositionChanged.connect(self.highlightCurrentLine)
525 self.textChanged.connect(self.highlightCurrentLine)
526 self.setContextMenuPolicy(ContextMenuPolicy.CustomContextMenu)
527 self.customContextMenuRequested.connect(self.onContextMenu)
528 # This event handler is the easy way to keep track of the vertical scroll position.
529 self.leo_vsb = vsb = self.verticalScrollBar()
530 vsb.valueChanged.connect(self.onSliderChanged)
531 # For QCompleter
532 self.leo_q_completer = None
533 self.leo_options = None
534 self.leo_model = None
536 hl_color_setting = c.config.getString('line-highlight-color') or ''
537 hl_color = QColor(hl_color_setting)
538 self.hiliter_params = {
539 'lastblock': -2, 'last_style_hash': 0,
540 'last_color_setting': hl_color_setting,
541 'last_fg': '', 'last_bg': '',
542 'last_hl_color': hl_color
543 }
544 #@+node:ekr.20110605121601.18007: *3* lqtb. __repr__ & __str__
545 def __repr__(self) -> str:
546 return f"(LeoQTextBrowser) {id(self)}"
548 __str__ = __repr__
549 #@+node:ekr.20110605121601.18008: *3* lqtb.Auto completion
550 #@+node:ekr.20110605121601.18009: *4* class LeoQListWidget(QListWidget)
551 class LeoQListWidget(QtWidgets.QListWidget): # type:ignore
552 #@+others
553 #@+node:ekr.20110605121601.18010: *5* lqlw.ctor
554 def __init__(self, c: Cmdr) -> None:
555 """ctor for LeoQListWidget class"""
556 super().__init__()
557 self.setWindowFlags(WindowType.Popup | self.windowFlags())
558 # Inject the ivars
559 # A LeoQTextBrowser, a subclass of QtWidgets.QTextBrowser.
560 self.leo_w = c.frame.body.wrapper.widget
561 self.leo_c = c
562 # A weird hack.
563 self.leo_geom_set = False # When true, self.geom returns global coords!
564 self.itemClicked.connect(self.select_callback)
565 #@+node:ekr.20110605121601.18011: *5* lqlw.closeEvent
566 def closeEvent(self, event: Event) -> None:
567 """Kill completion and close the window."""
568 self.leo_c.k.autoCompleter.abort()
569 #@+node:ekr.20110605121601.18012: *5* lqlw.end_completer
570 def end_completer(self) -> None:
571 """End completion."""
572 c = self.leo_c
573 c.in_qt_dialog = False
574 # This is important: it clears the autocompletion state.
575 c.k.keyboardQuit()
576 c.bodyWantsFocusNow()
577 try:
578 self.deleteLater()
579 except RuntimeError:
580 # Avoid bug 1338773: Autocompleter error
581 pass
582 #@+node:ekr.20141024170936.7: *5* lqlw.get_selection
583 def get_selection(self) -> str:
584 """Return the presently selected item's text."""
585 return self.currentItem().text()
586 #@+node:ekr.20110605121601.18013: *5* lqlw.keyPressEvent
587 def keyPressEvent(self, event: Event) -> None:
588 """Handle a key event from QListWidget."""
589 c = self.leo_c
590 w = c.frame.body.wrapper
591 key = event.key()
592 if event.modifiers() != Modifier.NoModifier and not event.text():
593 # A modifier key on it's own.
594 pass
595 elif key in (Key.Key_Up, Key.Key_Down):
596 QtWidgets.QListWidget.keyPressEvent(self, event)
597 elif key == Key.Key_Tab:
598 self.tab_callback()
599 elif key in (Key.Key_Enter, Key.Key_Return):
600 self.select_callback()
601 else:
602 # Pass all other keys to the autocompleter via the event filter.
603 w.ev_filter.eventFilter(obj=self, event=event)
604 #@+node:ekr.20110605121601.18014: *5* lqlw.select_callback
605 def select_callback(self) -> None:
606 """
607 Called when user selects an item in the QListWidget.
608 """
609 c = self.leo_c
610 p = c.p
611 w = c.k.autoCompleter.w or c.frame.body.wrapper
612 oldSel = w.getSelectionRange()
613 oldText = w.getAllText()
614 # Replace the tail of the prefix with the completion.
615 completion = self.currentItem().text()
616 prefix = c.k.autoCompleter.get_autocompleter_prefix()
617 parts = prefix.split('.')
618 if len(parts) > 1:
619 tail = parts[-1]
620 else:
621 tail = prefix
622 if tail != completion:
623 j = w.getInsertPoint()
624 i = j - len(tail)
625 w.delete(i, j)
626 w.insert(i, completion)
627 j = i + len(completion)
628 c.setChanged()
629 w.setInsertPoint(j)
630 c.undoer.doTyping(p, 'Typing', oldText,
631 newText=w.getAllText(),
632 newInsert=w.getInsertPoint(),
633 newSel=w.getSelectionRange(),
634 oldSel=oldSel,
635 )
636 self.end_completer()
637 #@+node:tbrown.20111011094944.27031: *5* lqlw.tab_callback
638 def tab_callback(self) -> None:
639 """Called when user hits tab on an item in the QListWidget."""
640 c = self.leo_c
641 w = c.k.autoCompleter.w or c.frame.body.wrapper # 2014/09/19
642 if w is None:
643 return
644 # Replace the tail of the prefix with the completion.
645 prefix = c.k.autoCompleter.get_autocompleter_prefix()
646 parts = prefix.split('.')
647 if len(parts) < 2:
648 return
649 i = j = w.getInsertPoint()
650 s = w.getAllText()
651 while (0 <= i < len(s) and s[i] != '.'):
652 i -= 1
653 i += 1
654 if j > i:
655 w.delete(i, j)
656 w.setInsertPoint(i)
657 c.k.autoCompleter.compute_completion_list()
658 #@+node:ekr.20110605121601.18015: *5* lqlw.set_position
659 def set_position(self, c: Cmdr) -> None:
660 """Set the position of the QListWidget."""
662 def glob(obj: Any, pt: str) -> Any:
663 """Convert pt from obj's local coordinates to global coordinates."""
664 return obj.mapToGlobal(pt)
666 w = self.leo_w
667 vp = self.viewport()
668 r = w.cursorRect()
669 geom = self.geometry() # In viewport coordinates.
670 gr_topLeft = glob(w, r.topLeft())
671 # As a workaround to the Qt setGeometry bug,
672 # The window is destroyed instead of being hidden.
673 if self.leo_geom_set:
674 g.trace('Error: leo_geom_set')
675 return
676 # This code illustrates the Qt bug...
677 # if self.leo_geom_set:
678 # # Unbelievable: geom is now in *global* coords.
679 # gg_topLeft = geom.topLeft()
680 # else:
681 # # Per documentation, geom in local (viewport) coords.
682 # gg_topLeft = glob(vp,geom.topLeft())
683 gg_topLeft = glob(vp, geom.topLeft())
684 delta_x = gr_topLeft.x() - gg_topLeft.x()
685 delta_y = gr_topLeft.y() - gg_topLeft.y()
686 # These offset are reasonable. Perhaps they should depend on font size.
687 x_offset, y_offset = 10, 60
688 # Compute the new geometry, setting the size by hand.
689 geom2_topLeft = QtCore.QPoint(
690 geom.x() + delta_x + x_offset,
691 geom.y() + delta_y + y_offset)
692 geom2_size = QtCore.QSize(400, 100)
693 geom2 = QtCore.QRect(geom2_topLeft, geom2_size)
694 # These tests fail once offsets are added.
695 if x_offset == 0 and y_offset == 0:
696 if self.leo_geom_set:
697 if geom2.topLeft() != glob(w, r.topLeft()):
698 g.trace(
699 f"Error: geom.topLeft: {geom2.topLeft()}, "
700 f"geom2.topLeft: {glob(w, r.topLeft())}")
701 else:
702 if glob(vp, geom2.topLeft()) != glob(w, r.topLeft()):
703 g.trace(
704 f"Error 2: geom.topLeft: {glob(vp, geom2.topLeft())}, "
705 f"geom2.topLeft: {glob(w, r.topLeft())}")
706 self.setGeometry(geom2)
707 self.leo_geom_set = True
708 #@+node:ekr.20110605121601.18016: *5* lqlw.show_completions
709 def show_completions(self, aList: List[str]) -> None:
710 """Set the QListView contents to aList."""
711 self.clear()
712 self.addItems(aList)
713 self.setCurrentRow(0)
714 self.activateWindow()
715 self.setFocus()
716 #@-others
717 #@+node:ekr.20110605121601.18017: *4* lqtb.lqtb.init_completer
718 def init_completer(self, options: List[str]) -> Widget:
719 """Connect a QCompleter."""
720 c = self.leo_c
721 self.leo_qc = qc = self.LeoQListWidget(c)
722 # Move the window near the body pane's cursor.
723 qc.set_position(c)
724 # Show the initial completions.
725 c.in_qt_dialog = True
726 qc.show()
727 qc.activateWindow()
728 c.widgetWantsFocusNow(qc)
729 qc.show_completions(options)
730 return qc
731 #@+node:ekr.20110605121601.18018: *4* lqtb.redirections to LeoQListWidget
732 def end_completer(self) -> None:
733 if hasattr(self, 'leo_qc'):
734 self.leo_qc.end_completer()
735 delattr(self, 'leo_qc')
737 def show_completions(self, aList: List) -> None:
738 if hasattr(self, 'leo_qc'):
739 self.leo_qc.show_completions(aList)
740 #@+node:tom.20210827230127.1: *3* lqtb Highlight Current Line
741 #@+node:tom.20210827225119.3: *4* lqtb.parse_css
742 #@@language python
743 @staticmethod
744 def parse_css(css_string: str, clas: str='') -> Tuple[str, str]:
745 """Extract colors from a css stylesheet string.
747 This is an extremely simple-minded function. It assumes
748 that no quotation marks are being used, and that the
749 first block in braces with the name clas is the controlling
750 css for our widget.
752 Returns a tuple of strings (color, background).
753 """
754 # Get first block with name matching "clas'
755 block = css_string.split(clas, 1)
756 block = block[1].split('{', 1)
757 block = block[1].split('}', 1)
759 # Split into styles separated by ";"
760 styles = block[0].split(';')
762 # Split into fields separated by ":"
763 fields = [style.split(':') for style in styles if style.strip()]
765 # Only get fields whose names are "color" and "background"
766 color = bg = ''
767 for style, val in fields:
768 style = style.strip()
769 if style == 'color':
770 color = val.strip()
771 elif style == 'background':
772 bg = val.strip()
773 if color and bg:
774 break
775 return color, bg
777 #@+node:tom.20210827225119.4: *4* lqtb.assign_bg
778 #@@language python
779 @staticmethod
780 def assign_bg(fg: str) -> Any:
781 """If fg or bg colors are missing, assign
782 reasonable values. Can happen with incorrectly
783 constructed themes, or no-theme color schemes.
785 Intended to be called when bg color is missing.
787 RETURNS
788 a QColor object for the background color
789 """
790 if not fg:
791 fg = 'black' # QTextEdit default
792 bg = 'white' # QTextEdit default
793 if fg == 'black':
794 bg = 'white' # QTextEdit default
795 else:
796 fg_color = QColor(fg)
797 h, s, v, a = fg_color.getHsv()
798 if v < 128: # dark foreground
799 bg = 'white'
800 else:
801 bg = 'black'
802 return QColor(bg)
803 #@+node:tom.20210827225119.5: *4* lqtb.calc_hl
804 #@@language python
805 @staticmethod
806 def calc_hl(palette: QtGui.QPalette) -> QColor: # type:ignore
807 """Return the line highlight color.
809 ARGUMENT
810 palette -- a QPalette object for the body
812 RETURNS
813 a QColor object for the highlight color
814 """
815 fg = palette.text().color()
816 bg = palette.window().color()
817 hsv_fg = fg.getHsv()
818 hsv_bg = bg.getHsv()
819 v_fg = hsv_fg[2]
820 v_bg = hsv_bg[2]
821 is_dark_on_light = v_fg < v_bg
822 if is_dark_on_light:
823 hl = bg.darker(110)
824 else:
825 if v_bg < 20:
826 hl = QColor(bg)
827 hl.setHsv(360, 0, 30, hsv_bg[3])
828 else:
829 hl = bg.lighter(140)
830 return hl
831 #@+node:tom.20210827225119.2: *4* lqtb.highlightCurrentLine
832 #@@language python
833 def highlightCurrentLine(self) -> None:
834 """Highlight cursor line."""
835 c = self.leo_c
836 params = self.hiliter_params
837 editor = c.frame.body.wrapper.widget
839 if not c.config.getBool('highlight-body-line', True):
840 editor.setExtraSelections([])
841 return
843 curs = editor.textCursor()
844 blocknum = curs.blockNumber()
846 # Some cursor movements don't change the line: ignore them
847 # if blocknum == params['lastblock'] and blocknum > 0:
848 # return
850 if blocknum == 0: # invalid position
851 blocknum = 1
852 params['lastblock'] = blocknum
854 hl_color = params['last_hl_color']
856 #@+<< Recalculate Color >>
857 #@+node:tom.20210909124441.1: *5* << Recalculate Color >>
858 config_setting = c.config.getString('line-highlight-color') \
859 or ''
860 config_setting = (config_setting.replace("'", '')
861 .replace('"', '').lower()
862 .replace('none', ''))
864 last_color_setting = params['last_color_setting']
865 config_setting_changed = config_setting != last_color_setting
867 if config_setting:
868 if config_setting_changed:
869 hl_color = QColor(config_setting)
870 params['last_hl_color'] = hl_color
871 params['last_color_setting'] = config_setting
872 else:
873 hl_color = params['last_hl_color']
874 else:
875 # Get current colors from the body editor widget
876 wrapper = c.frame.body.wrapper
877 w = wrapper.widget
878 palette = w.viewport().palette()
880 fg_hex = palette.text().color().rgb()
881 bg_hex = palette.window().color().rgb()
882 fg = f'#{fg_hex:x}'
883 bg = f'#{bg_hex:x}'
885 if (params['last_fg'] != fg or params['last_bg'] != bg):
886 #bg_color = QColor(bg) if bg else self.assign_bg(fg)
887 hl_color = self.calc_hl(palette)
888 params['last_hl_color'] = hl_color
889 params['last_fg'] = fg
890 params['last_bg'] = bg
891 #@-<< Recalculate Color >>
892 #@+<< Apply Highlight >>
893 #@+node:tom.20210909124551.1: *5* << Apply Highlight >>
894 # Based on code from
895 # https://doc.qt.io/qt-5/qtwidgets-widgets-codeeditor-example.html
897 selection = editor.ExtraSelection()
898 selection.format.setBackground(hl_color)
899 selection.format.setProperty(FullWidthSelection, True)
900 selection.cursor = curs
901 selection.cursor.clearSelection()
903 editor.setExtraSelections([selection])
904 #@-<< Apply Highlight >>
905 #@+node:ekr.20141103061944.31: *3* lqtb.get/setXScrollPosition
906 def getXScrollPosition(self) -> int:
907 """Get the horizontal scrollbar position."""
908 w = self
909 sb = w.horizontalScrollBar()
910 pos = sb.sliderPosition()
911 return pos
913 def setXScrollPosition(self, pos: int) -> None:
914 """Set the position of the horizontal scrollbar."""
915 if pos is not None:
916 w = self
917 sb = w.horizontalScrollBar()
918 sb.setSliderPosition(pos)
919 #@+node:ekr.20111002125540.7021: *3* lqtb.get/setYScrollPosition
920 def getYScrollPosition(self) -> int:
921 """Get the vertical scrollbar position."""
922 w = self
923 sb = w.verticalScrollBar()
924 pos = sb.sliderPosition()
925 return pos
927 def setYScrollPosition(self, pos: int) -> None:
928 """Set the position of the vertical scrollbar."""
929 w = self
930 if pos is None:
931 pos = 0
932 sb = w.verticalScrollBar()
933 sb.setSliderPosition(pos)
934 #@+node:ekr.20110605121601.18019: *3* lqtb.leo_dumpButton
935 def leo_dumpButton(self, event: Event, tag: str) -> str:
936 button = event.button()
937 table = (
938 (MouseButton.NoButton, 'no button'),
939 (MouseButton.LeftButton, 'left-button'),
940 (MouseButton.RightButton, 'right-button'),
941 (MouseButton.MiddleButton, 'middle-button'),
942 )
943 for val, s in table:
944 if button == val:
945 kind = s
946 break
947 else:
948 kind = f"unknown: {repr(button)}"
949 return kind
950 #@+node:ekr.20200304130514.1: *3* lqtb.onContextMenu
951 def onContextMenu(self, point: Any) -> None:
952 """LeoQTextBrowser: Callback for customContextMenuRequested events."""
953 # #1286.
954 c, w = self.leo_c, self
955 g.app.gui.onContextMenu(c, w, point)
956 #@+node:ekr.20120925061642.13506: *3* lqtb.onSliderChanged
957 def onSliderChanged(self, arg: int) -> None:
958 """Handle a Qt onSliderChanged event."""
959 c = self.leo_c
960 p = c.p
961 # Careful: changing nodes changes the scrollbars.
962 if hasattr(c.frame.tree, 'tree_select_lockout'):
963 if c.frame.tree.tree_select_lockout:
964 return
965 # Only scrolling in the body pane should set v.scrollBarSpot.
966 if not c.frame.body or self != c.frame.body.wrapper.widget:
967 return
968 if p:
969 p.v.scrollBarSpot = arg
970 #@+node:ekr.20201204172235.1: *3* lqtb.paintEvent
971 leo_cursor_width = 0
973 leo_vim_mode = None
975 def paintEvent(self, event: Event) -> None:
976 """
977 LeoQTextBrowser.paintEvent.
979 New in Leo 6.4: Draw a box around the cursor in command mode.
980 This is as close as possible to vim's look.
981 New in Leo 6.6.2: Draw right margin guideline.
982 """
983 c, vc, w = self.leo_c, self.leo_c.vimCommands, self
984 #
985 # First, call the base class paintEvent.
986 QtWidgets.QTextBrowser.paintEvent(self, event)
988 def set_cursor_width(width: int) -> None:
989 """Set the cursor width, but only if necessary."""
990 if self.leo_cursor_width != width:
991 self.leo_cursor_width = width
992 w.setCursorWidth(width)
994 if w == c.frame.body.widget and \
995 c.config.getBool('show-rmargin-guide'):
996 #@+<< paint margin guides >>
997 #@+node:tom.20220423204906.1: *4* << paint margin guides >>
998 # based on https://stackoverflow.com/questions/30371613/draw-vertical-lines-on-qtextedit-in-pyqt
999 # Honor @pagewidth directive if any
1000 dict_list = g.get_directives_dict_list(c.p)
1001 rcol = (g.scanAtPagewidthDirectives(dict_list)
1002 or c.config.getInt('rguide-col') or 80)
1004 vp = w.viewport()
1005 palette = vp.palette()
1006 font = w.document().defaultFont()
1007 fm = QFontMetrics(font)
1008 rmargin = fm.horizontalAdvance('9' * rcol) + 2
1009 if vp.width() >= rmargin:
1010 painter = QtGui.QPainter(vp)
1011 pen = QtGui.QPen(SolidLine)
1013 # guideline color
1014 fg = palette.text().color()
1015 bg = palette.window().color()
1016 hsv_fg = fg.getHsv()[2]
1017 hsv_bg = bg.getHsv()[2]
1018 is_dark_on_light = hsv_fg < hsv_bg
1019 if is_dark_on_light:
1020 fg = fg.lighter()
1021 else:
1022 fg = fg.darker()
1023 pen.setColor(fg)
1025 pen.setWidth(1)
1026 painter.setPen(pen)
1027 painter.drawLine(rmargin, 0, rmargin, vp.height())
1028 #@-<< paint margin guides >>
1030 #
1031 # Are we in vim mode?
1032 if self.leo_vim_mode is None:
1033 self.leo_vim_mode = c.config.getBool('vim-mode', default=False)
1034 #
1035 # Are we in command mode?
1036 if self.leo_vim_mode:
1037 in_command = vc and vc.state == 'normal' # vim mode.
1038 else:
1039 in_command = c.k.unboundKeyAction == 'command' # vim emulation.
1040 #
1041 # Draw the box only in command mode, when w is the body pane, with focus.
1042 if (
1043 not in_command
1044 or w != c.frame.body.widget
1045 or w != g.app.gui.get_focus()
1046 ):
1047 set_cursor_width(c.config.getInt('qt-cursor-width') or 1)
1048 return
1049 #
1050 # Set the width of the cursor.
1051 font = w.currentFont()
1052 cursor_width = QtGui.QFontMetrics(font).averageCharWidth()
1053 set_cursor_width(cursor_width)
1054 #
1055 # Draw a box around the cursor.
1056 qp = QtGui.QPainter()
1057 qp.begin(self.viewport())
1058 qp.drawRect(w.cursorRect())
1059 qp.end()
1061 #@+node:tbrown.20130411145310.18855: *3* lqtb.wheelEvent
1062 def wheelEvent(self, event: Event) -> None:
1063 """Handle a wheel event."""
1064 if KeyboardModifier.ControlModifier & event.modifiers():
1065 d = {'c': self.leo_c}
1066 try: # Qt5 or later.
1067 point = event.angleDelta()
1068 delta = point.y() or point.x()
1069 except AttributeError:
1070 delta = event.delta() # Qt4.
1071 if delta < 0:
1072 zoom_out(d)
1073 else:
1074 zoom_in(d)
1075 event.accept()
1076 return
1077 QtWidgets.QTextBrowser.wheelEvent(self, event)
1078 #@-others
1079#@+node:ekr.20150403094706.2: ** class NumberBar(QFrame)
1080class NumberBar(QtWidgets.QFrame): # type:ignore
1081 #@+others
1082 #@+node:ekr.20150403094706.3: *3* NumberBar.__init__
1083 def __init__(self, c: Cmdr, e: Any, *args: Any) -> None:
1084 """Ctor for NumberBar class."""
1085 super().__init__(*args)
1086 self.c = c
1087 self.edit = e # A QTextEdit.
1088 self.d = e.document() # A QTextDocument.
1089 self.fm = self.fontMetrics() # A QFontMetrics
1090 self.image = QtGui.QImage(g.app.gui.getImageImage(
1091 g.os_path_finalize_join(g.app.loadDir,
1092 '..', 'Icons', 'Tango', '16x16', 'actions', 'stop.png')))
1093 self.highest_line = 0 # The highest line that is currently visibile.
1094 # Set the name to gutter so that the QFrame#gutter style sheet applies.
1095 self.offsets: List[Tuple[int, Any]] = []
1096 self.setObjectName('gutter')
1097 self.reloadSettings()
1098 #@+node:ekr.20181005093003.1: *3* NumberBar.reloadSettings
1099 def reloadSettings(self) -> None:
1100 c = self.c
1101 c.registerReloadSettings(self)
1102 # Extra width for column.
1103 self.w_adjust = c.config.getInt('gutter-w-adjust') or 12
1104 # The y offset of the first line of the gutter.
1105 self.y_adjust = c.config.getInt('gutter-y-adjust') or 10
1106 #@+node:ekr.20181005085507.1: *3* NumberBar.mousePressEvent
1107 def mousePressEvent(self, event: Event) -> None:
1109 c = self.c
1111 def find_line(y: int) -> int:
1112 n, last_y = 0, 0
1113 for n, y2 in self.offsets:
1114 if last_y <= y < y2:
1115 return n
1116 last_y = y2
1117 return n if self.offsets else 0
1119 xdb = getattr(g.app, 'xdb', None)
1120 if not xdb:
1121 return
1122 path = xdb.canonic(g.fullPath(c, c.p))
1123 if not path:
1124 return
1125 n = find_line(event.y())
1126 if not xdb.checkline(path, n):
1127 g.trace('FAIL checkline', path, n)
1128 return
1129 if xdb.has_breakpoint(path, n):
1130 xdb.qc.put(f"clear {path}:{n}")
1131 else:
1132 xdb.qc.put(f"b {path}:{n}")
1133 #@+node:ekr.20150403094706.5: *3* NumberBar.update
1134 def update(self, *args: Any) -> None:
1135 """
1136 Updates the number bar to display the current set of numbers.
1137 Also, adjusts the width of the number bar if necessary.
1138 """
1139 # w_adjust is used to compensate for the current line being bold.
1140 # Always allocate room for 2 columns
1141 #width = self.fm.width(str(max(1000, self.highest_line))) + self.w_adjust
1142 if isQt6:
1143 width = self.fm.boundingRect(str(max(1000, self.highest_line))).width()
1144 else:
1145 width = self.fm.width(str(max(1000, self.highest_line))) + self.w_adjust
1146 if self.width() != width:
1147 self.setFixedWidth(width)
1148 QtWidgets.QWidget.update(self, *args)
1149 #@+node:ekr.20150403094706.6: *3* NumberBar.paintEvent
1150 def paintEvent(self, event: Event) -> None:
1151 """
1152 Enhance QFrame.paintEvent.
1153 Paint all visible text blocks in the editor's document.
1154 """
1155 e = self.edit
1156 d = self.d
1157 layout = d.documentLayout()
1158 # Compute constants.
1159 current_block = d.findBlock(e.textCursor().position())
1160 scroll_y = e.verticalScrollBar().value()
1161 page_bottom = scroll_y + e.viewport().height()
1162 # Paint each visible block.
1163 painter = QtGui.QPainter(self)
1164 block = d.begin()
1165 n = i = 0
1166 c = self.c
1167 translation = c.user_dict.get('line_number_translation', [])
1168 self.offsets = []
1169 while block.isValid():
1170 i = translation[n] if n < len(translation) else n + 1
1171 n += 1
1172 top_left = layout.blockBoundingRect(block).topLeft()
1173 if top_left.y() > page_bottom:
1174 break # Outside the visible area.
1175 bold = block == current_block
1176 self.paintBlock(bold, i, painter, top_left, scroll_y)
1177 block = block.next()
1178 self.highest_line = i
1179 painter.end()
1180 QtWidgets.QWidget.paintEvent(self, event) # Propagate the event.
1181 #@+node:ekr.20150403094706.7: *3* NumberBar.paintBlock
1182 def paintBlock(self,
1183 bold: bool, n: int, painter: Any, top_left: int, scroll_y: int,
1184 ) -> None:
1185 """Paint n, right justified in the line number field."""
1186 c = self.c
1187 if bold:
1188 self.setBold(painter, True)
1189 s = str(n)
1190 pad = max(4, len(str(self.highest_line))) - len(s)
1191 s = ' ' * pad + s
1192 # x = self.width() - self.fm.width(s) - self.w_adjust
1193 x = 0
1194 y = round(top_left.y()) - scroll_y + self.fm.ascent() + self.y_adjust
1195 self.offsets.append((n, y),)
1196 painter.drawText(x, y, s)
1197 if bold:
1198 self.setBold(painter, False)
1199 xdb = getattr(g.app, 'xdb', None)
1200 if not xdb:
1201 return
1202 if not xdb.has_breakpoints():
1203 return
1204 path = g.fullPath(c, c.p)
1205 if xdb.has_breakpoint(path, n):
1206 target_r = QtCore.QRect(
1207 self.fm.width(s) + 16,
1208 top_left.y() + self.y_adjust - 2,
1209 16.0, 16.0)
1210 if self.image:
1211 source_r = QtCore.QRect(0.0, 0.0, 16.0, 16.0)
1212 painter.drawImage(target_r, self.image, source_r)
1213 else:
1214 painter.drawEllipse(target_r)
1215 #@+node:ekr.20150403094706.8: *3* NumberBar.setBold
1216 def setBold(self, painter: Any, flag: bool) -> None:
1217 """Set or clear bold facing in the painter, depending on flag."""
1218 font = painter.font()
1219 font.setBold(flag)
1220 painter.setFont(font)
1221 #@-others
1222#@+node:ekr.20140901141402.18700: ** class PlainTextWrapper(QTextMixin)
1223class PlainTextWrapper(QTextMixin):
1224 """A Qt class for use by the find code."""
1226 def __init__(self, widget: Widget) -> None:
1227 """Ctor for the PlainTextWrapper class."""
1228 super().__init__()
1229 self.widget = widget
1230#@+node:ekr.20110605121601.18116: ** class QHeadlineWrapper (QLineEditWrapper)
1231class QHeadlineWrapper(QLineEditWrapper):
1232 """
1233 A wrapper class for QLineEdit widgets in QTreeWidget's.
1234 This class just redefines the check method.
1235 """
1236 #@+others
1237 #@+node:ekr.20110605121601.18117: *3* qhw.Birth
1238 def __init__(self, c: Cmdr, item: str, name: str, widget: Widget) -> None:
1239 """The ctor for the QHeadlineWrapper class."""
1240 assert isinstance(widget, QtWidgets.QLineEdit), widget
1241 super().__init__(widget, name, c)
1242 # Set ivars.
1243 self.c = c
1244 self.item = item
1245 self.name = name
1246 self.permanent = False # Warn the minibuffer that we can go away.
1247 self.widget = widget
1248 # Set the signal.
1249 g.app.gui.setFilter(c, self.widget, self, tag=name)
1251 def __repr__(self) -> str:
1252 return f"QHeadlineWrapper: {id(self)}"
1253 #@+node:ekr.20110605121601.18119: *3* qhw.check
1254 def check(self) -> bool:
1255 """Return True if the tree item exists and it's edit widget exists."""
1256 tree = self.c.frame.tree
1257 try:
1258 e = tree.treeWidget.itemWidget(self.item, 0)
1259 except RuntimeError:
1260 return False
1261 valid = tree.isValidItem(self.item)
1262 result = valid and e == self.widget
1263 return result
1264 #@-others
1265#@+node:ekr.20110605121601.18131: ** class QMinibufferWrapper (QLineEditWrapper)
1266class QMinibufferWrapper(QLineEditWrapper):
1268 def __init__(self, c: Cmdr) -> None:
1269 """Ctor for QMinibufferWrapper class."""
1270 self.c = c
1271 w = c.frame.top.lineEdit # QLineEdit
1272 super().__init__(widget=w, name='minibuffer', c=c)
1273 assert self.widget
1274 g.app.gui.setFilter(c, w, self, tag='minibuffer')
1275 # Monkey-patch the event handlers
1276 #@+<< define mouseReleaseEvent >>
1277 #@+node:ekr.20110605121601.18132: *3* << define mouseReleaseEvent >> (QMinibufferWrapper)
1278 def mouseReleaseEvent(event: Event, self: Any=self) -> None:
1279 """Override QLineEdit.mouseReleaseEvent.
1281 Simulate alt-x if we are not in an input state.
1282 """
1283 assert isinstance(self, QMinibufferWrapper), self
1284 assert isinstance(self.widget, QtWidgets.QLineEdit), self.widget
1285 c, k = self.c, self.c.k
1286 if not k.state.kind:
1287 # c.widgetWantsFocusNow(w) # Doesn't work.
1288 event2 = g.app.gui.create_key_event(c, w=c.frame.body.wrapper)
1289 k.fullCommand(event2)
1290 # c.outerUpdate() # Doesn't work.
1292 #@-<< define mouseReleaseEvent >>
1294 w.mouseReleaseEvent = mouseReleaseEvent
1296 def setStyleClass(self, style_class: Any) -> None:
1297 self.widget.setProperty('style_class', style_class)
1298 #
1299 # to get the appearance to change because of a property
1300 # change, unlike a focus or hover change, we need to
1301 # re-apply the stylesheet. But re-applying at the top level
1302 # is too CPU hungry, so apply just to this widget instead.
1303 # It may lag a bit when the style's edited, but the new top
1304 # level sheet will get pushed down quite frequently.
1305 self.widget.setStyleSheet(self.c.frame.top.styleSheet())
1307 def setSelectionRange(self, i: Index, j: Index, insert: Index=None, s: str=None) -> None:
1308 QLineEditWrapper.setSelectionRange(self, i, j, insert, s)
1309 insert = j if insert is None else insert
1310 if self.widget:
1311 self.widget._sel_and_insert = (i, j, insert)
1312#@+node:ekr.20110605121601.18103: ** class QScintillaWrapper(QTextMixin)
1313class QScintillaWrapper(QTextMixin):
1314 """
1315 A wrapper for QsciScintilla supporting the high-level interface.
1317 This widget will likely always be less capable the QTextEditWrapper.
1318 To do:
1319 - Fix all Scintilla unit-test failures.
1320 - Add support for all scintilla lexers.
1321 """
1322 #@+others
1323 #@+node:ekr.20110605121601.18105: *3* qsciw.ctor
1324 def __init__(self, widget: Widget, c: Cmdr, name: str=None) -> None:
1325 """Ctor for the QScintillaWrapper class."""
1326 super().__init__(c)
1327 self.baseClassName = 'QScintillaWrapper'
1328 self.c = c
1329 self.name = name
1330 self.useScintilla = True
1331 self.widget = widget
1332 # Complete the init.
1333 self.set_config()
1334 # Set the signal.
1335 g.app.gui.setFilter(c, widget, self, tag=name)
1336 #@+node:ekr.20110605121601.18106: *3* qsciw.set_config
1337 def set_config(self) -> None:
1338 """Set QScintillaWrapper configuration options."""
1339 c, w = self.c, self.widget
1340 n = c.config.getInt('qt-scintilla-zoom-in') # type:ignore
1341 if n not in (None, 1, 0):
1342 w.zoomIn(n)
1343 w.setUtf8(True) # Important.
1344 if 1:
1345 w.setBraceMatching(2) # Sloppy
1346 else:
1347 w.setBraceMatching(0) # wrapper.flashCharacter creates big problems.
1348 if 0:
1349 w.setMarginWidth(1, 40)
1350 w.setMarginLineNumbers(1, True)
1351 w.setIndentationWidth(4)
1352 w.setIndentationsUseTabs(False)
1353 w.setAutoIndent(True)
1354 #@+node:ekr.20110605121601.18107: *3* qsciw.WidgetAPI
1355 #@+node:ekr.20140901062324.18593: *4* qsciw.delete
1356 def delete(self, i: Index, j: Index=None) -> None:
1357 """Delete s[i:j]"""
1358 w = self.widget
1359 i = self.toPythonIndex(i)
1360 if j is None:
1361 j = i + 1
1362 j = self.toPythonIndex(j)
1363 self.setSelectionRange(i, j)
1364 try:
1365 self.changingText = True # Disable onTextChanged
1366 w.replaceSelectedText('')
1367 finally:
1368 self.changingText = False
1369 #@+node:ekr.20140901062324.18594: *4* qsciw.flashCharacter (disabled)
1370 def flashCharacter(self,
1371 i: int, bg: str='white', fg: str='red', flashes: int=2, delay: int=50,
1372 ) -> None:
1373 """Flash the character at position i."""
1374 if 0: # This causes a lot of problems: Better to use Scintilla matching.
1375 # This causes problems during unit tests:
1376 # The selection point isn't restored in time.
1377 if g.unitTesting:
1378 return
1379 #@+others
1380 #@+node:ekr.20140902084950.18635: *5* after
1381 def after(func: Callable, delay: int=delay) -> None:
1382 """Run func after the given delay."""
1383 QtCore.QTimer.singleShot(delay, func)
1384 #@+node:ekr.20140902084950.18636: *5* addFlashCallback
1385 def addFlashCallback(self=self) -> None:
1386 i = self.flashIndex
1387 w = self.widget
1388 self.setSelectionRange(i, i + 1)
1389 if self.flashBg:
1390 w.setSelectionBackgroundColor(QtGui.QColor(self.flashBg))
1391 if self.flashFg:
1392 w.setSelectionForegroundColor(QtGui.QColor(self.flashFg))
1393 self.flashCount -= 1
1394 after(removeFlashCallback)
1395 #@+node:ekr.20140902084950.18637: *5* removeFlashCallback
1396 def removeFlashCallback(self=self) -> None:
1397 """Remove the extra selections."""
1398 self.setInsertPoint(self.flashIndex)
1399 w = self.widget
1400 if self.flashCount > 0:
1401 after(addFlashCallback)
1402 else:
1403 w.resetSelectionBackgroundColor()
1404 self.setInsertPoint(self.flashIndex1)
1405 w.setFocus()
1406 #@-others
1407 # Numbered color names don't work in Ubuntu 8.10, so...
1408 if bg and bg[-1].isdigit() and bg[0] != '#':
1409 bg = bg[:-1]
1410 if fg and fg[-1].isdigit() and fg[0] != '#':
1411 fg = fg[:-1]
1412 # w = self.widget # A QsciScintilla widget.
1413 self.flashCount = flashes
1414 self.flashIndex1 = self.getInsertPoint()
1415 self.flashIndex = self.toPythonIndex(i)
1416 self.flashBg = None if bg.lower() == 'same' else bg
1417 self.flashFg = None if fg.lower() == 'same' else fg
1418 addFlashCallback()
1419 #@+node:ekr.20140901062324.18595: *4* qsciw.get
1420 def get(self, i: int, j: int=None) -> str:
1421 # Fix the following two bugs by using vanilla code:
1422 # https://bugs.launchpad.net/leo-editor/+bug/979142
1423 # https://bugs.launchpad.net/leo-editor/+bug/971166
1424 s = self.getAllText()
1425 i = self.toPythonIndex(i)
1426 j = self.toPythonIndex(j)
1427 return s[i:j]
1428 #@+node:ekr.20110605121601.18108: *4* qsciw.getAllText
1429 def getAllText(self) -> str:
1430 """Get all text from a QsciScintilla widget."""
1431 w = self.widget
1432 return w.text()
1433 #@+node:ekr.20110605121601.18109: *4* qsciw.getInsertPoint
1434 def getInsertPoint(self) -> int:
1435 """Get the insertion point from a QsciScintilla widget."""
1436 w = self.widget
1437 i = int(w.SendScintilla(w.SCI_GETCURRENTPOS))
1438 return i
1439 #@+node:ekr.20110605121601.18110: *4* qsciw.getSelectionRange
1440 def getSelectionRange(self, sort: bool=True) -> Tuple[int, int]:
1441 """Get the selection range from a QsciScintilla widget."""
1442 w = self.widget
1443 i = int(w.SendScintilla(w.SCI_GETCURRENTPOS))
1444 j = int(w.SendScintilla(w.SCI_GETANCHOR))
1445 if sort and i > j:
1446 i, j = j, i
1447 return i, j
1448 #@+node:ekr.20140901062324.18599: *4* qsciw.getX/YScrollPosition (to do)
1449 def getXScrollPosition(self) -> int:
1450 # w = self.widget
1451 return 0 # Not ready yet.
1453 def getYScrollPosition(self) -> int:
1454 # w = self.widget
1455 return 0 # Not ready yet.
1456 #@+node:ekr.20110605121601.18111: *4* qsciw.hasSelection
1457 def hasSelection(self) -> bool:
1458 """Return True if a QsciScintilla widget has a selection range."""
1459 return self.widget.hasSelectedText()
1460 #@+node:ekr.20140901062324.18601: *4* qsciw.insert
1461 def insert(self, i: Index, s: str) -> int:
1462 """Insert s at position i."""
1463 w = self.widget
1464 i = self.toPythonIndex(i)
1465 w.SendScintilla(w.SCI_SETSEL, i, i)
1466 w.SendScintilla(w.SCI_ADDTEXT, len(s), g.toEncodedString(s))
1467 i += len(s)
1468 w.SendScintilla(w.SCI_SETSEL, i, i)
1469 return i
1470 #@+node:ekr.20140901062324.18603: *4* qsciw.linesPerPage
1471 def linesPerPage(self) -> int:
1472 """Return the number of lines presently visible."""
1473 # Not used in Leo's core. Not tested.
1474 w = self.widget
1475 return int(w.SendScintilla(w.SCI_LINESONSCREEN))
1476 #@+node:ekr.20140901062324.18604: *4* qsciw.scrollDelegate (maybe)
1477 if 0: # Not yet.
1479 def scrollDelegate(self, kind: str) -> None:
1480 """
1481 Scroll a QTextEdit up or down one page.
1482 direction is in ('down-line','down-page','up-line','up-page')
1483 """
1484 c = self.c
1485 w = self.widget
1486 vScroll = w.verticalScrollBar()
1487 h = w.size().height()
1488 lineSpacing = w.fontMetrics().lineSpacing()
1489 n = h / lineSpacing
1490 n = max(2, n - 3)
1491 if kind == 'down-half-page':
1492 delta = n / 2
1493 elif kind == 'down-line':
1494 delta = 1
1495 elif kind == 'down-page':
1496 delta = n
1497 elif kind == 'up-half-page':
1498 delta = -n / 2
1499 elif kind == 'up-line':
1500 delta = -1
1501 elif kind == 'up-page':
1502 delta = -n
1503 else:
1504 delta = 0
1505 g.trace('bad kind:', kind)
1506 val = vScroll.value()
1507 vScroll.setValue(val + (delta * lineSpacing))
1508 c.bodyWantsFocus()
1509 #@+node:ekr.20110605121601.18112: *4* qsciw.see
1510 def see(self, i: int) -> None:
1511 """Ensure insert point i is visible in a QsciScintilla widget."""
1512 # Ok for now. Using SCI_SETYCARETPOLICY might be better.
1513 w = self.widget
1514 s = self.getAllText()
1515 i = self.toPythonIndex(i)
1516 row, col = g.convertPythonIndexToRowCol(s, i)
1517 w.ensureLineVisible(row)
1518 #@+node:ekr.20110605121601.18113: *4* qsciw.setAllText
1519 def setAllText(self, s: str) -> None:
1520 """Set the text of a QScintilla widget."""
1521 w = self.widget
1522 assert isinstance(w, Qsci.QsciScintilla), w
1523 w.setText(s)
1524 # w.update()
1525 #@+node:ekr.20110605121601.18114: *4* qsciw.setInsertPoint
1526 def setInsertPoint(self, i: int, s: str=None) -> None:
1527 """Set the insertion point in a QsciScintilla widget."""
1528 w = self.widget
1529 i = self.toPythonIndex(i)
1530 # w.SendScintilla(w.SCI_SETCURRENTPOS,i)
1531 # w.SendScintilla(w.SCI_SETANCHOR,i)
1532 w.SendScintilla(w.SCI_SETSEL, i, i)
1533 #@+node:ekr.20110605121601.18115: *4* qsciw.setSelectionRange
1534 def setSelectionRange(self, i: Index, j: int, insert: Index=None, s: str=None) -> None:
1535 """Set the selection range in a QsciScintilla widget."""
1536 w = self.widget
1537 i = self.toPythonIndex(i)
1538 j = self.toPythonIndex(j)
1539 insert = j if insert is None else self.toPythonIndex(insert)
1540 if insert >= i:
1541 w.SendScintilla(w.SCI_SETSEL, i, j)
1542 else:
1543 w.SendScintilla(w.SCI_SETSEL, j, i)
1544 #@+node:ekr.20140901062324.18609: *4* qsciw.setX/YScrollPosition (to do)
1545 def setXScrollPosition(self, pos: int) -> None:
1546 """Set the position of the horizontal scrollbar."""
1548 def setYScrollPosition(self, pos: int) -> None:
1549 """Set the position of the vertical scrollbar."""
1550 #@-others
1551#@+node:ekr.20110605121601.18071: ** class QTextEditWrapper(QTextMixin)
1552class QTextEditWrapper(QTextMixin):
1553 """A wrapper for a QTextEdit/QTextBrowser supporting the high-level interface."""
1554 #@+others
1555 #@+node:ekr.20110605121601.18073: *3* qtew.ctor & helpers
1556 def __init__(self, widget: Widget, name: str, c: Cmdr=None) -> None:
1557 """Ctor for QTextEditWrapper class. widget is a QTextEdit/QTextBrowser."""
1558 super().__init__(c)
1559 # Make sure all ivars are set.
1560 self.baseClassName = 'QTextEditWrapper'
1561 self.c = c
1562 self.name = name
1563 self.widget = widget
1564 self.useScintilla = False
1565 # Complete the init.
1566 if c and widget:
1567 self.widget.setUndoRedoEnabled(False)
1568 self.set_config()
1569 self.set_signals()
1571 #@+node:ekr.20110605121601.18076: *4* qtew.set_config
1572 def set_config(self) -> None:
1573 """Set configuration options for QTextEdit."""
1574 w = self.widget
1575 w.setWordWrapMode(WrapMode.NoWrap)
1576 # tab stop in pixels - no config for this (yet)
1577 if isQt6:
1578 w.setTabStopDistance(24)
1579 else:
1580 w.setTabStopWidth(24)
1581 #@+node:ekr.20140901062324.18566: *4* qtew.set_signals (should be distributed?)
1582 def set_signals(self) -> None:
1583 """Set up signals."""
1584 c, name = self.c, self.name
1585 if name in ('body', 'rendering-pane-wrapper') or name.startswith('head'):
1586 # Hook up qt events.
1587 g.app.gui.setFilter(c, self.widget, self, tag=name)
1588 if name == 'body':
1589 w = self.widget
1590 w.textChanged.connect(self.onTextChanged)
1591 w.cursorPositionChanged.connect(self.onCursorPositionChanged)
1592 if name in ('body', 'log'):
1593 # Monkey patch the event handler.
1594 #@+others
1595 #@+node:ekr.20140901062324.18565: *5* mouseReleaseEvent (monkey-patch) QTextEditWrapper
1596 def mouseReleaseEvent(event: Event, self: Any=self) -> None:
1597 """
1598 Monkey patch for self.widget (QTextEditWrapper) mouseReleaseEvent.
1599 """
1600 assert isinstance(self, QTextEditWrapper), self
1601 assert isinstance(self.widget, QtWidgets.QTextEdit), self.widget
1602 # Call the base class.
1603 QtWidgets.QTextEdit.mouseReleaseEvent(self.widget, event)
1604 c = self.c
1605 setattr(event, 'c', c)
1606 # Open the url on a control-click.
1607 if KeyboardModifier.ControlModifier & event.modifiers():
1608 g.openUrlOnClick(event)
1609 else:
1610 if name == 'body':
1611 c.p.v.insertSpot = c.frame.body.wrapper.getInsertPoint()
1612 g.doHook("bodyclick2", c=c, p=c.p, v=c.p)
1613 # Do *not* change the focus! This would rip focus away from tab panes.
1614 c.k.keyboardQuit(setFocus=False)
1615 #@-others
1616 self.widget.mouseReleaseEvent = mouseReleaseEvent
1617 #@+node:ekr.20200312052821.1: *3* qtew.repr
1618 def __repr__(self) -> str:
1619 # Add a leading space to align with StringTextWrapper.
1620 return f" <QTextEditWrapper: {id(self)} {self.name}>"
1622 __str__ = __repr__
1623 #@+node:ekr.20110605121601.18078: *3* qtew.High-level interface
1624 # These are all widget-dependent
1625 #@+node:ekr.20110605121601.18079: *4* qtew.delete (avoid call to setAllText)
1626 def delete(self, i: Index, j: Index=None) -> None:
1627 """QTextEditWrapper."""
1628 w = self.widget
1629 i = self.toPythonIndex(i)
1630 if j is None:
1631 j = i + 1
1632 j = self.toPythonIndex(j)
1633 if i > j:
1634 i, j = j, i
1635 sb = w.verticalScrollBar()
1636 pos = sb.sliderPosition()
1637 cursor = w.textCursor()
1638 try:
1639 self.changingText = True # Disable onTextChanged
1640 old_i, old_j = self.getSelectionRange()
1641 if i == old_i and j == old_j:
1642 # Work around an apparent bug in cursor.movePosition.
1643 cursor.removeSelectedText()
1644 elif i == j:
1645 pass
1646 else:
1647 cursor.setPosition(i)
1648 moveCount = abs(j - i)
1649 cursor.movePosition(MoveOperation.Right, MoveMode.KeepAnchor, moveCount)
1650 w.setTextCursor(cursor) # Bug fix: 2010/01/27
1651 cursor.removeSelectedText()
1652 finally:
1653 self.changingText = False
1654 sb.setSliderPosition(pos)
1655 #@+node:ekr.20110605121601.18080: *4* qtew.flashCharacter
1656 def flashCharacter(self,
1657 i: int, bg: str='white', fg: str='red', flashes: int=3, delay: int=75,
1658 ) -> None:
1659 """QTextEditWrapper."""
1660 # numbered color names don't work in Ubuntu 8.10, so...
1661 if bg[-1].isdigit() and bg[0] != '#':
1662 bg = bg[:-1]
1663 if fg[-1].isdigit() and fg[0] != '#':
1664 fg = fg[:-1]
1665 # This might causes problems during unit tests.
1666 # The selection point isn't restored in time.
1667 if g.unitTesting:
1668 return
1669 w = self.widget # A QTextEdit.
1670 # Remember highlighted line:
1671 last_selections = w.extraSelections()
1673 def after(func: Callable) -> None:
1674 QtCore.QTimer.singleShot(delay, func)
1676 def addFlashCallback(self: Any=self, w: str=w) -> None:
1677 i = self.flashIndex
1678 cursor = w.textCursor() # Must be the widget's cursor.
1679 cursor.setPosition(i)
1680 cursor.movePosition(MoveOperation.Right, MoveMode.KeepAnchor, 1)
1681 extra = w.ExtraSelection()
1682 extra.cursor = cursor
1683 if self.flashBg:
1684 extra.format.setBackground(QtGui.QColor(self.flashBg))
1685 if self.flashFg:
1686 extra.format.setForeground(QtGui.QColor(self.flashFg))
1687 self.extraSelList = last_selections[:]
1688 self.extraSelList.append(extra) # must be last
1689 w.setExtraSelections(self.extraSelList)
1690 self.flashCount -= 1
1691 after(removeFlashCallback)
1693 def removeFlashCallback(self: Any=self, w: Widget=w) -> None:
1694 w.setExtraSelections(last_selections)
1695 if self.flashCount > 0:
1696 after(addFlashCallback)
1697 else:
1698 w.setFocus()
1700 self.flashCount = flashes
1701 self.flashIndex = i
1702 self.flashBg = None if bg.lower() == 'same' else bg
1703 self.flashFg = None if fg.lower() == 'same' else fg
1704 addFlashCallback()
1706 #@+node:ekr.20110605121601.18081: *4* qtew.getAllText
1707 def getAllText(self) -> str:
1708 """QTextEditWrapper."""
1709 w = self.widget
1710 return w.toPlainText()
1711 #@+node:ekr.20110605121601.18082: *4* qtew.getInsertPoint
1712 def getInsertPoint(self) -> int:
1713 """QTextEditWrapper."""
1714 return self.widget.textCursor().position()
1715 #@+node:ekr.20110605121601.18083: *4* qtew.getSelectionRange
1716 def getSelectionRange(self, sort: bool=True) -> Tuple[int, int]:
1717 """QTextEditWrapper."""
1718 w = self.widget
1719 tc = w.textCursor()
1720 i, j = tc.selectionStart(), tc.selectionEnd()
1721 return i, j
1722 #@+node:ekr.20110605121601.18084: *4* qtew.getX/YScrollPosition
1723 # **Important**: There is a Qt bug here: the scrollbar position
1724 # is valid only if cursor is visible. Otherwise the *reported*
1725 # scrollbar position will be such that the cursor *is* visible.
1727 def getXScrollPosition(self) -> int:
1728 """QTextEditWrapper: Get the horizontal scrollbar position."""
1729 w = self.widget
1730 sb = w.horizontalScrollBar()
1731 pos = sb.sliderPosition()
1732 return pos
1734 def getYScrollPosition(self) -> int:
1735 """QTextEditWrapper: Get the vertical scrollbar position."""
1736 w = self.widget
1737 sb = w.verticalScrollBar()
1738 pos = sb.sliderPosition()
1739 return pos
1740 #@+node:ekr.20110605121601.18085: *4* qtew.hasSelection
1741 def hasSelection(self) -> bool:
1742 """QTextEditWrapper."""
1743 return self.widget.textCursor().hasSelection()
1744 #@+node:ekr.20110605121601.18089: *4* qtew.insert (avoid call to setAllText)
1745 def insert(self, i: Index, s: str) -> None:
1746 """QTextEditWrapper."""
1747 w = self.widget
1748 i = self.toPythonIndex(i)
1749 cursor = w.textCursor()
1750 try:
1751 self.changingText = True # Disable onTextChanged.
1752 cursor.setPosition(i)
1753 cursor.insertText(s)
1754 w.setTextCursor(cursor) # Bug fix: 2010/01/27
1755 finally:
1756 self.changingText = False
1757 #@+node:ekr.20110605121601.18077: *4* qtew.leoMoveCursorHelper & helper
1758 def leoMoveCursorHelper(self, kind: str, extend: bool=False, linesPerPage: int=15) -> None:
1759 """QTextEditWrapper."""
1760 w = self.widget
1761 d = {
1762 'begin-line': MoveOperation.StartOfLine, # Was start-line
1763 'down': MoveOperation.Down,
1764 'end': MoveOperation.End,
1765 'end-line': MoveOperation.EndOfLine, # Not used.
1766 'exchange': True, # Dummy.
1767 'home': MoveOperation.Start,
1768 'left': MoveOperation.Left,
1769 'page-down': MoveOperation.Down,
1770 'page-up': MoveOperation.Up,
1771 'right': MoveOperation.Right,
1772 'up': MoveOperation.Up,
1773 }
1774 kind = kind.lower()
1775 op = d.get(kind)
1776 mode = MoveMode.KeepAnchor if extend else MoveMode.MoveAnchor
1777 if not op:
1778 g.trace(f"can not happen: bad kind: {kind}")
1779 return
1780 if kind in ('page-down', 'page-up'):
1781 self.pageUpDown(op, mode)
1782 elif kind == 'exchange': # exchange-point-and-mark
1783 cursor = w.textCursor()
1784 anchor = cursor.anchor()
1785 pos = cursor.position()
1786 cursor.setPosition(pos, MoveOperation.MoveAnchor)
1787 cursor.setPosition(anchor, MoveOperation.KeepAnchor)
1788 w.setTextCursor(cursor)
1789 else:
1790 if not extend:
1791 # Fix an annoyance. Make sure to clear the selection.
1792 cursor = w.textCursor()
1793 cursor.clearSelection()
1794 w.setTextCursor(cursor)
1795 w.moveCursor(op, mode)
1796 self.seeInsertPoint()
1797 self.rememberSelectionAndScroll()
1798 # #218.
1799 cursor = w.textCursor()
1800 sel = cursor.selection().toPlainText()
1801 if sel and hasattr(g.app.gui, 'setClipboardSelection'):
1802 g.app.gui.setClipboardSelection(sel)
1803 self.c.frame.updateStatusLine()
1804 #@+node:btheado.20120129145543.8180: *5* qtew.pageUpDown
1805 def pageUpDown(self, op: Any, moveMode: Any) -> None:
1806 """
1807 The QTextEdit PageUp/PageDown functionality seems to be "baked-in"
1808 and not externally accessible. Since Leo has its own keyhandling
1809 functionality, this code emulates the QTextEdit paging. This is a
1810 straight port of the C++ code found in the pageUpDown method of
1811 gui/widgets/qtextedit.cpp.
1812 """
1813 control = self.widget
1814 cursor = control.textCursor()
1815 moved = False
1816 lastY = control.cursorRect(cursor).top()
1817 distance = 0
1818 # move using movePosition to keep the cursor's x
1819 while True:
1820 y = control.cursorRect(cursor).top()
1821 distance += abs(y - lastY)
1822 lastY = y
1823 moved = cursor.movePosition(op, moveMode)
1824 if (not moved or distance >= control.height()):
1825 break
1826 sb = control.verticalScrollBar()
1827 if moved:
1828 if op == MoveOperation.Up:
1829 cursor.movePosition(MoveOperation.Down, moveMode)
1830 sb.triggerAction(SliderAction.SliderPageStepSub)
1831 else:
1832 cursor.movePosition(MoveOperation.Up, moveMode)
1833 sb.triggerAction(SliderAction.SliderPageStepAdd)
1834 control.setTextCursor(cursor)
1835 #@+node:ekr.20110605121601.18087: *4* qtew.linesPerPage
1836 def linesPerPage(self) -> float:
1837 """QTextEditWrapper."""
1838 # Not used in Leo's core.
1839 w = self.widget
1840 h = w.size().height()
1841 lineSpacing = w.fontMetrics().lineSpacing()
1842 n = h / lineSpacing
1843 return n
1844 #@+node:ekr.20110605121601.18088: *4* qtew.scrollDelegate
1845 def scrollDelegate(self, kind: str) -> None:
1846 """
1847 Scroll a QTextEdit up or down one page.
1848 direction is in ('down-line','down-page','up-line','up-page')
1849 """
1850 c = self.c
1851 w = self.widget
1852 vScroll = w.verticalScrollBar()
1853 h = w.size().height()
1854 lineSpacing = w.fontMetrics().lineSpacing()
1855 n = h / lineSpacing
1856 n = max(2, n - 3)
1857 if kind == 'down-half-page':
1858 delta = n / 2
1859 elif kind == 'down-line':
1860 delta = 1
1861 elif kind == 'down-page':
1862 delta = n
1863 elif kind == 'up-half-page':
1864 delta = -n / 2
1865 elif kind == 'up-line':
1866 delta = -1
1867 elif kind == 'up-page':
1868 delta = -n
1869 else:
1870 delta = 0
1871 g.trace('bad kind:', kind)
1872 val = vScroll.value()
1873 vScroll.setValue(val + (delta * lineSpacing))
1874 c.bodyWantsFocus()
1875 #@+node:ekr.20110605121601.18090: *4* qtew.see & seeInsertPoint
1876 def see(self, see_i: int) -> None:
1877 """Scroll so that position see_i is visible."""
1878 w = self.widget
1879 tc = w.textCursor()
1880 # Put see_i in range.
1881 s = self.getAllText()
1882 see_i = max(0, min(see_i, len(s)))
1883 # Remember the old cursor
1884 old_cursor = QtGui.QTextCursor(tc)
1885 # Scroll so that see_i is visible.
1886 tc.setPosition(see_i)
1887 w.setTextCursor(tc)
1888 w.ensureCursorVisible()
1889 # Restore the old cursor
1890 w.setTextCursor(old_cursor)
1892 def seeInsertPoint(self) -> None:
1893 """Make sure the insert point is visible."""
1894 self.widget.ensureCursorVisible()
1895 #@+node:ekr.20110605121601.18092: *4* qtew.setAllText
1896 def setAllText(self, s: str) -> None:
1897 """Set the text of body pane."""
1898 w = self.widget
1899 try:
1900 self.changingText = True # Disable onTextChanged.
1901 w.setReadOnly(False)
1902 w.setPlainText(s)
1903 finally:
1904 self.changingText = False
1905 #@+node:ekr.20110605121601.18095: *4* qtew.setInsertPoint
1906 def setInsertPoint(self, i: Index, s: str=None) -> None:
1907 # Fix bug 981849: incorrect body content shown.
1908 # Use the more careful code in setSelectionRange.
1909 self.setSelectionRange(i=i, j=i, insert=i, s=s)
1910 #@+node:ekr.20110605121601.18096: *4* qtew.setSelectionRange
1911 def setSelectionRange(self, i: Index, j: int, insert: Index=None, s: str=None) -> None:
1912 """Set the selection range and the insert point."""
1913 #
1914 # Part 1
1915 w = self.widget
1916 i = self.toPythonIndex(i)
1917 j = self.toPythonIndex(j)
1918 if s is None:
1919 s = self.getAllText()
1920 n = len(s)
1921 i = max(0, min(i, n))
1922 j = max(0, min(j, n))
1923 if insert is None:
1924 ins = max(i, j)
1925 else:
1926 ins = self.toPythonIndex(insert)
1927 ins = max(0, min(ins, n))
1928 #
1929 # Part 2:
1930 # 2010/02/02: Use only tc.setPosition here.
1931 # Using tc.movePosition doesn't work.
1932 tc = w.textCursor()
1933 if i == j:
1934 tc.setPosition(i)
1935 elif ins == j:
1936 # Put the insert point at j
1937 tc.setPosition(i)
1938 tc.setPosition(j, MoveMode.KeepAnchor)
1939 elif ins == i:
1940 # Put the insert point at i
1941 tc.setPosition(j)
1942 tc.setPosition(i, MoveMode.KeepAnchor)
1943 else:
1944 # 2014/08/21: It doesn't seem possible to put the insert point somewhere else!
1945 tc.setPosition(j)
1946 tc.setPosition(i, MoveMode.KeepAnchor)
1947 w.setTextCursor(tc)
1948 # #218.
1949 if hasattr(g.app.gui, 'setClipboardSelection'):
1950 if s[i:j]:
1951 g.app.gui.setClipboardSelection(s[i:j])
1952 #
1953 # Remember the values for v.restoreCursorAndScroll.
1954 v = self.c.p.v # Always accurate.
1955 v.insertSpot = ins
1956 if i > j:
1957 i, j = j, i
1958 assert i <= j
1959 v.selectionStart = i
1960 v.selectionLength = j - i
1961 v.scrollBarSpot = w.verticalScrollBar().value()
1962 #@+node:ekr.20141103061944.40: *4* qtew.setXScrollPosition
1963 def setXScrollPosition(self, pos: Index) -> None:
1964 """Set the position of the horizonatl scrollbar."""
1965 if pos is not None:
1966 w = self.widget
1967 sb = w.horizontalScrollBar()
1968 sb.setSliderPosition(pos)
1969 #@+node:ekr.20110605121601.18098: *4* qtew.setYScrollPosition
1970 def setYScrollPosition(self, pos: Index) -> None:
1971 """Set the vertical scrollbar position."""
1972 if pos is not None:
1973 w = self.widget
1974 sb = w.verticalScrollBar()
1975 sb.setSliderPosition(pos)
1976 #@+node:ekr.20110605121601.18100: *4* qtew.toPythonIndex
1977 def toPythonIndex(self, index: Index, s: str=None) -> int:
1978 """This is much faster than versions using g.toPythonIndex."""
1979 w = self
1980 te = self.widget
1981 if index is None:
1982 return 0
1983 if isinstance(index, int):
1984 return index
1985 if index == '1.0':
1986 return 0
1987 if index == 'end':
1988 return w.getLastPosition()
1989 doc = te.document()
1990 data = index.split('.')
1991 if len(data) == 2:
1992 row1, col1 = data
1993 row, col = int(row1), int(col1)
1994 bl = doc.findBlockByNumber(row - 1)
1995 return bl.position() + col
1996 g.trace(f"bad string index: {index}")
1997 return 0
1998 #@+node:ekr.20110605121601.18101: *4* qtew.toPythonIndexRowCol
1999 def toPythonIndexRowCol(self, index: int) -> Tuple[int, int, int]:
2000 w = self
2001 if index == '1.0':
2002 return 0, 0, 0
2003 if index == 'end':
2004 index = w.getLastPosition()
2005 te = self.widget
2006 doc = te.document()
2007 i = w.toPythonIndex(index)
2008 bl = doc.findBlock(i)
2009 row = bl.blockNumber()
2010 col = i - bl.position()
2011 return i, row, col
2012 #@-others
2013#@-others
2015#@@language python
2016#@@tabwidth -4
2017#@@pagewidth 70
2018#@-leo