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