This commit is contained in:
Mike Sawka 2024-10-17 14:50:36 -07:00 committed by GitHub
parent c1c90bb4f8
commit 46783ba315
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 2247 additions and 383 deletions

View File

@ -29,6 +29,7 @@ func GenerateWshClient() error {
"github.com/wavetermdev/waveterm/pkg/waveobj", "github.com/wavetermdev/waveterm/pkg/waveobj",
"github.com/wavetermdev/waveterm/pkg/wconfig", "github.com/wavetermdev/waveterm/pkg/wconfig",
"github.com/wavetermdev/waveterm/pkg/wps", "github.com/wavetermdev/waveterm/pkg/wps",
"github.com/wavetermdev/waveterm/pkg/vdom",
}) })
wshDeclMap := wshrpc.GenerateWshCommandDeclMap() wshDeclMap := wshrpc.GenerateWshCommandDeclMap()
for _, key := range utilfn.GetOrderedMapKeys(wshDeclMap) { for _, key := range utilfn.GetOrderedMapKeys(wshDeclMap) {

View File

@ -14,7 +14,7 @@ import (
func Page(ctx context.Context, props map[string]any) any { func Page(ctx context.Context, props map[string]any) any {
clicked, setClicked := vdom.UseState(ctx, false) clicked, setClicked := vdom.UseState(ctx, false)
var clickedDiv *vdom.Elem var clickedDiv *vdom.VDomElem
if clicked { if clicked {
clickedDiv = vdom.Bind(`<div>clicked</div>`, nil) clickedDiv = vdom.Bind(`<div>clicked</div>`, nil)
} }
@ -35,7 +35,7 @@ func Page(ctx context.Context, props map[string]any) any {
} }
func Button(ctx context.Context, props map[string]any) any { func Button(ctx context.Context, props map[string]any) any {
ref := vdom.UseRef(ctx, nil) ref := vdom.UseVDomRef(ctx)
clName, setClName := vdom.UseState(ctx, "button") clName, setClName := vdom.UseState(ctx, "button")
vdom.UseEffect(ctx, func() func() { vdom.UseEffect(ctx, func() func() {
fmt.Printf("Button useEffect\n") fmt.Printf("Button useEffect\n")

View File

@ -4,9 +4,12 @@
package cmd package cmd
import ( import (
"fmt" "log"
"time"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/wavetermdev/waveterm/pkg/vdom"
"github.com/wavetermdev/waveterm/pkg/vdom/vdomclient"
"github.com/wavetermdev/waveterm/pkg/wshutil" "github.com/wavetermdev/waveterm/pkg/wshutil"
) )
@ -17,27 +20,59 @@ func init() {
var htmlCmd = &cobra.Command{ var htmlCmd = &cobra.Command{
Use: "html", Use: "html",
Hidden: true, Hidden: true,
Short: "Launch a demo html-mode terminal", Short: "launch demo vdom application",
Run: htmlRun, RunE: htmlRun,
PreRunE: preRunSetupRpcClient,
} }
func htmlRun(cmd *cobra.Command, args []string) { func MakeVDom() *vdom.VDomElem {
defer wshutil.DoShutdown("normal exit", 0, true) vdomStr := `
setTermHtmlMode() <div>
for { <h1 style="color:red; background-color: #bind:$.bgcolor; border-radius: 4px; padding: 5px;">hello vdom world</h1>
var buf [1]byte <div><bind key="$.text"/> | num[<bind key="$.num"/>]</div>
_, err := WrappedStdin.Read(buf[:]) <div>
<button onClick="#globalevent:clickinc">increment</button>
</div>
</div>
`
elem := vdom.Bind(vdomStr, nil)
return elem
}
func GlobalEventHandler(client *vdomclient.Client, event vdom.VDomEvent) {
if event.PropName == "clickinc" {
client.SetAtomVal("num", client.GetAtomVal("num").(int)+1)
return
}
}
func htmlRun(cmd *cobra.Command, args []string) error {
WriteStderr("running wsh html %q\n", RpcContext.BlockId)
client, err := vdomclient.MakeClient(&vdom.VDomBackendOpts{CloseOnCtrlC: true})
if err != nil { if err != nil {
wshutil.DoShutdown(fmt.Sprintf("stdin closed/error (%v)", err), 1, true) return err
}
if buf[0] == 0x03 {
wshutil.DoShutdown("read Ctrl-C from stdin", 1, true)
break
}
if buf[0] == 'x' {
wshutil.DoShutdown("read 'x' from stdin", 0, true)
break
} }
client.SetGlobalEventHandler(GlobalEventHandler)
log.Printf("created client: %v\n", client)
client.SetAtomVal("bgcolor", "#0000ff77")
client.SetAtomVal("text", "initial text")
client.SetAtomVal("num", 0)
client.SetRootElem(MakeVDom())
err = client.CreateVDomContext()
if err != nil {
return err
} }
log.Printf("created context\n")
go func() {
<-client.DoneCh
wshutil.DoShutdown("vdom closed by FE", 0, true)
}()
log.Printf("created vdom context\n")
go func() {
time.Sleep(5 * time.Second)
client.SetAtomVal("text", "updated text")
client.SendAsyncInitiation()
}()
<-client.DoneCh
return nil
} }

View File

@ -36,7 +36,7 @@ type FullBlockProps = {
function makeViewModel(blockId: string, blockView: string, nodeModel: NodeModel): ViewModel { function makeViewModel(blockId: string, blockView: string, nodeModel: NodeModel): ViewModel {
if (blockView === "term") { if (blockView === "term") {
return makeTerminalModel(blockId); return makeTerminalModel(blockId, nodeModel);
} }
if (blockView === "preview") { if (blockView === "preview") {
return makePreviewModel(blockId, nodeModel); return makePreviewModel(blockId, nodeModel);
@ -253,7 +253,7 @@ const BlockFull = memo(({ nodeModel, viewModel }: FullBlockProps) => {
const Block = memo((props: BlockProps) => { const Block = memo((props: BlockProps) => {
counterInc("render-Block"); counterInc("render-Block");
counterInc("render-Block-" + props.nodeModel.blockId.substring(0, 8)); counterInc("render-Block-" + props.nodeModel?.blockId?.substring(0, 8));
const [blockData, loading] = useWaveObjectValue<Block>(makeORef("block", props.nodeModel.blockId)); const [blockData, loading] = useWaveObjectValue<Block>(makeORef("block", props.nodeModel.blockId));
const bcm = getBlockComponentModel(props.nodeModel.blockId); const bcm = getBlockComponentModel(props.nodeModel.blockId);
let viewModel = bcm?.viewModel; let viewModel = bcm?.viewModel;
@ -264,6 +264,7 @@ const Block = memo((props: BlockProps) => {
useEffect(() => { useEffect(() => {
return () => { return () => {
unregisterBlockComponentModel(props.nodeModel.blockId); unregisterBlockComponentModel(props.nodeModel.blockId);
viewModel?.dispose?.();
}; };
}, []); }, []);
if (loading || isBlank(props.nodeModel.blockId) || blockData == null) { if (loading || isBlank(props.nodeModel.blockId) || blockData == null) {

View File

@ -18,6 +18,10 @@ class RpcResponseHelper {
this.done = cmdMsg.reqid == null; this.done = cmdMsg.reqid == null;
} }
getSource(): string {
return this.cmdMsg?.source;
}
sendResponse(msg: RpcMessage) { sendResponse(msg: RpcMessage) {
if (this.done || util.isBlank(this.cmdMsg.reqid)) { if (this.done || util.isBlank(this.cmdMsg.reqid)) {
return; return;

View File

@ -217,6 +217,21 @@ class RpcApiType {
return client.wshRpcCall("test", data, opts); return client.wshRpcCall("test", data, opts);
} }
// command "vdomasyncinitiation" [call]
VDomAsyncInitiationCommand(client: WshClient, data: VDomAsyncInitiationRequest, opts?: RpcOpts): Promise<void> {
return client.wshRpcCall("vdomasyncinitiation", data, opts);
}
// command "vdomcreatecontext" [call]
VDomCreateContextCommand(client: WshClient, data: VDomCreateContext, opts?: RpcOpts): Promise<void> {
return client.wshRpcCall("vdomcreatecontext", data, opts);
}
// command "vdomrender" [call]
VDomRenderCommand(client: WshClient, data: VDomFrontendUpdate, opts?: RpcOpts): Promise<VDomBackendUpdate> {
return client.wshRpcCall("vdomrender", data, opts);
}
// command "webselector" [call] // command "webselector" [call]
WebSelectorCommand(client: WshClient, data: CommandWebSelectorData, opts?: RpcOpts): Promise<string[]> { WebSelectorCommand(client: WshClient, data: CommandWebSelectorData, opts?: RpcOpts): Promise<string[]> {
return client.wshRpcCall("webselector", data, opts); return client.wshRpcCall("webselector", data, opts);

View File

@ -0,0 +1,52 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { WOS } from "@/app/store/global";
import { waveEventSubscribe } from "@/app/store/wps";
import { RpcResponseHelper, WshClient } from "@/app/store/wshclient";
import { RpcApi } from "@/app/store/wshclientapi";
import { makeFeBlockRouteId } from "@/app/store/wshrouter";
import { TermViewModel } from "@/app/view/term/term";
import debug from "debug";
const dlog = debug("wave:vdom");
export class TermWshClient extends WshClient {
blockId: string;
model: TermViewModel;
constructor(blockId: string, model: TermViewModel) {
super(makeFeBlockRouteId(blockId));
this.blockId = blockId;
this.model = model;
}
handle_vdomcreatecontext(rh: RpcResponseHelper, data: VDomCreateContext) {
console.log("vdom-create", rh.getSource(), data);
this.model.vdomModel.reset();
this.model.vdomModel.backendRoute = rh.getSource();
if (!data.persist) {
const unsubFn = waveEventSubscribe({
eventType: "route:gone",
scope: rh.getSource(),
handler: () => {
RpcApi.SetMetaCommand(this, {
oref: WOS.makeORef("block", this.blockId),
meta: { "term:mode": null },
});
unsubFn();
},
});
}
RpcApi.SetMetaCommand(this, {
oref: WOS.makeORef("block", this.blockId),
meta: { "term:mode": "html" },
});
this.model.vdomModel.queueUpdate(true);
}
handle_vdomasyncinitiation(rh: RpcResponseHelper, data: VDomAsyncInitiationRequest) {
console.log("async-initiation", rh.getSource(), data);
this.model.vdomModel.queueUpdate(true);
}
}

View File

@ -4,12 +4,15 @@
import { getAllGlobalKeyBindings } from "@/app/store/keymodel"; import { getAllGlobalKeyBindings } from "@/app/store/keymodel";
import { waveEventSubscribe } from "@/app/store/wps"; import { waveEventSubscribe } from "@/app/store/wps";
import { RpcApi } from "@/app/store/wshclientapi"; import { RpcApi } from "@/app/store/wshclientapi";
import { TabRpcClient } from "@/app/store/wshrpcutil"; import { makeFeBlockRouteId } from "@/app/store/wshrouter";
import { DefaultRouter, TabRpcClient } from "@/app/store/wshrpcutil";
import { TermWshClient } from "@/app/view/term/term-wsh";
import { VDomView } from "@/app/view/term/vdom"; import { VDomView } from "@/app/view/term/vdom";
import { VDomModel } from "@/app/view/term/vdom-model";
import { NodeModel } from "@/layout/index";
import { WOS, atoms, getConnStatusAtom, getSettingsKeyAtom, globalStore, useSettingsPrefixAtom } from "@/store/global"; import { WOS, atoms, getConnStatusAtom, getSettingsKeyAtom, globalStore, useSettingsPrefixAtom } from "@/store/global";
import * as services from "@/store/services"; import * as services from "@/store/services";
import * as keyutil from "@/util/keyutil"; import * as keyutil from "@/util/keyutil";
import * as util from "@/util/util";
import clsx from "clsx"; import clsx from "clsx";
import * as jotai from "jotai"; import * as jotai from "jotai";
import * as React from "react"; import * as React from "react";
@ -19,102 +22,35 @@ import { computeTheme } from "./termutil";
import { TermWrap } from "./termwrap"; import { TermWrap } from "./termwrap";
import "./xterm.css"; import "./xterm.css";
const keyMap = {
Enter: "\r",
Backspace: "\x7f",
Tab: "\t",
Escape: "\x1b",
ArrowUp: "\x1b[A",
ArrowDown: "\x1b[B",
ArrowRight: "\x1b[C",
ArrowLeft: "\x1b[D",
Insert: "\x1b[2~",
Delete: "\x1b[3~",
Home: "\x1b[1~",
End: "\x1b[4~",
PageUp: "\x1b[5~",
PageDown: "\x1b[6~",
};
function keyboardEventToASCII(event: React.KeyboardEvent<HTMLInputElement>): string {
// check modifiers
// if no modifiers are set, just send the key
if (!event.altKey && !event.ctrlKey && !event.metaKey) {
if (event.key == null || event.key == "") {
return "";
}
if (keyMap[event.key] != null) {
return keyMap[event.key];
}
if (event.key.length == 1) {
return event.key;
} else {
console.log("not sending keyboard event", event.key, event);
}
}
// if meta or alt is set, there is no ASCII representation
if (event.metaKey || event.altKey) {
return "";
}
// if ctrl is set, if it is a letter, subtract 64 from the uppercase value to get the ASCII value
if (event.ctrlKey) {
if (
(event.key.length === 1 && event.key >= "A" && event.key <= "Z") ||
(event.key >= "a" && event.key <= "z")
) {
const key = event.key.toUpperCase();
return String.fromCharCode(key.charCodeAt(0) - 64);
}
}
return "";
}
type InitialLoadDataType = { type InitialLoadDataType = {
loaded: boolean; loaded: boolean;
heldData: Uint8Array[]; heldData: Uint8Array[];
}; };
function vdomText(text: string): VDomElem {
return {
tag: "#text",
text: text,
};
}
const testVDom: VDomElem = {
id: "testid1",
tag: "div",
children: [
{
id: "testh1",
tag: "h1",
children: [vdomText("Hello World")],
},
{
id: "testp",
tag: "p",
children: [vdomText("This is a paragraph (from VDOM)")],
},
],
};
class TermViewModel { class TermViewModel {
viewType: string; viewType: string;
nodeModel: NodeModel;
connected: boolean; connected: boolean;
termRef: React.RefObject<TermWrap>; termRef: React.RefObject<TermWrap>;
blockAtom: jotai.Atom<Block>; blockAtom: jotai.Atom<Block>;
termMode: jotai.Atom<string>; termMode: jotai.Atom<string>;
htmlElemFocusRef: React.RefObject<HTMLInputElement>;
blockId: string; blockId: string;
viewIcon: jotai.Atom<string>; viewIcon: jotai.Atom<string>;
viewName: jotai.Atom<string>; viewName: jotai.Atom<string>;
blockBg: jotai.Atom<MetaType>; blockBg: jotai.Atom<MetaType>;
manageConnection: jotai.Atom<boolean>; manageConnection: jotai.Atom<boolean>;
connStatus: jotai.Atom<ConnStatus>; connStatus: jotai.Atom<ConnStatus>;
termWshClient: TermWshClient;
shellProcStatusRef: React.MutableRefObject<string>;
vdomModel: VDomModel;
constructor(blockId: string) { constructor(blockId: string, nodeModel: NodeModel) {
this.viewType = "term"; this.viewType = "term";
this.blockId = blockId; this.blockId = blockId;
this.termWshClient = new TermWshClient(blockId, this);
DefaultRouter.registerRoute(makeFeBlockRouteId(blockId), this.termWshClient);
this.nodeModel = nodeModel;
this.vdomModel = new VDomModel(blockId, nodeModel, null, this.termWshClient);
this.blockAtom = WOS.getWaveObjectAtom<Block>(`block:${blockId}`); this.blockAtom = WOS.getWaveObjectAtom<Block>(`block:${blockId}`);
this.termMode = jotai.atom((get) => { this.termMode = jotai.atom((get) => {
const blockData = get(this.blockAtom); const blockData = get(this.blockAtom);
@ -152,6 +88,10 @@ class TermViewModel {
}); });
} }
dispose() {
DefaultRouter.unregisterRoute(makeFeBlockRouteId(this.blockId));
}
giveFocus(): boolean { giveFocus(): boolean {
let termMode = globalStore.get(this.termMode); let termMode = globalStore.get(this.termMode);
if (termMode == "term") { if (termMode == "term") {
@ -159,15 +99,70 @@ class TermViewModel {
this.termRef.current.terminal.focus(); this.termRef.current.terminal.focus();
return true; return true;
} }
} else {
if (this.htmlElemFocusRef?.current) {
this.htmlElemFocusRef.current.focus();
return true;
}
} }
return false; return false;
} }
keyDownHandler(waveEvent: WaveKeyboardEvent): boolean {
if (keyutil.checkKeyPressed(waveEvent, "Cmd:Escape")) {
const blockAtom = WOS.getWaveObjectAtom<Block>(`block:${this.blockId}`);
const blockData = globalStore.get(blockAtom);
const newTermMode = blockData?.meta?.["term:mode"] == "html" ? null : "html";
RpcApi.SetMetaCommand(TabRpcClient, {
oref: WOS.makeORef("block", this.blockId),
meta: { "term:mode": newTermMode },
});
return true;
}
const blockData = globalStore.get(this.blockAtom);
if (blockData.meta?.["term:mode"] == "html") {
return this.vdomModel?.globalKeydownHandler(waveEvent);
}
return false;
}
handleTerminalKeydown(event: KeyboardEvent): boolean {
const waveEvent = keyutil.adaptFromReactOrNativeKeyEvent(event);
if (waveEvent.type != "keydown") {
return true;
}
if (this.keyDownHandler(waveEvent)) {
event.preventDefault();
event.stopPropagation();
return false;
}
// deal with terminal specific keybindings
if (keyutil.checkKeyPressed(waveEvent, "Ctrl:Shift:v")) {
const p = navigator.clipboard.readText();
p.then((text) => {
this.termRef.current?.terminal.paste(text);
});
event.preventDefault();
event.stopPropagation();
return false;
} else if (keyutil.checkKeyPressed(waveEvent, "Ctrl:Shift:c")) {
const sel = this.termRef.current?.terminal.getSelection();
navigator.clipboard.writeText(sel);
event.preventDefault();
event.stopPropagation();
return false;
}
if (this.shellProcStatusRef.current != "running" && keyutil.checkKeyPressed(waveEvent, "Enter")) {
// restart
const tabId = globalStore.get(atoms.staticTabId);
const prtn = RpcApi.ControllerResyncCommand(TabRpcClient, { tabid: tabId, blockid: this.blockId });
prtn.catch((e) => console.log("error controller resync (enter)", this.blockId, e));
return false;
}
const globalKeys = getAllGlobalKeyBindings();
for (const key of globalKeys) {
if (keyutil.checkKeyPressed(waveEvent, key)) {
return false;
}
}
return true;
}
setTerminalTheme(themeName: string) { setTerminalTheme(themeName: string) {
RpcApi.SetMetaCommand(TabRpcClient, { RpcApi.SetMetaCommand(TabRpcClient, {
oref: WOS.makeORef("block", this.blockId), oref: WOS.makeORef("block", this.blockId),
@ -215,8 +210,8 @@ class TermViewModel {
} }
} }
function makeTerminalModel(blockId: string): TermViewModel { function makeTerminalModel(blockId: string, nodeModel: NodeModel): TermViewModel {
return new TermViewModel(blockId); return new TermViewModel(blockId, nodeModel);
} }
interface TerminalViewProps { interface TerminalViewProps {
@ -247,63 +242,22 @@ const TermResyncHandler = React.memo(({ blockId, model }: TerminalViewProps) =>
}); });
const TerminalView = ({ blockId, model }: TerminalViewProps) => { const TerminalView = ({ blockId, model }: TerminalViewProps) => {
const viewRef = React.createRef<HTMLDivElement>(); const viewRef = React.useRef<HTMLDivElement>(null);
const connectElemRef = React.useRef<HTMLDivElement>(null); const connectElemRef = React.useRef<HTMLDivElement>(null);
const termRef = React.useRef<TermWrap>(null); const termRef = React.useRef<TermWrap>(null);
model.termRef = termRef; model.termRef = termRef;
const shellProcStatusRef = React.useRef<string>(null); const spstatusRef = React.useRef<string>(null);
const htmlElemFocusRef = React.useRef<HTMLInputElement>(null); model.shellProcStatusRef = spstatusRef;
model.htmlElemFocusRef = htmlElemFocusRef;
const [blockData] = WOS.useWaveObjectValue<Block>(WOS.makeORef("block", blockId)); const [blockData] = WOS.useWaveObjectValue<Block>(WOS.makeORef("block", blockId));
const termSettingsAtom = useSettingsPrefixAtom("term"); const termSettingsAtom = useSettingsPrefixAtom("term");
const termSettings = jotai.useAtomValue(termSettingsAtom); const termSettings = jotai.useAtomValue(termSettingsAtom);
let termMode = blockData?.meta?.["term:mode"] ?? "term";
if (termMode != "term" && termMode != "html") {
termMode = "term";
}
const termModeRef = React.useRef(termMode);
React.useEffect(() => { React.useEffect(() => {
function handleTerminalKeydown(event: KeyboardEvent): boolean {
const waveEvent = keyutil.adaptFromReactOrNativeKeyEvent(event);
if (waveEvent.type != "keydown") {
return true;
}
// deal with terminal specific keybindings
if (keyutil.checkKeyPressed(waveEvent, "Cmd:Escape")) {
event.preventDefault();
event.stopPropagation();
RpcApi.SetMetaCommand(TabRpcClient, {
oref: WOS.makeORef("block", blockId),
meta: { "term:mode": null },
});
return false;
}
if (keyutil.checkKeyPressed(waveEvent, "Ctrl:Shift:v")) {
const p = navigator.clipboard.readText();
p.then((text) => {
termRef.current?.terminal.paste(text);
});
event.preventDefault();
event.stopPropagation();
return false;
} else if (keyutil.checkKeyPressed(waveEvent, "Ctrl:Shift:c")) {
const sel = termRef.current?.terminal.getSelection();
navigator.clipboard.writeText(sel);
event.preventDefault();
event.stopPropagation();
return false;
}
if (shellProcStatusRef.current != "running" && keyutil.checkKeyPressed(waveEvent, "Enter")) {
// restart
const tabId = globalStore.get(atoms.staticTabId);
const prtn = RpcApi.ControllerResyncCommand(TabRpcClient, { tabid: tabId, blockid: blockId });
prtn.catch((e) => console.log("error controller resync (enter)", blockId, e));
return false;
}
const globalKeys = getAllGlobalKeyBindings();
for (const key of globalKeys) {
if (keyutil.checkKeyPressed(waveEvent, key)) {
return false;
}
}
return true;
}
const fullConfig = globalStore.get(atoms.fullConfigAtom); const fullConfig = globalStore.get(atoms.fullConfigAtom);
const termTheme = computeTheme(fullConfig, blockData?.meta?.["term:theme"]); const termTheme = computeTheme(fullConfig, blockData?.meta?.["term:theme"]);
const themeCopy = { ...termTheme }; const themeCopy = { ...termTheme };
@ -335,7 +289,7 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => {
scrollback: termScrollback, scrollback: termScrollback,
}, },
{ {
keydownHandler: handleTerminalKeydown, keydownHandler: model.handleTerminalKeydown.bind(model),
useWebGl: !termSettings?.["term:disablewebgl"], useWebGl: !termSettings?.["term:disablewebgl"],
} }
); );
@ -352,29 +306,13 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => {
}; };
}, [blockId, termSettings]); }, [blockId, termSettings]);
const handleHtmlKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => { React.useEffect(() => {
const waveEvent = keyutil.adaptFromReactOrNativeKeyEvent(event); if (termModeRef.current == "html" && termMode == "term") {
if (keyutil.checkKeyPressed(waveEvent, "Cmd:Escape")) { // focus the terminal
// reset term:mode model.giveFocus();
RpcApi.SetMetaCommand(TabRpcClient, {
oref: WOS.makeORef("block", blockId),
meta: { "term:mode": null },
});
return false;
}
const asciiVal = keyboardEventToASCII(event);
if (asciiVal.length == 0) {
return false;
}
const b64data = util.stringToBase64(asciiVal);
RpcApi.ControllerInputCommand(TabRpcClient, { blockid: blockId, inputdata64: b64data });
return true;
};
let termMode = blockData?.meta?.["term:mode"] ?? "term";
if (termMode != "term" && termMode != "html") {
termMode = "term";
} }
termModeRef.current = termMode;
}, [termMode]);
// set intitial controller status, and then subscribe for updates // set intitial controller status, and then subscribe for updates
React.useEffect(() => { React.useEffect(() => {
@ -382,7 +320,7 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => {
if (status == null) { if (status == null) {
return; return;
} }
shellProcStatusRef.current = status; model.shellProcStatusRef.current = status;
if (status == "running") { if (status == "running") {
termRef.current?.setIsRunning(true); termRef.current?.setIsRunning(true);
} else { } else {
@ -418,26 +356,9 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => {
<TermThemeUpdater blockId={blockId} termRef={termRef} /> <TermThemeUpdater blockId={blockId} termRef={termRef} />
<TermStickers config={stickerConfig} /> <TermStickers config={stickerConfig} />
<div key="conntectElem" className="term-connectelem" ref={connectElemRef}></div> <div key="conntectElem" className="term-connectelem" ref={connectElemRef}></div>
<div <div key="htmlElem" className="term-htmlelem">
key="htmlElem"
className="term-htmlelem"
onClick={() => {
if (htmlElemFocusRef.current != null) {
htmlElemFocusRef.current.focus();
}
}}
>
<div key="htmlElemFocus" className="term-htmlelem-focus">
<input
type="text"
value={""}
ref={htmlElemFocusRef}
onKeyDown={handleHtmlKeyDown}
onChange={() => {}}
/>
</div>
<div key="htmlElemContent" className="term-htmlelem-content"> <div key="htmlElemContent" className="term-htmlelem-content">
<VDomView rootNode={testVDom} /> <VDomView blockId={blockId} nodeModel={model.nodeModel} viewRef={viewRef} model={model.vdomModel} />
</div> </div>
</div> </div>
</div> </div>

View File

@ -0,0 +1,528 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { globalStore, WOS } from "@/app/store/global";
import { makeORef } from "@/app/store/wos";
import { RpcApi } from "@/app/store/wshclientapi";
import { TabRpcClient } from "@/app/store/wshrpcutil";
import { TermWshClient } from "@/app/view/term/term-wsh";
import { NodeModel } from "@/layout/index";
import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil";
import debug from "debug";
import * as jotai from "jotai";
const dlog = debug("wave:vdom");
type AtomContainer = {
val: any;
beVal: any;
usedBy: Set<string>;
};
type RefContainer = {
refFn: (elem: HTMLElement) => void;
vdomRef: VDomRef;
elem: HTMLElement;
updated: boolean;
};
function makeVDomIdMap(vdom: VDomElem, idMap: Map<string, VDomElem>) {
if (vdom == null) {
return;
}
if (vdom.waveid != null) {
idMap.set(vdom.waveid, vdom);
}
if (vdom.children == null) {
return;
}
for (let child of vdom.children) {
makeVDomIdMap(child, idMap);
}
}
function convertEvent(e: React.SyntheticEvent, fromProp: string): any {
if (e == null) {
return null;
}
if (fromProp == "onClick") {
return { type: "click" };
}
if (fromProp == "onKeyDown") {
const waveKeyEvent = adaptFromReactOrNativeKeyEvent(e as React.KeyboardEvent);
return waveKeyEvent;
}
if (fromProp == "onFocus") {
return { type: "focus" };
}
if (fromProp == "onBlur") {
return { type: "blur" };
}
return { type: "unknown" };
}
export class VDomModel {
blockId: string;
nodeModel: NodeModel;
viewRef: React.RefObject<HTMLDivElement>;
vdomRoot: jotai.PrimitiveAtom<VDomElem> = jotai.atom();
atoms: Map<string, AtomContainer> = new Map(); // key is atomname
refs: Map<string, RefContainer> = new Map(); // key is refid
batchedEvents: VDomEvent[] = [];
messages: VDomMessage[] = [];
needsInitialization: boolean = true;
needsResync: boolean = true;
vdomNodeVersion: WeakMap<VDomElem, jotai.PrimitiveAtom<number>> = new WeakMap();
compoundAtoms: Map<string, jotai.PrimitiveAtom<{ [key: string]: any }>> = new Map();
rootRefId: string = crypto.randomUUID();
termWshClient: TermWshClient;
backendRoute: string;
backendOpts: VDomBackendOpts;
shouldDispose: boolean;
disposed: boolean;
hasPendingRequest: boolean;
needsUpdate: boolean;
maxNormalUpdateIntervalMs: number = 100;
needsImmediateUpdate: boolean;
lastUpdateTs: number = 0;
queuedUpdate: { timeoutId: any; ts: number; quick: boolean };
constructor(
blockId: string,
nodeModel: NodeModel,
viewRef: React.RefObject<HTMLDivElement>,
termWshClient: TermWshClient
) {
this.blockId = blockId;
this.nodeModel = nodeModel;
this.viewRef = viewRef;
this.termWshClient = termWshClient;
this.reset();
}
reset() {
globalStore.set(this.vdomRoot, null);
this.atoms.clear();
this.refs.clear();
this.batchedEvents = [];
this.messages = [];
this.needsResync = true;
this.needsInitialization = true;
this.vdomNodeVersion = new WeakMap();
this.compoundAtoms.clear();
this.rootRefId = crypto.randomUUID();
this.backendRoute = null;
this.backendOpts = {};
this.shouldDispose = false;
this.disposed = false;
this.hasPendingRequest = false;
this.needsUpdate = false;
this.maxNormalUpdateIntervalMs = 100;
this.needsImmediateUpdate = false;
this.lastUpdateTs = 0;
this.queuedUpdate = null;
}
globalKeydownHandler(e: WaveKeyboardEvent): boolean {
if (this.backendOpts?.closeonctrlc && checkKeyPressed(e, "Ctrl:c")) {
this.shouldDispose = true;
this.queueUpdate(true);
return true;
}
if (this.backendOpts?.globalkeyboardevents) {
if (e.cmd || e.meta) {
return false;
}
this.batchedEvents.push({
waveid: null,
propname: "onKeyDown",
eventdata: e,
});
this.queueUpdate();
return true;
}
return false;
}
hasRefUpdates() {
for (let ref of this.refs.values()) {
if (ref.updated) {
return true;
}
}
return false;
}
getRefUpdates(): VDomRefUpdate[] {
let updates: VDomRefUpdate[] = [];
for (let ref of this.refs.values()) {
if (ref.updated || (ref.vdomRef.trackposition && ref.elem != null)) {
const ru: VDomRefUpdate = {
refid: ref.vdomRef.refid,
hascurrent: ref.vdomRef.hascurrent,
};
if (ref.vdomRef.trackposition && ref.elem != null) {
ru.position = {
offsetheight: ref.elem.offsetHeight,
offsetwidth: ref.elem.offsetWidth,
scrollheight: ref.elem.scrollHeight,
scrollwidth: ref.elem.scrollWidth,
scrolltop: ref.elem.scrollTop,
boundingclientrect: ref.elem.getBoundingClientRect(),
};
}
updates.push(ru);
ref.updated = false;
}
}
return updates;
}
queueUpdate(quick: boolean = false, delay: number = 10) {
this.needsUpdate = true;
let nowTs = Date.now();
if (delay > this.maxNormalUpdateIntervalMs) {
delay = this.maxNormalUpdateIntervalMs;
}
if (quick) {
if (this.queuedUpdate) {
if (this.queuedUpdate.quick || this.queuedUpdate.ts <= nowTs) {
return;
}
clearTimeout(this.queuedUpdate.timeoutId);
this.queuedUpdate = null;
}
let timeoutId = setTimeout(() => {
this._sendRenderRequest(true);
}, 0);
this.queuedUpdate = { timeoutId: timeoutId, ts: nowTs, quick: true };
return;
}
if (this.queuedUpdate) {
return;
}
let lastUpdateDiff = nowTs - this.lastUpdateTs;
let timeoutMs: number = null;
if (lastUpdateDiff >= this.maxNormalUpdateIntervalMs) {
// it has been a while since the last update, so use delay
timeoutMs = delay;
} else {
timeoutMs = this.maxNormalUpdateIntervalMs - lastUpdateDiff;
}
if (timeoutMs < delay) {
timeoutMs = delay;
}
let timeoutId = setTimeout(() => {
this._sendRenderRequest(false);
}, timeoutMs);
this.queuedUpdate = { timeoutId: timeoutId, ts: nowTs + timeoutMs, quick: false };
}
async _sendRenderRequest(force: boolean) {
this.queuedUpdate = null;
if (this.disposed) {
return;
}
if (this.hasPendingRequest) {
if (force) {
this.needsImmediateUpdate = true;
}
return;
}
if (!force && !this.needsUpdate) {
return;
}
if (this.backendRoute == null) {
console.log("vdom-model", "no backend route");
return;
}
this.hasPendingRequest = true;
this.needsImmediateUpdate = false;
try {
const feUpdate = this.createFeUpdate();
dlog("fe-update", feUpdate);
const beUpdate = await RpcApi.VDomRenderCommand(TabRpcClient, feUpdate, { route: this.backendRoute });
this.handleBackendUpdate(beUpdate);
} finally {
this.lastUpdateTs = Date.now();
this.hasPendingRequest = false;
}
if (this.needsImmediateUpdate) {
this.queueUpdate(true);
}
}
getAtomContainer(atomName: string): AtomContainer {
let container = this.atoms.get(atomName);
if (container == null) {
container = {
val: null,
beVal: null,
usedBy: new Set(),
};
this.atoms.set(atomName, container);
}
return container;
}
getOrCreateRefContainer(vdomRef: VDomRef): RefContainer {
let container = this.refs.get(vdomRef.refid);
if (container == null) {
container = {
refFn: (elem: HTMLElement) => {
container.elem = elem;
const hasElem = elem != null;
if (vdomRef.hascurrent != hasElem) {
container.updated = true;
vdomRef.hascurrent = hasElem;
}
},
vdomRef: vdomRef,
elem: null,
updated: false,
};
this.refs.set(vdomRef.refid, container);
}
return container;
}
tagUseAtoms(waveId: string, atomNames: Set<string>) {
for (let atomName of atomNames) {
let container = this.getAtomContainer(atomName);
container.usedBy.add(waveId);
}
}
tagUnuseAtoms(waveId: string, atomNames: Set<string>) {
for (let atomName of atomNames) {
let container = this.getAtomContainer(atomName);
container.usedBy.delete(waveId);
}
}
getVDomNodeVersionAtom(vdom: VDomElem) {
let atom = this.vdomNodeVersion.get(vdom);
if (atom == null) {
atom = jotai.atom(0);
this.vdomNodeVersion.set(vdom, atom);
}
return atom;
}
incVDomNodeVersion(vdom: VDomElem) {
if (vdom == null) {
return;
}
const atom = this.getVDomNodeVersionAtom(vdom);
globalStore.set(atom, globalStore.get(atom) + 1);
}
addErrorMessage(message: string) {
this.messages.push({
messagetype: "error",
message: message,
});
}
handleRenderUpdates(update: VDomBackendUpdate, idMap: Map<string, VDomElem>) {
if (!update.renderupdates) {
return;
}
for (let renderUpdate of update.renderupdates) {
if (renderUpdate.updatetype == "root") {
globalStore.set(this.vdomRoot, renderUpdate.vdom);
continue;
}
if (renderUpdate.updatetype == "append") {
let parent = idMap.get(renderUpdate.waveid);
if (parent == null) {
this.addErrorMessage(`Could not find vdom with id ${renderUpdate.waveid} (for renderupdates)`);
continue;
}
if (parent.children == null) {
parent.children = [];
}
parent.children.push(renderUpdate.vdom);
this.incVDomNodeVersion(parent);
continue;
}
if (renderUpdate.updatetype == "replace") {
let parent = idMap.get(renderUpdate.waveid);
if (parent == null) {
this.addErrorMessage(`Could not find vdom with id ${renderUpdate.waveid} (for renderupdates)`);
continue;
}
if (renderUpdate.index < 0 || parent.children == null || parent.children.length <= renderUpdate.index) {
this.addErrorMessage(`Could not find child at index ${renderUpdate.index} (for renderupdates)`);
continue;
}
parent.children[renderUpdate.index] = renderUpdate.vdom;
this.incVDomNodeVersion(parent);
continue;
}
if (renderUpdate.updatetype == "remove") {
let parent = idMap.get(renderUpdate.waveid);
if (parent == null) {
this.addErrorMessage(`Could not find vdom with id ${renderUpdate.waveid} (for renderupdates)`);
continue;
}
if (renderUpdate.index < 0 || parent.children == null || parent.children.length <= renderUpdate.index) {
this.addErrorMessage(`Could not find child at index ${renderUpdate.index} (for renderupdates)`);
continue;
}
parent.children.splice(renderUpdate.index, 1);
this.incVDomNodeVersion(parent);
continue;
}
if (renderUpdate.updatetype == "insert") {
let parent = idMap.get(renderUpdate.waveid);
if (parent == null) {
this.addErrorMessage(`Could not find vdom with id ${renderUpdate.waveid} (for renderupdates)`);
continue;
}
if (parent.children == null) {
parent.children = [];
}
if (renderUpdate.index < 0 || parent.children.length < renderUpdate.index) {
this.addErrorMessage(`Could not find child at index ${renderUpdate.index} (for renderupdates)`);
continue;
}
parent.children.splice(renderUpdate.index, 0, renderUpdate.vdom);
this.incVDomNodeVersion(parent);
continue;
}
this.addErrorMessage(`Unknown updatetype ${renderUpdate.updatetype}`);
}
}
setAtomValue(atomName: string, value: any, fromBe: boolean, idMap: Map<string, VDomElem>) {
dlog("setAtomValue", atomName, value, fromBe);
let container = this.getAtomContainer(atomName);
container.val = value;
if (fromBe) {
container.beVal = value;
}
for (let id of container.usedBy) {
this.incVDomNodeVersion(idMap.get(id));
}
}
handleStateSync(update: VDomBackendUpdate, idMap: Map<string, VDomElem>) {
if (update.statesync == null) {
return;
}
for (let sync of update.statesync) {
this.setAtomValue(sync.atom, sync.value, true, idMap);
}
}
getRefElem(refId: string): HTMLElement {
if (refId == this.rootRefId) {
return this.viewRef.current;
}
const ref = this.refs.get(refId);
return ref?.elem;
}
handleRefOperations(update: VDomBackendUpdate, idMap: Map<string, VDomElem>) {
if (update.refoperations == null) {
return;
}
for (let refOp of update.refoperations) {
const elem = this.getRefElem(refOp.refid);
if (elem == null) {
this.addErrorMessage(`Could not find ref with id ${refOp.refid}`);
continue;
}
if (refOp.op == "focus") {
if (elem == null) {
this.addErrorMessage(`Could not focus ref with id ${refOp.refid}: elem is null`);
continue;
}
try {
elem.focus();
} catch (e) {
this.addErrorMessage(`Could not focus ref with id ${refOp.refid}: ${e.message}`);
}
} else {
this.addErrorMessage(`Unknown ref operation ${refOp.refid} ${refOp.op}`);
}
}
}
handleBackendUpdate(update: VDomBackendUpdate) {
if (update == null) {
return;
}
const idMap = new Map<string, VDomElem>();
const vdomRoot = globalStore.get(this.vdomRoot);
if (update.opts != null) {
this.backendOpts = update.opts;
}
makeVDomIdMap(vdomRoot, idMap);
this.handleRenderUpdates(update, idMap);
this.handleStateSync(update, idMap);
this.handleRefOperations(update, idMap);
if (update.messages) {
for (let message of update.messages) {
console.log("vdom-message", this.blockId, message.messagetype, message.message);
if (message.stacktrace) {
console.log("vdom-message-stacktrace", message.stacktrace);
}
}
}
}
callVDomFunc(fnDecl: VDomFunc, e: any, compId: string, propName: string) {
const eventData = convertEvent(e, propName);
if (fnDecl.globalevent) {
const waveEvent: VDomEvent = {
waveid: null,
propname: fnDecl.globalevent,
eventdata: eventData,
};
this.batchedEvents.push(waveEvent);
} else {
const vdomEvent: VDomEvent = {
waveid: compId,
propname: propName,
eventdata: eventData,
};
this.batchedEvents.push(vdomEvent);
}
this.queueUpdate();
}
createFeUpdate(): VDomFrontendUpdate {
const blockORef = makeORef("block", this.blockId);
const blockAtom = WOS.getWaveObjectAtom<Block>(blockORef);
const blockData = globalStore.get(blockAtom);
const isBlockFocused = globalStore.get(this.nodeModel.isFocused);
const renderContext: VDomRenderContext = {
blockid: this.blockId,
focused: isBlockFocused,
width: this.viewRef?.current?.offsetWidth ?? 0,
height: this.viewRef?.current?.offsetHeight ?? 0,
rootrefid: this.rootRefId,
background: false,
};
const feUpdate: VDomFrontendUpdate = {
type: "frontendupdate",
ts: Date.now(),
blockid: this.blockId,
initialize: this.needsInitialization,
rendercontext: renderContext,
dispose: this.shouldDispose,
resync: this.needsResync,
events: this.batchedEvents,
refupdates: this.getRefUpdates(),
};
this.needsResync = false;
this.needsInitialization = false;
this.batchedEvents = [];
if (this.shouldDispose) {
this.disposed = true;
}
return feUpdate;
}
}

View File

@ -1,9 +1,25 @@
// Copyright 2024, Command Line Inc. // Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
import { VDomModel } from "@/app/view/term/vdom-model";
import { NodeModel } from "@/layout/index";
import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil"; import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil";
import { useAtomValueSafe } from "@/util/util";
import debug from "debug";
import * as jotai from "jotai";
import * as React from "react"; import * as React from "react";
const TextTag = "#text";
const FragmentTag = "#fragment";
const WaveTextTag = "wave:text";
const WaveNullTag = "wave:null";
const VDomObjType_Ref = "ref";
const VDomObjType_Binding = "binding";
const VDomObjType_Func = "func";
const dlog = debug("wave:vdom");
const AllowedTags: { [tagName: string]: boolean } = { const AllowedTags: { [tagName: string]: boolean } = {
div: true, div: true,
b: true, b: true,
@ -30,38 +46,38 @@ const AllowedTags: { [tagName: string]: boolean } = {
form: true, form: true,
}; };
function convertVDomFunc(fnDecl: VDomFuncType, compId: string, propName: string): (e: any) => void { function convertVDomFunc(model: VDomModel, fnDecl: VDomFunc, compId: string, propName: string): (e: any) => void {
return (e: any) => { return (e: any) => {
if ((propName == "onKeyDown" || propName == "onKeyDownCapture") && fnDecl["#keys"]) { if ((propName == "onKeyDown" || propName == "onKeyDownCapture") && fnDecl["#keys"]) {
let waveEvent = adaptFromReactOrNativeKeyEvent(e); let waveEvent = adaptFromReactOrNativeKeyEvent(e);
for (let keyDesc of fnDecl["#keys"]) { for (let keyDesc of fnDecl.keys || []) {
if (checkKeyPressed(waveEvent, keyDesc)) { if (checkKeyPressed(waveEvent, keyDesc)) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
callFunc(e, compId, propName); model.callVDomFunc(fnDecl, e, compId, propName);
return; return;
} }
} }
return; return;
} }
if (fnDecl["#preventDefault"]) { if (fnDecl.preventdefault) {
e.preventDefault(); e.preventDefault();
} }
if (fnDecl["#stopPropagation"]) { if (fnDecl.stoppropagation) {
e.stopPropagation(); e.stopPropagation();
} }
callFunc(e, compId, propName); model.callVDomFunc(fnDecl, e, compId, propName);
}; };
} }
function convertElemToTag(elem: VDomElem): JSX.Element | string { function convertElemToTag(elem: VDomElem, model: VDomModel): JSX.Element | string {
if (elem == null) { if (elem == null) {
return null; return null;
} }
if (elem.tag == "#text") { if (elem.tag == TextTag) {
return elem.text; return elem.text;
} }
return React.createElement(VDomTag, { elem: elem, key: elem.id }); return React.createElement(VDomTag, { key: elem.waveid, elem, model });
} }
function isObject(v: any): boolean { function isObject(v: any): boolean {
@ -72,19 +88,35 @@ function isArray(v: any): boolean {
return Array.isArray(v); return Array.isArray(v);
} }
function callFunc(e: any, compId: string, propName: string) { function resolveBinding(binding: VDomBinding, model: VDomModel): [any, string[]] {
console.log("callfunc", compId, propName); const bindName = binding.bind;
if (bindName == null || bindName == "") {
return [null, []];
}
// for now we only recognize $.[atomname] bindings
if (!bindName.startsWith("$.")) {
return [null, []];
}
const atomName = bindName.substring(2);
if (atomName == "") {
return [null, []];
}
const atom = model.getAtomContainer(atomName);
if (atom == null) {
return [null, []];
}
return [atom.val, [atomName]];
} }
function updateRefFunc(elem: any, ref: VDomRefType) { type GenericPropsType = { [key: string]: any };
console.log("updateref", ref["#ref"], elem);
}
function VDomTag({ elem }: { elem: VDomElem }) { // returns props, and a set of atom keys used in the props
if (!AllowedTags[elem.tag]) { function convertProps(elem: VDomElem, model: VDomModel): [GenericPropsType, Set<string>] {
return <div>{"Invalid Tag <" + elem.tag + ">"}</div>; let props: GenericPropsType = {};
let atomKeys = new Set<string>();
if (elem.props == null) {
return [props, atomKeys];
} }
let props = {};
for (let key in elem.props) { for (let key in elem.props) {
let val = elem.props[key]; let val = elem.props[key];
if (val == null) { if (val == null) {
@ -94,35 +126,149 @@ function VDomTag({ elem }: { elem: VDomElem }) {
if (val == null) { if (val == null) {
continue; continue;
} }
if (isObject(val) && "#ref" in val) { if (isObject(val) && val.type == VDomObjType_Ref) {
props[key] = (elem: HTMLElement) => { const valRef = val as VDomRef;
updateRefFunc(elem, val); const refContainer = model.getOrCreateRefContainer(valRef);
}; props[key] = refContainer.refFn;
} }
continue; continue;
} }
if (isObject(val) && "#func" in val) { if (isObject(val) && val.type == VDomObjType_Func) {
props[key] = convertVDomFunc(val, elem.id, key); const valFunc = val as VDomFunc;
props[key] = convertVDomFunc(model, valFunc, elem.waveid, key);
continue; continue;
} }
if (isObject(val) && val.type == VDomObjType_Binding) {
const [propVal, atomDeps] = resolveBinding(val as VDomBinding, model);
props[key] = propVal;
for (let atomDep of atomDeps) {
atomKeys.add(atomDep);
} }
continue;
}
if (key == "style" && isObject(val)) {
// assuming the entire style prop wasn't bound, look through the individual keys and bind them
for (let styleKey in val) {
let styleVal = val[styleKey];
if (isObject(styleVal) && styleVal.type == VDomObjType_Binding) {
const [stylePropVal, styleAtomDeps] = resolveBinding(styleVal as VDomBinding, model);
val[styleKey] = stylePropVal;
for (let styleAtomDep of styleAtomDeps) {
atomKeys.add(styleAtomDep);
}
}
}
// fallthrough to set props[key] = val
}
props[key] = val;
}
return [props, atomKeys];
}
function convertChildren(elem: VDomElem, model: VDomModel): (string | JSX.Element)[] {
let childrenComps: (string | JSX.Element)[] = []; let childrenComps: (string | JSX.Element)[] = [];
if (elem.children) { if (elem.children == null) {
return childrenComps;
}
for (let child of elem.children) { for (let child of elem.children) {
if (child == null) { if (child == null) {
continue; continue;
} }
childrenComps.push(convertElemToTag(child)); childrenComps.push(convertElemToTag(child, model));
} }
}
if (elem.tag == "#fragment") {
return childrenComps; return childrenComps;
} }
function stringSetsEqual(set1: Set<string>, set2: Set<string>): boolean {
if (set1.size != set2.size) {
return false;
}
for (let elem of set1) {
if (!set2.has(elem)) {
return false;
}
}
return true;
}
function VDomTag({ elem, model }: { elem: VDomElem; model: VDomModel }) {
const version = jotai.useAtomValue(model.getVDomNodeVersionAtom(elem));
const [oldAtomKeys, setOldAtomKeys] = React.useState<Set<string>>(new Set());
let [props, atomKeys] = convertProps(elem, model);
React.useEffect(() => {
if (stringSetsEqual(atomKeys, oldAtomKeys)) {
return;
}
model.tagUnuseAtoms(elem.waveid, oldAtomKeys);
model.tagUseAtoms(elem.waveid, atomKeys);
setOldAtomKeys(atomKeys);
}, [atomKeys]);
React.useEffect(() => {
return () => {
model.tagUnuseAtoms(elem.waveid, oldAtomKeys);
};
}, []);
if (elem.tag == WaveNullTag) {
return null;
}
if (elem.tag == WaveTextTag) {
return props.text;
}
if (!AllowedTags[elem.tag]) {
return <div>{"Invalid Tag <" + elem.tag + ">"}</div>;
}
let childrenComps = convertChildren(elem, model);
dlog("children", childrenComps);
if (elem.tag == FragmentTag) {
return childrenComps;
}
props.key = "e-" + elem.waveid;
return React.createElement(elem.tag, props, childrenComps); return React.createElement(elem.tag, props, childrenComps);
} }
function VDomView({ rootNode }: { rootNode: VDomElem }) { function vdomText(text: string): VDomElem {
let rtn = convertElemToTag(rootNode); return {
tag: "#text",
text: text,
};
}
const testVDom: VDomElem = {
waveid: "testid1",
tag: "div",
children: [
{
waveid: "testh1",
tag: "h1",
children: [vdomText("Hello World")],
},
{
waveid: "testp",
tag: "p",
children: [vdomText("This is a paragraph (from VDOM)")],
},
],
};
function VDomView({
blockId,
nodeModel,
viewRef,
model,
}: {
blockId: string;
nodeModel: NodeModel;
viewRef: React.RefObject<HTMLDivElement>;
model: VDomModel;
}) {
let rootNode = useAtomValueSafe(model?.vdomRoot);
if (!model || viewRef.current == null || rootNode == null) {
return null;
}
dlog("render", rootNode);
model.viewRef = viewRef;
let rtn = convertElemToTag(rootNode, model);
return <div className="vdom">{rtn}</div>; return <div className="vdom">{rtn}</div>;
} }

View File

@ -274,6 +274,7 @@ declare global {
getSettingsMenuItems?: () => ContextMenuItem[]; getSettingsMenuItems?: () => ContextMenuItem[];
giveFocus?: () => boolean; giveFocus?: () => boolean;
keyDownHandler?: (e: WaveKeyboardEvent) => boolean; keyDownHandler?: (e: WaveKeyboardEvent) => boolean;
dispose?: () => void;
} }
type UpdaterStatus = "up-to-date" | "checking" | "downloading" | "ready" | "error" | "installing"; type UpdaterStatus = "up-to-date" | "checking" | "downloading" | "ready" | "error" | "installing";

View File

@ -192,6 +192,16 @@ declare global {
count: number; count: number;
}; };
// vdom.DomRect
type DomRect = {
top: number;
left: number;
right: number;
bottom: number;
width: number;
height: number;
};
// waveobj.FileDef // waveobj.FileDef
type FileDef = { type FileDef = {
filetype?: string; filetype?: string;
@ -324,6 +334,9 @@ declare global {
"term:localshellpath"?: string; "term:localshellpath"?: string;
"term:localshellopts"?: string[]; "term:localshellopts"?: string[];
"term:scrollback"?: number; "term:scrollback"?: number;
"vdom:*"?: boolean;
"vdom:initialized"?: boolean;
"vdom:correlationid"?: string;
count?: number; count?: number;
}; };
@ -588,27 +601,150 @@ declare global {
checkboxstat?: boolean; checkboxstat?: boolean;
}; };
// vdom.Elem // vdom.VDomAsyncInitiationRequest
type VDomAsyncInitiationRequest = {
type: "asyncinitiationrequest";
ts: number;
blockid?: string;
};
// vdom.VDomBackendOpts
type VDomBackendOpts = {
closeonctrlc?: boolean;
globalkeyboardevents?: boolean;
};
// vdom.VDomBackendUpdate
type VDomBackendUpdate = {
type: "backendupdate";
ts: number;
blockid: string;
opts?: VDomBackendOpts;
renderupdates?: VDomRenderUpdate[];
statesync?: VDomStateSync[];
refoperations?: VDomRefOperation[];
messages?: VDomMessage[];
};
// vdom.VDomBinding
type VDomBinding = {
type: "binding";
bind: string;
};
// vdom.VDomCreateContext
type VDomCreateContext = {
type: "createcontext";
ts: number;
meta?: MetaType;
newblock?: boolean;
persist?: boolean;
};
// vdom.VDomElem
type VDomElem = { type VDomElem = {
id?: string; waveid?: string;
tag: string; tag: string;
props?: {[key: string]: any}; props?: {[key: string]: any};
children?: VDomElem[]; children?: VDomElem[];
text?: string; text?: string;
}; };
// vdom.VDomFuncType // vdom.VDomEvent
type VDomFuncType = { type VDomEvent = {
#func: string; waveid: string;
#stopPropagation?: boolean; propname: string;
#preventDefault?: boolean; eventdata: any;
#keys?: string[];
}; };
// vdom.VDomRefType // vdom.VDomFrontendUpdate
type VDomRefType = { type VDomFrontendUpdate = {
#ref: string; type: "frontendupdate";
current: any; ts: number;
blockid: string;
correlationid?: string;
initialize?: boolean;
dispose?: boolean;
resync?: boolean;
rendercontext?: VDomRenderContext;
events?: VDomEvent[];
statesync?: VDomStateSync[];
refupdates?: VDomRefUpdate[];
messages?: VDomMessage[];
};
// vdom.VDomFunc
type VDomFunc = {
type: "func";
stoppropagation?: boolean;
preventdefault?: boolean;
globalevent?: string;
keys?: string[];
};
// vdom.VDomMessage
type VDomMessage = {
messagetype: string;
message: string;
stacktrace?: string;
params?: any[];
};
// vdom.VDomRef
type VDomRef = {
type: "ref";
refid: string;
trackposition?: boolean;
position?: VDomRefPosition;
hascurrent?: boolean;
};
// vdom.VDomRefOperation
type VDomRefOperation = {
refid: string;
op: string;
params?: any[];
};
// vdom.VDomRefPosition
type VDomRefPosition = {
offsetheight: number;
offsetwidth: number;
scrollheight: number;
scrollwidth: number;
scrolltop: number;
boundingclientrect: DomRect;
};
// vdom.VDomRefUpdate
type VDomRefUpdate = {
refid: string;
hascurrent: boolean;
position?: VDomRefPosition;
};
// vdom.VDomRenderContext
type VDomRenderContext = {
blockid: string;
focused: boolean;
width: number;
height: number;
rootrefid: string;
background?: boolean;
};
// vdom.VDomRenderUpdate
type VDomRenderUpdate = {
updatetype: "root"|"append"|"replace"|"remove"|"insert";
waveid?: string;
vdom: VDomElem;
index?: number;
};
// vdom.VDomStateSync
type VDomStateSync = {
atom: string;
value: any;
}; };
type WSCommandType = { type WSCommandType = {

View File

@ -241,6 +241,56 @@ function adaptFromElectronKeyEvent(event: any): WaveKeyboardEvent {
return rtn; return rtn;
} }
const keyMap = {
Enter: "\r",
Backspace: "\x7f",
Tab: "\t",
Escape: "\x1b",
ArrowUp: "\x1b[A",
ArrowDown: "\x1b[B",
ArrowRight: "\x1b[C",
ArrowLeft: "\x1b[D",
Insert: "\x1b[2~",
Delete: "\x1b[3~",
Home: "\x1b[1~",
End: "\x1b[4~",
PageUp: "\x1b[5~",
PageDown: "\x1b[6~",
};
function keyboardEventToASCII(event: WaveKeyboardEvent): string {
// check modifiers
// if no modifiers are set, just send the key
if (!event.alt && !event.control && !event.meta) {
if (event.key == null || event.key == "") {
return "";
}
if (keyMap[event.key] != null) {
return keyMap[event.key];
}
if (event.key.length == 1) {
return event.key;
} else {
console.log("not sending keyboard event", event.key, event);
}
}
// if meta or alt is set, there is no ASCII representation
if (event.meta || event.alt) {
return "";
}
// if ctrl is set, if it is a letter, subtract 64 from the uppercase value to get the ASCII value
if (event.control) {
if (
(event.key.length === 1 && event.key >= "A" && event.key <= "Z") ||
(event.key >= "a" && event.key <= "z")
) {
const key = event.key.toUpperCase();
return String.fromCharCode(key.charCodeAt(0) - 64);
}
}
return "";
}
export { export {
adaptFromElectronKeyEvent, adaptFromElectronKeyEvent,
adaptFromReactOrNativeKeyEvent, adaptFromReactOrNativeKeyEvent,
@ -248,6 +298,7 @@ export {
getKeyUtilPlatform, getKeyUtilPlatform,
isCharacterKeyEvent, isCharacterKeyEvent,
isInputEvent, isInputEvent,
keyboardEventToASCII,
keydownWrapper, keydownWrapper,
parseKeyDescription, parseKeyDescription,
setKeyUtilPlatform, setKeyUtilPlatform,

View File

@ -42,9 +42,13 @@ var ExtraTypes = []any{
wshutil.RpcMessage{}, wshutil.RpcMessage{},
wshrpc.WshServerCommandMeta{}, wshrpc.WshServerCommandMeta{},
userinput.UserInputRequest{}, userinput.UserInputRequest{},
vdom.Elem{}, vdom.VDomCreateContext{},
vdom.VDomFuncType{}, vdom.VDomElem{},
vdom.VDomRefType{}, vdom.VDomFunc{},
vdom.VDomRef{},
vdom.VDomBinding{},
vdom.VDomFrontendUpdate{},
vdom.VDomBackendUpdate{},
waveobj.MetaTSType{}, waveobj.MetaTSType{},
} }

View File

@ -0,0 +1,89 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
package utilfn
import (
"reflect"
)
// this is a shallow equal, but with special handling for numeric types
// it will up convert to float64 and compare
func JsonValEqual(a, b any) bool {
if a == nil && b == nil {
return true
}
if a == nil || b == nil {
return false
}
typeA := reflect.TypeOf(a)
typeB := reflect.TypeOf(b)
if typeA == typeB && typeA.Comparable() {
return a == b
}
if IsNumericType(a) && IsNumericType(b) {
return CompareAsFloat64(a, b)
}
if typeA != typeB {
return false
}
// for slices and maps, compare their pointers
valA := reflect.ValueOf(a)
valB := reflect.ValueOf(b)
switch valA.Kind() {
case reflect.Slice, reflect.Map:
return valA.Pointer() == valB.Pointer()
}
return false
}
// Helper to check if a value is a numeric type
func IsNumericType(val any) bool {
switch val.(type) {
case int, int8, int16, int32, int64,
uint, uint8, uint16, uint32, uint64,
float32, float64:
return true
default:
return false
}
}
// Helper to handle numeric comparisons as float64
func CompareAsFloat64(a, b any) bool {
valA, okA := ToFloat64(a)
valB, okB := ToFloat64(b)
return okA && okB && valA == valB
}
// Convert various numeric types to float64 for comparison
func ToFloat64(val any) (float64, bool) {
switch v := val.(type) {
case int:
return float64(v), true
case int8:
return float64(v), true
case int16:
return float64(v), true
case int32:
return float64(v), true
case int64:
return float64(v), true
case uint:
return float64(v), true
case uint8:
return float64(v), true
case uint16:
return float64(v), true
case uint32:
return float64(v), true
case uint64:
return float64(v), true
case float32:
return float64(v), true
case float64:
return v, true
default:
return 0, false
}
}

View File

@ -0,0 +1,159 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
package cssparser
import (
"fmt"
"strings"
"unicode"
)
type Parser struct {
Input string
Pos int
Length int
InQuote bool
QuoteChar rune
OpenParens int
Debug bool
}
func MakeParser(input string) *Parser {
return &Parser{
Input: input,
Length: len(input),
}
}
func (p *Parser) Parse() (map[string]string, error) {
result := make(map[string]string)
lastProp := ""
for {
p.skipWhitespace()
if p.eof() {
break
}
propName, err := p.parseIdentifierColon(lastProp)
if err != nil {
return nil, err
}
lastProp = propName
p.skipWhitespace()
value, err := p.parseValue(propName)
if err != nil {
return nil, err
}
result[propName] = value
p.skipWhitespace()
if p.eof() {
break
}
if !p.expectChar(';') {
break
}
}
p.skipWhitespace()
if !p.eof() {
return nil, fmt.Errorf("bad style attribute, unexpected character %q at pos %d", string(p.Input[p.Pos]), p.Pos+1)
}
return result, nil
}
func (p *Parser) parseIdentifierColon(lastProp string) (string, error) {
start := p.Pos
for !p.eof() {
c := p.peekChar()
if isIdentChar(c) || c == '-' {
p.advance()
} else {
break
}
}
attrName := p.Input[start:p.Pos]
p.skipWhitespace()
if p.eof() {
return "", fmt.Errorf("bad style attribute, expected colon after property %q, got EOF, at pos %d", attrName, p.Pos+1)
}
if attrName == "" {
return "", fmt.Errorf("bad style attribute, invalid property name after property %q, at pos %d", lastProp, p.Pos+1)
}
if !p.expectChar(':') {
return "", fmt.Errorf("bad style attribute, bad property name starting with %q, expected colon, got %q, at pos %d", attrName, string(p.Input[p.Pos]), p.Pos+1)
}
return attrName, nil
}
func (p *Parser) parseValue(propName string) (string, error) {
start := p.Pos
quotePos := 0
parenPosStack := make([]int, 0)
for !p.eof() {
c := p.peekChar()
if p.InQuote {
if c == p.QuoteChar {
p.InQuote = false
} else if c == '\\' {
p.advance()
}
} else {
if c == '"' || c == '\'' {
p.InQuote = true
p.QuoteChar = c
quotePos = p.Pos
} else if c == '(' {
p.OpenParens++
parenPosStack = append(parenPosStack, p.Pos)
} else if c == ')' {
if p.OpenParens == 0 {
return "", fmt.Errorf("unmatched ')' at pos %d", p.Pos+1)
}
p.OpenParens--
parenPosStack = parenPosStack[:len(parenPosStack)-1]
} else if c == ';' && p.OpenParens == 0 {
break
}
}
p.advance()
}
if p.eof() && p.InQuote {
return "", fmt.Errorf("bad style attribute, while parsing attribute %q, unmatched quote at pos %d", propName, quotePos+1)
}
if p.eof() && p.OpenParens > 0 {
return "", fmt.Errorf("bad style attribute, while parsing property %q, unmatched '(' at pos %d", propName, parenPosStack[len(parenPosStack)-1]+1)
}
return strings.TrimSpace(p.Input[start:p.Pos]), nil
}
func isIdentChar(r rune) bool {
return unicode.IsLetter(r) || unicode.IsDigit(r)
}
func (p *Parser) skipWhitespace() {
for !p.eof() && unicode.IsSpace(p.peekChar()) {
p.advance()
}
}
func (p *Parser) expectChar(expected rune) bool {
if !p.eof() && p.peekChar() == expected {
p.advance()
return true
}
return false
}
func (p *Parser) peekChar() rune {
if p.Pos >= p.Length {
return 0
}
return rune(p.Input[p.Pos])
}
func (p *Parser) advance() {
p.Pos++
}
func (p *Parser) eof() bool {
return p.Pos >= p.Length
}

View File

@ -0,0 +1,81 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
package cssparser
import (
"fmt"
"log"
"testing"
)
func compareMaps(a, b map[string]string) error {
if len(a) != len(b) {
return fmt.Errorf("map length mismatch: %d != %d", len(a), len(b))
}
for k, v := range a {
if b[k] != v {
return fmt.Errorf("value mismatch for key %s: %q != %q", k, v, b[k])
}
}
return nil
}
func TestParse1(t *testing.T) {
style := `background: url("example;with;semicolons.jpg"); color: red; margin-right: 5px; content: "hello;world";`
p := MakeParser(style)
parsed, err := p.Parse()
if err != nil {
t.Fatalf("Parse failed: %v", err)
return
}
expected := map[string]string{
"background": `url("example;with;semicolons.jpg")`,
"color": "red",
"margin-right": "5px",
"content": `"hello;world"`,
}
if err := compareMaps(parsed, expected); err != nil {
t.Fatalf("Parsed map does not match expected: %v", err)
}
style = `margin-right: calc(10px + 5px); color: red; font-family: "Arial";`
p = MakeParser(style)
parsed, err = p.Parse()
if err != nil {
t.Fatalf("Parse failed: %v", err)
return
}
expected = map[string]string{
"margin-right": `calc(10px + 5px)`,
"color": "red",
"font-family": `"Arial"`,
}
if err := compareMaps(parsed, expected); err != nil {
t.Fatalf("Parsed map does not match expected: %v", err)
}
}
func TestParserErrors(t *testing.T) {
style := `hello more: bad;`
p := MakeParser(style)
_, err := p.Parse()
if err == nil {
t.Fatalf("expected error, got nil")
}
log.Printf("got expected error: %v\n", err)
style = `background: url("example.jpg`
p = MakeParser(style)
_, err = p.Parse()
if err == nil {
t.Fatalf("expected error, got nil")
}
log.Printf("got expected error: %v\n", err)
style = `foo: url(...`
p = MakeParser(style)
_, err = p.Parse()
if err == nil {
t.Fatalf("expected error, got nil")
}
log.Printf("got expected error: %v\n", err)
}

View File

@ -15,35 +15,6 @@ import (
// ReactNode types = nil | string | Elem // ReactNode types = nil | string | Elem
const TextTag = "#text"
const FragmentTag = "#fragment"
const ChildrenPropKey = "children"
const KeyPropKey = "key"
// doubles as VDOM structure
type Elem struct {
Id string `json:"id,omitempty"` // used for vdom
Tag string `json:"tag"`
Props map[string]any `json:"props,omitempty"`
Children []Elem `json:"children,omitempty"`
Text string `json:"text,omitempty"`
}
type VDomRefType struct {
RefId string `json:"#ref"`
Current any `json:"current"`
}
// can be used to set preventDefault/stopPropagation
type VDomFuncType struct {
Fn any `json:"-"` // the actual function to call (called via reflection)
FuncType string `json:"#func"`
StopPropagation bool `json:"#stopPropagation,omitempty"`
PreventDefault bool `json:"#preventDefault,omitempty"`
Keys []string `json:"#keys,omitempty"` // special for keyDown events a list of keys to "capture"
}
// generic hook structure // generic hook structure
type Hook struct { type Hook struct {
Init bool // is initialized Init bool // is initialized
@ -56,7 +27,7 @@ type Hook struct {
type CFunc = func(ctx context.Context, props map[string]any) any type CFunc = func(ctx context.Context, props map[string]any) any
func (e *Elem) Key() string { func (e *VDomElem) Key() string {
keyVal, ok := e.Props[KeyPropKey] keyVal, ok := e.Props[KeyPropKey]
if !ok { if !ok {
return "" return ""
@ -68,8 +39,8 @@ func (e *Elem) Key() string {
return "" return ""
} }
func TextElem(text string) Elem { func TextElem(text string) VDomElem {
return Elem{Tag: TextTag, Text: text} return VDomElem{Tag: TextTag, Text: text}
} }
func mergeProps(props *map[string]any, newProps map[string]any) { func mergeProps(props *map[string]any, newProps map[string]any) {
@ -85,8 +56,8 @@ func mergeProps(props *map[string]any, newProps map[string]any) {
} }
} }
func E(tag string, parts ...any) *Elem { func E(tag string, parts ...any) *VDomElem {
rtn := &Elem{Tag: tag} rtn := &VDomElem{Tag: tag}
for _, part := range parts { for _, part := range parts {
if part == nil { if part == nil {
continue continue
@ -135,19 +106,44 @@ func UseState[T any](ctx context.Context, initialVal T) (T, func(T)) {
} }
setVal := func(newVal T) { setVal := func(newVal T) {
hookVal.Val = newVal hookVal.Val = newVal
vc.Root.AddRenderWork(vc.Comp.Id) vc.Root.AddRenderWork(vc.Comp.WaveId)
} }
return rtnVal, setVal return rtnVal, setVal
} }
func UseRef(ctx context.Context, initialVal any) *VDomRefType { func UseAtom[T any](ctx context.Context, atomName string) (T, func(T)) {
vc, hookVal := getHookFromCtx(ctx) vc, hookVal := getHookFromCtx(ctx)
if !hookVal.Init { if !hookVal.Init {
hookVal.Init = true hookVal.Init = true
refId := vc.Comp.Id + ":" + strconv.Itoa(hookVal.Idx) closedWaveId := vc.Comp.WaveId
hookVal.Val = &VDomRefType{RefId: refId, Current: initialVal} hookVal.UnmountFn = func() {
atom := vc.Root.GetAtom(atomName)
delete(atom.UsedBy, closedWaveId)
} }
refVal, ok := hookVal.Val.(*VDomRefType) }
atom := vc.Root.GetAtom(atomName)
atom.UsedBy[vc.Comp.WaveId] = true
atomVal, ok := atom.Val.(T)
if !ok {
panic(fmt.Sprintf("UseAtom %q value type mismatch (expected %T, got %T)", atomName, atomVal, atom.Val))
}
setVal := func(newVal T) {
atom.Val = newVal
for waveId := range atom.UsedBy {
vc.Root.AddRenderWork(waveId)
}
}
return atomVal, setVal
}
func UseVDomRef(ctx context.Context) *VDomRef {
vc, hookVal := getHookFromCtx(ctx)
if !hookVal.Init {
hookVal.Init = true
refId := vc.Comp.WaveId + ":" + strconv.Itoa(hookVal.Idx)
hookVal.Val = &VDomRef{Type: ObjectType_Ref, RefId: refId}
}
refVal, ok := hookVal.Val.(*VDomRef)
if !ok { if !ok {
panic("UseRef hook value is not a ref (possible out of order or conditional hooks)") panic("UseRef hook value is not a ref (possible out of order or conditional hooks)")
} }
@ -159,7 +155,7 @@ func UseId(ctx context.Context) string {
if vc == nil { if vc == nil {
panic("UseId must be called within a component (no context)") panic("UseId must be called within a component (no context)")
} }
return vc.Comp.Id return vc.Comp.WaveId
} }
func depsEqual(deps1 []any, deps2 []any) bool { func depsEqual(deps1 []any, deps2 []any) bool {
@ -181,7 +177,7 @@ func UseEffect(ctx context.Context, fn func() func(), deps []any) {
hookVal.Init = true hookVal.Init = true
hookVal.Fn = fn hookVal.Fn = fn
hookVal.Deps = deps hookVal.Deps = deps
vc.Root.AddEffectWork(vc.Comp.Id, hookVal.Idx) vc.Root.AddEffectWork(vc.Comp.WaveId, hookVal.Idx)
return return
} }
if depsEqual(hookVal.Deps, deps) { if depsEqual(hookVal.Deps, deps) {
@ -189,7 +185,7 @@ func UseEffect(ctx context.Context, fn func() func(), deps []any) {
} }
hookVal.Fn = fn hookVal.Fn = fn
hookVal.Deps = deps hookVal.Deps = deps
vc.Root.AddEffectWork(vc.Comp.Id, hookVal.Idx) vc.Root.AddEffectWork(vc.Comp.WaveId, hookVal.Idx)
} }
func numToString[T any](value T) (string, bool) { func numToString[T any](value T) (string, bool) {
@ -207,24 +203,24 @@ func numToString[T any](value T) (string, bool) {
} }
} }
func partToElems(part any) []Elem { func partToElems(part any) []VDomElem {
if part == nil { if part == nil {
return nil return nil
} }
switch part := part.(type) { switch part := part.(type) {
case string: case string:
return []Elem{TextElem(part)} return []VDomElem{TextElem(part)}
case *Elem: case *VDomElem:
if part == nil { if part == nil {
return nil return nil
} }
return []Elem{*part} return []VDomElem{*part}
case Elem: case VDomElem:
return []Elem{part} return []VDomElem{part}
case []Elem: case []VDomElem:
return part return part
case []*Elem: case []*VDomElem:
var rtn []Elem var rtn []VDomElem
for _, e := range part { for _, e := range part {
if e == nil { if e == nil {
continue continue
@ -235,11 +231,11 @@ func partToElems(part any) []Elem {
} }
sval, ok := numToString(part) sval, ok := numToString(part)
if ok { if ok {
return []Elem{TextElem(sval)} return []VDomElem{TextElem(sval)}
} }
partVal := reflect.ValueOf(part) partVal := reflect.ValueOf(part)
if partVal.Kind() == reflect.Slice { if partVal.Kind() == reflect.Slice {
var rtn []Elem var rtn []VDomElem
for i := 0; i < partVal.Len(); i++ { for i := 0; i < partVal.Len(); i++ {
subPart := partVal.Index(i).Interface() subPart := partVal.Index(i).Interface()
rtn = append(rtn, partToElems(subPart)...) rtn = append(rtn, partToElems(subPart)...)
@ -248,14 +244,14 @@ func partToElems(part any) []Elem {
} }
stringer, ok := part.(fmt.Stringer) stringer, ok := part.(fmt.Stringer)
if ok { if ok {
return []Elem{TextElem(stringer.String())} return []VDomElem{TextElem(stringer.String())}
} }
jsonStr, jsonErr := json.Marshal(part) jsonStr, jsonErr := json.Marshal(part)
if jsonErr == nil { if jsonErr == nil {
return []Elem{TextElem(string(jsonStr))} return []VDomElem{TextElem(string(jsonStr))}
} }
typeText := "invalid:" + reflect.TypeOf(part).String() typeText := "invalid:" + reflect.TypeOf(part).String()
return []Elem{TextElem(typeText)} return []VDomElem{TextElem(typeText)}
} }
func isWaveTag(tag string) bool { func isWaveTag(tag string) bool {

View File

@ -13,10 +13,10 @@ type ChildKey struct {
} }
type Component struct { type Component struct {
Id string WaveId string
Tag string Tag string
Key string Key string
Elem *Elem Elem *VDomElem
Mounted bool Mounted bool
// hooks // hooks

View File

@ -10,11 +10,18 @@ import (
"strings" "strings"
"github.com/wavetermdev/htmltoken" "github.com/wavetermdev/htmltoken"
"github.com/wavetermdev/waveterm/pkg/vdom/cssparser"
) )
// can tokenize and bind HTML to Elems // can tokenize and bind HTML to Elems
func appendChildToStack(stack []*Elem, child *Elem) { const Html_BindPrefix = "#bind:"
const Html_ParamPrefix = "#param:"
const Html_GlobalEventPrefix = "#globalevent"
const Html_BindParamTagName = "bindparam"
const Html_BindTagName = "bind"
func appendChildToStack(stack []*VDomElem, child *VDomElem) {
if child == nil { if child == nil {
return return
} }
@ -25,14 +32,14 @@ func appendChildToStack(stack []*Elem, child *Elem) {
parent.Children = append(parent.Children, *child) parent.Children = append(parent.Children, *child)
} }
func pushElemStack(stack []*Elem, elem *Elem) []*Elem { func pushElemStack(stack []*VDomElem, elem *VDomElem) []*VDomElem {
if elem == nil { if elem == nil {
return stack return stack
} }
return append(stack, elem) return append(stack, elem)
} }
func popElemStack(stack []*Elem) []*Elem { func popElemStack(stack []*VDomElem) []*VDomElem {
if len(stack) <= 1 { if len(stack) <= 1 {
return stack return stack
} }
@ -41,14 +48,14 @@ func popElemStack(stack []*Elem) []*Elem {
return stack[:len(stack)-1] return stack[:len(stack)-1]
} }
func curElemTag(stack []*Elem) string { func curElemTag(stack []*VDomElem) string {
if len(stack) == 0 { if len(stack) == 0 {
return "" return ""
} }
return stack[len(stack)-1].Tag return stack[len(stack)-1].Tag
} }
func finalizeStack(stack []*Elem) *Elem { func finalizeStack(stack []*VDomElem) *VDomElem {
if len(stack) == 0 { if len(stack) == 0 {
return nil return nil
} }
@ -74,8 +81,38 @@ func getAttr(token htmltoken.Token, key string) string {
return "" return ""
} }
func tokenToElem(token htmltoken.Token, data map[string]any) *Elem { func attrToProp(attrVal string, params map[string]any) any {
elem := &Elem{Tag: token.Data} if strings.HasPrefix(attrVal, Html_ParamPrefix) {
bindKey := attrVal[len(Html_ParamPrefix):]
bindVal, ok := params[bindKey]
if !ok {
return nil
}
return bindVal
}
if strings.HasPrefix(attrVal, Html_BindPrefix) {
bindKey := attrVal[len(Html_BindPrefix):]
if bindKey == "" {
return nil
}
return &VDomBinding{Type: ObjectType_Binding, Bind: bindKey}
}
if strings.HasPrefix(attrVal, Html_GlobalEventPrefix) {
splitArr := strings.Split(attrVal, ":")
if len(splitArr) < 2 {
return nil
}
eventName := splitArr[1]
if eventName == "" {
return nil
}
return &VDomFunc{Type: ObjectType_Func, GlobalEvent: eventName}
}
return attrVal
}
func tokenToElem(token htmltoken.Token, params map[string]any) *VDomElem {
elem := &VDomElem{Tag: token.Data}
if len(token.Attr) > 0 { if len(token.Attr) > 0 {
elem.Props = make(map[string]any) elem.Props = make(map[string]any)
} }
@ -83,16 +120,8 @@ func tokenToElem(token htmltoken.Token, data map[string]any) *Elem {
if attr.Key == "" || attr.Val == "" { if attr.Key == "" || attr.Val == "" {
continue continue
} }
if strings.HasPrefix(attr.Val, "#bind:") { propVal := attrToProp(attr.Val, params)
bindKey := attr.Val[6:] elem.Props[attr.Key] = propVal
bindVal, ok := data[bindKey]
if !ok {
continue
}
elem.Props[attr.Key] = bindVal
continue
}
elem.Props[attr.Key] = attr.Val
} }
return elem return elem
} }
@ -177,12 +206,101 @@ func processTextStr(s string) string {
return strings.TrimSpace(s) return strings.TrimSpace(s)
} }
func Bind(htmlStr string, data map[string]any) *Elem { func makePathStr(elemPath []string) string {
return strings.Join(elemPath, " ")
}
func capitalizeAscii(s string) string {
if s == "" || s[0] < 'a' || s[0] > 'z' {
return s
}
return strings.ToUpper(s[:1]) + s[1:]
}
func toReactName(input string) string {
// Check for CSS custom properties (variables) which start with '--'
if strings.HasPrefix(input, "--") {
return input
}
parts := strings.Split(input, "-")
result := ""
index := 0
if parts[0] == "" && len(parts) > 1 {
// handle vendor prefixes
prefix := parts[1]
if prefix == "ms" {
result += "ms"
} else {
result += capitalizeAscii(prefix)
}
index = 2 // Skip the empty string and prefix
} else {
result += parts[0]
index = 1
}
// Convert remaining parts to CamelCase
for ; index < len(parts); index++ {
if parts[index] != "" {
result += capitalizeAscii(parts[index])
}
}
return result
}
func convertStyleToReactStyles(styleMap map[string]string, params map[string]any) map[string]any {
if len(styleMap) == 0 {
return nil
}
rtn := make(map[string]any)
for key, val := range styleMap {
rtn[toReactName(key)] = attrToProp(val, params)
}
return rtn
}
func fixStyleAttribute(elem *VDomElem, params map[string]any, elemPath []string) error {
styleText, ok := elem.Props["style"].(string)
if !ok {
return nil
}
parser := cssparser.MakeParser(styleText)
m, err := parser.Parse()
if err != nil {
return fmt.Errorf("%v (at %s)", err, makePathStr(elemPath))
}
elem.Props["style"] = convertStyleToReactStyles(m, params)
return nil
}
func fixupStyleAttributes(elem *VDomElem, params map[string]any, elemPath []string) {
if elem == nil {
return
}
// call fixStyleAttribute, and walk children
elemCountMap := make(map[string]int)
if len(elemPath) == 0 {
elemPath = append(elemPath, elem.Tag)
}
fixStyleAttribute(elem, params, elemPath)
for i := range elem.Children {
child := &elem.Children[i]
elemCountMap[child.Tag]++
subPath := child.Tag
if elemCountMap[child.Tag] > 1 {
subPath = fmt.Sprintf("%s[%d]", child.Tag, elemCountMap[child.Tag])
}
elemPath = append(elemPath, subPath)
fixupStyleAttributes(&elem.Children[i], params, elemPath)
elemPath = elemPath[:len(elemPath)-1]
}
}
func Bind(htmlStr string, params map[string]any) *VDomElem {
htmlStr = processWhitespace(htmlStr) htmlStr = processWhitespace(htmlStr)
r := strings.NewReader(htmlStr) r := strings.NewReader(htmlStr)
iter := htmltoken.NewTokenizer(r) iter := htmltoken.NewTokenizer(r)
var elemStack []*Elem var elemStack []*VDomElem
elemStack = append(elemStack, &Elem{Tag: FragmentTag}) elemStack = append(elemStack, &VDomElem{Tag: FragmentTag})
var tokenErr error var tokenErr error
outer: outer:
for { for {
@ -190,15 +308,15 @@ outer:
token := iter.Token() token := iter.Token()
switch tokenType { switch tokenType {
case htmltoken.StartTagToken: case htmltoken.StartTagToken:
if token.Data == "bind" { if token.Data == Html_BindTagName || token.Data == Html_BindParamTagName {
tokenErr = errors.New("bind tag must be self closing") tokenErr = errors.New("bind tags must be self closing")
break outer break outer
} }
elem := tokenToElem(token, data) elem := tokenToElem(token, params)
elemStack = pushElemStack(elemStack, elem) elemStack = pushElemStack(elemStack, elem)
case htmltoken.EndTagToken: case htmltoken.EndTagToken:
if token.Data == "bind" { if token.Data == Html_BindTagName || token.Data == Html_BindParamTagName {
tokenErr = errors.New("bind tag must be self closing") tokenErr = errors.New("bind tags must be self closing")
break outer break outer
} }
if len(elemStack) <= 1 { if len(elemStack) <= 1 {
@ -211,16 +329,22 @@ outer:
} }
elemStack = popElemStack(elemStack) elemStack = popElemStack(elemStack)
case htmltoken.SelfClosingTagToken: case htmltoken.SelfClosingTagToken:
if token.Data == "bind" { if token.Data == Html_BindParamTagName {
keyAttr := getAttr(token, "key") keyAttr := getAttr(token, "key")
dataVal := data[keyAttr] dataVal := params[keyAttr]
elemList := partToElems(dataVal) elemList := partToElems(dataVal)
for _, elem := range elemList { for _, elem := range elemList {
appendChildToStack(elemStack, &elem) appendChildToStack(elemStack, &elem)
} }
continue continue
} }
elem := tokenToElem(token, data) if token.Data == Html_BindTagName {
keyAttr := getAttr(token, "key")
binding := &VDomBinding{Type: ObjectType_Binding, Bind: keyAttr}
appendChildToStack(elemStack, &VDomElem{Tag: WaveTextTag, Props: map[string]any{"text": binding}})
continue
}
elem := tokenToElem(token, params)
appendChildToStack(elemStack, elem) appendChildToStack(elemStack, elem)
case htmltoken.TextToken: case htmltoken.TextToken:
if token.Data == "" { if token.Data == "" {
@ -249,5 +373,7 @@ outer:
errTextElem := TextElem(tokenErr.Error()) errTextElem := TextElem(tokenErr.Error())
appendChildToStack(elemStack, &errTextElem) appendChildToStack(elemStack, &errTextElem)
} }
return finalizeStack(elemStack) rtn := finalizeStack(elemStack)
fixupStyleAttributes(rtn, params, nil)
return rtn
} }

View File

@ -10,6 +10,7 @@ import (
"reflect" "reflect"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/wavetermdev/waveterm/pkg/util/utilfn"
) )
type vdomContextKeyType struct{} type vdomContextKeyType struct{}
@ -22,13 +23,20 @@ type VDomContextVal struct {
HookIdx int HookIdx int
} }
type Atom struct {
Val any
Dirty bool
UsedBy map[string]bool // component waveid -> true
}
type RootElem struct { type RootElem struct {
OuterCtx context.Context OuterCtx context.Context
Root *Component Root *Component
CFuncs map[string]CFunc CFuncs map[string]CFunc
CompMap map[string]*Component // component id -> component CompMap map[string]*Component // component waveid -> component
EffectWorkQueue []*EffectWorkElem EffectWorkQueue []*EffectWorkElem
NeedsRenderMap map[string]bool NeedsRenderMap map[string]bool
Atoms map[string]*Atom
} }
const ( const (
@ -57,9 +65,49 @@ func MakeRoot() *RootElem {
Root: nil, Root: nil,
CFuncs: make(map[string]CFunc), CFuncs: make(map[string]CFunc),
CompMap: make(map[string]*Component), CompMap: make(map[string]*Component),
Atoms: make(map[string]*Atom),
} }
} }
func (r *RootElem) GetAtom(name string) *Atom {
atom, ok := r.Atoms[name]
if !ok {
atom = &Atom{UsedBy: make(map[string]bool)}
r.Atoms[name] = atom
}
return atom
}
func (r *RootElem) GetAtomVal(name string) any {
atom := r.GetAtom(name)
return atom.Val
}
func (r *RootElem) GetStateSync(full bool) []VDomStateSync {
stateSync := make([]VDomStateSync, 0)
for atomName, atom := range r.Atoms {
if atom.Dirty || full {
stateSync = append(stateSync, VDomStateSync{Atom: atomName, Value: atom.Val})
atom.Dirty = false
}
}
return stateSync
}
func (r *RootElem) SetAtomVal(name string, val any, markDirty bool) {
atom := r.GetAtom(name)
if !markDirty {
atom.Val = val
return
}
// try to avoid setting the value and marking as dirty if it's the "same"
if utilfn.JsonValEqual(val, atom.Val) {
return
}
atom.Val = val
atom.Dirty = true
}
func (r *RootElem) SetOuterCtx(ctx context.Context) { func (r *RootElem) SetOuterCtx(ctx context.Context) {
r.OuterCtx = ctx r.OuterCtx = ctx
} }
@ -68,30 +116,60 @@ func (r *RootElem) RegisterComponent(name string, cfunc CFunc) {
r.CFuncs[name] = cfunc r.CFuncs[name] = cfunc
} }
func (r *RootElem) Render(elem *Elem) { func (r *RootElem) Render(elem *VDomElem) {
log.Printf("Render %s\n", elem.Tag) log.Printf("Render %s\n", elem.Tag)
r.render(elem, &r.Root) r.render(elem, &r.Root)
} }
func (r *RootElem) Event(id string, propName string) { func (vdf *VDomFunc) CallFn() {
if vdf.Fn == nil {
return
}
rval := reflect.ValueOf(vdf.Fn)
if rval.Kind() != reflect.Func {
return
}
rval.Call(nil)
}
func callVDomFn(fnVal any, data any) {
if fnVal == nil {
return
}
fn := fnVal
if vdf, ok := fnVal.(*VDomFunc); ok {
fn = vdf.Fn
}
if fn == nil {
return
}
rval := reflect.ValueOf(fn)
if rval.Kind() != reflect.Func {
return
}
rtype := rval.Type()
if rtype.NumIn() == 0 {
rval.Call(nil)
return
}
if rtype.NumIn() == 1 {
rval.Call([]reflect.Value{reflect.ValueOf(data)})
return
}
}
func (r *RootElem) Event(id string, propName string, data any) {
comp := r.CompMap[id] comp := r.CompMap[id]
if comp == nil || comp.Elem == nil { if comp == nil || comp.Elem == nil {
return return
} }
fnVal := comp.Elem.Props[propName] fnVal := comp.Elem.Props[propName]
if fnVal == nil { callVDomFn(fnVal, data)
return
}
fn, ok := fnVal.(func())
if !ok {
return
}
fn()
} }
// this will be called by the frontend to say the DOM has been mounted // this will be called by the frontend to say the DOM has been mounted
// it will eventually send any updated "refs" to the backend as well // it will eventually send any updated "refs" to the backend as well
func (r *RootElem) runWork() { func (r *RootElem) RunWork() {
workQueue := r.EffectWorkQueue workQueue := r.EffectWorkQueue
r.EffectWorkQueue = nil r.EffectWorkQueue = nil
// first, run effect cleanups // first, run effect cleanups
@ -123,7 +201,7 @@ func (r *RootElem) runWork() {
} }
} }
func (r *RootElem) render(elem *Elem, comp **Component) { func (r *RootElem) render(elem *VDomElem, comp **Component) {
if elem == nil || elem.Tag == "" { if elem == nil || elem.Tag == "" {
r.unmount(comp) r.unmount(comp)
return return
@ -171,13 +249,13 @@ func (r *RootElem) unmount(comp **Component) {
r.unmount(&child) r.unmount(&child)
} }
} }
delete(r.CompMap, (*comp).Id) delete(r.CompMap, (*comp).WaveId)
*comp = nil *comp = nil
} }
func (r *RootElem) createComp(tag string, key string, comp **Component) { func (r *RootElem) createComp(tag string, key string, comp **Component) {
*comp = &Component{Id: uuid.New().String(), Tag: tag, Key: key} *comp = &Component{WaveId: uuid.New().String(), Tag: tag, Key: key}
r.CompMap[(*comp).Id] = *comp r.CompMap[(*comp).WaveId] = *comp
} }
func (r *RootElem) renderText(text string, comp **Component) { func (r *RootElem) renderText(text string, comp **Component) {
@ -186,7 +264,7 @@ func (r *RootElem) renderText(text string, comp **Component) {
} }
} }
func (r *RootElem) renderChildren(elems []Elem, curChildren []*Component) []*Component { func (r *RootElem) renderChildren(elems []VDomElem, curChildren []*Component) []*Component {
newChildren := make([]*Component, len(elems)) newChildren := make([]*Component, len(elems))
curCM := make(map[ChildKey]*Component) curCM := make(map[ChildKey]*Component)
usedMap := make(map[*Component]bool) usedMap := make(map[*Component]bool)
@ -217,7 +295,7 @@ func (r *RootElem) renderChildren(elems []Elem, curChildren []*Component) []*Com
return newChildren return newChildren
} }
func (r *RootElem) renderSimple(elem *Elem, comp **Component) { func (r *RootElem) renderSimple(elem *VDomElem, comp **Component) {
if (*comp).Comp != nil { if (*comp).Comp != nil {
r.unmount(&(*comp).Comp) r.unmount(&(*comp).Comp)
} }
@ -243,7 +321,7 @@ func getRenderContext(ctx context.Context) *VDomContextVal {
return v.(*VDomContextVal) return v.(*VDomContextVal)
} }
func (r *RootElem) renderComponent(cfunc CFunc, elem *Elem, comp **Component) { func (r *RootElem) renderComponent(cfunc CFunc, elem *VDomElem, comp **Component) {
if (*comp).Children != nil { if (*comp).Children != nil {
for _, child := range (*comp).Children { for _, child := range (*comp).Children {
r.unmount(&child) r.unmount(&child)
@ -262,11 +340,11 @@ func (r *RootElem) renderComponent(cfunc CFunc, elem *Elem, comp **Component) {
r.unmount(&(*comp).Comp) r.unmount(&(*comp).Comp)
return return
} }
var rtnElem *Elem var rtnElem *VDomElem
if len(rtnElemArr) == 1 { if len(rtnElemArr) == 1 {
rtnElem = &rtnElemArr[0] rtnElem = &rtnElemArr[0]
} else { } else {
rtnElem = &Elem{Tag: FragmentTag, Children: rtnElemArr} rtnElem = &VDomElem{Tag: FragmentTag, Children: rtnElemArr}
} }
r.render(rtnElem, &(*comp).Comp) r.render(rtnElem, &(*comp).Comp)
} }
@ -282,7 +360,7 @@ func convertPropsToVDom(props map[string]any) map[string]any {
} }
val := reflect.ValueOf(v) val := reflect.ValueOf(v)
if val.Kind() == reflect.Func { if val.Kind() == reflect.Func {
vdomProps[k] = VDomFuncType{FuncType: "server"} vdomProps[k] = VDomFunc{Type: ObjectType_Func}
continue continue
} }
vdomProps[k] = v vdomProps[k] = v
@ -290,8 +368,8 @@ func convertPropsToVDom(props map[string]any) map[string]any {
return vdomProps return vdomProps
} }
func convertBaseToVDom(c *Component) *Elem { func convertBaseToVDom(c *Component) *VDomElem {
elem := &Elem{Id: c.Id, Tag: c.Tag} elem := &VDomElem{WaveId: c.WaveId, Tag: c.Tag}
if c.Elem != nil { if c.Elem != nil {
elem.Props = convertPropsToVDom(c.Elem.Props) elem.Props = convertPropsToVDom(c.Elem.Props)
} }
@ -304,12 +382,12 @@ func convertBaseToVDom(c *Component) *Elem {
return elem return elem
} }
func convertToVDom(c *Component) *Elem { func convertToVDom(c *Component) *VDomElem {
if c == nil { if c == nil {
return nil return nil
} }
if c.Tag == TextTag { if c.Tag == TextTag {
return &Elem{Tag: TextTag, Text: c.Text} return &VDomElem{Tag: TextTag, Text: c.Text}
} }
if isBaseTag(c.Tag) { if isBaseTag(c.Tag) {
return convertBaseToVDom(c) return convertBaseToVDom(c)
@ -318,11 +396,11 @@ func convertToVDom(c *Component) *Elem {
} }
} }
func (r *RootElem) makeVDom(comp *Component) *Elem { func (r *RootElem) makeVDom(comp *Component) *VDomElem {
vdomElem := convertToVDom(comp) vdomElem := convertToVDom(comp)
return vdomElem return vdomElem
} }
func (r *RootElem) MakeVDom() *Elem { func (r *RootElem) MakeVDom() *VDomElem {
return r.makeVDom(r.Root) return r.makeVDom(r.Root)
} }

View File

@ -18,7 +18,7 @@ type TestContext struct {
func Page(ctx context.Context, props map[string]any) any { func Page(ctx context.Context, props map[string]any) any {
clicked, setClicked := UseState(ctx, false) clicked, setClicked := UseState(ctx, false)
var clickedDiv *Elem var clickedDiv *VDomElem
if clicked { if clicked {
clickedDiv = Bind(`<div>clicked</div>`, nil) clickedDiv = Bind(`<div>clicked</div>`, nil)
} }
@ -30,8 +30,8 @@ func Page(ctx context.Context, props map[string]any) any {
` `
<div> <div>
<h1>hello world</h1> <h1>hello world</h1>
<Button onClick="#bind:clickFn">hello</Button> <Button onClick="#param:clickFn">hello</Button>
<bind key="clickedDiv"/> <bindparam key="clickedDiv"/>
</div> </div>
`, `,
map[string]any{"clickFn": clickFn, "clickedDiv": clickedDiv}, map[string]any{"clickFn": clickFn, "clickedDiv": clickedDiv},
@ -39,7 +39,7 @@ func Page(ctx context.Context, props map[string]any) any {
} }
func Button(ctx context.Context, props map[string]any) any { func Button(ctx context.Context, props map[string]any) any {
ref := UseRef(ctx, nil) ref := UseVDomRef(ctx)
clName, setClName := UseState(ctx, "button") clName, setClName := UseState(ctx, "button")
UseEffect(ctx, func() func() { UseEffect(ctx, func() func() {
fmt.Printf("Button useEffect\n") fmt.Printf("Button useEffect\n")
@ -52,8 +52,8 @@ func Button(ctx context.Context, props map[string]any) any {
testContext.ButtonId = compId testContext.ButtonId = compId
} }
return Bind(` return Bind(`
<div className="#bind:clName" ref="#bind:ref" onClick="#bind:onClick"> <div className="#param:clName" ref="#param:ref" onClick="#param:onClick">
<bind key="children"/> <bindparam key="children"/>
</div> </div>
`, map[string]any{"clName": clName, "ref": ref, "onClick": props["onClick"], "children": props["children"]}) `, map[string]any{"clName": clName, "ref": ref, "onClick": props["onClick"], "children": props["children"]})
} }
@ -85,10 +85,10 @@ func Test1(t *testing.T) {
t.Fatalf("root.Root is nil") t.Fatalf("root.Root is nil")
} }
printVDom(root) printVDom(root)
root.runWork() root.RunWork()
printVDom(root) printVDom(root)
root.Event(testContext.ButtonId, "onClick") root.Event(testContext.ButtonId, "onClick", nil)
root.runWork() root.RunWork()
printVDom(root) printVDom(root)
} }
@ -111,8 +111,8 @@ func TestBind(t *testing.T) {
elem = Bind(` elem = Bind(`
<div> <div>
<h1>hello world</h1> <h1>hello world</h1>
<Button onClick="#bind:clickFn">hello</Button> <Button onClick="#param:clickFn">hello</Button>
<bind key="clickedDiv"/> <bindparam key="clickedDiv"/>
</div> </div>
`, nil) `, nil)
jsonBytes, _ = json.MarshalIndent(elem, "", " ") jsonBytes, _ = json.MarshalIndent(elem, "", " ")

195
pkg/vdom/vdom_types.go Normal file
View File

@ -0,0 +1,195 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
package vdom
import (
"time"
"github.com/wavetermdev/waveterm/pkg/waveobj"
)
const TextTag = "#text"
const WaveTextTag = "wave:text"
const WaveNullTag = "wave:null"
const FragmentTag = "#fragment"
const BindTag = "#bind"
const ChildrenPropKey = "children"
const KeyPropKey = "key"
const ObjectType_Ref = "ref"
const ObjectType_Binding = "binding"
const ObjectType_Func = "func"
// vdom element
type VDomElem struct {
WaveId string `json:"waveid,omitempty"` // required, except for #text nodes
Tag string `json:"tag"`
Props map[string]any `json:"props,omitempty"`
Children []VDomElem `json:"children,omitempty"`
Text string `json:"text,omitempty"`
}
//// protocol messages
type VDomCreateContext struct {
Type string `json:"type" tstype:"\"createcontext\""`
Ts int64 `json:"ts"`
Meta waveobj.MetaMapType `json:"meta,omitempty"`
NewBlock bool `json:"newblock,omitempty"`
Persist bool `json:"persist,omitempty"`
}
type VDomAsyncInitiationRequest struct {
Type string `json:"type" tstype:"\"asyncinitiationrequest\""`
Ts int64 `json:"ts"`
BlockId string `json:"blockid,omitempty"`
}
func MakeAsyncInitiationRequest(blockId string) VDomAsyncInitiationRequest {
return VDomAsyncInitiationRequest{
Type: "asyncinitiationrequest",
Ts: time.Now().UnixMilli(),
BlockId: blockId,
}
}
type VDomFrontendUpdate struct {
Type string `json:"type" tstype:"\"frontendupdate\""`
Ts int64 `json:"ts"`
BlockId string `json:"blockid"`
CorrelationId string `json:"correlationid,omitempty"`
Initialize bool `json:"initialize,omitempty"` // initialize the app
Dispose bool `json:"dispose,omitempty"` // the vdom context was closed
Resync bool `json:"resync,omitempty"` // resync (send all backend data). useful when the FE reloads
RenderContext VDomRenderContext `json:"rendercontext,omitempty"`
Events []VDomEvent `json:"events,omitempty"`
StateSync []VDomStateSync `json:"statesync,omitempty"`
RefUpdates []VDomRefUpdate `json:"refupdates,omitempty"`
Messages []VDomMessage `json:"messages,omitempty"`
}
type VDomBackendUpdate struct {
Type string `json:"type" tstype:"\"backendupdate\""`
Ts int64 `json:"ts"`
BlockId string `json:"blockid"`
Opts *VDomBackendOpts `json:"opts,omitempty"`
RenderUpdates []VDomRenderUpdate `json:"renderupdates,omitempty"`
StateSync []VDomStateSync `json:"statesync,omitempty"`
RefOperations []VDomRefOperation `json:"refoperations,omitempty"`
Messages []VDomMessage `json:"messages,omitempty"`
}
///// prop types
// used in props
type VDomBinding struct {
Type string `json:"type" tstype:"\"binding\""`
Bind string `json:"bind"`
}
// used in props
type VDomFunc struct {
Fn any `json:"-"` // server side function (called with reflection)
Type string `json:"type" tstype:"\"func\""`
StopPropagation bool `json:"stoppropagation,omitempty"`
PreventDefault bool `json:"preventdefault,omitempty"`
GlobalEvent string `json:"globalevent,omitempty"`
Keys []string `json:"keys,omitempty"` // special for keyDown events a list of keys to "capture"
}
// used in props
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"`
}
type DomRect struct {
Top float64 `json:"top"`
Left float64 `json:"left"`
Right float64 `json:"right"`
Bottom float64 `json:"bottom"`
Width float64 `json:"width"`
Height float64 `json:"height"`
}
type VDomRefPosition struct {
OffsetHeight int `json:"offsetheight"`
OffsetWidth int `json:"offsetwidth"`
ScrollHeight int `json:"scrollheight"`
ScrollWidth int `json:"scrollwidth"`
ScrollTop int `json:"scrolltop"`
BoundingClientRect DomRect `json:"boundingclientrect"`
}
///// subbordinate protocol types
type VDomEvent struct {
WaveId string `json:"waveid"`
PropName string `json:"propname"`
EventData any `json:"eventdata"`
}
type VDomRenderContext struct {
BlockId string `json:"blockid"`
Focused bool `json:"focused"`
Width int `json:"width"`
Height int `json:"height"`
RootRefId string `json:"rootrefid"`
Background bool `json:"background,omitempty"`
}
type VDomStateSync struct {
Atom string `json:"atom"`
Value any `json:"value"`
}
type VDomRefUpdate struct {
RefId string `json:"refid"`
HasCurrent bool `json:"hascurrent"`
Position *VDomRefPosition `json:"position,omitempty"`
}
type VDomBackendOpts struct {
CloseOnCtrlC bool `json:"closeonctrlc,omitempty"`
GlobalKeyboardEvents bool `json:"globalkeyboardevents,omitempty"`
}
type VDomRenderUpdate struct {
UpdateType string `json:"updatetype" tstype:"\"root\"|\"append\"|\"replace\"|\"remove\"|\"insert\""`
WaveId string `json:"waveid,omitempty"`
VDom VDomElem `json:"vdom"`
Index *int `json:"index,omitempty"`
}
type VDomRefOperation struct {
RefId string `json:"refid"`
Op string `json:"op" tsype:"\"focus\""`
Params []any `json:"params,omitempty"`
}
type VDomMessage struct {
MessageType string `json:"messagetype"`
Message string `json:"message"`
StackTrace string `json:"stacktrace,omitempty"`
Params []any `json:"params,omitempty"`
}
// matches WaveKeyboardEvent
type VDomKeyboardEvent struct {
Type string `json:"type"`
Key string `json:"key"`
Code string `json:"code"`
Shift bool `json:"shift,omitempty"`
Control bool `json:"ctrl,omitempty"`
Alt bool `json:"alt,omitempty"`
Meta bool `json:"meta,omitempty"`
Cmd bool `json:"cmd,omitempty"`
Option bool `json:"option,omitempty"`
Repeat bool `json:"repeat,omitempty"`
Location int `json:"location,omitempty"`
}

View File

@ -0,0 +1,199 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
package vdomclient
import (
"context"
"fmt"
"log"
"os"
"sync"
"time"
"github.com/google/uuid"
"github.com/wavetermdev/waveterm/pkg/vdom"
"github.com/wavetermdev/waveterm/pkg/waveobj"
"github.com/wavetermdev/waveterm/pkg/wps"
"github.com/wavetermdev/waveterm/pkg/wshrpc"
"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient"
"github.com/wavetermdev/waveterm/pkg/wshutil"
)
type Client struct {
Root *vdom.RootElem
RootElem *vdom.VDomElem
RpcClient *wshutil.WshRpc
RpcContext *wshrpc.RpcContext
ServerImpl *VDomServerImpl
IsDone bool
RouteId string
DoneReason string
DoneOnce *sync.Once
DoneCh chan struct{}
Opts vdom.VDomBackendOpts
GlobalEventHandler func(client *Client, event vdom.VDomEvent)
}
type VDomServerImpl struct {
Client *Client
BlockId string
}
func (*VDomServerImpl) WshServerImpl() {}
func (impl *VDomServerImpl) VDomRenderCommand(ctx context.Context, feUpdate vdom.VDomFrontendUpdate) (*vdom.VDomBackendUpdate, error) {
if feUpdate.Dispose {
log.Printf("got dispose from frontend\n")
impl.Client.doShutdown("got dispose from frontend")
return nil, nil
}
if impl.Client.IsDone {
return nil, nil
}
// set atoms
for _, ss := range feUpdate.StateSync {
impl.Client.Root.SetAtomVal(ss.Atom, ss.Value, false)
}
// run events
for _, event := range feUpdate.Events {
if event.WaveId == "" {
if impl.Client.GlobalEventHandler != nil {
impl.Client.GlobalEventHandler(impl.Client, event)
}
} else {
impl.Client.Root.Event(event.WaveId, event.PropName, event.EventData)
}
}
if feUpdate.Initialize || feUpdate.Resync {
return impl.Client.fullRender()
}
return impl.Client.incrementalRender()
}
func (c *Client) doShutdown(reason string) {
c.DoneOnce.Do(func() {
c.DoneReason = reason
c.IsDone = true
close(c.DoneCh)
})
}
func (c *Client) SetGlobalEventHandler(handler func(client *Client, event vdom.VDomEvent)) {
c.GlobalEventHandler = handler
}
func MakeClient(opts *vdom.VDomBackendOpts) (*Client, error) {
client := &Client{
Root: vdom.MakeRoot(),
DoneCh: make(chan struct{}),
DoneOnce: &sync.Once{},
}
if opts != nil {
client.Opts = *opts
}
jwtToken := os.Getenv(wshutil.WaveJwtTokenVarName)
if jwtToken == "" {
return nil, fmt.Errorf("no %s env var set", wshutil.WaveJwtTokenVarName)
}
rpcCtx, err := wshutil.ExtractUnverifiedRpcContext(jwtToken)
if err != nil {
return nil, fmt.Errorf("error extracting rpc context from %s: %v", wshutil.WaveJwtTokenVarName, err)
}
client.RpcContext = rpcCtx
if client.RpcContext == nil || client.RpcContext.BlockId == "" {
return nil, fmt.Errorf("no block id in rpc context")
}
client.ServerImpl = &VDomServerImpl{BlockId: client.RpcContext.BlockId, Client: client}
sockName, err := wshutil.ExtractUnverifiedSocketName(jwtToken)
if err != nil {
return nil, fmt.Errorf("error extracting socket name from %s: %v", wshutil.WaveJwtTokenVarName, err)
}
rpcClient, err := wshutil.SetupDomainSocketRpcClient(sockName, client.ServerImpl)
if err != nil {
return nil, fmt.Errorf("error setting up domain socket rpc client: %v", err)
}
client.RpcClient = rpcClient
authRtn, err := wshclient.AuthenticateCommand(client.RpcClient, jwtToken, &wshrpc.RpcOpts{NoResponse: true})
if err != nil {
return nil, fmt.Errorf("error authenticating rpc connection: %v", err)
}
client.RouteId = authRtn.RouteId
return client, nil
}
func (c *Client) SetRootElem(elem *vdom.VDomElem) {
c.RootElem = elem
}
func (c *Client) CreateVDomContext() error {
err := wshclient.VDomCreateContextCommand(c.RpcClient, vdom.VDomCreateContext{}, &wshrpc.RpcOpts{Route: wshutil.MakeFeBlockRouteId(c.RpcContext.BlockId)})
if err != nil {
return err
}
wshclient.EventSubCommand(c.RpcClient, wps.SubscriptionRequest{Event: "blockclose", Scopes: []string{
waveobj.MakeORef("block", c.RpcContext.BlockId).String(),
}}, nil)
c.RpcClient.EventListener.On("blockclose", func(event *wps.WaveEvent) {
c.doShutdown("got blockclose event")
})
return nil
}
func (c *Client) SendAsyncInitiation() {
wshclient.VDomAsyncInitiationCommand(c.RpcClient, vdom.MakeAsyncInitiationRequest(c.RpcContext.BlockId), &wshrpc.RpcOpts{Route: wshutil.MakeFeBlockRouteId(c.RpcContext.BlockId)})
}
func (c *Client) SetAtomVals(m map[string]any) {
for k, v := range m {
c.Root.SetAtomVal(k, v, true)
}
}
func (c *Client) SetAtomVal(name string, val any) {
c.Root.SetAtomVal(name, val, true)
}
func (c *Client) GetAtomVal(name string) any {
return c.Root.GetAtomVal(name)
}
func makeNullVDom() *vdom.VDomElem {
return &vdom.VDomElem{WaveId: uuid.New().String(), Tag: vdom.WaveNullTag}
}
func (c *Client) fullRender() (*vdom.VDomBackendUpdate, error) {
c.Root.RunWork()
c.Root.Render(c.RootElem)
renderedVDom := c.Root.MakeVDom()
if renderedVDom == nil {
renderedVDom = makeNullVDom()
}
return &vdom.VDomBackendUpdate{
Type: "backendupdate",
Ts: time.Now().UnixMilli(),
BlockId: c.RpcContext.BlockId,
Opts: &c.Opts,
RenderUpdates: []vdom.VDomRenderUpdate{
{UpdateType: "root", VDom: *renderedVDom},
},
StateSync: c.Root.GetStateSync(true),
}, nil
}
func (c *Client) incrementalRender() (*vdom.VDomBackendUpdate, error) {
c.Root.RunWork()
renderedVDom := c.Root.MakeVDom()
if renderedVDom == nil {
renderedVDom = makeNullVDom()
}
return &vdom.VDomBackendUpdate{
Type: "backendupdate",
Ts: time.Now().UnixMilli(),
BlockId: c.RpcContext.BlockId,
RenderUpdates: []vdom.VDomRenderUpdate{
{UpdateType: "root", VDom: *renderedVDom},
},
StateSync: c.Root.GetStateSync(false),
}, nil
}

View File

@ -78,6 +78,10 @@ const (
MetaKey_TermLocalShellOpts = "term:localshellopts" MetaKey_TermLocalShellOpts = "term:localshellopts"
MetaKey_TermScrollback = "term:scrollback" MetaKey_TermScrollback = "term:scrollback"
MetaKey_VDomClear = "vdom:*"
MetaKey_VDomInitialized = "vdom:initialized"
MetaKey_VDomCorrelationId = "vdom:correlationid"
MetaKey_Count = "count" MetaKey_Count = "count"
) )

View File

@ -79,6 +79,10 @@ type MetaTSType struct {
TermLocalShellOpts []string `json:"term:localshellopts,omitempty"` // matches settings TermLocalShellOpts []string `json:"term:localshellopts,omitempty"` // matches settings
TermScrollback *int `json:"term:scrollback,omitempty"` TermScrollback *int `json:"term:scrollback,omitempty"`
VDomClear bool `json:"vdom:*,omitempty"`
VDomInitialized bool `json:"vdom:initialized,omitempty"`
VDomCorrelationId string `json:"vdom:correlationid,omitempty"`
Count int `json:"count,omitempty"` // temp for cpu plot. will remove later Count int `json:"count,omitempty"` // temp for cpu plot. will remove later
} }

View File

@ -11,6 +11,7 @@ const (
Event_BlockFile = "blockfile" Event_BlockFile = "blockfile"
Event_Config = "config" Event_Config = "config"
Event_UserInput = "userinput" Event_UserInput = "userinput"
Event_RouteGone = "route:gone"
) )
type WaveEvent struct { type WaveEvent struct {

View File

@ -11,6 +11,7 @@ import (
"github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/waveobj"
"github.com/wavetermdev/waveterm/pkg/wconfig" "github.com/wavetermdev/waveterm/pkg/wconfig"
"github.com/wavetermdev/waveterm/pkg/wps" "github.com/wavetermdev/waveterm/pkg/wps"
"github.com/wavetermdev/waveterm/pkg/vdom"
) )
// command "authenticate", wshserver.AuthenticateCommand // command "authenticate", wshserver.AuthenticateCommand
@ -260,6 +261,24 @@ func TestCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) error {
return err return err
} }
// command "vdomasyncinitiation", wshserver.VDomAsyncInitiationCommand
func VDomAsyncInitiationCommand(w *wshutil.WshRpc, data vdom.VDomAsyncInitiationRequest, opts *wshrpc.RpcOpts) error {
_, err := sendRpcRequestCallHelper[any](w, "vdomasyncinitiation", data, opts)
return err
}
// command "vdomcreatecontext", wshserver.VDomCreateContextCommand
func VDomCreateContextCommand(w *wshutil.WshRpc, data vdom.VDomCreateContext, opts *wshrpc.RpcOpts) error {
_, err := sendRpcRequestCallHelper[any](w, "vdomcreatecontext", data, opts)
return err
}
// command "vdomrender", wshserver.VDomRenderCommand
func VDomRenderCommand(w *wshutil.WshRpc, data vdom.VDomFrontendUpdate, opts *wshrpc.RpcOpts) (*vdom.VDomBackendUpdate, error) {
resp, err := sendRpcRequestCallHelper[*vdom.VDomBackendUpdate](w, "vdomrender", data, opts)
return resp, err
}
// command "webselector", wshserver.WebSelectorCommand // command "webselector", wshserver.WebSelectorCommand
func WebSelectorCommand(w *wshutil.WshRpc, data wshrpc.CommandWebSelectorData, opts *wshrpc.RpcOpts) ([]string, error) { func WebSelectorCommand(w *wshutil.WshRpc, data wshrpc.CommandWebSelectorData, opts *wshrpc.RpcOpts) ([]string, error) {
resp, err := sendRpcRequestCallHelper[[]string](w, "webselector", data, opts) resp, err := sendRpcRequestCallHelper[[]string](w, "webselector", data, opts)

View File

@ -11,6 +11,7 @@ import (
"reflect" "reflect"
"github.com/wavetermdev/waveterm/pkg/ijson" "github.com/wavetermdev/waveterm/pkg/ijson"
"github.com/wavetermdev/waveterm/pkg/vdom"
"github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/waveobj"
"github.com/wavetermdev/waveterm/pkg/wconfig" "github.com/wavetermdev/waveterm/pkg/wconfig"
"github.com/wavetermdev/waveterm/pkg/wps" "github.com/wavetermdev/waveterm/pkg/wps"
@ -69,6 +70,10 @@ const (
Command_WebSelector = "webselector" Command_WebSelector = "webselector"
Command_Notify = "notify" Command_Notify = "notify"
Command_VDomCreateContext = "vdomcreatecontext"
Command_VDomAsyncInitiation = "vdomasyncinitiation"
Command_VDomRender = "vdomrender"
) )
type RespOrErrorUnion[T any] struct { type RespOrErrorUnion[T any] struct {
@ -126,8 +131,16 @@ type WshRpcInterface interface {
RemoteFileJoinCommand(ctx context.Context, paths []string) (*FileInfo, error) RemoteFileJoinCommand(ctx context.Context, paths []string) (*FileInfo, error)
RemoteStreamCpuDataCommand(ctx context.Context) chan RespOrErrorUnion[TimeSeriesData] RemoteStreamCpuDataCommand(ctx context.Context) chan RespOrErrorUnion[TimeSeriesData]
// emain
WebSelectorCommand(ctx context.Context, data CommandWebSelectorData) ([]string, error) WebSelectorCommand(ctx context.Context, data CommandWebSelectorData) ([]string, error)
NotifyCommand(ctx context.Context, notificationOptions WaveNotificationOptions) error NotifyCommand(ctx context.Context, notificationOptions WaveNotificationOptions) error
// terminal
VDomCreateContextCommand(ctx context.Context, data vdom.VDomCreateContext) error
VDomAsyncInitiationCommand(ctx context.Context, data vdom.VDomAsyncInitiationRequest) error
// proc
VDomRenderCommand(ctx context.Context, data vdom.VDomFrontendUpdate) (*vdom.VDomBackendUpdate, error)
} }
// for frontend // for frontend

View File

@ -60,6 +60,10 @@ func MakeTabRouteId(tabId string) string {
return "tab:" + tabId return "tab:" + tabId
} }
func MakeFeBlockRouteId(blockId string) string {
return "feblock:" + blockId
}
var DefaultRouter = NewWshRouter() var DefaultRouter = NewWshRouter()
func NewWshRouter() *WshRouter { func NewWshRouter() *WshRouter {
@ -322,6 +326,7 @@ func (router *WshRouter) UnregisterRoute(routeId string) {
} }
go func() { go func() {
wps.Broker.UnsubscribeAll(routeId) wps.Broker.UnsubscribeAll(routeId)
wps.Broker.Publish(wps.WaveEvent{Event: wps.Event_RouteGone, Scopes: []string{routeId}})
}() }()
} }