toga-code-editor 0.1.1__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.
- toga_code_editor/__init__.py +16 -0
- toga_code_editor/code_editor.py +169 -0
- toga_code_editor/highlighting.py +199 -0
- toga_code_editor/platforms/__init__.py +0 -0
- toga_code_editor/platforms/android.py +217 -0
- toga_code_editor/platforms/cocoa.py +264 -0
- toga_code_editor/platforms/dummy.py +21 -0
- toga_code_editor/platforms/iOS.py +314 -0
- toga_code_editor-0.1.1.dist-info/METADATA +128 -0
- toga_code_editor-0.1.1.dist-info/RECORD +14 -0
- toga_code_editor-0.1.1.dist-info/WHEEL +5 -0
- toga_code_editor-0.1.1.dist-info/entry_points.txt +11 -0
- toga_code_editor-0.1.1.dist-info/licenses/LICENSE +27 -0
- toga_code_editor-0.1.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from importlib.metadata import version
|
|
2
|
+
|
|
3
|
+
from .code_editor import CodeEditor
|
|
4
|
+
from .highlighting import DEFAULT_THEME, Span, Style, TokenKind, language_for_filename
|
|
5
|
+
|
|
6
|
+
__version__ = version("toga-code-editor")
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"DEFAULT_THEME",
|
|
10
|
+
"CodeEditor",
|
|
11
|
+
"Span",
|
|
12
|
+
"Style",
|
|
13
|
+
"TokenKind",
|
|
14
|
+
"__version__",
|
|
15
|
+
"language_for_filename",
|
|
16
|
+
]
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import warnings
|
|
5
|
+
from functools import cached_property
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import toga
|
|
9
|
+
from toga.fonts import MONOSPACE
|
|
10
|
+
from toga.platform import get_factory
|
|
11
|
+
from toga.widgets.base import StyleT
|
|
12
|
+
from toga.widgets.multilinetextinput import OnChangeHandler
|
|
13
|
+
|
|
14
|
+
from .highlighting import (
|
|
15
|
+
DEFAULT_THEME,
|
|
16
|
+
Highlighter,
|
|
17
|
+
NullHighlighter,
|
|
18
|
+
PygmentsHighlighter,
|
|
19
|
+
Theme,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
REHIGHLIGHT_DELAY = 0.15
|
|
23
|
+
"""Seconds to wait after the last native edit before re-highlighting."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class CodeEditor(toga.MultilineTextInput):
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
id: str | None = None,
|
|
30
|
+
style: StyleT | None = None,
|
|
31
|
+
value: str | None = None,
|
|
32
|
+
readonly: bool = False,
|
|
33
|
+
placeholder: str | None = None,
|
|
34
|
+
on_change: OnChangeHandler | None = None,
|
|
35
|
+
language: str | None = None,
|
|
36
|
+
show_line_numbers: bool = True,
|
|
37
|
+
theme: Theme | None = None,
|
|
38
|
+
**kwargs,
|
|
39
|
+
):
|
|
40
|
+
"""Create a new code editor.
|
|
41
|
+
|
|
42
|
+
:param id: The ID for the widget.
|
|
43
|
+
:param style: A style object. If no style is provided, a default style will
|
|
44
|
+
be applied to the widget.
|
|
45
|
+
:param value: The initial content to display in the widget.
|
|
46
|
+
:param readonly: Can the value of the widget be modified by the user?
|
|
47
|
+
:param placeholder: The content to display as a placeholder when there is no
|
|
48
|
+
user content to display.
|
|
49
|
+
:param on_change: A handler that will be invoked when the value of the widget
|
|
50
|
+
changes.
|
|
51
|
+
:param language: A Pygments lexer alias such as ``"python"``, or ``None`` for
|
|
52
|
+
no highlighting.
|
|
53
|
+
:param show_line_numbers: Whether to show the line-number gutter.
|
|
54
|
+
:param theme: A mapping from :class:`TokenKind` to :class:`Style`, or ``None``
|
|
55
|
+
for the default theme.
|
|
56
|
+
:param kwargs: Initial style properties. Unless a font family is given here or
|
|
57
|
+
on ``style``, the editor uses a monospace font.
|
|
58
|
+
"""
|
|
59
|
+
if "font_family" not in kwargs and (
|
|
60
|
+
style is None or "font_family" not in style
|
|
61
|
+
):
|
|
62
|
+
kwargs["font_family"] = MONOSPACE
|
|
63
|
+
|
|
64
|
+
# State the inherited value setter needs before super().__init__() runs.
|
|
65
|
+
self._highlighter: Highlighter = NullHighlighter()
|
|
66
|
+
self._language: str | None = None
|
|
67
|
+
self._theme: Theme = DEFAULT_THEME
|
|
68
|
+
self._show_line_numbers = True
|
|
69
|
+
self._pending_rehighlight = None
|
|
70
|
+
|
|
71
|
+
super().__init__(
|
|
72
|
+
id=id,
|
|
73
|
+
style=style,
|
|
74
|
+
value=value,
|
|
75
|
+
readonly=readonly,
|
|
76
|
+
placeholder=placeholder,
|
|
77
|
+
on_change=on_change,
|
|
78
|
+
**kwargs,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
self.theme = theme
|
|
82
|
+
self.language = language
|
|
83
|
+
self.show_line_numbers = show_line_numbers
|
|
84
|
+
|
|
85
|
+
@cached_property
|
|
86
|
+
def factory(self):
|
|
87
|
+
# This is an official BeeWare external widget, so its interface group uses the
|
|
88
|
+
# "toga_" prefix. Toga currently warns about any "toga_" interface it does not
|
|
89
|
+
# know. Until Toga provides a way to register external widgets, swallow that
|
|
90
|
+
# one warning here rather than letting every app print it at startup.
|
|
91
|
+
with warnings.catch_warnings():
|
|
92
|
+
warnings.filterwarnings(
|
|
93
|
+
"ignore",
|
|
94
|
+
message="Unrecognized official Toga interface",
|
|
95
|
+
category=RuntimeWarning,
|
|
96
|
+
)
|
|
97
|
+
return get_factory("toga_code_editor")
|
|
98
|
+
|
|
99
|
+
def _create(self) -> Any:
|
|
100
|
+
return self.factory.CodeEditor(interface=self)
|
|
101
|
+
|
|
102
|
+
@toga.MultilineTextInput.value.setter
|
|
103
|
+
def value(self, value: object) -> None:
|
|
104
|
+
toga.MultilineTextInput.value.fset(self, value)
|
|
105
|
+
self._rehighlight()
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def language(self) -> str | None:
|
|
109
|
+
"""The Pygments lexer alias used for highlighting, or ``None`` for none.
|
|
110
|
+
|
|
111
|
+
Setting an alias Pygments does not know raises :exc:`ValueError` and leaves
|
|
112
|
+
the previous language in place.
|
|
113
|
+
"""
|
|
114
|
+
return self._language
|
|
115
|
+
|
|
116
|
+
@language.setter
|
|
117
|
+
def language(self, value: str | None) -> None:
|
|
118
|
+
highlighter = NullHighlighter() if value is None else PygmentsHighlighter(value)
|
|
119
|
+
self._language = value
|
|
120
|
+
self._highlighter = highlighter
|
|
121
|
+
self._rehighlight()
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def theme(self) -> Theme:
|
|
125
|
+
"""The mapping from token kind to style; ``None`` restores the default."""
|
|
126
|
+
return self._theme
|
|
127
|
+
|
|
128
|
+
@theme.setter
|
|
129
|
+
def theme(self, value: Theme | None) -> None:
|
|
130
|
+
self._theme = DEFAULT_THEME if value is None else value
|
|
131
|
+
self._impl.set_theme(self._theme)
|
|
132
|
+
self._rehighlight()
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def show_line_numbers(self) -> bool:
|
|
136
|
+
"""Whether the line-number gutter is shown."""
|
|
137
|
+
return self._show_line_numbers
|
|
138
|
+
|
|
139
|
+
@show_line_numbers.setter
|
|
140
|
+
def show_line_numbers(self, value: object) -> None:
|
|
141
|
+
self._show_line_numbers = bool(value)
|
|
142
|
+
self._impl.set_show_line_numbers(self._show_line_numbers)
|
|
143
|
+
|
|
144
|
+
def _rehighlight(self) -> None:
|
|
145
|
+
self._cancel_pending_rehighlight()
|
|
146
|
+
self._impl.set_highlights(self._highlighter.highlight(self.value))
|
|
147
|
+
|
|
148
|
+
def _schedule_rehighlight(self) -> None:
|
|
149
|
+
"""Called by the backend when the user edits the text.
|
|
150
|
+
|
|
151
|
+
Re-lexing on every keystroke would be wasteful, so wait for a short pause.
|
|
152
|
+
This is a task rather than a bare ``call_later`` because Toga's Android event
|
|
153
|
+
loop only arms its next wakeup for work scheduled through ``call_soon``; a
|
|
154
|
+
timer added from a native callback would otherwise never fire.
|
|
155
|
+
"""
|
|
156
|
+
self._cancel_pending_rehighlight()
|
|
157
|
+
self._pending_rehighlight = toga.App.app.loop.create_task(
|
|
158
|
+
self._rehighlight_after_delay()
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
async def _rehighlight_after_delay(self) -> None:
|
|
162
|
+
await asyncio.sleep(REHIGHLIGHT_DELAY)
|
|
163
|
+
self._pending_rehighlight = None
|
|
164
|
+
self._rehighlight()
|
|
165
|
+
|
|
166
|
+
def _cancel_pending_rehighlight(self) -> None:
|
|
167
|
+
if self._pending_rehighlight is not None:
|
|
168
|
+
self._pending_rehighlight.cancel()
|
|
169
|
+
self._pending_rehighlight = None
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable, Mapping
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
from os import PathLike
|
|
7
|
+
from typing import Protocol
|
|
8
|
+
|
|
9
|
+
from pygments.lexers import find_lexer_class_for_filename, get_lexer_by_name
|
|
10
|
+
from pygments.token import (
|
|
11
|
+
Comment,
|
|
12
|
+
Keyword,
|
|
13
|
+
Name,
|
|
14
|
+
Number,
|
|
15
|
+
Operator,
|
|
16
|
+
Punctuation,
|
|
17
|
+
String,
|
|
18
|
+
)
|
|
19
|
+
from pygments.util import ClassNotFound
|
|
20
|
+
from toga.colors import Color
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TokenKind(StrEnum):
|
|
24
|
+
"""The flat vocabulary of token kinds a theme can style."""
|
|
25
|
+
|
|
26
|
+
TEXT = "text"
|
|
27
|
+
KEYWORD = "keyword"
|
|
28
|
+
BUILTIN = "builtin"
|
|
29
|
+
DEFINITION = "definition"
|
|
30
|
+
DECORATOR = "decorator"
|
|
31
|
+
STRING = "string"
|
|
32
|
+
NUMBER = "number"
|
|
33
|
+
COMMENT = "comment"
|
|
34
|
+
OPERATOR = "operator"
|
|
35
|
+
PUNCTUATION = "punctuation"
|
|
36
|
+
TAG = "tag"
|
|
37
|
+
ATTRIBUTE = "attribute"
|
|
38
|
+
VARIABLE = "variable"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class Span:
|
|
43
|
+
"""A run of text with one token kind.
|
|
44
|
+
|
|
45
|
+
Offsets are Python string indices: ``start`` inclusive, ``end`` exclusive.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
start: int
|
|
49
|
+
end: int
|
|
50
|
+
kind: TokenKind
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class Style:
|
|
55
|
+
"""How a token kind is drawn. ``color`` accepts any Toga color value."""
|
|
56
|
+
|
|
57
|
+
color: Color | str
|
|
58
|
+
bold: bool = False
|
|
59
|
+
italic: bool = False
|
|
60
|
+
|
|
61
|
+
def __post_init__(self):
|
|
62
|
+
object.__setattr__(self, "color", Color.parse(self.color))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
Theme = Mapping[TokenKind, Style]
|
|
66
|
+
"""A mapping from token kind to style. Kinds absent from the theme are left unstyled."""
|
|
67
|
+
|
|
68
|
+
DEFAULT_THEME: Theme = {
|
|
69
|
+
TokenKind.KEYWORD: Style("#a626a4"),
|
|
70
|
+
TokenKind.BUILTIN: Style("#0184bc"),
|
|
71
|
+
TokenKind.DEFINITION: Style("#4078f2", bold=True),
|
|
72
|
+
TokenKind.DECORATOR: Style("#986801"),
|
|
73
|
+
TokenKind.STRING: Style("#50a14f"),
|
|
74
|
+
TokenKind.NUMBER: Style("#986801"),
|
|
75
|
+
TokenKind.COMMENT: Style("#8a8a8a", italic=True),
|
|
76
|
+
TokenKind.TAG: Style("#e45649"),
|
|
77
|
+
TokenKind.ATTRIBUTE: Style("#986801"),
|
|
78
|
+
TokenKind.VARIABLE: Style("#e45649"),
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
# Ordered most-specific first. The first row whose Pygments type contains the token
|
|
82
|
+
# wins, so Operator.Word must precede Operator, and the Name.* rows precede nothing
|
|
83
|
+
# more general because bare Name falls through to TEXT.
|
|
84
|
+
_TOKEN_TABLE = [
|
|
85
|
+
(Comment, TokenKind.COMMENT),
|
|
86
|
+
(String, TokenKind.STRING),
|
|
87
|
+
(Number, TokenKind.NUMBER),
|
|
88
|
+
(Keyword, TokenKind.KEYWORD),
|
|
89
|
+
(Operator.Word, TokenKind.KEYWORD),
|
|
90
|
+
(Operator, TokenKind.OPERATOR),
|
|
91
|
+
(Punctuation, TokenKind.PUNCTUATION),
|
|
92
|
+
(Name.Builtin, TokenKind.BUILTIN),
|
|
93
|
+
(Name.Function, TokenKind.DEFINITION),
|
|
94
|
+
(Name.Class, TokenKind.DEFINITION),
|
|
95
|
+
(Name.Decorator, TokenKind.DECORATOR),
|
|
96
|
+
(Name.Tag, TokenKind.TAG),
|
|
97
|
+
(Name.Attribute, TokenKind.ATTRIBUTE),
|
|
98
|
+
(Name.Variable, TokenKind.VARIABLE),
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def token_kind(token_type) -> TokenKind:
|
|
103
|
+
"""Map a Pygments token type onto the flat vocabulary."""
|
|
104
|
+
for pygments_type, kind in _TOKEN_TABLE:
|
|
105
|
+
if token_type in pygments_type:
|
|
106
|
+
return kind
|
|
107
|
+
return TokenKind.TEXT
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def merge_spans(spans: Iterable[Span]) -> list[Span]:
|
|
111
|
+
"""Drop TEXT spans and merge adjacent spans of the same kind."""
|
|
112
|
+
merged: list[Span] = []
|
|
113
|
+
for span in spans:
|
|
114
|
+
if span.kind is TokenKind.TEXT:
|
|
115
|
+
continue
|
|
116
|
+
if merged and merged[-1].kind is span.kind and merged[-1].end == span.start:
|
|
117
|
+
merged[-1] = Span(merged[-1].start, span.end, span.kind)
|
|
118
|
+
else:
|
|
119
|
+
merged.append(span)
|
|
120
|
+
return merged
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class Highlighter(Protocol):
|
|
124
|
+
def highlight(self, text: str) -> list[Span]: ...
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class NullHighlighter:
|
|
128
|
+
"""The highlighter used when no language is set."""
|
|
129
|
+
|
|
130
|
+
def highlight(self, text: str) -> list[Span]:
|
|
131
|
+
return []
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class PygmentsHighlighter:
|
|
135
|
+
"""Tokenize text with a Pygments lexer.
|
|
136
|
+
|
|
137
|
+
:param language: A Pygments lexer alias, such as ``"python"``.
|
|
138
|
+
:raises ValueError: If Pygments has no lexer for the alias.
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
def __init__(self, language: str):
|
|
142
|
+
try:
|
|
143
|
+
# Pygments trims and appends newlines by default, which shifts every
|
|
144
|
+
# offset. Turn all of that off.
|
|
145
|
+
self.lexer = get_lexer_by_name(
|
|
146
|
+
language, stripnl=False, stripall=False, ensurenl=False
|
|
147
|
+
)
|
|
148
|
+
except ClassNotFound:
|
|
149
|
+
raise ValueError(f"Unknown language {language!r}") from None
|
|
150
|
+
|
|
151
|
+
def highlight(self, text: str) -> list[Span]:
|
|
152
|
+
return merge_spans(
|
|
153
|
+
Span(index, index + len(value), token_kind(token_type))
|
|
154
|
+
for index, token_type, value in self.lexer.get_tokens_unprocessed(text)
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def language_for_filename(path: str | PathLike) -> str | None:
|
|
159
|
+
"""Return the Pygments lexer alias for a filename, or ``None`` if there is none.
|
|
160
|
+
|
|
161
|
+
The whole filename is matched, so names such as ``Makefile`` resolve.
|
|
162
|
+
"""
|
|
163
|
+
lexer_class = find_lexer_class_for_filename(str(path))
|
|
164
|
+
return None if lexer_class is None else lexer_class.aliases[0]
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def to_utf16_spans(text: str, spans: list[Span]) -> list[Span]:
|
|
168
|
+
"""Convert span offsets from code points to UTF-16 code units.
|
|
169
|
+
|
|
170
|
+
Native text views on macOS, iOS, and Android index by UTF-16 code unit, so every
|
|
171
|
+
character outside the Basic Multilingual Plane shifts later offsets by one. Spans
|
|
172
|
+
must be in document order, which is how the highlighters produce them.
|
|
173
|
+
"""
|
|
174
|
+
converted = []
|
|
175
|
+
scanned = 0 # code-point index already accounted for
|
|
176
|
+
extra = 0 # extra UTF-16 units contributed by astral characters before `scanned`
|
|
177
|
+
|
|
178
|
+
def utf16(index: int) -> int:
|
|
179
|
+
nonlocal scanned, extra
|
|
180
|
+
extra += sum(1 for ch in text[scanned:index] if ord(ch) > 0xFFFF)
|
|
181
|
+
scanned = index
|
|
182
|
+
return index + extra
|
|
183
|
+
|
|
184
|
+
for span in spans:
|
|
185
|
+
start = utf16(span.start)
|
|
186
|
+
end = utf16(span.end)
|
|
187
|
+
converted.append(Span(start, end, span.kind))
|
|
188
|
+
return converted
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def utf16_line_starts(text: str) -> list[int]:
|
|
192
|
+
"""Return the UTF-16 offset of the first character of each logical line."""
|
|
193
|
+
starts = [0]
|
|
194
|
+
offset = 0
|
|
195
|
+
for ch in text:
|
|
196
|
+
offset += 2 if ord(ch) > 0xFFFF else 1
|
|
197
|
+
if ch == "\n":
|
|
198
|
+
starts.append(offset)
|
|
199
|
+
return starts
|
|
File without changes
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import weakref
|
|
2
|
+
|
|
3
|
+
from android.graphics import Typeface
|
|
4
|
+
from android.text import InputType, Spanned
|
|
5
|
+
from android.text.method import ScrollingMovementMethod
|
|
6
|
+
from android.text.style import ForegroundColorSpan, StyleSpan
|
|
7
|
+
from android.util import TypedValue
|
|
8
|
+
from android.view import Gravity, View, WindowManager
|
|
9
|
+
from android.widget import RelativeLayout, TextView
|
|
10
|
+
from java import dynamic_proxy
|
|
11
|
+
from java.lang import Runnable
|
|
12
|
+
from toga_android.colors import native_color
|
|
13
|
+
from toga_android.widgets.base import suppress_reference_error
|
|
14
|
+
from toga_android.widgets.multilinetextinput import MultilineTextInput
|
|
15
|
+
|
|
16
|
+
from ..highlighting import to_utf16_spans
|
|
17
|
+
|
|
18
|
+
GUTTER_PADDING = 6 # CSS pixels; scaled to physical pixels at creation
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TogaGutterScrollListener(dynamic_proxy(View.OnScrollChangeListener)):
|
|
22
|
+
def __init__(self, impl):
|
|
23
|
+
super().__init__()
|
|
24
|
+
self.impl = weakref.proxy(impl)
|
|
25
|
+
|
|
26
|
+
def onScrollChange(self, view, new_x, new_y, old_x, old_y):
|
|
27
|
+
with suppress_reference_error():
|
|
28
|
+
self.impl.gutter.setScrollY(new_y)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class TogaGutterLayoutListener(dynamic_proxy(View.OnLayoutChangeListener)):
|
|
32
|
+
def __init__(self, impl):
|
|
33
|
+
super().__init__()
|
|
34
|
+
self.impl = weakref.proxy(impl)
|
|
35
|
+
|
|
36
|
+
def onLayoutChange(
|
|
37
|
+
self, view, left, top, right, bottom, o_left, o_top, o_right, o_bottom
|
|
38
|
+
):
|
|
39
|
+
# A new width re-wraps the text, which changes which visual lines start a
|
|
40
|
+
# logical line. Defer the rebuild until this layout pass has finished.
|
|
41
|
+
with suppress_reference_error():
|
|
42
|
+
if (right - left, bottom - top) != (o_right - o_left, o_bottom - o_top):
|
|
43
|
+
self.impl.native.post(self.impl.gutter_updater)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class TogaGutterTouchListener(dynamic_proxy(View.OnTouchListener)):
|
|
47
|
+
def onTouch(self, view, event):
|
|
48
|
+
# The gutter follows the editor; it must not scroll on its own.
|
|
49
|
+
return True
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class TogaGutterUpdater(dynamic_proxy(Runnable)):
|
|
53
|
+
def __init__(self, impl):
|
|
54
|
+
super().__init__()
|
|
55
|
+
self.impl = weakref.proxy(impl)
|
|
56
|
+
|
|
57
|
+
def run(self):
|
|
58
|
+
with suppress_reference_error():
|
|
59
|
+
self.impl.update_gutter()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class CodeEditor(MultilineTextInput):
|
|
63
|
+
def __init__(self, interface):
|
|
64
|
+
super().__init__(interface)
|
|
65
|
+
# ContainedWidget wraps self.native in a RelativeLayout *after* create()
|
|
66
|
+
# returns, so the gutter can only be added here.
|
|
67
|
+
padding = self.scale_in(GUTTER_PADDING)
|
|
68
|
+
self.gutter = TextView(self._native_activity)
|
|
69
|
+
self.gutter.setId(View.generateViewId())
|
|
70
|
+
self.gutter.setGravity(Gravity.END | Gravity.TOP)
|
|
71
|
+
self.gutter.setTextColor(self.native.getCurrentHintTextColor())
|
|
72
|
+
# Without a movement method, a TextView scrolls back to its start on the
|
|
73
|
+
# first draw after setText(), which would undo the scroll sync every time
|
|
74
|
+
# the numbers are rebuilt. The movement method also makes the view
|
|
75
|
+
# focusable and clickable, so undo that and swallow touches.
|
|
76
|
+
self.gutter.setMovementMethod(ScrollingMovementMethod.getInstance())
|
|
77
|
+
self.gutter.setFocusable(False)
|
|
78
|
+
self.gutter.setClickable(False)
|
|
79
|
+
self.gutter.setLongClickable(False)
|
|
80
|
+
self.gutter.setOnTouchListener(TogaGutterTouchListener())
|
|
81
|
+
self.gutter.setPadding(
|
|
82
|
+
padding,
|
|
83
|
+
self.native.getPaddingTop(),
|
|
84
|
+
padding,
|
|
85
|
+
self.native.getPaddingBottom(),
|
|
86
|
+
)
|
|
87
|
+
self.sync_gutter_font()
|
|
88
|
+
|
|
89
|
+
gutter_params = RelativeLayout.LayoutParams(
|
|
90
|
+
RelativeLayout.LayoutParams.WRAP_CONTENT,
|
|
91
|
+
RelativeLayout.LayoutParams.MATCH_PARENT,
|
|
92
|
+
)
|
|
93
|
+
gutter_params.addRule(RelativeLayout.ALIGN_PARENT_START)
|
|
94
|
+
self.native_toplevel.addView(self.gutter, gutter_params)
|
|
95
|
+
|
|
96
|
+
text_params = RelativeLayout.LayoutParams(
|
|
97
|
+
RelativeLayout.LayoutParams.MATCH_PARENT,
|
|
98
|
+
RelativeLayout.LayoutParams.MATCH_PARENT,
|
|
99
|
+
)
|
|
100
|
+
text_params.addRule(RelativeLayout.END_OF, self.gutter.getId())
|
|
101
|
+
self.native.setLayoutParams(text_params)
|
|
102
|
+
|
|
103
|
+
self.native.setOnScrollChangeListener(TogaGutterScrollListener(self))
|
|
104
|
+
self.native.addOnLayoutChangeListener(TogaGutterLayoutListener(self))
|
|
105
|
+
self.gutter_updater = TogaGutterUpdater(self)
|
|
106
|
+
self.gutter_numbers = None
|
|
107
|
+
self.update_gutter()
|
|
108
|
+
|
|
109
|
+
def create(self):
|
|
110
|
+
super().create()
|
|
111
|
+
self.disable_suggestions()
|
|
112
|
+
self.prefer_keyboard_resize()
|
|
113
|
+
self.theme = {}
|
|
114
|
+
self.spans = []
|
|
115
|
+
self.active_spans = []
|
|
116
|
+
|
|
117
|
+
def prefer_keyboard_resize(self):
|
|
118
|
+
# By default Android pans the whole window to keep the cursor above the soft
|
|
119
|
+
# keyboard, which scrolls everything above the editor off the screen. Ask for
|
|
120
|
+
# the content area to shrink instead, so the editor scrolls internally, but
|
|
121
|
+
# only when the app has not chosen a mode itself.
|
|
122
|
+
params = WindowManager.LayoutParams
|
|
123
|
+
window = self._native_activity.getWindow()
|
|
124
|
+
mode = window.getAttributes().softInputMode
|
|
125
|
+
if mode & params.SOFT_INPUT_MASK_ADJUST == params.SOFT_INPUT_ADJUST_UNSPECIFIED:
|
|
126
|
+
mode = (
|
|
127
|
+
mode & ~params.SOFT_INPUT_MASK_ADJUST
|
|
128
|
+
) | params.SOFT_INPUT_ADJUST_RESIZE
|
|
129
|
+
window.setSoftInputMode(mode)
|
|
130
|
+
|
|
131
|
+
def disable_suggestions(self):
|
|
132
|
+
# NO_SUGGESTIONS turns off autocorrect on most keyboards. Some third-party
|
|
133
|
+
# keyboards ignore it; that is a known rough edge.
|
|
134
|
+
self.native.setInputType(
|
|
135
|
+
self.native.getInputType() | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
# Inherited TextInput methods that must keep the gutter and flags in sync
|
|
139
|
+
|
|
140
|
+
def set_readonly(self, readonly):
|
|
141
|
+
# The parent toggles NO_SUGGESTIONS with readonly; keep it on regardless.
|
|
142
|
+
super().set_readonly(readonly)
|
|
143
|
+
self.disable_suggestions()
|
|
144
|
+
|
|
145
|
+
def set_font(self, font):
|
|
146
|
+
super().set_font(font)
|
|
147
|
+
self.sync_gutter_font()
|
|
148
|
+
self.native.post(self.gutter_updater)
|
|
149
|
+
|
|
150
|
+
def _on_change(self):
|
|
151
|
+
self.interface._schedule_rehighlight()
|
|
152
|
+
self.interface.on_change()
|
|
153
|
+
# The text layout is rebuilt after this callback returns; update the
|
|
154
|
+
# gutter once that has happened.
|
|
155
|
+
self.native.post(self.gutter_updater)
|
|
156
|
+
|
|
157
|
+
# CodeEditor backend contract
|
|
158
|
+
|
|
159
|
+
def set_theme(self, theme):
|
|
160
|
+
self.theme = theme
|
|
161
|
+
|
|
162
|
+
def set_highlights(self, spans):
|
|
163
|
+
self.spans = spans
|
|
164
|
+
editable = self.native.getText()
|
|
165
|
+
for span in self.active_spans:
|
|
166
|
+
editable.removeSpan(span)
|
|
167
|
+
self.active_spans = []
|
|
168
|
+
|
|
169
|
+
for span in to_utf16_spans(str(editable), spans):
|
|
170
|
+
style = self.theme.get(span.kind)
|
|
171
|
+
if style is None:
|
|
172
|
+
continue
|
|
173
|
+
native_spans = [ForegroundColorSpan(native_color(style.color))]
|
|
174
|
+
if style.bold or style.italic:
|
|
175
|
+
typeface_style = (Typeface.BOLD if style.bold else Typeface.NORMAL) | (
|
|
176
|
+
Typeface.ITALIC if style.italic else Typeface.NORMAL
|
|
177
|
+
)
|
|
178
|
+
native_spans.append(StyleSpan(typeface_style))
|
|
179
|
+
for native_span in native_spans:
|
|
180
|
+
editable.setSpan(
|
|
181
|
+
native_span, span.start, span.end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
|
|
182
|
+
)
|
|
183
|
+
self.active_spans.append(native_span)
|
|
184
|
+
|
|
185
|
+
def set_show_line_numbers(self, value):
|
|
186
|
+
# A GONE anchor collapses to zero width, so the editor fills the row.
|
|
187
|
+
self.gutter.setVisibility(View.VISIBLE if value else View.GONE)
|
|
188
|
+
|
|
189
|
+
# Gutter
|
|
190
|
+
|
|
191
|
+
def sync_gutter_font(self):
|
|
192
|
+
self.gutter.setTypeface(self.native.getTypeface())
|
|
193
|
+
self.gutter.setTextSize(TypedValue.COMPLEX_UNIT_PX, self.native.getTextSize())
|
|
194
|
+
self.gutter.setLineSpacing(
|
|
195
|
+
self.native.getLineSpacingExtra(), self.native.getLineSpacingMultiplier()
|
|
196
|
+
)
|
|
197
|
+
self.gutter.setIncludeFontPadding(self.native.getIncludeFontPadding())
|
|
198
|
+
|
|
199
|
+
def update_gutter(self):
|
|
200
|
+
layout = self.native.getLayout()
|
|
201
|
+
if layout is None:
|
|
202
|
+
# No layout pass has happened yet; the next change will post again.
|
|
203
|
+
return
|
|
204
|
+
text = self.native.getText()
|
|
205
|
+
numbers = []
|
|
206
|
+
line = 0
|
|
207
|
+
for i in range(layout.getLineCount()):
|
|
208
|
+
start = layout.getLineStart(i)
|
|
209
|
+
if start == 0 or text.charAt(start - 1) == "\n":
|
|
210
|
+
line += 1
|
|
211
|
+
numbers.append(str(line))
|
|
212
|
+
else:
|
|
213
|
+
numbers.append("") # a wrapped continuation line
|
|
214
|
+
gutter_numbers = "\n".join(numbers)
|
|
215
|
+
if gutter_numbers != self.gutter_numbers:
|
|
216
|
+
self.gutter_numbers = gutter_numbers
|
|
217
|
+
self.gutter.setText(gutter_numbers)
|