From ee77619da302f6a4126793183ab4a6728e4ec8e6 Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Tue, 10 Mar 2026 16:20:40 +0000 Subject: [PATCH] Fix code editor autocomplete using wa popup (#30081) --- src/components/ha-code-editor.ts | 203 ++++++++++++++++++++++++++++++- src/resources/codemirror.ts | 24 ++-- 2 files changed, 214 insertions(+), 13 deletions(-) diff --git a/src/components/ha-code-editor.ts b/src/components/ha-code-editor.ts index 988f32856b..f30960b4ce 100644 --- a/src/components/ha-code-editor.ts +++ b/src/components/ha-code-editor.ts @@ -1,3 +1,5 @@ +import "@home-assistant/webawesome/dist/components/popup/popup"; +import type WaPopup from "@home-assistant/webawesome/dist/components/popup/popup"; import type { Completion, CompletionContext, @@ -110,6 +112,18 @@ export class HaCodeEditor extends ReactiveElement { // eslint-disable-next-line @typescript-eslint/consistent-type-imports private _loadedCodeMirror?: typeof import("../resources/codemirror"); + private _completionInfoPopover?: WaPopup; + + private _completionInfoContainer?: HTMLDivElement; + + private _completionInfoDestroy?: () => void; + + private _completionInfoRequest = 0; + + private _completionInfoKey?: string; + + private _completionInfoFrame?: number; + private _editorToolbar?: HaIconButtonToolbar; private _iconList?: Completion[]; @@ -155,6 +169,14 @@ export class HaCodeEditor extends ReactiveElement { public disconnectedCallback() { fireEvent(this, "dialog-set-fullscreen", false); + this._clearCompletionInfo(); + if (this._completionInfoFrame !== undefined) { + cancelAnimationFrame(this._completionInfoFrame); + this._completionInfoFrame = undefined; + } + this._completionInfoPopover?.remove(); + this._completionInfoPopover = undefined; + this._completionInfoContainer = undefined; super.disconnectedCallback(); this.removeEventListener("keydown", stopPropagation); this.removeEventListener("keydown", this._handleKeyDown); @@ -288,6 +310,9 @@ export class HaCodeEditor extends ReactiveElement { this._loadedCodeMirror.foldingCompartment.of( this._getFoldingExtensions() ), + this._loadedCodeMirror.tooltips({ + position: "absolute", + }), ...(this.placeholder ? [placeholder(this.placeholder)] : []), ]; @@ -595,6 +620,157 @@ export class HaCodeEditor extends ReactiveElement { return completionInfo; }; + private _getCompletionInfo = ( + completion: Completion + ): CompletionInfo | Promise | null => { + if (this.hass && completion.label in this.hass.states) { + return this._renderInfo(completion); + } + + if (completion.label.startsWith("mdi:")) { + return renderIcon(completion); + } + + return null; + }; + + private _ensureCompletionInfoPopover(): WaPopup { + if (!this._completionInfoPopover) { + this._completionInfoPopover = document.createElement( + "wa-popup" + ) as WaPopup; + this._completionInfoPopover.classList.add("completion-info-popover"); + this._completionInfoPopover.placement = "right-start"; + this._completionInfoPopover.distance = 4; + this._completionInfoPopover.flip = true; + this._completionInfoPopover.flipFallbackPlacements = + "left-start bottom-start top-start"; + this._completionInfoPopover.shift = true; + this._completionInfoPopover.shiftPadding = 8; + this._completionInfoPopover.autoSize = "both"; + this._completionInfoPopover.autoSizePadding = 8; + + this._completionInfoContainer = document.createElement("div"); + this._completionInfoPopover.appendChild(this._completionInfoContainer); + this.renderRoot.appendChild(this._completionInfoPopover); + } + + return this._completionInfoPopover; + } + + private _clearCompletionInfo() { + this._completionInfoRequest += 1; + this._completionInfoKey = undefined; + this._completionInfoDestroy?.(); + this._completionInfoDestroy = undefined; + this._completionInfoContainer?.replaceChildren(); + + if (this._completionInfoPopover?.active) { + this._completionInfoPopover.active = false; + } + } + + private _renderCompletionInfoContent(info: CompletionInfo) { + this._completionInfoDestroy?.(); + this._completionInfoDestroy = undefined; + + if (!this._completionInfoContainer) { + return; + } + + if (info === null) { + this._completionInfoContainer.replaceChildren(); + return; + } + + if ("nodeType" in info) { + this._completionInfoContainer.replaceChildren(info); + return; + } + + this._completionInfoContainer.replaceChildren(info.dom); + this._completionInfoDestroy = info.destroy; + } + + private _syncCompletionInfoPopover = () => { + if (this._completionInfoFrame !== undefined) { + cancelAnimationFrame(this._completionInfoFrame); + } + + this._completionInfoFrame = requestAnimationFrame(() => { + this._completionInfoFrame = undefined; + this._syncCompletionInfoPopoverNow(); + }); + }; + + private _syncCompletionInfoPopoverNow = () => { + if (!this.codemirror || !this._loadedCodeMirror) { + return; + } + + if (window.matchMedia("(max-width: 600px)").matches) { + this._clearCompletionInfo(); + return; + } + + const completion = this._loadedCodeMirror.selectedCompletion( + this.codemirror.state + ); + const selectedOption = this.codemirror.dom.querySelector( + ".cm-tooltip-autocomplete li[aria-selected]" + ) as HTMLElement | null; + + if (!completion || !selectedOption) { + this._clearCompletionInfo(); + return; + } + + const infoResult = this._getCompletionInfo(completion); + + if (!infoResult) { + this._clearCompletionInfo(); + return; + } + + const requestId = ++this._completionInfoRequest; + const infoKey = completion.label; + const popover = this._ensureCompletionInfoPopover(); + popover.anchor = selectedOption; + + const showPopover = async (info: CompletionInfo) => { + if (requestId !== this._completionInfoRequest) { + if (info && typeof info === "object" && "destroy" in info) { + info.destroy?.(); + } + return; + } + + if (infoKey !== this._completionInfoKey) { + this._renderCompletionInfoContent(info); + this._completionInfoKey = infoKey; + } + + await popover.updateComplete; + popover.active = true; + popover.reposition(); + }; + + if ("then" in infoResult) { + infoResult.then(showPopover).catch(() => { + if (requestId === this._completionInfoRequest) { + this._clearCompletionInfo(); + } + }); + return; + } + + showPopover(infoResult).catch(() => { + if (requestId === this._completionInfoRequest) { + this._clearCompletionInfo(); + } + }); + }; + private _getStates = memoizeOne((states: HassEntities): Completion[] => { if (!states) { return []; @@ -604,7 +780,6 @@ export class HaCodeEditor extends ReactiveElement { type: "variable", label: key, detail: states[key].attributes.friendly_name, - info: this._renderInfo, })); return options; @@ -778,7 +953,6 @@ export class HaCodeEditor extends ReactiveElement { type: "variable", label: `mdi:${icon.name}`, detail: icon.keywords.join(", "), - info: renderIcon, })); } @@ -806,6 +980,7 @@ export class HaCodeEditor extends ReactiveElement { private _onUpdate = (update: ViewUpdate): void => { this._canUndo = !this.readOnly && undoDepth(update.state) > 0; this._canRedo = !this.readOnly && redoDepth(update.state) > 0; + this._syncCompletionInfoPopover(); if (!update.docChanged) { return; } @@ -925,9 +1100,31 @@ export class HaCodeEditor extends ReactiveElement { padding: 8px; } + wa-popup.completion-info-popover { + --auto-size-available-width: min( + 420px, + calc(var(--safe-width) - var(--ha-space-8)) + ); + } + + wa-popup.completion-info-popover::part(popup) { + padding: 0; + color: var(--primary-text-color); + background-color: var( + --code-editor-background-color, + var(--card-background-color) + ); + border: 1px solid var(--divider-color); + border-radius: var(--mdc-shape-medium, 4px); + box-shadow: + 0px 5px 5px -3px rgb(0 0 0 / 20%), + 0px 8px 10px 1px rgb(0 0 0 / 14%), + 0px 3px 14px 2px rgb(0 0 0 / 12%); + } + /* Hide completion info on narrow screens */ @media (max-width: 600px) { - .cm-completionInfo, + wa-popup.completion-info-popover, .completion-info { display: none; } diff --git a/src/resources/codemirror.ts b/src/resources/codemirror.ts index 3e199d79cf..1d8893ae1f 100644 --- a/src/resources/codemirror.ts +++ b/src/resources/codemirror.ts @@ -12,7 +12,7 @@ import type { KeyBinding } from "@codemirror/view"; import { EditorView } from "@codemirror/view"; import { tags } from "@lezer/highlight"; -export { autocompletion } from "@codemirror/autocomplete"; +export { autocompletion, selectedCompletion } from "@codemirror/autocomplete"; export { defaultKeymap, history, historyKeymap } from "@codemirror/commands"; export { highlightingFor, foldGutter } from "@codemirror/language"; export { @@ -32,6 +32,7 @@ export { lineNumbers, rectangularSelection, dropCursor, + tooltips, } from "@codemirror/view"; export { indentationMarkers } from "@replit/codemirror-indentation-markers"; export { tags } from "@lezer/highlight"; @@ -151,10 +152,22 @@ export const haTheme = EditorView.theme({ "var(--code-editor-background-color, var(--card-background-color))", border: "1px solid var(--divider-color)", borderRadius: "var(--mdc-shape-medium, 4px)", + maxWidth: "min(420px, calc(var(--safe-width) - var(--ha-space-8)))", + boxSizing: "border-box", boxShadow: "0px 5px 5px -3px rgb(0 0 0 / 20%), 0px 8px 10px 1px rgb(0 0 0 / 14%), 0px 3px 14px 2px rgb(0 0 0 / 12%)", }, + ".cm-tooltip.cm-tooltip-autocomplete": { + maxWidth: + "min(420px, calc(var(--safe-width) - var(--ha-space-8)), calc(100% - var(--ha-space-2)))", + }, + + ".cm-tooltip-autocomplete > ul": { + maxWidth: "100%", + boxSizing: "border-box", + }, + "& .cm-tooltip.cm-tooltip-autocomplete > ul > li": { padding: "4px 8px", }, @@ -177,15 +190,6 @@ export const haTheme = EditorView.theme({ color: "var(--text-primary-color)", }, - "& .cm-completionInfo.cm-completionInfo-right": { - left: "calc(100% + 4px)", - }, - - "& .cm-tooltip.cm-completionInfo": { - padding: "4px 8px", - marginTop: "-5px", - }, - ".cm-selectionMatch": { backgroundColor: "rgba(var(--rgb-primary-color), 0.1)", },