Skip to content

Commit fd40f1a

Browse files
ceccopierangiolieugenioCopilot
andauthored
chore: add HybridVimWrap (#626)
Co-authored-by: Copilot <copilot@github.com>
1 parent 02f621d commit fd40f1a

8 files changed

Lines changed: 844 additions & 7 deletions

File tree

demo/showcase/textedit.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,11 @@ def demoTextEdit(root=None, document=None):
5858

5959
te.setText(ttk.TTkString("Text Edit DEMO\n",ttk.TTkColor.UNDERLINE+ttk.TTkColor.BOLD+ttk.TTkColor.ITALIC))
6060

61+
te.append(" /)/)")
62+
te.append(" ( ..)")
63+
te.append("( づ♡")
64+
te.append("")
65+
6166
# Load ANSI input
6267
if os.path.exists(os.path.join(os.path.dirname(os.path.abspath(__file__)),'textedit.ANSI.txt')):
6368
te.append(ttk.TTkString("ANSI Input Test\n",ttk.TTkColor.UNDERLINE+ttk.TTkColor.BOLD))

libs/pyTermTk/TermTk/TTkCore/constant.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,8 @@ class WrapEngine(Enum):
373373
'''Full document Wrap at any change, Ideal for small documents (~300 Lines) [Precise] (Slow)'''
374374
FastWrap = auto()
375375
'''Chunk based document Wrap, Default wrap [Fast] (Scrolling position is estimated)'''
376+
HybridVimWrap = auto()
377+
'''Hybrid implementation between VimWrap and FastWrap, [Faster] (Scrolling position is estimated)'''
376378
VimWrap = auto()
377379
'''Wrap applied only in the displayed area, [Fastest] (the scrolling is snap to the beginning of the lines)'''
378380

libs/pyTermTk/TermTk/TTkGui/TTkTextWrap/text_wrap.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,14 +33,16 @@
3333
from .text_wrap_engine import _WrapEngine_Interface
3434
from .text_wrap_engine_no_wrap import _WrapEngine_NoWrap
3535
from .text_wrap_engine_vim_wrap import _WrapEngine_VimWrap
36+
from .text_wrap_engine_vim_wrap_hybrid import _WrapEngine_HybridVimWrap
3637
from .text_wrap_engine_fast_wrap import _WrapEngine_FastWrap
3738
from .text_wrap_engine_full_wrap import _WrapEngine_FullWrap
3839

3940
_wrapEngines = {
41+
TTkK.WrapEngine.NoWrap : _WrapEngine_NoWrap,
42+
TTkK.WrapEngine.VimWrap : _WrapEngine_VimWrap,
4043
TTkK.WrapEngine.FastWrap : _WrapEngine_FastWrap,
4144
TTkK.WrapEngine.FullWrap : _WrapEngine_FullWrap,
42-
TTkK.WrapEngine.VimWrap : _WrapEngine_VimWrap,
43-
TTkK.WrapEngine.NoWrap : _WrapEngine_NoWrap,
45+
TTkK.WrapEngine.HybridVimWrap : _WrapEngine_HybridVimWrap,
4446
}
4547

4648
class TTkTextWrap():
Lines changed: 333 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,333 @@
1+
# MIT License
2+
#
3+
# Copyright (c) 2026 Eugenio Parodi <ceccopierangiolieugenio AT googlemail DOT com>
4+
#
5+
# Permission is hereby granted, free of charge, to any person obtaining a copy
6+
# of this software and associated documentation files (the "Software"), to deal
7+
# in the Software without restriction, including without limitation the rights
8+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
# copies of the Software, and to permit persons to whom the Software is
10+
# furnished to do so, subject to the following conditions:
11+
#
12+
# The above copyright notice and this permission notice shall be included in all
13+
# copies or substantial portions of the Software.
14+
#
15+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
# SOFTWARE.
22+
23+
__all__:list = []
24+
25+
from dataclasses import dataclass
26+
from threading import RLock
27+
from typing import Final, List, Optional, Tuple
28+
29+
from .text_wrap import _WrapEngine_Interface
30+
from .text_wrap_data import _ReWrapData, _RetScreenPosition, _RetScreenPositions, _RetScreenRows, _WrapLine, _WrapState
31+
32+
_WINDOW_BORDER : Final[int] = 32
33+
34+
35+
@dataclass
36+
class _getBufferSignature():
37+
'''Viewport signature for caching.
38+
39+
:param y: first viewport row.
40+
:type y: int
41+
:param h: viewport height.
42+
:type h: int
43+
'''
44+
y: int
45+
h: int
46+
47+
48+
@dataclass
49+
class _LastWindow():
50+
'''Cache of the last wrapped viewport window.
51+
52+
:param buffer: cached wrapped-row descriptors.
53+
:type buffer: List[:py:class:`_WrapLine`]
54+
:param signature: viewport signature used to build the cache.
55+
:type signature: Optional[:py:class:`_getBufferSignature`]
56+
'''
57+
buffer: List[_WrapLine]
58+
signature: Optional[_getBufferSignature] = None
59+
60+
@property
61+
def y(self) -> int:
62+
if not self.buffer:
63+
return 0
64+
return self.buffer[0].line
65+
66+
@property
67+
def h(self) -> int:
68+
return len(self.buffer)
69+
70+
@property
71+
def lines(self) -> int:
72+
if not self.buffer:
73+
return 0
74+
return self.buffer[-1].line - self.buffer[0].line + 1
75+
76+
@property
77+
def first_line(self) -> int:
78+
if not self.buffer:
79+
return 0
80+
return self.buffer[0].line
81+
82+
def hasSlice(self, y:int, h:int) -> bool:
83+
return self.y <= y < y+h <= self.y+self.h
84+
85+
def slice(self, y:int, h:int) -> List[_WrapLine]:
86+
by = self.y
87+
return self.buffer[y-by:y-by+h+1]
88+
89+
90+
91+
class _WrapEngine_HybridVimWrap(_WrapEngine_Interface):
92+
'''Lazy wrap engine optimized around the active viewport.
93+
94+
This engine is intentionally *viewport-accurate* only:
95+
96+
* rows inside the cached window are wrapped with full accuracy;
97+
* coordinates outside the cached window are treated as unwrapped
98+
document lines to keep lookups and navigation fast on large files.
99+
100+
This trade-off is deliberate and favors responsiveness while preserving
101+
precise behavior on visible content.
102+
103+
Thread Safety: All cache operations are protected with an RLock mutex.
104+
'''
105+
__slots__ = ('_lastWindow', '_cacheLock')
106+
107+
_lastWindow: _LastWindow
108+
109+
def __init__(self, state):
110+
'''Initialize the viewport cache for lazy wrapping.
111+
112+
:param state: shared wrap state.
113+
:type state: :py:class:`_WrapState`
114+
'''
115+
self._lastWindow = _LastWindow(buffer=[])
116+
self._cacheLock = RLock()
117+
super().__init__(state)
118+
119+
def size(self) -> int:
120+
'''Return an approximate number of addressable screen rows.
121+
122+
For visible rows this behaves like wrapped coordinates. Outside the
123+
cached viewport, this engine falls back to unwrapped line semantics,
124+
so the returned value is intentionally an approximation suitable for
125+
scrolling/navigation bounds.
126+
127+
:return: logical line count.
128+
:rtype: int
129+
'''
130+
document = self._wrapState.textDocument
131+
document_size = document.lineCount()
132+
133+
with self._cacheLock:
134+
lastWindow = self._lastWindow
135+
return max(0,document_size - lastWindow.lines + len(lastWindow.buffer))
136+
137+
def rewrap(self, data: Optional[_ReWrapData]=None) -> None:
138+
'''Invalidate cached viewport data.
139+
140+
Wrapping is generated on demand by :py:meth:`screenRows`. This method
141+
clears the last-window cache so subsequent calls rebuild from the
142+
current document/wrap settings.
143+
144+
:param data: optional change descriptor, ignored.
145+
:type data: Optional[:py:class:`_ReWrapData`]
146+
'''
147+
with self._cacheLock:
148+
if signature := self._lastWindow.signature:
149+
self._getBuffer(signature.y, signature.h, force=True)
150+
else:
151+
self._lastWindow = _LastWindow(buffer=[])
152+
153+
def dataToScreenPosition(self, line:int, pos:int) -> _RetScreenPositions:
154+
'''Map document coordinates to screen coordinates.
155+
156+
Behavior depends on whether the target position is in the cached
157+
viewport window:
158+
159+
* in-window: return fully wrapped ``(x, y)`` coordinates;
160+
* offscreen: return unwrapped-style coordinates where ``y`` matches
161+
the source line index.
162+
163+
:param line: source line index.
164+
:type line: int
165+
:param pos: source position within line.
166+
:type pos: int
167+
168+
:return: wrapped or fallback unwrapped-style screen coordinates.
169+
:rtype: :py:class:`_RetScreenPositions`
170+
'''
171+
if not self._wrapState.size:
172+
return _RetScreenPositions(main=_RetScreenPosition(x=0,y=0))
173+
text_document = self._wrapState.textDocument
174+
with self._cacheLock:
175+
buffer = self._lastWindow.buffer
176+
for i, row in enumerate(buffer, self._lastWindow.y):
177+
dt=row.line
178+
fr=row.start
179+
to=row.stop
180+
if dt == line and fr <= pos <= to:
181+
data_line = text_document.dataLine(dt)
182+
if data_line is None:
183+
return _RetScreenPositions(main=_RetScreenPosition(x=0,y=0))
184+
l = data_line.substring(fr,pos).tab2spaces(self._wrapState.tabSpaces)
185+
x, y = l.termWidth(), i
186+
return _RetScreenPositions(main=_RetScreenPosition(x=x,y=y))
187+
elif dt == line and row.last_slice and pos > row.stop:
188+
data_line = text_document.dataLine(dt)
189+
l = data_line.substring(row.start, row.stop).tab2spaces(self._wrapState.tabSpaces)
190+
x, y = l.termWidth(), i
191+
return _RetScreenPositions(main=_RetScreenPosition(x=x,y=y))
192+
line = self._clampLine(line)
193+
data_line = text_document.dataLine(line)
194+
if data_line is None:
195+
return _RetScreenPositions(main=_RetScreenPosition(x=0,y=0))
196+
if 0 <= pos <= len(data_line) + 1:
197+
l = data_line.substring(0,pos).tab2spaces(self._wrapState.tabSpaces)
198+
x, y = l.termWidth(), line
199+
return _RetScreenPositions(main=_RetScreenPosition(x=x,y=y))
200+
return _RetScreenPositions(main=_RetScreenPosition(x=0,y=0))
201+
202+
def screenToDataPosition(self, x:int, y:int) -> Tuple[int, int]:
203+
'''Map screen coordinates back to source coordinates.
204+
205+
For rows inside the cached viewport the mapping is wrap-accurate.
206+
For rows above/below the cached window, ``y`` is interpreted as an
207+
unwrapped source line index.
208+
209+
:param x: horizontal coordinate.
210+
:type x: int
211+
:param y: vertical coordinate.
212+
:type y: int
213+
214+
:return: ``(line, pos)`` from the cached viewport.
215+
:rtype: Tuple[int, int]
216+
'''
217+
with self._cacheLock:
218+
dy = y - self._lastWindow.y
219+
if dy < 0 or dy >= self._lastWindow.h:
220+
y = self._clampLine(y)
221+
line = self._wrapState.textDocument.dataLine(y)
222+
if line is None:
223+
return 0, 0
224+
pos = line.tabCharPos(x,self._wrapState.tabSpaces)
225+
return y, pos
226+
dy = min(dy, len(self._lastWindow.buffer)-1)
227+
row = self._lastWindow.buffer[dy]
228+
dt=row.line
229+
fr=row.start
230+
to=row.stop
231+
text_document = self._wrapState.textDocument
232+
data_line = text_document.dataLine(dt)
233+
if data_line is None:
234+
return 0, 0
235+
pos = fr+data_line.substring(fr,to).tabCharPos(x,self._wrapState.tabSpaces)
236+
return dt, pos
237+
238+
def normalizeScreenPosition(self, x:int, y:int) -> Tuple[int, int]:
239+
'''Clamp a screen coordinate to a valid character cell.
240+
241+
For rows inside the cached viewport this normalizes within the wrapped
242+
row fragment. For rows outside the cached viewport this normalizes as
243+
an unwrapped line coordinate.
244+
245+
:param x: horizontal coordinate.
246+
:type x: int
247+
:param y: vertical coordinate.
248+
:type y: int
249+
250+
:return: normalized ``(x, y)``.
251+
:rtype: Tuple[int, int]
252+
'''
253+
x = max(0,x)
254+
with self._cacheLock:
255+
dy = y - self._lastWindow.y
256+
if dy < 0 or dy >= self._lastWindow.h:
257+
y = self._clampLine(y)
258+
line = self._wrapState.textDocument.dataLine(y)
259+
if line is None:
260+
return 0, 0
261+
x = line.tabCharPos(x, self._wrapState.tabSpaces)
262+
x = line.substring(0,x).tab2spaces(self._wrapState.tabSpaces).termWidth()
263+
return x, y
264+
dy = min(dy, len(self._lastWindow.buffer)-1)
265+
row = self._lastWindow.buffer[dy]
266+
dt=row.line
267+
fr=row.start
268+
to=row.stop
269+
x = max(0,x)
270+
text_document = self._wrapState.textDocument
271+
data_line = text_document.dataLine(dt)
272+
if data_line is None:
273+
return 0, 0
274+
s = data_line.substring(fr,to)
275+
x = s.tabCharPos(x, self._wrapState.tabSpaces)
276+
x = s.substring(0,x).tab2spaces(self._wrapState.tabSpaces).termWidth()
277+
return x, y
278+
279+
def screenRows(self, y:int, h:int) -> _RetScreenRows:
280+
'''Wrap and cache enough rows to satisfy a viewport request.
281+
282+
The engine caches only the last requested viewport window. If the same
283+
``(y, h)`` pair is requested repeatedly and the cache is still valid,
284+
rows are returned directly without re-wrapping.
285+
286+
:param y: first viewport row.
287+
:type y: int
288+
:param h: viewport height.
289+
:type h: int
290+
291+
:return: cached wrapped row descriptors.
292+
:rtype: :py:class:`_RetScreenRows`
293+
'''
294+
buffer = self._getBuffer(y=y, h=h)
295+
return _RetScreenRows(rows=buffer)
296+
297+
def _getBuffer(self, y:int, h:int, force:bool=False) -> List[_WrapLine]:
298+
'''Build or retrieve cached wrapped rows for a viewport window.
299+
300+
:param y: first viewport row.
301+
:type y: int
302+
:param h: viewport height.
303+
:type h: int
304+
:param force: force re-wrapping even if cached slice is available.
305+
:type force: bool
306+
307+
:return: list of wrapped row descriptors.
308+
:rtype: List[:py:class:`_WrapLine`]
309+
'''
310+
if not (w := self._wrapState.size):
311+
return []
312+
313+
with self._cacheLock:
314+
if not force and self._lastWindow.hasSlice(y,h):
315+
return self._lastWindow.slice(y,h)
316+
else:
317+
buffer:List[_WrapLine] = []
318+
319+
document = self._wrapState.textDocument
320+
document_size = document.lineCount()
321+
322+
first_line = min(document_size, y+h) - h
323+
first_line = max(0,first_line-_WINDOW_BORDER)
324+
slice_size = min(document_size, first_line+_WINDOW_BORDER+h) - first_line
325+
326+
for _i,_line in enumerate(document.dataLines(slice(first_line,first_line+slice_size)), start=max(0,first_line)):
327+
buffer.extend(self._wrapLine(w,_i,_line))
328+
# if len(buffer) >= h:
329+
# break
330+
self._lastWindow = _LastWindow(buffer=buffer, signature=_getBufferSignature(y=y, h=h))
331+
return self._lastWindow.slice(y,h)
332+
333+
return []

0 commit comments

Comments
 (0)