lecodes-cli 0.6.4 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,86 @@
1
+ // Editor plugins: `registerEditorWindow` / `registerEditorTool` (docs/scene-editor-plan.md,
2
+ // phase 11). Registrations live in `*.editor.ts` files — compiled and executed ONLY in
3
+ // scene-editor bundles (phase 10), so none of this reaches shipped app.js (nothing references it
4
+ // there, and method-granular DCE drops it).
5
+ //
6
+ // The scene-editor harness (packages/projects/src/scene-editor) reads the registry through the
7
+ // injected `__editorPlugins` global — the harness, the scene, and the editor files compile into
8
+ // ONE bundle, so they share this module instance. Windows and tools describe their UI through the
9
+ // same immediate-mode `InspectorUI` protocol custom inspector cards use (one widget vocabulary,
10
+ // three mount points), and mutate the scene ONLY through the `editor` doc API — every plugin write
11
+ // lands in the scene document (undoable, diffable), never in live engine state.
12
+
13
+ import type { InspectorUI } from "../core/InspectorUI"
14
+
15
+ /** A geometry hit under the viewport pointer, handed to tools. Physics builds raycast the scene's
16
+ * meshes (precise); otherwise (and on misses) the hit falls back to the ground plane (y = 0). */
17
+ export type EditorRayHit = {
18
+ point: [number, number, number]
19
+ normal: [number, number, number]
20
+ /** The scene-file node the hit belongs to — null for ground-plane fallback hits. */
21
+ node: string | null
22
+ }
23
+
24
+ /**
25
+ * The editor scripting API handed to windows and tools. Doc-op methods write the scene DOCUMENT
26
+ * through the editor's normal commit path (undo, file, live patching all included); they return
27
+ * false / no-op when the document can't take the edit (duplicate name, unknown node).
28
+ */
29
+ export type EditorApi = {
30
+ /** The currently selected node name (or `model::part` key), null when nothing is selected. */
31
+ readonly selection: string | null
32
+ select(name: string | null): void
33
+ /** The scene document's nodes (name + source kind: mesh / model / light / group). */
34
+ nodes(): { name: string, kind: string }[]
35
+ /** First unused "base", "base2", "base3", … node name. */
36
+ uniqueName(base: string): string
37
+ /** Add a node from plain def data (scene-file grammar; a string `model` value means an asset
38
+ * path). One undo step unless grouped by `transact`. */
39
+ addNode(name: string, def: Record<string, unknown>): boolean
40
+ /** Write one def prop (transforms apply live; anything else patches/re-runs the node). */
41
+ setProp(name: string, key: string, value: unknown): boolean
42
+ removeNode(name: string): void
43
+ /** Duplicate a node; returns the copy's name (null when it can't). */
44
+ duplicate(name: string): string | null
45
+ /** Raycast the scene under a viewport pixel (same hit rules as tool clicks). */
46
+ raycast(screenX: number, screenY: number): EditorRayHit | null
47
+ /** Group every doc edit inside `fn` into ONE undo step. */
48
+ transact(fn: () => void): void
49
+ }
50
+
51
+ export type EditorWindowFn = (ui: InspectorUI, editor: EditorApi) => void
52
+
53
+ export type EditorToolHooks = {
54
+ /** CSS cursor for the viewport while this tool is active (default "crosshair"). */
55
+ cursor?: string
56
+ /** A short glyph (one character / emoji) for the tool's toolbar button — tools without one all
57
+ * share the generic wand icon, so any editor with two or more tools should set it. */
58
+ icon?: string
59
+ /** A viewport click while the tool is active — `hit` is the raycast result under the pointer.
60
+ * Selection clicks and the transform gizmo are suspended while a tool is active. */
61
+ onViewportClick?(hit: EditorRayHit, editor: EditorApi): void
62
+ }
63
+
64
+ /** @internal The registry the scene-editor harness reads (windows/tools in registration order). */
65
+ export const __editorPlugins = {
66
+ windows: [] as { title: string, render: EditorWindowFn }[],
67
+ tools: [] as { name: string, hooks: EditorToolHooks }[],
68
+ }
69
+
70
+ /** Register an editor panel — a collapsible overlay docked over the viewport (the inspector rail
71
+ * stays selection-scoped). A window that emits `ui.toolButton` for a tool becomes that tool's
72
+ * settings panel: activating the tool expands and highlights it. `render` re-runs immediate-mode
73
+ * on every interaction — same protocol as `static inspector` cards. */
74
+ export const registerEditorWindow = (title: string, render: EditorWindowFn): void => {
75
+ const existing = __editorPlugins.windows.find((w) => w.title === title)
76
+ if (existing) existing.render = render
77
+ else __editorPlugins.windows.push({ title, render })
78
+ }
79
+
80
+ /** Register a viewport tool — a toolbar entry beside move/rotate/scale (activate it there or via
81
+ * `ui.toolButton(label, name)`). While active, viewport clicks arrive as raycast hits. */
82
+ export const registerEditorTool = (name: string, hooks: EditorToolHooks): void => {
83
+ const existing = __editorPlugins.tools.find((t) => t.name === name)
84
+ if (existing) existing.hooks = hooks
85
+ else __editorPlugins.tools.push({ name, hooks })
86
+ }
@@ -13,6 +13,7 @@ export { UIWidget, type UIWidgetStyle } from './UIWidget'
13
13
 
14
14
  export { UIRow, UIColumn, UIBox, type UIContainerStyle } from './UIContainer'
15
15
  export { UIScrollable, type UIScrollableStyle } from './UIScrollable'
16
+ export { UIScreenHost, type UIScreenHostStyle } from './UIScreenHost'
16
17
 
17
18
  export { UIVirtualizedList } from './UIVirtualizedList'
18
19
 
@@ -34,6 +34,17 @@ export interface UIScreen <Scrollable extends boolean = false> {
34
34
  onOpen(callback: () => void): this
35
35
  onClose(callback: () => void): this
36
36
 
37
+ /**
38
+ * Keep this screen's built native tree alive when a UIScreenHost detaches it (replace / remove /
39
+ * pop): instead of being destroyed, its views + layout + live state (scroll position, input text)
40
+ * are retained, so re-hosting the SAME screen object restores it in place rather than rebuilding.
41
+ * `onClose`/`onOpen` still fire on detach/re-attach. You own the lifetime — a kept tree is freed
42
+ * only by `dispose()` or when the project unloads. (Native-only today; web rebuilds.)
43
+ */
44
+ keepAlive(enabled?: boolean): this
45
+ /** Free a `keepAlive()` screen's retained tree now. No-op unless it's currently detached-and-kept. */
46
+ dispose(): this
47
+
37
48
  onLayout(onLayout: OnLayoutCallback): this
38
49
 
39
50
  setClass(name: string, enabled: boolean): this
@@ -62,6 +73,18 @@ export class ScreenElement extends ContainerElement<"screen"> {
62
73
  this.cl.push(callback)
63
74
  return this
64
75
  }
76
+ // Read by the host at detach time (a plain flag on the node, like _interactive).
77
+ _keepAlive = false
78
+ keepAlive(enabled = true): this {
79
+ this._keepAlive = enabled
80
+ return this
81
+ }
82
+ dispose(): this {
83
+ // Rides the generic command channel (no dedicated bridge). No-op while _id is 0 (never mounted)
84
+ // or when the tree isn't currently detached-and-kept — the host ignores it then.
85
+ if (this._id !== 0) _creatorUI.command(this, "disposeKeepAlive")
86
+ return this
87
+ }
65
88
  readonly touchStartListeners: any[] = []
66
89
  _interactive = false
67
90
  onTouchStart (callback: any): this {
@@ -0,0 +1,212 @@
1
+ import { ContainerElement, type AnimateStyle, type BaseStyle, type ChildrenFn, type DrawableStyle, type ElementStyle, type OnLayoutCallback, type Style, type UINodeChild } from "./UINode"
2
+ import type { UIScreen } from "./UIScreen"
3
+
4
+ // UIScreenHost — the system's ONE screen host: an element whose pages are full UIScreens, each laid
5
+ // out to the host's box (the same mechanism screens use, with the "device" swapped for the slot).
6
+ // With several screens it pages between them (wrapping each platform's native pager); with a single
7
+ // screen it simply embeds it — a refreshable region under a fixed header, or a screen-swap slot via
8
+ // setContent(next). UITabs is pure TS over a swipe-disabled UIScreenHost.
9
+ //
10
+ // Pages are screens ONLY — no plain elements. Every page then carries the full screen contract
11
+ // (onOpen/onClose from the settle lifecycle, makeScrollable()/onRefresh), and every platform
12
+ // implements exactly one page kind: mount the screen root in the slot, lay it out to the slot size.
13
+ // To host a plain element, wrap it: UIScreenHost(UIScreen([el])).
14
+
15
+ /** A hosted page: any UIScreen, scrollable or not. Sizing styles on it are ignored (screens are roots sized by their host). */
16
+ export type HostedScreen = UIScreen<boolean>
17
+ /** Reactive pages — ChildrenFn narrowed to screens. */
18
+ export type ScreensFn = () => (HostedScreen | null | undefined | false)[]
19
+
20
+ /**
21
+ * Transition for `replace()`. The `Router.replace` catalogue, plus `"push"`/`"pop"` — the native
22
+ * UINavigationController feel (the incoming/outgoing screen slides full-width while the other
23
+ * parallaxes ~30% under a dim overlay with a soft edge shadow). Use `"push"` to go forward, `"pop"`
24
+ * to go back. (Animated transitions are native-only; web swaps instantly.)
25
+ */
26
+ export type ScreenHostTransition =
27
+ | "push" | "pop"
28
+ | "slide-from-left" | "slide-from-right" | "slide-from-top" | "slide-from-bottom"
29
+ | "zoom" | "zoom-in" | "zoom-out" | "fade" | "none"
30
+
31
+ export type UIScreenHostStyle = ElementStyle & DrawableStyle & {
32
+ // Paging axis. Default "horizontal". Read by the host at mount; live-updatable via .style().
33
+ scrollDirection?: "horizontal" | "vertical",
34
+ // false = programmatic-only paging (no user swipe) — the mode UITabs uses. Default true.
35
+ swipeEnabled?: boolean,
36
+ }
37
+
38
+ export interface UIScreenHost {
39
+ readonly type: "screenhost",
40
+ style: Style<this, UIScreenHostStyle>,
41
+ animateTo: AnimateStyle<this, DrawableStyle & BaseStyle>,
42
+ animateFrom: AnimateStyle<this, DrawableStyle & BaseStyle>,
43
+
44
+ /** Current settled page index. */
45
+ readonly page: number,
46
+ /** Programmatic page switch. `animated` defaults to true (ignored when swipe physics can't run). */
47
+ setPage(index: number, animated?: boolean): this,
48
+ /** Fires when the settled page changes (after the swipe/scroll comes to rest). */
49
+ onPageChange(callback: (index: number) => void): this,
50
+ /** Continuous fractional scroll position (e.g. 1.35) — for tab-strip indicators / page dots. */
51
+ onPageScroll(callback: (position: number) => void): this,
52
+
53
+ append(...screens: HostedScreen[]): this,
54
+ insert(index: number, ...screens: HostedScreen[]): this,
55
+ remove(...screens: HostedScreen[]): this,
56
+ /** Replace the pages. A single screen swaps the hosted screen in place (old page onClose → new page onOpen). */
57
+ setContent(screens: HostedScreen | HostedScreen[] | ScreensFn): this,
58
+ /**
59
+ * Replace the page at `index` with a new screen, animating the swap when that page is the visible
60
+ * one (off-screen pages swap instantly). The settled page index is preserved — a replace is not a
61
+ * page change. This is the push/pop primitive: keep your own stack of screens and replace the
62
+ * visible slot with a forward transition to push, a backward one to pop.
63
+ */
64
+ replace(index: number, screen: HostedScreen, transition?: ScreenHostTransition): this,
65
+
66
+ readonly children: UINodeChild[],
67
+
68
+ onLayout(onLayout: OnLayoutCallback): this,
69
+
70
+ setClass(name: string, enabled: boolean): this,
71
+ toggleClass(name: string): this,
72
+ hasClass(name: string): boolean,
73
+ bindClass(name: string, fn: () => boolean): this,
74
+ }
75
+
76
+ export class ScreenHostElement extends ContainerElement<"screenhost"> {
77
+ // --- read by the host at mount (createScreenHostSystem) ---
78
+ _page = 0 // settled index; host writes it back on settle
79
+ _dir: "horizontal" | "vertical" = "horizontal"
80
+ _swipe = true
81
+
82
+ // --- listeners the host invokes directly (same pattern as UIScrollable.sl / vlist) ---
83
+ readonly pcl: ((index: number) => void)[] = [] // onPageChange
84
+ readonly psl: ((position: number) => void)[] = [] // onPageScroll
85
+
86
+ get page(): number {
87
+ return this._page
88
+ }
89
+
90
+ setPage(index: number, animated = true): this {
91
+ if (this._id !== 0) {
92
+ _creatorUI.command(this, "setPage", index, animated)
93
+ } else {
94
+ this._page = index // remembered as the initial page until the host mounts
95
+ }
96
+ return this
97
+ }
98
+
99
+ onPageChange(callback: (index: number) => void): this {
100
+ this.pcl.push(callback)
101
+ return this
102
+ }
103
+ onPageScroll(callback: (position: number) => void): this {
104
+ this.psl.push(callback)
105
+ return this
106
+ }
107
+
108
+ // Pages are separate layout roots, not yoga children, so the generic insertNode/removeNode path
109
+ // doesn't apply. Keep `children` in sync and ask the host to reconcile pages by node identity.
110
+ append(...nodes: UINodeChild[]): this {
111
+ this.children.push(...nodes)
112
+ if (this._id !== 0) _creatorUI.command(this, "syncPages")
113
+ return this
114
+ }
115
+ insert(index: number, ...nodes: UINodeChild[]): this {
116
+ this.children.splice(index, 0, ...nodes)
117
+ if (this._id !== 0) _creatorUI.command(this, "syncPages")
118
+ return this
119
+ }
120
+ remove(...nodes: UINodeChild[]): this {
121
+ const set = new Set(nodes)
122
+ this.children = this.children.filter(c => !set.has(c as UINodeChild))
123
+ if (this._id !== 0) _creatorUI.command(this, "syncPages")
124
+ return this
125
+ }
126
+ setContent(nodes: UINodeChild | UINodeChild[] | ChildrenFn): this {
127
+ if (typeof nodes === "function") {
128
+ // Reactive pages: the base binding drives _reconcile, which calls our insert/remove above.
129
+ return super.setContent(nodes)
130
+ }
131
+ this.children = Array.isArray(nodes) ? [...nodes] : [nodes]
132
+ if (this._id !== 0) _creatorUI.command(this, "syncPages")
133
+ return this
134
+ }
135
+
136
+ // Replace one page in place, animated. The node travels via children[index] (the command channel
137
+ // carries only index + transition); the host reads it back and swaps the page, animating the swap
138
+ // when `index` is the visible page. Before mount it just seeds the initial content at that index.
139
+ replace(index: number, screen: UINodeChild, transition: ScreenHostTransition = "fade"): this {
140
+ if (index < 0 || index >= this.children.length) return this
141
+ this.children[index] = screen
142
+ if (this._id !== 0) _creatorUI.command(this, "replace", index, transition)
143
+ return this
144
+ }
145
+
146
+ // scrollDirection/swipeEnabled are host behavior, not layout — intercept them here so they never
147
+ // reach the layout engine, and forward live changes to the host. Everything else flows to the base
148
+ // style proxy unchanged (including property get/set via the forwarding Proxy below).
149
+ private _hostStyleProxy?: any
150
+ get style(): any {
151
+ if (this._hostStyleProxy) return this._hostStyleProxy
152
+ const base = super.style
153
+ const self = this
154
+ const apply = (s: any) => {
155
+ if (s && typeof s === "object") {
156
+ if ("scrollDirection" in s && typeof s.scrollDirection !== "function") {
157
+ self._dir = s.scrollDirection
158
+ delete s.scrollDirection
159
+ if (self._id !== 0) _creatorUI.command(self, "setDirection", self._dir)
160
+ }
161
+ if ("swipeEnabled" in s && typeof s.swipeEnabled !== "function") {
162
+ self._swipe = s.swipeEnabled
163
+ delete s.swipeEnabled
164
+ if (self._id !== 0) _creatorUI.command(self, "setSwipe", self._swipe)
165
+ }
166
+ }
167
+ return base(s)
168
+ }
169
+ this._hostStyleProxy = new Proxy(apply, {
170
+ apply: (_t, _thisArg, args) => apply(args[0]),
171
+ get: (_t, k) => (base as any)[k],
172
+ set: (_t, k, v) => { (base as any)[k] = v; return true },
173
+ })
174
+ return this._hostStyleProxy
175
+ }
176
+ }
177
+
178
+ function makeScreenHost(style: UIScreenHostStyle | null, children: UINodeChild[] | ChildrenFn): ScreenHostElement {
179
+ const dir = style?.scrollDirection
180
+ const swipe = style?.swipeEnabled
181
+ if (style) {
182
+ delete style.scrollDirection
183
+ delete style.swipeEnabled
184
+ }
185
+ const el = new ScreenHostElement("screenhost", style ?? {}, children)
186
+ if (dir !== undefined) el._dir = dir
187
+ if (swipe !== undefined) el._swipe = swipe
188
+ return el
189
+ }
190
+
191
+ // A screen is the only non-array, non-function, non-style argument the factory accepts.
192
+ const isScreenArg = (v: any): boolean => v != null && typeof v === "object" && v.type === "screen"
193
+ const asPages = (v: any): UINodeChild[] | ChildrenFn => (Array.isArray(v) || typeof v === "function") ? v : [v]
194
+
195
+ export function UIScreenHost(): UIScreenHost
196
+ export function UIScreenHost(screen: HostedScreen): UIScreenHost
197
+ export function UIScreenHost(screens: HostedScreen[] | ScreensFn): UIScreenHost
198
+ export function UIScreenHost(style: UIScreenHostStyle): UIScreenHost
199
+ export function UIScreenHost(style: UIScreenHostStyle, screens: HostedScreen | HostedScreen[] | ScreensFn): UIScreenHost
200
+ export function UIScreenHost(...args: [] | [HostedScreen | HostedScreen[] | ScreensFn | UIScreenHostStyle] | [UIScreenHostStyle, HostedScreen | HostedScreen[] | ScreensFn]): UIScreenHost {
201
+ if (args.length === 0) {
202
+ return makeScreenHost({}, [])
203
+ } else if (args.length === 1) {
204
+ if (Array.isArray(args[0]) || typeof args[0] === "function" || isScreenArg(args[0])) {
205
+ return makeScreenHost({}, asPages(args[0]))
206
+ } else {
207
+ return makeScreenHost(args[0] as UIScreenHostStyle, [])
208
+ }
209
+ } else {
210
+ return makeScreenHost(args[0] as UIScreenHostStyle, asPages(args[1]))
211
+ }
212
+ }