diff --git a/tsunami/app/defaultclient.go b/tsunami/app/defaultclient.go index e033bfd2a6..1359f0f58b 100644 --- a/tsunami/app/defaultclient.go +++ b/tsunami/app/defaultclient.go @@ -64,6 +64,13 @@ func SendAsyncInitiation() error { return engine.GetDefaultClient().SendAsyncInitiation() } +func TermWrite(ref *vdom.VDomRef, data string) error { + if ref == nil || !ref.HasCurrent.Load() { + return nil + } + return engine.GetDefaultClient().SendTermWrite(ref.RefId, data) +} + func ConfigAtom[T any](name string, defaultValue T, meta *AtomMeta) Atom[T] { fullName := "$config." + name client := engine.GetDefaultClient() @@ -155,7 +162,7 @@ func DeepCopy[T any](v T) T { // If the ref is nil or not current, the operation is ignored. // This function must be called within a component context. func QueueRefOp(ref *vdom.VDomRef, op vdom.VDomRefOperation) { - if ref == nil || !ref.HasCurrent { + if ref == nil || !ref.HasCurrent.Load() { return } if op.RefId == "" { diff --git a/tsunami/app/hooks.go b/tsunami/app/hooks.go index 6b6ebbd36c..54418a00e0 100644 --- a/tsunami/app/hooks.go +++ b/tsunami/app/hooks.go @@ -31,6 +31,38 @@ func UseVDomRef() *vdom.VDomRef { return refVal } +// TermRef wraps a VDomRef and implements io.Writer by forwarding writes to the terminal. +type TermRef struct { + *vdom.VDomRef +} + +// Write implements io.Writer by sending data to the terminal via TermWrite. +func (tr *TermRef) Write(p []byte) (n int, err error) { + if tr.VDomRef == nil || !tr.VDomRef.HasCurrent.Load() { + return 0, fmt.Errorf("TermRef not current") + } + err = TermWrite(tr.VDomRef, string(p)) + if err != nil { + return 0, err + } + return len(p), nil +} + +// TermSize returns the current terminal size, or nil if not yet set. +func (tr *TermRef) TermSize() *vdom.VDomTermSize { + if tr.VDomRef == nil { + return nil + } + return tr.VDomRef.TermSize +} + +// UseTermRef returns a TermRef that can be passed as a ref to "wave:term" elements +// and also implements io.Writer for writing directly to the terminal. +func UseTermRef() *TermRef { + ref := UseVDomRef() + return &TermRef{VDomRef: ref} +} + // UseRef is the tsunami analog to React's useRef hook. // It provides a mutable ref object that persists across re-renders. // Unlike UseVDomRef, this is not tied to DOM elements but holds arbitrary values. diff --git a/tsunami/cmd/main-tsunami.go b/tsunami/cmd/main-tsunami.go index 6399aa6d52..f8b85f3e46 100644 --- a/tsunami/cmd/main-tsunami.go +++ b/tsunami/cmd/main-tsunami.go @@ -41,14 +41,22 @@ func validateEnvironmentVars(opts *build.BuildOpts) error { if scaffoldPath == "" { return fmt.Errorf("%s environment variable must be set", EnvTsunamiScaffoldPath) } + absScaffoldPath, err := filepath.Abs(scaffoldPath) + if err != nil { + return fmt.Errorf("failed to resolve %s to absolute path: %w", EnvTsunamiScaffoldPath, err) + } sdkReplacePath := os.Getenv(EnvTsunamiSdkReplacePath) if sdkReplacePath == "" { return fmt.Errorf("%s environment variable must be set", EnvTsunamiSdkReplacePath) } + absSdkReplacePath, err := filepath.Abs(sdkReplacePath) + if err != nil { + return fmt.Errorf("failed to resolve %s to absolute path: %w", EnvTsunamiSdkReplacePath, err) + } - opts.ScaffoldPath = scaffoldPath - opts.SdkReplacePath = sdkReplacePath + opts.ScaffoldPath = absScaffoldPath + opts.SdkReplacePath = absSdkReplacePath // NodePath is optional if nodePath := os.Getenv(EnvTsunamiNodePath); nodePath != "" { diff --git a/tsunami/engine/clientimpl.go b/tsunami/engine/clientimpl.go index 79c760e98b..ac9cb29109 100644 --- a/tsunami/engine/clientimpl.go +++ b/tsunami/engine/clientimpl.go @@ -5,6 +5,7 @@ package engine import ( "context" + "encoding/base64" "encoding/json" "fmt" "io/fs" @@ -304,6 +305,18 @@ func (c *ClientImpl) SendAsyncInitiation() error { return c.SendSSEvent(ssEvent{Event: "asyncinitiation", Data: nil}) } +func (c *ClientImpl) SendTermWrite(refId string, data string) error { + payload := rpctypes.TermWritePacket{ + RefId: refId, + Data64: base64.StdEncoding.EncodeToString([]byte(data)), + } + jsonData, err := json.Marshal(payload) + if err != nil { + return err + } + return c.SendSSEvent(ssEvent{Event: "termwrite", Data: jsonData}) +} + func makeNullRendered() *rpctypes.RenderedElem { return &rpctypes.RenderedElem{WaveId: uuid.New().String(), Tag: vdom.WaveNullTag} } diff --git a/tsunami/engine/render.go b/tsunami/engine/render.go index 29c39c9cb7..87750f8740 100644 --- a/tsunami/engine/render.go +++ b/tsunami/engine/render.go @@ -5,6 +5,7 @@ package engine import ( "fmt" + "log" "reflect" "unicode" @@ -247,12 +248,6 @@ func convertPropsToVDom(props map[string]any) map[string]any { vdomProps[k] = vdomFuncPtr continue } - if vdomRef, ok := v.(vdom.VDomRef); ok { - // ensure Type is set on all VDomRefs - vdomRef.Type = vdom.ObjectType_Ref - vdomProps[k] = vdomRef - continue - } if vdomRefPtr, ok := v.(*vdom.VDomRef); ok { if vdomRefPtr == nil { continue // handle typed-nil @@ -263,6 +258,10 @@ func convertPropsToVDom(props map[string]any) map[string]any { continue } val := reflect.ValueOf(v) + if val.Type() == reflect.TypeOf(vdom.VDomRef{}) { + log.Printf("warning: VDomRef passed as non-pointer for prop %q (VDomRef contains atomics and must be passed as *VDomRef); dropping prop\n", k) + continue + } if val.Kind() == reflect.Func { // convert go functions passed to event handlers to VDomFuncs vdomProps[k] = vdom.VDomFunc{Type: vdom.ObjectType_Func} diff --git a/tsunami/engine/rootelem.go b/tsunami/engine/rootelem.go index 1d8b93808e..787be044e0 100644 --- a/tsunami/engine/rootelem.go +++ b/tsunami/engine/rootelem.go @@ -443,9 +443,11 @@ func (r *RootElem) UpdateRef(updateRef rpctypes.VDomRefUpdate) { if !ok { return } - ref.HasCurrent = updateRef.HasCurrent + ref.HasCurrent.Store(updateRef.HasCurrent) ref.Position = updateRef.Position - r.addRenderWork(waveId) + if updateRef.TermSize != nil { + ref.TermSize = updateRef.TermSize + } } func (r *RootElem) QueueRefOp(op vdom.VDomRefOperation) { diff --git a/tsunami/engine/serverhandlers.go b/tsunami/engine/serverhandlers.go index 5c5325610c..1e7bc94fda 100644 --- a/tsunami/engine/serverhandlers.go +++ b/tsunami/engine/serverhandlers.go @@ -18,6 +18,7 @@ import ( "github.com/wavetermdev/waveterm/tsunami/rpctypes" "github.com/wavetermdev/waveterm/tsunami/util" + "github.com/wavetermdev/waveterm/tsunami/vdom" ) const SSEKeepAliveDuration = 5 * time.Second @@ -83,6 +84,7 @@ func (h *httpHandlers) registerHandlers(mux *http.ServeMux, opts handlerOpts) { mux.HandleFunc("/api/schemas", h.handleSchemas) mux.HandleFunc("/api/manifest", h.handleManifest(opts.ManifestFile)) mux.HandleFunc("/api/modalresult", h.handleModalResult) + mux.HandleFunc("/api/terminput", h.handleTermInput) mux.HandleFunc("/dyn/", h.handleDynContent) // Add handler for static files at /static/ path @@ -392,6 +394,48 @@ func (h *httpHandlers) handleModalResult(w http.ResponseWriter, r *http.Request) json.NewEncoder(w).Encode(map[string]any{"success": true}) } +func (h *httpHandlers) handleTermInput(w http.ResponseWriter, r *http.Request) { + defer func() { + panicErr := util.PanicHandler("handleTermInput", recover()) + if panicErr != nil { + http.Error(w, fmt.Sprintf("internal server error: %v", panicErr), http.StatusInternalServerError) + } + }() + + setNoCacheHeaders(w) + + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, fmt.Sprintf("failed to read request body: %v", err), http.StatusBadRequest) + return + } + + var event vdom.VDomEvent + if err := json.Unmarshal(body, &event); err != nil { + http.Error(w, fmt.Sprintf("failed to parse JSON: %v", err), http.StatusBadRequest) + return + } + if strings.TrimSpace(event.WaveId) == "" { + http.Error(w, "waveid is required", http.StatusBadRequest) + return + } + if event.TermInput == nil { + http.Error(w, "terminput is required", http.StatusBadRequest) + return + } + + h.renderLock.Lock() + h.Client.Root.Event(event, h.Client.GlobalEventHandler) + h.renderLock.Unlock() + + w.WriteHeader(http.StatusNoContent) +} + func (h *httpHandlers) handleDynContent(w http.ResponseWriter, r *http.Request) { defer func() { panicErr := util.PanicHandler("handleDynContent", recover()) diff --git a/tsunami/frontend/src/element/tsunamiterm.tsx b/tsunami/frontend/src/element/tsunamiterm.tsx new file mode 100644 index 0000000000..603d4b1889 --- /dev/null +++ b/tsunami/frontend/src/element/tsunamiterm.tsx @@ -0,0 +1,157 @@ +import { FitAddon } from "@xterm/addon-fit"; +import { Terminal } from "@xterm/xterm"; +import "@xterm/xterm/css/xterm.css"; +import * as React from "react"; + +import { base64ToArray } from "@/util/base64"; + +export type TsunamiTermElem = HTMLDivElement & { + __termWrite: (data64: string) => void; + __termFocus: () => void; + __termSize: () => VDomTermSize | null; +}; + +type TsunamiTermProps = React.HTMLAttributes & { + onData?: (data: string | null, termsize: VDomTermSize | null) => void; + termFontSize?: number; + termFontFamily?: string; + termScrollback?: number; +}; + +const TsunamiTerm = React.forwardRef(function TsunamiTerm(props, ref) { + const { onData, termFontSize, termFontFamily, termScrollback, ...outerProps } = props; + const outerRef = React.useRef(null); + const termRef = React.useRef(null); + const terminalRef = React.useRef(null); + const onDataRef = React.useRef(onData); + onDataRef.current = onData; + + const setOuterRef = React.useCallback( + (elem: TsunamiTermElem) => { + outerRef.current = elem; + if (elem != null) { + elem.__termWrite = (data64: string) => { + if (data64 == null || data64 === "") { + return; + } + try { + terminalRef.current?.write(base64ToArray(data64)); + } catch (error) { + console.error("Failed to write to terminal:", error); + } + }; + elem.__termFocus = () => { + terminalRef.current?.focus(); + }; + elem.__termSize = () => { + const terminal = terminalRef.current; + if (terminal == null) { + return null; + } + return { rows: terminal.rows, cols: terminal.cols }; + }; + } + if (typeof ref === "function") { + ref(elem); + return; + } + if (ref != null) { + ref.current = elem; + } + }, + [ref] + ); + + React.useEffect(() => { + if (termRef.current == null) { + return; + } + const terminal = new Terminal({ + convertEol: false, + ...(termFontSize != null ? { fontSize: termFontSize } : {}), + ...(termFontFamily != null ? { fontFamily: termFontFamily } : {}), + ...(termScrollback != null ? { scrollback: termScrollback } : {}), + }); + const fitAddon = new FitAddon(); + terminal.loadAddon(fitAddon); + terminal.open(termRef.current); + fitAddon.fit(); + terminalRef.current = terminal; + + const onDataDisposable = terminal.onData((data) => { + if (onDataRef.current == null) { + return; + } + onDataRef.current(data, null); + }); + const onResizeDisposable = terminal.onResize((size) => { + if (onDataRef.current == null) { + return; + } + onDataRef.current(null, { rows: size.rows, cols: size.cols }); + }); + if (onDataRef.current != null) { + onDataRef.current(null, { rows: terminal.rows, cols: terminal.cols }); + } + + const resizeObserver = new ResizeObserver(() => { + fitAddon.fit(); + }); + if (outerRef.current != null) { + resizeObserver.observe(outerRef.current); + } + + return () => { + resizeObserver.disconnect(); + onResizeDisposable.dispose(); + onDataDisposable.dispose(); + terminal.dispose(); + terminalRef.current = null; + }; + }, []); + + React.useEffect(() => { + const terminal = terminalRef.current; + if (terminal == null) { + return; + } + if (termFontSize != null) { + terminal.options.fontSize = termFontSize; + } + if (termFontFamily != null) { + terminal.options.fontFamily = termFontFamily; + } + if (termScrollback != null) { + terminal.options.scrollback = termScrollback; + } + }, [termFontSize, termFontFamily, termScrollback]); + + const handleFocus = React.useCallback( + (e: React.FocusEvent) => { + terminalRef.current?.focus(); + outerProps.onFocus?.(e); + }, + [outerProps.onFocus] + ); + + const handleBlur = React.useCallback( + (e: React.FocusEvent) => { + terminalRef.current?.blur(); + outerProps.onBlur?.(e); + }, + [outerProps.onBlur] + ); + + return ( +
} + onFocus={handleFocus} + onBlur={handleBlur} + > +
+
+ ); +}); + +export { TsunamiTerm }; diff --git a/tsunami/frontend/src/model/model-utils.ts b/tsunami/frontend/src/model/model-utils.ts index 9ea4d92982..da14252a6e 100644 --- a/tsunami/frontend/src/model/model-utils.ts +++ b/tsunami/frontend/src/model/model-utils.ts @@ -1,6 +1,8 @@ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 +import type { TsunamiTermElem } from "@/element/tsunamiterm"; + const TextTag = "#text"; // TODO support binding @@ -79,6 +81,22 @@ export function restoreVDomElems(backendUpdate: VDomBackendUpdate) { }); } +export function isTsunamiTermElem(elem: HTMLElement): elem is TsunamiTermElem { + return elem != null && typeof (elem as TsunamiTermElem).__termWrite === "function"; +} + +export function applyTermOp(elem: TsunamiTermElem, termOp: VDomRefOperation) { + const { op, params } = termOp; + if (op === "termwrite") { + const data64 = params?.[0]; + if (typeof data64 === "string" && data64 !== "") { + elem.__termWrite(data64); + } + } else if (op === "focus") { + elem.__termFocus(); + } +} + export function applyCanvasOp(canvas: HTMLCanvasElement, canvasOp: VDomRefOperation, refStore: Map) { const ctx = canvas.getContext("2d"); if (!ctx) { diff --git a/tsunami/frontend/src/model/tsunami-model.tsx b/tsunami/frontend/src/model/tsunami-model.tsx index 61857dbebe..cc83104b81 100644 --- a/tsunami/frontend/src/model/tsunami-model.tsx +++ b/tsunami/frontend/src/model/tsunami-model.tsx @@ -9,7 +9,7 @@ import { getOrCreateClientId } from "@/util/clientid"; import { adaptFromReactOrNativeKeyEvent } from "@/util/keyutil"; import { PLATFORM, PlatformMacOS } from "@/util/platformutil"; import { getDefaultStore } from "jotai"; -import { applyCanvasOp, restoreVDomElems } from "./model-utils"; +import { applyCanvasOp, applyTermOp, isTsunamiTermElem, restoreVDomElems } from "./model-utils"; const dlog = debug("wave:vdom"); @@ -236,6 +236,25 @@ export class TsunamiModel { } }); + this.serverEventSource.addEventListener("termwrite", (event: MessageEvent) => { + try { + const packet = JSON.parse(event.data); + if (packet?.refid == null || packet?.data64 == null) { + return; + } + const refOp: VDomRefOperation = { refid: packet.refid, op: "termwrite", params: [packet.data64] }; + const elem = this.getRefElem(refOp.refid); + if (elem == null) { + return; + } + if (isTsunamiTermElem(elem)) { + applyTermOp(elem, refOp); + } + } catch (e) { + console.error("Failed to parse termwrite event:", e); + } + }); + this.serverEventSource.addEventListener("error", (event) => { console.error("SSE connection error:", event); }); @@ -319,6 +338,12 @@ export class TsunamiModel { boundingclientrect: ref.elem.getBoundingClientRect(), }; } + if (isTsunamiTermElem(ref.elem)) { + const termsize = ref.elem.__termSize(); + if (termsize != null) { + ru.termsize = termsize; + } + } updates.push(ru); ref.updated = false; } @@ -606,6 +631,10 @@ export class TsunamiModel { applyCanvasOp(elem, refOp, this.refOutputStore); continue; } + if (isTsunamiTermElem(elem)) { + applyTermOp(elem, refOp); + continue; + } if (refOp.op == "focus") { if (elem == null) { this.addErrorMessage(`Could not focus ref with id ${refOp.refid}: elem is null`); @@ -718,8 +747,7 @@ export class TsunamiModel { vdomEvent.globaleventtype = fnDecl.globalevent; } const needsAsync = - propName == "onSubmit" || - (propName == "onChange" && (e.target as HTMLInputElement)?.type === "file"); + propName == "onSubmit" || (propName == "onChange" && (e.target as HTMLInputElement)?.type === "file"); if (needsAsync) { asyncAnnotateEvent(vdomEvent, propName, e) .then(() => { diff --git a/tsunami/frontend/src/types/vdom.d.ts b/tsunami/frontend/src/types/vdom.d.ts index 485ada680b..2ca0f73867 100644 --- a/tsunami/frontend/src/types/vdom.d.ts +++ b/tsunami/frontend/src/types/vdom.d.ts @@ -33,6 +33,18 @@ type VDomElem = { text?: string; }; +// vdom.VDomTermSize +type VDomTermSize = { + rows: number; + cols: number; +}; + +// vdom.VDomTermInputData +type VDomTermInputData = { + termsize?: VDomTermSize; + data?: string; +}; + // vdom.VDomEvent type VDomEvent = { waveid: string; @@ -46,6 +58,7 @@ type VDomEvent = { keydata?: VDomKeyboardEvent; mousedata?: VDomPointerData; formdata?: VDomFormData; + terminput?: VDomTermInputData; }; // vdom.VDomFrontendUpdate @@ -103,7 +116,6 @@ type VDomRef = { type: "ref"; refid: string; trackposition?: boolean; - position?: VDomRefPosition; hascurrent?: boolean; }; @@ -130,6 +142,7 @@ type VDomRefUpdate = { refid: string; hascurrent: boolean; position?: VDomRefPosition; + termsize?: VDomTermSize; }; // rpctypes.VDomRenderContext diff --git a/tsunami/frontend/src/vdom.tsx b/tsunami/frontend/src/vdom.tsx index 37de4c0f1c..a51e119193 100644 --- a/tsunami/frontend/src/vdom.tsx +++ b/tsunami/frontend/src/vdom.tsx @@ -7,8 +7,9 @@ import * as jotai from "jotai"; import * as React from "react"; import { twMerge } from "tailwind-merge"; -import { AlertModal, ConfirmModal } from "@/element/modals"; import { Markdown } from "@/element/markdown"; +import { AlertModal, ConfirmModal } from "@/element/modals"; +import { TsunamiTerm } from "@/element/tsunamiterm"; import { getTextChildren } from "@/model/model-utils"; import type { TsunamiModel } from "@/model/tsunami-model"; import { RechartsTag } from "@/recharts/recharts"; @@ -30,6 +31,7 @@ type VDomReactTagType = (props: { elem: VDomElem; model: TsunamiModel }) => Reac const WaveTagMap: Record = { "wave:markdown": WaveMarkdown, + "wave:term": WaveTerm, }; const AllowedSimpleTags: { [tagName: string]: boolean } = { @@ -278,6 +280,46 @@ function WaveMarkdown({ elem, model }: { elem: VDomElem; model: TsunamiModel }) ); } +async function sendTermInputEvent(event: VDomEvent) { + const response = await fetch("/api/terminput", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(event), + }); + if (!response.ok) { + throw new Error(`terminal input request failed: ${response.status} ${response.statusText}`); + } +} + +function WaveTerm({ elem, model }: { elem: VDomElem; model: TsunamiModel }) { + const props = useVDom(model, elem); + const hasOnData = props.onData != null; + const onData = React.useCallback( + (data: string | null, termsize: VDomTermSize | null) => { + const terminput: VDomTermInputData = {}; + if (data != null) { + terminput.data = data; + } + if (termsize != null) { + terminput.termsize = termsize; + } + const event: VDomEvent = { + waveid: elem.waveid, + eventtype: "onData", + terminput: terminput, + }; + sendTermInputEvent(event).catch((error) => { + console.error("Failed to send terminal input:", error); + }); + }, + [elem.waveid] + ); + const termProps = { ...props, onData: hasOnData ? onData : undefined }; + return ; +} + function StyleTag({ elem, model }: { elem: VDomElem; model: TsunamiModel }) { const styleText = getTextChildren(elem); if (styleText == null) { diff --git a/tsunami/rpctypes/protocoltypes.go b/tsunami/rpctypes/protocoltypes.go index f2728f0bb6..bad88a8745 100644 --- a/tsunami/rpctypes/protocoltypes.go +++ b/tsunami/rpctypes/protocoltypes.go @@ -166,6 +166,7 @@ type VDomRefUpdate struct { RefId string `json:"refid"` HasCurrent bool `json:"hascurrent"` Position *vdom.VDomRefPosition `json:"position,omitempty"` + TermSize *vdom.VDomTermSize `json:"termsize,omitempty"` } type VDomBackendOpts struct { @@ -206,3 +207,8 @@ type ModalResult struct { ModalId string `json:"modalid"` // ID of the modal Confirm bool `json:"confirm"` // true = confirmed/ok, false = cancelled } + +type TermWritePacket struct { + RefId string `json:"refid"` + Data64 string `json:"data64"` +} diff --git a/tsunami/templates/package.json.tmpl b/tsunami/templates/package.json.tmpl index a214510649..c8d88dae83 100644 --- a/tsunami/templates/package.json.tmpl +++ b/tsunami/templates/package.json.tmpl @@ -10,7 +10,7 @@ "email": "info@commandline.dev" }, "dependencies": { - "@tailwindcss/cli": "^4.1.13", - "tailwindcss": "^4.1.13" + "@tailwindcss/cli": "^4.2.1", + "tailwindcss": "^4.2.1" } } diff --git a/tsunami/vdom/vdom_types.go b/tsunami/vdom/vdom_types.go index d20a02ac3d..58725e4010 100644 --- a/tsunami/vdom/vdom_types.go +++ b/tsunami/vdom/vdom_types.go @@ -1,8 +1,13 @@ -// Copyright 2025, Command Line Inc. +// Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package vdom +import ( + "encoding/json" + "sync/atomic" +) + const TextTag = "#text" const WaveTextTag = "wave:text" const WaveNullTag = "wave:null" @@ -36,8 +41,42 @@ type VDomRef struct { Type string `json:"type" tstype:"\"ref\""` RefId string `json:"refid"` TrackPosition bool `json:"trackposition,omitempty"` - Position *VDomRefPosition `json:"position,omitempty"` - HasCurrent bool `json:"hascurrent,omitempty"` + Position *VDomRefPosition `json:"-"` + HasCurrent atomic.Bool `json:"-"` + TermSize *VDomTermSize `json:"-"` +} + +func (r *VDomRef) MarshalJSON() ([]byte, error) { + type vdomRefAlias struct { + Type string `json:"type"` + RefId string `json:"refid"` + TrackPosition bool `json:"trackposition,omitempty"` + HasCurrent bool `json:"hascurrent,omitempty"` + } + return json.Marshal(vdomRefAlias{ + Type: r.Type, + RefId: r.RefId, + TrackPosition: r.TrackPosition, + HasCurrent: r.HasCurrent.Load(), + }) +} + +func (r *VDomRef) UnmarshalJSON(data []byte) error { + type vdomRefAlias struct { + Type string `json:"type"` + RefId string `json:"refid"` + TrackPosition bool `json:"trackposition,omitempty"` + HasCurrent bool `json:"hascurrent,omitempty"` + } + var alias vdomRefAlias + if err := json.Unmarshal(data, &alias); err != nil { + return err + } + r.Type = alias.Type + r.RefId = alias.RefId + r.TrackPosition = alias.TrackPosition + r.HasCurrent.Store(alias.HasCurrent) + return nil } type VDomSimpleRef[T any] struct { @@ -62,18 +101,29 @@ type VDomRefPosition struct { BoundingClientRect DomRect `json:"boundingclientrect"` } +type VDomTermInputData struct { + TermSize *VDomTermSize `json:"termsize,omitempty"` + Data string `json:"data,omitempty"` +} + +type VDomTermSize struct { + Rows int `json:"rows"` + Cols int `json:"cols"` +} + type VDomEvent struct { WaveId string `json:"waveid"` EventType string `json:"eventtype"` // usually the prop name (e.g. onClick, onKeyDown) GlobalEventType string `json:"globaleventtype,omitempty"` - TargetValue string `json:"targetvalue,omitempty"` // set for onChange events on input/textarea/select + TargetValue string `json:"targetvalue,omitempty"` // set for onChange events on input/textarea/select TargetChecked bool `json:"targetchecked,omitempty"` // set for onChange events on checkbox/radio inputs - TargetName string `json:"targetname,omitempty"` // target element's name attribute - TargetId string `json:"targetid,omitempty"` // target element's id attribute - TargetFiles []VDomFileData `json:"targetfiles,omitempty"` // set for onChange events on file inputs - KeyData *VDomKeyboardEvent `json:"keydata,omitempty"` // set for onKeyDown events - MouseData *VDomPointerData `json:"mousedata,omitempty"` // set for onClick, onMouseDown, onMouseUp, onDoubleClick events - FormData *VDomFormData `json:"formdata,omitempty"` // set for onSubmit events on forms + TargetName string `json:"targetname,omitempty"` // target element's name attribute + TargetId string `json:"targetid,omitempty"` // target element's id attribute + TargetFiles []VDomFileData `json:"targetfiles,omitempty"` // set for onChange events on file inputs + KeyData *VDomKeyboardEvent `json:"keydata,omitempty"` // set for onKeyDown events + MouseData *VDomPointerData `json:"mousedata,omitempty"` // set for onClick, onMouseDown, onMouseUp, onDoubleClick events + FormData *VDomFormData `json:"formdata,omitempty"` // set for onSubmit events on forms + TermInput *VDomTermInputData `json:"terminput,omitempty"` // set for onData events on wave:term elements } type VDomKeyboardEvent struct { @@ -115,13 +165,13 @@ type VDomPointerData struct { } type VDomFormData struct { - Action string `json:"action,omitempty"` - Method string `json:"method"` - Enctype string `json:"enctype"` - FormId string `json:"formid,omitempty"` - FormName string `json:"formname,omitempty"` - Fields map[string][]string `json:"fields"` - Files map[string][]VDomFileData `json:"files"` + Action string `json:"action,omitempty"` + Method string `json:"method"` + Enctype string `json:"enctype"` + FormId string `json:"formid,omitempty"` + FormName string `json:"formname,omitempty"` + Fields map[string][]string `json:"fields"` + Files map[string][]VDomFileData `json:"files"` } func (f *VDomFormData) GetField(fieldName string) string {