Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
97fa41c
Drop empty spans, convert paths with os.fspath, and scan line starts …
kattni Oct 10, 2026
d689297
Paint once at construction, report debounce failures, and freeze the …
kattni Oct 10, 2026
b26f04a
Pin both the operator and operator-word mappings in the Python snippet
kattni Oct 10, 2026
9a6d741
Bound toga-core below 0.6 and tighten the README and spec wording
kattni Oct 10, 2026
edfcc91
Clarify the toga-core range and the Android keyboard caveat in the RE…
kattni Oct 10, 2026
c16f0e9
Paste plain text, cache gutter attributes, and query the layout in co…
kattni Oct 10, 2026
c8bd1a5
Note that Android 16 removes the edge-to-edge opt-out for SDK 36 apps
kattni Oct 10, 2026
0f2d1c4
Reuse Toga's placeholder constraints, cache gutter attributes, and gu…
kattni Oct 10, 2026
629a27c
Drop the dead font check on iOS; only the trait descriptor can be nil
kattni Oct 10, 2026
6d4b1f1
Coalesce gutter rebuilds and repost them after highlighting on Android
kattni Oct 10, 2026
20b87a9
Filter the sample listing and use one toolbar row on desktop
kattni Oct 10, 2026
3ad1ab9
Keep a hint-color fallback and rebuild the Android gutter only when s…
kattni Oct 10, 2026
1d1f8b2
Align the spec and README with the debounce task, gutter color, and r…
kattni Oct 10, 2026
128625f
Pin the theme at paint time, type the Style constructor, and decode b…
kattni Oct 10, 2026
b14334a
Spell out the example's toolbar rows
kattni Oct 10, 2026
fbb7422
Read only plain text from the pasteboard, use textContainerOrigin, an…
kattni Oct 10, 2026
d674be2
Strip every color and style span on Android before re-highlighting
kattni Oct 10, 2026
131e00c
Tighten two comments from the follow-up review
kattni Oct 10, 2026
da84bf3
Keep accepting drops on macOS by naming the legacy plain-text pastebo…
kattni Oct 10, 2026
37aa67e
Record Android 12 support and the Android 15 keyboard check in the RE…
kattni Oct 10, 2026
3a396cd
Merge remote-tracking branch 'kattni/main'
kattni Oct 10, 2026
991d907
Add the change note for #5
kattni Oct 10, 2026
04f403f
Add a Winforms backend with a painted gutter and selection-based high…
kattni Oct 11, 2026
a95529b
Document the Windows backend
kattni Oct 11, 2026
843b351
Point Windows developers at the venv's Scripts directory
kattni Oct 11, 2026
87f559b
Keep the paint's restore chain unconditional and redraw the gutter on…
kattni Oct 11, 2026
4866701
Harden the paint's restore chain further, size the gutter at creation…
kattni Oct 12, 2026
635fc7e
Miscellaneous cleanups.
freakboy3742 Oct 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ __pycache__/
.coverage
.coverage.*
.DS_Store
.idea/
.kilo
.opencode
.pytest_cache/
.ruff_cache/
.rumdl_cache/
.tox/
.venv*
.vscode/
*.dist-info/
*.egg-info/
*.py[cod]
Expand Down
21 changes: 11 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,19 +1,16 @@
# toga-code-editor

A [Toga](https://toga.beeware.org) widget for editing code, with line numbers and syntax highlighting. `CodeEditor` is a `toga.MultilineTextInput` that colors its text with [Pygments](https://pygments.org) and shows a line-number gutter. It supports macOS, iOS, and Android.
A [Toga](https://toga.beeware.org) widget for editing code, with line numbers and syntax highlighting. `CodeEditor` is a `toga.MultilineTextInput` that colors its text with [Pygments](https://pygments.org) and shows a line-number gutter. It supports macOS, Windows, iOS, and Android.

## Installation

```console
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.

## Usage

```python
import toga
from toga_code_editor import CodeEditor, language_for_filename

editor = CodeEditor(
Expand All @@ -34,11 +31,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 Python mapping between "kinds" of content, and a text style definition. The `DEFAULT_THEME` can be customized:

```python
from toga_code_editor import DEFAULT_THEME, Style, TokenKind
Expand All @@ -51,15 +48,17 @@ 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, uses a kind of `TEXT`, which is never styled.

## Platform notes

- **macOS** uses an `NSRulerView` for the gutter and the system's adaptive color mapping, so theme colors follow dark mode.
- **Windows** paints the gutter beside a `RichTextBox` and colors text through RichEdit's character formatting, with undo recording suspended while it paints, so Ctrl+Z still undoes typing. Theme colors do not change with the system appearance; WinForms draws the editor light. Tab inserts a tab, and Ctrl+V pastes plain text.
- **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.
- 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

Expand All @@ -69,16 +68,18 @@ uv pip install -e . --group dev
.venv/bin/tox -m test
```

On Windows the virtual environment's scripts are in `.venv\Scripts` rather than `.venv/bin`; activate it with `.venv\Scripts\activate` before running `tox` or `briefcase`.

The test suite runs against Toga's dummy backend. Real backends are checked with the example app in `examples/editor`:

```console
cd examples/editor
briefcase dev # macOS
briefcase dev # macOS and Windows
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, Windows, 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 from the repository root before each Android run that should pick up widget changes:

```console
uv build --wheel
Expand Down
1 change: 1 addition & 0 deletions changes/5.bugfix.md
Original file line number Diff line number Diff line change
@@ -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.
23 changes: 16 additions & 7 deletions examples/editor/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@ license-files = ["../../LICENSE"]
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.
# macOS, Windows, and iOS install the widget from this repository; Android installs it
# from a built wheel (run `uv build --wheel` from the repository root first), because
# Gradle rejects a path requirement whose directory contains the Android build tree.
requires = []

[tool.briefcase.app.editor]
Expand All @@ -31,20 +31,29 @@ sources = ["src/editor"]
requires = [
"../..",
"toga-cocoa ~= 0.5.7",
"std-nslog >= 1.0.0",
"std-nslog~=2.0.0; python_version < '3.14'",
]

[tool.briefcase.app.editor.windows]
requires = [
"../..",
"toga-winforms ~= 0.5.7",
]

[tool.briefcase.app.editor.iOS]
requires = [
"../..",
"toga-iOS ~= 0.5.7",
"std-nslog >= 1.0.0",
]

[tool.briefcase.app.editor.android]
requirement_installer_args = [
"-f", "../../dist",
"--pre",
]
requires = [
"../../dist/toga_code_editor-0.1.0.dev0-py3-none-any.whl",
"toga-android ~= 0.5.7",
"toga_code_editor",
"toga-android ~= 0.5.7"
]
base_theme = "Theme.MaterialComponents.Light.DarkActionBar"
build_gradle_dependencies = [
Expand Down
28 changes: 14 additions & 14 deletions examples/editor/src/editor/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand All @@ -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)
Expand All @@ -23,21 +29,15 @@ def startup(self):
)
self.status = toga.Label("", flex=1)

# Two rows 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:
rows = [[self.sample, self.language, self.line_numbers, self.status]]
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,
Expand Down
6 changes: 5 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ classifiers = [
"Topic :: Software Development :: Widget Sets",
]
dependencies = [
"toga-core >= 0.5.7",
"toga-core >= 0.5.7, < 0.6",
"pygments >= 2.19",
]

Expand All @@ -46,6 +46,9 @@ CodeEditor = "toga_code_editor.platforms.iOS:CodeEditor"
[project.entry-points."toga_code_editor.backend.toga_android"]
CodeEditor = "toga_code_editor.platforms.android:CodeEditor"

[project.entry-points."toga_code_editor.backend.toga_winforms"]
CodeEditor = "toga_code_editor.platforms.winforms:CodeEditor"

[dependency-groups]
# Extras used by developers *of* briefcase are pinned to specific versions to
# ensure environment consistency.
Expand Down Expand Up @@ -92,6 +95,7 @@ omit = [
"*/toga_code_editor/platforms/cocoa.py",
"*/toga_code_editor/platforms/iOS.py",
"*/toga_code_editor/platforms/android.py",
"*/toga_code_editor/platforms/winforms.py",
]

[tool.coverage.paths]
Expand Down
59 changes: 37 additions & 22 deletions src/toga_code_editor/code_editor.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@

from .highlighting import (
DEFAULT_THEME,
Highlighter,
NullHighlighter,
PygmentsHighlighter,
Theme,
Expand All @@ -23,6 +22,19 @@
"""Seconds to wait after the last native edit before re-highlighting."""


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,
"task": task,
}
)


class CodeEditor(toga.MultilineTextInput):
def __init__(
self,
Expand All @@ -48,25 +60,25 @@ def __init__(
user content to display.
:param on_change: A handler that will be invoked when the value of the widget
changes.
:param language: A Pygments lexer alias such as ``"python"``, or ``None`` for
:param language: A Pygments lexer alias such as `"python"`, or `None` for
no highlighting.
:param show_line_numbers: Whether to show the line-number gutter.
:param theme: A mapping from :class:`TokenKind` to :class:`Style`, or ``None``
:param theme: A mapping from :class:`TokenKind` to :class:`Style`, or `None`
for the default theme.
:param kwargs: Initial style properties. Unless a font family is given here or
on ``style``, the editor uses a monospace font.
on `style`, the editor uses a monospace font.
"""
if "font_family" not in kwargs and (
style is None or "font_family" not in style
):
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
# Prime the initial state of the widget.
self._language = None
self._highlighter = NullHighlighter()
self._theme = DEFAULT_THEME
self._show_line_numbers = True
self._pending_rehighlight = None
self._pending_rehighlight: asyncio.Task | None = None

super().__init__(
id=id,
Expand All @@ -78,9 +90,9 @@ def __init__(
**kwargs,
)

self.show_line_numbers = show_line_numbers
self.theme = theme
self.language = language
self.show_line_numbers = show_line_numbers

@cached_property
def factory(self):
Expand All @@ -97,7 +109,8 @@ 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)
return impl

@toga.MultilineTextInput.value.setter
def value(self, value: object) -> None:
Expand All @@ -106,23 +119,25 @@ def value(self, value: object) -> None:

@property
def language(self) -> str | None:
"""The Pygments lexer alias used for highlighting, or ``None`` for none.
"""The Pygments lexer alias used for highlighting, or `None` for none.

Setting an alias Pygments does not know raises :exc:`ValueError` and leaves
the previous language in place.
:raises ValueError: if an unknown language is specified.
"""
return self._language

@language.setter
def language(self, value: str | None) -> None:
highlighter = NullHighlighter() if value is None else PygmentsHighlighter(value)
# Define the highlighter first - this will raise an error if the
# language is not recognized.
self._highlighter = (
NullHighlighter() if value is None else PygmentsHighlighter(value)
)
self._language = value
self._highlighter = highlighter
self._rehighlight()

@property
def theme(self) -> Theme:
"""The mapping from token kind to style; ``None`` restores the default."""
"""The mapping from token kind to style; `None` restores the default."""
return self._theme

@theme.setter
Expand All @@ -149,14 +164,14 @@ def _schedule_rehighlight(self) -> None:
"""Called by the backend when the user edits the text.

Re-lexing on every keystroke would be wasteful, so wait for a short pause.
This is a task rather than a bare ``call_later`` because Toga's Android event
loop only arms its next wakeup for work scheduled through ``call_soon``; a
This is a task rather than a bare `call_later` because Toga's Android event
loop only arms its next wakeup for work scheduled through `call_soon`; a
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)
Expand Down
Loading
Loading