From 3412fc17c2d8837b76690fbae166188a3a415aa7 Mon Sep 17 00:00:00 2001 From: Pier CeccoPierangioliEugenio Date: Tue, 15 Apr 2025 19:39:35 +0100 Subject: [PATCH] Added the basic of the TextEdit configurable ruler (#312) --- libs/pyTermTk/TermTk/TTkWidgets/texedit.py | 103 ++++++++- ...=> test.ui.018.TextEdit.04.ExtraSelect.py} | 0 .../test.ui.018.TextEdit.05.customRuler.py | 201 ++++++++++++++++++ 3 files changed, 294 insertions(+), 10 deletions(-) rename tests/t.ui/{test.ui.018.TextEdit.04.Pygments.py => test.ui.018.TextEdit.04.ExtraSelect.py} (100%) create mode 100755 tests/t.ui/test.ui.018.TextEdit.05.customRuler.py diff --git a/libs/pyTermTk/TermTk/TTkWidgets/texedit.py b/libs/pyTermTk/TermTk/TTkWidgets/texedit.py index ac4f57b7..fe897427 100644 --- a/libs/pyTermTk/TermTk/TTkWidgets/texedit.py +++ b/libs/pyTermTk/TermTk/TTkWidgets/texedit.py @@ -20,7 +20,7 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -__all__ = ['TTkTextEditView', 'TTkTextEdit'] +__all__ = ['TTkTextEditView', 'TTkTextEdit', 'TTkTextEditRuler'] from TermTk.TTkCore.log import TTkLog @@ -43,7 +43,46 @@ from TermTk.TTkWidgets.widget import TTkWidget from TermTk.TTkAbstract.abstractscrollarea import TTkAbstractScrollArea from TermTk.TTkAbstract.abstractscrollview import TTkAbstractScrollView, TTkAbstractScrollViewGridLayout -class _TTkTextEditViewLineNumber(TTkAbstractScrollView): +class TTkTextEditRuler(TTkAbstractScrollView): + class MarkRuler(): + class States(int): + NONE = 0x00 + FLAGGED = 0x01 + UNFLAGGED = NONE + + # class MarkRulerType(int): + # ALLOW_EMPTY = 0x01 + # SINGLE_STATE = 0x02 + # MULTI_STATE = 0x04 + + __slots__ = ('_markers','_states','_width','_lines','_defaultState') + def __init__(self, + markers:dict[int,TTkString]) -> None: + self._lines = {} + self._markers = markers + self._states = len(markers) + self._defaultState = next(iter(markers)) + self._width = max(v.termWidth() for v in markers.values()) + + def width(self) -> int: + return self._width + + def nextState(self, state:int) -> int: + return (state+1)%self._states + + def setState(self, line:int, state:int) -> None: + if state == self._defaultState: + if line in self._lines: + del self._lines[line] + self._lines[line] = state + + def getState(self, line:int) -> int: + return self._lines.get(line, self._defaultState) + + def getTTkStr(self, line:int) -> TTkString: + state=self._lines.get(line, self._defaultState) + return self._markers.get(state, TTkString()) + classStyle = { 'default': { 'color': TTkColor.fg("#88aaaa")+TTkColor.bg("#333333"), @@ -55,20 +94,28 @@ class _TTkTextEditViewLineNumber(TTkAbstractScrollView): 'separatorColor': TTkColor.fg("#888888")}, } - __slots__ = ('_textWrap','_startingNumber') + __slots__ = ('_textWrap','_startingNumber', '_markRuler', '_markRulerSizes') def __init__(self, startingNumber=0, **kwargs) -> None: - self._startingNumber = startingNumber - self._textWrap = None + self._startingNumber:int = startingNumber + self._textWrap:bool = None + self._markRuler:list[TTkTextEditRuler.MarkRuler] = [] + self._markRulerSizes:list[int] = [] super().__init__(**kwargs) self.setMaximumWidth(2) def _wrapChanged(self) -> None: dt = max(1,self._textWrap._lines[-1][0]) off = self._startingNumber - width = 1+max(len(str(int(dt+off))),len(str(int(off)))) + width = 2+max(len(str(int(dt+off))),len(str(int(off)))) + width += sum(self._markRulerSizes) self.setMaximumWidth(width) self.update() + def addMarkRuler(self, markRuler:MarkRuler) -> None: + self._markRuler.append(markRuler) + self._markRulerSizes.append(markRuler.width()) + self._wrapChanged() + def setTextWrap(self, tw) -> None: self._textWrap = tw tw.wrapChanged.connect(self._wrapChanged) @@ -80,11 +127,32 @@ class _TTkTextEditViewLineNumber(TTkAbstractScrollView): else: return self.size() + def mousePressEvent(self, evt:TTkMouseEvent) -> bool: + if not self._markRuler: + return True + ox, oy = self.getViewOffsets() + w, h = self.size() + mx,my = evt.x+ox, evt.y+oy + for mk in self._markRuler: + mx -= mk.width() + if mx < 0: + break + if self._textWrap and my < len(self._textWrap._lines): + dt = self._textWrap._lines[my][0] + mk.setState(dt, mk.nextState(mk.getState(dt))) + else: + mk.setState(my, mk.nextState(mk.getState(my))) + + self.update() + return True + def paintEvent(self, canvas: TTkCanvas) -> None: if not self._textWrap: return _, oy = self.getViewOffsets() w, h = self.size() off = self._startingNumber + leftOff = sum(self._markRulerSizes) + sum(self._markRulerSizes) style = self.currentStyle() color = style['color'] @@ -94,15 +162,26 @@ class _TTkTextEditViewLineNumber(TTkAbstractScrollView): if self._textWrap: for i, (dt, (fr, _)) in enumerate(self._textWrap._lines[oy:oy+h]): if fr: - canvas.drawText(pos=(0,i), text='<', width=w, color=wrapColor) + canvas.drawText(pos=(leftOff,i), text='<', width=w, color=wrapColor) else: - canvas.drawText(pos=(0,i), text=f"{dt+off}", width=w, color=color) + canvas.drawText(pos=(leftOff,i), text=f"{dt+off}", width=w, color=color) canvas.drawChar(pos=(w-1,i), char='▌', color=separatorColor) else: for y in range(h): - canvas.drawText(pos=(0,y), text=f"{y+oy+off}", width=w, color=color) + canvas.drawText(pos=(leftOff,y), text=f"{y+oy+off}", width=w, color=color) canvas.drawChar(pos=(w-1,y), char='▌', color=separatorColor) + ox = 0 + for mk in self._markRuler: + if self._textWrap: + for i, (dt, (fr, _)) in enumerate(self._textWrap._lines[oy:oy+h]): + if not fr: + canvas.drawText(pos=(ox,i), text=mk.getTTkStr(dt+off)) + else: + for y in range(h): + canvas.drawText(pos=(ox,y), text=mk.getTTkStr(dt+off)) + ox += mk.width() + class TTkTextEditView(TTkAbstractScrollView): ''' :py:class:`TTkTextEditView` @@ -897,7 +976,7 @@ class TTkTextEdit(TTkAbstractScrollArea): textEditLayout = TTkAbstractScrollViewGridLayout() textEditLayout.addWidget(self._textEditView,0,1) - self._lineNumberView = _TTkTextEditViewLineNumber(visible=self._lineNumber, startingNumber=lineNumberStarting) + self._lineNumberView = TTkTextEditRuler(visible=self._lineNumber, startingNumber=lineNumberStarting) self._lineNumberView.setTextWrap(self._textEditView._textWrap) textEditLayout.addWidget(self._lineNumberView,0,0) self.setViewport(textEditLayout) @@ -905,6 +984,10 @@ class TTkTextEdit(TTkAbstractScrollArea): for _attr in self._forwardedSignals+self._forwardedMethods: setattr(self,_attr,getattr(self._textEditView,_attr)) + def ruler(self) -> TTkTextEditRuler: + '''ruler''' + return self._lineNumberView + def textEditView(self): '''textEditView''' return self._textEditView diff --git a/tests/t.ui/test.ui.018.TextEdit.04.Pygments.py b/tests/t.ui/test.ui.018.TextEdit.04.ExtraSelect.py similarity index 100% rename from tests/t.ui/test.ui.018.TextEdit.04.Pygments.py rename to tests/t.ui/test.ui.018.TextEdit.04.ExtraSelect.py diff --git a/tests/t.ui/test.ui.018.TextEdit.05.customRuler.py b/tests/t.ui/test.ui.018.TextEdit.05.customRuler.py new file mode 100755 index 00000000..a8044813 --- /dev/null +++ b/tests/t.ui/test.ui.018.TextEdit.05.customRuler.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 + +# MIT License +# +# Copyright (c) 2021 Eugenio Parodi +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import os +import sys +import random +import argparse + +sys.path.append(os.path.join(sys.path[0],'../..')) +import TermTk as ttk + +def demoTextEdit(root, filename): + frame = ttk.TTkFrame(parent=root, border=False, layout=ttk.TTkGridLayout()) + + te = ttk.TTkTextEdit(lineNumber=True) + te.setReadOnly(False) + + for _ in range(random.randint(1,4)): + # Define few custoim rulers + r0 = ttk.TTkTextEditRuler.MarkRuler({ + 0: ttk.TTkString(' ') , + 1: ttk.TTkString('● ',ttk.TTkColor.RED) }) + + r1 = ttk.TTkTextEditRuler.MarkRuler({ + 0: ttk.TTkString('◼ ',ttk.TTkColor.GREEN) , + 1: ttk.TTkString('● ',ttk.TTkColor.RED) }) + + r2 = ttk.TTkTextEditRuler.MarkRuler({ + 0: ttk.TTkString('○ ',ttk.TTkColor.fg('#00FF00')) , + 1: ttk.TTkString('◐ ',ttk.TTkColor.fg('#00FF00')) , + 2: ttk.TTkString('◒ ',ttk.TTkColor.fg('#88FF00')) , + 3: ttk.TTkString('◑ ',ttk.TTkColor.fg('#FFFF00')) , + 4: ttk.TTkString('◓ ',ttk.TTkColor.fg('#FF8800')) , + 5: ttk.TTkString('● ',ttk.TTkColor.fg('#FF0000')) ,}) + + r3 = ttk.TTkTextEditRuler.MarkRuler({ + 0: ttk.TTkString(' ', ttk.TTkColor.fg('#0000FF')) , + 1: ttk.TTkString('FOO', ttk.TTkColor.fg('#0000FF')) , + 2: ttk.TTkString('BAR', ttk.TTkColor.fg('#0000FF')) , + 3: ttk.TTkString('BAZ', ttk.TTkColor.fg('#0088FF')) , + 4: ttk.TTkString('QUX', ttk.TTkColor.fg('#00FFFF')) , + 5: ttk.TTkString('QUUX', ttk.TTkColor.fg('#00FF88')) , + 6: ttk.TTkString('Eugenio',ttk.TTkColor.fg('#00FF00')) ,}) + te.ruler().addMarkRuler(random.choice([r0,r1,r2,r3])) + + with open(filename, 'r') as f: + content = f.read() + doc = ttk.TextDocumentHighlight(text=content) + te.setDocument(doc) + + # use the widget size to wrap + # te.setLineWrapMode(ttk.TTkK.WidgetWidth) + # te.setWordWrapMode(ttk.TTkK.WordWrap) + + # Use a fixed wrap size + # te.setLineWrapMode(ttk.TTkK.FixedWidth) + # te.setWrapWidth(100) + + frame.layout().addWidget(te,1,0,1,10) + frame.layout().addWidget(ttk.TTkLabel(text="Wrap: ", maxWidth=6),0,0) + frame.layout().addWidget(lineWrap := ttk.TTkComboBox(list=['NoWrap','WidgetWidth','FixedWidth']),0,1) + frame.layout().addWidget(ttk.TTkLabel(text=" Type: ",maxWidth=7),0,2) + frame.layout().addWidget(wordWrap := ttk.TTkComboBox(list=['WordWrap','WrapAnywhere'], enabled=False),0,3) + frame.layout().addWidget(ttk.TTkLabel(text=" FixW: ",maxWidth=7),0,4) + frame.layout().addWidget(fixWidth := ttk.TTkSpinBox(value=te.wrapWidth(), maxWidth=6, maximum=500, minimum=10, enabled=False),0,5) + frame.layout().addWidget(ttk.TTkLabel(text=" Lexer: ",maxWidth=8),0,6) + frame.layout().addWidget(lexers := ttk.TTkComboBox(list=ttk.TextDocumentHighlight.getLexers()),0,7) + frame.layout().addWidget(ttk.TTkLabel(text=" Style: ",maxWidth=8),0,8) + frame.layout().addWidget(styles := ttk.TTkComboBox(list=ttk.TextDocumentHighlight.getStyles()),0,9) + + + lineWrap.setCurrentIndex(0) + wordWrap.setCurrentIndex(1) + + fixWidth.valueChanged.connect(te.setWrapWidth) + lexers.currentTextChanged.connect(doc.setLexer) + styles.currentTextChanged.connect(doc.setStyle) + + @ttk.pyTTkSlot(int) + def _lineWrapCallback(index): + if index == 0: + te.setLineWrapMode(ttk.TTkK.NoWrap) + wordWrap.setDisabled() + fixWidth.setDisabled() + elif index == 1: + te.setLineWrapMode(ttk.TTkK.WidgetWidth) + wordWrap.setEnabled() + fixWidth.setDisabled() + else: + te.setLineWrapMode(ttk.TTkK.FixedWidth) + wordWrap.setEnabled() + fixWidth.setEnabled() + + lineWrap.currentIndexChanged.connect(_lineWrapCallback) + + @ttk.pyTTkSlot(int) + def _wordWrapCallback(index): + if index == 0: + te.setWordWrapMode(ttk.TTkK.WordWrap) + else: + te.setWordWrapMode(ttk.TTkK.WrapAnywhere) + + @ttk.pyTTkSlot(ttk.TTkTextCursor) + def _positionChanged(cursor:ttk.TTkTextCursor): + extra_selections = [] + + # Hiighlight YELLOW all the selected lines + cursor = te.textCursor().copy() + lines = [] + for cur in cursor.cursors(): + selSt = cur.selectionStart().line + selEn = cur.selectionEnd().line + lines += [x for x in range(selSt,selEn+1)] + cursor.clearCursors() + cursor.clearSelection() + for x in set(lines): + cursor.addCursor(x,0) + selection = ttk.TTkTextEdit.ExtraSelection( + cursor=cursor, + color=ttk.TTkColor.BG_YELLOW, + format=ttk.TTkK.SelectionFormat.FullWidthSelection) + extra_selections.append(selection) + + # Highlight Red only the lines under the cursor positions + cursor = te.textCursor().copy() + cursor.clearSelection() + selection = ttk.TTkTextEdit.ExtraSelection( + cursor=cursor, + color=ttk.TTkColor.BG_RED, + format=ttk.TTkK.SelectionFormat.FullWidthSelection) + extra_selections.append(selection) + + # Highlight GREEN the words under the cursor positions + cursor = te.textCursor().copy() + cursor.select(ttk.TTkTextCursor.SelectionType.WordUnderCursor) + selection = ttk.TTkTextEdit.ExtraSelection( + cursor=cursor, + color=ttk.TTkColor.BG_GREEN) + extra_selections.append(selection) + + te.setExtraSelections(extra_selections) + + wordWrap.currentIndexChanged.connect(_wordWrapCallback) + te.cursorPositionChanged.connect(_positionChanged) + + return frame + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('filename', type=str, nargs='+', + help='the filename/s') + args = parser.parse_args() + + ttk.TTkTheme.loadTheme(ttk.TTkTheme.NERD) + + root = ttk.TTk(layout=ttk.TTkGridLayout()) + appTemplate = ttk.TTkAppTemplate(parent=root) + appTemplate.setWidget(fileTree := ttk.TTkFileTree(), position=ttk.TTkK.LEFT, size=30) + appTemplate.setItem(layoutArea := ttk.TTkLayout(), position=appTemplate.Position.MAIN) + + def _openFile(fileName): + newPos = (0,0) + oldPos = [win.pos() for win in layoutArea.children()] + while newPos in oldPos: + newPos = (newPos[0]+1,newPos[1]+1,) + + win = ttk.TTkWindow(pos = newPos, size=(100,40), title=f"Test Text Edit ({fileName})", layout=ttk.TTkGridLayout(), border=True) + layoutArea.addWidget(win) + demoTextEdit(win, fileName) + + for file in args.filename: + _openFile(file) + + fileTree.fileActivated.connect(lambda x: _openFile(x.path())) + + root.mainloop() + +if __name__ == "__main__": + main() \ No newline at end of file