From c3d0e6d62f07d5d1de71f2c101f5f65e1bac0937 Mon Sep 17 00:00:00 2001 From: Kattni Date: Sat, 10 Oct 2026 23:58:20 -0400 Subject: [PATCH 01/12] Add the GTK backend --- pyproject.toml | 4 + src/toga_code_editor/platforms/gtk.py | 136 ++++++++++++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 src/toga_code_editor/platforms/gtk.py diff --git a/pyproject.toml b/pyproject.toml index ba53a73..39dc30b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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_gtk"] +CodeEditor = "toga_code_editor.platforms.gtk:CodeEditor" + [project.entry-points."toga_code_editor.backend.toga_winforms"] CodeEditor = "toga_code_editor.platforms.winforms:CodeEditor" @@ -95,6 +98,7 @@ 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/winforms.py", ] diff --git a/src/toga_code_editor/platforms/gtk.py b/src/toga_code_editor/platforms/gtk.py new file mode 100644 index 0000000..f35f5c6 --- /dev/null +++ b/src/toga_code_editor/platforms/gtk.py @@ -0,0 +1,136 @@ +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): + # 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 gtk_on_changed(self, *args): + # The re-highlight is scheduled before the user's handler runs, as on the + # other backends. + self.interface._schedule_rehighlight() + super().gtk_on_changed(*args) + self.native_textview.queue_draw() + + def set_font(self, font): + super().set_font(font) + self.update_gutter_width() + + # 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) + cr.restore() + return False + + def draw_line_numbers(self, view, cr): + # 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() + Gdk.cairo_set_source_rgba( + cr, view.get_style_context().get_color(Gtk.StateFlags.INSENSITIVE) + ) + 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)) + width, _ = layout.get_pixel_size() + cr.move_to(self.gutter_width - GUTTER_PADDING - width, window_y) + PangoCairo.show_layout(cr, layout) + line += 1 From f7b5cc2cc6dead79b001da6b60b59b1981b56476 Mon Sep 17 00:00:00 2001 From: Kattni Date: Sun, 11 Oct 2026 00:09:45 -0400 Subject: [PATCH 02/12] Look up the gutter color through a saved style context --- src/toga_code_editor/platforms/gtk.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/toga_code_editor/platforms/gtk.py b/src/toga_code_editor/platforms/gtk.py index f35f5c6..eb3dc42 100644 --- a/src/toga_code_editor/platforms/gtk.py +++ b/src/toga_code_editor/platforms/gtk.py @@ -89,9 +89,7 @@ def update_gutter_width(self): 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 - ) + 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) @@ -113,9 +111,14 @@ def draw_line_numbers(self, view, cr): # placeholder shown, that is its single line, which reads as line 1 of an # empty document. buffer = view.get_buffer() - Gdk.cairo_set_source_rgba( - cr, view.get_style_context().get_color(Gtk.StateFlags.INSENSITIVE) - ) + # 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) From dbea98f26920f9a751247278ba249ca3f0ea6c78 Mon Sep 17 00:00:00 2001 From: Kattni Date: Sun, 11 Oct 2026 00:15:52 -0400 Subject: [PATCH 03/12] Add the Qt backend --- pyproject.toml | 4 + src/toga_code_editor/platforms/qt.py | 197 +++++++++++++++++++++++++++ 2 files changed, 201 insertions(+) create mode 100644 src/toga_code_editor/platforms/qt.py diff --git a/pyproject.toml b/pyproject.toml index 39dc30b..8296721 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,6 +49,9 @@ 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" @@ -99,6 +102,7 @@ omit = [ "*/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", ] diff --git a/src/toga_code_editor/platforms/qt.py b/src/toga_code_editor/platforms/qt.py new file mode 100644 index 0000000..0185c63 --- /dev/null +++ b/src/toga_code_editor/platforms/qt.py @@ -0,0 +1,197 @@ +from bisect import bisect_right + +from PySide6.QtCore import QRect, Qt +from PySide6.QtGui import ( + QBrush, + QFont, + QPainter, + QPalette, + QSyntaxHighlighter, + QTextCharFormat, +) +from PySide6.QtWidgets import QPlainTextEdit, QWidget +from toga_qt.colors import native_color +from toga_qt.widgets.multilinetextinput import MultilineTextInput + +from ..highlighting import to_utf16_spans + +GUTTER_PADDING = 6 + + +class TogaLineNumberArea(QWidget): + def __init__(self, editor): + super().__init__(editor) + self.editor = editor + + def paintEvent(self, event): + self.editor.draw_line_numbers(event) + + +class TogaCodeTextEdit(QPlainTextEdit): + # Owns the gutter geometry: setViewportMargins and the block geometry queries + # are protected in C++, which PySide exposes only inside a subclass. + + def __init__(self): + super().__init__() + self.gutter_width = 0 + self.gutter = TogaLineNumberArea(self) + + def set_gutter_width(self, width): + self.gutter_width = width + self.setViewportMargins(width, 0, 0, 0) + self.place_gutter() + + def place_gutter(self): + rect = self.contentsRect() + self.gutter.setGeometry( + QRect(rect.left(), rect.top(), self.gutter_width, rect.height()) + ) + + def resizeEvent(self, event): + super().resizeEvent(event) + self.place_gutter() + + def draw_line_numbers(self, event): + painter = QPainter(self.gutter) + painter.setFont(self.font()) + painter.setPen(self.palette().color(QPalette.ColorRole.PlaceholderText)) + height = self.fontMetrics().height() + label_width = self.gutter_width - GUTTER_PADDING + # A block is a logical line; its rectangle spans every wrapped visual line, + # so continuation lines get no number. + block = self.firstVisibleBlock() + geometry = self.blockBoundingGeometry(block).translated(self.contentOffset()) + top = round(geometry.top()) + bottom = top + round(self.blockBoundingRect(block).height()) + while block.isValid() and top <= event.rect().bottom(): + if block.isVisible() and bottom >= event.rect().top(): + painter.drawText( + 0, + top, + label_width, + height, + Qt.AlignmentFlag.AlignRight, + str(block.blockNumber() + 1), + ) + block = block.next() + top = bottom + bottom = top + round(self.blockBoundingRect(block).height()) + painter.end() + + +class TogaSpanHighlighter(QSyntaxHighlighter): + # Formats set here live in the text layout, not the document, so they never + # enter the undo stack and never travel with copied text. + + def __init__(self, document): + super().__init__(document) + self.formats = {} + self.spans = [] + self.starts = [] + + def set_spans(self, spans): + self.spans = spans + self.starts = [span.start for span in spans] + self.rehighlight() + + def highlightBlock(self, text): + # Document positions are UTF-16 code units and the text argument's Python + # length is in code points, so work from the block instead of the text. + block = self.currentBlock() + start = block.position() + end = start + block.length() + # Spans are sorted and disjoint, so the first that can reach this block is + # the last one starting before it. + index = max(bisect_right(self.starts, start) - 1, 0) + for span in self.spans[index:]: + if span.start >= end: + break + fmt = self.formats.get(span.kind) + low = max(span.start, start) + high = min(span.end, end) + if fmt is not None and high > low: + self.setFormat(low - start, high - low, fmt) + + +class CodeEditor(MultilineTextInput): + def create(self): + # Mirrors toga_qt's MultilineTextInput.create() with QPlainTextEdit, the Qt + # widget built for code, in place of QTextEdit. It holds only plain text, so + # Toga's setAcceptRichText(False) has no counterpart here. + self.native = TogaCodeTextEdit() + self.native.textChanged.connect(self.qt_on_change) + self.native.blockCountChanged.connect(self.qt_on_block_count_changed) + self.native.updateRequest.connect(self.qt_on_update_request) + + self.highlighter = TogaSpanHighlighter(self.native.document()) + self.applying_highlights = False + + self.gutter_visible = True + self.update_gutter_width() + + def qt_on_change(self): + # QSyntaxHighlighter.rehighlight() emits textChanged even though the text is + # untouched. Counting that as an edit would schedule another re-highlight, + # forever, and call the user's handler with nothing changed. + if self.applying_highlights: + return + # The re-highlight is scheduled before the user's handler runs, as on the + # other backends. + self.interface._schedule_rehighlight() + self.interface.on_change() + + def set_font(self, font): + super().set_font(font) + self.update_gutter_width() + + # CodeEditor backend contract + + def set_theme(self, theme): + # Formats set only weight and italic, so the widget font shows through. The + # interface always follows set_theme with set_highlights, which repaints. + formats = {} + for kind, style in theme.items(): + fmt = QTextCharFormat() + fmt.setForeground(QBrush(native_color(style.color))) + if style.bold: + fmt.setFontWeight(QFont.Weight.Bold) + if style.italic: + fmt.setFontItalic(True) + formats[kind] = fmt + self.highlighter.formats = formats + + def set_highlights(self, spans): + utf16_spans = to_utf16_spans(self.get_value(), spans) + self.applying_highlights = True + try: + self.highlighter.set_spans(utf16_spans) + finally: + self.applying_highlights = False + + def set_show_line_numbers(self, value): + self.gutter_visible = value + self.native.gutter.setVisible(value) + self.update_gutter_width() + + # Gutter + + def update_gutter_width(self): + if self.gutter_visible: + digits = len(str(self.native.blockCount())) + width = ( + self.native.fontMetrics().horizontalAdvance("0") * digits + + 2 * GUTTER_PADDING + ) + else: + width = 0 + self.native.set_gutter_width(width) + + def qt_on_block_count_changed(self, count): + self.update_gutter_width() + + def qt_on_update_request(self, rect, dy): + gutter = self.native.gutter + if dy: + gutter.scroll(0, dy) + else: + gutter.update(0, rect.y(), gutter.width(), rect.height()) From b5c4c336e3e3a85b47117d7b6d6dda27827dc424 Mon Sep 17 00:00:00 2001 From: Kattni Date: Sun, 11 Oct 2026 00:26:59 -0400 Subject: [PATCH 04/12] Bound the span walk per block in the Qt highlighter --- src/toga_code_editor/platforms/qt.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/toga_code_editor/platforms/qt.py b/src/toga_code_editor/platforms/qt.py index 0185c63..7863f43 100644 --- a/src/toga_code_editor/platforms/qt.py +++ b/src/toga_code_editor/platforms/qt.py @@ -1,4 +1,4 @@ -from bisect import bisect_right +from bisect import bisect_left, bisect_right from PySide6.QtCore import QRect, Qt from PySide6.QtGui import ( @@ -100,12 +100,12 @@ def highlightBlock(self, text): block = self.currentBlock() start = block.position() end = start + block.length() - # Spans are sorted and disjoint, so the first that can reach this block is - # the last one starting before it. - index = max(bisect_right(self.starts, start) - 1, 0) - for span in self.spans[index:]: - if span.start >= end: - break + # Spans are sorted and disjoint, so the ones that can reach this block + # are the last one starting before it through the last one starting + # inside it. + first = max(bisect_right(self.starts, start) - 1, 0) + last = bisect_left(self.starts, end) + for span in self.spans[first:last]: fmt = self.formats.get(span.kind) low = max(span.start, start) high = min(span.end, end) From 74b4ed4a3107e6271c418e9bdbd8375abe5dd7ac Mon Sep 17 00:00:00 2001 From: Kattni Date: Sun, 11 Oct 2026 00:30:19 -0400 Subject: [PATCH 05/12] Document Linux support and configure the example for GTK --- README.md | 18 ++++++++++++++---- examples/editor/pyproject.toml | 10 +++++++++- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 2909072..9b7ad8d 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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`. - 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. @@ -74,12 +75,12 @@ The test suite runs against Toga's dummy backend. Real backends are checked with ```console cd examples/editor -briefcase dev # macOS and Windows +briefcase dev # macOS, or Linux with the GTK backend and Windows briefcase run iOS briefcase run android ``` -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. 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 @@ -87,6 +88,15 @@ cd examples/editor briefcase run android -r ``` +On Linux, `briefcase dev` installs `toga-gtk`, whose PyGObject dependency builds against the system GTK libraries. On Ubuntu 24.04 or later and Debian 13 or later, install `pkg-config python3-dev libgirepository-2.0-dev libcairo2-dev gir1.2-gtk-3.0 libcanberra-gtk3-module` first; on Fedora, install `pkg-config python3-devel gobject-introspection-devel cairo-gobject-devel gtk3 libcanberra-gtk3`. Toga's Linux platform documentation has the packages for other distributions. The Qt backend runs from its own virtual environment, because Toga refuses to guess when both Linux backends are installed: + +```console +cd examples/editor +uv venv .venv-qt +uv pip install --python .venv-qt/bin/python -e ../.. "toga-qt[pyside6-essentials] ~= 0.5.7" +TOGA_BACKEND=toga_qt PYTHONPATH=src .venv-qt/bin/python -m editor +``` + Every user-visible change needs a fragment in `changes/`, named `..md` where kind is `feature`, `bugfix`, `doc`, or `misc`. Release notes are assembled with `towncrier build`. ## Community diff --git a/examples/editor/pyproject.toml b/examples/editor/pyproject.toml index cd9a49e..494002d 100644 --- a/examples/editor/pyproject.toml +++ b/examples/editor/pyproject.toml @@ -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 = [] @@ -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 = [ "../..", From 642c026ea3f7ce0692ee7cc9db9735a9e3aac47f Mon Sep 17 00:00:00 2001 From: Kattni Date: Sun, 11 Oct 2026 01:13:31 -0400 Subject: [PATCH 06/12] Stop the GTK editor hanging on its first programmatic value --- src/toga_code_editor/platforms/gtk.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/toga_code_editor/platforms/gtk.py b/src/toga_code_editor/platforms/gtk.py index eb3dc42..bcd7df1 100644 --- a/src/toga_code_editor/platforms/gtk.py +++ b/src/toga_code_editor/platforms/gtk.py @@ -12,6 +12,7 @@ def create(self): # 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. + self.setting_value = False super().create() # Highlights are text tags on the content buffer, one per token kind. @@ -27,10 +28,22 @@ def create(self): # Inherited MultilineTextInput methods that must keep the gutter in sync + def set_value(self, value): + # Toga's set_value spins the GTK main loop until the buffer change has + # settled. An asyncio task created inside that spin is never dispatched and + # keeps the spin alive, so skip the debounce for programmatic changes; the + # interface re-highlights synchronously right after set_value. + self.setting_value = True + try: + super().set_value(value) + finally: + self.setting_value = False + def gtk_on_changed(self, *args): # The re-highlight is scheduled before the user's handler runs, as on the # other backends. - self.interface._schedule_rehighlight() + if not self.setting_value: + self.interface._schedule_rehighlight() super().gtk_on_changed(*args) self.native_textview.queue_draw() From dc9e03e37896521b263d38a1211bf2ce2f60dd2f Mon Sep 17 00:00:00 2001 From: Kattni Date: Sun, 11 Oct 2026 01:13:42 -0400 Subject: [PATCH 07/12] Record the compiler and X11 libraries the Linux backends need --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9b7ad8d..835bea1 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ editor.theme = theme - **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**, **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`. +- **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. @@ -88,7 +88,7 @@ cd examples/editor briefcase run android -r ``` -On Linux, `briefcase dev` installs `toga-gtk`, whose PyGObject dependency builds against the system GTK libraries. On Ubuntu 24.04 or later and Debian 13 or later, install `pkg-config python3-dev libgirepository-2.0-dev libcairo2-dev gir1.2-gtk-3.0 libcanberra-gtk3-module` first; on Fedora, install `pkg-config python3-devel gobject-introspection-devel cairo-gobject-devel gtk3 libcanberra-gtk3`. Toga's Linux platform documentation has the packages for other distributions. The Qt backend runs from its own virtual environment, because Toga refuses to guess when both Linux backends are installed: +On Linux, `briefcase dev` installs `toga-gtk`, which builds PyGObject and pycairo from source, so it needs a compiler and the GLib, GObject Introspection, and cairo development packages. On Ubuntu 24.04 or later and Debian 13 or later, install `build-essential pkg-config python3-dev libgirepository-2.0-dev libcairo2-dev gir1.2-gtk-3.0 libcanberra-gtk3-module` first; on Fedora, install `gcc pkg-config python3-devel gobject-introspection-devel cairo-gobject-devel gtk3 libcanberra-gtk3`. Toga's Linux platform documentation has the packages for other distributions. The Qt backend runs from its own virtual environment, because Toga refuses to guess when both Linux backends are installed: ```console cd examples/editor @@ -97,6 +97,8 @@ uv pip install --python .venv-qt/bin/python -e ../.. "toga-qt[pyside6-essentials TOGA_BACKEND=toga_qt PYTHONPATH=src .venv-qt/bin/python -m editor ``` +On a Wayland session Qt uses its Wayland plugin and needs nothing more. On an X11 session, PySide6's X11 plugin also needs `libxcb-cursor0 libxcb-icccm4 libxcb-keysyms1 libxcb-xinerama0 libxcb-xkb1 libxkbcommon-x11-0` on Ubuntu, which a stock desktop install lacks. + Every user-visible change needs a fragment in `changes/`, named `..md` where kind is `feature`, `bugfix`, `doc`, or `misc`. Release notes are assembled with `towncrier build`. ## Community From dae5b67d2c1c6ee200fc9fae9c248b46bde36edd Mon Sep 17 00:00:00 2001 From: Kattni Date: Sun, 11 Oct 2026 01:38:34 -0400 Subject: [PATCH 08/12] Explain the GTK startup spin, paint the Qt gutter, and trim redundant work --- src/toga_code_editor/platforms/gtk.py | 30 ++++++++++++++------------- src/toga_code_editor/platforms/qt.py | 6 +++++- 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/src/toga_code_editor/platforms/gtk.py b/src/toga_code_editor/platforms/gtk.py index bcd7df1..9ae7908 100644 --- a/src/toga_code_editor/platforms/gtk.py +++ b/src/toga_code_editor/platforms/gtk.py @@ -8,11 +8,12 @@ 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. - self.setting_value = False super().create() # Highlights are text tags on the content buffer, one per token kind. @@ -29,15 +30,19 @@ def create(self): # Inherited MultilineTextInput methods that must keep the gutter in sync def set_value(self, value): - # Toga's set_value spins the GTK main loop until the buffer change has - # settled. An asyncio task created inside that spin is never dispatched and - # keeps the spin alive, so skip the debounce for programmatic changes; the - # interface re-highlights synchronously right after set_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 = False + self.setting_value = previous def gtk_on_changed(self, *args): # The re-highlight is scheduled before the user's handler runs, as on the @@ -47,10 +52,6 @@ def gtk_on_changed(self, *args): super().gtk_on_changed(*args) self.native_textview.queue_draw() - def set_font(self, font): - super().set_font(font) - self.update_gutter_width() - # CodeEditor backend contract def set_theme(self, theme): @@ -115,11 +116,11 @@ def gtk_on_draw(self, view, cr): 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) + self.draw_line_numbers(view, cr, window.get_width()) cr.restore() return False - def draw_line_numbers(self, view, cr): + 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. @@ -146,7 +147,8 @@ def draw_line_numbers(self, view, cr): break _, window_y = view.buffer_to_window_coords(Gtk.TextWindowType.LEFT, 0, y) layout = view.create_pango_layout(str(line + 1)) - width, _ = layout.get_pixel_size() - cr.move_to(self.gutter_width - GUTTER_PADDING - width, window_y) + 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 diff --git a/src/toga_code_editor/platforms/qt.py b/src/toga_code_editor/platforms/qt.py index 7863f43..fd0cbd6 100644 --- a/src/toga_code_editor/platforms/qt.py +++ b/src/toga_code_editor/platforms/qt.py @@ -53,6 +53,9 @@ def resizeEvent(self, event): def draw_line_numbers(self, event): painter = QPainter(self.gutter) + # Paint the strip, so the gutter looks the same whatever sits behind the + # editor. + painter.fillRect(event.rect(), self.palette().color(QPalette.ColorRole.Window)) painter.setFont(self.font()) painter.setPen(self.palette().color(QPalette.ColorRole.PlaceholderText)) height = self.fontMetrics().height() @@ -184,7 +187,8 @@ def update_gutter_width(self): ) else: width = 0 - self.native.set_gutter_width(width) + if width != self.native.gutter_width: + self.native.set_gutter_width(width) def qt_on_block_count_changed(self, count): self.update_gutter_width() From cff4098746de2fc6faefbbd643f21580bbb3a650 Mon Sep 17 00:00:00 2001 From: Kattni Date: Sun, 11 Oct 2026 20:33:57 -0400 Subject: [PATCH 09/12] Add the change note for #6 --- changes/6.feature.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changes/6.feature.md diff --git a/changes/6.feature.md b/changes/6.feature.md new file mode 100644 index 0000000..3cecf91 --- /dev/null +++ b/changes/6.feature.md @@ -0,0 +1 @@ +Added GTK and Qt backends, so `CodeEditor` now runs on Linux. From 9d5acbb8653ba2cf325995207eb329200006f410 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Mon, 12 Oct 2026 16:09:51 +0800 Subject: [PATCH 10/12] Add a separate testbed for Qt. --- README.md | 7 ++++--- examples/editor/pyproject.toml | 12 ++++++++++++ examples/editor/src/editor_qt/__init__.py | 0 examples/editor/src/editor_qt/__main__.py | 4 ++++ 4 files changed, 20 insertions(+), 3 deletions(-) create mode 100644 examples/editor/src/editor_qt/__init__.py create mode 100644 examples/editor/src/editor_qt/__main__.py diff --git a/README.md b/README.md index 835bea1..aadb107 100644 --- a/README.md +++ b/README.md @@ -75,9 +75,10 @@ The test suite runs against Toga's dummy backend. Real backends are checked with ```console cd examples/editor -briefcase dev # macOS, or Linux with the GTK backend 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 +briefcase run android -a testbed ``` 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. 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: diff --git a/examples/editor/pyproject.toml b/examples/editor/pyproject.toml index 494002d..b4903a3 100644 --- a/examples/editor/pyproject.toml +++ b/examples/editor/pyproject.toml @@ -85,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", +] diff --git a/examples/editor/src/editor_qt/__init__.py b/examples/editor/src/editor_qt/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/editor/src/editor_qt/__main__.py b/examples/editor/src/editor_qt/__main__.py new file mode 100644 index 0000000..d758d20 --- /dev/null +++ b/examples/editor/src/editor_qt/__main__.py @@ -0,0 +1,4 @@ +from editor.app import main + +if __name__ == "__main__": + main().main_loop() From 338e60dc1140f035480f9fe2b63afe2312a25e5b Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Mon, 12 Oct 2026 16:12:20 +0800 Subject: [PATCH 11/12] Clarify dev instructions. --- README.md | 24 +++++++----------------- 1 file changed, 7 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index aadb107..b80304c 100644 --- a/README.md +++ b/README.md @@ -75,31 +75,21 @@ The test suite runs against Toga's dummy backend. Real backends are checked with ```console cd examples/editor -briefcase dev -a testbed # macOS, Windows, or Linux (GTK backend) -briefcase dev -a testbed-qt # Linux (Qt backend) -briefcase run iOS -a testbed -briefcase run android -a testbed +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, iOS, Windows, and Linux, 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. -```console -uv build --wheel -cd examples/editor -briefcase run android -r -``` - -On Linux, `briefcase dev` installs `toga-gtk`, which builds PyGObject and pycairo from source, so it needs a compiler and the GLib, GObject Introspection, and cairo development packages. On Ubuntu 24.04 or later and Debian 13 or later, install `build-essential pkg-config python3-dev libgirepository-2.0-dev libcairo2-dev gir1.2-gtk-3.0 libcanberra-gtk3-module` first; on Fedora, install `gcc pkg-config python3-devel gobject-introspection-devel cairo-gobject-devel gtk3 libcanberra-gtk3`. Toga's Linux platform documentation has the packages for other distributions. The Qt backend runs from its own virtual environment, because Toga refuses to guess when both Linux backends are installed: +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 -uv venv .venv-qt -uv pip install --python .venv-qt/bin/python -e ../.. "toga-qt[pyside6-essentials] ~= 0.5.7" -TOGA_BACKEND=toga_qt PYTHONPATH=src .venv-qt/bin/python -m editor +briefcase run android -a testbed -r ``` -On a Wayland session Qt uses its Wayland plugin and needs nothing more. On an X11 session, PySide6's X11 plugin also needs `libxcb-cursor0 libxcb-icccm4 libxcb-keysyms1 libxcb-xinerama0 libxcb-xkb1 libxkbcommon-x11-0` on Ubuntu, which a stock desktop install lacks. - Every user-visible change needs a fragment in `changes/`, named `..md` where kind is `feature`, `bugfix`, `doc`, or `misc`. Release notes are assembled with `towncrier build`. ## Community From 7de4ba2d5136533d44385a996826d6e75d122061 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Mon, 12 Oct 2026 16:13:47 +0800 Subject: [PATCH 12/12] Add a reference for Linux system requirements. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b80304c..7656956 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ briefcase dev -a testbed-qt # Linux (Qt backend) briefcase run iOS -a testbed # iOS ``` -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. +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: