diff --git a/README.md b/README.md index b121231..2d361bf 100644 --- a/README.md +++ b/README.md @@ -8,12 +8,11 @@ A [Toga](https://toga.beeware.org) widget for editing code, with line numbers an pip install toga-code-editor ``` -The package depends on `toga-core` and `pygments`. Your app installs the Toga backend for its platform as usual; `toga-code-editor` contributes its implementation for that backend through entry points, the same way Toga's own widgets are found. +The package depends on `pygments` and on `toga-core` 0.5.7 or later in the 0.5 series, because it subclasses Toga backend internals that can change between minor versions. Your app installs the Toga backend for its platform as usual; `toga-code-editor` contributes its implementation for that backend through entry points, the same way Toga's own widgets are found. ## Usage ```python -import toga from toga_code_editor import CodeEditor, language_for_filename editor = CodeEditor( @@ -34,11 +33,11 @@ editor.language = language_for_filename(path) - `show_line_numbers`: shows or hides the gutter. Default `True`. - `theme`: a mapping from `TokenKind` to `Style`. `None` selects the built-in `DEFAULT_THEME`. -Unless you give the widget a font family, it uses a monospace font. Autocorrect, smart quotes, smart dashes, auto-capitalization, and spell checking are always off. +Unless you give the widget a font family, it uses a monospace font. Autocorrect, smart quotes, smart dashes, smart insert and delete, auto-capitalization, and spell checking are always off. ### Themes -A theme is a plain mapping. Kinds you leave out are drawn in the widget's normal text color. +A theme is a plain mapping. Kinds you leave out are drawn in the widget's normal text color. `DEFAULT_THEME` is read-only, so unpack it into a new dict to change it, as below. ```python from toga_code_editor import DEFAULT_THEME, Style, TokenKind @@ -51,15 +50,16 @@ theme = { editor.theme = theme ``` -`Style.color` accepts anything Toga's color properties accept. The token kinds are `KEYWORD`, `BUILTIN`, `DEFINITION`, `DECORATOR`, `STRING`, `NUMBER`, `COMMENT`, `OPERATOR`, `PUNCTUATION`, `TAG`, `ATTRIBUTE`, and `VARIABLE`. +`Style.color` accepts anything Toga's color properties accept. The token kinds are `KEYWORD`, `BUILTIN`, `DEFINITION`, `DECORATOR`, `STRING`, `NUMBER`, `COMMENT`, `OPERATOR`, `PUNCTUATION`, `TAG`, `ATTRIBUTE`, and `VARIABLE`. Everything else, including plain names, is `TEXT`, which is never styled. ## Platform notes - **macOS** uses an `NSRulerView` for the gutter and the system's adaptive color mapping, so theme colors follow dark mode. +- **Android** 12 and later are supported. The example app is checked on Android 12 and Android 15 emulators. - **iOS** and **Android** draw the gutter themselves. Theme colors do not change with the system appearance; the default theme is chosen to be legible on both light and dark backgrounds. - Highlighting re-lexes the whole buffer after a short pause in typing. Files of a few thousand lines are fine on a desktop; very large files are slower on phones. - Some third-party Android keyboards ignore the flag that disables suggestions. -- On Android, the widget asks the window to shrink its content when the soft keyboard appears, so the editor scrolls internally and the rest of the layout stays put. It only does this when the app has not chosen a soft-input mode itself. +- 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 @@ -78,7 +78,7 @@ briefcase run iOS briefcase run android ``` -On macOS and iOS the example installs the widget straight from this repository; add `-r` after changing the widget source so Briefcase reinstalls it. On Android the example installs the widget from a wheel in `dist/`, because Gradle rejects a path requirement whose directory contains the Android build tree. Build the wheel before each Android run that should pick up widget changes: +On macOS and iOS the example installs the widget straight from this repository; add `-r` after changing the widget source so Briefcase reinstalls it. On Android the example installs the widget from a wheel in `dist/`, because Gradle rejects a path requirement whose directory contains the Android build tree. The example's Android requirement names that wheel file, version and all, so a version bump must update `examples/editor/pyproject.toml` too, and a stale wheel in `dist/` ships the old code without complaint. Build the wheel from the repository root before each Android run that should pick up widget changes: ```console uv build --wheel diff --git a/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..c76a408 100644 --- a/examples/editor/pyproject.toml +++ b/examples/editor/pyproject.toml @@ -19,8 +19,9 @@ author = "Kattni" author_email = "hello@kattni.com" description = "A code editor demonstrating the toga-code-editor widget." # macOS and iOS install the widget from this repository; Android installs it from a -# built wheel (run `uv build` first), because Gradle rejects a path requirement whose -# directory contains the Android build tree. +# built wheel (run `uv build --wheel` from the repository root first), because Gradle +# rejects a path requirement whose directory contains the Android build tree. The +# wheel filename below carries the package version, so a version bump must update it. requires = [] [tool.briefcase.app.editor] diff --git a/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..36864cc 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", ] diff --git a/src/toga_code_editor/code_editor.py b/src/toga_code_editor/code_editor.py index 4259954..88ffd9a 100644 --- a/src/toga_code_editor/code_editor.py +++ b/src/toga_code_editor/code_editor.py @@ -23,6 +23,27 @@ """Seconds to wait after the last native edit before re-highlighting.""" +def _highlighter_for(language: str | None) -> Highlighter: + return NullHighlighter() if language is None else PygmentsHighlighter(language) + + +def _theme_for(theme: Theme | None) -> Theme: + return DEFAULT_THEME if theme is None else theme + + +def _report_rehighlight_failure(task: asyncio.Task) -> None: + # Hand a failure to the loop's exception handler as soon as the task finishes. + # Otherwise asyncio only reports it when the task object is garbage collected. + if not task.cancelled() and (exc := task.exception()) is not None: + task.get_loop().call_exception_handler( + { + "message": "Re-highlighting the code editor failed", + "exception": exc, + "task": task, + } + ) + + class CodeEditor(toga.MultilineTextInput): def __init__( self, @@ -61,12 +82,14 @@ def __init__( ): kwargs["font_family"] = MONOSPACE - # State the inherited value setter needs before super().__init__() runs. - self._highlighter: Highlighter = NullHighlighter() - self._language: str | None = None - self._theme: Theme = DEFAULT_THEME + # Resolve the highlighting state before the widget exists: an unknown + # language raises before anything is created, and the inherited constructor + # paints the initial value once, with the highlighter and theme in hand. + self._highlighter: Highlighter = _highlighter_for(language) + self._language = language + self._theme: Theme = _theme_for(theme) self._show_line_numbers = True - self._pending_rehighlight = None + self._pending_rehighlight: asyncio.Task | None = None super().__init__( id=id, @@ -78,8 +101,6 @@ def __init__( **kwargs, ) - self.theme = theme - self.language = language self.show_line_numbers = show_line_numbers @cached_property @@ -97,7 +118,11 @@ def factory(self): return get_factory("toga_code_editor") def _create(self) -> Any: - return self.factory.CodeEditor(interface=self) + impl = self.factory.CodeEditor(interface=self) + # The backend needs the theme before the inherited constructor sets the + # initial value, which paints the first highlights. + impl.set_theme(self._theme) + return impl @toga.MultilineTextInput.value.setter def value(self, value: object) -> None: @@ -115,7 +140,7 @@ def language(self) -> str | None: @language.setter def language(self, value: str | None) -> None: - highlighter = NullHighlighter() if value is None else PygmentsHighlighter(value) + highlighter = _highlighter_for(value) self._language = value self._highlighter = highlighter self._rehighlight() @@ -127,7 +152,7 @@ def theme(self) -> Theme: @theme.setter def theme(self, value: Theme | None) -> None: - self._theme = DEFAULT_THEME if value is None else value + self._theme = _theme_for(value) self._impl.set_theme(self._theme) self._rehighlight() @@ -154,9 +179,9 @@ def _schedule_rehighlight(self) -> None: timer added from a native callback would otherwise never fire. """ self._cancel_pending_rehighlight() - self._pending_rehighlight = toga.App.app.loop.create_task( - self._rehighlight_after_delay() - ) + task = toga.App.app.loop.create_task(self._rehighlight_after_delay()) + task.add_done_callback(_report_rehighlight_failure) + self._pending_rehighlight = task async def _rehighlight_after_delay(self) -> None: await asyncio.sleep(REHIGHLIGHT_DELAY) diff --git a/src/toga_code_editor/highlighting.py b/src/toga_code_editor/highlighting.py index b2f15f2..05da557 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 @@ -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 + :class:`~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) @@ -155,12 +168,12 @@ def highlight(self, text: str) -> list[Span]: ) -def language_for_filename(path: str | PathLike) -> str | None: +def language_for_filename(path: str | bytes | PathLike) -> str | None: """Return the Pygments lexer alias for a filename, or ``None`` if there is none. The whole filename is matched, so names such as ``Makefile`` resolve. """ - lexer_class = find_lexer_class_for_filename(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/tests/test_code_editor.py b/tests/test_code_editor.py index e63c84b..4e373b0 100644 --- a/tests/test_code_editor.py +++ b/tests/test_code_editor.py @@ -3,7 +3,12 @@ import pytest from toga.fonts import MONOSPACE, SERIF, SYSTEM from toga.style import Pack -from toga_dummy.utils import assert_action_performed, attribute_value +from toga_dummy.utils import ( + EventLog, + assert_action_performed, + assert_action_performed_with, + attribute_value, +) from toga_code_editor import DEFAULT_THEME, CodeEditor, Span, Style, TokenKind from toga_code_editor.code_editor import REHIGHLIGHT_DELAY @@ -69,6 +74,16 @@ def test_theme_and_line_numbers(app): assert attribute_value(editor, "show_line_numbers") is False +def test_construction_highlights_once(app): + """The initial value, language, and theme are painted in a single pass.""" + theme = {TokenKind.NUMBER: Style("red")} + editor = CodeEditor(value="x = 1", language="python", theme=theme) + + assert EventLog.values(editor, "highlights") == [X_EQUALS_ONE] + # The backend had the theme before that paint. + assert_action_performed_with(editor, "paint", theme=theme) + + def test_value_rehighlights(app): editor = CodeEditor(language="python") editor.value = "x = 1" @@ -95,3 +110,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