From a6ece94cedd4447f60a6229120578acfb7ee79e3 Mon Sep 17 00:00:00 2001 From: Jeff Lee Date: Fri, 12 Jun 2026 12:06:18 -0400 Subject: [PATCH 1/3] feat: add completion and hover language-feature providers Add `completion` and `hover` props to the Editor widget. Each names a trame @trigger that receives (code, line, column) and returns results; the component registers Monaco completion/hover providers that call the trigger over the existing websocket and map the normalized results onto Monaco. The consumer writes only Python: no client-side JavaScript and no access to the Monaco instance are required. - completion items: {label, kind, detail, documentation, insertText} - hover: a markdown string, a list of strings, or {contents: [...]} - positions: line is 1-based, column is 0-based - requests honor Monaco's CancellationToken; providers are disposed on unmount and re-registered when the language changes - adds a jedi-backed Python example under example/language-features --- example/language-features/app.py | 98 +++++++++++++ example/language-features/requirements.txt | 4 + example/live-state/app.py | 123 +++++++++++++++++ example/live-state/requirements.txt | 3 + tests/requirements.txt | 1 + tests/test_language_features.py | 20 +++ trame_code/widgets/code.py | 9 ++ vue-components/src/components/Editor.js | 153 +++++++++++++++++++++ 8 files changed, 411 insertions(+) create mode 100644 example/language-features/app.py create mode 100644 example/language-features/requirements.txt create mode 100644 example/live-state/app.py create mode 100644 example/live-state/requirements.txt create mode 100644 tests/test_language_features.py diff --git a/example/language-features/app.py b/example/language-features/app.py new file mode 100644 index 0000000..98ed52d --- /dev/null +++ b/example/language-features/app.py @@ -0,0 +1,98 @@ +"""Editor language features: completion + hover backed by a Python callback. + +The ``completion`` and ``hover`` props on ``code.Editor`` each name a trame +``@trigger`` that receives ``(code, line, column)`` and returns results. Here +both are backed by jedi, giving live Python completion and docstring-on-hover +entirely in-process, with no client-side JavaScript. + +The contract: + +* completion trigger returns a list of items, each a dict with keys + ``label`` (required), ``kind``, ``detail``, ``documentation``, ``insertText``. +* hover trigger returns a markdown string, a list of markdown strings, or + ``{"contents": [...]}`` (or ``None`` for no hover). +* positions are passed as ``line`` (1-based) and ``column`` (0-based), matching + jedi's API directly. + +Run with:: + + pip install trame trame-vuetify trame-code jedi + python app.py +""" + +import jedi +from trame.app import get_server +from trame.ui.vuetify3 import SinglePageLayout +from trame.widgets import code +from trame.widgets import vuetify3 as vuetify + +server = get_server() +state, ctrl = server.state, server.controller + +INITIAL_CODE = '''import math + + +def circle_area(radius): + """Return the area of a circle with the given radius.""" + return math.pi * radius**2 + + +# Type "math." below, or hover a name, to see completion and docstrings. +math. +''' + + +@server.trigger("py_complete") +def py_complete(code_text, line, column): + """Completion items for (code, line, column). line 1-based, column 0-based.""" + try: + completions = jedi.Script(code=code_text).complete(line, column) + except Exception: + return [] + return [ + { + "label": c.name, + "kind": c.type, + "detail": (c.description or "")[:80], + } + for c in completions[:200] + ] + + +@server.trigger("py_hover") +def py_hover(code_text, line, column): + """Hover markdown (signature + docstring) for the symbol at the cursor.""" + try: + definitions = jedi.Script(code=code_text).help(line, column) + except Exception: + return None + if not definitions: + return None + definition = definitions[0] + contents = [] + signatures = [s.to_string() for s in definition.get_signatures()] + if signatures: + contents.append("```python\n" + "\n".join(signatures) + "\n```") + doc = definition.docstring(raw=True) or "" + if doc: + contents.append(doc) + return {"contents": contents} if contents else None + + +state.trame__title = "Editor language features" + +with SinglePageLayout(server) as layout: + layout.title.set_text("Editor language features (jedi)") + with layout.content: + with vuetify.VContainer(fluid=True, classes="fill-height pa-0"): + code.Editor( + value=INITIAL_CODE, + language="python", + theme="vs", + completion="py_complete", + hover="py_hover", + style="width: 100%; height: 100%;", + ) + +if __name__ == "__main__": + server.start() diff --git a/example/language-features/requirements.txt b/example/language-features/requirements.txt new file mode 100644 index 0000000..5b8dc30 --- /dev/null +++ b/example/language-features/requirements.txt @@ -0,0 +1,4 @@ +trame +trame-vuetify +trame-code +jedi diff --git a/example/live-state/app.py b/example/live-state/app.py new file mode 100644 index 0000000..d47767f --- /dev/null +++ b/example/live-state/app.py @@ -0,0 +1,123 @@ +"""Editor language features: completion from live in-process state. + +This example shows the capability that distinguishes a callback-backed provider +from a language server: the suggestions come from a live Python object in the +running process, not from source text or type stubs. A language server cannot +offer these names, because they exist only at runtime. + +Here the editor completes the keys of an in-memory ``DATASET`` when the cursor +is inside a ``dataset["..."]`` subscript, annotating each with the live value's +type and length. Swap ``DATASET`` for a loaded data file, a database schema, or +any live object and the same handler surfaces those names into the editor. + +The contract (shared with the language-features example): + +* completion trigger returns a list of items, each a dict with key ``label`` + (required) and optional ``kind``, ``detail``, ``documentation``, ``insertText``. +* positions are passed as ``line`` (1-based) and ``column`` (0-based). + +Run with:: + + pip install trame trame-vuetify trame-code + python app.py +""" + +import re + +from trame.app import get_server +from trame.ui.vuetify3 import SinglePageLayout +from trame.widgets import code +from trame.widgets import vuetify3 as vuetify + +server = get_server() +state, ctrl = server.state, server.controller + +# Stand-in for state that exists only at runtime (a loaded dataset, a live +# object graph, a fetched schema...). None of these names appear in the source. +DATASET = { + "pressure": [0.0] * 1000, + "density": [0.0] * 1000, + "temperature": [0.0] * 1000, + "velocity": [(0.0, 0.0, 0.0)] * 1000, + "time_steps": list(range(50)), +} + +# Match an unclosed string subscript at the cursor: dataset["pre +_SUBSCRIPT_RE = re.compile(r"""\[\s*["']([^"']*)$""") + + +def _describe(value): + """A short, live description of a value: type plus length when available.""" + type_name = type(value).__name__ + try: + return f"{type_name}, len {len(value)}" + except TypeError: + return type_name + + +@server.trigger("live_complete") +def live_complete(code_text, line, column): + """Complete DATASET keys when the cursor is inside a string subscript.""" + lines = code_text.split("\n") + if line < 1 or line > len(lines): + return [] + prefix = lines[line - 1][:column] + + match = _SUBSCRIPT_RE.search(prefix) + if not match: + return [] # not inside a key subscript -> no suggestions (defer to others) + + stub = match.group(1) + return [ + {"label": key, "kind": "field", "detail": _describe(value)} + for key, value in DATASET.items() + if key.startswith(stub) + ] + + +INITIAL_CODE = """# Live-state completion demo +# +# 1. Put the cursor between the empty quotes on the last line: dataset[""] +# 2. Type a letter (p, d, t, or v). Suggestions appear automatically. +# 3. The keys are read live from the in-memory DATASET object +# (pressure, density, temperature, velocity, time_steps), +# each shown with its Python type and length. +# +# A language server cannot offer these: they exist only at runtime. + +field = dataset[""] +""" + +state.trame__title = "Live-state completion" + +with SinglePageLayout(server) as layout: + layout.title.set_text("Live-state completion") + with layout.content: + with vuetify.VContainer(fluid=True, classes="fill-height pa-0"): + code.Editor( + v_model=("live_code", INITIAL_CODE), + language="python", + theme="vs", + completion="live_complete", + # open the list as soon as the key string is entered + completion_trigger_characters=("completion_triggers", ['"', "'", "["]), + # dict-key completion happens inside a string literal, where + # Monaco suppresses suggestions by default; enable them there. + options=( + "live_editor_options", + { + "quickSuggestions": { + "other": True, + "comments": False, + "strings": True, + }, + # only our provider's keys, no document-word noise + "wordBasedSuggestions": False, + "minimap": {"enabled": False}, + }, + ), + style="width: 100%; height: 100%;", + ) + +if __name__ == "__main__": + server.start() diff --git a/example/live-state/requirements.txt b/example/live-state/requirements.txt new file mode 100644 index 0000000..1eb2265 --- /dev/null +++ b/example/live-state/requirements.txt @@ -0,0 +1,3 @@ +trame +trame-vuetify +trame-code diff --git a/tests/requirements.txt b/tests/requirements.txt index e079f8a..16e2e88 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -1 +1,2 @@ pytest +trame diff --git a/tests/test_language_features.py b/tests/test_language_features.py new file mode 100644 index 0000000..3b8448e --- /dev/null +++ b/tests/test_language_features.py @@ -0,0 +1,20 @@ +def test_completion_and_hover_props_serialize(): + """The completion/hover props are accepted and render onto the element.""" + from trame.app import get_server + from trame.ui.html import DivLayout + from trame.widgets import code + + server = get_server("test_language_features") + with DivLayout(server): + editor = code.Editor( + language="python", + completion="my_complete", + hover="my_hover", + ) + + assert "completion" in editor._attr_names + assert "hover" in editor._attr_names + + html = editor.html + assert 'completion="my_complete"' in html + assert 'hover="my_hover"' in html diff --git a/trame_code/widgets/code.py b/trame_code/widgets/code.py index 2e71044..2387698 100644 --- a/trame_code/widgets/code.py +++ b/trame_code/widgets/code.py @@ -29,6 +29,12 @@ class Editor(HtmlElement): :param theme: :param language: :param textmate: + :param completion: name of a server ``@trigger`` that returns completion + items (list of {label, kind, detail, documentation, insertText}) for + ``(code, line, column)``. line is 1-based, column 0-based. + :param hover: name of a server ``@trigger`` that returns hover content (a + markdown string, list of strings, or {contents: [...]}) for + ``(code, line, column)``. Events: @@ -48,6 +54,9 @@ def __init__(self, **kwargs): "theme", "language", "textmate", + "completion", + "hover", + ("completion_trigger_characters", "completionTriggerCharacters"), ] self._event_names += [ "input", diff --git a/vue-components/src/components/Editor.js b/vue-components/src/components/Editor.js index 1cb8f67..d5c5afe 100644 --- a/vue-components/src/components/Editor.js +++ b/vue-components/src/components/Editor.js @@ -6,6 +6,27 @@ import { import * as monaco from "monaco-editor"; +// Map a normalized completion-kind string to a Monaco CompletionItemKind. +function completionItemKind(kind) { + const K = monaco.languages.CompletionItemKind; + const map = { + function: K.Function, + method: K.Method, + class: K.Class, + instance: K.Variable, + variable: K.Variable, + module: K.Module, + keyword: K.Keyword, + statement: K.Snippet, + param: K.Variable, + property: K.Property, + field: K.Field, + constant: K.Constant, + path: K.File, + }; + return map[kind] || K.Text; +} + export default { name: "VSEditor", props: { @@ -34,6 +55,16 @@ export default { textmate: { type: Object, }, + completion: { + type: String, + }, + hover: { + type: String, + }, + completionTriggerCharacters: { + type: Array, + default: () => ["."], + }, }, watch: { modelValue(v) { @@ -54,6 +85,7 @@ export default { language(lang) { if (this.editor) { monaco.editor.setModelLanguage(this.editor.getModel(), lang); + this.registerLanguageProviders(); } }, theme(theme) { @@ -96,6 +128,124 @@ export default { return this.provider; }, + disposeLanguageProviders() { + if (this._completionProvider) { + this._completionProvider.dispose(); + this._completionProvider = null; + } + if (this._hoverProvider) { + this._hoverProvider.dispose(); + this._hoverProvider = null; + } + }, + registerLanguageProviders() { + // Bridge Monaco language features to a Python callback exposed as a trame + // trigger. The consumer sets the `completion` / `hover` props to trigger + // names and registers the matching server triggers; no client JS needed. + // Re-registering is safe: any previous registration is disposed first. + this.disposeLanguageProviders(); + if (!this.completion && !this.hover) { + return; + } + // Monaco only runs language features for a registered language. When no + // textmate grammar registered it (e.g. a plain language= editor), register + // the id here so completion/hover providers are actually consulted. + const known = monaco.languages + .getLanguages() + .some((l) => l.id === this.language); + if (this.language && !known) { + monaco.languages.register({ id: this.language }); + } + const self = this; + if (this.completion) { + this._completionProvider = + monaco.languages.registerCompletionItemProvider(this.language, { + triggerCharacters: this.completionTriggerCharacters, + async provideCompletionItems(model, position, context, token) { + if (!window.trame || !window.trame.trigger) { + return { suggestions: [] }; + } + let items = []; + try { + items = await window.trame.trigger(self.completion, [ + model.getValue(), + position.lineNumber, + position.column - 1, + ]); + } catch (e) { + items = []; + } + if (token && token.isCancellationRequested) { + return { suggestions: [] }; + } + if (!items) items = []; + const word = model.getWordUntilPosition(position); + const range = { + startLineNumber: position.lineNumber, + endLineNumber: position.lineNumber, + startColumn: word.startColumn, + endColumn: word.endColumn, + }; + return { + suggestions: items.map((it) => ({ + label: it.label, + kind: completionItemKind(it.kind), + detail: it.detail || "", + documentation: it.documentation || undefined, + insertText: it.insertText || it.label, + range, + })), + }; + }, + }); + } + if (this.hover) { + this._hoverProvider = monaco.languages.registerHoverProvider( + this.language, + { + async provideHover(model, position, token) { + if (!window.trame || !window.trame.trigger) { + return null; + } + let res = null; + try { + res = await window.trame.trigger(self.hover, [ + model.getValue(), + position.lineNumber, + position.column - 1, + ]); + } catch (e) { + res = null; + } + if (token && token.isCancellationRequested) { + return null; + } + if (!res) return null; + // Accept a markdown string, an array of strings, or { contents: [...] }. + let contents = []; + if (typeof res === "string") { + contents = [{ value: res }]; + } else if (Array.isArray(res)) { + contents = res.map((v) => ({ value: v })); + } else if (res.contents) { + contents = res.contents.map((v) => ({ value: v })); + } + if (!contents.length) return null; + const word = model.getWordAtPosition(position); + const range = word + ? { + startLineNumber: position.lineNumber, + endLineNumber: position.lineNumber, + startColumn: word.startColumn, + endColumn: word.endColumn, + } + : undefined; + return { range, contents }; + }, + } + ); + } + }, }, mounted() { let provider = null; @@ -130,8 +280,11 @@ export default { this.$emit("update:modelValue", newValue); this.$emit("input", newValue); }); + + this.registerLanguageProviders(); }, beforeUnmount() { + this.disposeLanguageProviders(); this.editor.dispose(); }, template: `
`, From d47eeb697b0b5af07b25a44b1ffc4f729623589b Mon Sep 17 00:00:00 2001 From: Jeff Lee Date: Mon, 15 Jun 2026 18:32:31 -0400 Subject: [PATCH 2/3] refactor: completion/hover take a callable, register trigger internally Address review: pass a function/method to completion/hover instead of a trigger-name string. The widget registers it via ctrl.trigger_name and hands the client the generated name, keeping the trigger internal while still returning results to Monaco's providers. Convert both examples to the callable form and update the test to the callable contract. --- example/language-features/app.py | 128 ++++++++++++------------ example/live-state/app.py | 3 +- tests/test_language_features.py | 27 +++-- trame_code/widgets/code.py | 33 ++++-- vue-components/src/components/Editor.js | 10 +- 5 files changed, 113 insertions(+), 88 deletions(-) diff --git a/example/language-features/app.py b/example/language-features/app.py index 98ed52d..41eae88 100644 --- a/example/language-features/app.py +++ b/example/language-features/app.py @@ -1,15 +1,15 @@ """Editor language features: completion + hover backed by a Python callback. -The ``completion`` and ``hover`` props on ``code.Editor`` each name a trame -``@trigger`` that receives ``(code, line, column)`` and returns results. Here -both are backed by jedi, giving live Python completion and docstring-on-hover -entirely in-process, with no client-side JavaScript. +The ``completion`` and ``hover`` props on ``code.Editor`` each take a callable +that receives ``(code, line, column)`` and returns results. Here both are backed +by jedi, giving live Python completion and docstring-on-hover entirely +in-process, with no client-side JavaScript. The contract: -* completion trigger returns a list of items, each a dict with keys - ``label`` (required), ``kind``, ``detail``, ``documentation``, ``insertText``. -* hover trigger returns a markdown string, a list of markdown strings, or +* completion returns a list of items, each a dict with keys ``label`` (required), + ``kind``, ``detail``, ``documentation``, ``insertText``. +* hover returns a markdown string, a list of markdown strings, or ``{"contents": [...]}`` (or ``None`` for no hover). * positions are passed as ``line`` (1-based) and ``column`` (0-based), matching jedi's API directly. @@ -21,13 +21,10 @@ """ import jedi -from trame.app import get_server +from trame.app import TrameApp from trame.ui.vuetify3 import SinglePageLayout from trame.widgets import code -from trame.widgets import vuetify3 as vuetify - -server = get_server() -state, ctrl = server.state, server.controller +from trame.widgets import vuetify3 as v3 INITIAL_CODE = '''import math @@ -42,57 +39,60 @@ def circle_area(radius): ''' -@server.trigger("py_complete") -def py_complete(code_text, line, column): - """Completion items for (code, line, column). line 1-based, column 0-based.""" - try: - completions = jedi.Script(code=code_text).complete(line, column) - except Exception: - return [] - return [ - { - "label": c.name, - "kind": c.type, - "detail": (c.description or "")[:80], - } - for c in completions[:200] - ] - - -@server.trigger("py_hover") -def py_hover(code_text, line, column): - """Hover markdown (signature + docstring) for the symbol at the cursor.""" - try: - definitions = jedi.Script(code=code_text).help(line, column) - except Exception: - return None - if not definitions: - return None - definition = definitions[0] - contents = [] - signatures = [s.to_string() for s in definition.get_signatures()] - if signatures: - contents.append("```python\n" + "\n".join(signatures) + "\n```") - doc = definition.docstring(raw=True) or "" - if doc: - contents.append(doc) - return {"contents": contents} if contents else None - - -state.trame__title = "Editor language features" - -with SinglePageLayout(server) as layout: - layout.title.set_text("Editor language features (jedi)") - with layout.content: - with vuetify.VContainer(fluid=True, classes="fill-height pa-0"): - code.Editor( - value=INITIAL_CODE, - language="python", - theme="vs", - completion="py_complete", - hover="py_hover", - style="width: 100%; height: 100%;", - ) +class PyCodeEditor(TrameApp): + def __init__(self, server=None): + super().__init__(server) + self._build_ui() + + def _build_ui(self): + self.state.trame__title = "PyEditor" + with SinglePageLayout(self.server) as self.ui: + self.ui.title.set_text("Editor language features (jedi)") + with self.ui.content: + with v3.VContainer(fluid=True, classes="fill-height pa-0"): + code.Editor( + v_model=("editor_code", INITIAL_CODE), + language="python", + theme="vs", + style="width: 100%; height: 100%;", + completion=self.on_completion, + hover=self.on_hover, + ) + + def on_completion(self, code_text, line, column): + """Completion items for (code, line, column): line 1-based, column 0-based.""" + try: + completions = jedi.Script(code=code_text).complete(line, column) + except Exception: + return [] + return [ + { + "label": c.name, + "kind": c.type, + "detail": (c.description or "")[:80], + } + for c in completions[:200] + ] + + def on_hover(self, code_text, line, column): + """Hover markdown (signature + docstring) for the symbol at the cursor.""" + try: + definitions = jedi.Script(code=code_text).help(line, column) + except Exception: + return None + if not definitions: + return None + definition = definitions[0] + contents = [] + signatures = [s.to_string() for s in definition.get_signatures()] + if signatures: + contents.append("```python\n" + "\n".join(signatures) + "\n```") + doc = definition.docstring(raw=True) or "" + if doc: + contents.append(doc) + return {"contents": contents} if contents else None + if __name__ == "__main__": - server.start() + app = PyCodeEditor() + app.server.start() diff --git a/example/live-state/app.py b/example/live-state/app.py index d47767f..6ee3891 100644 --- a/example/live-state/app.py +++ b/example/live-state/app.py @@ -55,7 +55,6 @@ def _describe(value): return type_name -@server.trigger("live_complete") def live_complete(code_text, line, column): """Complete DATASET keys when the cursor is inside a string subscript.""" lines = code_text.split("\n") @@ -98,7 +97,7 @@ def live_complete(code_text, line, column): v_model=("live_code", INITIAL_CODE), language="python", theme="vs", - completion="live_complete", + completion=live_complete, # open the list as soon as the key string is entered completion_trigger_characters=("completion_triggers", ['"', "'", "["]), # dict-key completion happens inside a string literal, where diff --git a/tests/test_language_features.py b/tests/test_language_features.py index 3b8448e..cc397e7 100644 --- a/tests/test_language_features.py +++ b/tests/test_language_features.py @@ -1,20 +1,29 @@ -def test_completion_and_hover_props_serialize(): - """The completion/hover props are accepted and render onto the element.""" +def test_completion_and_hover_register_as_triggers(): + """A callable passed to completion/hover is registered as a trigger and + surfaces on the element as a trigger-name attribute.""" from trame.app import get_server from trame.ui.html import DivLayout from trame.widgets import code + def on_complete(code_text, line, column): + return [] + + def on_hover(code_text, line, column): + return None + server = get_server("test_language_features") with DivLayout(server): editor = code.Editor( language="python", - completion="my_complete", - hover="my_hover", + completion=on_complete, + hover=on_hover, ) - assert "completion" in editor._attr_names - assert "hover" in editor._attr_names - html = editor.html - assert 'completion="my_complete"' in html - assert 'hover="my_hover"' in html + assert 'completion="' in html + assert 'hover="' in html + + # the internal triggers resolve back to the original callables + ctrl = server.controller + assert ctrl.trigger_fn(server.trigger_name(on_complete)) is on_complete + assert ctrl.trigger_fn(server.trigger_name(on_hover)) is on_hover diff --git a/trame_code/widgets/code.py b/trame_code/widgets/code.py index 2387698..fb56580 100644 --- a/trame_code/widgets/code.py +++ b/trame_code/widgets/code.py @@ -29,12 +29,16 @@ class Editor(HtmlElement): :param theme: :param language: :param textmate: - :param completion: name of a server ``@trigger`` that returns completion - items (list of {label, kind, detail, documentation, insertText}) for - ``(code, line, column)``. line is 1-based, column 0-based. - :param hover: name of a server ``@trigger`` that returns hover content (a - markdown string, list of strings, or {contents: [...]}) for - ``(code, line, column)``. + :param completion: a callable ``fn(code, line, column)`` returning a list of + completion items, each a dict with keys ``label`` (required), ``kind``, + ``detail``, ``documentation``, ``insertText``. ``line`` is 1-based, + ``column`` 0-based. Registered as a trigger internally so the client can + invoke it and receive the returned items (Monaco needs the result back). + :param hover: a callable ``fn(code, line, column)`` returning hover content: + a markdown string, a list of markdown strings, ``{contents: [...]}``, or + ``None``. Registered as a trigger internally like ``completion``. + :param completion_trigger_characters: list of characters that open the + completion list (defaults to ``["."]`` in the component). Events: @@ -42,7 +46,7 @@ class Editor(HtmlElement): """ - def __init__(self, **kwargs): + def __init__(self, completion=None, hover=None, **kwargs): super().__init__( "vs-editor", **kwargs, @@ -54,10 +58,21 @@ def __init__(self, **kwargs): "theme", "language", "textmate", - "completion", - "hover", ("completion_trigger_characters", "completionTriggerCharacters"), ] self._event_names += [ "input", ] + + # `completion` / `hover` take a callable receiving (code, line, column). + # Monaco pulls results from the provider and needs them returned, so the + # callback is registered as a trigger internally and the client invokes + # it by name and awaits the returned value. + if completion is not None: + self._attributes[ + "completion_trigger" + ] = f'completion="{self.ctrl.trigger_name(completion)}"' + if hover is not None: + self._attributes[ + "hover_trigger" + ] = f'hover="{self.ctrl.trigger_name(hover)}"' diff --git a/vue-components/src/components/Editor.js b/vue-components/src/components/Editor.js index d5c5afe..268cb14 100644 --- a/vue-components/src/components/Editor.js +++ b/vue-components/src/components/Editor.js @@ -139,10 +139,12 @@ export default { } }, registerLanguageProviders() { - // Bridge Monaco language features to a Python callback exposed as a trame - // trigger. The consumer sets the `completion` / `hover` props to trigger - // names and registers the matching server triggers; no client JS needed. - // Re-registering is safe: any previous registration is disposed first. + // Bridge Monaco language features to a Python callback. The consumer + // passes a callable to the `completion` / `hover` props; the widget + // registers it as a trigger internally and hands this component the + // generated trigger name, which we invoke and await for the result + // (Monaco needs the items returned). No client JS needed on the consumer + // side. Re-registering is safe: any previous registration is disposed. this.disposeLanguageProviders(); if (!this.completion && !this.hover) { return; From 325c78583e817f0b31a2b120879c6c6a53df1998 Mon Sep 17 00:00:00 2001 From: Jeff Lee Date: Mon, 15 Jun 2026 20:45:25 -0400 Subject: [PATCH 3/3] refactor(example): use TrameApp in live-state example Match the language-features example: wrap the live-state demo in a TrameApp subclass with the completion callback as a method, instead of a bare get_server plus module-level layout. --- example/live-state/app.py | 138 ++++++++++++++++++++------------------ 1 file changed, 72 insertions(+), 66 deletions(-) diff --git a/example/live-state/app.py b/example/live-state/app.py index 6ee3891..0e119a5 100644 --- a/example/live-state/app.py +++ b/example/live-state/app.py @@ -12,8 +12,8 @@ The contract (shared with the language-features example): -* completion trigger returns a list of items, each a dict with key ``label`` - (required) and optional ``kind``, ``detail``, ``documentation``, ``insertText``. +* completion returns a list of items, each a dict with key ``label`` (required) + and optional ``kind``, ``detail``, ``documentation``, ``insertText``. * positions are passed as ``line`` (1-based) and ``column`` (0-based). Run with:: @@ -24,14 +24,11 @@ import re -from trame.app import get_server +from trame.app import TrameApp from trame.ui.vuetify3 import SinglePageLayout from trame.widgets import code from trame.widgets import vuetify3 as vuetify -server = get_server() -state, ctrl = server.state, server.controller - # Stand-in for state that exists only at runtime (a loaded dataset, a live # object graph, a fetched schema...). None of these names appear in the source. DATASET = { @@ -45,35 +42,6 @@ # Match an unclosed string subscript at the cursor: dataset["pre _SUBSCRIPT_RE = re.compile(r"""\[\s*["']([^"']*)$""") - -def _describe(value): - """A short, live description of a value: type plus length when available.""" - type_name = type(value).__name__ - try: - return f"{type_name}, len {len(value)}" - except TypeError: - return type_name - - -def live_complete(code_text, line, column): - """Complete DATASET keys when the cursor is inside a string subscript.""" - lines = code_text.split("\n") - if line < 1 or line > len(lines): - return [] - prefix = lines[line - 1][:column] - - match = _SUBSCRIPT_RE.search(prefix) - if not match: - return [] # not inside a key subscript -> no suggestions (defer to others) - - stub = match.group(1) - return [ - {"label": key, "kind": "field", "detail": _describe(value)} - for key, value in DATASET.items() - if key.startswith(stub) - ] - - INITIAL_CODE = """# Live-state completion demo # # 1. Put the cursor between the empty quotes on the last line: dataset[""] @@ -87,36 +55,74 @@ def live_complete(code_text, line, column): field = dataset[""] """ -state.trame__title = "Live-state completion" - -with SinglePageLayout(server) as layout: - layout.title.set_text("Live-state completion") - with layout.content: - with vuetify.VContainer(fluid=True, classes="fill-height pa-0"): - code.Editor( - v_model=("live_code", INITIAL_CODE), - language="python", - theme="vs", - completion=live_complete, - # open the list as soon as the key string is entered - completion_trigger_characters=("completion_triggers", ['"', "'", "["]), - # dict-key completion happens inside a string literal, where - # Monaco suppresses suggestions by default; enable them there. - options=( - "live_editor_options", - { - "quickSuggestions": { - "other": True, - "comments": False, - "strings": True, - }, - # only our provider's keys, no document-word noise - "wordBasedSuggestions": False, - "minimap": {"enabled": False}, - }, - ), - style="width: 100%; height: 100%;", - ) + +def _describe(value): + """A short, live description of a value: type plus length when available.""" + type_name = type(value).__name__ + try: + return f"{type_name}, len {len(value)}" + except TypeError: + return type_name + + +class LiveStateEditor(TrameApp): + def __init__(self, server=None): + super().__init__(server) + self._build_ui() + + def _build_ui(self): + self.state.trame__title = "Live-state completion" + with SinglePageLayout(self.server) as self.ui: + self.ui.title.set_text("Live-state completion") + with self.ui.content: + with vuetify.VContainer(fluid=True, classes="fill-height pa-0"): + code.Editor( + v_model=("live_code", INITIAL_CODE), + language="python", + theme="vs", + completion=self.live_complete, + # open the list as soon as the key string is entered + completion_trigger_characters=( + "completion_triggers", + ['"', "'", "["], + ), + # dict-key completion happens inside a string literal, + # where Monaco suppresses suggestions by default; enable + # them there, and drop document-word noise. + options=( + "live_editor_options", + { + "quickSuggestions": { + "other": True, + "comments": False, + "strings": True, + }, + "wordBasedSuggestions": False, + "minimap": {"enabled": False}, + }, + ), + style="width: 100%; height: 100%;", + ) + + def live_complete(self, code_text, line, column): + """Complete DATASET keys when the cursor is inside a string subscript.""" + lines = code_text.split("\n") + if line < 1 or line > len(lines): + return [] + prefix = lines[line - 1][:column] + + match = _SUBSCRIPT_RE.search(prefix) + if not match: + return [] # not inside a key subscript -> defer to others + + stub = match.group(1) + return [ + {"label": key, "kind": "field", "detail": _describe(value)} + for key, value in DATASET.items() + if key.startswith(stub) + ] + if __name__ == "__main__": - server.start() + app = LiveStateEditor() + app.server.start()