Skip to content

Commit 6f083fc

Browse files
refactor(textEdit): refactor the text edit and define a library for the wrap engines (#617)
1 parent 2037345 commit 6f083fc

47 files changed

Lines changed: 6413 additions & 1085 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/copilot-instructions.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ def ttkStringData(self, row:int, col:int) -> TTkString:
125125
data = self.data(row,col)
126126
return TTkAbstractTableModel._dataToTTkString(data)
127127

128-
def setGeometry(self, x: int, y: int, width: int, height: int):
128+
def setGeometry(self, x: int, y: int, width: int, height: int) -> None:
129129
''' Resize and move the widget
130130
131131
:param x: the horizontal position
@@ -203,6 +203,7 @@ i.e., whenever the user checks or unchecks it.
203203

204204
**Key conventions**:
205205
- Use single quotes `'''` for docstrings
206+
- When adding Python code, type hints are required; use standard `typing` module types (or builtin generic types where equivalent) whenever possible
206207
- Include ASCII art for visual widgets showing borders/layout
207208
- Link to demo files with full GitHub URLs
208209
- Use `:py:class:` for cross-references to other classes
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
applyTo: "tests/pytest/**/*.py,apps/*/tests/**/*.py"
3+
description: Rules for creating or updating tests in core and app test folders with behavior-first assertions and full coverage expectations.
4+
---
5+
# Test Authoring Rules (Path Scoped)
6+
7+
These rules apply when creating or editing tests under `tests/pytest/` and `apps/*/tests/`.
8+
9+
## Coverage Requirements
10+
11+
- New or modified tests must drive the touched production code to full line and branch coverage.
12+
- Add tests for normal, edge, and error paths.
13+
- Do not leave uncovered branches for newly introduced logic.
14+
15+
## Behavior-First Assertions
16+
17+
- Test the expected behavior of widgets and functionality from a user/API perspective.
18+
- Prefer public APIs, signals, and documented contracts over internal implementation details.
19+
- Validate observable outcomes (state changes, emitted signals, rendered output, return values, raised errors).
20+
21+
## Anti-Pattern to Avoid
22+
23+
- Do not tune tests to match known bugs, undefined behavior, or accidental current implementation quirks.
24+
- If current behavior is buggy but intentional fixes are in scope, write the test for the correct expected behavior and align code to pass it.
25+
- If behavior is ambiguous, derive expectations from docs, demos, existing stable APIs, and widget design intent.
26+
27+
## Test Quality
28+
29+
- Keep tests deterministic and isolated; avoid timing-sensitive or flaky assertions.
30+
- Use concise fixtures and clear test names describing the behavior under test.
31+
- When fixing a bug, include a regression test that fails before the fix and passes after it.

libs/pyTermTk/TermTk/TTkCore/constant.py

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424

2525
__all__ = ['TTkConstant', 'TTkK']
2626

27-
from enum import IntEnum, Flag
27+
from enum import Enum, IntEnum, Flag, auto
2828

2929
class TTkConstant:
3030
'''Class container of all the constants used in :mod:`~TermTk`'''
@@ -334,13 +334,12 @@ class MouseKey(int):
334334
MiddleButton = MouseKey.MiddleButton
335335
Wheel = MouseKey.Wheel
336336

337-
class WrapMode(int):
337+
class WrapMode(IntEnum):
338338
'''Those constants describes how text is wrapped in a document.
339339
340340
.. autosummary::
341341
WordWrap
342342
WrapAnywhere
343-
WrapAtWordBoundaryOrAnywhere
344343
'''
345344
# NoWrap = 0x00
346345
# '''Text is not wrapped at all.'''
@@ -350,22 +349,40 @@ class WrapMode(int):
350349
# '''Same as :py:class:`~TermTk.TTkCore.constant.TTkConstant.WrapMode.NoWrap`'''
351350
WrapAnywhere = 0x03
352351
'''Text can be wrapped at any point on a line, even if it occurs in the middle of a word.'''
353-
WrapAtWordBoundaryOrAnywhere = 0x04
354-
'''If possible, wrapping occurs at a word boundary; otherwise it will occur at the appropriate point on the line, even in the middle of a word.'''
352+
# WrapAtWordBoundaryOrAnywhere = 0x04
353+
# '''If possible, wrapping occurs at a word boundary; otherwise it will occur at the appropriate point on the line, even in the middle of a word.'''
355354

356355
# NoWrap = WrapMode.NoWrap
357356
WordWrap = WrapMode.WordWrap
358357
# ManualWrap = WrapMode.ManualWrap
359358
WrapAnywhere = WrapMode.WrapAnywhere
360-
WrapAtWordBoundaryOrAnywhere = WrapMode.WrapAtWordBoundaryOrAnywhere
359+
# WrapAtWordBoundaryOrAnywhere = WrapMode.WrapAtWordBoundaryOrAnywhere
360+
361+
class WrapEngine(Enum):
362+
'''Those constants describes which wrapping engine should be used
363+
364+
.. autosummary::
365+
NoWrap
366+
FullWrap
367+
FastWrap
368+
VimWrap
369+
'''
370+
NoWrap = auto()
371+
'''No Wrapping'''
372+
FullWrap = auto()
373+
'''Full document Wrap at any change, Ideal for small documents (~300 Lines) [Precise] (Slow)'''
374+
# FastWrap = auto()
375+
# '''Chunk based document Wrap, Default wrap [Fast] (Scrolling position is estimated)'''
376+
VimWrap = auto()
377+
'''Wrap applied only in the displayed area, [Fastest] (the scrolling is snap to the beginning of the lines)'''
361378

362379
class LineWrapMode(IntEnum):
363380
'''Those constants describes which wrapping status is required in the document
364381
365382
.. autosummary::
366-
NoWrapk
367-
WidgetWidthk
368-
FixedWidthk
383+
NoWrap
384+
WidgetWidth
385+
FixedWidth
369386
'''
370387
NoWrap = 0x00
371388
'''No Wrapping is applied'''
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from .text_wrap import *
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
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__ = ['TTkTextWrap']
24+
25+
26+
from typing import Optional, Tuple
27+
28+
from TermTk.TTkCore.constant import TTkK
29+
from TermTk.TTkCore.signal import pyTTkSignal, pyTTkSlot
30+
from TermTk.TTkGui.textdocument import TTkTextDocument
31+
32+
from .text_wrap_data import _RetScreenRows, _WrapState, _ReWrapData
33+
from .text_wrap_engine import _WrapEngine_Interface
34+
from .text_wrap_engine_no_wrap import _WrapEngine_NoWrap
35+
from .text_wrap_engine_vim_wrap import _WrapEngine_VimWrap
36+
from .text_wrap_engine_fast_wrap import _WrapEngine_FastWrap
37+
from .text_wrap_engine_full_wrap import _WrapEngine_FullWrap
38+
39+
_wrapEngines = {
40+
# TTkK.WrapEngine.FastWrap : _WrapEngine_FastWrap,
41+
TTkK.WrapEngine.FullWrap : _WrapEngine_FullWrap,
42+
TTkK.WrapEngine.VimWrap : _WrapEngine_VimWrap,
43+
TTkK.WrapEngine.NoWrap : _WrapEngine_NoWrap,
44+
}
45+
46+
class TTkTextWrap():
47+
'''TTkTextWrap:
48+
49+
Incremental text wrapping helper for :py:class:`TTkTextDocument`.
50+
It maps document positions to wrapped screen rows and vice versa.
51+
'''
52+
__slots__ = (
53+
'_wrapState',
54+
'_wrapEngine',
55+
56+
# Signals
57+
'wrapChanged'
58+
)
59+
60+
_wrapState: _WrapState
61+
_wrapEngine: _WrapEngine_Interface
62+
wrapChanged: pyTTkSignal
63+
'''
64+
This signal is emitted whenever wrapped line mapping changes.
65+
66+
It is triggered after incremental updates from document edits and after
67+
explicit full rewrap requests.
68+
'''
69+
70+
def __init__(self, document:TTkTextDocument) -> None:
71+
'''Create a wrap manager bound to a text document.
72+
73+
:param document: the source text document to wrap.
74+
:type document: :py:class:`TTkTextDocument`
75+
'''
76+
# signals
77+
self.wrapChanged = pyTTkSignal()
78+
79+
self._wrapState = _WrapState(
80+
size=80,
81+
tabSpaces=4,
82+
textDocument=document,
83+
wordWrapMode=TTkK.WrapAnywhere,
84+
)
85+
self._wrapEngine = _WrapEngine_NoWrap(state=self._wrapState)
86+
document.contentsChange.connect(self._documentContentsChange)
87+
88+
@pyTTkSlot(int,int,int)
89+
def _documentContentsChange(self, line:int, removed:int, added:int) -> None:
90+
self._wrapEngine.rewrap(data=_ReWrapData(line,added,removed))
91+
self.wrapChanged.emit()
92+
93+
def engine(self) -> TTkK.WrapEngine:
94+
for _e, _t in _wrapEngines.items():
95+
if isinstance(self._wrapEngine, _t):
96+
return _e
97+
return TTkK.WrapEngine.NoWrap
98+
99+
def setEngine(self, engine:TTkK.WrapEngine, width:Optional[int]=None) -> None:
100+
'''Switch the wrapping backend implementation.
101+
102+
:param engine: engine selector from :py:class:`TTkK.WrapEngine`.
103+
:type engine: :py:class:`TTkK.WrapEngine`
104+
:param width: optional wrap width applied before switching engines.
105+
:type width: Optional[int]
106+
'''
107+
engine_class = _wrapEngines.get(engine, _WrapEngine_NoWrap)
108+
if isinstance(self._wrapEngine, engine_class):
109+
return
110+
if width is not None:
111+
self._wrapState.size = width
112+
self._wrapEngine = engine_class(state=self._wrapState)
113+
self.rewrap()
114+
115+
def size(self) -> int:
116+
'''Return the estimated wrapped row count.
117+
118+
:return: wrapped size in screen rows.
119+
:rtype: int
120+
'''
121+
return self._wrapEngine.size()
122+
123+
def documentLineCount(self) -> int:
124+
'''Return the number of logical data lines in the document.
125+
126+
:return: document line count.
127+
:rtype: int
128+
'''
129+
return self._wrapState.textDocument.lineCount()
130+
131+
def wrapWidth(self) -> int:
132+
'''Return the current wrap width in terminal cells.
133+
134+
:return: wrap width.
135+
:rtype: int
136+
'''
137+
return self._wrapState.size
138+
139+
def setWrapWidth(self, width:int) -> None:
140+
'''Set wrap width and trigger a full rewrap.
141+
142+
:param width: target width in terminal cells.
143+
:type width: int
144+
'''
145+
self._wrapState.size = width
146+
self.rewrap()
147+
148+
def wordWrapMode(self) -> TTkK.WrapMode:
149+
'''Return the active word-wrap mode.
150+
151+
:return: current wrap mode.
152+
:rtype: :py:class:`TTkK.WrapMode`
153+
'''
154+
return self._wrapState.wordWrapMode
155+
156+
def setWordWrapMode(self, mode:TTkK.WrapMode) -> None:
157+
'''Set the word-wrap mode and invalidate cached wrapping.
158+
159+
:param mode: new wrap mode.
160+
:type mode: :py:class:`TTkK.WrapMode`
161+
'''
162+
self._wrapState.wordWrapMode = mode
163+
self.rewrap()
164+
165+
def screenRows(self, y:int, h:int) -> _RetScreenRows:
166+
'''Return wrapped slices visible in the requested viewport.
167+
168+
:param y: first screen row.
169+
:type y: int
170+
:param h: number of rows to extract.
171+
:type h: int
172+
173+
:return: wrapped row slices.
174+
:rtype: List[:py:class:`_WrapLine`]
175+
'''
176+
if h <= 0:
177+
return _RetScreenRows(y=y, rows=[])
178+
return self._wrapEngine.screenRows(y=y,h=h)
179+
180+
def rewrap(self) -> None:
181+
'''Force a complete wrap refresh and emit ``wrapChanged``.
182+
183+
This invalidates any incremental wrapping cache maintained by the
184+
active engine.
185+
'''
186+
self._wrapEngine.rewrap()
187+
self.wrapChanged.emit()
188+
189+
def dataToScreenPosition(self, line:int, pos:int) -> Tuple[int, int]:
190+
'''Map a document position to wrapped screen coordinates.
191+
192+
:param line: logical line index.
193+
:type line: int
194+
:param pos: character position in the logical line.
195+
:type pos: int
196+
:return: ``(x, y)`` wrapped screen coordinates.
197+
:rtype: Tuple[int, int]
198+
'''
199+
return self._wrapEngine.dataToScreenPosition(line=line, pos=pos)
200+
201+
def screenToDataPosition(self, x:int, y:int) -> Tuple[int, int]:
202+
'''Map wrapped screen coordinates to a document position.
203+
204+
:param x: horizontal screen coordinate.
205+
:type x: int
206+
:param y: vertical screen coordinate.
207+
:type y: int
208+
:return: ``(line, pos)`` document position.
209+
:rtype: Tuple[int, int]
210+
'''
211+
return self._wrapEngine.screenToDataPosition(x=x, y=y)
212+
213+
def normalizeScreenPosition(self, x:int, y:int) -> Tuple[int, int]:
214+
'''Snap a screen position to the nearest editable character cell.
215+
216+
:param x: horizontal widget-relative coordinate.
217+
:type x: int
218+
:param y: vertical widget-relative coordinate.
219+
:type y: int
220+
:return: normalized ``(x, y)`` screen position.
221+
:rtype: Tuple[int, int]
222+
'''
223+
return self._wrapEngine.normalizeScreenPosition(x=x, y=y)

0 commit comments

Comments
 (0)