pgwidgets-python 0.3.3__py3-none-any.whl → 0.3.5__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.
- pgwidgets/async_/application.py +8 -0
- pgwidgets/extras/file_browser.py +4 -0
- pgwidgets/method_types.py +30 -4
- pgwidgets/sync/application.py +20 -0
- pgwidgets/sync/text_source.py +200 -0
- pgwidgets/sync/widget.py +5 -0
- pgwidgets/text_model.py +792 -0
- {pgwidgets_python-0.3.3.dist-info → pgwidgets_python-0.3.5.dist-info}/METADATA +2 -2
- {pgwidgets_python-0.3.3.dist-info → pgwidgets_python-0.3.5.dist-info}/RECORD +12 -12
- {pgwidgets_python-0.3.3.dist-info → pgwidgets_python-0.3.5.dist-info}/WHEEL +1 -1
- pgwidgets_python-0.3.3.dist-info/scm_file_list.json +0 -68
- pgwidgets_python-0.3.3.dist-info/scm_version.json +0 -8
- {pgwidgets_python-0.3.3.dist-info → pgwidgets_python-0.3.5.dist-info}/licenses/LICENSE.md +0 -0
- {pgwidgets_python-0.3.3.dist-info → pgwidgets_python-0.3.5.dist-info}/top_level.txt +0 -0
pgwidgets/async_/application.py
CHANGED
|
@@ -434,6 +434,14 @@ class Session:
|
|
|
434
434
|
widget._state[spec[0]] = tuple(args)
|
|
435
435
|
else:
|
|
436
436
|
widget._state[spec] = args[0]
|
|
437
|
+
# ComboBox: a value committed with Enter in the browser may be
|
|
438
|
+
# a new entry the browser appended to its list; mirror that
|
|
439
|
+
# append so our _items (and thus get_index) stays consistent.
|
|
440
|
+
if (widget._js_class == "ComboBox" and action == "activated"
|
|
441
|
+
and len(args) >= 2):
|
|
442
|
+
items = widget._state.setdefault("_items", [])
|
|
443
|
+
if args[1] not in items:
|
|
444
|
+
items.append(args[1])
|
|
437
445
|
|
|
438
446
|
# Dialog autoclose: the JS side auto-hides when a button is
|
|
439
447
|
# clicked. Sync that to Python so reconstruction doesn't
|
pgwidgets/extras/file_browser.py
CHANGED
|
@@ -209,6 +209,10 @@ class FileBrowser(Callbacks):
|
|
|
209
209
|
|
|
210
210
|
# ── Public API ──────────────────────────────────────────────
|
|
211
211
|
|
|
212
|
+
def set_title(self, title):
|
|
213
|
+
"""Set the dialog's title bar text."""
|
|
214
|
+
self._dialog.set_title(title)
|
|
215
|
+
|
|
212
216
|
def set_mode(self, mode):
|
|
213
217
|
"""Set the selection mode: 'file', 'files', 'directory', 'save'."""
|
|
214
218
|
self._mode = mode
|
pgwidgets/method_types.py
CHANGED
|
@@ -62,7 +62,8 @@ JS_ONLY_METHODS = {
|
|
|
62
62
|
# MDIWidget
|
|
63
63
|
"get_subwin", "get_subwindows", "get_configuration",
|
|
64
64
|
"get_child_size", "get_child_position",
|
|
65
|
-
# Button icon
|
|
65
|
+
# Button icon: set_icon stores state (so it reconstructs); get_icon
|
|
66
|
+
# reads the live browser value, so it round-trips.
|
|
66
67
|
"get_icon",
|
|
67
68
|
}
|
|
68
69
|
|
|
@@ -107,7 +108,7 @@ ACTION_METHODS = {
|
|
|
107
108
|
# Timer
|
|
108
109
|
"start", "cancel", "set", "cond_set",
|
|
109
110
|
# Table/Tree row-level modifications (tracked via bulk set_data)
|
|
110
|
-
"add_item", "remove_item", "update_tree", "remove_items",
|
|
111
|
+
"add_item", "remove_item", "update_tree", "remove_items", "delete_tree",
|
|
111
112
|
"insert_row", "append_row", "delete_row",
|
|
112
113
|
"insert_column", "append_column", "delete_column",
|
|
113
114
|
"set_cell",
|
|
@@ -121,8 +122,6 @@ ACTION_METHODS = {
|
|
|
121
122
|
"set_minimum_size",
|
|
122
123
|
# TreeView per-column
|
|
123
124
|
"set_column_width", "set_column_editable",
|
|
124
|
-
# TextSource per-line
|
|
125
|
-
"set_icon",
|
|
126
125
|
}
|
|
127
126
|
|
|
128
127
|
# Setter methods that DON'T follow the set_* naming convention.
|
|
@@ -420,7 +419,34 @@ BINARY_STATE_KEYS = {
|
|
|
420
419
|
}
|
|
421
420
|
|
|
422
421
|
|
|
422
|
+
def _combobox_get_index(self):
|
|
423
|
+
"""Index of the item matching the current text, or -1 when the text is
|
|
424
|
+
not one of the offerings (e.g. a value typed into an editable combo box
|
|
425
|
+
but not yet committed with Enter). Mirrors the JS get_index(), which
|
|
426
|
+
is items.indexOf(input value), and stays consistent with get_text()
|
|
427
|
+
(the cached entry text, kept live by the 'modified' callback)."""
|
|
428
|
+
items = self._state.get("_items", [])
|
|
429
|
+
try:
|
|
430
|
+
return items.index(self._state.get("text", ""))
|
|
431
|
+
except ValueError:
|
|
432
|
+
return -1
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _combobox_set_index(self, idx):
|
|
436
|
+
"""Select the item at *idx*, keeping the cached text consistent with
|
|
437
|
+
the index so get_text() agrees with get_index() after a programmatic
|
|
438
|
+
change (the generic setter would leave 'text' stale)."""
|
|
439
|
+
items = self._state.get("_items", [])
|
|
440
|
+
if 0 <= idx < len(items):
|
|
441
|
+
self._state["text"] = items[idx]
|
|
442
|
+
self._state["index"] = idx
|
|
443
|
+
self._user_set_state.add("index")
|
|
444
|
+
return self._call("set_index", idx)
|
|
445
|
+
|
|
446
|
+
|
|
423
447
|
CUSTOM_METHODS = {
|
|
448
|
+
("ComboBox", "get_index"): _combobox_get_index,
|
|
449
|
+
("ComboBox", "set_index"): _combobox_set_index,
|
|
424
450
|
("TabWidget", "index_to_widget"): _index_to_widget,
|
|
425
451
|
("TabWidget", "index_of"): _index_of,
|
|
426
452
|
("StackWidget", "index_to_widget"): _index_to_widget,
|
pgwidgets/sync/application.py
CHANGED
|
@@ -524,6 +524,14 @@ class Session:
|
|
|
524
524
|
widget._state[spec[0]] = tuple(args)
|
|
525
525
|
else:
|
|
526
526
|
widget._state[spec] = args[0]
|
|
527
|
+
# ComboBox: a value committed with Enter in the browser may be
|
|
528
|
+
# a new entry the browser appended to its list; mirror that
|
|
529
|
+
# append so our _items (and thus get_index) stays consistent.
|
|
530
|
+
if (widget._js_class == "ComboBox" and action == "activated"
|
|
531
|
+
and len(args) >= 2):
|
|
532
|
+
items = widget._state.setdefault("_items", [])
|
|
533
|
+
if args[1] not in items:
|
|
534
|
+
items.append(args[1])
|
|
527
535
|
# Dialog autoclose: the JS side auto-hides when a button is
|
|
528
536
|
# clicked. Sync that to Python so reconstruction doesn't
|
|
529
537
|
# re-show a dialog that was autoclosed.
|
|
@@ -1399,6 +1407,18 @@ class Session:
|
|
|
1399
1407
|
# callbacks transferred by _transfer_proxy during step 3.
|
|
1400
1408
|
self._reregister_callbacks(widget)
|
|
1401
1409
|
|
|
1410
|
+
# 6. Widgets with a Python-authoritative model (TextSource) push
|
|
1411
|
+
# their full model state (text, tags, cursor) to the freshly
|
|
1412
|
+
# (re)connected browser. Guard it: a failure here must not abort
|
|
1413
|
+
# reconstruction of the whole widget tree.
|
|
1414
|
+
if hasattr(widget, "_reconstruct_model"):
|
|
1415
|
+
try:
|
|
1416
|
+
widget._reconstruct_model()
|
|
1417
|
+
except Exception:
|
|
1418
|
+
self._logger.error(
|
|
1419
|
+
"TextSource model reconstruction failed for wid=%s",
|
|
1420
|
+
getattr(widget, "_wid", "?"), exc_info=True)
|
|
1421
|
+
|
|
1402
1422
|
def _reregister_callbacks(self, widget):
|
|
1403
1423
|
"""Re-register user callbacks and auto-sync listeners on *widget*.
|
|
1404
1424
|
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Python-authoritative TextSource for the synchronous API.
|
|
2
|
+
|
|
3
|
+
Unlike most widgets (whose state is a handful of scalars), a text buffer
|
|
4
|
+
has rich structure -- content, live position refs, a tag table, cursor and
|
|
5
|
+
selection. We keep the *authoritative* copy of all of that on the Python
|
|
6
|
+
side in a :class:`~pgwidgets.text_model.TextModel`, so the whole API works
|
|
7
|
+
before a browser is ever connected (the UI can be built up headless and
|
|
8
|
+
replayed on connect). The browser is driven as a *view*: model changes are
|
|
9
|
+
pushed as offset-based operations through the render hooks, and are no-ops
|
|
10
|
+
(``_call`` returns None) until a browser attaches, at which point
|
|
11
|
+
``_reconstruct_model`` replays the full state.
|
|
12
|
+
|
|
13
|
+
Refs never cross the wire -- they are ordinary Python ``TextBufferRef``
|
|
14
|
+
objects owned by the local model. (The in-situ / JS-only case, where the
|
|
15
|
+
browser is authoritative, is handled by the separate pgwidgets-js/pyodide
|
|
16
|
+
wrapper, not here.)
|
|
17
|
+
|
|
18
|
+
The class subclasses the *generated* TextSource proxy so it inherits all the
|
|
19
|
+
base-widget methods (show/hide/set_tooltip/get_size/...) and the widget
|
|
20
|
+
option setters (set_editable/set_wrap/scrolling); it overrides only the
|
|
21
|
+
methods that manipulate the text model.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from pgwidgets.sync.widget import build_widget_class
|
|
25
|
+
from pgwidgets.defs import WIDGETS
|
|
26
|
+
from pgwidgets.text_model import TextModel
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
_GeneratedTextSource = build_widget_class("TextSource", WIDGETS["TextSource"])
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class _BrowserTextModel(TextModel):
|
|
33
|
+
"""A TextModel whose render hooks push offset-based operations to the
|
|
34
|
+
browser via the owning widget. When no browser is connected the calls
|
|
35
|
+
are no-ops, so the model is fully usable headless."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, widget):
|
|
38
|
+
self._widget = widget
|
|
39
|
+
super().__init__()
|
|
40
|
+
|
|
41
|
+
def _push(self, method, *args):
|
|
42
|
+
# _call round-trips when a browser is connected and is a no-op
|
|
43
|
+
# returning None otherwise.
|
|
44
|
+
self._widget._call(method, *args)
|
|
45
|
+
|
|
46
|
+
# -- render hooks (see TextModel) --
|
|
47
|
+
def _set_editor_text(self, text):
|
|
48
|
+
self._push("set_text", text)
|
|
49
|
+
|
|
50
|
+
def _replace_editor_range(self, start, end, new_text):
|
|
51
|
+
# Drive the JS buffer by offset; pushUndo=False because the Python
|
|
52
|
+
# model owns undo/redo.
|
|
53
|
+
self._push("_replaceRange", start, end, new_text, {"pushUndo": False})
|
|
54
|
+
|
|
55
|
+
def _apply_all_formats(self, region=None):
|
|
56
|
+
# Push the current tag intervals; the JS side re-renders styling.
|
|
57
|
+
# (Tag *definitions* are pushed by TextSource.create_tag.)
|
|
58
|
+
self._push("_restoreTagIntervals", self._tag_intervals())
|
|
59
|
+
|
|
60
|
+
def _apply_selection_to_editor(self):
|
|
61
|
+
if self._sel_start == self._sel_end:
|
|
62
|
+
self._push("_setCursorOffset", self._cursor)
|
|
63
|
+
else:
|
|
64
|
+
self._push("_setSelectionOffsets", self._sel_start, self._sel_end)
|
|
65
|
+
|
|
66
|
+
def _refresh_icon_gutter(self):
|
|
67
|
+
# TODO: render ref-anchored gutter icons in the browser.
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
def _mark_clean(self):
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
def _tag_intervals(self):
|
|
74
|
+
return [{"name": t["name"], "start": t["start"], "end": t["end"]}
|
|
75
|
+
for t in self._tags]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# Model methods delegated verbatim to self._model. Refs in/out are the
|
|
79
|
+
# model's own TextBufferRefs; nothing here touches the wire. These override
|
|
80
|
+
# the generated round-trip proxies of the same name.
|
|
81
|
+
_DELEGATED = (
|
|
82
|
+
# content
|
|
83
|
+
"get_length", "get_text", "get_text_range", "clear", "set_text",
|
|
84
|
+
"insert_text", "delete_range",
|
|
85
|
+
# refs
|
|
86
|
+
"create_ref", "remove_ref", "create_named_ref", "get_named_ref",
|
|
87
|
+
"remove_named_ref", "get_ref_start", "get_ref_end", "get_ref_bounds",
|
|
88
|
+
"get_ref_line_start", "get_ref_line_end", "set_icon",
|
|
89
|
+
# tags (create_tag handled explicitly to also push the tag definition)
|
|
90
|
+
"remove_tag_def", "has_tag", "apply_tag", "remove_tag",
|
|
91
|
+
"get_tags_at", "get_tags_range", "get_tag_region", "get_tag_regions",
|
|
92
|
+
# find / replace
|
|
93
|
+
"find", "find_all", "replace",
|
|
94
|
+
# cursor / selection
|
|
95
|
+
"get_cursor", "set_cursor", "has_selection", "get_selection_range",
|
|
96
|
+
"get_selection_bounds", "set_selection_range",
|
|
97
|
+
# undo / redo / dirty flag
|
|
98
|
+
"can_undo", "can_redo", "undo", "redo", "get_modified", "mark_clean",
|
|
99
|
+
# misc convenience
|
|
100
|
+
"get_end_lineno", "clear_icons",
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
# Callback actions that are model-authoritative (fire from the Python model)
|
|
104
|
+
# rather than being browser events.
|
|
105
|
+
_MODEL_CALLBACKS = {"changed", "cursor-moved", "cursor_moved"}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class TextSource(_GeneratedTextSource):
|
|
109
|
+
|
|
110
|
+
def __init__(self, session, *args, **kwargs):
|
|
111
|
+
super().__init__(session, *args, **kwargs)
|
|
112
|
+
# The authoritative model. Seed its text from the initial value the
|
|
113
|
+
# generated __init__ recorded (the JS side already has it from the
|
|
114
|
+
# constructor, so no push). Text/tags/cursor are reconstructed by
|
|
115
|
+
# _reconstruct_model(), not by the generic state replay, so drop
|
|
116
|
+
# 'text' from _state.
|
|
117
|
+
self._model = _BrowserTextModel(self)
|
|
118
|
+
text = self._state.get("text")
|
|
119
|
+
self._model._text = "" if text is None else str(text)
|
|
120
|
+
self._state.pop("text", None)
|
|
121
|
+
|
|
122
|
+
def create_tag(self, name, attrs=None, **kwdargs):
|
|
123
|
+
"""Define a display tag locally and push its styling to the browser."""
|
|
124
|
+
merged = {} if attrs is None else dict(attrs)
|
|
125
|
+
merged.update(kwdargs)
|
|
126
|
+
self._model.create_tag(name, merged)
|
|
127
|
+
self._call("create_tag", name, merged)
|
|
128
|
+
|
|
129
|
+
def scroll_to_ref(self, ref):
|
|
130
|
+
"""Scroll the view to a ref, addressed by its current offset."""
|
|
131
|
+
self._call("_scrollToOffset", self._model._offset_of(ref))
|
|
132
|
+
|
|
133
|
+
def scroll_to_lineno(self, lineno):
|
|
134
|
+
"""Scroll so that line ``lineno`` is visible."""
|
|
135
|
+
self._call("_scrollToOffset",
|
|
136
|
+
self._model._offset_of_line_start(lineno))
|
|
137
|
+
|
|
138
|
+
def scroll_to_end(self):
|
|
139
|
+
"""Scroll to the end of the buffer."""
|
|
140
|
+
self._call("_scrollToOffset", self._model.get_length())
|
|
141
|
+
|
|
142
|
+
def show_tooltip(self, text, x, y):
|
|
143
|
+
"""Show a hover tooltip (``text``) near viewport point (x, y), or
|
|
144
|
+
hide it when ``text`` is empty. Normally called from a handler of
|
|
145
|
+
the 'tooltip' callback."""
|
|
146
|
+
self._call("_showTooltip", text, x, y)
|
|
147
|
+
|
|
148
|
+
# -- callbacks: 'changed'/'cursor_moved' are model-authoritative --
|
|
149
|
+
def add_callback(self, action, handler, *extra_args, **extra_kwargs):
|
|
150
|
+
if action in _MODEL_CALLBACKS:
|
|
151
|
+
self._model.add_callback(
|
|
152
|
+
self._model_cb_name(action),
|
|
153
|
+
lambda _m, *a: handler(self, *a, *extra_args, **extra_kwargs))
|
|
154
|
+
return
|
|
155
|
+
super().add_callback(action, handler, *extra_args, **extra_kwargs)
|
|
156
|
+
|
|
157
|
+
def on(self, action, handler, *extra_args, **extra_kwargs):
|
|
158
|
+
if action in _MODEL_CALLBACKS:
|
|
159
|
+
self._model.add_callback(
|
|
160
|
+
self._model_cb_name(action),
|
|
161
|
+
lambda _m, *a: handler(*a, *extra_args, **extra_kwargs))
|
|
162
|
+
return
|
|
163
|
+
super().on(action, handler, *extra_args, **extra_kwargs)
|
|
164
|
+
|
|
165
|
+
@staticmethod
|
|
166
|
+
def _model_cb_name(action):
|
|
167
|
+
return "cursor-moved" if action in ("cursor_moved",
|
|
168
|
+
"cursor-moved") else "changed"
|
|
169
|
+
|
|
170
|
+
# -- reconstruction: push the full model state to a freshly-connected
|
|
171
|
+
# browser (called by Session._reconstruct_widget after the generic
|
|
172
|
+
# create/state/callback replay) --
|
|
173
|
+
def _reconstruct_model(self):
|
|
174
|
+
m = self._model
|
|
175
|
+
self._call("set_text", m.get_text())
|
|
176
|
+
for name, attrs in m._tag_defs.items():
|
|
177
|
+
self._call("create_tag", name, attrs)
|
|
178
|
+
intervals = m._tag_intervals()
|
|
179
|
+
if intervals:
|
|
180
|
+
self._call("_restoreTagIntervals", intervals)
|
|
181
|
+
if m._sel_start != m._sel_end:
|
|
182
|
+
self._call("_setSelectionOffsets", m._sel_start, m._sel_end)
|
|
183
|
+
else:
|
|
184
|
+
self._call("_setCursorOffset", m._cursor)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _install_delegators():
|
|
188
|
+
"""Override the generated round-trip proxies with model-delegating
|
|
189
|
+
methods (defined at class scope so ``hasattr(TextSource, name)`` holds)."""
|
|
190
|
+
for _name in _DELEGATED:
|
|
191
|
+
def _make(n):
|
|
192
|
+
def method(self, *args, **kwargs):
|
|
193
|
+
return getattr(self._model, n)(*args, **kwargs)
|
|
194
|
+
method.__name__ = n
|
|
195
|
+
method.__qualname__ = "TextSource." + n
|
|
196
|
+
return method
|
|
197
|
+
setattr(TextSource, _name, _make(_name))
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
_install_delegators()
|
pgwidgets/sync/widget.py
CHANGED
|
@@ -965,4 +965,9 @@ def build_all_widget_classes():
|
|
|
965
965
|
classes = {}
|
|
966
966
|
for js_class, defn in WIDGETS.items():
|
|
967
967
|
classes[js_class] = build_widget_class(js_class, defn)
|
|
968
|
+
# TextSource is hand-written (Python-authoritative local text model)
|
|
969
|
+
# rather than generated -- see pgwidgets.sync.text_source. Deferred
|
|
970
|
+
# import avoids a circular import at module load.
|
|
971
|
+
from pgwidgets.sync.text_source import TextSource as _TextSource
|
|
972
|
+
classes['TextSource'] = _TextSource
|
|
968
973
|
return classes
|
pgwidgets/text_model.py
ADDED
|
@@ -0,0 +1,792 @@
|
|
|
1
|
+
#
|
|
2
|
+
# text_model.py -- toolkit-neutral text buffer model with tags and refs
|
|
3
|
+
#
|
|
4
|
+
# This is open-source software licensed under a BSD license.
|
|
5
|
+
# Please see the file LICENSE.txt for details.
|
|
6
|
+
#
|
|
7
|
+
"""A backend-neutral text buffer model.
|
|
8
|
+
|
|
9
|
+
``TextModel`` holds the content string, a set of live position references
|
|
10
|
+
(``TextBufferRef``) that follow edits, a named tag table with interval
|
|
11
|
+
bookkeeping, find/replace, and a model-level undo/redo stack. None of it
|
|
12
|
+
depends on a GUI toolkit.
|
|
13
|
+
|
|
14
|
+
A toolkit widget (qt, gtk, ...) subclasses ``TextModel`` and implements the
|
|
15
|
+
small set of *render hooks* to reflect model changes in a native widget:
|
|
16
|
+
|
|
17
|
+
_set_editor_text(text) push the whole text into the widget
|
|
18
|
+
_replace_editor_range(s, e, text) edit just the changed span
|
|
19
|
+
_apply_all_formats(region=None) (re)apply tag styling to a range
|
|
20
|
+
_apply_selection_to_editor() mirror model selection into the widget
|
|
21
|
+
_refresh_icon_gutter() redraw ref-anchored gutter icons
|
|
22
|
+
_mark_clean() clear the widget's "modified" flag
|
|
23
|
+
|
|
24
|
+
The default implementations are no-ops, so a bare ``TextModel`` is usable
|
|
25
|
+
headless (e.g. for tests or a non-visual buffer).
|
|
26
|
+
"""
|
|
27
|
+
import weakref
|
|
28
|
+
|
|
29
|
+
from pgwidgets.callbacks import Callbacks
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class TextBufferRef:
|
|
33
|
+
"""Live reference to a character offset in a text model buffer.
|
|
34
|
+
|
|
35
|
+
The ref follows inserts and deletes performed through the owning buffer.
|
|
36
|
+
Gravity controls how the ref behaves when text is inserted at exactly the
|
|
37
|
+
ref's offset.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, buffer, offset, gravity='right'):
|
|
41
|
+
if gravity not in ('left', 'right'):
|
|
42
|
+
raise ValueError("gravity must be 'left' or 'right'")
|
|
43
|
+
self._buffer = buffer
|
|
44
|
+
self._offset = buffer._clamp_offset(offset)
|
|
45
|
+
self._gravity = gravity
|
|
46
|
+
self._valid = True
|
|
47
|
+
|
|
48
|
+
def get_offset(self):
|
|
49
|
+
return self._offset
|
|
50
|
+
|
|
51
|
+
def get_gravity(self):
|
|
52
|
+
return self._gravity
|
|
53
|
+
|
|
54
|
+
def is_valid(self):
|
|
55
|
+
return self._valid
|
|
56
|
+
|
|
57
|
+
def get_line_column(self):
|
|
58
|
+
self._check_valid()
|
|
59
|
+
text = self._buffer.get_text()
|
|
60
|
+
prefix = text[:self._offset]
|
|
61
|
+
line = prefix.count('\n')
|
|
62
|
+
last_nl = prefix.rfind('\n')
|
|
63
|
+
col = self._offset if last_nl < 0 else self._offset - last_nl - 1
|
|
64
|
+
return (line, col)
|
|
65
|
+
|
|
66
|
+
def get_line(self):
|
|
67
|
+
self._check_valid()
|
|
68
|
+
return self._buffer._line_of_offset(self._offset)
|
|
69
|
+
|
|
70
|
+
def set_offset(self, offset):
|
|
71
|
+
self._check_valid()
|
|
72
|
+
self._set_offset(offset)
|
|
73
|
+
|
|
74
|
+
def set_line(self, lineno):
|
|
75
|
+
self._check_valid()
|
|
76
|
+
self._set_offset(self._buffer._offset_of_line_start(lineno))
|
|
77
|
+
|
|
78
|
+
def to_ref(self, other):
|
|
79
|
+
self._check_valid()
|
|
80
|
+
if not isinstance(other, TextBufferRef):
|
|
81
|
+
raise TypeError("to_ref requires a TextBufferRef")
|
|
82
|
+
if other._buffer is not self._buffer:
|
|
83
|
+
raise ValueError("TextBufferRef belongs to a different buffer")
|
|
84
|
+
if not other._valid:
|
|
85
|
+
raise ValueError("Source TextBufferRef has been invalidated")
|
|
86
|
+
self._set_offset(other._offset)
|
|
87
|
+
|
|
88
|
+
def copy(self):
|
|
89
|
+
self._check_valid()
|
|
90
|
+
return self._buffer.create_ref(self._offset, self._gravity)
|
|
91
|
+
|
|
92
|
+
def to_line_start(self):
|
|
93
|
+
self._check_valid()
|
|
94
|
+
self._set_offset(self._buffer._offset_of_line_start(self.get_line()))
|
|
95
|
+
|
|
96
|
+
def to_line_end(self):
|
|
97
|
+
self._check_valid()
|
|
98
|
+
text = self._buffer.get_text()
|
|
99
|
+
idx = text.find('\n', self._offset)
|
|
100
|
+
if idx < 0:
|
|
101
|
+
idx = len(text)
|
|
102
|
+
self._set_offset(idx)
|
|
103
|
+
|
|
104
|
+
def to_next_line(self):
|
|
105
|
+
self._check_valid()
|
|
106
|
+
text = self._buffer.get_text()
|
|
107
|
+
idx = text.find('\n', self._offset)
|
|
108
|
+
if idx >= 0:
|
|
109
|
+
self._set_offset(idx + 1)
|
|
110
|
+
|
|
111
|
+
def to_prev_line(self):
|
|
112
|
+
self._check_valid()
|
|
113
|
+
line = self.get_line()
|
|
114
|
+
if line > 0:
|
|
115
|
+
self._set_offset(self._buffer._offset_of_line_start(line - 1))
|
|
116
|
+
|
|
117
|
+
def to_next_char(self):
|
|
118
|
+
self._check_valid()
|
|
119
|
+
self._set_offset(self._offset + 1)
|
|
120
|
+
|
|
121
|
+
def to_prev_char(self):
|
|
122
|
+
self._check_valid()
|
|
123
|
+
self._set_offset(self._offset - 1)
|
|
124
|
+
|
|
125
|
+
def _check_valid(self):
|
|
126
|
+
if not self._valid:
|
|
127
|
+
raise ValueError("TextBufferRef has been invalidated")
|
|
128
|
+
|
|
129
|
+
def _set_offset(self, new_offset):
|
|
130
|
+
self._offset = self._buffer._clamp_offset(new_offset)
|
|
131
|
+
self._buffer._refresh_icon_gutter()
|
|
132
|
+
|
|
133
|
+
def _invalidate(self):
|
|
134
|
+
self._valid = False
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class TextModel(Callbacks):
|
|
138
|
+
"""Toolkit-neutral text buffer: content, refs, a tag table, find/replace
|
|
139
|
+
and model-level undo/redo. Subclass and implement the render hooks to
|
|
140
|
+
drive a native widget."""
|
|
141
|
+
|
|
142
|
+
def __init__(self):
|
|
143
|
+
Callbacks.__init__(self)
|
|
144
|
+
|
|
145
|
+
self._text = ''
|
|
146
|
+
self._refs = weakref.WeakSet()
|
|
147
|
+
self._named_refs = {}
|
|
148
|
+
self._tag_defs = {}
|
|
149
|
+
self._tags = []
|
|
150
|
+
self._tag_seq = 0
|
|
151
|
+
self._icon_refs = {}
|
|
152
|
+
self._cursor = 0
|
|
153
|
+
self._sel_start = 0
|
|
154
|
+
self._sel_end = 0
|
|
155
|
+
|
|
156
|
+
# Model-level undo/redo. Each entry records a single _replace_range
|
|
157
|
+
# edit as (start, old_text, new_text, cursor_before, cursor_after) so
|
|
158
|
+
# it can be inverted with refs and tags kept consistent.
|
|
159
|
+
self._undo_stack = []
|
|
160
|
+
self._redo_stack = []
|
|
161
|
+
self._undo_limit = 500
|
|
162
|
+
self._in_undo_redo = False
|
|
163
|
+
# dirty flag: True after any edit, cleared by mark_clean()/set_text()
|
|
164
|
+
self._modified = False
|
|
165
|
+
|
|
166
|
+
for name in ('changed', 'cursor-moved'):
|
|
167
|
+
self.enable_callback(name)
|
|
168
|
+
|
|
169
|
+
# ------------------------------------------------------------------
|
|
170
|
+
# Render hooks -- subclasses override these to reflect model changes in
|
|
171
|
+
# a native widget. Defaults are no-ops so a bare model works headless.
|
|
172
|
+
# ------------------------------------------------------------------
|
|
173
|
+
def _set_editor_text(self, text):
|
|
174
|
+
pass
|
|
175
|
+
|
|
176
|
+
def _replace_editor_range(self, start, end, new_text):
|
|
177
|
+
pass
|
|
178
|
+
|
|
179
|
+
def _apply_all_formats(self, region=None):
|
|
180
|
+
pass
|
|
181
|
+
|
|
182
|
+
def _apply_selection_to_editor(self):
|
|
183
|
+
pass
|
|
184
|
+
|
|
185
|
+
def _refresh_icon_gutter(self):
|
|
186
|
+
pass
|
|
187
|
+
|
|
188
|
+
def _mark_clean(self):
|
|
189
|
+
pass
|
|
190
|
+
|
|
191
|
+
# ------------------------------------------------------------------
|
|
192
|
+
# Content
|
|
193
|
+
# ------------------------------------------------------------------
|
|
194
|
+
def get_length(self):
|
|
195
|
+
return len(self._text)
|
|
196
|
+
|
|
197
|
+
def get_text(self):
|
|
198
|
+
return self._text
|
|
199
|
+
|
|
200
|
+
def get_text_range(self, start_ref, end_ref):
|
|
201
|
+
"""Return the text spanning ``[start_ref, end_ref)``."""
|
|
202
|
+
start = self._offset_of(start_ref)
|
|
203
|
+
end = self._offset_of(end_ref)
|
|
204
|
+
if start > end:
|
|
205
|
+
start, end = end, start
|
|
206
|
+
return self._text[start:end]
|
|
207
|
+
|
|
208
|
+
def clear(self):
|
|
209
|
+
self.set_text('')
|
|
210
|
+
|
|
211
|
+
def set_text(self, text):
|
|
212
|
+
"""Replace the full buffer contents.
|
|
213
|
+
|
|
214
|
+
This is destructive with respect to refs and applied tags: existing
|
|
215
|
+
refs are invalidated and applied tag intervals are cleared.
|
|
216
|
+
"""
|
|
217
|
+
self._text = '' if text is None else str(text)
|
|
218
|
+
self._cursor = 0
|
|
219
|
+
self._sel_start = 0
|
|
220
|
+
self._sel_end = 0
|
|
221
|
+
self._tags = []
|
|
222
|
+
self._undo_stack = []
|
|
223
|
+
self._redo_stack = []
|
|
224
|
+
self._invalidate_all_refs()
|
|
225
|
+
self._named_refs.clear()
|
|
226
|
+
self._icon_refs.clear()
|
|
227
|
+
self._modified = False
|
|
228
|
+
self._set_editor_text(self._text)
|
|
229
|
+
self._apply_all_formats()
|
|
230
|
+
self._refresh_icon_gutter()
|
|
231
|
+
self._mark_clean()
|
|
232
|
+
|
|
233
|
+
def insert_text(self, ref, text, tags=None):
|
|
234
|
+
"""Insert text at ``ref`` and optionally apply tags to that range."""
|
|
235
|
+
if text is None or text == '':
|
|
236
|
+
return
|
|
237
|
+
offset = self._offset_of(ref)
|
|
238
|
+
self._replace_range(offset, offset, str(text), tags=tags)
|
|
239
|
+
|
|
240
|
+
def delete_range(self, start_ref, end_ref):
|
|
241
|
+
"""Delete the text spanning ``[start_ref, end_ref)``."""
|
|
242
|
+
start = self._offset_of(start_ref)
|
|
243
|
+
end = self._offset_of(end_ref)
|
|
244
|
+
if start > end:
|
|
245
|
+
start, end = end, start
|
|
246
|
+
if start == end:
|
|
247
|
+
return
|
|
248
|
+
self._replace_range(start, end, '')
|
|
249
|
+
|
|
250
|
+
# ------------------------------------------------------------------
|
|
251
|
+
# Refs
|
|
252
|
+
# ------------------------------------------------------------------
|
|
253
|
+
def create_ref(self, offset, gravity='right'):
|
|
254
|
+
"""Create a live buffer ref at ``offset``."""
|
|
255
|
+
ref = TextBufferRef(self, offset, gravity=gravity)
|
|
256
|
+
self._refs.add(ref)
|
|
257
|
+
return ref
|
|
258
|
+
|
|
259
|
+
def remove_ref(self, ref):
|
|
260
|
+
"""Invalidate a ref and detach any named/icon bindings that use it."""
|
|
261
|
+
if not isinstance(ref, TextBufferRef):
|
|
262
|
+
return
|
|
263
|
+
if not ref.is_valid():
|
|
264
|
+
return
|
|
265
|
+
for name, named_ref in list(self._named_refs.items()):
|
|
266
|
+
if named_ref is ref:
|
|
267
|
+
del self._named_refs[name]
|
|
268
|
+
if ref in self._icon_refs:
|
|
269
|
+
del self._icon_refs[ref]
|
|
270
|
+
ref._invalidate()
|
|
271
|
+
self._refresh_icon_gutter()
|
|
272
|
+
|
|
273
|
+
def create_named_ref(self, name, offset, gravity='right'):
|
|
274
|
+
"""Create a live ref and bind it to ``name``."""
|
|
275
|
+
existing = self._named_refs.get(name)
|
|
276
|
+
if existing is not None:
|
|
277
|
+
self.remove_ref(existing)
|
|
278
|
+
ref = self.create_ref(offset, gravity=gravity)
|
|
279
|
+
self._named_refs[name] = ref
|
|
280
|
+
return ref
|
|
281
|
+
|
|
282
|
+
def get_named_ref(self, name):
|
|
283
|
+
return self._named_refs.get(name)
|
|
284
|
+
|
|
285
|
+
def remove_named_ref(self, name):
|
|
286
|
+
ref = self._named_refs.pop(name, None)
|
|
287
|
+
if ref is not None:
|
|
288
|
+
self.remove_ref(ref)
|
|
289
|
+
|
|
290
|
+
def get_ref_start(self):
|
|
291
|
+
return self.create_ref(0, 'right')
|
|
292
|
+
|
|
293
|
+
def get_ref_end(self):
|
|
294
|
+
return self.create_ref(len(self._text), 'right')
|
|
295
|
+
|
|
296
|
+
def get_ref_bounds(self):
|
|
297
|
+
return (self.get_ref_start(), self.get_ref_end())
|
|
298
|
+
|
|
299
|
+
def get_ref_line_start(self, lineno):
|
|
300
|
+
return self.create_ref(self._offset_of_line_start(lineno), 'right')
|
|
301
|
+
|
|
302
|
+
def get_ref_line_end(self, lineno):
|
|
303
|
+
start = self._offset_of_line_start(lineno)
|
|
304
|
+
idx = self._text.find('\n', start)
|
|
305
|
+
end = len(self._text) if idx < 0 else idx
|
|
306
|
+
return self.create_ref(end, 'right')
|
|
307
|
+
|
|
308
|
+
def get_end_lineno(self):
|
|
309
|
+
"""Return the 0-based line number of the end of the buffer."""
|
|
310
|
+
return self._line_of_offset(len(self._text))
|
|
311
|
+
|
|
312
|
+
def set_icon(self, ref, image):
|
|
313
|
+
"""Associate an icon with a ref so it follows text movement by line."""
|
|
314
|
+
if not isinstance(ref, TextBufferRef):
|
|
315
|
+
raise TypeError("set_icon requires a TextBufferRef")
|
|
316
|
+
if ref._buffer is not self:
|
|
317
|
+
raise ValueError("TextBufferRef belongs to a different buffer")
|
|
318
|
+
if image is None:
|
|
319
|
+
self._icon_refs.pop(ref, None)
|
|
320
|
+
else:
|
|
321
|
+
self._icon_refs.pop(ref, None)
|
|
322
|
+
self._icon_refs[ref] = image
|
|
323
|
+
self._refresh_icon_gutter()
|
|
324
|
+
|
|
325
|
+
def clear_icons(self):
|
|
326
|
+
"""Remove all ref-anchored gutter icons."""
|
|
327
|
+
self._icon_refs.clear()
|
|
328
|
+
self._refresh_icon_gutter()
|
|
329
|
+
|
|
330
|
+
# ------------------------------------------------------------------
|
|
331
|
+
# Tags
|
|
332
|
+
# ------------------------------------------------------------------
|
|
333
|
+
def create_tag(self, name, attrs=None, **kwdargs):
|
|
334
|
+
"""Define or redefine a named display tag."""
|
|
335
|
+
attrs = {} if attrs is None else dict(attrs)
|
|
336
|
+
attrs.update(kwdargs)
|
|
337
|
+
self._tag_defs[name] = attrs
|
|
338
|
+
# A tag definition only affects rendering where the tag is applied.
|
|
339
|
+
# Defining a brand-new (unapplied) tag needs no reformat, which keeps
|
|
340
|
+
# bulk tag creation (e.g. one tag per AST node) cheap.
|
|
341
|
+
if self.has_tag(name):
|
|
342
|
+
self._apply_all_formats()
|
|
343
|
+
|
|
344
|
+
def remove_tag_def(self, name):
|
|
345
|
+
if name in self._tag_defs:
|
|
346
|
+
del self._tag_defs[name]
|
|
347
|
+
self._tags = [tag for tag in self._tags if tag['name'] != name]
|
|
348
|
+
self._apply_all_formats()
|
|
349
|
+
|
|
350
|
+
def has_tag(self, name):
|
|
351
|
+
return any(tag['name'] == name for tag in self._tags)
|
|
352
|
+
|
|
353
|
+
def apply_tag(self, name, start_ref, end_ref):
|
|
354
|
+
"""Apply a previously-defined tag across a buffer range."""
|
|
355
|
+
if name not in self._tag_defs:
|
|
356
|
+
raise ValueError("Unknown tag: %s" % (name,))
|
|
357
|
+
start = self._offset_of(start_ref)
|
|
358
|
+
end = self._offset_of(end_ref)
|
|
359
|
+
if start > end:
|
|
360
|
+
start, end = end, start
|
|
361
|
+
if start == end:
|
|
362
|
+
return
|
|
363
|
+
self._add_tag_interval(name, start, end)
|
|
364
|
+
self._apply_all_formats(region=(start, end))
|
|
365
|
+
|
|
366
|
+
def remove_tag(self, name, start_ref, end_ref):
|
|
367
|
+
"""Clip a tag out of the given buffer range."""
|
|
368
|
+
start = self._offset_of(start_ref)
|
|
369
|
+
end = self._offset_of(end_ref)
|
|
370
|
+
if start > end:
|
|
371
|
+
start, end = end, start
|
|
372
|
+
next_tags = []
|
|
373
|
+
for tag in self._tags:
|
|
374
|
+
if tag['name'] != name or tag['end'] <= start or tag['start'] >= end:
|
|
375
|
+
next_tags.append(tag)
|
|
376
|
+
continue
|
|
377
|
+
if tag['start'] < start:
|
|
378
|
+
next_tags.append(dict(tag, end=start))
|
|
379
|
+
if tag['end'] > end:
|
|
380
|
+
next_tags.append(dict(tag, start=end))
|
|
381
|
+
self._tags = next_tags
|
|
382
|
+
self._apply_all_formats(region=(start, end))
|
|
383
|
+
|
|
384
|
+
def get_tags_at(self, ref):
|
|
385
|
+
offset = self._offset_of(ref)
|
|
386
|
+
names = []
|
|
387
|
+
seen = set()
|
|
388
|
+
for tag in self._tags:
|
|
389
|
+
if tag['start'] <= offset < tag['end'] and tag['name'] not in seen:
|
|
390
|
+
names.append(tag['name'])
|
|
391
|
+
seen.add(tag['name'])
|
|
392
|
+
return names
|
|
393
|
+
|
|
394
|
+
def get_tags_range(self, start_ref, end_ref):
|
|
395
|
+
start = self._offset_of(start_ref)
|
|
396
|
+
end = self._offset_of(end_ref)
|
|
397
|
+
if start > end:
|
|
398
|
+
start, end = end, start
|
|
399
|
+
names = []
|
|
400
|
+
seen = set()
|
|
401
|
+
for tag in self._tags:
|
|
402
|
+
if tag['end'] <= start or tag['start'] >= end:
|
|
403
|
+
continue
|
|
404
|
+
if tag['name'] not in seen:
|
|
405
|
+
names.append(tag['name'])
|
|
406
|
+
seen.add(tag['name'])
|
|
407
|
+
return names
|
|
408
|
+
|
|
409
|
+
def get_tag_region(self, name):
|
|
410
|
+
"""Return the overall ``(start_ref, end_ref)`` span of a named tag.
|
|
411
|
+
|
|
412
|
+
This mirrors the old GTK ``get_region`` helper: it returns a single
|
|
413
|
+
ref pair covering from the first occurrence of the tag to the last.
|
|
414
|
+
Returns ``None`` if the tag is not applied anywhere.
|
|
415
|
+
"""
|
|
416
|
+
spans = [tag for tag in self._tags if tag['name'] == name]
|
|
417
|
+
if not spans:
|
|
418
|
+
return None
|
|
419
|
+
start = min(tag['start'] for tag in spans)
|
|
420
|
+
end = max(tag['end'] for tag in spans)
|
|
421
|
+
return (self.create_ref(start, 'right'),
|
|
422
|
+
self.create_ref(end, 'right'))
|
|
423
|
+
|
|
424
|
+
def get_tag_regions(self, name):
|
|
425
|
+
"""Return a list of ``(start_ref, end_ref)`` pairs, one per maximal
|
|
426
|
+
contiguous run of the named tag."""
|
|
427
|
+
spans = sorted((tag for tag in self._tags if tag['name'] == name),
|
|
428
|
+
key=lambda tag: tag['start'])
|
|
429
|
+
if not spans:
|
|
430
|
+
return []
|
|
431
|
+
merged = []
|
|
432
|
+
cur_start, cur_end = spans[0]['start'], spans[0]['end']
|
|
433
|
+
for tag in spans[1:]:
|
|
434
|
+
if tag['start'] <= cur_end:
|
|
435
|
+
cur_end = max(cur_end, tag['end'])
|
|
436
|
+
else:
|
|
437
|
+
merged.append((cur_start, cur_end))
|
|
438
|
+
cur_start, cur_end = tag['start'], tag['end']
|
|
439
|
+
merged.append((cur_start, cur_end))
|
|
440
|
+
return [(self.create_ref(s, 'right'), self.create_ref(e, 'right'))
|
|
441
|
+
for s, e in merged]
|
|
442
|
+
|
|
443
|
+
# ------------------------------------------------------------------
|
|
444
|
+
# Find / replace
|
|
445
|
+
# ------------------------------------------------------------------
|
|
446
|
+
def find(self, query, start=None, case_insensitive=False):
|
|
447
|
+
"""Return the first match as ``(start_ref, end_ref)`` or ``None``."""
|
|
448
|
+
match = self._find_offset(query, start=start,
|
|
449
|
+
case_insensitive=case_insensitive)
|
|
450
|
+
if match is None:
|
|
451
|
+
return None
|
|
452
|
+
return (self.create_ref(match[0], 'right'),
|
|
453
|
+
self.create_ref(match[1], 'right'))
|
|
454
|
+
|
|
455
|
+
def find_all(self, query, start=None, case_insensitive=False):
|
|
456
|
+
"""Return all non-overlapping matches as ref pairs."""
|
|
457
|
+
matches = self._find_all_offsets(query, start=start,
|
|
458
|
+
case_insensitive=case_insensitive)
|
|
459
|
+
return [(self.create_ref(start_off, 'right'),
|
|
460
|
+
self.create_ref(end_off, 'right'))
|
|
461
|
+
for start_off, end_off in matches]
|
|
462
|
+
|
|
463
|
+
def replace(self, query, replacement, all=False, start=None,
|
|
464
|
+
case_insensitive=False):
|
|
465
|
+
"""Replace one or all matches and return the replacement count."""
|
|
466
|
+
if not query:
|
|
467
|
+
return 0
|
|
468
|
+
if all:
|
|
469
|
+
matches = self._find_all_offsets(query, start=start,
|
|
470
|
+
case_insensitive=case_insensitive)
|
|
471
|
+
else:
|
|
472
|
+
match = self._find_offset(query, start=start,
|
|
473
|
+
case_insensitive=case_insensitive)
|
|
474
|
+
matches = [] if match is None else [match]
|
|
475
|
+
for start_off, end_off in reversed(matches):
|
|
476
|
+
self._replace_range(start_off, end_off, replacement)
|
|
477
|
+
return len(matches)
|
|
478
|
+
|
|
479
|
+
# ------------------------------------------------------------------
|
|
480
|
+
# Cursor / selection
|
|
481
|
+
# ------------------------------------------------------------------
|
|
482
|
+
def get_cursor(self):
|
|
483
|
+
return self.create_ref(self._cursor, 'right')
|
|
484
|
+
|
|
485
|
+
def set_cursor(self, ref):
|
|
486
|
+
"""Move the editor cursor to ``ref`` and clear any selection."""
|
|
487
|
+
offset = self._offset_of(ref)
|
|
488
|
+
self._cursor = offset
|
|
489
|
+
self._sel_start = offset
|
|
490
|
+
self._sel_end = offset
|
|
491
|
+
self._apply_selection_to_editor()
|
|
492
|
+
|
|
493
|
+
def has_selection(self):
|
|
494
|
+
return self._sel_start != self._sel_end
|
|
495
|
+
|
|
496
|
+
def get_selection_range(self):
|
|
497
|
+
if self._sel_start == self._sel_end:
|
|
498
|
+
return None
|
|
499
|
+
start = min(self._sel_start, self._sel_end)
|
|
500
|
+
end = max(self._sel_start, self._sel_end)
|
|
501
|
+
return (self.create_ref(start, 'right'),
|
|
502
|
+
self.create_ref(end, 'right'))
|
|
503
|
+
|
|
504
|
+
# get_selection_bounds() is the canonical accessor used by page code;
|
|
505
|
+
# it returns a (start_ref, end_ref) pair or None when there is no
|
|
506
|
+
# selection. Callers guard with has_selection().
|
|
507
|
+
get_selection_bounds = get_selection_range
|
|
508
|
+
|
|
509
|
+
def set_selection_range(self, start_ref, end_ref):
|
|
510
|
+
"""Select the text spanning ``[start_ref, end_ref)``."""
|
|
511
|
+
start = self._offset_of(start_ref)
|
|
512
|
+
end = self._offset_of(end_ref)
|
|
513
|
+
self._sel_start = start
|
|
514
|
+
self._sel_end = end
|
|
515
|
+
self._cursor = end
|
|
516
|
+
self._apply_selection_to_editor()
|
|
517
|
+
|
|
518
|
+
# ------------------------------------------------------------------
|
|
519
|
+
# Undo / redo
|
|
520
|
+
# ------------------------------------------------------------------
|
|
521
|
+
def can_undo(self):
|
|
522
|
+
return len(self._undo_stack) > 0
|
|
523
|
+
|
|
524
|
+
def can_redo(self):
|
|
525
|
+
return len(self._redo_stack) > 0
|
|
526
|
+
|
|
527
|
+
def get_modified(self):
|
|
528
|
+
"""True if the buffer has been edited since the last mark_clean()
|
|
529
|
+
(or set_text)."""
|
|
530
|
+
return self._modified
|
|
531
|
+
|
|
532
|
+
def mark_clean(self):
|
|
533
|
+
"""Reset the modified flag (e.g. after saving)."""
|
|
534
|
+
self._modified = False
|
|
535
|
+
self._mark_clean()
|
|
536
|
+
|
|
537
|
+
def undo(self):
|
|
538
|
+
if not self._undo_stack:
|
|
539
|
+
return False
|
|
540
|
+
start, old_text, new_text, cursor_before, cursor_after = \
|
|
541
|
+
self._undo_stack.pop()
|
|
542
|
+
self._in_undo_redo = True
|
|
543
|
+
try:
|
|
544
|
+
self._replace_range(start, start + len(new_text), old_text,
|
|
545
|
+
push_undo=False)
|
|
546
|
+
self._cursor, self._sel_start, self._sel_end = cursor_before
|
|
547
|
+
self._apply_selection_to_editor()
|
|
548
|
+
finally:
|
|
549
|
+
self._in_undo_redo = False
|
|
550
|
+
self._redo_stack.append((start, old_text, new_text, cursor_before,
|
|
551
|
+
cursor_after))
|
|
552
|
+
return True
|
|
553
|
+
|
|
554
|
+
def redo(self):
|
|
555
|
+
if not self._redo_stack:
|
|
556
|
+
return False
|
|
557
|
+
start, old_text, new_text, cursor_before, cursor_after = \
|
|
558
|
+
self._redo_stack.pop()
|
|
559
|
+
self._in_undo_redo = True
|
|
560
|
+
try:
|
|
561
|
+
self._replace_range(start, start + len(old_text), new_text,
|
|
562
|
+
push_undo=False)
|
|
563
|
+
self._cursor, self._sel_start, self._sel_end = cursor_after
|
|
564
|
+
self._apply_selection_to_editor()
|
|
565
|
+
finally:
|
|
566
|
+
self._in_undo_redo = False
|
|
567
|
+
self._undo_stack.append((start, old_text, new_text, cursor_before,
|
|
568
|
+
cursor_after))
|
|
569
|
+
return True
|
|
570
|
+
|
|
571
|
+
# ------------------------------------------------------------------
|
|
572
|
+
# Internal offset / ref / tag bookkeeping (pure)
|
|
573
|
+
# ------------------------------------------------------------------
|
|
574
|
+
def _clamp_offset(self, offset):
|
|
575
|
+
try:
|
|
576
|
+
offset = int(offset)
|
|
577
|
+
except Exception:
|
|
578
|
+
offset = 0
|
|
579
|
+
return max(0, min(len(self._text), offset))
|
|
580
|
+
|
|
581
|
+
def _offset_of(self, ref):
|
|
582
|
+
if not isinstance(ref, TextBufferRef):
|
|
583
|
+
raise TypeError("API requires a TextBufferRef")
|
|
584
|
+
if ref._buffer is not self:
|
|
585
|
+
raise ValueError("TextBufferRef belongs to a different buffer")
|
|
586
|
+
if not ref.is_valid():
|
|
587
|
+
raise ValueError("TextBufferRef has been invalidated")
|
|
588
|
+
return self._clamp_offset(ref._offset)
|
|
589
|
+
|
|
590
|
+
def _line_of_offset(self, offset):
|
|
591
|
+
return self._text[:self._clamp_offset(offset)].count('\n')
|
|
592
|
+
|
|
593
|
+
def _offset_of_line_start(self, lineno):
|
|
594
|
+
if lineno <= 0:
|
|
595
|
+
return 0
|
|
596
|
+
offset = 0
|
|
597
|
+
for _idx in range(lineno):
|
|
598
|
+
nl = self._text.find('\n', offset)
|
|
599
|
+
if nl < 0:
|
|
600
|
+
return len(self._text)
|
|
601
|
+
offset = nl + 1
|
|
602
|
+
return offset
|
|
603
|
+
|
|
604
|
+
def _replace_range(self, start, end, new_text, tags=None,
|
|
605
|
+
selection_after=None, sync_editor=True, push_undo=True):
|
|
606
|
+
"""Replace ``[start, end)`` in the model and synchronize the view.
|
|
607
|
+
|
|
608
|
+
All ref and tag shifting happens here so every edit path shares the
|
|
609
|
+
same semantics. ``sync_editor`` is disabled only when the edit
|
|
610
|
+
originated from the native widget (which already reflects the new
|
|
611
|
+
text). ``push_undo`` is disabled when the edit is itself the result
|
|
612
|
+
of an undo/redo replay.
|
|
613
|
+
"""
|
|
614
|
+
old_text = self._text[start:end]
|
|
615
|
+
if old_text == new_text:
|
|
616
|
+
if selection_after is not None:
|
|
617
|
+
self._cursor, self._sel_start, self._sel_end = selection_after
|
|
618
|
+
return
|
|
619
|
+
|
|
620
|
+
cursor_before = (self._cursor, self._sel_start, self._sel_end)
|
|
621
|
+
|
|
622
|
+
self._text = self._text[:start] + new_text + self._text[end:]
|
|
623
|
+
if end > start:
|
|
624
|
+
self._update_refs_on_delete(start, end)
|
|
625
|
+
self._update_tags_on_delete(start, end)
|
|
626
|
+
if new_text:
|
|
627
|
+
self._update_refs_on_insert(start, len(new_text))
|
|
628
|
+
self._update_tags_on_insert(start, len(new_text))
|
|
629
|
+
if tags:
|
|
630
|
+
for name in tags:
|
|
631
|
+
self._add_tag_interval(name, start, start + len(new_text))
|
|
632
|
+
if selection_after is None:
|
|
633
|
+
new_pos = start + len(new_text)
|
|
634
|
+
self._cursor = new_pos
|
|
635
|
+
self._sel_start = new_pos
|
|
636
|
+
self._sel_end = new_pos
|
|
637
|
+
else:
|
|
638
|
+
self._cursor, self._sel_start, self._sel_end = selection_after
|
|
639
|
+
|
|
640
|
+
if not self._in_undo_redo and push_undo:
|
|
641
|
+
self._undo_stack.append((start, old_text, new_text, cursor_before,
|
|
642
|
+
(self._cursor, self._sel_start, self._sel_end)))
|
|
643
|
+
if len(self._undo_stack) > self._undo_limit:
|
|
644
|
+
self._undo_stack.pop(0)
|
|
645
|
+
self._redo_stack = []
|
|
646
|
+
|
|
647
|
+
if sync_editor:
|
|
648
|
+
# Edit only the changed span in the editor (not the whole
|
|
649
|
+
# document), preserving existing formatting outside it.
|
|
650
|
+
self._replace_editor_range(start, end, new_text)
|
|
651
|
+
self._apply_selection_to_editor()
|
|
652
|
+
# Only the inserted span needs (re)formatting: on an incremental edit
|
|
653
|
+
# the toolkit keeps the formatting of the surrounding text, which has
|
|
654
|
+
# merely shifted. This keeps appends O(edit) rather than O(buffer).
|
|
655
|
+
self._apply_all_formats(region=(start, start + len(new_text)))
|
|
656
|
+
self._refresh_icon_gutter()
|
|
657
|
+
|
|
658
|
+
self._modified = True
|
|
659
|
+
self.make_callback('changed')
|
|
660
|
+
|
|
661
|
+
def _invalidate_all_refs(self):
|
|
662
|
+
for ref in list(self._refs):
|
|
663
|
+
ref._offset = 0
|
|
664
|
+
ref._invalidate()
|
|
665
|
+
|
|
666
|
+
def _update_refs_on_insert(self, offset, amount):
|
|
667
|
+
for ref in list(self._refs):
|
|
668
|
+
if not ref.is_valid():
|
|
669
|
+
continue
|
|
670
|
+
if ref._offset > offset or (
|
|
671
|
+
ref._offset == offset and ref.get_gravity() == 'right'):
|
|
672
|
+
ref._offset += amount
|
|
673
|
+
|
|
674
|
+
def _update_refs_on_delete(self, start, end):
|
|
675
|
+
amount = end - start
|
|
676
|
+
for ref in list(self._refs):
|
|
677
|
+
if not ref.is_valid():
|
|
678
|
+
continue
|
|
679
|
+
if ref._offset <= start:
|
|
680
|
+
continue
|
|
681
|
+
if ref._offset >= end:
|
|
682
|
+
ref._offset -= amount
|
|
683
|
+
else:
|
|
684
|
+
ref._offset = start
|
|
685
|
+
|
|
686
|
+
def _add_tag_interval(self, name, start, end):
|
|
687
|
+
self._tag_seq += 1
|
|
688
|
+
self._tags.append(dict(name=name, start=start, end=end,
|
|
689
|
+
seq=self._tag_seq))
|
|
690
|
+
|
|
691
|
+
def _update_tags_on_insert(self, offset, amount):
|
|
692
|
+
for tag in self._tags:
|
|
693
|
+
if tag['start'] >= offset:
|
|
694
|
+
tag['start'] += amount
|
|
695
|
+
if tag['end'] > offset or (
|
|
696
|
+
tag['end'] == offset and tag['start'] == tag['end']):
|
|
697
|
+
tag['end'] += amount
|
|
698
|
+
if tag['end'] < tag['start']:
|
|
699
|
+
tag['end'] = tag['start']
|
|
700
|
+
|
|
701
|
+
def _update_tags_on_delete(self, start, end):
|
|
702
|
+
amount = end - start
|
|
703
|
+
next_tags = []
|
|
704
|
+
for tag in self._tags:
|
|
705
|
+
tag_start = tag['start']
|
|
706
|
+
tag_end = tag['end']
|
|
707
|
+
if tag_end <= start:
|
|
708
|
+
next_tags.append(tag)
|
|
709
|
+
continue
|
|
710
|
+
if tag_start >= end:
|
|
711
|
+
next_tags.append(dict(tag, start=tag_start - amount,
|
|
712
|
+
end=tag_end - amount))
|
|
713
|
+
continue
|
|
714
|
+
new_start = tag_start if tag_start < start else start
|
|
715
|
+
new_end = tag_end - amount if tag_end > end else start
|
|
716
|
+
if new_end > new_start:
|
|
717
|
+
next_tags.append(dict(tag, start=new_start, end=new_end))
|
|
718
|
+
self._tags = next_tags
|
|
719
|
+
|
|
720
|
+
def _segments_for_range(self, start, end):
|
|
721
|
+
"""Split a range into maximal subranges sharing the same tag stack.
|
|
722
|
+
|
|
723
|
+
Returns a list of ``(seg_start, seg_end, [tag_name, ...])`` with the
|
|
724
|
+
tag names ordered by application sequence (so later tags win)."""
|
|
725
|
+
points = {start, end}
|
|
726
|
+
active = []
|
|
727
|
+
for tag in self._tags:
|
|
728
|
+
if tag['end'] <= start or tag['start'] >= end:
|
|
729
|
+
continue
|
|
730
|
+
active.append(tag)
|
|
731
|
+
points.add(max(tag['start'], start))
|
|
732
|
+
points.add(min(tag['end'], end))
|
|
733
|
+
sorted_points = sorted(points)
|
|
734
|
+
segments = []
|
|
735
|
+
for idx in range(len(sorted_points) - 1):
|
|
736
|
+
seg_start = sorted_points[idx]
|
|
737
|
+
seg_end = sorted_points[idx + 1]
|
|
738
|
+
if seg_start >= seg_end:
|
|
739
|
+
continue
|
|
740
|
+
in_seg = [tag for tag in active
|
|
741
|
+
if tag['start'] <= seg_start and tag['end'] >= seg_end]
|
|
742
|
+
in_seg.sort(key=lambda item: item['seq'])
|
|
743
|
+
segments.append((seg_start, seg_end,
|
|
744
|
+
[tag['name'] for tag in in_seg]))
|
|
745
|
+
return segments
|
|
746
|
+
|
|
747
|
+
def _merged_attrs(self, tag_names):
|
|
748
|
+
"""Merge the attr dicts of the given tags (later tags win)."""
|
|
749
|
+
attrs = {}
|
|
750
|
+
for name in tag_names:
|
|
751
|
+
attrs.update(self._tag_defs.get(name, {}))
|
|
752
|
+
return attrs
|
|
753
|
+
|
|
754
|
+
def _style_list_from_attrs(self, attrs):
|
|
755
|
+
style = []
|
|
756
|
+
if attrs.get('bold'):
|
|
757
|
+
style.append('bold')
|
|
758
|
+
if attrs.get('italic'):
|
|
759
|
+
style.append('italic')
|
|
760
|
+
return style
|
|
761
|
+
|
|
762
|
+
def _find_offset(self, query, start=None, case_insensitive=False):
|
|
763
|
+
if not query:
|
|
764
|
+
return None
|
|
765
|
+
offset = 0 if start is None else self._offset_of(start)
|
|
766
|
+
haystack = self._text
|
|
767
|
+
needle = query
|
|
768
|
+
if case_insensitive:
|
|
769
|
+
haystack = haystack.lower()
|
|
770
|
+
needle = needle.lower()
|
|
771
|
+
idx = haystack.find(needle, offset)
|
|
772
|
+
if idx < 0:
|
|
773
|
+
return None
|
|
774
|
+
return (idx, idx + len(query))
|
|
775
|
+
|
|
776
|
+
def _find_all_offsets(self, query, start=None, case_insensitive=False):
|
|
777
|
+
if not query:
|
|
778
|
+
return []
|
|
779
|
+
offsets = []
|
|
780
|
+
offset = 0 if start is None else self._offset_of(start)
|
|
781
|
+
haystack = self._text
|
|
782
|
+
needle = query
|
|
783
|
+
if case_insensitive:
|
|
784
|
+
haystack = haystack.lower()
|
|
785
|
+
needle = needle.lower()
|
|
786
|
+
while True:
|
|
787
|
+
idx = haystack.find(needle, offset)
|
|
788
|
+
if idx < 0:
|
|
789
|
+
break
|
|
790
|
+
offsets.append((idx, idx + len(query)))
|
|
791
|
+
offset = idx + max(1, len(query))
|
|
792
|
+
return offsets
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pgwidgets-python
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.5
|
|
4
4
|
Summary: Python bindings for the pgwidgets JavaScript widget library
|
|
5
5
|
Author: PGWidgets Developers
|
|
6
6
|
License: BSD-3-Clause
|
|
@@ -17,7 +17,7 @@ Classifier: Topic :: Software Development :: User Interfaces
|
|
|
17
17
|
Requires-Python: >=3.12
|
|
18
18
|
Description-Content-Type: text/markdown
|
|
19
19
|
License-File: LICENSE.md
|
|
20
|
-
Requires-Dist: pgwidgets-js>=0.
|
|
20
|
+
Requires-Dist: pgwidgets-js>=0.3.4
|
|
21
21
|
Requires-Dist: websockets>=12
|
|
22
22
|
Provides-Extra: dev
|
|
23
23
|
Requires-Dist: sphinx; extra == "dev"
|
|
@@ -3,22 +3,22 @@ pgwidgets/_json.py,sha256=o21qywJ6yAldbqxTq3nLwgK9O67r3a8JxhvI_WAJnMY,2184
|
|
|
3
3
|
pgwidgets/buffer.py,sha256=BYj_nb6fuyGsBqUp3_Z1YrzUm4h5VmoWPGKvpTjWrYk,4048
|
|
4
4
|
pgwidgets/callbacks.py,sha256=gA2FnX0N5BmbmOMyEV35yHySgAfVjfAZcZhZbo7j4-c,3314
|
|
5
5
|
pgwidgets/defs.py,sha256=Q8qhvTeansFU1Av6j5ncdwF2xSNHrpXuKErdHWQ3HiA,436
|
|
6
|
-
pgwidgets/method_types.py,sha256=
|
|
6
|
+
pgwidgets/method_types.py,sha256=kfLBghv0nQFKr4ZrF0VC7MGOEU5qvdbW0PCLPhsrFJg,19462
|
|
7
|
+
pgwidgets/text_model.py,sha256=xlSAoh1ikIG9ZYVwx5wHWrohkGkyu7R6Cbs36_xnbMg,29313
|
|
7
8
|
pgwidgets/async_/Widgets.py,sha256=vld2xkvusBBEVbhbezaJsjBRRj3L7c17Up0pOVs8PGo,723
|
|
8
9
|
pgwidgets/async_/__init__.py,sha256=rXB-v9XRrYt1imYuYikhkzIyiRqaYhlTpQei2LHaQ18,564
|
|
9
|
-
pgwidgets/async_/application.py,sha256=
|
|
10
|
+
pgwidgets/async_/application.py,sha256=VndsY-j1nD_MQjCQz6nugww8hQjJVIwsn6nOpp3qBUA,81438
|
|
10
11
|
pgwidgets/async_/widget.py,sha256=gO7eJh9LV3UY7yRLdeml_0gqmHnFB5VU3NWlDURLtfc,38042
|
|
11
12
|
pgwidgets/extras/__init__.py,sha256=AXUmFtnn4RSlp6pJX6z55j-m_29VmfzpYIjQvAy7pO0,325
|
|
12
|
-
pgwidgets/extras/file_browser.py,sha256=
|
|
13
|
+
pgwidgets/extras/file_browser.py,sha256=Lkz_n56_YW7rV_k8pmhgZrwulGsujk9vrzv66zgIgec,17347
|
|
13
14
|
pgwidgets/icons/pgw-python-logo.png,sha256=_Rxicp9EKy2Pzz1HtobxEnntSI-y5TtAVPlIzZIsfYs,2428840
|
|
14
15
|
pgwidgets/sync/Widgets.py,sha256=7SaocMVMzFHhi51pjuEAr47Wez90b_a61kDbiv0jOfM,706
|
|
15
16
|
pgwidgets/sync/__init__.py,sha256=SF5RTAvtu8BbYBWzpiCPipy6DJzNXf6nqk9xdvwxUhQ,542
|
|
16
|
-
pgwidgets/sync/application.py,sha256=
|
|
17
|
-
pgwidgets/sync/
|
|
18
|
-
|
|
19
|
-
pgwidgets_python-0.3.
|
|
20
|
-
pgwidgets_python-0.3.
|
|
21
|
-
pgwidgets_python-0.3.
|
|
22
|
-
pgwidgets_python-0.3.
|
|
23
|
-
pgwidgets_python-0.3.
|
|
24
|
-
pgwidgets_python-0.3.3.dist-info/RECORD,,
|
|
17
|
+
pgwidgets/sync/application.py,sha256=_2VG9h8tJjZZBD3x1oLA216jN8CxAYt0_fECwwcmx5k,97313
|
|
18
|
+
pgwidgets/sync/text_source.py,sha256=Vz8mJsQXLFOAVwZCOrAXSixNfYQHjYZxxAIZjWBCAnY,8341
|
|
19
|
+
pgwidgets/sync/widget.py,sha256=Kw2CQFOaa7rfroKfn0MwNwXT-PjoecqCErav5BEc0UI,38652
|
|
20
|
+
pgwidgets_python-0.3.5.dist-info/licenses/LICENSE.md,sha256=LoM3fMTiMnQuHRCJghdjOtjnCrL8soBpu2PFk24Xvyg,1528
|
|
21
|
+
pgwidgets_python-0.3.5.dist-info/METADATA,sha256=QpifhF0maJe_O5ECaHL4pd-IaPI2Sd2GT5hL8_cDOao,4683
|
|
22
|
+
pgwidgets_python-0.3.5.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
23
|
+
pgwidgets_python-0.3.5.dist-info/top_level.txt,sha256=wwL6fBq0gU-JwzlM6TdduY1qYpu39ysqnnbQT-1bqAs,10
|
|
24
|
+
pgwidgets_python-0.3.5.dist-info/RECORD,,
|
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"files": [
|
|
3
|
-
".flake8",
|
|
4
|
-
"test_pg2.py",
|
|
5
|
-
"README.md",
|
|
6
|
-
"pyproject.toml",
|
|
7
|
-
"LICENSE.md",
|
|
8
|
-
".readthedocs.yaml",
|
|
9
|
-
".gitignore",
|
|
10
|
-
"docs/web-servers.rst",
|
|
11
|
-
"docs/WhatsNew.rst",
|
|
12
|
-
"docs/subclassing.rst",
|
|
13
|
-
"docs/sync.rst",
|
|
14
|
-
"docs/utilities.rst",
|
|
15
|
-
"docs/extras.rst",
|
|
16
|
-
"docs/async.rst",
|
|
17
|
-
"docs/architecture.rst",
|
|
18
|
-
"docs/widgets.rst",
|
|
19
|
-
"docs/conf.py",
|
|
20
|
-
"docs/Makefile",
|
|
21
|
-
"docs/index.rst",
|
|
22
|
-
"docs/callbacks.rst",
|
|
23
|
-
"docs/getting-started.rst",
|
|
24
|
-
"docs/api/async.rst",
|
|
25
|
-
"docs/api/sync.rst",
|
|
26
|
-
"docs/api/index.rst",
|
|
27
|
-
"tests/test_protocol.py",
|
|
28
|
-
"tests/__init__.py",
|
|
29
|
-
"tests/test_stateful.py",
|
|
30
|
-
"tests/test_resolve_kwargs.py",
|
|
31
|
-
"tests/test_async_session.py",
|
|
32
|
-
"tests/test_defs.py",
|
|
33
|
-
"tests/test_fonts.py",
|
|
34
|
-
"tests/test_session.py",
|
|
35
|
-
"tests/test_extras.py",
|
|
36
|
-
"tests/test_widget_classes.py",
|
|
37
|
-
"tests/test_reconstruct.py",
|
|
38
|
-
"pgwidgets/_json.py",
|
|
39
|
-
"pgwidgets/__init__.py",
|
|
40
|
-
"pgwidgets/callbacks.py",
|
|
41
|
-
"pgwidgets/method_types.py",
|
|
42
|
-
"pgwidgets/buffer.py",
|
|
43
|
-
"pgwidgets/defs.py",
|
|
44
|
-
"pgwidgets/sync/Widgets.py",
|
|
45
|
-
"pgwidgets/sync/__init__.py",
|
|
46
|
-
"pgwidgets/sync/application.py",
|
|
47
|
-
"pgwidgets/sync/widget.py",
|
|
48
|
-
"pgwidgets/extras/__init__.py",
|
|
49
|
-
"pgwidgets/extras/file_browser.py",
|
|
50
|
-
"pgwidgets/icons/pgw-python-logo.png",
|
|
51
|
-
"pgwidgets/async_/Widgets.py",
|
|
52
|
-
"pgwidgets/async_/application.py",
|
|
53
|
-
"pgwidgets/async_/__init__.py",
|
|
54
|
-
"pgwidgets/async_/widget.py",
|
|
55
|
-
"examples/demo_sync.py",
|
|
56
|
-
"examples/all_widgets.py",
|
|
57
|
-
"examples/all_widgets_async.py",
|
|
58
|
-
"examples/README.md",
|
|
59
|
-
"examples/demo_treeview.py",
|
|
60
|
-
"examples/demo_async.py",
|
|
61
|
-
"examples/flask-multi-process/server.py",
|
|
62
|
-
"examples/flask-multi-process/README.md",
|
|
63
|
-
"examples/flask-multi-process/nginx.conf",
|
|
64
|
-
"examples/flask-multi-process/gunicorn.conf.py",
|
|
65
|
-
"examples/flask-multi-process/user_app.py",
|
|
66
|
-
".github/workflows/tests.yml"
|
|
67
|
-
]
|
|
68
|
-
}
|
|
File without changes
|
|
File without changes
|