diff --git a/.gitignore b/.gitignore index c995f92..381bbda 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ __pycache__/ .coverage .coverage.* .DS_Store +.idea/ .kilo .opencode .pytest_cache/ @@ -10,6 +11,7 @@ __pycache__/ .rumdl_cache/ .tox/ .venv* +.vscode/ *.dist-info/ *.egg-info/ *.py[cod] diff --git a/README.md b/README.md index b121231..2909072 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, 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 @@ -8,12 +8,9 @@ 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. - ## Usage ```python -import toga from toga_code_editor import CodeEditor, language_for_filename editor = CodeEditor( @@ -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 @@ -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 @@ -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 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. diff --git a/examples/editor/pyproject.toml b/examples/editor/pyproject.toml index d020398..cd9a49e 100644 --- a/examples/editor/pyproject.toml +++ b/examples/editor/pyproject.toml @@ -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] @@ -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 = [ diff --git a/examples/editor/src/editor/app.py b/examples/editor/src/editor/app.py index bf2415d..3b3e7df 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,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, diff --git a/pyproject.toml b/pyproject.toml index 80879b0..ba53a73 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", ] @@ -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. @@ -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] diff --git a/src/toga_code_editor/code_editor.py b/src/toga_code_editor/code_editor.py index 4259954..89ac4aa 100644 --- a/src/toga_code_editor/code_editor.py +++ b/src/toga_code_editor/code_editor.py @@ -13,7 +13,6 @@ from .highlighting import ( DEFAULT_THEME, - Highlighter, NullHighlighter, PygmentsHighlighter, Theme, @@ -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, @@ -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, @@ -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): @@ -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: @@ -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 @@ -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) diff --git a/src/toga_code_editor/highlighting.py b/src/toga_code_editor/highlighting.py index b2f15f2..33fa3a5 100644 --- a/src/toga_code_editor/highlighting.py +++ b/src/toga_code_editor/highlighting.py @@ -3,7 +3,8 @@ from collections.abc import Iterable, Mapping from dataclasses import dataclass from enum import StrEnum -from os import PathLike +from os import PathLike, fsdecode +from types import MappingProxyType from typing import Protocol from pygments.lexers import find_lexer_class_for_filename, get_lexer_by_name @@ -42,7 +43,7 @@ class TokenKind(StrEnum): class Span: """A run of text with one token kind. - Offsets are Python string indices: ``start`` inclusive, ``end`` exclusive. + Offsets are Python string indices: `start` inclusive, `end` exclusive. """ start: int @@ -52,35 +53,47 @@ 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 + [Color][toga.colors.Color]. + """ + + color: Color 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; + # 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) + object.__setattr__(self, "italic", italic) 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"), -} - -# 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. +# 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 +# Name, is TEXT. _TOKEN_TABLE = [ (Comment, TokenKind.COMMENT), (String, TokenKind.STRING), @@ -108,10 +121,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) @@ -134,7 +147,7 @@ def highlight(self, text: str) -> list[Span]: class PygmentsHighlighter: """Tokenize text with a Pygments lexer. - :param language: A Pygments lexer alias, such as ``"python"``. + :param language: A Pygments lexer alias, such as `"python"`. :raises ValueError: If Pygments has no lexer for the alias. """ @@ -155,12 +168,12 @@ def highlight(self, text: str) -> list[Span]: ) -def language_for_filename(path: str | PathLike) -> str | None: - """Return the Pygments lexer alias for a filename, or ``None`` if there is 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. + 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(fsdecode(path)) return None if lexer_class is None else lexer_class.aliases[0] @@ -192,8 +205,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/src/toga_code_editor/platforms/android.py b/src/toga_code_editor/platforms/android.py index cfacd57..a56ea99 100644 --- a/src/toga_code_editor/platforms/android.py +++ b/src/toga_code_editor/platforms/android.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,12 @@ 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()) + # 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 # first draw after setText(), which would undo the scroll sync every time # the numbers are rebuilt. The movement method also makes the view @@ -111,8 +116,6 @@ def create(self): self.disable_suggestions() self.prefer_keyboard_resize() self.theme = {} - self.spans = [] - self.active_spans = [] def prefer_keyboard_resize(self): # By default Android pans the whole window to keep the cursor above the soft @@ -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,11 +163,16 @@ 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) - 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) @@ -176,11 +184,16 @@ 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, so + # rebuild the gutter when one was present before or after this pass. + if styled: + 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 +209,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 = [] diff --git a/src/toga_code_editor/platforms/cocoa.py b/src/toga_code_editor/platforms/cocoa.py index 753f101..f6bfb68 100644 --- a/src/toga_code_editor/platforms/cocoa.py +++ b/src/toga_code_editor/platforms/cocoa.py @@ -6,6 +6,7 @@ NSRange, NSRect, ObjCClass, + objc_id, objc_method, objc_property, ) @@ -37,6 +38,14 @@ NSBoldFontMask = 1 << 1 GUTTER_PADDING = 6 +# 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): @@ -46,6 +55,18 @@ 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 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 PLAIN_TEXT_PASTEBOARD_TYPES + + @objc_method + def paste_(self, sender) -> None: + self.pasteAsPlainText(sender) + class TogaLineNumberView(NSRulerView): impl = objc_property(object, weak=True) @@ -89,6 +110,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 +149,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 +168,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,21 +247,33 @@ 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 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() + container_rect = NSRect( + NSPoint(visible.origin.x - origin.x, visible.origin.y - origin.y), + visible.size, + ) glyph_range = layout.glyphRangeForBoundingRect( - visible, inTextContainer=container + container_rect, inTextContainer=container ) char_range = layout.characterRangeForGlyphRange( glyph_range, actualGlyphRange=None @@ -260,5 +297,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 + origin.y - visible.origin.y label.drawAtPoint(NSPoint(x, y)) diff --git a/src/toga_code_editor/platforms/dummy.py b/src/toga_code_editor/platforms/dummy.py index 33dd190..d1b4cfb 100644 --- a/src/toga_code_editor/platforms/dummy.py +++ b/src/toga_code_editor/platforms/dummy.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/platforms/iOS.py b/src/toga_code_editor/platforms/iOS.py index ad1e354..da21578 100644 --- a/src/toga_code_editor/platforms/iOS.py +++ b/src/toga_code_editor/platforms/iOS.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,25 @@ 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 + ), + 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 @@ -176,6 +146,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,13 +183,14 @@ 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. + descriptor = base_font.fontDescriptor.fontDescriptorWithSymbolicTraits( + traits ) - 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): @@ -262,15 +234,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: diff --git a/src/toga_code_editor/platforms/winforms.py b/src/toga_code_editor/platforms/winforms.py new file mode 100644 index 0000000..36c24e0 --- /dev/null +++ b/src/toga_code_editor/platforms/winforms.py @@ -0,0 +1,408 @@ +from bisect import bisect_right +from ctypes import ( + HRESULT, + POINTER, + WINFUNCTYPE, + Structure, + addressof, + byref, + c_long, + c_ubyte, + c_ulong, + c_void_p, + cast, +) +from ctypes.wintypes import DWORD, POINT, WORD + +import System.Windows.Forms as WinForms +from System import Int32 +from System.Drawing import Font, FontStyle, Point, Rectangle, Size, SystemColors +from System.Windows.Forms import TextFormatFlags, TextRenderer +from toga.handlers import WeakrefCallable +from toga_winforms.colors import native_color +from toga_winforms.libs.user32 import SendMessageW +from toga_winforms.widgets.multilinetextinput import MultilineTextInput + +from ..highlighting import to_utf16_spans, utf16_line_starts + +GUTTER_PADDING = 6 # CSS pixels; scaled to physical pixels where it is used + +# Edit-control messages from winuser.h and richedit.h; toga_winforms binds none. +WM_USER = 0x0400 +EM_POSFROMCHAR = 0x00D6 +EM_CHARFROMPOS = 0x00D7 +EM_GETEVENTMASK = WM_USER + 59 +EM_GETOLEINTERFACE = WM_USER + 60 +EM_SETEVENTMASK = WM_USER + 69 +EM_GETSCROLLPOS = WM_USER + 221 +EM_SETSCROLLPOS = WM_USER + 222 +ENM_CHANGE = 0x00000001 +ENM_SELCHANGE = 0x00000002 + +# Text Object Model, from tom.h: the ITextDocument.Undo arguments, and the vtable +# slots of the methods used here. IUnknown fills slots 0 to 2 and IDispatch 3 to 6. +TOM_SUSPEND = -9999995 # tomSuspend +TOM_RESUME = -9999994 # tomResume +IUNKNOWN_QUERY_INTERFACE = 0 +IUNKNOWN_RELEASE = 2 +ITEXTDOCUMENT_FREEZE = 18 +ITEXTDOCUMENT_UNFREEZE = 19 +ITEXTDOCUMENT_UNDO = 22 + +UNBOUNDED = Size(Int32.MaxValue, Int32.MaxValue) +GUTTER_TEXT_FLAGS = ( + TextFormatFlags.Right | TextFormatFlags.NoPadding | TextFormatFlags.SingleLine +) + + +class GUID(Structure): + _fields_ = [ + ("Data1", DWORD), + ("Data2", WORD), + ("Data3", WORD), + ("Data4", c_ubyte * 8), + ] + + +IID_ITEXTDOCUMENT = GUID( + 0x8CC497C0, + 0xA1DF, + 0x11CE, + (c_ubyte * 8)(0x80, 0x98, 0x00, 0xAA, 0x00, 0x47, 0xBE, 0x5D), +) + +# COM method prototypes; the first argument is always the interface pointer. The +# document methods declare a plain integer result rather than HRESULT, so ctypes +# never raises from them, and their results are discarded by design: the paint's +# restore chain must run to its end whatever they report, and none of them fails +# on a live control. +QUERY_INTERFACE = WINFUNCTYPE(HRESULT, c_void_p, POINTER(GUID), POINTER(c_void_p)) +RELEASE = WINFUNCTYPE(c_ulong, c_void_p) +COUNT_METHOD = WINFUNCTYPE(c_long, c_void_p, POINTER(c_long)) # Freeze, Unfreeze +UNDO_METHOD = WINFUNCTYPE(c_long, c_void_p, c_long, POINTER(c_long)) + + +def com_method(interface, slot, prototype): + """Bind the method at a vtable slot of a COM interface pointer.""" + vtable = cast(interface, POINTER(c_void_p))[0] + return prototype(cast(vtable, POINTER(c_void_p))[slot]) + + +class TextDocument: + """The Text Object Model document behind a RichEdit control. + + WinForms exposes neither Freeze nor undo suspension, so both are called through + the interface's vtable. The document reference is never released: Toga gives a + widget implementation no teardown hook, and the object dies with the control. + """ + + def __init__(self, hwnd): + ole = c_void_p() + SendMessageW(hwnd, EM_GETOLEINTERFACE, 0, byref(ole)) + if ole.value is None: + raise RuntimeError("The RichEdit control did not supply its OLE interface") + self.pointer = c_void_p() + com_method(ole, IUNKNOWN_QUERY_INTERFACE, QUERY_INTERFACE)( + ole, byref(IID_ITEXTDOCUMENT), byref(self.pointer) + ) + com_method(ole, IUNKNOWN_RELEASE, RELEASE)(ole) + self._freeze = com_method(self.pointer, ITEXTDOCUMENT_FREEZE, COUNT_METHOD) + self._unfreeze = com_method(self.pointer, ITEXTDOCUMENT_UNFREEZE, COUNT_METHOD) + self._undo = com_method(self.pointer, ITEXTDOCUMENT_UNDO, UNDO_METHOD) + + def freeze(self): + self._freeze(self.pointer, byref(c_long())) + + def unfreeze(self): + self._unfreeze(self.pointer, byref(c_long())) + + def suspend_undo(self): + self._undo(self.pointer, TOM_SUSPEND, byref(c_long())) + + def resume_undo(self): + self._undo(self.pointer, TOM_RESUME, byref(c_long())) + + +class CodeEditor(MultilineTextInput): + def create(self): + # The inherited create() builds the RichTextBox and hooks its events. + super().create() + # Settings that would recreate a live window handle come first; the handle + # does not exist yet. + self.native.BorderStyle = getattr(WinForms.BorderStyle, "None") + self.native.DetectUrls = False # an auto-linked URL recolors itself + self.native.AcceptsTab = True # Tab inserts a tab, as on macOS + self.native.RichTextShortcutsEnabled = False # Ctrl+E and friends realign + self.native.Dock = WinForms.DockStyle.Fill + # VScroll covers the scroll bar and the wheel; the caret moving the view, + # as with Page Down, reports a selection change instead. + self.native.VScroll += WeakrefCallable(self.winforms_gutter_stale) + self.native.SelectionChanged += WeakrefCallable(self.winforms_gutter_stale) + self.native.Resize += WeakrefCallable(self.winforms_gutter_stale) + self.native.KeyDown += WeakrefCallable(self.winforms_key_down) + self.native.HandleCreated += WeakrefCallable(self.winforms_handle_created) + + self.gutter = WinForms.Panel() + self.gutter.DoubleBuffered = True + self.gutter.TabStop = False + self.gutter.Dock = WinForms.DockStyle.Left + self.gutter.Paint += WeakrefCallable(self.winforms_gutter_paint) + + # One sunken border around both, with the text box's own border removed. + # Docking lays out later controls first, so the gutter takes its width and + # the text box fills what is left. + self.frame = WinForms.Panel() + self.frame.BorderStyle = WinForms.BorderStyle.Fixed3D + self.frame.Controls.Add(self.native) + self.frame.Controls.Add(self.gutter) + + # RichTextBox.Font applies to every character, so the base font is tracked + # here; scale_font keeps it current. + self.base_font = self.native.Font + self.theme = {} + self.attributes = {} + self.spans = [] + self.line_starts = [0] + self.line_height = 0 + self.document = None + # Size the gutter for the empty text now, rather than leaving the Panel's + # default width in place until the style applies a font. + self.text_changed() + + @property + def hwnd(self): + return int(self.native.Handle.ToString()) + + # Toga positions, parents, hides, and colors self.native; the frame stands in. + + @property + def container(self): + return self._container + + @container.setter + def container(self, container): + if self._container: + self._container.native_content.Controls.Remove(self.frame) + self._container = container + if container: + container.native_content.Controls.Add(self.frame) + self.frame.BringToFront() + self.refresh() + self.set_background_color(self.interface.style.background_color) + + def set_bounds(self, x, y, width, height): + self.frame.Size = Size(*map(self.scale_in, (width, height))) + self.frame.Location = Point(*map(self.scale_in, (x, y))) + + def set_hidden(self, hidden): + self.frame.Visible = not hidden + + def set_background_color(self, color): + super().set_background_color(color) + self.gutter.BackColor = self.native.BackColor + + def get_tab_index(self): + return self.frame.TabIndex + + def set_tab_index(self, tab_index): + # The frame is what sits among the window's other widgets; inside it the + # text box is the only tab stop. + self.frame.TabIndex = tab_index + self.native.TabIndex = tab_index + + # Native events + + def winforms_text_changed(self, sender, event): + # Replaces Toga's handler, which reads Text twice; each read streams the + # whole buffer out of the control. Formatting also raises this event, and + # the comparison filters it out. + if self._placeholder_visible: + return + text = self.native.Text + if text == self._text_history: + return + self._text_history = text + self.interface._schedule_rehighlight() + self.interface.on_change() + self.text_changed(text) + + def winforms_key_down(self, sender, event): + # RichTextBox pastes rich text, whose paragraph formatting the re-highlight + # never resets. Paste plain text for Ctrl+V and Shift+Insert instead. A + # read-only control is left to RichEdit, which ignores the paste. + if self.native.ReadOnly: + return + keys = WinForms.Keys + if (event.Control and not event.Alt and event.KeyCode == keys.V) or ( + event.Shift and not event.Control and event.KeyCode == keys.Insert + ): + self.native.Paste( + WinForms.DataFormats.GetFormat(WinForms.DataFormats.UnicodeText) + ) + event.Handled = True + event.SuppressKeyPress = True + + def winforms_gutter_stale(self, sender, event): + # The text scrolled or re-wrapped; the line tops moved with it. + self.gutter.Invalidate() + + def winforms_handle_created(self, sender, event): + # A recreated handle is a new RichEdit object with a new document. + self.document = None + + # Inherited methods that must keep the gutter and colors in sync + + def set_value(self, value): + super().set_value(value) + self.text_changed() + + def scale_font(self): + # Toga routes set_font and DPI changes through here. Assigning Font resets + # every character's formatting, so paint again afterwards. + super().scale_font() + self.base_font = self.native.Font + self.rebuild_attributes() + self.apply_highlights() + self.text_changed() + + def set_color(self, value): + super().set_color(value) + self.apply_highlights() + + # CodeEditor backend contract + + def set_theme(self, theme): + self.theme = theme + self.rebuild_attributes() + + def set_highlights(self, spans): + self.spans = spans + self.apply_highlights() + + def set_show_line_numbers(self, value): + # Docking hands a hidden gutter's width to the text box. + self.gutter.Visible = value + + # Highlighting + + def rebuild_attributes(self): + base_font = self.base_font + family = base_font.FontFamily + self.attributes = {} + for kind, style in self.theme.items(): + font_style = base_font.Style + if style.bold: + font_style |= FontStyle.Bold + if style.italic: + font_style |= FontStyle.Italic + font = None + # A family without a face for the style would make Font() throw. + if font_style != base_font.Style and family.IsStyleAvailable(font_style): + font = Font(base_font, font_style) + self.attributes[kind] = (native_color(style.color), font) + + def apply_highlights(self): + # The placeholder is the text box's own gray text; leave it alone. + if self._placeholder_visible: + return + length = self.native.TextLength + if length == 0: + return + # Reading Handle can create the handle, which drops any cached document, so + # it comes before the document is acquired. + hwnd = self.hwnd + if self.document is None: + self.document = TextDocument(hwnd) + # Between a native edit and the debounced re-highlight the spans describe the + # previous text, so clamp them to the current length. + spans = [ + (span.start, min(span.end, length), self.attributes.get(span.kind)) + for span in to_utf16_spans(self.get_value(), self.spans) + if span.start < length + ] + native = self.native + selection = (native.SelectionStart, native.SelectionLength) + scroll = POINT() + SendMessageW(hwnd, EM_GETSCROLLPOS, 0, byref(scroll)) + mask = SendMessageW(hwnd, EM_GETEVENTMASK, 0, 0) + try: + # Formatting raises TextChanged, whose handler streams the buffer out to + # compare it, and every Select raises SelectionChanged. Silence both for + # the duration. + SendMessageW(hwnd, EM_SETEVENTMASK, 0, mask & ~(ENM_CHANGE | ENM_SELCHANGE)) + self.document.freeze() + # Formatting would otherwise land on the undo stack ahead of the typing. + self.document.suspend_undo() + # Reset to the base font and color, then paint each span. + native.Select(0, length) + native.SelectionFont = self.base_font + native.SelectionColor = self._color + for start, end, attributes in spans: + if attributes is None or end <= start: + continue + color, font = attributes + native.Select(start, end - start) + native.SelectionColor = color + if font is not None: + native.SelectionFont = font + finally: + # The notifications come back before the selection is restored, so + # WinForms sees that change. WinForms' Select sends EM_SCROLLCARET after + # EM_SETSEL, and the freeze defers every one of those scrolls until the + # view thaws, so the selection goes back before the thaw and the scroll + # position is put back after it; the scroll restore is load-bearing. + self.document.resume_undo() + SendMessageW(hwnd, EM_SETEVENTMASK, 0, mask) + try: + native.Select(*selection) + finally: + self.document.unfreeze() + SendMessageW(hwnd, EM_SETSCROLLPOS, 0, byref(scroll)) + # A bold or italic span can re-wrap a line. + self.gutter.Invalidate() + + # Gutter + + def text_changed(self, text=None): + # The change handler passes the text it has already read; every other + # caller pays one read of the buffer. + if text is None: + text = self.get_value() + self.line_starts = utf16_line_starts(text) + digits = len(str(len(self.line_starts))) + size = TextRenderer.MeasureText( + "0" * digits, self.base_font, UNBOUNDED, GUTTER_TEXT_FLAGS + ) + self.line_height = size.Height + width = size.Width + 2 * self.scale_in(GUTTER_PADDING) + if self.gutter.Width != width: + self.gutter.Width = width + self.gutter.Invalidate() + + def winforms_gutter_paint(self, sender, event): + hwnd = self.hwnd + height = self.native.ClientSize.Height + line_height = self.line_height + right = self.gutter.Width - self.scale_in(GUTTER_PADDING) + point = POINT(0, 0) + # The first visible character and the logical line it belongs to. That + # line's number may sit above the view, where it is clipped. + first_char = SendMessageW(hwnd, EM_CHARFROMPOS, 0, byref(point)) + first_line = max(bisect_right(self.line_starts, first_char) - 1, 0) + for number, start in enumerate( + self.line_starts[first_line:], start=first_line + 1 + ): + # Client coordinates of the line's first character. The index equal to + # the text length addresses the final paragraph mark RichEdit always + # keeps, which places the empty line after a trailing newline. + SendMessageW(hwnd, EM_POSFROMCHAR, addressof(point), start) + if point.y >= height: + break + TextRenderer.DrawText( + event.Graphics, + str(number), + self.base_font, + Rectangle(0, point.y, right, line_height), + SystemColors.GrayText, + GUTTER_TEXT_FLAGS, + ) diff --git a/tests/test_code_editor.py b/tests/test_code_editor.py index e63c84b..d19b03b 100644 --- a/tests/test_code_editor.py +++ b/tests/test_code_editor.py @@ -3,7 +3,10 @@ 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 ( + 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 @@ -95,3 +98,31 @@ 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 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 d5a251f..8d86071 100644 --- a/tests/test_highlighting.py +++ b/tests/test_highlighting.py @@ -1,11 +1,14 @@ import pytest from toga_code_editor.highlighting import ( + DEFAULT_THEME, NullHighlighter, PygmentsHighlighter, Span, + Style, TokenKind, language_for_filename, + merge_spans, to_utf16_spans, utf16_line_starts, ) @@ -14,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 + 1 # hi\n" + "def f(x):\n return x + 1 and 2 # hi\n" ) assert spans == [ Span(0, 3, TokenKind.KEYWORD), @@ -24,10 +27,28 @@ def test_python_snippet(): Span(14, 20, TokenKind.KEYWORD), Span(23, 24, TokenKind.OPERATOR), Span(25, 26, TokenKind.NUMBER), - Span(28, 32, TokenKind.COMMENT), + Span(27, 30, TokenKind.KEYWORD), # "and" is Operator.Word, not an operator + Span(31, 32, TokenKind.NUMBER), + Span(34, 38, 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_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") @@ -48,11 +69,25 @@ 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: + """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), + pytest.param(FSPath(), "python", id="pathlike"), + pytest.param(b"foo.py", "python", id="bytes"), + ], ) def test_language_for_filename(name, expected): assert language_for_filename(name) == expected