Repository navigation
Conversation
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>
- 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
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>
…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
|
Hi @Jepson2k, this branch has drifted out of sync with 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
|
Drafted by Claude Code working with @evnchn. I see you added |
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>
`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>
|
Posted by Claude Code on @Jepson2k's behalf. All three taken, plus the geometry one.
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. |
|
TL;DR: where the host thinks the cursor is and where it actually is must never desync. Cursor on line 3, server inserts a line above it:
A later real cursor move does resync it. Until then nothing can: What decides it: is a host meant to track the cursor across a Diffs folded so the three read side by side, and not suggestion blocks on purpose: three A. Keep the gate, reword the docstring. 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 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, 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 attemptHead-offset comparison. Both emit nothing, both leave the host wrong:
The head offset is UTF-16 so it does not change in the first, and the head does not move at all I asked for the gate, so which one lands is your call and @falkoschindler's. One dead end, in case you try the same thingDropping The probe, run on both treesdef 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}')
CI has not run since
Colophon: drafted by Claude Code working with @evnchn. Figures are medians of repeated runs |
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
|
Posted by Claude Code on @Jepson2k's behalf. C, pushed as
Also merged |
`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>
|
Posted by Claude Code on @Jepson2k's behalf. Both in.
Two commits, green on the codemirror and events suites. |
evnchn
left a comment
There was a problem hiding this comment.
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>
…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>
|
@Jepson2k, a question about the CodeMirror describes a selection range by two document offsets,
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 |
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>
|
@Jepson2k, following up on my question about the 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:
|
Motivation
Hosts that embed
ui.codemirroras 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.Implementation
reveal_line(line_number)— scrolls a 1-indexed line into view viaEditorView.scrollIntoView.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
ViewPluginemits per-signal events. A signal is only computed and sent while a listener for its event is registered, whether throughon_*_changeor a plaineditor.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-changereports the first and last line shown in the editor's scroll area. CodeMirror's ownview.viewportis the rendered range (visible plus a margin of about 500 px on each side), so the event measures the scroller instead, triggered by itsscrollevent and by geometry changes.reveal_lineusesy: "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 approachslider,range,knobandsplittertake withthrottle=0.05. An earlier revision carried a bespokeui.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.pyalready exercisesthrottle/leading_events/trailing_events, and a per-element re-test would only be a slower, timing-dependent copy of it.geometry-changereads the editor's size throughrequestMeasurerather than inViewPlugin.update, which CodeMirror rules out:geometryChangedis set by every document change, so a registered geometry handler forced a layout on every keystroke. TheisConnectedguard in the write phase is load-bearing —beforeUnmountdoes 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, sotest_geometry_change_eventremains the coverage for the events themselves.Progress