From 97fa41ce8e8644a570928146f35889c437b86d36 Mon Sep 17 00:00:00 2001 From: Kattni Date: Sat, 10 Oct 2026 00:09:51 -0400 Subject: [PATCH 01/21] Drop empty spans, convert paths with os.fspath, and scan line starts per line --- src/toga_code_editor/highlighting.py | 31 +++++++++++++++----------- tests/test_highlighting.py | 33 +++++++++++++++++++++++----- 2 files changed, 46 insertions(+), 18 deletions(-) diff --git a/src/toga_code_editor/highlighting.py b/src/toga_code_editor/highlighting.py index b2f15f2..7abf15d 100644 --- a/src/toga_code_editor/highlighting.py +++ b/src/toga_code_editor/highlighting.py @@ -3,7 +3,7 @@ from collections.abc import Iterable, Mapping from dataclasses import dataclass from enum import StrEnum -from os import PathLike +from os import PathLike, fspath from typing import Protocol from pygments.lexers import find_lexer_class_for_filename, get_lexer_by_name @@ -52,9 +52,13 @@ class Span: @dataclass(frozen=True) class Style: - """How a token kind is drawn. ``color`` accepts any Toga color value.""" + """How a token kind is drawn. - color: Color | str + ``color`` accepts any Toga color value, including a string, and is stored as a + :class:`~toga.colors.Color`. + """ + + color: Color bold: bool = False italic: bool = False @@ -78,9 +82,9 @@ def __post_init__(self): TokenKind.VARIABLE: Style("#e45649"), } -# Ordered most-specific first. The first row whose Pygments type contains the token -# wins, so Operator.Word must precede Operator, and the Name.* rows precede nothing -# more general because bare Name falls through to TEXT. +# Ordered most-specific first: the first row whose Pygments type contains the token +# wins, so Operator.Word must precede Operator. Anything unmatched, including bare +# Name, is TEXT. _TOKEN_TABLE = [ (Comment, TokenKind.COMMENT), (String, TokenKind.STRING), @@ -108,10 +112,10 @@ def token_kind(token_type) -> TokenKind: def merge_spans(spans: Iterable[Span]) -> list[Span]: - """Drop TEXT spans and merge adjacent spans of the same kind.""" + """Drop TEXT and empty spans, and merge adjacent spans of the same kind.""" merged: list[Span] = [] for span in spans: - if span.kind is TokenKind.TEXT: + if span.kind is TokenKind.TEXT or span.end <= span.start: continue if merged and merged[-1].kind is span.kind and merged[-1].end == span.start: merged[-1] = Span(merged[-1].start, span.end, span.kind) @@ -160,7 +164,7 @@ def language_for_filename(path: str | PathLike) -> str | None: The whole filename is matched, so names such as ``Makefile`` resolve. """ - lexer_class = find_lexer_class_for_filename(str(path)) + lexer_class = find_lexer_class_for_filename(fspath(path)) return None if lexer_class is None else lexer_class.aliases[0] @@ -192,8 +196,9 @@ def utf16_line_starts(text: str) -> list[int]: """Return the UTF-16 offset of the first character of each logical line.""" starts = [0] offset = 0 - for ch in text: - offset += 2 if ord(ch) > 0xFFFF else 1 - if ch == "\n": - starts.append(offset) + for line in text.split("\n")[:-1]: + # An astral character is two UTF-16 units. "surrogatepass" keeps a lone + # surrogate at one unit rather than raising. + offset += len(line.encode("utf-16-le", "surrogatepass")) // 2 + 1 + starts.append(offset) return starts diff --git a/tests/test_highlighting.py b/tests/test_highlighting.py index 89b522c..d222968 100644 --- a/tests/test_highlighting.py +++ b/tests/test_highlighting.py @@ -6,6 +6,7 @@ Span, TokenKind, language_for_filename, + merge_spans, to_utf16_spans, utf16_line_starts, ) @@ -14,7 +15,7 @@ def test_python_snippet(): """A Python snippet lexes into merged, offset-correct spans; names are dropped.""" spans = PygmentsHighlighter("python").highlight( - "def f(x):\n return x + 1 # hi\n" + "def f(x):\n return x and 1 # hi\n" ) assert spans == [ Span(0, 3, TokenKind.KEYWORD), @@ -22,12 +23,22 @@ def test_python_snippet(): Span(5, 6, TokenKind.PUNCTUATION), Span(7, 9, TokenKind.PUNCTUATION), # ")" and ":" merged into one span Span(14, 20, TokenKind.KEYWORD), - Span(23, 24, TokenKind.OPERATOR), - Span(25, 26, TokenKind.NUMBER), - Span(28, 32, TokenKind.COMMENT), + Span(23, 26, TokenKind.KEYWORD), # "and" is Operator.Word, not an operator + Span(27, 28, TokenKind.NUMBER), + Span(30, 34, TokenKind.COMMENT), ] +def test_merge_spans_drops_empty_spans(): + """A zero-length span is dropped, and its neighbors merge across it.""" + spans = [ + Span(0, 1, TokenKind.NUMBER), + Span(1, 1, TokenKind.KEYWORD), + Span(1, 2, TokenKind.NUMBER), + ] + assert merge_spans(spans) == [Span(0, 2, TokenKind.NUMBER)] + + def test_unknown_language(): with pytest.raises(ValueError, match="nope"): PygmentsHighlighter("nope") @@ -50,9 +61,21 @@ def test_utf16_offsets(): assert utf16_line_starts("\U0001f600\r\nb") == [0, 4] +class FSPath: + """A PathLike whose str() is not its path; only os.fspath() sees "foo.py".""" + + def __fspath__(self): + return "foo.py" + + @pytest.mark.parametrize( "name, expected", - [("foo.py", "python"), ("Makefile", "make"), ("notes.xyz", None)], + [ + ("foo.py", "python"), + ("Makefile", "make"), + ("notes.xyz", None), + (FSPath(), "python"), + ], ) def test_language_for_filename(name, expected): assert language_for_filename(name) == expected From d689297db7a7f1168a722b95879794e7e7ed1904 Mon Sep 17 00:00:00 2001 From: Kattni Date: Sat, 10 Oct 2026 00:13:28 -0400 Subject: [PATCH 02/21] Paint once at construction, report debounce failures, and freeze the default theme --- src/toga_code_editor/code_editor.py | 41 ++++++++++++++++++++-------- src/toga_code_editor/highlighting.py | 29 ++++++++++++-------- tests/test_code_editor.py | 38 +++++++++++++++++++++++++- tests/test_highlighting.py | 8 ++++++ 4 files changed, 91 insertions(+), 25 deletions(-) diff --git a/src/toga_code_editor/code_editor.py b/src/toga_code_editor/code_editor.py index 4259954..9633f57 100644 --- a/src/toga_code_editor/code_editor.py +++ b/src/toga_code_editor/code_editor.py @@ -23,6 +23,19 @@ """Seconds to wait after the last native edit before re-highlighting.""" +def _highlighter_for(language: str | None) -> Highlighter: + return NullHighlighter() if language is None else PygmentsHighlighter(language) + + +def _report_rehighlight_failure(task: asyncio.Task) -> None: + # Hand a failure to the loop's exception handler as soon as the task finishes. + # Otherwise asyncio only reports it when the task object is garbage collected. + if not task.cancelled() and (exc := task.exception()) is not None: + task.get_loop().call_exception_handler( + {"message": "Re-highlighting the code editor failed", "exception": exc} + ) + + class CodeEditor(toga.MultilineTextInput): def __init__( self, @@ -61,12 +74,14 @@ def __init__( ): kwargs["font_family"] = MONOSPACE - # State the inherited value setter needs before super().__init__() runs. - self._highlighter: Highlighter = NullHighlighter() - self._language: str | None = None - self._theme: Theme = DEFAULT_THEME + # Resolve the highlighting state before the widget exists: an unknown + # language raises before anything is created, and the inherited constructor + # paints the initial value once, with the highlighter and theme in hand. + self._highlighter: Highlighter = _highlighter_for(language) + self._language = language + self._theme: Theme = DEFAULT_THEME if theme is None else theme self._show_line_numbers = True - self._pending_rehighlight = None + self._pending_rehighlight: asyncio.Task | None = None super().__init__( id=id, @@ -78,8 +93,6 @@ def __init__( **kwargs, ) - self.theme = theme - self.language = language self.show_line_numbers = show_line_numbers @cached_property @@ -97,7 +110,11 @@ def factory(self): return get_factory("toga_code_editor") def _create(self) -> Any: - return self.factory.CodeEditor(interface=self) + impl = self.factory.CodeEditor(interface=self) + # The backend needs the theme before the inherited constructor sets the + # initial value, which paints the first highlights. + impl.set_theme(self._theme) + return impl @toga.MultilineTextInput.value.setter def value(self, value: object) -> None: @@ -115,7 +132,7 @@ def language(self) -> str | None: @language.setter def language(self, value: str | None) -> None: - highlighter = NullHighlighter() if value is None else PygmentsHighlighter(value) + highlighter = _highlighter_for(value) self._language = value self._highlighter = highlighter self._rehighlight() @@ -154,9 +171,9 @@ def _schedule_rehighlight(self) -> None: timer added from a native callback would otherwise never fire. """ self._cancel_pending_rehighlight() - self._pending_rehighlight = toga.App.app.loop.create_task( - self._rehighlight_after_delay() - ) + task = toga.App.app.loop.create_task(self._rehighlight_after_delay()) + task.add_done_callback(_report_rehighlight_failure) + self._pending_rehighlight = task async def _rehighlight_after_delay(self) -> None: await asyncio.sleep(REHIGHLIGHT_DELAY) diff --git a/src/toga_code_editor/highlighting.py b/src/toga_code_editor/highlighting.py index 7abf15d..4dcc786 100644 --- a/src/toga_code_editor/highlighting.py +++ b/src/toga_code_editor/highlighting.py @@ -4,6 +4,7 @@ from dataclasses import dataclass from enum import StrEnum from os import PathLike, fspath +from types import MappingProxyType from typing import Protocol from pygments.lexers import find_lexer_class_for_filename, get_lexer_by_name @@ -69,18 +70,22 @@ def __post_init__(self): Theme = Mapping[TokenKind, Style] """A mapping from token kind to style. Kinds absent from the theme are left unstyled.""" -DEFAULT_THEME: Theme = { - TokenKind.KEYWORD: Style("#a626a4"), - TokenKind.BUILTIN: Style("#0184bc"), - TokenKind.DEFINITION: Style("#4078f2", bold=True), - TokenKind.DECORATOR: Style("#986801"), - TokenKind.STRING: Style("#50a14f"), - TokenKind.NUMBER: Style("#986801"), - TokenKind.COMMENT: Style("#8a8a8a", italic=True), - TokenKind.TAG: Style("#e45649"), - TokenKind.ATTRIBUTE: Style("#986801"), - TokenKind.VARIABLE: Style("#e45649"), -} +# Read-only, so no editor can change the default under another; unpack it with +# ``{**DEFAULT_THEME, ...}`` to build a custom theme. +DEFAULT_THEME: Theme = MappingProxyType( + { + TokenKind.KEYWORD: Style("#a626a4"), + TokenKind.BUILTIN: Style("#0184bc"), + TokenKind.DEFINITION: Style("#4078f2", bold=True), + TokenKind.DECORATOR: Style("#986801"), + TokenKind.STRING: Style("#50a14f"), + TokenKind.NUMBER: Style("#986801"), + TokenKind.COMMENT: Style("#8a8a8a", italic=True), + TokenKind.TAG: Style("#e45649"), + TokenKind.ATTRIBUTE: Style("#986801"), + TokenKind.VARIABLE: Style("#e45649"), + } +) # Ordered most-specific first: the first row whose Pygments type contains the token # wins, so Operator.Word must precede Operator. Anything unmatched, including bare diff --git a/tests/test_code_editor.py b/tests/test_code_editor.py index 150446a..c914da3 100644 --- a/tests/test_code_editor.py +++ b/tests/test_code_editor.py @@ -3,7 +3,7 @@ import pytest from toga.fonts import MONOSPACE, SERIF, SYSTEM from toga.style import Pack -from toga_dummy.utils import assert_action_performed, attribute_value +from toga_dummy.utils import EventLog, assert_action_performed, attribute_value from toga_code_editor import DEFAULT_THEME, CodeEditor, Span, Style, TokenKind from toga_code_editor.code_editor import REHIGHLIGHT_DELAY @@ -69,6 +69,15 @@ def test_theme_and_line_numbers(app): assert attribute_value(editor, "show_line_numbers") is False +def test_construction_highlights_once(app): + """The initial value, language, and theme are painted in a single pass.""" + theme = {TokenKind.NUMBER: Style("red")} + editor = CodeEditor(value="x = 1", language="python", theme=theme) + + assert EventLog.values(editor, "highlights") == [X_EQUALS_ONE] + assert attribute_value(editor, "theme") is theme + + def test_value_rehighlights(app): editor = CodeEditor(language="python") editor.value = "x = 1" @@ -95,3 +104,30 @@ async def test_native_change_debounces(app): await asyncio.sleep(REHIGHLIGHT_DELAY * 2) assert attribute_value(editor, "highlights") == X_EQUALS_ONE + + # A programmatic assignment re-highlights now and cancels the pending pass. + editor._impl.simulate_change() + editor.value = "x = 2" + assert editor._pending_rehighlight is None + + +async def test_rehighlight_failure_is_reported(app): + """A failure in the debounced re-highlight reaches the loop's exception handler.""" + + class BrokenHighlighter: + def highlight(self, text): + raise RuntimeError("lexer failed") + + reported = [] + asyncio.get_running_loop().set_exception_handler( + lambda loop, context: reported.append(context) + ) + editor = CodeEditor() + editor._highlighter = BrokenHighlighter() + + editor._impl.simulate_change() + # Hold the task, so garbage collection cannot be what reports the failure. + task = editor._pending_rehighlight + await asyncio.sleep(REHIGHLIGHT_DELAY * 2) + assert [type(context["exception"]) for context in reported] == [RuntimeError] + assert task.done() diff --git a/tests/test_highlighting.py b/tests/test_highlighting.py index d222968..df8632d 100644 --- a/tests/test_highlighting.py +++ b/tests/test_highlighting.py @@ -1,9 +1,11 @@ import pytest from toga_code_editor.highlighting import ( + DEFAULT_THEME, NullHighlighter, PygmentsHighlighter, Span, + Style, TokenKind, language_for_filename, merge_spans, @@ -39,6 +41,12 @@ def test_merge_spans_drops_empty_spans(): assert merge_spans(spans) == [Span(0, 2, TokenKind.NUMBER)] +def test_default_theme_is_read_only(): + """One editor cannot change the default theme under another.""" + with pytest.raises(TypeError): + DEFAULT_THEME[TokenKind.KEYWORD] = Style("red") + + def test_unknown_language(): with pytest.raises(ValueError, match="nope"): PygmentsHighlighter("nope") From b26f04a8204596fae46aaecb16381f8f34fc2061 Mon Sep 17 00:00:00 2001 From: Kattni Date: Sat, 10 Oct 2026 00:14:40 -0400 Subject: [PATCH 03/21] Pin both the operator and operator-word mappings in the Python snippet --- tests/test_highlighting.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/test_highlighting.py b/tests/test_highlighting.py index df8632d..5063315 100644 --- a/tests/test_highlighting.py +++ b/tests/test_highlighting.py @@ -17,7 +17,7 @@ def test_python_snippet(): """A Python snippet lexes into merged, offset-correct spans; names are dropped.""" spans = PygmentsHighlighter("python").highlight( - "def f(x):\n return x and 1 # hi\n" + "def f(x):\n return x + 1 and 2 # hi\n" ) assert spans == [ Span(0, 3, TokenKind.KEYWORD), @@ -25,9 +25,11 @@ def test_python_snippet(): Span(5, 6, TokenKind.PUNCTUATION), Span(7, 9, TokenKind.PUNCTUATION), # ")" and ":" merged into one span Span(14, 20, TokenKind.KEYWORD), - Span(23, 26, TokenKind.KEYWORD), # "and" is Operator.Word, not an operator - Span(27, 28, TokenKind.NUMBER), - Span(30, 34, TokenKind.COMMENT), + Span(23, 24, TokenKind.OPERATOR), + Span(25, 26, TokenKind.NUMBER), + Span(27, 30, TokenKind.KEYWORD), # "and" is Operator.Word, not an operator + Span(31, 32, TokenKind.NUMBER), + Span(34, 38, TokenKind.COMMENT), ] From 9a6d7413df6835d1dbbb4d6b33433c92a42afd32 Mon Sep 17 00:00:00 2001 From: Kattni Date: Sat, 10 Oct 2026 00:16:23 -0400 Subject: [PATCH 04/21] Bound toga-core below 0.6 and tighten the README and spec wording --- README.md | 9 ++++----- docs/superpowers/specs/2026-10-06-code-editor-design.md | 4 ++-- examples/editor/pyproject.toml | 5 +++-- pyproject.toml | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 0567e3c..b594fa2 100644 --- a/README.md +++ b/README.md @@ -8,12 +8,11 @@ A [Toga](https://toga.beeware.org) widget for editing code, with line numbers an pip install toga-code-editor ``` -The package depends on `toga-core` and `pygments`. Your app installs the Toga backend for its platform as usual; `toga-code-editor` contributes its implementation for that backend through entry points, the same way Toga's own widgets are found. +The package depends on `pygments` and on `toga-core` 0.5.7 or later, but not 0.6, because it subclasses Toga backend internals that can change between minor versions. Your app installs the Toga backend for its platform as usual; `toga-code-editor` contributes its implementation for that backend through entry points, the same way Toga's own widgets are found. ## Usage ```python -import toga from toga_code_editor import CodeEditor, language_for_filename editor = CodeEditor( @@ -51,7 +50,7 @@ theme = { editor.theme = theme ``` -`Style.color` accepts anything Toga's color properties accept. The token kinds are `KEYWORD`, `BUILTIN`, `DEFINITION`, `DECORATOR`, `STRING`, `NUMBER`, `COMMENT`, `OPERATOR`, `PUNCTUATION`, `TAG`, `ATTRIBUTE`, and `VARIABLE`. +`Style.color` accepts anything Toga's color properties accept. The token kinds are `KEYWORD`, `BUILTIN`, `DEFINITION`, `DECORATOR`, `STRING`, `NUMBER`, `COMMENT`, `OPERATOR`, `PUNCTUATION`, `TAG`, `ATTRIBUTE`, and `VARIABLE`. Everything else, including plain names, is `TEXT`, which is never styled. ## Platform notes @@ -59,7 +58,7 @@ editor.theme = theme - **iOS** and **Android** draw the gutter themselves. Theme colors do not change with the system appearance; the default theme is chosen to be legible on both light and dark backgrounds. - Highlighting re-lexes the whole buffer after a short pause in typing. Files of a few thousand lines are fine on a desktop; very large files are slower on phones. - Some third-party Android keyboards ignore the flag that disables suggestions. -- On Android, the widget asks the window to shrink its content when the soft keyboard appears, so the editor scrolls internally and the rest of the layout stays put. It only does this when the app has not chosen a soft-input mode itself. +- On Android, the widget asks the window to shrink its content when the soft keyboard appears, so the editor scrolls internally and the rest of the layout stays put. It only does this when the app has not chosen a soft-input mode itself. Android deprecated that request in API 30 and ignores it on Android 15 and later for apps that target SDK 35 or later, which run edge to edge; there the window keeps its size when the keyboard appears. Briefcase's current Android template targets SDK 36, so the example app is in that group. ## Developing @@ -78,7 +77,7 @@ briefcase run iOS briefcase run android ``` -On macOS and iOS the example installs the widget straight from this repository; add `-r` after changing the widget source so Briefcase reinstalls it. On Android the example installs the widget from a wheel in `dist/`, because Gradle rejects a path requirement whose directory contains the Android build tree. Build the wheel before each Android run that should pick up widget changes: +On macOS and iOS the example installs the widget straight from this repository; add `-r` after changing the widget source so Briefcase reinstalls it. On Android the example installs the widget from a wheel in `dist/`, because Gradle rejects a path requirement whose directory contains the Android build tree. The example's Android requirement names that wheel file, version and all, so a version bump must update `examples/editor/pyproject.toml` too, and a stale wheel in `dist/` ships the old code without complaint. Build the wheel from the repository root before each Android run that should pick up widget changes: ```console uv build --wheel diff --git a/docs/superpowers/specs/2026-10-06-code-editor-design.md b/docs/superpowers/specs/2026-10-06-code-editor-design.md index c9099bb..65d6151 100644 --- a/docs/superpowers/specs/2026-10-06-code-editor-design.md +++ b/docs/superpowers/specs/2026-10-06-code-editor-design.md @@ -40,7 +40,7 @@ toga-code-editor/ README.md, LICENSE, CHANGELOG.md ``` -Runtime dependencies: `toga-core >= 0.5.7` and `pygments`. The entry-point factory shipped in Toga 0.5.4, but the pin is 0.5.7 because the package subclasses internal backend classes and supports only the versions it has been tested against. Raise the floor deliberately when a new Toga release lands. No Toga backend is a dependency of the package; the app declares its platform backend as usual. +Runtime dependencies: `toga-core >= 0.5.7, < 0.6` and `pygments`. The entry-point factory shipped in Toga 0.5.4, but the pin is 0.5.7 because the package subclasses internal backend classes and supports only the versions it has been tested against. Raise the floor deliberately when a new Toga release lands. No Toga backend is a dependency of the package; the app declares its platform backend as usual. Entry points, one table per backend: @@ -261,7 +261,7 @@ Real backends are checked by running the example app on each platform and typing The example app at `examples/editor` is a Briefcase app that bundles a few sample files and offers a `Selection` to pick one, a `Selection` for language, and a `Switch` for line numbers. It depends on the package by relative path, as Toga's `customwidget` example does. -CI is one pre-commit job and one pytest job on Ubuntu with `TOGA_BACKEND=toga_dummy`. +CI is one Ubuntu job that runs pre-commit, then pytest with coverage against `toga_dummy`. Docs are the README and docstrings. Changelog fragments go in `changes/` for towncrier. diff --git a/examples/editor/pyproject.toml b/examples/editor/pyproject.toml index dacbdc8..e032485 100644 --- a/examples/editor/pyproject.toml +++ b/examples/editor/pyproject.toml @@ -16,8 +16,9 @@ author = "Kattni" author_email = "hello@kattni.com" description = "A code editor demonstrating the toga-code-editor widget." # macOS and iOS install the widget from this repository; Android installs it from a -# built wheel (run `uv build` first), because Gradle rejects a path requirement whose -# directory contains the Android build tree. +# built wheel (run `uv build --wheel` from the repository root first), because Gradle +# rejects a path requirement whose directory contains the Android build tree. The +# wheel filename below carries the package version, so a version bump must update it. requires = [] [tool.briefcase.app.editor] diff --git a/pyproject.toml b/pyproject.toml index 1b5f708..d770745 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,7 @@ classifiers = [ "Topic :: Software Development :: Widget Sets", ] dependencies = [ - "toga-core >= 0.5.7", + "toga-core >= 0.5.7, < 0.6", "pygments >= 2.19", ] From edfcc91359cc6fed9b17ca2b2f6a8fb1d2940699 Mon Sep 17 00:00:00 2001 From: Kattni Date: Sat, 10 Oct 2026 00:25:26 -0400 Subject: [PATCH 05/21] Clarify the toga-core range and the Android keyboard caveat in the README --- README.md | 4 ++-- docs/superpowers/specs/2026-10-06-code-editor-design.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b594fa2..e13a23d 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ A [Toga](https://toga.beeware.org) widget for editing code, with line numbers an pip install toga-code-editor ``` -The package depends on `pygments` and on `toga-core` 0.5.7 or later, but not 0.6, because it subclasses Toga backend internals that can change between minor versions. Your app installs the Toga backend for its platform as usual; `toga-code-editor` contributes its implementation for that backend through entry points, the same way Toga's own widgets are found. +The package depends on `pygments` and on `toga-core` 0.5.7 or later in the 0.5 series, because it subclasses Toga backend internals that can change between minor versions. Your app installs the Toga backend for its platform as usual; `toga-code-editor` contributes its implementation for that backend through entry points, the same way Toga's own widgets are found. ## Usage @@ -58,7 +58,7 @@ editor.theme = theme - **iOS** and **Android** draw the gutter themselves. Theme colors do not change with the system appearance; the default theme is chosen to be legible on both light and dark backgrounds. - Highlighting re-lexes the whole buffer after a short pause in typing. Files of a few thousand lines are fine on a desktop; very large files are slower on phones. - Some third-party Android keyboards ignore the flag that disables suggestions. -- On Android, the widget asks the window to shrink its content when the soft keyboard appears, so the editor scrolls internally and the rest of the layout stays put. It only does this when the app has not chosen a soft-input mode itself. Android deprecated that request in API 30 and ignores it on Android 15 and later for apps that target SDK 35 or later, which run edge to edge; there the window keeps its size when the keyboard appears. Briefcase's current Android template targets SDK 36, so the example app is in that group. +- On Android, the widget asks the window to shrink its content when the soft keyboard appears, so the editor scrolls internally and the rest of the layout stays put. It only does this when the app has not chosen a soft-input mode itself. Android deprecated that request in API 30. On Android 15 and later, an app that targets SDK 35 or later runs edge to edge unless it opts out, and the request is ignored, so the keyboard can cover the bottom of the editor. The example app is in that group, since Briefcase targets SDK 36. ## Developing diff --git a/docs/superpowers/specs/2026-10-06-code-editor-design.md b/docs/superpowers/specs/2026-10-06-code-editor-design.md index 65d6151..ab9c63c 100644 --- a/docs/superpowers/specs/2026-10-06-code-editor-design.md +++ b/docs/superpowers/specs/2026-10-06-code-editor-design.md @@ -60,7 +60,7 @@ CodeEditor = "toga_code_editor.dummy_code_editor:CodeEditor" A backend with no table gets the factory's standard `NotImplementedError`, which names the backend. -Tooling: ruff for lint and format, pre-commit, tox with coverage, towncrier fragments in `changes/`, and BeeWare's shared `python-package-create` workflow for CI. Python support matches toga-core 0.5.7. License BSD-3-Clause. +Tooling: ruff for lint and format, pre-commit, tox with coverage, towncrier fragments in `changes/`, and a GitHub Actions workflow for CI. Python support matches toga-core 0.5.7. License BSD-3-Clause. ## Public API From c16f0e975ae1d599074a70d0b04428d211c81c4a Mon Sep 17 00:00:00 2001 From: Kattni Date: Sat, 10 Oct 2026 00:26:59 -0400 Subject: [PATCH 06/21] Paste plain text, cache gutter attributes, and query the layout in container coordinates on macOS --- src/toga_code_editor/cocoa_code_editor.py | 30 ++++++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/src/toga_code_editor/cocoa_code_editor.py b/src/toga_code_editor/cocoa_code_editor.py index fd36afc..c3dcd6a 100644 --- a/src/toga_code_editor/cocoa_code_editor.py +++ b/src/toga_code_editor/cocoa_code_editor.py @@ -46,6 +46,12 @@ def textDidChange_(self, notification) -> None: self.interface.on_change() self.impl.text_changed() + @objc_method + def paste_(self, sender) -> None: + # Rich text from another app would keep attributes that the re-highlight + # never resets, so paste the plain text only. + self.pasteAsPlainText(sender) + class TogaLineNumberView(NSRulerView): impl = objc_property(object, weak=True) @@ -89,6 +95,8 @@ def create(self): self.native_text.setAutomaticSpellingCorrectionEnabled(False) self.native_text.setAutomaticTextReplacementEnabled(False) self.native_text.setAutomaticTextCompletionEnabled(False) + # Smart insert and delete add and remove spaces around words. + self.native_text.setSmartInsertDeleteEnabled(False) self.native_text.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable self.native.documentView = self.native_text @@ -126,6 +134,7 @@ def create(self): # once the storage is styled, so the base style is tracked here instead. self.base_font = self.native_text.font self.base_color = NSColor.textColor + self.rebuild_gutter_attributes() self.theme = {} self.attributes = {} @@ -144,6 +153,7 @@ def set_font(self, font): super().set_font(font) self.base_font = font._impl.native self.rebuild_attributes() + self.rebuild_gutter_attributes() self.apply_highlights() self.text_changed() @@ -222,11 +232,17 @@ def text_changed(self): self.ruler.ruleThickness = thickness self.ruler.setNeedsDisplay(True) - def gutter_label(self, text): + def rebuild_gutter_attributes(self): + # Built once per font, rather than once per visible line per draw. attributes = NSMutableDictionary.alloc().init() attributes[NSFontAttributeName] = self.base_font attributes[NSForegroundColorAttributeName] = NSColor.secondaryLabelColor - return NSAttributedString.alloc().initWithString(text, attributes=attributes) + self.gutter_attributes = attributes + + def gutter_label(self, text): + return NSAttributedString.alloc().initWithString( + text, attributes=self.gutter_attributes + ) def draw_line_numbers(self): layout = self.layout_manager @@ -235,8 +251,14 @@ def draw_line_numbers(self): inset = self.native_text.textContainerInset text_length = self.native_text.textStorage.length() + # The layout manager works in container coordinates, which sit inset.width + # and inset.height inside the view's own. + container_rect = NSRect( + NSPoint(visible.origin.x - inset.width, visible.origin.y - inset.height), + visible.size, + ) glyph_range = layout.glyphRangeForBoundingRect( - visible, inTextContainer=container + container_rect, inTextContainer=container ) char_range = layout.characterRangeForGlyphRange( glyph_range, actualGlyphRange=None @@ -260,5 +282,5 @@ def draw_line_numbers(self): ) label = self.gutter_label(str(number)) x = thickness - GUTTER_PADDING - label.size().width - y = fragment.origin.y - visible.origin.y + inset.height + y = fragment.origin.y + inset.height - visible.origin.y label.drawAtPoint(NSPoint(x, y)) From c8bd1a514591438f3d63473a004ccaae53354ac8 Mon Sep 17 00:00:00 2001 From: Kattni Date: Sat, 10 Oct 2026 00:33:30 -0400 Subject: [PATCH 07/21] Note that Android 16 removes the edge-to-edge opt-out for SDK 36 apps --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e13a23d..1fc34b5 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ editor.theme = theme - **iOS** and **Android** draw the gutter themselves. Theme colors do not change with the system appearance; the default theme is chosen to be legible on both light and dark backgrounds. - Highlighting re-lexes the whole buffer after a short pause in typing. Files of a few thousand lines are fine on a desktop; very large files are slower on phones. - Some third-party Android keyboards ignore the flag that disables suggestions. -- On Android, the widget asks the window to shrink its content when the soft keyboard appears, so the editor scrolls internally and the rest of the layout stays put. It only does this when the app has not chosen a soft-input mode itself. Android deprecated that request in API 30. On Android 15 and later, an app that targets SDK 35 or later runs edge to edge unless it opts out, and the request is ignored, so the keyboard can cover the bottom of the editor. The example app is in that group, since Briefcase targets SDK 36. +- On Android, the widget asks the window to shrink its content when the soft keyboard appears, so the editor scrolls internally and the rest of the layout stays put. It only does this when the app has not chosen a soft-input mode itself. Android deprecated that request in API 30. On Android 15 and later, an app that targets SDK 35 or later runs edge to edge, and the request is ignored, so the keyboard can cover the bottom of the editor. Android 15 still lets such an app opt out of edge to edge; Android 16 does not for apps that target SDK 36. The example app is in that group, since Briefcase targets SDK 36. ## Developing From 0f2d1c4075b1265b307fb51f66c1f6acf460abef Mon Sep 17 00:00:00 2001 From: Kattni Date: Sat, 10 Oct 2026 00:33:30 -0400 Subject: [PATCH 08/21] Reuse Toga's placeholder constraints, cache gutter attributes, and guard the trait descriptor on iOS --- src/toga_code_editor/iOS_code_editor.py | 92 +++++++++---------------- 1 file changed, 33 insertions(+), 59 deletions(-) diff --git a/src/toga_code_editor/iOS_code_editor.py b/src/toga_code_editor/iOS_code_editor.py index 5676b34..c3d675d 100644 --- a/src/toga_code_editor/iOS_code_editor.py +++ b/src/toga_code_editor/iOS_code_editor.py @@ -15,12 +15,7 @@ NSAttributedString, NSFontAttributeName, NSForegroundColorAttributeName, - NSLayoutAttributeBottom, NSLayoutAttributeLeading, - NSLayoutAttributeTop, - NSLayoutAttributeTrailing, - NSLayoutConstraint, - NSLayoutRelationEqual, UIColor, UIFont, UIFontDescriptorTraitBold, @@ -49,7 +44,6 @@ UITextSmartInsertDeleteTypeNo = 1 GUTTER_PADDING = 6 -PLACEHOLDER_LEADING = 4.0 class TogaCodeTextView(TogaMultilineTextView): @@ -103,9 +97,12 @@ def create(self): self.native.placeholder_label = self.placeholder_label # UITextView.font and .textColor read back the *first character's* attributes - # once the storage is styled, so the base style is tracked here instead. + # once the storage is styled, so the base style is tracked here instead. Unlike + # an NSTextView, a UITextView created with no text reports no font, so every + # use of base_font is guarded until set_font supplies one. self.base_font = self.native.font self.base_color = UIColor.labelColor + self.rebuild_gutter_attributes() self.theme = {} self.attributes = {} @@ -119,52 +116,17 @@ def create(self): self.text_changed() def constrain_placeholder_label(self): - # Same constraints as toga_iOS, but keeping a reference to the leading one - # so the gutter can push the placeholder right. - self.placeholder_leading = NSLayoutConstraint.constraintWithItem( - self.placeholder_label, - attribute__1=NSLayoutAttributeLeading, - relatedBy=NSLayoutRelationEqual, - toItem=self.native, - attribute__2=NSLayoutAttributeLeading, - multiplier=1.0, - constant=PLACEHOLDER_LEADING, - ) - trailing_constraint = NSLayoutConstraint.constraintWithItem( - self.placeholder_label, - attribute__1=NSLayoutAttributeTrailing, - relatedBy=NSLayoutRelationEqual, - toItem=self.native, - attribute__2=NSLayoutAttributeTrailing, - multiplier=1.0, - constant=0, - ) - top_constraint = NSLayoutConstraint.constraintWithItem( - self.placeholder_label, - attribute__1=NSLayoutAttributeTop, - relatedBy=NSLayoutRelationEqual, - toItem=self.native, - attribute__2=NSLayoutAttributeTop, - multiplier=1.0, - constant=8.0, - ) - bottom_constraint = NSLayoutConstraint.constraintWithItem( - self.placeholder_label, - attribute__1=NSLayoutAttributeBottom, - relatedBy=NSLayoutRelationEqual, - toItem=self.native, - attribute__2=NSLayoutAttributeBottom, - multiplier=1.0, - constant=0, - ) - self.native.addConstraints( - [ - self.placeholder_leading, - trailing_constraint, - top_constraint, - bottom_constraint, - ] + super().constrain_placeholder_label() + # Keep a handle on Toga's leading constraint, and its original inset, so + # the gutter can push the placeholder right. + label = self.placeholder_label.ptr.value + self.placeholder_leading = next( + constraint + for constraint in self.native.constraints() + if constraint.firstItem.ptr.value == label + and constraint.firstAttribute == NSLayoutAttributeLeading ) + self.placeholder_inset = self.placeholder_leading.constant # Inherited MultilineTextInput methods that must keep the gutter and colors in sync @@ -176,6 +138,7 @@ def set_font(self, font): super().set_font(font) self.base_font = font._impl.native self.rebuild_attributes() + self.rebuild_gutter_attributes() self.apply_highlights() self.text_changed() @@ -212,10 +175,15 @@ def rebuild_attributes(self): if style.italic: traits |= UIFontDescriptorTraitItalic if traits and base_font is not None: - # If there is no font with the requested traits, this returns None. - font = UIFont.fontWithDescriptor( - base_font.fontDescriptor.fontDescriptorWithSymbolicTraits(traits), - size=base_font.pointSize, + # A font without a face for the requested traits yields no descriptor, + # and a descriptor can still yield no font. + descriptor = base_font.fontDescriptor.fontDescriptorWithSymbolicTraits( + traits + ) + font = ( + None + if descriptor is None + else UIFont.fontWithDescriptor(descriptor, size=base_font.pointSize) ) if font is not None: attributes[NSFontAttributeName] = font @@ -262,15 +230,21 @@ def text_changed(self): self.native.textContainerInset = UIEdgeInsets( inset.top, inset.left + width, inset.bottom, inset.right ) - self.placeholder_leading.constant = PLACEHOLDER_LEADING + width + self.placeholder_leading.constant = self.placeholder_inset + width self.native.setNeedsDisplay() - def gutter_label(self, text): + def rebuild_gutter_attributes(self): + # Built once per font, rather than once per visible line per draw. attributes = NSMutableDictionary.alloc().init() if self.base_font is not None: attributes[NSFontAttributeName] = self.base_font attributes[NSForegroundColorAttributeName] = UIColor.secondaryLabelColor - return NSAttributedString.alloc().initWithString(text, attributes=attributes) + self.gutter_attributes = attributes + + def gutter_label(self, text): + return NSAttributedString.alloc().initWithString( + text, attributes=self.gutter_attributes + ) def draw_line_numbers(self): if not self.show_line_numbers: From 629a27c18d8484fcea84a59513df10c78011f492 Mon Sep 17 00:00:00 2001 From: Kattni Date: Sat, 10 Oct 2026 00:38:29 -0400 Subject: [PATCH 09/21] Drop the dead font check on iOS; only the trait descriptor can be nil --- src/toga_code_editor/iOS_code_editor.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/src/toga_code_editor/iOS_code_editor.py b/src/toga_code_editor/iOS_code_editor.py index c3d675d..efcd144 100644 --- a/src/toga_code_editor/iOS_code_editor.py +++ b/src/toga_code_editor/iOS_code_editor.py @@ -175,18 +175,14 @@ def rebuild_attributes(self): if style.italic: traits |= UIFontDescriptorTraitItalic if traits and base_font is not None: - # A font without a face for the requested traits yields no descriptor, - # and a descriptor can still yield no font. + # A font without a face for the requested traits yields no descriptor. descriptor = base_font.fontDescriptor.fontDescriptorWithSymbolicTraits( traits ) - font = ( - None - if descriptor is None - else UIFont.fontWithDescriptor(descriptor, size=base_font.pointSize) - ) - if font is not None: - attributes[NSFontAttributeName] = font + if descriptor is not None: + attributes[NSFontAttributeName] = UIFont.fontWithDescriptor( + descriptor, size=base_font.pointSize + ) self.attributes[kind] = attributes def apply_highlights(self): From 6d4b1f1da50c6b7f9ba1e5c8fde6eb798832e509 Mon Sep 17 00:00:00 2001 From: Kattni Date: Sat, 10 Oct 2026 00:38:30 -0400 Subject: [PATCH 10/21] Coalesce gutter rebuilds and repost them after highlighting on Android --- src/toga_code_editor/android_code_editor.py | 24 +++++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/src/toga_code_editor/android_code_editor.py b/src/toga_code_editor/android_code_editor.py index f20bb9e..064dfb9 100644 --- a/src/toga_code_editor/android_code_editor.py +++ b/src/toga_code_editor/android_code_editor.py @@ -40,7 +40,7 @@ def onLayoutChange( # logical line. Defer the rebuild until this layout pass has finished. with suppress_reference_error(): if (right - left, bottom - top) != (o_right - o_left, o_bottom - o_top): - self.impl.native.post(self.impl.gutter_updater) + self.impl.post_gutter_update() class TogaGutterTouchListener(dynamic_proxy(View.OnTouchListener)): @@ -68,7 +68,9 @@ def __init__(self, interface): self.gutter = TextView(self._native_activity) self.gutter.setId(View.generateViewId()) self.gutter.setGravity(Gravity.END | Gravity.TOP) - self.gutter.setTextColor(self.native.getCurrentHintTextColor()) + self.gutter.setTextColor(self.native.getHintTextColors()) + # A column of line numbers is noise to a screen reader. + self.gutter.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO) # Without a movement method, a TextView scrolls back to its start on the # first draw after setText(), which would undo the scroll sync every time # the numbers are rebuilt. The movement method also makes the view @@ -111,7 +113,8 @@ def create(self): self.disable_suggestions() self.prefer_keyboard_resize() self.theme = {} - self.spans = [] + # Android keeps the spans itself, so only the native objects to remove are + # tracked here; set_font does not need to re-apply them. self.active_spans = [] def prefer_keyboard_resize(self): @@ -145,14 +148,14 @@ def set_readonly(self, readonly): def set_font(self, font): super().set_font(font) self.sync_gutter_font() - self.native.post(self.gutter_updater) + self.post_gutter_update() def _on_change(self): self.interface._schedule_rehighlight() self.interface.on_change() # The text layout is rebuilt after this callback returns; update the # gutter once that has happened. - self.native.post(self.gutter_updater) + self.post_gutter_update() # CodeEditor backend contract @@ -160,7 +163,6 @@ def set_theme(self, theme): self.theme = theme def set_highlights(self, spans): - self.spans = spans editable = self.native.getText() for span in self.active_spans: editable.removeSpan(span) @@ -181,6 +183,8 @@ def set_highlights(self, spans): native_span, span.start, span.end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE ) self.active_spans.append(native_span) + # A bold or italic span can re-wrap a line without any text change. + self.post_gutter_update() def set_show_line_numbers(self, value): # A GONE anchor collapses to zero width, so the editor fills the row. @@ -196,10 +200,16 @@ def sync_gutter_font(self): ) self.gutter.setIncludeFontPadding(self.native.getIncludeFontPadding()) + def post_gutter_update(self): + # Rebuild once after the pending layout pass, however many changes precede it. + self.native.removeCallbacks(self.gutter_updater) + self.native.post(self.gutter_updater) + def update_gutter(self): layout = self.native.getLayout() if layout is None: - # No layout pass has happened yet; the next change will post again. + # No layout pass has happened yet; the layout listener posts again when + # the first one finishes. return text = self.native.getText() numbers = [] From 20b87a95617533b7a97cf2e2256e79c299e46114 Mon Sep 17 00:00:00 2001 From: Kattni Date: Sat, 10 Oct 2026 00:38:32 -0400 Subject: [PATCH 11/21] Filter the sample listing and use one toolbar row on desktop --- examples/editor/src/editor/app.py | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/examples/editor/src/editor/app.py b/examples/editor/src/editor/app.py index bf2415d..0d19b9f 100644 --- a/examples/editor/src/editor/app.py +++ b/examples/editor/src/editor/app.py @@ -2,10 +2,12 @@ import toga from toga.constants import COLUMN, ROW +from toga.platform import current_platform from toga_code_editor import CodeEditor, language_for_filename SAMPLES = Path(__file__).parent / "resources" / "samples" +MOBILE = current_platform in {"iOS", "android"} NO_LANGUAGE = "none" LANGUAGES = ["python", "json", "html", NO_LANGUAGE] @@ -14,7 +16,11 @@ class Editor(toga.App): def startup(self): self.editor = CodeEditor(flex=1, on_change=self.on_edit) self.sample = toga.Selection( - items=sorted(path.name for path in SAMPLES.iterdir()), + items=sorted( + path.name + for path in SAMPLES.iterdir() + if path.is_file() and not path.name.startswith(".") + ), on_change=self.load_sample, ) self.language = toga.Selection(items=LANGUAGES, on_change=self.set_language) @@ -23,21 +29,13 @@ def startup(self): ) self.status = toga.Label("", flex=1) - # Two rows so the controls fit a phone's width. + controls = [self.sample, self.language, self.line_numbers, self.status] + # Two rows on a phone, so the controls fit its width. + rows = [controls[:2], controls[2:]] if MOBILE else [controls] toolbar = toga.Box( children=[ - toga.Box( - children=[self.sample, self.language], - direction=ROW, - align_items="center", - gap=5, - ), - toga.Box( - children=[self.line_numbers, self.status], - direction=ROW, - align_items="center", - gap=5, - ), + toga.Box(children=row, direction=ROW, align_items="center", gap=5) + for row in rows ], direction=COLUMN, margin=5, From 3ad1ab973b5ce6dfe0152bbf6d19f556d22b000d Mon Sep 17 00:00:00 2001 From: Kattni Date: Sat, 10 Oct 2026 00:47:27 -0400 Subject: [PATCH 12/21] Keep a hint-color fallback and rebuild the Android gutter only when styled spans change --- .../specs/2026-10-06-code-editor-design.md | 2 +- src/toga_code_editor/android_code_editor.py | 18 ++++++++++++++---- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/docs/superpowers/specs/2026-10-06-code-editor-design.md b/docs/superpowers/specs/2026-10-06-code-editor-design.md index ab9c63c..0de3335 100644 --- a/docs/superpowers/specs/2026-10-06-code-editor-design.md +++ b/docs/superpowers/specs/2026-10-06-code-editor-design.md @@ -212,7 +212,7 @@ Whole-buffer re-lexing is the right first version. Pygments cannot resume mid-fi ### Shared contract -Each backend module subclasses its backend's `MultilineTextInput` implementation, so value, readonly, placeholder, scrolling, and layout are inherited. On top it implements `set_theme`, `set_highlights`, and `set_show_line_numbers`, calls `interface._schedule_rehighlight()` from its native change callback before `interface.on_change()`, and forces autocorrect and smart punctuation off at creation. Each backend keeps the last span list and re-applies it after `set_font` and `set_color`, because Toga's implementations of those repaint the whole text storage. +Each backend module subclasses its backend's `MultilineTextInput` implementation, so value, readonly, placeholder, scrolling, and layout are inherited. On top it implements `set_theme`, `set_highlights`, and `set_show_line_numbers`, calls `interface._schedule_rehighlight()` from its native change callback before `interface.on_change()`, and forces autocorrect and smart punctuation off at creation. Cocoa and iOS keep the last span list and re-apply it after `set_font` and `set_color`, because Toga's implementations of those repaint the whole text storage. Android's spans live in the `Editable` and survive both, so it keeps only the native span objects it must remove. ### Cocoa diff --git a/src/toga_code_editor/android_code_editor.py b/src/toga_code_editor/android_code_editor.py index 064dfb9..c8968f7 100644 --- a/src/toga_code_editor/android_code_editor.py +++ b/src/toga_code_editor/android_code_editor.py @@ -68,7 +68,10 @@ def __init__(self, interface): self.gutter = TextView(self._native_activity) self.gutter.setId(View.generateViewId()) self.gutter.setGravity(Gravity.END | Gravity.TOP) - self.gutter.setTextColor(self.native.getHintTextColors()) + # Themes always resolve a hint color; fall back to the text color if not. + self.gutter.setTextColor( + self.native.getHintTextColors() or self.native.getTextColors() + ) # A column of line numbers is noise to a screen reader. self.gutter.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO) # Without a movement method, a TextView scrolls back to its start on the @@ -114,8 +117,9 @@ def create(self): self.prefer_keyboard_resize() self.theme = {} # Android keeps the spans itself, so only the native objects to remove are - # tracked here; set_font does not need to re-apply them. + # tracked here; set_font and set_color do not need to re-apply them. self.active_spans = [] + self.styled = False # whether active_spans holds a bold or italic span def prefer_keyboard_resize(self): # By default Android pans the whole window to keep the cursor above the soft @@ -167,6 +171,7 @@ def set_highlights(self, spans): for span in self.active_spans: editable.removeSpan(span) self.active_spans = [] + styled = False for span in to_utf16_spans(str(editable), spans): style = self.theme.get(span.kind) @@ -178,13 +183,18 @@ def set_highlights(self, spans): Typeface.ITALIC if style.italic else Typeface.NORMAL ) native_spans.append(StyleSpan(typeface_style)) + styled = True for native_span in native_spans: editable.setSpan( native_span, span.start, span.end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE ) self.active_spans.append(native_span) - # A bold or italic span can re-wrap a line without any text change. - self.post_gutter_update() + + # A bold or italic span can re-wrap a line without any text change, so + # rebuild the gutter when one was added or removed. + if styled or self.styled: + self.post_gutter_update() + self.styled = styled def set_show_line_numbers(self, value): # A GONE anchor collapses to zero width, so the editor fills the row. From 1d1f8b243aa7186f4fdde0f938cbb3295c76858d Mon Sep 17 00:00:00 2001 From: Kattni Date: Sat, 10 Oct 2026 01:00:24 -0400 Subject: [PATCH 13/21] Align the spec and README with the debounce task, gutter color, and read-only theme --- README.md | 4 ++-- docs/superpowers/specs/2026-10-06-code-editor-design.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 1fc34b5..356f29a 100644 --- a/README.md +++ b/README.md @@ -33,11 +33,11 @@ editor.language = language_for_filename(path) - `show_line_numbers`: shows or hides the gutter. Default `True`. - `theme`: a mapping from `TokenKind` to `Style`. `None` selects the built-in `DEFAULT_THEME`. -Unless you give the widget a font family, it uses a monospace font. Autocorrect, smart quotes, smart dashes, auto-capitalization, and spell checking are always off. +Unless you give the widget a font family, it uses a monospace font. Autocorrect, smart quotes, smart dashes, smart insert and delete, auto-capitalization, and spell checking are always off. ### Themes -A theme is a plain mapping. Kinds you leave out are drawn in the widget's normal text color. +A theme is a plain mapping. Kinds you leave out are drawn in the widget's normal text color. `DEFAULT_THEME` is read-only, so unpack it into a new dict to change it, as below. ```python from toga_code_editor import DEFAULT_THEME, Style, TokenKind diff --git a/docs/superpowers/specs/2026-10-06-code-editor-design.md b/docs/superpowers/specs/2026-10-06-code-editor-design.md index 0de3335..6458c37 100644 --- a/docs/superpowers/specs/2026-10-06-code-editor-design.md +++ b/docs/superpowers/specs/2026-10-06-code-editor-design.md @@ -197,7 +197,7 @@ Spans carry Python code-point indices. NSString, UITextView, and Android's `Span The interface calls two backend methods: `set_theme(theme)` when the theme changes, so the backend can build its native attribute set once per kind, and `set_highlights(spans)` to paint. - Programmatic `value`, `language`, or `theme` assignment re-highlights synchronously and cancels any pending debounce. -- A native edit reaches the backend's existing change callback, which calls the interface hook `_schedule_rehighlight()` and then the user's `on_change()`. The hook cancels any pending `call_later` handle on the running asyncio loop and schedules a new one after `REHIGHLIGHT_DELAY`, a module constant of 150 milliseconds. The callback re-lexes the whole buffer. +- A native edit reaches the backend's existing change callback, which calls the interface hook `_schedule_rehighlight()` and then the user's `on_change()`. The hook cancels any pending re-highlight task on the running asyncio loop and schedules a new one after `REHIGHLIGHT_DELAY`, a module constant of 150 milliseconds; a done callback hands any failure to the loop's exception handler. The task re-lexes the whole buffer. Whole-buffer re-lexing is the right first version. Pygments cannot resume mid-file, and a few thousand lines re-lex well within the delay on desktop. Partial or off-thread re-lexing is a future option with a known seam. @@ -234,7 +234,7 @@ There is no ruler on iOS. Reserve the gutter with `textContainerInset.left` and Subclass `toga_android.widgets.multilinetextinput.MultilineTextInput`. Chaquopy implements Java interfaces from Python through `dynamic_proxy` but cannot subclass Java classes at runtime, so there is no `onDraw` to override. The `EditText` stays `self.native`, which keeps every inherited method working. -The gutter is a `TextView` added to `native_toplevel`, the `RelativeLayout` that `ContainedWidget` already creates. Give it a generated view id, align it to the parent's left edge, and replace the edit text's layout params with ones that add a `RIGHT_OF` rule pointing at the gutter. Its text is a column of numbers rebuilt from the edit text's `Layout`: for each visual line, the logical line number if `getLineStart` is zero or follows a newline, else a blank. It shares typeface, size, padding, and line spacing with the editor through the backend's `set_textview_font` helper so rows align, is right-aligned, and uses the secondary text color. Rebuild it from the change callback by posting a `Runnable` through `View.post`, so it runs after the pending layout pass, and on `set_show_line_numbers`, which toggles its visibility between `VISIBLE` and `GONE`. A `View.OnScrollChangeListener` on the edit text, the pattern `ScrollContainer` already uses, copies the vertical scroll offset onto the gutter. +The gutter is a `TextView` added to `native_toplevel`, the `RelativeLayout` that `ContainedWidget` already creates. Give it a generated view id, align it to the parent's left edge, and replace the edit text's layout params with ones that add a `RIGHT_OF` rule pointing at the gutter. Its text is a column of numbers rebuilt from the edit text's `Layout`: for each visual line, the logical line number if `getLineStart` is zero or follows a newline, else a blank. It shares typeface, size, padding, and line spacing with the editor through the backend's `set_textview_font` helper so rows align, is right-aligned, and uses the hint text color. Rebuild it from the change callback by posting a `Runnable` through `View.post`, so it runs after the pending layout pass, and on `set_show_line_numbers`, which toggles its visibility between `VISIBLE` and `GONE`. A `View.OnScrollChangeListener` on the edit text, the pattern `ScrollContainer` already uses, copies the vertical scroll offset onto the gutter. Highlights are `ForegroundColorSpan` and `StyleSpan` objects applied to the `Editable` with `SPAN_EXCLUSIVE_EXCLUSIVE`, kept in a list so the previous set can be removed before the next is applied. Span changes do not notify the text watcher. From 128625f59ef89af0e279fe8689b5a5e52312612e Mon Sep 17 00:00:00 2001 From: Kattni Date: Sat, 10 Oct 2026 02:18:15 -0400 Subject: [PATCH 14/21] Pin the theme at paint time, type the Style constructor, and decode byte paths --- src/toga_code_editor/code_editor.py | 14 +++++++++++--- src/toga_code_editor/dummy_code_editor.py | 2 ++ src/toga_code_editor/highlighting.py | 13 ++++++++----- tests/test_code_editor.py | 13 ++++++++++--- tests/test_highlighting.py | 4 +++- 5 files changed, 34 insertions(+), 12 deletions(-) diff --git a/src/toga_code_editor/code_editor.py b/src/toga_code_editor/code_editor.py index 9633f57..88ffd9a 100644 --- a/src/toga_code_editor/code_editor.py +++ b/src/toga_code_editor/code_editor.py @@ -27,12 +27,20 @@ def _highlighter_for(language: str | None) -> Highlighter: return NullHighlighter() if language is None else PygmentsHighlighter(language) +def _theme_for(theme: Theme | None) -> Theme: + return DEFAULT_THEME if theme is None else theme + + def _report_rehighlight_failure(task: asyncio.Task) -> None: # Hand a failure to the loop's exception handler as soon as the task finishes. # Otherwise asyncio only reports it when the task object is garbage collected. if not task.cancelled() and (exc := task.exception()) is not None: task.get_loop().call_exception_handler( - {"message": "Re-highlighting the code editor failed", "exception": exc} + { + "message": "Re-highlighting the code editor failed", + "exception": exc, + "task": task, + } ) @@ -79,7 +87,7 @@ def __init__( # paints the initial value once, with the highlighter and theme in hand. self._highlighter: Highlighter = _highlighter_for(language) self._language = language - self._theme: Theme = DEFAULT_THEME if theme is None else theme + self._theme: Theme = _theme_for(theme) self._show_line_numbers = True self._pending_rehighlight: asyncio.Task | None = None @@ -144,7 +152,7 @@ def theme(self) -> Theme: @theme.setter def theme(self, value: Theme | None) -> None: - self._theme = DEFAULT_THEME if value is None else value + self._theme = _theme_for(value) self._impl.set_theme(self._theme) self._rehighlight() diff --git a/src/toga_code_editor/dummy_code_editor.py b/src/toga_code_editor/dummy_code_editor.py index 33dd190..d1b4cfb 100644 --- a/src/toga_code_editor/dummy_code_editor.py +++ b/src/toga_code_editor/dummy_code_editor.py @@ -9,6 +9,8 @@ def set_theme(self, theme): self._set_value("theme", theme) def set_highlights(self, spans): + # Record the theme in force at paint time, as a real backend would use it. + self._action("paint", theme=self._get_value("theme", None)) self._set_value("highlights", spans) def set_show_line_numbers(self, value): diff --git a/src/toga_code_editor/highlighting.py b/src/toga_code_editor/highlighting.py index 4dcc786..b1c9021 100644 --- a/src/toga_code_editor/highlighting.py +++ b/src/toga_code_editor/highlighting.py @@ -3,7 +3,7 @@ from collections.abc import Iterable, Mapping from dataclasses import dataclass from enum import StrEnum -from os import PathLike, fspath +from os import PathLike, fsdecode from types import MappingProxyType from typing import Protocol @@ -63,8 +63,11 @@ class Style: bold: bool = False italic: bool = False - def __post_init__(self): - object.__setattr__(self, "color", Color.parse(self.color)) + # Written out so the constructor's type is Color | str while the field is Color. + def __init__(self, color: Color | str, bold: bool = False, italic: bool = False): + object.__setattr__(self, "color", Color.parse(color)) + object.__setattr__(self, "bold", bold) + object.__setattr__(self, "italic", italic) Theme = Mapping[TokenKind, Style] @@ -164,12 +167,12 @@ def highlight(self, text: str) -> list[Span]: ) -def language_for_filename(path: str | PathLike) -> str | None: +def language_for_filename(path: str | bytes | PathLike) -> str | None: """Return the Pygments lexer alias for a filename, or ``None`` if there is none. The whole filename is matched, so names such as ``Makefile`` resolve. """ - lexer_class = find_lexer_class_for_filename(fspath(path)) + lexer_class = find_lexer_class_for_filename(fsdecode(path)) return None if lexer_class is None else lexer_class.aliases[0] diff --git a/tests/test_code_editor.py b/tests/test_code_editor.py index c914da3..fdd85ba 100644 --- a/tests/test_code_editor.py +++ b/tests/test_code_editor.py @@ -3,7 +3,12 @@ import pytest from toga.fonts import MONOSPACE, SERIF, SYSTEM from toga.style import Pack -from toga_dummy.utils import EventLog, assert_action_performed, attribute_value +from toga_dummy.utils import ( + EventLog, + assert_action_performed, + assert_action_performed_with, + attribute_value, +) from toga_code_editor import DEFAULT_THEME, CodeEditor, Span, Style, TokenKind from toga_code_editor.code_editor import REHIGHLIGHT_DELAY @@ -75,7 +80,8 @@ def test_construction_highlights_once(app): editor = CodeEditor(value="x = 1", language="python", theme=theme) assert EventLog.values(editor, "highlights") == [X_EQUALS_ONE] - assert attribute_value(editor, "theme") is theme + # The backend had the theme before that paint. + assert_action_performed_with(editor, "paint", theme=theme) def test_value_rehighlights(app): @@ -130,4 +136,5 @@ def highlight(self, text): task = editor._pending_rehighlight await asyncio.sleep(REHIGHLIGHT_DELAY * 2) assert [type(context["exception"]) for context in reported] == [RuntimeError] - assert task.done() + assert reported[0]["message"] == "Re-highlighting the code editor failed" + assert reported[0]["task"] is task diff --git a/tests/test_highlighting.py b/tests/test_highlighting.py index 5063315..5ba368a 100644 --- a/tests/test_highlighting.py +++ b/tests/test_highlighting.py @@ -69,6 +69,7 @@ def test_utf16_offsets(): assert utf16_line_starts("") == [0] assert utf16_line_starts("a\n") == [0, 2] assert utf16_line_starts("\U0001f600\r\nb") == [0, 4] + assert utf16_line_starts("\ud800\nb") == [0, 2] # a lone surrogate is one unit class FSPath: @@ -84,7 +85,8 @@ def __fspath__(self): ("foo.py", "python"), ("Makefile", "make"), ("notes.xyz", None), - (FSPath(), "python"), + pytest.param(FSPath(), "python", id="pathlike"), + pytest.param(b"foo.py", "python", id="bytes"), ], ) def test_language_for_filename(name, expected): From b14334a43615e03066e049766430cfad110c1cac Mon Sep 17 00:00:00 2001 From: Kattni Date: Sat, 10 Oct 2026 02:18:16 -0400 Subject: [PATCH 15/21] Spell out the example's toolbar rows --- examples/editor/src/editor/app.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/examples/editor/src/editor/app.py b/examples/editor/src/editor/app.py index 0d19b9f..697bccd 100644 --- a/examples/editor/src/editor/app.py +++ b/examples/editor/src/editor/app.py @@ -29,9 +29,11 @@ def startup(self): ) self.status = toga.Label("", flex=1) - controls = [self.sample, self.language, self.line_numbers, self.status] - # Two rows on a phone, so the controls fit its width. - rows = [controls[:2], controls[2:]] if MOBILE else [controls] + # Two rows on mobile, so the controls fit a phone's width. + if MOBILE: + rows = [[self.sample, self.language], [self.line_numbers, self.status]] + else: + rows = [[self.sample, self.language, self.line_numbers, self.status]] toolbar = toga.Box( children=[ toga.Box(children=row, direction=ROW, align_items="center", gap=5) From fbb74225ed9199ce0ad31756d5428c27b1654dda Mon Sep 17 00:00:00 2001 From: Kattni Date: Sat, 10 Oct 2026 02:20:07 -0400 Subject: [PATCH 16/21] Read only plain text from the pasteboard, use textContainerOrigin, and name a missing placeholder constraint --- src/toga_code_editor/cocoa_code_editor.py | 23 ++++++++++++++++------- src/toga_code_editor/iOS_code_editor.py | 16 ++++++++++++---- 2 files changed, 28 insertions(+), 11 deletions(-) diff --git a/src/toga_code_editor/cocoa_code_editor.py b/src/toga_code_editor/cocoa_code_editor.py index c3dcd6a..656f859 100644 --- a/src/toga_code_editor/cocoa_code_editor.py +++ b/src/toga_code_editor/cocoa_code_editor.py @@ -6,6 +6,7 @@ NSRange, NSRect, ObjCClass, + objc_id, objc_method, objc_property, ) @@ -37,6 +38,8 @@ NSBoldFontMask = 1 << 1 GUTTER_PADDING = 6 +# NSPasteboardTypeString; toga_cocoa does not bind the pasteboard constants. +NSPasteboardTypeString = "public.utf8-plain-text" class TogaCodeTextView(TogaTextView): @@ -46,10 +49,16 @@ def textDidChange_(self, notification) -> None: self.interface.on_change() self.impl.text_changed() + # Rich text from another app would keep attributes that the re-highlight never + # resets. Reading only plain text from the pasteboard covers paste, drag and + # drop, and Services alike; the paste: override is belt and braces for Cmd-V. + + @objc_method + def readablePasteboardTypes(self) -> objc_id: + return [NSPasteboardTypeString] + @objc_method def paste_(self, sender) -> None: - # Rich text from another app would keep attributes that the re-highlight - # never resets, so paste the plain text only. self.pasteAsPlainText(sender) @@ -248,13 +257,13 @@ def draw_line_numbers(self): layout = self.layout_manager container = self.native_text.textContainer visible = self.native_text.visibleRect - inset = self.native_text.textContainerInset + # The layout manager works in container coordinates; textContainerOrigin is + # where the container sits in the view, inset included. + origin = self.native_text.textContainerOrigin text_length = self.native_text.textStorage.length() - # The layout manager works in container coordinates, which sit inset.width - # and inset.height inside the view's own. container_rect = NSRect( - NSPoint(visible.origin.x - inset.width, visible.origin.y - inset.height), + NSPoint(visible.origin.x - origin.x, visible.origin.y - origin.y), visible.size, ) glyph_range = layout.glyphRangeForBoundingRect( @@ -282,5 +291,5 @@ def draw_line_numbers(self): ) label = self.gutter_label(str(number)) x = thickness - GUTTER_PADDING - label.size().width - y = fragment.origin.y + inset.height - visible.origin.y + y = fragment.origin.y + origin.y - visible.origin.y label.drawAtPoint(NSPoint(x, y)) diff --git a/src/toga_code_editor/iOS_code_editor.py b/src/toga_code_editor/iOS_code_editor.py index efcd144..0a88f5d 100644 --- a/src/toga_code_editor/iOS_code_editor.py +++ b/src/toga_code_editor/iOS_code_editor.py @@ -121,11 +121,19 @@ def constrain_placeholder_label(self): # the gutter can push the placeholder right. label = self.placeholder_label.ptr.value self.placeholder_leading = next( - constraint - for constraint in self.native.constraints() - if constraint.firstItem.ptr.value == label - and constraint.firstAttribute == NSLayoutAttributeLeading + ( + constraint + for constraint in self.native.constraints() + if constraint.firstItem.ptr.value == label + and constraint.firstAttribute == NSLayoutAttributeLeading + ), + None, ) + if self.placeholder_leading is None: + raise RuntimeError( + "toga_iOS no longer gives its placeholder label a leading constraint; " + "the gutter cannot push the placeholder right" + ) self.placeholder_inset = self.placeholder_leading.constant # Inherited MultilineTextInput methods that must keep the gutter and colors in sync From d674be2d42dd43aba2d894604fce904a91f52e43 Mon Sep 17 00:00:00 2001 From: Kattni Date: Sat, 10 Oct 2026 02:24:38 -0400 Subject: [PATCH 17/21] Strip every color and style span on Android before re-highlighting --- src/toga_code_editor/android_code_editor.py | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/src/toga_code_editor/android_code_editor.py b/src/toga_code_editor/android_code_editor.py index c8968f7..337837b 100644 --- a/src/toga_code_editor/android_code_editor.py +++ b/src/toga_code_editor/android_code_editor.py @@ -116,10 +116,6 @@ def create(self): self.disable_suggestions() self.prefer_keyboard_resize() self.theme = {} - # Android keeps the spans itself, so only the native objects to remove are - # tracked here; set_font and set_color do not need to re-apply them. - self.active_spans = [] - self.styled = False # whether active_spans holds a bold or italic span def prefer_keyboard_resize(self): # By default Android pans the whole window to keep the cursor above the soft @@ -168,10 +164,15 @@ def set_theme(self, theme): def set_highlights(self, spans): editable = self.native.getText() - for span in self.active_spans: - editable.removeSpan(span) - self.active_spans = [] + # Strip every color and style span, not only the ones applied here, so + # formatting that arrived with pasted rich text is reset as well. Android + # keeps the spans in the Editable, so set_font and set_color need no + # re-apply and nothing is tracked between calls. styled = False + for span_class in (ForegroundColorSpan, StyleSpan): + for span in editable.getSpans(0, editable.length(), span_class): + editable.removeSpan(span) + styled |= span_class is StyleSpan for span in to_utf16_spans(str(editable), spans): style = self.theme.get(span.kind) @@ -188,13 +189,11 @@ def set_highlights(self, spans): editable.setSpan( native_span, span.start, span.end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE ) - self.active_spans.append(native_span) # A bold or italic span can re-wrap a line without any text change, so - # rebuild the gutter when one was added or removed. - if styled or self.styled: + # rebuild the gutter when one was present before or after this pass. + if styled: self.post_gutter_update() - self.styled = styled def set_show_line_numbers(self, value): # A GONE anchor collapses to zero width, so the editor fills the row. From 131e00cdd80b569b44fc3d59a9b7a9dddec2094d Mon Sep 17 00:00:00 2001 From: Kattni Date: Sat, 10 Oct 2026 02:24:39 -0400 Subject: [PATCH 18/21] Tighten two comments from the follow-up review --- examples/editor/src/editor/app.py | 2 +- src/toga_code_editor/highlighting.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/examples/editor/src/editor/app.py b/examples/editor/src/editor/app.py index 697bccd..3b3e7df 100644 --- a/examples/editor/src/editor/app.py +++ b/examples/editor/src/editor/app.py @@ -29,7 +29,7 @@ def startup(self): ) self.status = toga.Label("", flex=1) - # Two rows on mobile, so the controls fit a phone's width. + # Two rows on mobile, so the controls fit a narrow screen. if MOBILE: rows = [[self.sample, self.language], [self.line_numbers, self.status]] else: diff --git a/src/toga_code_editor/highlighting.py b/src/toga_code_editor/highlighting.py index b1c9021..05da557 100644 --- a/src/toga_code_editor/highlighting.py +++ b/src/toga_code_editor/highlighting.py @@ -63,7 +63,8 @@ class Style: bold: bool = False italic: bool = False - # Written out so the constructor's type is Color | str while the field is Color. + # Written out so the constructor's type is Color | str while the field is Color; + # keep its parameters in step with the fields above. def __init__(self, color: Color | str, bold: bool = False, italic: bool = False): object.__setattr__(self, "color", Color.parse(color)) object.__setattr__(self, "bold", bold) From da84bf367e0aa280ffd8eb401e3ddfc3d4e7459a Mon Sep 17 00:00:00 2001 From: Kattni Date: Sat, 10 Oct 2026 02:29:02 -0400 Subject: [PATCH 19/21] Keep accepting drops on macOS by naming the legacy plain-text pasteboard types --- src/toga_code_editor/cocoa_code_editor.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/toga_code_editor/cocoa_code_editor.py b/src/toga_code_editor/cocoa_code_editor.py index 656f859..424b838 100644 --- a/src/toga_code_editor/cocoa_code_editor.py +++ b/src/toga_code_editor/cocoa_code_editor.py @@ -38,8 +38,14 @@ NSBoldFontMask = 1 << 1 GUTTER_PADDING = 6 -# NSPasteboardTypeString; toga_cocoa does not bind the pasteboard constants. -NSPasteboardTypeString = "public.utf8-plain-text" +# The pasteboard types the editor reads; toga_cocoa binds none of these constants. +# NSTextView's drag-and-drop still matches the legacy names, so both spellings of +# "string" are needed, and the filenames type keeps "drop a file, get its path". +PLAIN_TEXT_PASTEBOARD_TYPES = [ + "public.utf8-plain-text", # NSPasteboardTypeString + "NSStringPboardType", + "NSFilenamesPboardType", +] class TogaCodeTextView(TogaTextView): @@ -50,12 +56,12 @@ def textDidChange_(self, notification) -> None: self.impl.text_changed() # Rich text from another app would keep attributes that the re-highlight never - # resets. Reading only plain text from the pasteboard covers paste, drag and - # drop, and Services alike; the paste: override is belt and braces for Cmd-V. + # resets. Reading only plain-text types from the pasteboard covers paste, drag + # and drop, and Services alike; the paste: override is belt and braces for Cmd-V. @objc_method def readablePasteboardTypes(self) -> objc_id: - return [NSPasteboardTypeString] + return PLAIN_TEXT_PASTEBOARD_TYPES @objc_method def paste_(self, sender) -> None: From 37aa67ef3505ac0cd1edb7e041471c4e40652c37 Mon Sep 17 00:00:00 2001 From: Kattni Date: Sat, 10 Oct 2026 02:47:27 -0400 Subject: [PATCH 20/21] Record Android 12 support and the Android 15 keyboard check in the README --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 356f29a..a2acde0 100644 --- a/README.md +++ b/README.md @@ -55,10 +55,11 @@ editor.theme = theme ## Platform notes - **macOS** uses an `NSRulerView` for the gutter and the system's adaptive color mapping, so theme colors follow dark mode. +- **Android** 12 and later are supported. The example app is checked on Android 12 and Android 15 emulators. - **iOS** and **Android** draw the gutter themselves. Theme colors do not change with the system appearance; the default theme is chosen to be legible on both light and dark backgrounds. - Highlighting re-lexes the whole buffer after a short pause in typing. Files of a few thousand lines are fine on a desktop; very large files are slower on phones. - Some third-party Android keyboards ignore the flag that disables suggestions. -- On Android, the widget asks the window to shrink its content when the soft keyboard appears, so the editor scrolls internally and the rest of the layout stays put. It only does this when the app has not chosen a soft-input mode itself. Android deprecated that request in API 30. On Android 15 and later, an app that targets SDK 35 or later runs edge to edge, and the request is ignored, so the keyboard can cover the bottom of the editor. Android 15 still lets such an app opt out of edge to edge; Android 16 does not for apps that target SDK 36. The example app is in that group, since Briefcase targets SDK 36. +- On Android, the widget asks the window to shrink its content when the soft keyboard appears, so the editor scrolls internally and the rest of the layout stays put. It only does this when the app has not chosen a soft-input mode itself. Android deprecated that request in API 30 in favour of handling window insets, so a future release may stop honouring it. With Briefcase's current template, which targets SDK 36 and enables edge-to-edge drawing, the layout still shrinks for the keyboard on Android 12 and Android 15 emulators. ## Developing From 991d907a26f9e4d1c24d2c30e81893abe8642e58 Mon Sep 17 00:00:00 2001 From: Kattni Date: Sat, 10 Oct 2026 19:57:55 -0400 Subject: [PATCH 21/21] Add the change note for #5 --- changes/5.bugfix.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changes/5.bugfix.md diff --git a/changes/5.bugfix.md b/changes/5.bugfix.md new file mode 100644 index 0000000..31d3564 --- /dev/null +++ b/changes/5.bugfix.md @@ -0,0 +1 @@ +Closed the minor findings from the initial review: the editor paints once at construction, re-highlight failures are reported immediately, pasted rich text is stripped on macOS and Android, and the default theme is read-only.