pycodecad 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pycodecad/textedit.py ADDED
@@ -0,0 +1,290 @@
1
+ """Pure text editing: every function takes an Editor and returns a new one.
2
+
3
+ Positions are character offsets into the text; lines and columns are zero based.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ from dataclasses import replace
8
+ from typing import Annotated
9
+
10
+ from pytypehint import Min, immutable
11
+
12
+ HISTORY = 200 # undo steps kept
13
+
14
+ Position = Annotated[int, Min(0)]
15
+
16
+
17
+ @immutable
18
+ class Snapshot:
19
+ """A point in the undo history: a text and a cursor within it."""
20
+
21
+ text: str
22
+ cursor: Position
23
+
24
+ def __post_init__(self) -> None:
25
+ if self.cursor > len(self.text):
26
+ raise ValueError(f"snapshot cursor {self.cursor} past the end of its text ({len(self.text)})")
27
+
28
+
29
+ @immutable
30
+ class Editor:
31
+ """Text, selection (from `anchor` to `cursor`) and undo/redo history of snapshots.
32
+ `reveal` asks the view to scroll the cursor into sight once.
33
+ Every position lies within its text, so an editor can never point past the end."""
34
+
35
+ text: str = ""
36
+ cursor: Position = 0
37
+ anchor: Position = 0
38
+ undo: tuple[Snapshot, ...] = ()
39
+ redo: tuple[Snapshot, ...] = ()
40
+ reveal: bool = False
41
+
42
+ def __post_init__(self) -> None:
43
+ if self.cursor > len(self.text) or self.anchor > len(self.text):
44
+ raise ValueError(f"cursor {self.cursor} / anchor {self.anchor} past the end of the text ({len(self.text)})")
45
+
46
+
47
+ def line_col(text: str, offset: int) -> tuple[int, int]:
48
+ offset = max(0, min(offset, len(text)))
49
+ return text.count("\n", 0, offset), offset - text.rfind("\n", 0, offset) - 1
50
+
51
+
52
+ def offset(text: str, line: int, col: int) -> int:
53
+ lines = text.split("\n")
54
+ row = max(0, min(line, len(lines) - 1))
55
+ return sum(len(value) + 1 for value in lines[:row]) + max(0, min(col, len(lines[row])))
56
+
57
+
58
+ def load(text: str) -> Editor:
59
+ """A new document: cursor at the start, no history."""
60
+ return Editor(text=text, reveal=True)
61
+
62
+
63
+ def selection(editor: Editor) -> tuple[int, int]:
64
+ return min(editor.cursor, editor.anchor), max(editor.cursor, editor.anchor)
65
+
66
+
67
+ # --- changing the text ---------------------------------------------------------------------
68
+
69
+ def _snapshot(editor: Editor) -> Snapshot:
70
+ return Snapshot(text=editor.text, cursor=editor.cursor)
71
+
72
+
73
+ def _checkpoint(editor: Editor) -> Editor:
74
+ """End a typing group: the next change gets its own undo step."""
75
+ if editor.undo and editor.undo[-1].text != editor.text:
76
+ return replace(editor, undo=(editor.undo + (_snapshot(editor),))[-HISTORY:])
77
+ return editor
78
+
79
+
80
+ def change(editor: Editor, text: str, cursor: int, anchor: int | None = None, typing: bool = False) -> Editor:
81
+ """Replace the text, recording undo. Consecutive typed characters share one undo step."""
82
+ anchor = cursor if anchor is None else anchor
83
+ if text == editor.text:
84
+ return replace(editor, cursor=cursor, anchor=anchor, reveal=True)
85
+ history = editor.undo
86
+ grouped = False
87
+ if typing and history and not editor.redo and editor.cursor == editor.anchor:
88
+ before, before_cursor = history[-1].text, history[-1].cursor
89
+ grouped = (editor.cursor > before_cursor and editor.text[:before_cursor] == before[:before_cursor]
90
+ and editor.text[editor.cursor:] == before[before_cursor:]
91
+ and "\n" not in editor.text[before_cursor:editor.cursor])
92
+ if not grouped and (not history or history[-1] != _snapshot(editor)):
93
+ history += (_snapshot(editor),)
94
+ result = replace(editor, text=text, cursor=cursor, anchor=anchor, undo=history[-HISTORY:], redo=(), reveal=True)
95
+ return result if typing else _checkpoint(result)
96
+
97
+
98
+ def replace_all(editor: Editor, text: str) -> Editor:
99
+ """Swap the whole text (reload from disk, paste code) as one undoable step, keeping the cursor."""
100
+ return change(editor, text, min(editor.cursor, len(text)), min(editor.anchor, len(text)))
101
+
102
+
103
+ def insert(editor: Editor, text: str, typing: bool = False) -> Editor:
104
+ """Insert over the selection. typing=True groups characters into one undo step."""
105
+ text = text.replace("\r\n", "\n").replace("\r", "\n")
106
+ if not text:
107
+ return editor
108
+ lo, hi = selection(editor)
109
+ return change(editor, editor.text[:lo] + text + editor.text[hi:], lo + len(text),
110
+ typing=typing and "\n" not in text)
111
+
112
+
113
+ def cut(editor: Editor) -> tuple[Editor, str]:
114
+ """Remove the selection (or the current line) and return it."""
115
+ lo, hi = copy_range(editor)
116
+ return change(_checkpoint(editor), editor.text[:lo] + editor.text[hi:], lo), editor.text[lo:hi]
117
+
118
+
119
+ def copy_range(editor: Editor) -> tuple[int, int]:
120
+ """The selection, or the whole current line when nothing is selected."""
121
+ lo, hi = selection(editor)
122
+ if lo == hi:
123
+ lo = editor.text.rfind("\n", 0, lo) + 1
124
+ end = editor.text.find("\n", hi)
125
+ hi = len(editor.text) if end < 0 else end + 1
126
+ return lo, hi
127
+
128
+
129
+ def undo(editor: Editor) -> Editor:
130
+ return _history(editor, forward=False)
131
+
132
+
133
+ def redo(editor: Editor) -> Editor:
134
+ return _history(editor, forward=True)
135
+
136
+
137
+ def _history(editor: Editor, forward: bool) -> Editor:
138
+ source = editor.redo if forward else editor.undo
139
+ while source and source[-1].text == editor.text:
140
+ source = source[:-1]
141
+ if not source:
142
+ return editor
143
+ text, cursor = source[-1].text, source[-1].cursor
144
+ other = ((editor.undo if forward else editor.redo) + (_snapshot(editor),))[-HISTORY:]
145
+ stacks = dict(redo=source[:-1], undo=other) if forward else dict(undo=source[:-1], redo=other)
146
+ restored = replace(editor, text=text, cursor=cursor, anchor=cursor, reveal=True, **stacks)
147
+ return _checkpoint(restored) if forward else restored
148
+
149
+
150
+ def _indent(editor: Editor, remove: bool) -> Editor:
151
+ """Tab: indent the selected lines (or insert spaces). Shift+Tab: unindent them."""
152
+ lo, hi = selection(editor)
153
+ start = editor.text.rfind("\n", 0, lo) + 1
154
+ if lo == hi and not remove:
155
+ return insert(editor, " " * (4 - len(editor.text[start:lo].expandtabs(4)) % 4))
156
+ lines = editor.text[start:max(lo, hi)].split("\n")
157
+ count = len(lines) - (hi > lo and editor.text[hi - 1:hi] == "\n")
158
+ edits = [] # (position, deleted characters, added text)
159
+ position = start
160
+ for _ in range(count):
161
+ stop = editor.text.find("\n", position)
162
+ line = editor.text[position:stop if stop >= 0 else len(editor.text)]
163
+ if remove:
164
+ deleted = 1 if line.startswith("\t") else min(4, len(line) - len(line.lstrip(" ")))
165
+ edits.append((position, deleted, ""))
166
+ else:
167
+ edits.append((position, 0, " "))
168
+ position += len(line) + 1
169
+ text = editor.text
170
+ for position, deleted, added in reversed(edits):
171
+ text = text[:position] + added + text[position + deleted:]
172
+
173
+ def moved(point: int) -> int:
174
+ return point + sum(len(added) - min(deleted, max(0, point - position))
175
+ for position, deleted, added in edits if position <= point)
176
+
177
+ return change(editor, text, moved(editor.cursor), moved(editor.anchor))
178
+
179
+
180
+ def _char_class(char: str) -> int:
181
+ """0 space, 1 word character, 2 anything else: words and punctuation runs are units."""
182
+ return 0 if char.isspace() else 1 if char.isalnum() or char == "_" else 2
183
+
184
+
185
+ def _word(text: str, position: int, direction: int) -> int:
186
+ """Next word boundary to the left (-1) or right (+1)."""
187
+ if direction < 0:
188
+ while position > 0 and text[position - 1].isspace():
189
+ position -= 1
190
+ if position:
191
+ kind = _char_class(text[position - 1])
192
+ while position > 0 and _char_class(text[position - 1]) == kind:
193
+ position -= 1
194
+ return position
195
+ if position < len(text) and not text[position].isspace():
196
+ kind = _char_class(text[position])
197
+ while position < len(text) and _char_class(text[position]) == kind:
198
+ position += 1
199
+ while position < len(text) and text[position].isspace():
200
+ position += 1
201
+ return position
202
+
203
+
204
+ # --- keys and mouse ------------------------------------------------------------------------
205
+
206
+ def key(editor: Editor, name: str, shift: bool = False, page_lines: int = 20) -> Editor:
207
+ """An editing or movement key. `shift` extends the selection when moving."""
208
+ text = editor.text
209
+ lo, hi = selection(editor)
210
+ row, col = line_col(text, editor.cursor)
211
+ start = editor.cursor - col
212
+ end = text.find("\n", editor.cursor)
213
+ end = len(text) if end < 0 else end
214
+ editor = _checkpoint(editor)
215
+ if name == "select_all":
216
+ return replace(editor, anchor=0, cursor=len(text), reveal=True)
217
+ if name in ("tab", "untab"):
218
+ return _indent(editor, remove=name == "untab")
219
+ if name == "enter":
220
+ before = text[text.rfind("\n", 0, lo) + 1:lo]
221
+ indentation = before[:len(before) - len(before.lstrip(" \t"))]
222
+ return insert(editor, "\n" + indentation + (" " if before.rstrip().endswith(":") else ""))
223
+ if name in ("backspace", "delete", "word_backspace", "word_delete"):
224
+ if lo == hi:
225
+ if name == "word_backspace":
226
+ lo = _word(text, lo, -1)
227
+ elif name == "word_delete":
228
+ hi = _word(text, hi, 1)
229
+ elif name == "delete":
230
+ hi = min(len(text), hi + 1)
231
+ elif text[start:lo] and not text[start:lo].strip(" \t"):
232
+ lo = _unindent_point(text, start, lo) # backspace in indentation: back to the tab stop
233
+ else:
234
+ lo = max(0, lo - 1)
235
+ return change(editor, text[:lo] + text[hi:], lo)
236
+ point = editor.cursor
237
+ if name in ("left", "right"):
238
+ if lo != hi and not shift:
239
+ point = lo if name == "left" else hi
240
+ else:
241
+ point += -1 if name == "left" else 1
242
+ elif name in ("word_left", "word_right"):
243
+ point = _word(text, point, -1 if name == "word_left" else 1)
244
+ elif name in ("up", "down", "page_up", "page_down"):
245
+ lines = page_lines if name.startswith("page") else 1
246
+ point = offset(text, row + (-lines if name in ("up", "page_up") else lines), col)
247
+ elif name == "home":
248
+ first = start + len(text[start:end]) - len(text[start:end].lstrip(" \t"))
249
+ point = start if point == first else first
250
+ elif name == "end":
251
+ point = end
252
+ elif name == "doc_start":
253
+ point = 0
254
+ elif name == "doc_end":
255
+ point = len(text)
256
+ else:
257
+ raise ValueError(f"Unknown editor key: {name}")
258
+ point = max(0, min(point, len(text)))
259
+ return replace(editor, cursor=point, anchor=editor.anchor if shift else point, reveal=True)
260
+
261
+
262
+ def _unindent_point(text: str, start: int, point: int) -> int:
263
+ target = (len(text[start:point].expandtabs(4)) - 1) // 4 * 4
264
+ column = 0
265
+ for index, char in enumerate(text[start:point]):
266
+ column += 4 - column % 4 if char == "\t" else 1
267
+ if column > target:
268
+ return start + index
269
+ return start
270
+
271
+
272
+ def click(editor: Editor, line: int, column: int, extend: bool = False, clicks: int = 1) -> Editor:
273
+ """Mouse press or drag. extend keeps the anchor; 2 clicks select a word, 3 a line."""
274
+ editor = _checkpoint(editor)
275
+ text = editor.text
276
+ point = offset(text, line, column)
277
+ anchor = editor.anchor if extend else point
278
+ if clicks >= 3:
279
+ anchor = text.rfind("\n", 0, point) + 1
280
+ end = text.find("\n", point)
281
+ point = len(text) if end < 0 else end + 1
282
+ elif clicks == 2 and text:
283
+ probe = min(point, len(text) - 1)
284
+ kind = _char_class(text[probe])
285
+ anchor, point = probe, probe + 1
286
+ while anchor > 0 and text[anchor - 1] != "\n" and _char_class(text[anchor - 1]) == kind:
287
+ anchor -= 1
288
+ while point < len(text) and text[point] != "\n" and _char_class(text[point]) == kind:
289
+ point += 1
290
+ return replace(editor, cursor=point, anchor=anchor, reveal=False)