Skip to content

Add CodeMirror editor signals (selection, focus, viewport, geometry) and reveal_line - #5984

Open
Jepson2k wants to merge 25 commits into
zauberzeug:mainfrom
Jepson2k:cm-cursor-save-reveal
Open

Jepson2k wants to merge 25 commits into
zauberzeug:mainfrom
Jepson2k:cm-cursor-save-reveal

Conversation

@Jepson2k

@Jepson2k Jepson2k commented Apr 23, 2026 •

Copy link
Copy Markdown
Contributor

Motivation

Hosts that embed ui.codemirror as an in-app editor often need feedback about the editor's state — cursor line / column, focus, the visible line range, and editor geometry. None of this is exposed through the Python API today, and there's no programmatic way to scroll a specific line into view.

pr-5984

Implementation

  • reveal_line(line_number) — scrolls a 1-indexed line into view via EditorView.scrollIntoView.
  • Four typed signal handlers: on_selection_change(line, column, from_line, to_line, empty), on_focus_change(focused), on_viewport_change(from_line, to_line), on_geometry_change(width, height, content_height).

A single JS ViewPlugin emits per-signal events. A signal is only computed and sent while a listener for its event is registered, whether through on_*_change or a plain editor.on('selection-change', ...), so unsubscribed signals cost nothing on the wire. Payloads are deduped against the last one sent, so an update that leaves a signal unchanged never reaches the socket either.

viewport-change reports the first and last line shown in the editor's scroll area. CodeMirror's own view.viewport is the rendered range (visible plus a margin of about 500 px on each side), so the event measures the scroller instead, triggered by its scroll event and by geometry changes.

reveal_line uses y: "nearest" with a margin of half the visible height. y: "center" would also re-center the window whenever the editor cannot scroll far enough itself. CodeMirror applies the margin to every scrollable ancestor, so it is taken from the smallest one, the window included: half the editor height would push the line out of sight whenever the editor is more than twice as tall as what it scrolls in, e.g. an auto-height editor in a scrolling page. ※

Rate limiting is Element.on(..., throttle=...), passed by the four registrars with per-signal defaults of 30 ms / none / 100 ms / 100 ms — the same approach slider, range, knob and splitter take with throttle=0.05. An earlier revision carried a bespoke ui.codemirror.handler(cb, debounce_ms=...) factory for this; it was removed in favour of the existing mechanism, which is per-listener rather than per-element and delivers the first event of a burst immediately.

No test covers the throttling itself: tests/test_events.py already exercises throttle / leading_events / trailing_events, and a per-element re-test would only be a slower, timing-dependent copy of it.

geometry-change reads the editor's size through requestMeasure rather than in ViewPlugin.update, which CodeMirror rules out: geometryChanged is set by every document change, so a registered geometry handler forced a layout on every keystroke. The isConnected guard in the write phase is load-bearing — beforeUnmount does not destroy the view, so a queued measure can outlive the editor's DOM and would otherwise report a 0×0 geometry. One consequence: the event now lands in a later measure cycle rather than in synchronous plugin-update order. No test covers the unmount path — reaching "view alive, DOM detached" needs scaffolding that mirrors the implementation, so test_geometry_change_event remains the coverage for the events themselves.

Progress

  • The PR title is a short phrase starting with a verb like "Add ...", "Fix ...", "Update ...", "Remove ...", etc.
  • The implementation is complete.
  • This PR does not address a security issue.
  • Pytests have been added.
  • Documentation has been added.
  • No breaking changes to the public API.

Jepson2k and others added 2 commits April 23, 2026 18:19
Three small editor-event primitives requested by host applications that
embed CodeMirror as an in-app code editor:

- `on_cursor_line` (typed `CodeMirrorCursorLineEventArguments`, 1-indexed
  line number, 30 ms debounce on the JS side) reports cursor-line
  changes. The debounce is short enough to feel immediate when arrow-
  keying through a file but long enough to coalesce bursts during
  multi-line selection drags.
- `on_save` (typed `CodeMirrorSaveEventArguments`) fires on Ctrl/Cmd+S
  inside the editor and suppresses the browser's default save dialog.
  The Mod-s keymap binding is only installed when the host opts in via
  the `save-shortcut-enabled` prop, so editors without an `on_save`
  handler keep the browser's default behavior.
- `reveal_line(line_number)` scrolls a 1-indexed line into view via
  CodeMirror's `EditorView.scrollIntoView` effect.

Tests dispatch CM6 selection transactions directly rather than relying
on Selenium keystroke focus timing.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Replaces the narrow `on_cursor_line` and `on_save` primitives added in
the prior commit with two generic mechanisms (one of which lands here,
the other deferred to a follow-up PR). Both removals are unreleased.

ViewUpdate signal dispatcher (this PR):

- Single JS `ViewPlugin` (`updateDispatcher`) reads four CM6 ViewUpdate
  flags and emits one event per signal: `selection-change` (line +
  column), `focus-change` (focused), `viewport-change` (visible line
  range), `geometry-change` (width / height / content height). Each
  signal has a dedicated `<signal>-tracking-enabled` prop so the
  dispatcher bails early when the host has not subscribed; per-signal
  `<signal>-debounce-ms` props (defaults 30 / 0 / 100 / 100) are read
  fresh on each emit so runtime overrides apply on the next event
  without a Vue watch. Per-signal dedupe of the last emitted payload
  drops redundant traffic. Timers live on the plugin instance and
  `destroy()` clears them, removing the leak-prone `_cursorTimer` on
  the Vue component.
- Public Python API: four typed `on_*_change` handlers + corresponding
  event-args dataclasses. Both the constructor kwargs and the methods
  accept `Handler | CodeMirrorHandlerSpec`; per-registration overrides
  go through a single `ui.codemirror.handler(callback, debounce_ms=...)`
  factory (a static method exposing the spec dataclass) so constructor
  and method surfaces stay shape-identical.

Generic keybindings API (separate follow-up PR): replaces `on_save` with
`keybindings={...}` and `editor.on_keybinding(key, handler)`. No
JS-plugin overlap with the dispatcher landed here (different CM6
primitives — `ViewPlugin` vs `keymap`); the only shared piece is
`CodeMirrorHandlerSpec`, which the keybindings PR extends with
`prevent_default`. Brief no-save-binding gap is invisible to users
since `on_save` is also unreleased.

`reveal_line` is unchanged. The new `viewport-change` signal closes
its previously missing feedback channel — a host can now confirm a
revealed line landed in the visible range.

Tests cover all four signals (with mid-line cursor positions to
exercise the column field, viewport range after `reveal_line`, focus
toggles via JS, geometry under container resize), plus
`test_no_handler_no_traffic` (verifies subscription gating prevents
emit when nothing is listening) and `test_debounce_override` (verifies
the spec factory's debounce_ms is honored end-to-end).

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
@Jepson2k Jepson2k changed the title Add cursor-line, save, and reveal-line events to CodeMirror Add reveal_line and a generic ViewUpdate signal dispatcher to CodeMirror Apr 25, 2026
@Jepson2k Jepson2k changed the title Add reveal_line and a generic ViewUpdate signal dispatcher to CodeMirror Add CodeMirror editor signals (selection, focus, viewport, geometry) and reveal_line Apr 25, 2026
Jepson2k added a commit to Jepson2k/nicegui that referenced this pull request Apr 27, 2026
- Remove highlight_lines (concerns covered by set_decorations + ui.timer for
  auto-clear, and reveal_line in zauberzeug#5984 for scrolling).
- Add ReplaceDecorationSpec (collapse, text-widget, block-mode forms) and
  WidgetDecorationSpec (text annotation at a position).
- Add internal TextWidget JS class (textContent only, no XSS risk) shared by
  the replace-with-text and widget-decoration paths.
- Simplify setDecorations: prop-driven only, no JS-internal merge.
- Tests cover replace collapse, replace with text, replace block mode, and
  widget decoration; all assert document is unchanged (decorations are
  presentation-only).
- Demo shows all four kinds.
- Drop 8 dead `add_rename` calls for new tracking/debounce-ms props
  (Vue auto-converts kebab-case attributes to camelCase props; `add_rename`
  is a one-shot deprecation shim that only fires on the OLD name)
- Move `CodeMirrorHandlerSpec` from `nicegui.events` to `codemirror.py`
  (it's a registration-config wrapper, not an event-arg dataclass)
- Bind `CodeMirror.handler()` factory to `EventT` so the wrapped callable's
  argument type flows through to the consuming `on_*_change` registration
- Replace blind `screen.wait(0.3)` mount-waits in 6 new tests with
  deterministic `screen.should_contain(...)` checks
@Jepson2k
Jepson2k marked this pull request as ready for review April 30, 2026 13:58
@falkoschindler falkoschindler added this to the Next milestone May 5, 2026
@falkoschindler falkoschindler added feature Type/scope: New or intentionally changed behavior review Status: PR is open and needs review labels May 5, 2026
@falkoschindler
falkoschindler self-requested a review May 5, 2026 12:58
@Jepson2k Jepson2k mentioned this pull request Jun 4, 2026
6 tasks done
Jepson2k and others added 2 commits June 29, 2026 12:11
Resolve conflicts from zauberzeug#6000 (CodeMirror custom keybindings), which refactored
the same files. Editor signals/reveal_line and keybindings are orthogonal, so
each conflict keeps both:

- codemirror.py: add `from __future__ import annotations`; import
  SUPPORTED_LANGUAGES/THEMES from .constants and KeyBindingElement from
  .keybindings (dropping the inline Literals); merge the events import (signal
  event args + CodeMirrorKeyBindingEventArguments); class now extends
  KeyBindingElement; keep CodeMirrorHandlerSpec and both sets of constructor
  params/docstrings.
- codemirror.js: keep both the signal-tracking props/methods (incl. revealLine)
  and the keymap props/methods/extensions.
- codemirror_documentation.py: keep both the Editor Signals and Custom
  Keybindings demos.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drop the redundant int()/bool() coercions in the selection/focus/viewport/
geometry handlers: the values come straight from CodeMirror (line numbers,
clientWidth/clientHeight, hasFocus) and already match the event-args' declared
types. The one fractional source, view.contentHeight, is now rounded in
codemirror.js so it honors the declared int contract on the JS side instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@falkoschindler falkoschindler self-assigned this Jun 30, 2026
Jepson2k and others added 2 commits July 24, 2026 00:50
…payload

The signal dispatcher dedupes each event against the last payload it
emitted, including selection echoes of programmatic value updates made
while the editor was unfocused. A host that ignores unfocused selection
events then never hears about a real click landing on the echoed
position — the dedupe cache is now cleared on every focus transition.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XbP8n8tzaJXKMjkRxGnoa1
from_line/to_line give the 1-indexed lines spanned by the main
selection and empty distinguishes a bare cursor, so hosts can act on
multi-line selections without a client round trip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LkVPPMQg33tFuGCVLkyrJQ
@falkoschindler

Copy link
Copy Markdown
Contributor

Hi @Jepson2k, this branch has drifted out of sync with main: the conflicts are in codemirror.js, codemirror.py and codemirror_documentation.py, and they come mostly from your own line-anchor work — #5988 landed in 3.16, the follow-up #6282 moved the on_anchor_change dispatch to Python, and the "Added in" annotations were standardized in between — so the CodeMirror element has seen quite some motion since this branch was cut. Could you merge current main into the branch and resolve the conflicts (no rebase/force-push needed)?

Since #5984, #5985 and #5986 all touch the same spots, I'd suggest doing them one at a time rather than keeping all three in sync: pick the one you want to land first, bring it up to date, mark it "ready for review", and once it is merged, do the same for the next. That also keeps you well within the three-ready-PRs limit Evan mentioned. Note that #5991 (decoration API) is planned for 3.17 and lands in the same files, so the first one may need one more small update after that — sorry about the churn.

Until then I'm assigning this to you and marking it as draft; flip it back to "ready for review" whenever it is green and I'll take it from there. ※

# Conflicts:
#	nicegui/elements/codemirror/codemirror.js
#	nicegui/elements/codemirror/codemirror.py
#	website/documentation/content/codemirror_documentation.py
@Jepson2k
Jepson2k marked this pull request as ready for review August 20, 2026 17:17
@evnchn

evnchn commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

Drafted by Claude Code working with @evnchn.

I see you added ui.codemirror.handler(cb, debounce_ms=...) and CodeMirrorHandlerSpec to rate-limit the new events, but Element.on(..., throttle=, leading_events=, trailing_events=) already does this for every event in seconds, and in my eyes it covers everything yours does (checked: with the JS debounce at 0, throttle=0.2, leading_events=False coalesced 10 cursor moves at 30 ms into 2 events carrying the latest payload). If you agree, please remove it; if not, let's discuss.

Element.on(..., throttle=, leading_events=, trailing_events=) already
rate-limits every event at the socket-emit boundary, and keys it on the
listener id, so the limit is genuinely per-registration. The debounce props
this replaces were element props: a second handler registered with a
different debounce_ms silently clobbered the first.

The old debounce values carry over as throttle defaults, matching how
slider, range, knob and splitter hardcode throttle=0.05. The first event in
a burst now arrives immediately rather than after the delay, since
leading_events defaults to True.

The JS dispatcher keeps its payload dedupe and its per-signal tracking
props: both gate traffic at the source rather than rate-limiting it.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Jepson2k and others added 2 commits September 21, 2026 09:41
`column` was a UTF-16 code-unit offset while the Python side indexes
`value` by code point -- the mismatch `_encode_codepoints` exists to
absorb everywhere else. On a line starting with an astral character a
host slicing `line[:e.column - 1]` got one character too many.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
geometry-change read clientWidth/clientHeight inside ViewPlugin.update,
which CodeMirror's docs rule out, and geometryChanged is set by every
document change -- so a registered geometry handler forced a layout on
every keystroke (~0.27 ms, measured by @evnchn). Throttling on the Python
side cannot help: the read happens client-side, before anything is
emitted. requestMeasure moves it into the phase CM already batches.

The isConnected guard is load-bearing. beforeUnmount does not destroy the
view, so a queued measure survives the editor's DOM being detached and
would otherwise hand the host a 0x0 geometry payload on unmount.

One consequence: geometry-change now lands in a later measure cycle
rather than synchronous plugin-update order, so a host watching several
signals at once can observe the ordering change.

No test: the unmount path needs the view detached while alive, which the
public API does not expose without scaffolding that mirrors the
implementation. test_geometry_change_event still covers the events
themselves.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Jepson2k

Copy link
Copy Markdown
Contributor Author

Posted by Claude Code on @Jepson2k's behalf.

All three taken, plus the geometry one.

  • reveal_line — one word changed: is not an integer in [1, N], since 2.5 genuinely is in [1, 3].
  • selection-change on set_value — as written.
  • Code-point column — as written. The real bug of the three.
  • The 0.27 ms — taken too, isConnected guard included. The forced layout only exists because this PR adds geometry-change, so it's ours to fix rather than inherit. The measure-cycle ordering change is in the commit message.

No test for the unmount path: it needs the view alive with its DOM detached, which the public API doesn't expose without scaffolding that mirrors the implementation. Reason is in Implementation.

Each of the three regression tests was verified to fail with its fix reverted. Four commits, one per item.

@evnchn

evnchn commented Sep 21, 2026

Copy link
Copy Markdown
Collaborator

TL;DR: where the host thinks the cursor is and where it actually is must never desync.
The emitting gate I asked for desyncs them, and nothing corrects it until the user happens to
move the cursor themselves. Three ways out below, none mine to pick.

Cursor on line 3, server inserts a line above it:

events emitted host believes cursor actually on
2f7acc51 parent 1 line 4 line 4 in sync
1792efe3 head 0 line 3 line 4 desynced

A later real cursor move does resync it. Until then nothing can: on_selection_change is the
only channel reporting the cursor, and there is no Python-side getter to read instead.

What decides it: is a host meant to track the cursor across a set_value? If no, A is enough.
If yes, only B or C close the desync.

Diffs folded so the three read side by side, and not suggestion blocks on purpose: three
Commit buttons on a decision point is a way to decide by accident.

A. Keep the gate, reword the docstring. codemirror.py L138. Cheapest, and leaves the
desync in place.

diff
-        Fires on selection moves and on document edits that shift the cursor line or column.
+        Fires on selection moves and on user edits that shift the cursor line or column.
+        A server-driven ``set_value`` does not emit, even when it moves the cursor.

B. Keep the gate, add programmatic: bool and emit again. Echo-ignorers filter on it,
cursor-trackers stay in sync. Untested, and it joins _maybeEmit's whole-payload comparison.

diff
--- a/nicegui/elements/codemirror/codemirror.js
-            if (self.selectionTrackingEnabled && (u.selectionSet || (u.docChanged && self.emitting))) {
+            if (self.selectionTrackingEnabled && (u.selectionSet || u.docChanged)) {
               const sel = u.state.selection.main;
               const line = u.state.doc.lineAt(sel.head);
               this._maybeEmit("selection-change", {
                 line: line.number,
+                programmatic: u.docChanged && !self.emitting,

--- a/nicegui/events.py
 class CodeMirrorSelectionChangeEventArguments(UiEventArguments):
     line: int
     column: int
+    programmatic: bool

--- a/nicegui/elements/codemirror/codemirror.py
             empty=e.args['empty'],
+            programmatic=e.args['programmatic'],
         )), throttle=0.03)

C. Emit on a document edit only when the selection payload changed. The only one I built,
and my first attempt was wrong: comparing the head offset misses a column change when ab
becomes 😎, and a to_line change when a backward selection loses its middle line. Comparing
the whole payload against u.startState handles both. 27 passed, table back to in-sync, and
both counterexamples now emit correctly.

diff
-            if (self.selectionTrackingEnabled && (u.selectionSet || (u.docChanged && self.emitting))) {
-              const sel = u.state.selection.main;
-              const line = u.state.doc.lineAt(sel.head);
-              this._maybeEmit("selection-change", {
-                line: line.number,
-                column: Array.from(u.state.doc.sliceString(line.from, sel.head)).length + 1,
-                from_line: u.state.doc.lineAt(sel.from).number,
-                to_line: u.state.doc.lineAt(sel.to).number,
-                empty: sel.empty,
-              });
+            if (self.selectionTrackingEnabled && (u.selectionSet || u.docChanged)) {
+              const payload = (state) => {
+                const sel = state.selection.main;
+                const line = state.doc.lineAt(sel.head);
+                return {
+                  line: line.number,
+                  column: Array.from(state.doc.sliceString(line.from, sel.head)).length + 1,
+                  from_line: state.doc.lineAt(sel.from).number,
+                  to_line: state.doc.lineAt(sel.to).number,
+                  empty: sel.empty,
+                };
+              };
+              const now = payload(u.state);
+              if (u.selectionSet || JSON.stringify(now) !== JSON.stringify(payload(u.startState))) {
+                this._maybeEmit("selection-change", now);
+              }
             }
the two cases that killed my first attempt

Head-offset comparison. Both emit nothing, both leave the host wrong:

host believed truth after the edit emitted
ab before the cursor becomes 😎 column 3 column 2 0
backward selection over 3 lines, middle one deleted to_line 3 to_line 2 0

The head offset is UTF-16 so it does not change in the first, and the head does not move at all
in the second. With the payload comparison both emit, (1, 2, 1, 1, True) and
(1, 1, 1, 2, False).

I asked for the gate, so which one lands is your call and @falkoschindler's.

One dead end, in case you try the same thing

Dropping && self.emitting and leaning on the existing _maybeEmit dedupe looks like C for
free. It fails your test with assert 2 == (0 + 1): _last["selection-change"] is still empty
there, so the first echo has nothing to dedupe against. The comparison has to be against
u.startState.

The probe, run on both trees
def test_cursor_after_set_value(screen: Screen):
    events, editor = [], None

    @ui.page('/')
    def page():
        nonlocal editor
        editor = ui.codemirror('L1\nL2\nL3',
                               on_selection_change=lambda e: events.append(e.line))

    screen.open('/')
    screen.should_contain('L3')
    screen.selenium.execute_script(
        f'const el = getElement({editor.id});'
        'el.editor.dispatch({selection: {anchor: el.editor.state.doc.line(3).from}});')
    screen.wait_for(lambda: 3 in events)
    before = len(events)

    editor.set_value('NEW\nL1\nL2\nL3')
    screen.should_contain('NEW')
    time.sleep(1.0)                      # give any echo time to arrive

    actual = screen.selenium.execute_script(
        f'const el = getElement({editor.id});'
        'const s = el.editor.state; return s.doc.lineAt(s.selection.main.head).number;')
    print(f'emitted={len(events) - before} host={events[-1]} actual={actual}')

parent: emitted=1 host=4 actual=4 · head: emitted=0 host=3 actual=4

CI has not run since 2f7acc51, so today's four commits have not been through it. I merged
main locally to check the above, and both conflicts are keep-both, in case it saves you a look:


Colophon: drafted by Claude Code working with @evnchn. Figures are medians of repeated runs
through the repo's screen fixture, head 1792efe3 against parent 2f7acc51. I have moved the
attribution to the foot so the first line can be about the work. Adopt it or not, no rule here.

Comment thread nicegui/elements/codemirror/codemirror.js Outdated
Jepson2k and others added 2 commits September 21, 2026 12:23
The `emitting` gate suppressed every server-driven edit, but `set_value`
replaces only the changed region and CodeMirror remaps the selection
through it -- so an insertion above the cursor moved it for real while the
host heard nothing and went on pointing at the old line. Nothing corrected
that until the user moved the cursor themselves.

Compare the whole payload against `u.startState` instead: an edit that
leaves the selection alone stays silent, one that moves it emits. The
whole payload rather than the head offset, because the offset is UTF-16
(so `ab` becoming an astral character does not change it) and does not
move at all when a backward selection loses its middle line.

The `_maybeEmit` dedupe cannot stand in for this: it compares against the
last payload sent, which is empty until something has been emitted.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both conflicts were keep-both:

- the 3.17.0 docstring paragraph landed beside the Decorations one
- zauberzeug#6336 moved the Line Anchors demo to the end of the documentation file,
  so this branch's copy at the old position goes and the Editor Signals
  demo stays where it was
@Jepson2k

Copy link
Copy Markdown
Contributor Author

Posted by Claude Code on @Jepson2k's behalf.

C, pushed as 32b44c22. You were right both times — the echo was real, and so was the desync the gate traded it for. The payload-vs-startState comparison is the thing that answers the actual question; emitting only ever meant "the server already knows the text".

test_set_value_emits_only_when_the_cursor_actually_moves pins it from both sides: it fails against the gate on the remap, and against the ungated original on the echo. Your two counterexamples are why it compares payloads rather than the head offset.

Also merged main — both conflicts keep-both, as you said. CI is running on these five commits now.

Comment thread nicegui/elements/codemirror/codemirror.js Outdated
Jepson2k and others added 2 commits September 21, 2026 22:19
`contentHeight` comes off CodeMirror in scaled pixels, while the `width`
and `height` beside it in the same payload are layout pixels. Under a CSS
transform the three disagree: at `scale(0.5)` a 28px content height is
reported as 14 while the width and height are unchanged.

A transform changes neither the document nor the space it is laid out in,
so undo the scale and let all three agree.

Reported with a reproduction by @evnchn.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
`column` has to index a Python `str`, so it counts code points rather than
the UTF-16 units a JS string is made of. Array.from() does that by
allocating an entry per code point, which made the cost of a selection
change scale with the length of the line: 0.565 ms on a 200 000-character
line against 0.106 before, measured by @evnchn. The payload comparison
added since then doubled it, being built twice per update.

The two counts differ only above U+FFFF, and a character above U+FFFF
reaches a JS string only as a surrogate pair. So a prefix with no high
surrogate has code units equal to code points, and the allocation is only
worth paying for when one is actually there. The implication runs one way
only, which is safe: a lone high surrogate takes the slow branch and
Array.from returns the same number.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread nicegui/elements/codemirror/codemirror.js Outdated
Comment thread nicegui/elements/codemirror/codemirror.py
@Jepson2k

Copy link
Copy Markdown
Contributor Author

Posted by Claude Code on @Jepson2k's behalf.

Both in.

content_height — this is a real bug in the API this PR adds, not a nitpick. Fixed as suggested, and test_geometry_change_event now pins it: a CSS transform changes neither the document nor the space it is laid out in, so all three numbers have to sit still. It fails assert 14 == 28 without the divide.

Array.from — taken. Worth noting your measurement understates it now: C builds the payload twice per update to compare against startState, so the cost had doubled since you timed it. The surrogate check is exact where it needs to be, and a lone high surrogate taking the slow branch costs nothing.

Two commits, green on the codemirror and events suites.

evnchn
evnchn previously approved these changes Sep 22, 2026

@evnchn evnchn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. I checked that the new test fails without the divide (assert 14 == 28) and that the value holds at scale(0.5), scale(2), scale(1.7), scaleY(0.25) and a scaled parent. The two suggestions are optional, resolve them if you would rather not take them.

※

- viewport-change reported CodeMirror's rendered range (visible plus a
  margin of about 500 px), not the visible lines, and only fired when
  scrolling got near its edge. It now measures the scroller, triggered
  by its scroll event and by geometry changes.
- reveal_line no longer scrolls the surrounding page when the editor
  cannot center the line itself: "nearest" with a margin of half the
  editor height instead of "center".
- A signal is sent while a listener for its event is registered, so a
  plain `editor.on('selection-change', ...)` works and the four
  `*-tracking-enabled` props are gone.
- focus-change is emitted ahead of selection-change: a click into an
  unfocused editor puts both into the same update.
- The four registrars move into a `SignalElement` mixin and their tests
  into `tests/test_codemirror_signals.py`. Eight screen tests become
  five: the focus and re-emit tests are merged, as are the viewport and
  reveal_line tests, and `test_no_handler_no_traffic` is dropped because
  it observed an internal mechanism.
- An out-of-range integer passed to reveal_line is reported as out of
  range, as suggested by @evnchn.
- The API is annotated with 3.18.0; 3.17.0 is already released.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@falkoschindler falkoschindler modified the milestones: Next, 3.18 Oct 3, 2026
SaadZahem pushed a commit to SaadZahem/nicegui that referenced this pull request Oct 4, 2026
…uberzeug#6368)

### Motivation

`ui.codemirror` never destroys its CodeMirror `EditorView` when the
component is unmounted. CodeMirror registers `resize` and `scroll`
listeners on `window`, so the old view stays alive after its DOM is gone
and keeps running `measure()` on every window resize. Every client-side
remount leaves one more view behind: when an event listener is added
after the first render, or when the editor sits in a `v-if` container.

Fixes zauberzeug#6367.

### Implementation

`beforeUnmount` now calls `this.editor.destroy()` after the value, line
anchors and decorations have been captured for the remount.

Checked in a browser with the example from zauberzeug#6367: before, the old view
reports `destroyed === false` after the remount and its `measure()` runs
on a window resize. With the fix it is destroyed, `measure()` is no
longer called, and the remounted editor keeps its value.

There is no test: the leak is only observable through the internals of
the old view, so a test would have to hold on to that view and patch its
`measure()`.
[※](https://zauberzeug.github.io/colophon/#m=claude-fable-5-1&t=Found%20during%20the%20review%20of%20%235984%3B%20fix%20and%20text%20by%20the%20model%2C%20checked%20in%20a%20real%20browser.&d=2026-10-03&a=claude-code
"claude-fable-5-1: Found during the review of zauberzeug#5984; fix and text by the
model, checked in a real browser.")

### Progress

- [x] The PR title is a short phrase starting with a verb like "Add
...", "Fix ...", "Update ...", "Remove ...", etc.
- [x] The implementation is complete.
- [x] This PR does not address a security issue.
- [x] The **Implementation** section explains why pytests are not
necessary.
- [x] Documentation is not necessary.
- [x] No breaking changes to the public API.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
reveal_line checked whether it got an integer and clamped everything else
to the nearest line. The annotation already says `int`, and neither
line_tooltips nor line_anchors guards against a non-integer in the
frontend: they check only what depends on the document and warn and skip
a line outside it. reveal_line now does the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@falkoschindler

Copy link
Copy Markdown
Contributor

@Jepson2k, a question about the selection-change payload: what made you go with line, column, from_line, to_line and empty instead of following CodeMirror's own selection model?

CodeMirror describes a selection range by two document offsets, anchor and head, and derives everything else from them. The current payload mixes two things: the cursor position (line, column) and a line-granular span (from_line, to_line). This leads to a few quirks:

  • line always equals either from_line or to_line, yet the payload doesn't tell which end the cursor is on.
  • The span has no columns, so a partial selection within a line can't be reconstructed. empty only tells you that something is selected.
  • column is 1-based and counts code points. That's neither CodeMirror's notion (UTF-16 offset from the line start) nor a Python index (line_text[column - 1]).
  • Only selection.main is reported, while basicSetup enables multiple selections. Moving a secondary cursor goes unnoticed.

I couldn't find a rationale in the discussion or the commits. 4bde004 says the span lets "hosts act on multi-line selections without a client round trip", but doesn't name a concrete use case. Did you have one in mind that needs exactly this shape?

Otherwise I'd suggest staying close to CodeMirror: report anchor and head as code-point offsets into value, so they can be used directly as Python string indices. line/column of the head could stay as a convenience for status bars. That would be lossless, and from_line, to_line and empty would follow from it. ※

falkoschindler and others added 2 commits October 6, 2026 23:07
reveal_line scrolled with a margin of half the editor height, but
CodeMirror applies that margin to every scrollable ancestor, the window
included. Whenever the editor is more than twice as tall as what it
scrolls in, the margin pushed the line past that container's edge: an
auto-height editor in a scrolling page or a tall editor in a short
ui.scroll_area moved the line out of sight. The margin is now half the
height of the smallest scrollable ancestor, so nothing changes for an
editor that scrolls itself. The heights are taken in screen pixels like
the line's rectangle, which also centers the line under a CSS transform.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The viewport measurement mixed the scroller's scrollTop and clientHeight,
which are layout pixels, with CodeMirror's height map, which is in
scaled pixels. Under a CSS transform the reported lines were off by the
scale factor: at scale(0.5) an editor showing lines 52 to 64 reported
102 to 128.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@falkoschindler

Copy link
Copy Markdown
Contributor

@Jepson2k, following up on my question about the selection-change payload: it's the one thing holding this PR back. The other signals and reveal_line are ready (I pushed two fixes for scaled and auto-height editors), but I'd rather not ship an event shape we'd have to break later.

Thinking about it more, the right shape depends on who uses the event and for what. A status bar needs the cursor's line and column. Acting on the selected text needs both ends with character precision. Commenting out the selected lines needs the line span. Each of these suggests different fields, and I don't want to guess.

So two questions:

  1. What do you use on_selection_change for in your app? A concrete example would settle it.
  2. Would it be okay to move on_selection_change into a follow-up PR? Then the rest could go into the next release, and we design the selection event around your use case without time pressure.

※

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature Type/scope: New or intentionally changed behavior review Status: PR is open and needs review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants