Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
17 changes: 10 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# 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, Windows, 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, Android, and Linux (both GTK and Qt).

## Installation

Expand Down Expand Up @@ -55,7 +55,8 @@ editor.theme = theme
- **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.
- **iOS**, **Android**, **GTK**, and **Qt** 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. On GTK the gutter background follows the theme's styling for text view borders.
- **Linux** needs GTK 3 for the GTK backend; Toga 0.5.7 has no working multiline text input on GTK 4 yet. The Qt backend uses PySide6. Both backends register as the `linux` platform, so an app that installs both must set `TOGA_BACKEND` to `toga_gtk` or `toga_qt`. Both backends were checked on Ubuntu 26.04.
- 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 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.
Expand All @@ -74,17 +75,19 @@ The test suite runs against Toga's dummy backend. Real backends are checked with

```console
cd examples/editor
briefcase dev # macOS and Windows
briefcase run iOS
briefcase run android
briefcase dev -a testbed # macOS, Windows, or Linux (GTK backend)
briefcase dev -a testbed-qt # Linux (Qt backend)
briefcase run iOS -a testbed # iOS
```

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:
On macOS, iOS, Windows, and Linux, the example installs the widget straight from this repository; add `-r` after changing the widget source so Briefcase reinstalls it. Running under Linux will also require any system dependencies; see [Toga's platform documentation](https://toga.beeware.org/en/stable/reference/platforms/linux/) for details.

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
cd examples/editor
briefcase run android -r
briefcase run android -a testbed -r
```

Every user-visible change needs a fragment in `changes/`, named `<issue>.<kind>.md` where kind is `feature`, `bugfix`, `doc`, or `misc`. Release notes are assembled with `towncrier build`.
Expand Down
1 change: 1 addition & 0 deletions changes/6.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added GTK and Qt backends, so `CodeEditor` now runs on Linux.
22 changes: 21 additions & 1 deletion examples/editor/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ license-files = ["../../LICENSE"]
author = "Kattni"
author_email = "hello@kattni.com"
description = "A code editor demonstrating the toga-code-editor widget."
# macOS, Windows, and iOS install the widget from this repository; Android installs it
# macOS, Windows, Linux 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 = []
Expand All @@ -34,6 +34,14 @@ requires = [
"std-nslog~=2.0.0; python_version < '3.14'",
]

[tool.briefcase.app.editor.linux]
# Toga refuses to guess when both Linux backends are installed, so the example
# requires GTK only. The README has the Qt recipe.
requires = [
"../..",
"toga-gtk ~= 0.5.7",
]

[tool.briefcase.app.editor.windows]
requires = [
"../..",
Expand Down Expand Up @@ -77,3 +85,15 @@ android {

[tool.setuptools_scm]
root = "../../"

[tool.briefcase.app.editor-qt]
formal_name = "Editor"
sources = [
"src/editor_qt",
"src/editor",
]

requires = [
"../..",
"toga-qt[system] ~= 0.5.7",
]
Empty file.
4 changes: 4 additions & 0 deletions examples/editor/src/editor_qt/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from editor.app import main

if __name__ == "__main__":
main().main_loop()
8 changes: 8 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ 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_gtk"]
CodeEditor = "toga_code_editor.platforms.gtk:CodeEditor"

[project.entry-points."toga_code_editor.backend.toga_qt"]
CodeEditor = "toga_code_editor.platforms.qt:CodeEditor"

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

Expand Down Expand Up @@ -95,6 +101,8 @@ omit = [
"*/toga_code_editor/platforms/cocoa.py",
"*/toga_code_editor/platforms/iOS.py",
"*/toga_code_editor/platforms/android.py",
"*/toga_code_editor/platforms/gtk.py",
"*/toga_code_editor/platforms/qt.py",
"*/toga_code_editor/platforms/winforms.py",
]

Expand Down
154 changes: 154 additions & 0 deletions src/toga_code_editor/platforms/gtk.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
from toga.handlers import WeakrefCallable
from toga_gtk.colors import native_color
from toga_gtk.libs import Gdk, Gtk, Pango, PangoCairo
from toga_gtk.widgets.multilinetextinput import MultilineTextInput

GUTTER_PADDING = 6


class CodeEditor(MultilineTextInput):
def create(self):
self.setting_value = False

# Toga builds the scrolled window, the text view, the content buffer, and the
# placeholder buffer, and connects the content buffer's "changed" signal to
# gtk_on_changed through a weak reference to the bound method, so the
# override below is what gets connected.
super().create()

# Highlights are text tags on the content buffer, one per token kind.
self.tags = {}

# The gutter is the text view's left border window. GTK paints the view and
# the border window's themed background in its own draw handler; connecting
# after it paints the numbers on top.
self.gutter_visible = True
self.gutter_width = 0
self.native_textview.connect_after("draw", WeakrefCallable(self.gtk_on_draw))
self.update_gutter_width()

# Inherited MultilineTextInput methods that must keep the gutter in sync

def set_value(self, value):
# Toga's set_value ends by spinning the GTK main loop until no events are
# pending. The buffer's "changed" signal fires inside set_text, before that
# spin. During startup, before Toga's asyncio loop has run its first
# iteration, a task created there is never dispatched by those nested
# iterations and keeps them pending forever, so the editor hung on its first
# non-empty value. Skip the debounce for programmatic changes; the interface
# re-highlights synchronously right after set_value, so nothing is lost.
previous = self.setting_value
self.setting_value = True
try:
super().set_value(value)
finally:
self.setting_value = previous

def gtk_on_changed(self, *args):
# The re-highlight is scheduled before the user's handler runs, as on the
# other backends.
if not self.setting_value:
self.interface._schedule_rehighlight()
super().gtk_on_changed(*args)
self.native_textview.queue_draw()

# CodeEditor backend contract

def set_theme(self, theme):
# Removing a tag from the table also strips it from the text. The interface
# always follows set_theme with set_highlights, which paints again.
table = self.buffer.get_tag_table()
for tag in self.tags.values():
table.remove(tag)
self.tags = {}
for kind, style in theme.items():
# Only the properties the style needs, so the view's CSS font and color
# show through everywhere else.
properties = {"foreground_rgba": Gdk.RGBA(*native_color(style.color))}
if style.bold:
properties["weight"] = Pango.Weight.BOLD
if style.italic:
properties["style"] = Pango.Style.ITALIC
self.tags[kind] = self.buffer.create_tag(None, **properties)

def set_highlights(self, spans):
start, end = self.buffer.get_bounds()
self.buffer.remove_all_tags(start, end)
for span in spans:
tag = self.tags.get(span.kind)
if tag is not None:
# Buffer offsets are characters, so the spans apply unchanged. An
# offset past the end, from spans that describe the previous text,
# gives the end iterator rather than an error.
self.buffer.apply_tag(
tag,
self.buffer.get_iter_at_offset(span.start),
self.buffer.get_iter_at_offset(span.end),
)

def set_show_line_numbers(self, value):
self.gutter_visible = value
self.update_gutter_width()

# Gutter

def update_gutter_width(self):
if self.gutter_visible:
view = self.native_textview
digits = len(str(view.get_buffer().get_line_count()))
layout = view.create_pango_layout("0" * digits)
width = layout.get_pixel_size()[0] + 2 * GUTTER_PADDING
else:
width = 0
if width != self.gutter_width:
self.gutter_width = width
# A width of zero removes the window.
self.native_textview.set_border_window_size(Gtk.TextWindowType.LEFT, width)

def gtk_on_draw(self, view, cr):
window = view.get_window(Gtk.TextWindowType.LEFT)
if window is None or not Gtk.cairo_should_draw_window(cr, window):
return False
# A font change reaches the view through CSS on a later frame, so measure
# here rather than tracking when the font settles.
self.update_gutter_width()
cr.save()
Gtk.cairo_transform_to_window(cr, view, window)
cr.rectangle(0, 0, window.get_width(), window.get_height())
cr.clip()
self.draw_line_numbers(view, cr, window.get_width())
cr.restore()
return False

def draw_line_numbers(self, view, cr, width):
# Number the lines of whatever buffer the view displays. With the
# placeholder shown, that is its single line, which reads as line 1 of an
# empty document.
buffer = view.get_buffer()
# Ask for the insensitive color through a saved context: querying a
# state the view is not in is unsupported on the live one.
context = view.get_style_context()
context.save()
context.set_state(Gtk.StateFlags.INSENSITIVE)
color = context.get_color(context.get_state())
context.restore()
Gdk.cairo_set_source_rgba(cr, color)
visible = view.get_visible_rect()
bottom = visible.y + visible.height
first, _ = view.get_line_at_y(visible.y)
line = first.get_line()
# Walk line indices rather than calling forward_line(), which reports
# failure when it lands on the end iterator and would skip the empty line
# after a trailing newline.
count = buffer.get_line_count()
while line < count:
y, _ = view.get_line_yrange(buffer.get_iter_at_line(line))
if y > bottom:
break
_, window_y = view.buffer_to_window_coords(Gtk.TextWindowType.LEFT, 0, y)
layout = view.create_pango_layout(str(line + 1))
label_width, _ = layout.get_pixel_size()
# Align to the window as it is now; a queued resize may still be pending.
cr.move_to(width - GUTTER_PADDING - label_width, window_y)
PangoCairo.show_layout(cr, layout)
line += 1
Loading
Loading