# MIT License
#
# Copyright (c) 2022 Eugenio Parodi <ceccopierangiolieugenio AT googlemail DOT com>
#
# 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.
__all__ = ['TTkTextCursor']
try:
from typing import Self
except:
class Self(): pass
from TermTk.TTkCore.log import TTkLog
from TermTk.TTkCore.color import TTkColor
from TermTk.TTkCore.string import TTkString
from TermTk.TTkGui.textwrap1 import TTkTextWrap
from TermTk.TTkGui.textdocument import TTkTextDocument
class _CP():
# The Cursor Position is based on the
# document data structure, where the
# the entire document is divided in lines
# instead of considering it a massive string
__slots__ = ('line','pos')
line:int
pos:int
def __init__(self, l:int=0, p:int=0) -> None:
self.set(l,p)
def copy(self) -> Self:
return _CP(self.line, self.pos)
def set(self, l, p) -> None:
self.pos = p
self.line = l
def toNum(self) -> int:
return self.pos | self.line << 16
class _Prop():
__slots__ = ('anchor', 'position')
anchor:_CP
position:_CP
def __init__(self, anchor:_CP, position:_CP) -> None:
self.anchor:_CP = anchor
self.position:_CP = position
def copy(self) -> Self:
return _Prop(self.anchor.copy(), self.position.copy())
def selectionStart(self) -> _CP:
if self.position.toNum() > self.anchor.toNum():
return self.anchor
else:
return self.position
def selectionEnd(self) -> _CP:
if self.position.toNum() >= self.anchor.toNum():
return self.position
else:
return self.anchor
def hasSelection(self) -> bool:
return not (self.position.line == self.anchor.line and self.position.pos == self.anchor.pos)
[docs]
class TTkTextCursor():
class MoveMode():
MoveAnchor = 0x00
'''Moves the anchor to the same position as the cursor itself.'''
KeepAnchor = 0x01
'''Keeps the anchor where it is.'''
MoveAnchor = MoveMode.MoveAnchor
KeepAnchor = MoveMode.KeepAnchor
class SelectionType():
Document = 0x03
'''Selects the entire document.'''
BlockUnderCursor = 0x02
'''Selects the block of text under the cursor.'''
LineUnderCursor = 0x01
'''Selects the line of text under the cursor.'''
WordUnderCursor = 0x00
'''Selects the word under the cursor. If the cursor is not positioned within a string of selectable characters, no text is selected.'''
Document = SelectionType.Document
BlockUnderCursor = SelectionType.BlockUnderCursor
LineUnderCursor = SelectionType.LineUnderCursor
WordUnderCursor = SelectionType.WordUnderCursor
class MoveOperation():
NoMove = 0
'''Keep the cursor where it is'''
Start = 1
'''Move to the start of the document.'''
StartOfLine = 3
'''Move to the start of the current line.'''
StartOfBlock = 4
'''Move to the start of the current block.'''
StartOfWord = 5
'''Move to the start of the current word.'''
PreviousBlock = 6
'''Move to the start of the previous block.'''
PreviousCharacter = 7
'''Move to the previous character.'''
PreviousWord = 8
'''Move to the beginning of the previous word.'''
Up = 2
'''Move up one line.'''
Left = 9
'''Move left one character.'''
WordLeft = 10
'''Move left one word.'''
End = 11
'''Move to the end of the document.'''
EndOfLine = 13
'''Move to the end of the current line.'''
EndOfWord = 14
'''Move to the end of the current word.'''
EndOfBlock = 15
'''Move to the end of the current block.'''
NextBlock = 16
'''Move to the beginning of the next block.'''
NextCharacter = 17
'''Move to the next character.'''
NextWord = 18
'''Move to the next word.'''
Down = 12
'''Move down one line.'''
Right = 19
'''Move right one character.'''
WordRight = 20
'''Move right one word.'''
NextCell = 21
'''Move to the beginning of the next table cell inside the current table. If the current cell is the last cell in the row, the cursor will move to the first cell in the next row.'''
PreviousCell = 22
'''Move to the beginning of the previous table cell inside the current table. If the current cell is the first cell in the row, the cursor will move to the last cell in the previous row.'''
NextRow = 23
'''Move to the first new cell of the next row in the current table.'''
PreviousRow = 24
'''Move to the last cell of the previous row in the current table.'''
NoMove = MoveOperation.NoMove
Start = MoveOperation.Start
StartOfLine = MoveOperation.StartOfLine
StartOfBlock = MoveOperation.StartOfBlock
StartOfWord = MoveOperation.StartOfWord
PreviousBlock = MoveOperation.PreviousBlock
PreviousCharacter = MoveOperation.PreviousCharacter
PreviousWord = MoveOperation.PreviousWord
Up = MoveOperation.Up
Left = MoveOperation.Left
WordLeft = MoveOperation.WordLeft
End = MoveOperation.End
EndOfLine = MoveOperation.EndOfLine
EndOfWord = MoveOperation.EndOfWord
EndOfBlock = MoveOperation.EndOfBlock
NextBlock = MoveOperation.NextBlock
NextCharacter = MoveOperation.NextCharacter
NextWord = MoveOperation.NextWord
Down = MoveOperation.Down
Right = MoveOperation.Right
WordRight = MoveOperation.WordRight
NextCell = MoveOperation.NextCell
PreviousCell = MoveOperation.PreviousCell
NextRow = MoveOperation.NextRow
PreviousRow = MoveOperation.PreviousRow
__slots__ = ('_document', '_properties', '_cID', '_color', '_autoChanged')
def __init__(self, document:TTkTextDocument=None) -> None:
self._color = None
self._cID = 0
self._autoChanged = False
self._properties = [_Prop(_CP(),_CP())]
if document:
self._document = document
self._document.contentsChanged.connect(self._documentContentChanged)
def _documentContentChanged(self):
if self._autoChanged: return True
self.clearCursors()
self.clearSelection()
[docs]
def copy(self) -> Self:
ret = TTkTextCursor()
ret._document = self._document
ret._properties = [p.copy() for p in self._properties]
ret._cID = self._cID
ret._color = self._color
ret._autoChanged = self._autoChanged
return ret
[docs]
def restore(self, cursor:Self) -> None:
self._document = cursor._document
self._properties = [p.copy() for p in cursor._properties]
self._cID = cursor._cID
self._color = cursor._color
self._autoChanged = cursor._autoChanged
self._document.cursorPositionChanged.emit(self)
[docs]
def setColor(self, color:TTkColor) -> None:
self._color = color
[docs]
def clearColor(self) -> None:
self._color = None
[docs]
def anchor(self) -> _CP:
return self._properties[self._cID].anchor
[docs]
def position(self) -> _CP:
return self._properties[self._cID].position
[docs]
def addCursor(self, line:int, pos:int) -> None:
self._cID = 0
self._properties.insert(0, _Prop( _CP(line, pos), _CP(line, pos)))
self._checkCursors(notify=True)
[docs]
def clearCursors(self) -> None:
p = self._properties[self._cID]
self._cID = 0
self._properties = [p]
[docs]
def clearSelection(self) -> None:
for p in self._properties:
p.anchor.line,p.anchor.pos = p.position.line,p.position.pos
[docs]
def positionChar(self, cID:int=-1) -> str:
cID = self._cID if cID==-1 else cID
p = self._properties[cID].position
l = self._document._dataLines[p.line]
pos = max(0,p.pos-1)
if pos < len(l):
ch = l.charAt(pos)
else:
ch = ' '
return ch
[docs]
def positionColor(self, cID:int=-1) -> TTkColor:
cID = self._cID if cID==-1 else cID
p = self._properties[cID].position
l = self._document._dataLines[p.line]
pos = max(0,p.pos-1)
if pos < len(l):
color = l.colorAt(pos)
else:
color = TTkColor()
return color
[docs]
def setPosition(self, line:int, pos:int, moveMode:MoveMode=MoveMode.MoveAnchor, cID:int=0) -> None:
self._properties[cID].position.set(line,pos)
if moveMode==TTkTextCursor.MoveAnchor:
self._properties[cID].anchor.set(line,pos)
self._document.cursorPositionChanged.emit(self)
[docs]
def getLinesUnderCursor(self) -> TTkString:
return [ self._document._dataLines[p.position.line] for p in self._properties ]
def _checkCursors(self, notify:bool=False) -> None:
currCurs = self._properties[self._cID]
currPos = currCurs.position.toNum()
# Sort the cursors based on the starting position
self._properties = sorted(
self._properties,
key=lambda x: x.selectionStart().toNum())
# remove /merge overlapping cursors
newProperties = self._properties[:1]
for np in self._properties:
op = newProperties[-1]
if op.selectionEnd().toNum() < np.selectionStart().toNum():
newProperties.append(np)
continue
if currCurs == np:
currCurs = op
# the two cursors are overlapping
# I try to combine the 2 selections
if op.selectionEnd().toNum() < np.selectionEnd().toNum():
if op.position.toNum()>op.anchor.toNum():
op.position=np.selectionEnd()
else:
op.anchor=np.selectionEnd()
self._properties = newProperties
self._cID = self._properties.index(currCurs)
if notify or currPos != currCurs.position.toNum():
self._document.cursorPositionChanged.emit(self)
[docs]
def movePosition(self, operation:MoveOperation, moveMode:MoveMode=MoveMode.MoveAnchor, n=1, textWrap:TTkTextWrap=None) -> None:
currPos = self.position().toNum()
def moveRight(cID,p,_):
if p.pos < len(self._document._dataLines[p.line]):
nextPos = self._document._dataLines[p.line].nextPos(p.pos)
self.setPosition(p.line, nextPos, moveMode, cID=cID)
elif p.line < len(self._document._dataLines)-1:
self.setPosition(p.line+1, 0, moveMode, cID=cID)
def moveLeft(cID,p,_):
if p.pos > 0:
prevPos = self._document._dataLines[p.line].prevPos(p.pos)
self.setPosition(p.line, prevPos, moveMode, cID=cID)
elif p.line > 0:
self.setPosition(p.line-1, len(self._document._dataLines[p.line-1]) , moveMode, cID=cID)
def moveUpDown(offset):
def _moveUpDown(cID,p,n):
cx, cy = textWrap.dataToScreenPosition(p.line, p.pos)
x, y = textWrap.normalizeScreenPosition(cx,cy+offset*n)
line, pos = textWrap.screenToDataPosition(x,y)
self.setPosition(line, pos, moveMode, cID=cID)
return _moveUpDown
def moveEndOfLine(cID,p,_):
l = self._document._dataLines[p.line]
self.setPosition(p.line, len(l), moveMode, cID=cID)
def moveHome(cID,p,_):
self.setPosition(p.line, 0, moveMode, cID=cID)
def moveEnd(cID,p,_):
l = self._document._dataLines[-1]
self.setPosition(len(self._document._dataLines)-1, len(l), moveMode, cID=cID)
op = {
TTkTextCursor.Right : moveRight,
TTkTextCursor.Left : moveLeft,
TTkTextCursor.Up : moveUpDown(-1),
TTkTextCursor.Down : moveUpDown(+1),
TTkTextCursor.EndOfLine : moveEndOfLine,
TTkTextCursor.StartOfLine: moveHome,
TTkTextCursor.End: moveEnd,
}.get(operation,lambda _:_)
for _ in range(n):
for cID, prop in enumerate(self._properties):
p = prop.position
op(cID,p,n)
self._checkCursors(notify=self.position().toNum()!=currPos)
[docs]
def document(self) -> TTkTextDocument:
return self._document
[docs]
def replaceText(self, text:TTkString, moveCursor:bool=False) -> None:
# if there is no selection, just select the next n chars till the end of the line
# the newline is not replaced
for p in self._properties:
if not p.hasSelection():
line = p.position.line
pos = p.position.pos
lenWoZero = TTkString._getLenTextWoZero(text)
size = len(self._document._dataLines[line])
for _ in range(lenWoZero):
pos = self._document._dataLines[line].nextPos(pos)
pos = min(size,pos)
p.anchor.set(line,pos)
return self.insertText(text, moveCursor)
[docs]
def insertText(self, text:TTkString, moveCursor:bool=False) -> None:
_lineFirst = -1
if self.hasSelection():
_lineFirst, _lineRem, _lineAdd = self._removeSelectedText()
lineFirst = self._properties[0].position.line
lineRem, lineAdd = 0,0
# Check if the number of lines is the same as the number of cursors
# this is a corner case where each line belongs to a
# different cursor
textLines = text.split('\n') if len(text)>1 else [text]
if len(textLines) != len(self._properties):
textLines = [text]*len(self._properties)
# Calc the added and removed lines
for i, pr in enumerate(self._properties):
lenNewLines=len(textLines[i].split('\n'))
l = pr.position.line
p = pr.position.pos
if ( textLines[i] == '\n' and
l == lineFirst != _lineFirst and
p==len(self._document._dataLines[l]) ):
lineFirst = l+1
lineAdd = 1
lineRem = 0
elif (lineFirst + lineRem) > l:
lineAdd += lenNewLines-1
else:
lineAdd += lenNewLines + l-lineFirst-lineRem
lineRem = l - lineFirst + 1
if _lineFirst != -1:
lineFirst, lineRem, lineAdd = TTkTextDocument._mergeChangesSlices(
(_lineFirst, _lineRem, _lineAdd),
( lineFirst, lineRem, lineAdd))
for i, pr in enumerate(self._properties):
text=textLines[i]
l = pr.position.line
p = pr.position.pos
color = self._color if self._color else self.positionColor(i)
# Use the same color under the cursor if no color is defined:
ttktext = text
if isinstance(ttktext, str):
ttktext = TTkString(text, color)
newLines = ( self._document._dataLines[l].substring(to=p) +
ttktext +
self._document._dataLines[l].substring(fr=p) ).split('\n')
self._document._dataLines[l] = newLines[0]
for nl in reversed(newLines[1:]):
self._document._dataLines.insert(l+1, nl)
# Move/Shift the cursors based on the pasted content
#
# 2 scenarios:
# 1) No Newline(s) added
# p p+1 p+2
# from: aaaaaaXaaaaaYaaaaaYaaaa
#
# to: aaaaaaX....aaaaaYaaaaaYaaaa
# diffPos = len(text)
#
# 2) Newlines are added
# p p+1 p+2
# from: aaaaaaXaaaaaYaaaaaYaaaa
#
# to: aaaaaaX...\n
# ......\n
# \n
# .....aaaaaYaaaaaYaaaa
# diffPos = len(text.split('\n')[-1]) - p
diffLine = len(newLines)-1
if diffLine:
diffPos = len(text.split('\n')[-1]) - p
else:
diffPos = len(text)
# Realign all the cursos (move the same if required)
for pp in self._properties[i+(0 if moveCursor else 1):]:
if pp.position.line == l:
pp.position.pos += diffPos
pp.anchor.pos += diffPos
pp.position.line += diffLine
pp.anchor.line += diffLine
self._autoChanged = True
self._document.setChanged(True)
self._document.contentsChanged.emit()
self._document.contentsChange.emit(lineFirst, lineRem, lineAdd)
self._autoChanged = False
self._document.cursorPositionChanged.emit(self)
[docs]
def selectionStart(self) -> _CP:
return self._properties[self._cID].selectionStart()
[docs]
def selectionEnd(self) -> _CP:
return self._properties[self._cID].selectionEnd()
[docs]
def select(self, selection:SelectionType) -> None:
currPos = self.position().toNum()
for p in self._properties:
if selection == TTkTextCursor.SelectionType.Document:
p.position.pos = 0
p.position.line = 0
p.anchor.pos = len(self._document._dataLines[-1])
p.anchor.line = len(self._document._dataLines)-1
elif selection == TTkTextCursor.SelectionType.LineUnderCursor:
line = p.position.line
p.position.pos = 0
p.anchor.pos = len(self._document._dataLines[line])
elif selection == TTkTextCursor.SelectionType.WordUnderCursor:
line = p.position.line
pos = p.position.pos
# Split the current line from the current cursor position
# search the leftmost(on the right slice)/rightmost(on the left slice) word
# in order to match the full word under the cursor
splitBefore = self._document._dataLines[line].substring(to=pos)
splitAfter = self._document._dataLines[line].substring(fr=pos)
xFrom = pos
xTo = pos
selectRE = r'[^ \t\r\n()[\]\.\,\+\-\*\/]*'
if m := splitBefore.search(selectRE+'$'):
xFrom -= len(m.group(0))
if m := splitAfter.search('^'+selectRE):
xTo += len(m.group(0))
p.position.pos = xTo
p.anchor.pos = xFrom
self._checkCursors(notify=self.position().toNum()!=currPos)
[docs]
def selectedText(self) -> TTkString:
def _getText(p):
_ret = []
selSt = p.selectionStart()
selEn = p.selectionEnd()
for l in range(selSt.line,selEn.line+1):
line = self._document._dataLines[l]
pf = 0 if l > selSt.line else selSt.pos
pt = len(line) if l < selEn.line else selEn.pos
_ret.append(line.substring(pf, pt))
return _ret
ret = []
for p in self._properties:
ret += _getText(p)
return TTkString('\n').join(ret)
[docs]
def hasSelection(self) -> bool:
for p in self._properties:
if p.hasSelection():
return True
return False
def _removeSelectedText(self) -> None:
currPos = self.position().toNum()
lineFirst = self._properties[0].selectionStart().line
lineAdd = 0
lineRem = 0
for p in self._properties:
# Check how many lines are removed/added after this operation
selSt = p.selectionStart()
selEn = p.selectionEnd()
if (lineFirst + lineRem) > selSt.line:
_lineAdd = 0
else:
_lineAdd = 0 if selSt.pos == selEn.pos == 0 else 1
lineAdd += selSt.line - lineFirst - lineRem + _lineAdd
lineRem = selEn.line - lineFirst + _lineAdd
def _alignPoint(point,st,en):
point.line += st.line - en.line
if point.line == st.line:
point.pos += st.pos - en.pos
for i, p in enumerate(self._properties):
selSt = p.selectionStart()
selEn = p.selectionEnd()
self._document._dataLines[selSt.line] = self._document._dataLines[selSt.line].substring(to=selSt.pos) + \
self._document._dataLines[selEn.line].substring(fr=selEn.pos)
self._document._dataLines = self._document._dataLines[:selSt.line+1] + self._document._dataLines[selEn.line+1:]
for pp in self._properties[i+1:]:
_alignPoint(pp.position, selSt, selEn)
_alignPoint(pp.anchor, selSt, selEn)
self.setPosition(selSt.line, selSt.pos, cID=i)
self._checkCursors(notify=self.position().toNum()!=currPos)
return lineFirst, lineRem, lineAdd
[docs]
def removeSelectedText(self) -> None:
if not self.hasSelection(): return
a,b,c = self._removeSelectedText()
self._autoChanged = True
self._document.setChanged(True)
self._document.contentsChanged.emit()
self._document.contentsChange.emit(a,b,c)
self._autoChanged = False
[docs]
def applyColor(self, color:TTkColor) -> None:
for p in self._properties:
selSt = p.selectionStart()
selEn = p.selectionEnd()
for l in range(selSt.line,selEn.line+1):
line = self._document._dataLines[l]
pf = 0 if l > selSt.line else selSt.pos
pt = len(line) if l < selEn.line else selEn.pos
self._document._dataLines[l] = line.setColor(color=color, posFrom=pf, posTo=pt)
self._autoChanged = True
self._document.setChanged(True)
self._document.contentsChanged.emit()
# self._document.contentsChange.emit(0,0,0)
self._autoChanged = True
[docs]
def getHighlightedLines(self, fr:int, to:int, color:TTkColor) -> list[TTkString]:
# Create a list of cursors (filtering out the ones which
# position/selection is outside the screen boundaries)
sel = []
for p in self._properties:
selSt = p.selectionStart()
selEn = p.selectionEnd()
if selEn.line >= fr and selSt.line<=to:
sel.append((selSt,selEn,p))
# Retrieve the sublist of lines to be required (displayed)
ret = self._document._dataLines[fr:to+1]
# Apply the selection color for each of them
for s in sel:
selSt, selEn, _ = s
for i in range(max(selSt.line,fr),min(selEn.line+1,to+1)):
l = ret[i-fr]
pf = 0 if i > selSt.line else selSt.pos
pt = len(l) if i < selEn.line else selEn.pos
ret[i-fr] = l.setColor(color=color, posFrom=pf, posTo=pt)
# Add Blinking cursor
if len(self._properties)>1:
for s in sel:
_, _, prop = s
p = prop.position
ret[p.line-fr] = ret[p.line-fr].setColor(color=color+TTkColor.BLINKING, posFrom=p.pos, posTo=p.pos+1)
if p.pos == len(ret[p.line-fr]):
ret[p.line-fr] = ret[p.line-fr]+TTkString('↵',color+TTkColor.BLINKING)
elif ret[p.line-fr].charAt(p.pos) == ' ':
ret[p.line-fr].setCharAt(pos=p.pos, char='∙')
# ret[p.line-fr].setColorAt(pos=p.pos, color=TTkCfg.theme.treeLineColor+TTkColor.BLINKING)
#elif ret[p.line-fr].charAt(p.pos) == '\t':
# ret[p.line-fr].setCharAt(pos=p.pos, char='\t')
return ret