diff --git a/example/language-features/app.py b/example/language-features/app.py new file mode 100644 index 0000000..41eae88 --- /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 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 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. + +Run with:: + + pip install trame trame-vuetify trame-code jedi + python app.py +""" + +import jedi +from trame.app import TrameApp +from trame.ui.vuetify3 import SinglePageLayout +from trame.widgets import code +from trame.widgets import vuetify3 as v3 + +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. +''' + + +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__": + app = PyCodeEditor() + app.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..0e119a5 --- /dev/null +++ b/example/live-state/app.py @@ -0,0 +1,128 @@ +"""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 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 TrameApp +from trame.ui.vuetify3 import SinglePageLayout +from trame.widgets import code +from trame.widgets import vuetify3 as vuetify + +# 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*["']([^"']*)$""") + +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[""] +""" + + +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__": + app = LiveStateEditor() + app.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..cc397e7 --- /dev/null +++ b/tests/test_language_features.py @@ -0,0 +1,29 @@ +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=on_complete, + hover=on_hover, + ) + + html = editor.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 2e71044..fb56580 100644 --- a/trame_code/widgets/code.py +++ b/trame_code/widgets/code.py @@ -29,6 +29,16 @@ class Editor(HtmlElement): :param theme: :param language: :param textmate: + :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: @@ -36,7 +46,7 @@ class Editor(HtmlElement): """ - def __init__(self, **kwargs): + def __init__(self, completion=None, hover=None, **kwargs): super().__init__( "vs-editor", **kwargs, @@ -48,7 +58,21 @@ def __init__(self, **kwargs): "theme", "language", "textmate", + ("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 1cb8f67..268cb14 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,126 @@ 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. 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; + } + // 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 +282,11 @@ export default { this.$emit("update:modelValue", newValue); this.$emit("input", newValue); }); + + this.registerLanguageProviders(); }, beforeUnmount() { + this.disposeLanguageProviders(); this.editor.dispose(); }, template: `
`,