From a6ece94cedd4447f60a6229120578acfb7ee79e3 Mon Sep 17 00:00:00 2001 From: Jeff Lee Date: Fri, 12 Jun 2026 12:06:18 -0400 Subject: [PATCH] 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: `
`,