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.
@@ -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)