@px-lsp/server 0.1.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.
- package/LICENSE +674 -0
- package/README.md +334 -0
- package/THIRD-PARTY-NOTICES.md +83 -0
- package/data/ck3/dataTypes.json +2195 -0
- package/data/ck3/data_types/data_types_common.txt +2040 -0
- package/data/ck3/data_types/data_types_gui.txt +5264 -0
- package/data/ck3/data_types/data_types_internalclausewitzgui.txt +14843 -0
- package/data/ck3/data_types/data_types_script.txt +4251 -0
- package/data/ck3/data_types/data_types_uncategorized.txt +109984 -0
- package/data/ck3/freqs.json +1 -0
- package/data/ck3/guiSchema.json +6344 -0
- package/data/ck3/script_docs/effects.log +16059 -0
- package/data/ck3/script_docs/event_targets.log +2098 -0
- package/data/ck3/script_docs/modifiers.log +2228 -0
- package/data/ck3/script_docs/on_actions.log +5275 -0
- package/data/ck3/script_docs/triggers.log +11991 -0
- package/data/ck3/structures.json +9743 -0
- package/data/ck3/wikidocs/ATTRIBUTION.md +18 -0
- package/data/ck3/wikidocs/Data_types.md +2568 -0
- package/data/ck3/wikidocs/Effects_list.md +1176 -0
- package/data/ck3/wikidocs/Scopes_list.md +341 -0
- package/data/ck3/wikidocs/Triggers_list.md +1097 -0
- package/data/eu5/data_types/data_types_common.txt +2087 -0
- package/data/eu5/data_types/data_types_gui.txt +6732 -0
- package/data/eu5/data_types/data_types_internalclausewitzgui.txt +19276 -0
- package/data/eu5/data_types/data_types_script.txt +5688 -0
- package/data/eu5/data_types/data_types_uncategorized.txt +135569 -0
- package/data/vic3/data_types/data_types_common.txt +2021 -0
- package/data/vic3/data_types/data_types_gui.txt +5592 -0
- package/data/vic3/data_types/data_types_internalclausewitzgui.txt +17304 -0
- package/data/vic3/data_types/data_types_script.txt +2817 -0
- package/data/vic3/data_types/data_types_uncategorized.txt +84354 -0
- package/data/vic3/freqs.json +1 -0
- package/data/vic3/guiSchema.json +5578 -0
- package/data/vic3/script_docs/effects.log +38135 -0
- package/data/vic3/script_docs/event_targets.log +2028 -0
- package/data/vic3/script_docs/modifiers.log +18954 -0
- package/data/vic3/script_docs/on_actions.log +1561 -0
- package/data/vic3/script_docs/triggers.log +15738 -0
- package/data/vic3/structures.json +10189 -0
- package/dist/server.js +63668 -0
- package/media/px-lsp.svg +12 -0
- package/package.json +50 -0
- package/src/clientMode.ts +60 -0
- package/src/coa/coa.ts +184 -0
- package/src/coa/coaParse.ts +267 -0
- package/src/context.ts +78 -0
- package/src/contextKeywords.ts +224 -0
- package/src/data/dataBindingMacros.ts +82 -0
- package/src/data/dataFnDocs.ts +152 -0
- package/src/data/dataFnUsage.ts +431 -0
- package/src/data/dataTypes.ts +279 -0
- package/src/data/defines.ts +123 -0
- package/src/data/docsParser.ts +453 -0
- package/src/data/keywordDocs.ts +98 -0
- package/src/data/modifierTemplates.ts +143 -0
- package/src/data/textFormatting.ts +165 -0
- package/src/data/wikiDocs.ts +187 -0
- package/src/dds/decoder.ts +1007 -0
- package/src/dds/encode.ts +235 -0
- package/src/dds/index.ts +58 -0
- package/src/dds/png.ts +96 -0
- package/src/dds/tga.ts +62 -0
- package/src/documents.ts +35 -0
- package/src/features/assetPaths.ts +169 -0
- package/src/features/codeActions.ts +148 -0
- package/src/features/colors.ts +244 -0
- package/src/features/completion.ts +961 -0
- package/src/features/datafunction.ts +729 -0
- package/src/features/definition.ts +84 -0
- package/src/features/diagnostics.ts +244 -0
- package/src/features/folding.ts +106 -0
- package/src/features/formatting.ts +60 -0
- package/src/features/guiLanguage.ts +366 -0
- package/src/features/guiNavigation.ts +140 -0
- package/src/features/guiTree.ts +97 -0
- package/src/features/hover.ts +817 -0
- package/src/features/hoverRender.ts +222 -0
- package/src/features/inlayHints.ts +147 -0
- package/src/features/locFormatting.ts +127 -0
- package/src/features/references.ts +70 -0
- package/src/features/rename.ts +135 -0
- package/src/features/scopeAt.ts +65 -0
- package/src/features/semanticTokens.ts +188 -0
- package/src/features/signatureHelp.ts +72 -0
- package/src/features/symbols.ts +241 -0
- package/src/features/textureHover.ts +143 -0
- package/src/features/workspaceSymbols.ts +69 -0
- package/src/games/active.ts +19 -0
- package/src/games/ck3/ambientScopes.ts +273 -0
- package/src/games/ck3/index.ts +38 -0
- package/src/games/ck3/meta.ts +28 -0
- package/src/games/ck3/modifierPlaceholders.ts +61 -0
- package/src/games/ck3/saveSchema.ts +134 -0
- package/src/games/ck3/scaffolds.ts +197 -0
- package/src/games/ck3/schema.ts +422 -0
- package/src/games/ck3/structures.ts +887 -0
- package/src/games/eu5/index.ts +75 -0
- package/src/games/eu5/meta.ts +44 -0
- package/src/games/eu5/scaffolds.ts +49 -0
- package/src/games/eu5/schema.generated.ts +1043 -0
- package/src/games/jomini/variables.ts +134 -0
- package/src/games/profile.ts +205 -0
- package/src/games/registry.ts +27 -0
- package/src/games/vic3/index.ts +52 -0
- package/src/games/vic3/meta.ts +55 -0
- package/src/games/vic3/saveSchema.ts +77 -0
- package/src/games/vic3/scaffolds.ts +135 -0
- package/src/games/vic3/schema.ts +650 -0
- package/src/games/vic3/structures.ts +33 -0
- package/src/gui/anchorSpec.ts +66 -0
- package/src/gui/declMarkers.ts +30 -0
- package/src/gui/fillGeometry.ts +101 -0
- package/src/gui/guiDefs.ts +386 -0
- package/src/gui/guiDependencies.ts +352 -0
- package/src/gui/guiLinks.ts +64 -0
- package/src/gui/layoutEngine.ts +1998 -0
- package/src/gui/layoutService.ts +221 -0
- package/src/gui/measuredMetrics.ts +21 -0
- package/src/gui/previewService.ts +89 -0
- package/src/gui/saveSchema.ts +220 -0
- package/src/gui/saveValues.ts +399 -0
- package/src/gui/saveZip.ts +60 -0
- package/src/gui/sourceEdit.ts +535 -0
- package/src/gui/sourceEditService.ts +439 -0
- package/src/gui/sourceModel.ts +603 -0
- package/src/gui/textResolve.ts +145 -0
- package/src/gui/textureInfo.ts +106 -0
- package/src/gui/vocabulary.ts +149 -0
- package/src/gui/widgetEdit.ts +52 -0
- package/src/gui/widgetInfo.ts +245 -0
- package/src/index/docComments.ts +103 -0
- package/src/index/extract.ts +252 -0
- package/src/index/indexer.ts +369 -0
- package/src/index/intern.ts +101 -0
- package/src/index/lazyRefs.ts +145 -0
- package/src/index/modOrigin.ts +69 -0
- package/src/index/references.ts +534 -0
- package/src/overview/dependencies.ts +240 -0
- package/src/overview/eventBanner.ts +95 -0
- package/src/overview/eventDetail.ts +482 -0
- package/src/overview/eventGraph.ts +617 -0
- package/src/overview/eventVocabulary.ts +214 -0
- package/src/overview/locCoverage.ts +138 -0
- package/src/overview/modOverview.ts +29 -0
- package/src/overview/overrides.ts +89 -0
- package/src/parseCache.ts +81 -0
- package/src/parser/cst.ts +257 -0
- package/src/parser/encoding.ts +106 -0
- package/src/parser/index.ts +7 -0
- package/src/parser/lexer.ts +245 -0
- package/src/parser/locParser.ts +276 -0
- package/src/parser/parser.ts +360 -0
- package/src/schema/freqs.ts +70 -0
- package/src/schema/loader.ts +113 -0
- package/src/schema/types.ts +142 -0
- package/src/scopes/inference.ts +478 -0
- package/src/scopes/model.ts +148 -0
- package/src/scopes/varTypes.ts +290 -0
- package/src/server.ts +1894 -0
- package/src/serverData.ts +98 -0
- package/src/structure.ts +56 -0
- package/src/wordAt.ts +49 -0
|
@@ -0,0 +1,1998 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PdxGui layout engine: turn a .gui document into absolute-positioned
|
|
3
|
+
* rectangles, the model behind the GUI designer's canvas.
|
|
4
|
+
*
|
|
5
|
+
* Every layout rule here is MEASURED, not guessed: the authority is
|
|
6
|
+
* docs/gui-designer/calibration/spec.md, and rule comments cite their batch
|
|
7
|
+
* ("B2-I1" = calibration batch 02, case I1). Where the spec is silent the
|
|
8
|
+
* comment says "unmeasured" and names the assumption, those are the first
|
|
9
|
+
* candidates for a future calibration batch when a rendering looks wrong.
|
|
10
|
+
*
|
|
11
|
+
* Scope (phase 1): structural widgets, boxes with layout policies,
|
|
12
|
+
* flowcontainer, container, margin_widget, scrollarea, textboxes with the
|
|
13
|
+
* calibrated font metrics, template/type/blockoverride resolution.
|
|
14
|
+
* Phase 2 (presentation, NOT calibrated pixel rules): datamodel-list ghost
|
|
15
|
+
* placeholders, nine-slice `spriteborder` geometry on fills, and confirmed
|
|
16
|
+
* exclusion of `state = {}` transition blocks from layout.
|
|
17
|
+
* Phase 3 (G2 layout merge): the rules spec.md carries under "Studio-verified
|
|
18
|
+
* engine behaviors", namely grid box flow and cell math, clipping containers,
|
|
19
|
+
* `ignoreinvisible`, `resizeparent`, container/item content sizing, sprite
|
|
20
|
+
* fill MODE and frame sheets. Those comments cite the spec bullet's own
|
|
21
|
+
* source tag plus the parity-checklist row, e.g. "(Studio §K v3, L14a)";
|
|
22
|
+
* docs/gui-designer/parity-checklist.md is the row index. Three rows are
|
|
23
|
+
* DISPUTED between the two engines (L07c state-supplied position, L13e sized
|
|
24
|
+
* flowcontainer, L23 position on a box child) and are deliberately NOT
|
|
25
|
+
* implemented: both sides measured, so the checklist asks for a re-run rather
|
|
26
|
+
* than letting one engine overwrite the other.
|
|
27
|
+
*
|
|
28
|
+
* No `vscode` imports: unit-tested in plain Node (test/guiLayout.test.ts
|
|
29
|
+
* holds the golden fixtures derived from the calibration screenshots).
|
|
30
|
+
*/
|
|
31
|
+
import { LineIndex, parseScript, type BlockNode, type ScalarNode, type Statement } from "../parser";
|
|
32
|
+
import {
|
|
33
|
+
collectBlockOverrides,
|
|
34
|
+
collectGuiDefs,
|
|
35
|
+
collectGuiDefsParsed,
|
|
36
|
+
emptyGuiDefs,
|
|
37
|
+
expandWidget,
|
|
38
|
+
type GuiDefs,
|
|
39
|
+
} from "./guiDefs";
|
|
40
|
+
import { DECL_MARKERS, SLOT_KEYS } from "./declMarkers";
|
|
41
|
+
import type { GuiTextSegment } from "@px-lsp/protocol/protocol";
|
|
42
|
+
// The anchor table is a leaf so the webview's anchor picker offers exactly the
|
|
43
|
+
// words this engine parses (B1-B/C).
|
|
44
|
+
import { anchorFractions } from "./anchorSpec";
|
|
45
|
+
import { GITAN_MEASURED_METRICS } from "./measuredMetrics";
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// Public model
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
export interface LayoutRect {
|
|
52
|
+
x: number;
|
|
53
|
+
y: number;
|
|
54
|
+
w: number;
|
|
55
|
+
h: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* How a texture fills its rect. Nine-slicing needs BOTH a `Cornered*`
|
|
60
|
+
* spriteType AND a non-zero `spriteborder`; a border on its own is IGNORED and
|
|
61
|
+
* the whole texture plain-stretches, and a `*tiled*` type without a border
|
|
62
|
+
* tiles the whole texture. Nine-sliced edges then tile or stretch with the
|
|
63
|
+
* type. (Studio §J1-J7, in-game 2026-07-17; L21a-d.)
|
|
64
|
+
*/
|
|
65
|
+
export type FillMode = "stretch" | "tile" | "nineslice-stretch" | "nineslice-tile";
|
|
66
|
+
|
|
67
|
+
export interface Fill {
|
|
68
|
+
texture?: string;
|
|
69
|
+
/** rgba 0..1; rendered = round(v*255), straight sRGB multiply (B1-G). */
|
|
70
|
+
color?: [number, number, number, number];
|
|
71
|
+
/**
|
|
72
|
+
* Nine-slice border widths [left, top, right, bottom] in texture pixels,
|
|
73
|
+
* sourced from the `spriteborder`/`spriteborder_<side>` .gui attributes.
|
|
74
|
+
* The values are read straight from the document, not a calibrated layout
|
|
75
|
+
* rule; `mode` is what says whether they APPLY (a border without a
|
|
76
|
+
* `Cornered*` type does not, Studio §J4).
|
|
77
|
+
*/
|
|
78
|
+
border?: [number, number, number, number];
|
|
79
|
+
/**
|
|
80
|
+
* Fill mode from `spriteType` + `spriteborder` (Studio §J, L21a-d). Set on
|
|
81
|
+
* every textured fill. `nineslice-*` means computeNineSlice's regions apply,
|
|
82
|
+
* with edges tiled or stretched per the suffix; `tile` repeats the whole
|
|
83
|
+
* texture; `stretch` scales it to the rect.
|
|
84
|
+
*/
|
|
85
|
+
mode?: FillMode;
|
|
86
|
+
/** `framesize = { w h }` grid cell size when the texture is a frame sheet (Studio §L, L22). */
|
|
87
|
+
framesize?: [number, number];
|
|
88
|
+
/** 1-based `frame` index into that grid, clamped by computeFrameCell (Studio §L, L22). */
|
|
89
|
+
frame?: number;
|
|
90
|
+
/** `alpha = x` on the widget or the background block: the fill's opacity, 0..1. */
|
|
91
|
+
alpha?: number;
|
|
92
|
+
/**
|
|
93
|
+
* `modify_texture = { texture blend_mode = alphamultiply }`: a mask whose
|
|
94
|
+
* alpha multiplies the fill's, stretched over the rect. Only the
|
|
95
|
+
* alphamultiply blend is carried; the engine's other blends are not drawn.
|
|
96
|
+
*/
|
|
97
|
+
mask?: string;
|
|
98
|
+
/** `fittype = centercrop`: the texture covers the rect, cropped to centre, never stretched. */
|
|
99
|
+
fit?: "centercrop";
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface TextInfo {
|
|
103
|
+
text: string;
|
|
104
|
+
raw?: string;
|
|
105
|
+
segments?: GuiTextSegment[];
|
|
106
|
+
fontsize: number;
|
|
107
|
+
/** Ink offset of the text run inside the widget rect (align; B4-T6). */
|
|
108
|
+
offsetX: number;
|
|
109
|
+
offsetY: number;
|
|
110
|
+
lines: string[];
|
|
111
|
+
/** Font color when the textbox sets one (rgba 0..1). */
|
|
112
|
+
color?: [number, number, number, number];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface LayoutNode {
|
|
116
|
+
key: string;
|
|
117
|
+
name?: string;
|
|
118
|
+
/** Absolute rect in canvas coordinates. */
|
|
119
|
+
rect: LayoutRect;
|
|
120
|
+
/** True for scrollarea viewports, the only measured clipper (B3-R1). */
|
|
121
|
+
clip: boolean;
|
|
122
|
+
bg?: Fill;
|
|
123
|
+
/** The widget's own texture fill (icon, textured widgets/buttons). */
|
|
124
|
+
fill?: Fill;
|
|
125
|
+
text?: TextInfo;
|
|
126
|
+
/**
|
|
127
|
+
* 0-based source line of the instance statement in the CURRENT document.
|
|
128
|
+
* Children spliced in from type definitions inherit their instance
|
|
129
|
+
* ancestor's line (their own statements live in other files).
|
|
130
|
+
*/
|
|
131
|
+
line?: number;
|
|
132
|
+
/**
|
|
133
|
+
* True when the widget was placed by anchor+position rules (its `position`
|
|
134
|
+
* is honored, so it is draggable). False for children whose rect is
|
|
135
|
+
* dictated by a box/flow parent.
|
|
136
|
+
*/
|
|
137
|
+
positioned: boolean;
|
|
138
|
+
/**
|
|
139
|
+
* True when `line` is the widget's OWN statement in the current document
|
|
140
|
+
* (safe to edit). False for children spliced from type definitions, whose
|
|
141
|
+
* `line` is the instance ancestor's, editing those would modify the
|
|
142
|
+
* wrong widget.
|
|
143
|
+
*/
|
|
144
|
+
editable: boolean;
|
|
145
|
+
/** Raw `position = { x y }` source values, when present. */
|
|
146
|
+
srcPosition?: [number, number];
|
|
147
|
+
/** Raw `size = { w h }` source values, when present (may be %, see sizePct). */
|
|
148
|
+
srcSize?: [number, number];
|
|
149
|
+
/**
|
|
150
|
+
* The widget's index among its parent body's REORDER SIBLINGS, which is what
|
|
151
|
+
* a `reorder`, `insert` or `delete` op counts: the body's declarations, the
|
|
152
|
+
* `blockoverride`/`block`/`template` entries included, properties excluded
|
|
153
|
+
* (sourceModel.ts `GuiBody.children`). Absent for anything whose statement is
|
|
154
|
+
* not a direct entry of a body in THIS document: a template- or type-spliced
|
|
155
|
+
* child, a datamodel ghost, the contents of a named slot, and a scrollarea's
|
|
156
|
+
* pass-through children, whose indices count a body their new parent does not
|
|
157
|
+
* own. Absent means "not addressable by index", which is the only honest
|
|
158
|
+
* answer a client can act on.
|
|
159
|
+
*/
|
|
160
|
+
srcIndex?: number;
|
|
161
|
+
/**
|
|
162
|
+
* True for placeholder copies of a datamodel item template: the list has no
|
|
163
|
+
* real runtime data in the preview, so GHOST_COUNT reduced-opacity instances
|
|
164
|
+
* stand in. Presentation only, propagated to the whole ghost subtree.
|
|
165
|
+
*/
|
|
166
|
+
ghost?: boolean;
|
|
167
|
+
/** The widget's `onclick` source string, verbatim, when it has one (see GuiLayoutNode.onclick). */
|
|
168
|
+
onclick?: string;
|
|
169
|
+
/** The widget's `tooltip` source string, verbatim, when it has one. */
|
|
170
|
+
tooltip?: string;
|
|
171
|
+
/**
|
|
172
|
+
* True for a root that is a `type name = base { }` DECLARATION laid out as
|
|
173
|
+
* one instance of itself, which is what a document that instantiates nothing
|
|
174
|
+
* at top level shows (see {@link declaredRoots}). The declaration header
|
|
175
|
+
* itself is not an editable widget, its children, which are real statements
|
|
176
|
+
* in this document, are.
|
|
177
|
+
*/
|
|
178
|
+
declared?: boolean;
|
|
179
|
+
children: LayoutNode[];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* How a widget's rect came to be, recorded only when a caller asks for it.
|
|
184
|
+
* Structurally identical to the wire types (`GuiPlacementTerm`, `GuiPlacedBy`,
|
|
185
|
+
* `GuiPlacement`), like LayoutNode is to GuiLayoutNode.
|
|
186
|
+
*/
|
|
187
|
+
export interface PlacementTerm {
|
|
188
|
+
kind: "parentOrigin" | "parentanchor" | "widgetanchor" | "position";
|
|
189
|
+
source?: string;
|
|
190
|
+
dx: number;
|
|
191
|
+
dy: number;
|
|
192
|
+
}
|
|
193
|
+
export interface PlacedBy {
|
|
194
|
+
key: string;
|
|
195
|
+
name?: string;
|
|
196
|
+
layout: "box" | "flow" | "grid";
|
|
197
|
+
droppedPosition?: [number, number];
|
|
198
|
+
}
|
|
199
|
+
export interface Placement {
|
|
200
|
+
rect: LayoutRect;
|
|
201
|
+
parentRect: LayoutRect;
|
|
202
|
+
terms: PlacementTerm[];
|
|
203
|
+
placedBy?: PlacedBy;
|
|
204
|
+
clippedBy?: { key: string; name?: string; rect: LayoutRect };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Placement-explanation request AND sink: name the widget's own 0-based line,
|
|
209
|
+
* read `result` after the run. Passing one is what turns the trace on; without
|
|
210
|
+
* it `arrange` pays a single truthy test per node and allocates nothing.
|
|
211
|
+
*/
|
|
212
|
+
export interface PlacementExplain {
|
|
213
|
+
line: number;
|
|
214
|
+
result?: Placement;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Ancestor facts the trace needs, built only while explaining. */
|
|
218
|
+
interface ExplainChain {
|
|
219
|
+
sink: PlacementExplain;
|
|
220
|
+
parent?: WNode;
|
|
221
|
+
/** The rect the parent laid its children in (for a container-placed child). */
|
|
222
|
+
parentRect?: LayoutRect;
|
|
223
|
+
/** The innermost clipping ancestor above this node. */
|
|
224
|
+
clip?: { key: string; name?: string; rect: LayoutRect };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* How the layout treats a widget whose `visible` holds an expression a static
|
|
229
|
+
* preview cannot evaluate. Structurally the wire's `GuiVisibilityOptions`.
|
|
230
|
+
* `visible = no` / `visible = yes` are deterministic and unaffected.
|
|
231
|
+
*/
|
|
232
|
+
export interface VisibilityOptions {
|
|
233
|
+
mode: "showAll" | "hideAll" | "evaluate";
|
|
234
|
+
/** `evaluate` only: condition source string -> shown/hidden. */
|
|
235
|
+
checks?: Record<string, boolean>;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** One conditional `visible` the run met (the wire's `GuiVisibilityCheck`). */
|
|
239
|
+
export interface VisibilityCheck {
|
|
240
|
+
key: string;
|
|
241
|
+
count: number;
|
|
242
|
+
hidden: boolean;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Per-stage wall clock, filled in when a caller passes one. */
|
|
246
|
+
export interface LayoutTiming {
|
|
247
|
+
/** Parsing the document and collecting its own template/type declarations. */
|
|
248
|
+
parseMs: number;
|
|
249
|
+
/** Building the widget tree and arranging every rect. */
|
|
250
|
+
layoutMs: number;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Number of placeholder rows drawn for a datamodel-driven list (unmeasured:
|
|
255
|
+
* a preview affordance, capped per ghostCount so it never overruns a container
|
|
256
|
+
* whose own size is known). GHOST_OPACITY is applied by the client renderer.
|
|
257
|
+
*/
|
|
258
|
+
export const GHOST_COUNT = 3;
|
|
259
|
+
export const GHOST_OPACITY = 0.45;
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Sprite fill geometry lives in its own leaf module so the canvas renderer can
|
|
263
|
+
* bundle it without the parser behind it, and re-exports here because it is
|
|
264
|
+
* part of this model's published surface.
|
|
265
|
+
*/
|
|
266
|
+
export { computeFrameCell, computeNineSlice, type NineSliceRegion } from "./fillGeometry";
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Fill mode: nine-slice iff a `Cornered*` type AND a non-zero border,
|
|
270
|
+
* otherwise tile for a `*tiled*` type, else stretch (Studio §J, L21a-d).
|
|
271
|
+
*/
|
|
272
|
+
function fillMode(spriteType: string | undefined, border?: [number, number, number, number]): FillMode {
|
|
273
|
+
const type = spriteType?.toLowerCase() ?? "";
|
|
274
|
+
const tiled = type.includes("tiled");
|
|
275
|
+
const cornered = type.startsWith("cornered") && (border?.some((v) => v > 0) ?? false);
|
|
276
|
+
if (cornered) return tiled ? "nineslice-tile" : "nineslice-stretch";
|
|
277
|
+
return tiled ? "tile" : "stretch";
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export interface TextMeasurer {
|
|
281
|
+
/** Advance-model width of one line: (n-1)*advance + ink(last). (B2-L) */
|
|
282
|
+
lineWidth(text: string, fontsize: number): number;
|
|
283
|
+
/** Line box height; 21 at fontsize 15, scales linearly. (B1-G, B3-S3) */
|
|
284
|
+
lineHeight(fontsize: number): number;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* A game's measured default-font text metrics: glyph advance/ink table and
|
|
289
|
+
* line-box height at the fontsize they were measured at. All metrics scale
|
|
290
|
+
* exactly linearly with fontsize (B3-S3), so one base size is enough. A game
|
|
291
|
+
* profile carries one once an in-game probe has measured its font
|
|
292
|
+
* (docs/gui-designer/calibration/probes/); until then the profile leaves it
|
|
293
|
+
* absent and the batch-01..03 table below is the assumption.
|
|
294
|
+
*/
|
|
295
|
+
export interface GuiTextMetrics {
|
|
296
|
+
/** fontsize the table was measured at. */
|
|
297
|
+
baseFontsize: number;
|
|
298
|
+
/** Line box height at baseFontsize. */
|
|
299
|
+
lineHeight: number;
|
|
300
|
+
glyphs: Record<string, { adv: number; ink: number }>;
|
|
301
|
+
/** Fallback for glyphs outside the measured set, a rough average. */
|
|
302
|
+
defaultGlyph: { adv: number; ink: number };
|
|
303
|
+
/**
|
|
304
|
+
* The fontsize a textbox renders at when it sets none. Absent = 15 (the
|
|
305
|
+
* default profile's Font_Size_Small).
|
|
306
|
+
*/
|
|
307
|
+
defaultFontsize?: number;
|
|
308
|
+
/**
|
|
309
|
+
* When true, each glyph's advance/ink is ROUNDED after scaling to the
|
|
310
|
+
* requested fontsize instead of scaling the sum linearly. The 2026-08-09 probe
|
|
311
|
+
* measured this law: M advance = round(0.9 * fontsize), 14 at
|
|
312
|
+
* 15, 15 at 17, 27 at 30, which no linear table reproduces. Pick
|
|
313
|
+
* baseFontsize so adv/base is the glyph's true em fraction. lineHeight
|
|
314
|
+
* still scales linearly (the same probe's 1.3 * fontsize law is exact).
|
|
315
|
+
*/
|
|
316
|
+
roundPerSize?: boolean;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Measured per-game layout rule divergences. Every flag cites the probe that
|
|
321
|
+
* measured it; absent flags mean the engine's default (in-game-measured for
|
|
322
|
+
* the default profile) applies.
|
|
323
|
+
*/
|
|
324
|
+
export interface GuiLayoutQuirks {
|
|
325
|
+
/**
|
|
326
|
+
* An EMPTY `container` with an authored `size` KEEPS it instead of
|
|
327
|
+
* collapsing to 0. Measured in-game (probe 2026-08-09: the empty sized
|
|
328
|
+
* container rendered its full 150x60, engine warning logged yet applied);
|
|
329
|
+
* the default profile measured the opposite, narrow rule (probe
|
|
330
|
+
* 2026-08-02, L25).
|
|
331
|
+
*/
|
|
332
|
+
emptySizedContainerKept?: boolean;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* What the engine threads through layout: the text measurer plus the game's
|
|
337
|
+
* measured defaults and quirks. A plain TextMeasurer is a valid LayoutEnv
|
|
338
|
+
* (every extra field optional = the default profile's measured behavior).
|
|
339
|
+
*/
|
|
340
|
+
export interface LayoutEnv extends TextMeasurer, GuiLayoutQuirks {
|
|
341
|
+
defaultFontsize?: number;
|
|
342
|
+
/**
|
|
343
|
+
* What a `text =` value shows (textResolve.ts): loc keys and datafunctions
|
|
344
|
+
* resolved as far as the preview can know. Absent = the value verbatim.
|
|
345
|
+
*/
|
|
346
|
+
resolveText?: (raw: string) => { text: string; segments?: GuiTextSegment[] };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** Advance-model measurer over a measured metrics table (B2-L, B3-S3). */
|
|
350
|
+
export function measurerFromMetrics(m: GuiTextMetrics): LayoutEnv {
|
|
351
|
+
return {
|
|
352
|
+
lineWidth(text, fontsize) {
|
|
353
|
+
if (text.length === 0) return 0;
|
|
354
|
+
const s = fontsize / m.baseFontsize; // linear scaling (B3-S3)
|
|
355
|
+
const scaled = m.roundPerSize ? (v: number) => Math.round(v * s) : (v: number) => v * s;
|
|
356
|
+
let w = 0;
|
|
357
|
+
for (let n = 0; n < text.length; n++) {
|
|
358
|
+
const g = m.glyphs[text[n]] ?? m.defaultGlyph;
|
|
359
|
+
w += scaled(n === text.length - 1 ? g.ink : g.adv);
|
|
360
|
+
}
|
|
361
|
+
return w;
|
|
362
|
+
},
|
|
363
|
+
lineHeight(fontsize) {
|
|
364
|
+
return m.lineHeight * (fontsize / m.baseFontsize); // B1-G, B3-S3
|
|
365
|
+
},
|
|
366
|
+
defaultFontsize: m.defaultFontsize,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* The default profile's measured font table (gui/measuredMetrics.ts) is also
|
|
372
|
+
* the assumption for games whose probe has not run yet.
|
|
373
|
+
*/
|
|
374
|
+
export const calibratedMeasurer: TextMeasurer = measurerFromMetrics(GITAN_MEASURED_METRICS);
|
|
375
|
+
|
|
376
|
+
export interface LayoutOptions {
|
|
377
|
+
/** Rect the top-level widgets are laid out against. */
|
|
378
|
+
viewport?: { w: number; h: number };
|
|
379
|
+
measurer?: TextMeasurer;
|
|
380
|
+
/**
|
|
381
|
+
* Cross-file template/type store (vanilla + mod, FIOS-merged via
|
|
382
|
+
* guiDefs.mergeGuiDefs). The current document's own declarations are always
|
|
383
|
+
* collected on top: store entries win for globals (FIOS), the file's
|
|
384
|
+
* local_templates win locally.
|
|
385
|
+
*/
|
|
386
|
+
defs?: GuiDefs;
|
|
387
|
+
/** Conditional-visibility preview mode; absent = `showAll` (today's rule). */
|
|
388
|
+
visibility?: VisibilityOptions;
|
|
389
|
+
/** Filled with every conditional `visible` met, key -> count + outcome. */
|
|
390
|
+
checks?: Map<string, VisibilityCheck>;
|
|
391
|
+
/** Record why ONE widget's rect is where it is; absent = no trace. */
|
|
392
|
+
explain?: PlacementExplain;
|
|
393
|
+
/** Filled with this run's per-stage wall clock; absent = no measurement. */
|
|
394
|
+
timing?: LayoutTiming;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
export function computeGuiLayout(text: string, options?: LayoutOptions): LayoutNode[] {
|
|
398
|
+
const viewport = options?.viewport ?? { w: 1920, h: 1080 };
|
|
399
|
+
const measurer = options?.measurer ?? calibratedMeasurer;
|
|
400
|
+
const t0 = options?.timing ? performance.now() : 0;
|
|
401
|
+
const result = parseScript(text);
|
|
402
|
+
const consts = collectConstants(result.root.statements);
|
|
403
|
+
const defs = effectiveDefs(text, options?.defs);
|
|
404
|
+
const lineIndex = new LineIndex(text);
|
|
405
|
+
const ctx: BuildCtx = {
|
|
406
|
+
consts,
|
|
407
|
+
defs,
|
|
408
|
+
overrides: new Map(),
|
|
409
|
+
stack: [],
|
|
410
|
+
lineOf: (offset) => lineIndex.positionAt(offset).line,
|
|
411
|
+
visibility: options?.visibility,
|
|
412
|
+
checks: options?.checks ?? new Map(),
|
|
413
|
+
};
|
|
414
|
+
const t1 = options?.timing ? performance.now() : 0;
|
|
415
|
+
let widgets = collectWidgets(result.root.statements, ctx);
|
|
416
|
+
// Nothing is instantiated here: preview what the file DECLARES instead of an
|
|
417
|
+
// empty canvas (see `declaredRoots`).
|
|
418
|
+
let declared = false;
|
|
419
|
+
if (widgets.length === 0) {
|
|
420
|
+
widgets = declaredRoots(result.root.statements, ctx);
|
|
421
|
+
declared = widgets.length > 0;
|
|
422
|
+
}
|
|
423
|
+
const root: LayoutRect = { x: 0, y: 0, w: viewport.w, h: viewport.h };
|
|
424
|
+
const chain: ExplainChain | undefined = options?.explain ? { sink: options.explain } : undefined;
|
|
425
|
+
const nodes = widgets.map((w) => arrange(w, root, "plain", measurer, undefined, chain));
|
|
426
|
+
if (declared) for (const n of nodes) n.declared = true;
|
|
427
|
+
if (options?.timing) {
|
|
428
|
+
options.timing.parseMs = t1 - t0;
|
|
429
|
+
options.timing.layoutMs = performance.now() - t1;
|
|
430
|
+
}
|
|
431
|
+
return nodes;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* The def store a document is laid out against: the cross-file store with the
|
|
436
|
+
* document's OWN declarations layered in (its local_templates always win, other
|
|
437
|
+
* names only when the store has none, FIOS). Exported so a reader about the
|
|
438
|
+
* same document resolves the same names the rendering did.
|
|
439
|
+
*/
|
|
440
|
+
export function effectiveDefs(text: string, store?: GuiDefs): GuiDefs {
|
|
441
|
+
const own = collectGuiDefs(text);
|
|
442
|
+
if (!store) return own;
|
|
443
|
+
const merged = emptyGuiDefs();
|
|
444
|
+
for (const [k, v] of store.types) merged.types.set(k, v);
|
|
445
|
+
for (const [k, v] of own.types) if (!merged.types.has(k)) merged.types.set(k, v);
|
|
446
|
+
for (const [k, v] of store.templates) merged.templates.set(k, v);
|
|
447
|
+
for (const [k, v] of own.templates) {
|
|
448
|
+
if (v.local || !merged.templates.has(k)) merged.templates.set(k, v);
|
|
449
|
+
}
|
|
450
|
+
return merged;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** Top-level `@name = 42` gui constants, referenced as `@name` in values. */
|
|
454
|
+
function collectConstants(statements: Statement[]): Map<string, number> {
|
|
455
|
+
const consts = new Map<string, number>();
|
|
456
|
+
for (const stmt of statements) {
|
|
457
|
+
if (stmt.kind !== "assignment" || !stmt.key.text.startsWith("@")) continue;
|
|
458
|
+
if (stmt.value?.kind !== "scalar") continue;
|
|
459
|
+
const v = parseFloat(stmt.value.text);
|
|
460
|
+
if (Number.isFinite(v)) consts.set(stmt.key.text, v);
|
|
461
|
+
}
|
|
462
|
+
return consts;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// ---------------------------------------------------------------------------
|
|
466
|
+
// CST -> raw widget nodes
|
|
467
|
+
// ---------------------------------------------------------------------------
|
|
468
|
+
|
|
469
|
+
export type WidgetClass =
|
|
470
|
+
| "plain" // widget, window, button, icon, ... : explicit size or ZERO (B4-T1)
|
|
471
|
+
| "box" // hbox, vbox
|
|
472
|
+
| "flow" // flowcontainer
|
|
473
|
+
| "container" // container: hugs at origin, empty = 0 (B2-I4, L25)
|
|
474
|
+
| "item" // datamodel item template: content-sizes like a container (L10)
|
|
475
|
+
| "grid" // fixedgridbox / dynamicgridbox (L14, L15)
|
|
476
|
+
| "marginwidget" // margin offsets children (B3-Q2, B4-T3)
|
|
477
|
+
| "scrollarea" // scrollarea / scrollbox: clips (B3-R1, L17b)
|
|
478
|
+
| "textbox" // text metrics sizing
|
|
479
|
+
| "expand"; // growing spacer (B4-T8)
|
|
480
|
+
|
|
481
|
+
interface WNode {
|
|
482
|
+
key: string;
|
|
483
|
+
cls: WidgetClass;
|
|
484
|
+
/** grid only: fixedgridbox (addcolumn/addrow ARE the cell size and stride). */
|
|
485
|
+
fixedCells: boolean;
|
|
486
|
+
vertical: boolean; // vbox / flow direction=vertical
|
|
487
|
+
props: Map<string, ScalarNode>;
|
|
488
|
+
pairs: Map<string, number[]>; // size/position/margin/color number lists
|
|
489
|
+
sizePct: [boolean, boolean]; // per-axis: size value is a percentage (B4-T2)
|
|
490
|
+
consts: Map<string, number>;
|
|
491
|
+
line?: number;
|
|
492
|
+
ownLine: boolean; // line points at this widget's own statement
|
|
493
|
+
/** Rank among the parent body's reorder siblings; see LayoutNode.srcIndex. */
|
|
494
|
+
srcIndex?: number;
|
|
495
|
+
/** Resolved once in the build phase, per the request's visibility mode. */
|
|
496
|
+
invisible: boolean;
|
|
497
|
+
bg?: Fill;
|
|
498
|
+
ghost?: boolean; // placeholder copy of a datamodel item template
|
|
499
|
+
/**
|
|
500
|
+
* Resolved `item = {}` wrapper from a datamodel container, captured during
|
|
501
|
+
* process() and stamped out as ghost copies. The wrapper NODE is kept rather
|
|
502
|
+
* than spliced away: a datamodel item content-sizes to the bounding box of
|
|
503
|
+
* its children the way a container does, and a gridbox needs that rect (L10).
|
|
504
|
+
*/
|
|
505
|
+
itemTemplate?: WNode;
|
|
506
|
+
children: WNode[];
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Attribute blocks that are data, not child widgets (a superset of
|
|
511
|
+
* guiTree.ts's list: layout also reads `background`/`state`/`block` blocks).
|
|
512
|
+
* `minimumsize = { w h }` belongs here and used to be walked as a phantom
|
|
513
|
+
* child widget, which cost a box child a whole space-around slot (L04c).
|
|
514
|
+
* Exported because `sourceModel.ts` splits widgets from properties by the same
|
|
515
|
+
* set: if the writer and the engine disagreed here, a preview selection could
|
|
516
|
+
* address an attribute block as if it were a widget.
|
|
517
|
+
*/
|
|
518
|
+
export const PROPERTY_BLOCKS = new Set([
|
|
519
|
+
"size",
|
|
520
|
+
"minimumsize",
|
|
521
|
+
"position",
|
|
522
|
+
"framesize",
|
|
523
|
+
"spriteborder",
|
|
524
|
+
"color",
|
|
525
|
+
"disabledcolor",
|
|
526
|
+
"uv_scale",
|
|
527
|
+
"margin",
|
|
528
|
+
"padding",
|
|
529
|
+
"mipmaplodbias",
|
|
530
|
+
"modify_texture",
|
|
531
|
+
"resizeparent",
|
|
532
|
+
"soundeffect",
|
|
533
|
+
"cursor_properties",
|
|
534
|
+
"background",
|
|
535
|
+
"state",
|
|
536
|
+
"animation",
|
|
537
|
+
"attachanimation",
|
|
538
|
+
"blockoverride",
|
|
539
|
+
"block",
|
|
540
|
+
]);
|
|
541
|
+
|
|
542
|
+
const CLASS_BY_KEY: Record<string, WidgetClass> = {
|
|
543
|
+
hbox: "box",
|
|
544
|
+
vbox: "box",
|
|
545
|
+
flowcontainer: "flow",
|
|
546
|
+
container: "container",
|
|
547
|
+
item: "item",
|
|
548
|
+
fixedgridbox: "grid",
|
|
549
|
+
dynamicgridbox: "grid",
|
|
550
|
+
margin_widget: "marginwidget",
|
|
551
|
+
scrollarea: "scrollarea",
|
|
552
|
+
scrollbox: "scrollarea", // same viewport behavior; both clip (L17b)
|
|
553
|
+
textbox: "textbox",
|
|
554
|
+
text_single: "textbox",
|
|
555
|
+
text_multi: "textbox",
|
|
556
|
+
editbox: "textbox",
|
|
557
|
+
expand: "expand",
|
|
558
|
+
};
|
|
559
|
+
|
|
560
|
+
function classify(key: string): WidgetClass {
|
|
561
|
+
return CLASS_BY_KEY[key] ?? "plain";
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* The engine's own key -> class mapping, for the WRITER's refusal guards: a
|
|
566
|
+
* resize is refused on a content-sized class, a drag on a child of a layout
|
|
567
|
+
* container. Exported rather than copied so a guard can never claim a rule the
|
|
568
|
+
* engine does not apply. The caller resolves a type instance to its base key
|
|
569
|
+
* first (`typeBaseChain`), the way `buildWNode` does.
|
|
570
|
+
*/
|
|
571
|
+
export function widgetClassOf(baseKey: string): WidgetClass {
|
|
572
|
+
return classify(baseKey.toLowerCase());
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function blockOf(stmt: Statement): BlockNode | null {
|
|
576
|
+
if (stmt.kind !== "assignment" || !stmt.value) return null;
|
|
577
|
+
if (stmt.value.kind === "block") return stmt.value;
|
|
578
|
+
if (stmt.value.kind === "tagged-block") return stmt.value.block;
|
|
579
|
+
return null;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Numeric value with @constant resolution; anything unresolvable (data
|
|
584
|
+
* bindings, unknown macros) becomes 0 so rects stay finite on real vanilla
|
|
585
|
+
* files (verified over all 373 game .gui files).
|
|
586
|
+
*/
|
|
587
|
+
function toNumber(text: string, consts: Map<string, number>): number {
|
|
588
|
+
if (text.startsWith("@")) return consts.get(text) ?? 0;
|
|
589
|
+
const v = parseFloat(text);
|
|
590
|
+
return Number.isFinite(v) ? v : 0;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function numbersIn(block: BlockNode, consts: Map<string, number>): number[] {
|
|
594
|
+
const out: number[] = [];
|
|
595
|
+
for (const s of block.statements) {
|
|
596
|
+
if (s.kind === "value" && s.value.kind === "scalar") {
|
|
597
|
+
out.push(toNumber(s.value.text, consts));
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
return out;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
interface BuildCtx {
|
|
604
|
+
consts: Map<string, number>;
|
|
605
|
+
defs: GuiDefs;
|
|
606
|
+
/**
|
|
607
|
+
* blockoverride map inherited from ancestor instances; outer overrides win
|
|
608
|
+
* over inner ones (an instance override reaches into blocks declared deep
|
|
609
|
+
* inside the type's subtree, the PoD resource-bar pattern).
|
|
610
|
+
*/
|
|
611
|
+
overrides: Map<string, BlockNode>;
|
|
612
|
+
/** Type keys currently being instantiated, to break recursion cycles. */
|
|
613
|
+
stack: string[];
|
|
614
|
+
/** Offset -> 0-based line in the current document. */
|
|
615
|
+
lineOf: (offset: number) => number;
|
|
616
|
+
/** Conditional-visibility mode; absent = showAll. */
|
|
617
|
+
visibility?: VisibilityOptions;
|
|
618
|
+
/** Every conditional `visible` met, accumulated across the whole document. */
|
|
619
|
+
checks: Map<string, VisibilityCheck>;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Subtrees that never take part in window layout. Tooltips are created
|
|
624
|
+
* lazily in-engine, which is also how vanilla legally ships type cycles
|
|
625
|
+
* through them (a tooltip containing its own widget type).
|
|
626
|
+
*/
|
|
627
|
+
const SKIP_SUBTREES = new Set(["tooltipwidget"]);
|
|
628
|
+
|
|
629
|
+
function buildWNode(
|
|
630
|
+
key: string,
|
|
631
|
+
block: BlockNode,
|
|
632
|
+
ctx: BuildCtx,
|
|
633
|
+
line?: number,
|
|
634
|
+
ownLine = false,
|
|
635
|
+
srcIndex?: number
|
|
636
|
+
): WNode {
|
|
637
|
+
const lower = key.toLowerCase();
|
|
638
|
+
// Cycle/depth guard: a TYPE instantiated inside its own expansion gets no
|
|
639
|
+
// type expansion (instance statements + templates only), which breaks
|
|
640
|
+
// mutual-recursion chains that the real engine only resolves lazily.
|
|
641
|
+
// Builtin keys (widget in widget) are not cycles and are never pushed.
|
|
642
|
+
const isType = ctx.defs.types.has(lower);
|
|
643
|
+
const cyclic = (isType && ctx.stack.includes(lower)) || ctx.stack.length > 64;
|
|
644
|
+
const { baseKey, statements } = expandWidget(lower, block, ctx.defs, cyclic);
|
|
645
|
+
const node: WNode = {
|
|
646
|
+
key: lower,
|
|
647
|
+
cls: classify(baseKey),
|
|
648
|
+
fixedCells: baseKey === "fixedgridbox",
|
|
649
|
+
vertical: baseKey === "vbox",
|
|
650
|
+
props: new Map(),
|
|
651
|
+
pairs: new Map(),
|
|
652
|
+
sizePct: [false, false],
|
|
653
|
+
consts: ctx.consts,
|
|
654
|
+
line,
|
|
655
|
+
ownLine,
|
|
656
|
+
srcIndex,
|
|
657
|
+
invisible: false,
|
|
658
|
+
children: [],
|
|
659
|
+
};
|
|
660
|
+
// Local block overrides, shadowed by inherited (outer) ones.
|
|
661
|
+
const rootOverrides = new Map(collectBlockOverrides(statements));
|
|
662
|
+
for (const [k, v] of ctx.overrides) rootOverrides.set(k, v);
|
|
663
|
+
const childStack = isType ? [...ctx.stack, lower] : ctx.stack;
|
|
664
|
+
// The reorder ranks of this instance's OWN body. Statements spliced in from a
|
|
665
|
+
// type, a template or a named slot are not in it, so they get no srcIndex:
|
|
666
|
+
// their index would count a body that is not the one the client would address.
|
|
667
|
+
const ranks = siblingRanks(block.statements);
|
|
668
|
+
|
|
669
|
+
// `ov` is threaded explicitly: an override is CONSUMED when applied, so a
|
|
670
|
+
// block re-declaring its own name inside override content (vanilla's
|
|
671
|
+
// cooltip chaining pattern) falls back to the default instead of recursing.
|
|
672
|
+
let usingDepth = 0;
|
|
673
|
+
const process = (stmts: Statement[], ov: Map<string, BlockNode>): void => {
|
|
674
|
+
let marker: "block" | "blockoverride" | null = null;
|
|
675
|
+
for (const stmt of stmts) {
|
|
676
|
+
if (stmt.kind === "value") {
|
|
677
|
+
const t = stmt.value.kind === "scalar" ? stmt.value.text.toLowerCase() : "";
|
|
678
|
+
marker = t === "block" ? "block" : t === "blockoverride" ? "blockoverride" : null;
|
|
679
|
+
continue;
|
|
680
|
+
}
|
|
681
|
+
const m = marker;
|
|
682
|
+
marker = null;
|
|
683
|
+
if (m === "blockoverride") continue; // consumed by collectBlockOverrides
|
|
684
|
+
const k = stmt.key.text.toLowerCase();
|
|
685
|
+
const child = blockOf(stmt);
|
|
686
|
+
if (k === "using" && stmt.value?.kind === "scalar" && !m) {
|
|
687
|
+
// expandWidget spliced the top-level `using`s; one inside a block's
|
|
688
|
+
// content (vanilla: `block "scrollbox_margins" { using =
|
|
689
|
+
// Scrollbox_Margins }`) or an override body reaches here and is
|
|
690
|
+
// spliced the same way, in place.
|
|
691
|
+
const tpl = ctx.defs.templates.get(stmt.value.text);
|
|
692
|
+
if (tpl && usingDepth < 8) {
|
|
693
|
+
usingDepth++;
|
|
694
|
+
process(tpl.block.statements, ov);
|
|
695
|
+
usingDepth--;
|
|
696
|
+
}
|
|
697
|
+
continue;
|
|
698
|
+
}
|
|
699
|
+
if (m === "block") {
|
|
700
|
+
// Named slot: overridden content (or its own default), spliced inline.
|
|
701
|
+
const override = ov.get(stmt.key.text);
|
|
702
|
+
const content = override ?? child;
|
|
703
|
+
let sub = ov;
|
|
704
|
+
if (override) {
|
|
705
|
+
sub = new Map(ov);
|
|
706
|
+
sub.delete(stmt.key.text);
|
|
707
|
+
}
|
|
708
|
+
if (content) process(content.statements, sub);
|
|
709
|
+
continue;
|
|
710
|
+
}
|
|
711
|
+
if (child) {
|
|
712
|
+
if (k === "item") {
|
|
713
|
+
// Datamodel item template: `item = { <widget> }` holds one instance
|
|
714
|
+
// of the per-row widget (the universal vanilla pattern, verified in
|
|
715
|
+
// window_character.gui skills hbox + modifiers fixedgridbox: item is
|
|
716
|
+
// always a plain wrapper whose children are the row widget). Captured
|
|
717
|
+
// here, stamped out as ghost copies after process(). The wrapper node
|
|
718
|
+
// survives because the item has a rect of its own: it content-sizes
|
|
719
|
+
// to the bounding box of its children (L10).
|
|
720
|
+
node.itemTemplate = buildWNode(
|
|
721
|
+
"item",
|
|
722
|
+
child,
|
|
723
|
+
{ ...ctx, overrides: ov, stack: childStack },
|
|
724
|
+
line,
|
|
725
|
+
false
|
|
726
|
+
);
|
|
727
|
+
continue;
|
|
728
|
+
}
|
|
729
|
+
if (SKIP_SUBTREES.has(k)) {
|
|
730
|
+
continue;
|
|
731
|
+
} else if (k === "background") {
|
|
732
|
+
node.bg = resolveFill(child, ctx.consts, ctx.defs, ov);
|
|
733
|
+
} else if (k === "modify_texture") {
|
|
734
|
+
const mask = maskOf(child);
|
|
735
|
+
if (mask) node.props.set("#mask", fakeScalar(mask));
|
|
736
|
+
} else if (k === "scrollwidget") {
|
|
737
|
+
// Pass-through: scrollarea content renders at the viewport origin
|
|
738
|
+
// with no rect of its own observed (B3-R1).
|
|
739
|
+
const inner = buildWNode("scrollwidget", child, {
|
|
740
|
+
...ctx,
|
|
741
|
+
overrides: ov,
|
|
742
|
+
stack: childStack,
|
|
743
|
+
});
|
|
744
|
+
// Adopted by the scrollarea, so their ranks count a body the client
|
|
745
|
+
// could not address: an index into the scrollwidget's children read
|
|
746
|
+
// as an index into the scrollarea's would move the wrong block.
|
|
747
|
+
for (const c of inner.children) c.srcIndex = undefined;
|
|
748
|
+
node.children.push(...inner.children);
|
|
749
|
+
} else if (PROPERTY_BLOCKS.has(k)) {
|
|
750
|
+
node.pairs.set(k, numbersIn(child, ctx.consts));
|
|
751
|
+
if (k === "size") {
|
|
752
|
+
const vals: number[] = [];
|
|
753
|
+
let i = 0;
|
|
754
|
+
for (const s of child.statements) {
|
|
755
|
+
if (s.kind === "value" && s.value.kind === "scalar") {
|
|
756
|
+
const t = s.value.text;
|
|
757
|
+
if (i < 2) node.sizePct[i] = t.endsWith("%");
|
|
758
|
+
vals.push(toNumber(t.endsWith("%") ? t.slice(0, -1) : t, ctx.consts));
|
|
759
|
+
i++;
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
node.pairs.set("size", vals);
|
|
763
|
+
}
|
|
764
|
+
} else {
|
|
765
|
+
// Line info only for statements physically inside this instance's
|
|
766
|
+
// block (type-def content lives in other files): children spliced
|
|
767
|
+
// from types inherit the instance's line.
|
|
768
|
+
const inInstance = stmt.range.start >= block.range.start && stmt.range.end <= block.range.end;
|
|
769
|
+
const childLine = inInstance ? ctx.lineOf(stmt.key.range.start) : line;
|
|
770
|
+
node.children.push(
|
|
771
|
+
buildWNode(
|
|
772
|
+
stmt.key.text,
|
|
773
|
+
child,
|
|
774
|
+
{ ...ctx, overrides: ov, stack: childStack },
|
|
775
|
+
childLine,
|
|
776
|
+
inInstance,
|
|
777
|
+
ranks.get(stmt)
|
|
778
|
+
)
|
|
779
|
+
);
|
|
780
|
+
}
|
|
781
|
+
} else if (stmt.value?.kind === "scalar") {
|
|
782
|
+
// A widget may carry several `onclick` lines; the game runs them all,
|
|
783
|
+
// so keep every one (newline-joined) rather than the last.
|
|
784
|
+
const prior = k === "onclick" ? node.props.get(k) : undefined;
|
|
785
|
+
node.props.set(
|
|
786
|
+
k,
|
|
787
|
+
prior
|
|
788
|
+
? {
|
|
789
|
+
...stmt.value,
|
|
790
|
+
text: `${prior.text}
|
|
791
|
+
${stmt.value.text}`,
|
|
792
|
+
}
|
|
793
|
+
: stmt.value
|
|
794
|
+
);
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
};
|
|
798
|
+
process(statements, rootOverrides);
|
|
799
|
+
if (node.props.get("direction")?.text.toLowerCase() === "vertical") node.vertical = true;
|
|
800
|
+
|
|
801
|
+
// Builtin fallbacks for the vanilla label types (gui/preload/labels.gui)
|
|
802
|
+
// when no defs store provides the real definitions. text_multi's hardcoded
|
|
803
|
+
// 45x45 bit us in B2-L; reproduce it faithfully.
|
|
804
|
+
if (lower === "text_single" && !ctx.defs.types.has("text_single")) {
|
|
805
|
+
if (!node.props.has("autoresize")) node.props.set("autoresize", fakeScalar("yes"));
|
|
806
|
+
}
|
|
807
|
+
if (lower === "text_multi" && !ctx.defs.types.has("text_multi")) {
|
|
808
|
+
if (!node.pairs.has("size")) node.pairs.set("size", [45, 45]);
|
|
809
|
+
if (!node.props.has("multiline")) node.props.set("multiline", fakeScalar("yes"));
|
|
810
|
+
}
|
|
811
|
+
// Resolved once, after expansion has settled `visible` last-in-wins.
|
|
812
|
+
node.invisible = resolveVisibility(node.props.get("visible")?.text, ctx);
|
|
813
|
+
|
|
814
|
+
// unmeasured: placeholder presentation, not a calibrated layout rule.
|
|
815
|
+
// A datamodel list has no runtime rows in a static preview, so it would draw
|
|
816
|
+
// empty. Assumption: each data row is one instance of the `item` template
|
|
817
|
+
// laid out as a normal child. Stamp GHOST_COUNT (capped) ghost copies so the
|
|
818
|
+
// container's real layout policy (box/flow stacking) is visible. Reuses the
|
|
819
|
+
// already-resolved template widgets; no extra expansion machinery.
|
|
820
|
+
if (node.itemTemplate && node.itemTemplate.children.length > 0) {
|
|
821
|
+
markGhost(node.itemTemplate);
|
|
822
|
+
const count = ghostCount(node);
|
|
823
|
+
for (let i = 0; i < count; i++) node.children.push(node.itemTemplate);
|
|
824
|
+
}
|
|
825
|
+
return node;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
/** Flag a template subtree as a placeholder (non-editable, dimmed by the client). */
|
|
829
|
+
function markGhost(node: WNode): void {
|
|
830
|
+
node.ghost = true;
|
|
831
|
+
for (const c of node.children) markGhost(c);
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
/**
|
|
835
|
+
* How many ghost rows to draw. GHOST_COUNT, but capped to what the container's
|
|
836
|
+
* own explicit size can hold on its main axis when both that size and the
|
|
837
|
+
* item's explicit size are known (so a small fixed list never overruns). Runs
|
|
838
|
+
* in the build phase, so it uses authored sizes only, no text measurement.
|
|
839
|
+
*/
|
|
840
|
+
function ghostCount(node: WNode): number {
|
|
841
|
+
const size = explicitSize(node);
|
|
842
|
+
if (!size || !node.itemTemplate) return GHOST_COUNT;
|
|
843
|
+
const avail = node.vertical ? size.h : size.w;
|
|
844
|
+
if (avail <= 0) return GHOST_COUNT;
|
|
845
|
+
const extent = staticExtent(node.itemTemplate);
|
|
846
|
+
if (!extent) return GHOST_COUNT; // item bounds unknown: no cap
|
|
847
|
+
const itemMain = node.vertical ? extent.h : extent.w;
|
|
848
|
+
if (itemMain <= 0) return GHOST_COUNT;
|
|
849
|
+
return Math.max(1, Math.min(GHOST_COUNT, Math.floor(avail / itemMain)));
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/**
|
|
853
|
+
* The item template's bounding box from AUTHORED sizes alone (the build phase
|
|
854
|
+
* has no measurer, so a text row stays unknown). Null when any child lacks an
|
|
855
|
+
* explicit size.
|
|
856
|
+
*/
|
|
857
|
+
function staticExtent(item: WNode): { w: number; h: number } | null {
|
|
858
|
+
let w = 0;
|
|
859
|
+
let h = 0;
|
|
860
|
+
for (const c of item.children) {
|
|
861
|
+
const s = explicitSize(c);
|
|
862
|
+
if (!s) return null;
|
|
863
|
+
const pos = c.pairs.get("position") ?? [0, 0];
|
|
864
|
+
w = Math.max(w, (pos[0] ?? 0) + s.w);
|
|
865
|
+
h = Math.max(h, (pos[1] ?? 0) + s.h);
|
|
866
|
+
}
|
|
867
|
+
return { w, h };
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
function fakeScalar(text: string): ScalarNode {
|
|
871
|
+
return { kind: "scalar", text, quoted: false, range: { start: 0, end: 0 } };
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
/**
|
|
875
|
+
* The Fill a `background = { ... }` block produces, `using =` templates
|
|
876
|
+
* spliced. Exported so the inspector reads a background exactly the way the
|
|
877
|
+
* canvas drew it instead of re-deriving the same attributes.
|
|
878
|
+
*/
|
|
879
|
+
export function resolveFill(
|
|
880
|
+
block: BlockNode,
|
|
881
|
+
consts: Map<string, number>,
|
|
882
|
+
defs: GuiDefs,
|
|
883
|
+
overrides: ReadonlyMap<string, BlockNode> = new Map()
|
|
884
|
+
): Fill {
|
|
885
|
+
const fill: Fill = {};
|
|
886
|
+
// `background = { using = Background_Area_Dark }` carries its texture via
|
|
887
|
+
// the template; expandWidget with an unknown key just splices templates.
|
|
888
|
+
// A `block "illustration_texture" { texture = ... }` inside it takes the
|
|
889
|
+
// instance's blockoverride, the way a widget's own slots do: vanilla's
|
|
890
|
+
// widget_header_with_picture names its picture exactly so.
|
|
891
|
+
const statements = spliceBlocks(expandWidget("#background", block, defs).statements, overrides, defs);
|
|
892
|
+
let sprite: number[] | undefined;
|
|
893
|
+
let spriteType: string | undefined;
|
|
894
|
+
let framesize: number[] | undefined;
|
|
895
|
+
let frame: number | undefined;
|
|
896
|
+
const side: { l?: number; t?: number; r?: number; b?: number } = {};
|
|
897
|
+
for (const stmt of statements) {
|
|
898
|
+
if (stmt.kind !== "assignment") continue;
|
|
899
|
+
const k = stmt.key.text.toLowerCase();
|
|
900
|
+
if (k === "texture" && stmt.value?.kind === "scalar") fill.texture = stmt.value.text;
|
|
901
|
+
if (k === "spritetype" && stmt.value?.kind === "scalar") spriteType = stmt.value.text;
|
|
902
|
+
if (k === "alpha" && stmt.value?.kind === "scalar") fill.alpha = toNumber(stmt.value.text, consts);
|
|
903
|
+
if (k === "fittype" && stmt.value?.kind === "scalar" && stmt.value.text.toLowerCase() === "centercrop")
|
|
904
|
+
fill.fit = "centercrop";
|
|
905
|
+
if (k === "modify_texture") {
|
|
906
|
+
const b = blockOf(stmt);
|
|
907
|
+
const mask = b ? maskOf(b) : undefined;
|
|
908
|
+
if (mask) fill.mask = mask;
|
|
909
|
+
}
|
|
910
|
+
if (k === "frame" && stmt.value?.kind === "scalar") frame = toNumber(stmt.value.text, consts);
|
|
911
|
+
if (k === "framesize") {
|
|
912
|
+
const b = blockOf(stmt);
|
|
913
|
+
if (b) framesize = numbersIn(b, consts);
|
|
914
|
+
}
|
|
915
|
+
if (k === "color") {
|
|
916
|
+
const b = blockOf(stmt);
|
|
917
|
+
if (b) {
|
|
918
|
+
const v = numbersIn(b, consts);
|
|
919
|
+
if (v.length >= 3) fill.color = [v[0], v[1], v[2], v[3] ?? 1];
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
// Nine-slice: `spriteborder = { x y }` (x=left/right, y=top/bottom) plus
|
|
923
|
+
// per-side scalar overrides. Reachable straight off the background block.
|
|
924
|
+
if (k === "spriteborder") {
|
|
925
|
+
const b = blockOf(stmt);
|
|
926
|
+
if (b) sprite = numbersIn(b, consts);
|
|
927
|
+
}
|
|
928
|
+
if (k.startsWith("spriteborder_") && stmt.value?.kind === "scalar") {
|
|
929
|
+
const v = toNumber(stmt.value.text, consts);
|
|
930
|
+
if (k === "spriteborder_left") side.l = v;
|
|
931
|
+
else if (k === "spriteborder_top") side.t = v;
|
|
932
|
+
else if (k === "spriteborder_right") side.r = v;
|
|
933
|
+
else if (k === "spriteborder_bottom") side.b = v;
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
const border = borderTuple(sprite, side);
|
|
937
|
+
if (border) fill.border = border;
|
|
938
|
+
if (fill.texture !== undefined) {
|
|
939
|
+
fill.mode = fillMode(spriteType, border); // Studio §J, L21a-d
|
|
940
|
+
if (framesize && framesize.length >= 2) {
|
|
941
|
+
// Studio §L, L22: the sheet grid; `frame` defaults to the first cell.
|
|
942
|
+
fill.framesize = [framesize[0], framesize[1]];
|
|
943
|
+
fill.frame = frame ?? 1;
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
return fill;
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
/**
|
|
950
|
+
* The statements with every `block "name" { ... }` slot replaced by its
|
|
951
|
+
* override (or its own default content), recursively, an override consumed
|
|
952
|
+
* once it is applied (the same rule buildWNode's process() follows).
|
|
953
|
+
*/
|
|
954
|
+
function spliceBlocks(
|
|
955
|
+
statements: Statement[],
|
|
956
|
+
overrides: ReadonlyMap<string, BlockNode>,
|
|
957
|
+
defs?: GuiDefs,
|
|
958
|
+
depth = 0
|
|
959
|
+
): Statement[] {
|
|
960
|
+
const out: Statement[] = [];
|
|
961
|
+
let marker: "block" | "blockoverride" | null = null;
|
|
962
|
+
for (const stmt of statements) {
|
|
963
|
+
if (stmt.kind === "value") {
|
|
964
|
+
const t = stmt.value.kind === "scalar" ? stmt.value.text.toLowerCase() : "";
|
|
965
|
+
marker = t === "block" ? "block" : t === "blockoverride" ? "blockoverride" : null;
|
|
966
|
+
continue;
|
|
967
|
+
}
|
|
968
|
+
const m = marker;
|
|
969
|
+
marker = null;
|
|
970
|
+
if (m === "blockoverride") continue;
|
|
971
|
+
if (m === "block") {
|
|
972
|
+
const override = overrides.get(stmt.key.text);
|
|
973
|
+
const content = override ?? blockOf(stmt);
|
|
974
|
+
if (!content) continue;
|
|
975
|
+
let sub = overrides;
|
|
976
|
+
if (override) {
|
|
977
|
+
const next = new Map(overrides);
|
|
978
|
+
next.delete(stmt.key.text);
|
|
979
|
+
sub = next;
|
|
980
|
+
}
|
|
981
|
+
out.push(...spliceBlocks(content.statements, sub, defs, depth + 1));
|
|
982
|
+
continue;
|
|
983
|
+
}
|
|
984
|
+
if (stmt.key.text.toLowerCase() === "using" && stmt.value?.kind === "scalar" && defs && depth < 8) {
|
|
985
|
+
const tpl = defs.templates.get(stmt.value.text);
|
|
986
|
+
if (tpl) out.push(...spliceBlocks(tpl.block.statements, overrides, defs, depth + 1));
|
|
987
|
+
continue;
|
|
988
|
+
}
|
|
989
|
+
out.push(stmt);
|
|
990
|
+
}
|
|
991
|
+
return out;
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
/** The mask texture of a `modify_texture` block when its blend is alphamultiply (the only blend drawn). */
|
|
995
|
+
function maskOf(block: BlockNode): string | undefined {
|
|
996
|
+
let texture: string | undefined;
|
|
997
|
+
let blend = "";
|
|
998
|
+
for (const stmt of block.statements) {
|
|
999
|
+
if (stmt.kind !== "assignment" || stmt.value?.kind !== "scalar") continue;
|
|
1000
|
+
const k = stmt.key.text.toLowerCase();
|
|
1001
|
+
if (k === "texture") texture = stmt.value.text;
|
|
1002
|
+
if (k === "blend_mode") blend = stmt.value.text.toLowerCase();
|
|
1003
|
+
}
|
|
1004
|
+
return texture && blend === "alphamultiply" ? texture : undefined;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
/**
|
|
1008
|
+
* Resolve `spriteborder = { x y }` (x = left & right, y = top & bottom) plus
|
|
1009
|
+
* per-side overrides into [left, top, right, bottom], or undefined when no
|
|
1010
|
+
* border attribute is present.
|
|
1011
|
+
*/
|
|
1012
|
+
function borderTuple(
|
|
1013
|
+
pair: number[] | undefined,
|
|
1014
|
+
side: { l?: number; t?: number; r?: number; b?: number }
|
|
1015
|
+
): [number, number, number, number] | undefined {
|
|
1016
|
+
const any =
|
|
1017
|
+
pair !== undefined ||
|
|
1018
|
+
side.l !== undefined ||
|
|
1019
|
+
side.t !== undefined ||
|
|
1020
|
+
side.r !== undefined ||
|
|
1021
|
+
side.b !== undefined;
|
|
1022
|
+
if (!any) return undefined;
|
|
1023
|
+
const x = pair?.[0] ?? 0;
|
|
1024
|
+
const y = pair?.[1] ?? 0;
|
|
1025
|
+
return [side.l ?? x, side.t ?? y, side.r ?? x, side.b ?? y];
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
function collectWidgets(statements: Statement[], ctx: BuildCtx): WNode[] {
|
|
1029
|
+
const out: WNode[] = [];
|
|
1030
|
+
const ranks = siblingRanks(statements);
|
|
1031
|
+
let isDecl = false;
|
|
1032
|
+
for (const stmt of statements) {
|
|
1033
|
+
if (stmt.kind === "value") {
|
|
1034
|
+
// A bare `template` / `types` / `type` word marks the next assignment
|
|
1035
|
+
// as a declaration (collected by guiDefs), not a live widget.
|
|
1036
|
+
isDecl =
|
|
1037
|
+
stmt.value.kind === "scalar" &&
|
|
1038
|
+
["template", "local_template", "types", "type"].includes(stmt.value.text.toLowerCase());
|
|
1039
|
+
continue;
|
|
1040
|
+
}
|
|
1041
|
+
const decl = isDecl;
|
|
1042
|
+
isDecl = false;
|
|
1043
|
+
const block = blockOf(stmt);
|
|
1044
|
+
if (!block || decl) continue;
|
|
1045
|
+
const k = stmt.key.text.toLowerCase();
|
|
1046
|
+
if (PROPERTY_BLOCKS.has(k)) continue;
|
|
1047
|
+
if (k.startsWith("@")) continue;
|
|
1048
|
+
out.push(buildWNode(stmt.key.text, block, ctx, ctx.lineOf(stmt.key.range.start), true, ranks.get(stmt)));
|
|
1049
|
+
}
|
|
1050
|
+
return out;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
/**
|
|
1054
|
+
* The fallback roots for a document that instantiates NOTHING at top level:
|
|
1055
|
+
* one laid-out instance per `type name = base { }` the document declares, in
|
|
1056
|
+
* declaration order.
|
|
1057
|
+
*
|
|
1058
|
+
* Where the engine instantiates a window BY NAME from code, that is how whole
|
|
1059
|
+
* panels are written: one `types Group { type panel = base_window { … } }` and
|
|
1060
|
+
* no instance at all. Two thirds of one shipped game's vanilla gui tree (132 of
|
|
1061
|
+
* 204 files) and three quarters of the largest community framework built on it
|
|
1062
|
+
* are that shape, and every one of them laid out to nothing.
|
|
1063
|
+
*
|
|
1064
|
+
* The declaration is expanded as its BASE plus its own body, which is exactly
|
|
1065
|
+
* one instantiation: passing the declared name would splice the definition into
|
|
1066
|
+
* itself. `ownLine` is false because the `type X = base` header is a
|
|
1067
|
+
* declaration, not an editable widget (`guiSourceEdit` and `guiWidgetInfo` both
|
|
1068
|
+
* refuse one); the body's children are ordinary statements of this document and
|
|
1069
|
+
* stay editable, since `buildWNode` measures them against the block it is given.
|
|
1070
|
+
*/
|
|
1071
|
+
function declaredRoots(statements: Statement[], ctx: BuildCtx): WNode[] {
|
|
1072
|
+
const out: WNode[] = [];
|
|
1073
|
+
for (const [name, def] of collectGuiDefsParsed(statements).types) {
|
|
1074
|
+
// The declared name guards the cycle here; `buildWNode` pushes the base.
|
|
1075
|
+
if (ctx.stack.includes(name)) continue;
|
|
1076
|
+
const node = buildWNode(
|
|
1077
|
+
def.base,
|
|
1078
|
+
def.block,
|
|
1079
|
+
{ ...ctx, stack: [...ctx.stack, name] },
|
|
1080
|
+
ctx.lineOf(def.keyOffset),
|
|
1081
|
+
false
|
|
1082
|
+
);
|
|
1083
|
+
node.key = name;
|
|
1084
|
+
out.push(node);
|
|
1085
|
+
}
|
|
1086
|
+
return out;
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
/**
|
|
1090
|
+
* Every DECLARATION entry of one body, ranked by the index a `reorder`,
|
|
1091
|
+
* `insert` or `delete` op counts in. That list is `GuiBody.children` in
|
|
1092
|
+
* `sourceModel.ts`: widgets AND decl entries, properties skipped. The marker
|
|
1093
|
+
* set and the attribute-block set are the shared ones, so the writer's list and
|
|
1094
|
+
* this ranking cannot drift; a `blockoverride` between two widget children
|
|
1095
|
+
* takes a slot here exactly as it does there, which is what a client counting
|
|
1096
|
+
* only the widgets it can see gets wrong.
|
|
1097
|
+
*
|
|
1098
|
+
* Only widget entries end up in the map, a decl has no layout node to carry a
|
|
1099
|
+
* rank, but every decl still advances the counter.
|
|
1100
|
+
*/
|
|
1101
|
+
function siblingRanks(statements: readonly Statement[]): Map<Statement, number> {
|
|
1102
|
+
const ranks = new Map<Statement, number>();
|
|
1103
|
+
let marker = false;
|
|
1104
|
+
let next = 0;
|
|
1105
|
+
for (const stmt of statements) {
|
|
1106
|
+
if (stmt.kind === "value") {
|
|
1107
|
+
marker = stmt.value.kind === "scalar" && DECL_MARKERS.has(stmt.value.text.toLowerCase());
|
|
1108
|
+
continue;
|
|
1109
|
+
}
|
|
1110
|
+
const declared = marker;
|
|
1111
|
+
marker = false;
|
|
1112
|
+
// `blockoverride = "name" { ... }`: the second vanilla spelling of a slot,
|
|
1113
|
+
// one assignment the writer reads as a declaration.
|
|
1114
|
+
const slotForm = stmt.value?.kind === "tagged-block" && SLOT_KEYS.has(stmt.key.text.toLowerCase());
|
|
1115
|
+
if (declared || slotForm) {
|
|
1116
|
+
next++;
|
|
1117
|
+
continue;
|
|
1118
|
+
}
|
|
1119
|
+
const key = stmt.key.text.toLowerCase();
|
|
1120
|
+
if (!blockOf(stmt) || PROPERTY_BLOCKS.has(key) || key.startsWith("@")) continue;
|
|
1121
|
+
ranks.set(stmt, next++);
|
|
1122
|
+
}
|
|
1123
|
+
return ranks;
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
// ---------------------------------------------------------------------------
|
|
1127
|
+
// Property helpers
|
|
1128
|
+
// ---------------------------------------------------------------------------
|
|
1129
|
+
|
|
1130
|
+
function num(node: WNode, key: string): number | undefined {
|
|
1131
|
+
const s = node.props.get(key);
|
|
1132
|
+
if (!s) return undefined;
|
|
1133
|
+
return toNumber(s.text, node.consts);
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
function str(node: WNode, key: string): string | undefined {
|
|
1137
|
+
return node.props.get(key)?.text;
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
function yes(node: WNode, key: string): boolean {
|
|
1141
|
+
return node.props.get(key)?.text.toLowerCase() === "yes";
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
/** margin pair + directional overrides -> [left, top, right, bottom]. (B1-E3, B4-T7) */
|
|
1145
|
+
function margins(node: WNode): [number, number, number, number] {
|
|
1146
|
+
const pair = node.pairs.get("margin");
|
|
1147
|
+
let l = pair?.[0] ?? 0;
|
|
1148
|
+
let t = pair?.[1] ?? 0;
|
|
1149
|
+
let r = pair?.[0] ?? 0;
|
|
1150
|
+
let b = pair?.[1] ?? 0;
|
|
1151
|
+
const ml = num(node, "margin_left");
|
|
1152
|
+
const mt = num(node, "margin_top");
|
|
1153
|
+
const mr = num(node, "margin_right");
|
|
1154
|
+
const mb = num(node, "margin_bottom");
|
|
1155
|
+
if (ml !== undefined) l = ml;
|
|
1156
|
+
if (mt !== undefined) t = mt;
|
|
1157
|
+
if (mr !== undefined) r = mr;
|
|
1158
|
+
if (mb !== undefined) b = mb;
|
|
1159
|
+
return [l, t, r, b];
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
type Policy = "fixed" | "expanding" | "growing" | "preferred" | "shrinking";
|
|
1163
|
+
|
|
1164
|
+
function policy(node: WNode, horizontal: boolean): Policy {
|
|
1165
|
+
if (node.cls === "expand") return "growing"; // B4-T8, B3-P2
|
|
1166
|
+
const p = str(node, horizontal ? "layoutpolicy_horizontal" : "layoutpolicy_vertical");
|
|
1167
|
+
switch (p?.toLowerCase()) {
|
|
1168
|
+
case "expanding":
|
|
1169
|
+
return "expanding";
|
|
1170
|
+
case "growing":
|
|
1171
|
+
return "growing";
|
|
1172
|
+
case "preferred":
|
|
1173
|
+
return "preferred";
|
|
1174
|
+
case "shrinking":
|
|
1175
|
+
return "shrinking";
|
|
1176
|
+
default:
|
|
1177
|
+
return "fixed";
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
/**
|
|
1182
|
+
* Is this widget hidden for layout purposes? `visible = no` is deterministic
|
|
1183
|
+
* and collapses; a `visible = "[binding]"` cannot be evaluated in a static
|
|
1184
|
+
* preview, so by DEFAULT the widget is KEPT even though the engine collapses a
|
|
1185
|
+
* binding that evaluates false at runtime (spec.md `ignoreinvisible`, L27):
|
|
1186
|
+
* showing it is the non-destructive default, and the same unknown makes a
|
|
1187
|
+
* container's content unmeasurable (L11b).
|
|
1188
|
+
*
|
|
1189
|
+
* The preview modes only move that default: `hideAll` collapses every
|
|
1190
|
+
* conditional, `evaluate` collapses the ones the caller assigned false. Every
|
|
1191
|
+
* conditional met is recorded either way, so a client can offer the toggles
|
|
1192
|
+
* before the user has switched mode.
|
|
1193
|
+
*/
|
|
1194
|
+
function resolveVisibility(value: string | undefined, ctx: BuildCtx): boolean {
|
|
1195
|
+
if (value === undefined) return false;
|
|
1196
|
+
const lower = value.toLowerCase();
|
|
1197
|
+
if (lower === "no") return true;
|
|
1198
|
+
if (lower === "yes") return false;
|
|
1199
|
+
const mode = ctx.visibility?.mode ?? "showAll";
|
|
1200
|
+
const hiddenNow =
|
|
1201
|
+
mode === "hideAll" ? true : mode === "evaluate" ? ctx.visibility?.checks?.[value] === false : false;
|
|
1202
|
+
const seen = ctx.checks.get(value);
|
|
1203
|
+
if (seen) seen.count++;
|
|
1204
|
+
else ctx.checks.set(value, { key: value, count: 1, hidden: hiddenNow });
|
|
1205
|
+
return hiddenNow;
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
/** The resolved flag, decided once per widget in the build phase. */
|
|
1209
|
+
function hidden(node: WNode): boolean {
|
|
1210
|
+
return node.invisible;
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
/** `ignoreinvisible` defaults to yes on hbox/vbox (spec.md, L27). */
|
|
1214
|
+
function collapsesHidden(box: WNode): boolean {
|
|
1215
|
+
return box.props.get("ignoreinvisible")?.text.toLowerCase() !== "no";
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
/**
|
|
1219
|
+
* `minimumsize = { w h }` floor. Applied on the box MAIN axis only: it is the
|
|
1220
|
+
* floor a shrinking child stops at, which is what the deficit redistribution
|
|
1221
|
+
* needs (spec.md "Minimum sizes in the box distribution", L04c). Cross-axis
|
|
1222
|
+
* effect unmeasured. A binding-valued `minimumsize` folds to 0 like every
|
|
1223
|
+
* other unresolvable value (5 vanilla widgets write one).
|
|
1224
|
+
*/
|
|
1225
|
+
function minimumSize(node: WNode): { w: number; h: number } {
|
|
1226
|
+
const min = node.pairs.get("minimumsize");
|
|
1227
|
+
return { w: min?.[0] ?? 0, h: min?.[1] ?? 0 };
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
/**
|
|
1231
|
+
* The child whose `resizeparent = yes` dictates this widget's size: the widget
|
|
1232
|
+
* takes that child's content extent instead of its own authored size
|
|
1233
|
+
* (spec.md "Container sizing", L28). The source's "a fixed-size DIRECT child
|
|
1234
|
+
* of one CAN be collapsed" side effect is NOT implemented: "can" is not a rect
|
|
1235
|
+
* rule, and nothing measured says when it fires.
|
|
1236
|
+
*/
|
|
1237
|
+
function resizeParentSource(node: WNode): WNode | undefined {
|
|
1238
|
+
return node.children.find((c) => yes(c, "resizeparent"));
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
/**
|
|
1242
|
+
* Classes whose size flows through naturalSize (spec.md "Container sizing",
|
|
1243
|
+
* L25 and L10). The probe DID say so (in-game 2026-08-02): a NON-empty
|
|
1244
|
+
* container KEEPS an authored `size` (the engine warns "you should not set a
|
|
1245
|
+
* size on a container" yet applies it), so naturalSize implements the narrow
|
|
1246
|
+
* reading and this predicate only routes containers/items through it. See
|
|
1247
|
+
* parity-checklist.md L25.
|
|
1248
|
+
*/
|
|
1249
|
+
function contentSized(cls: WidgetClass): boolean {
|
|
1250
|
+
return cls === "container" || cls === "item";
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
// ---------------------------------------------------------------------------
|
|
1254
|
+
// Natural (content-hug) sizes, bottom-up
|
|
1255
|
+
// ---------------------------------------------------------------------------
|
|
1256
|
+
|
|
1257
|
+
function naturalSize(node: WNode, measurer: LayoutEnv): { w: number; h: number } {
|
|
1258
|
+
switch (node.cls) {
|
|
1259
|
+
case "expand":
|
|
1260
|
+
return { w: 0, h: 0 };
|
|
1261
|
+
case "textbox":
|
|
1262
|
+
return textSize(node, measurer).size;
|
|
1263
|
+
case "box": {
|
|
1264
|
+
// Hug = children floors + spacing + margins (B2-I2: exact, packed).
|
|
1265
|
+
// A collapsed hidden child contributes nothing, not even its spacing
|
|
1266
|
+
// (L27). An expanding child contributes its FLOOR only, never a share of
|
|
1267
|
+
// free space: it cannot GROW the box's cross size (L31). A floor wider
|
|
1268
|
+
// than a fixed sibling's still sets the hug, and that shape is unmeasured
|
|
1269
|
+
// by either source (parity-checklist.md L31).
|
|
1270
|
+
const [ml, mt, mr, mb] = margins(node);
|
|
1271
|
+
const spacing = num(node, "spacing") ?? 0;
|
|
1272
|
+
let main = 0;
|
|
1273
|
+
let cross = 0;
|
|
1274
|
+
let laid = 0;
|
|
1275
|
+
for (const c of boxChildren(node)) {
|
|
1276
|
+
const s = naturalSize(c, measurer);
|
|
1277
|
+
const min = minimumSize(c);
|
|
1278
|
+
const cm = Math.max(node.vertical ? s.h : s.w, node.vertical ? min.h : min.w);
|
|
1279
|
+
const cc = node.vertical ? s.w : s.h;
|
|
1280
|
+
main += cm + (laid > 0 ? spacing : 0);
|
|
1281
|
+
cross = Math.max(cross, cc);
|
|
1282
|
+
laid++;
|
|
1283
|
+
}
|
|
1284
|
+
return node.vertical
|
|
1285
|
+
? { w: cross + ml + mr, h: main + mt + mb }
|
|
1286
|
+
: { w: main + ml + mr, h: cross + mt + mb };
|
|
1287
|
+
}
|
|
1288
|
+
case "grid": {
|
|
1289
|
+
// A grid keeps an authored size; otherwise it hugs the slots it filled
|
|
1290
|
+
// (unmeasured: neither source records a gridbox's own rect without a
|
|
1291
|
+
// size, and every fixture authors the cells rather than the box).
|
|
1292
|
+
const explicit = explicitSize(node);
|
|
1293
|
+
if (explicit) return explicit;
|
|
1294
|
+
let w = 0;
|
|
1295
|
+
let h = 0;
|
|
1296
|
+
for (const cell of gridCells(node, measurer)) {
|
|
1297
|
+
w = Math.max(w, cell.x + cell.w);
|
|
1298
|
+
h = Math.max(h, cell.y + cell.h);
|
|
1299
|
+
}
|
|
1300
|
+
return { w, h };
|
|
1301
|
+
}
|
|
1302
|
+
case "flow": {
|
|
1303
|
+
// Single non-wrapping run (B2-K, B3-Q1). Explicit size sets the flow's
|
|
1304
|
+
// own rect but not the content run (B3-Q1).
|
|
1305
|
+
const explicit = explicitSize(node);
|
|
1306
|
+
if (explicit) return explicit;
|
|
1307
|
+
const spacing = num(node, "spacing") ?? 0;
|
|
1308
|
+
let main = 0;
|
|
1309
|
+
let cross = 0;
|
|
1310
|
+
node.children.forEach((c, i) => {
|
|
1311
|
+
const s = naturalSize(c, measurer);
|
|
1312
|
+
main += (node.vertical ? s.h : s.w) + (i > 0 ? spacing : 0);
|
|
1313
|
+
cross = Math.max(cross, node.vertical ? s.w : s.h);
|
|
1314
|
+
});
|
|
1315
|
+
return node.vertical ? { w: cross, h: main } : { w: main, h: cross };
|
|
1316
|
+
}
|
|
1317
|
+
case "container": {
|
|
1318
|
+
// NARROW rule, measured in-game 2026-08-02 (L25): a container WITH
|
|
1319
|
+
// children keeps an authored `size` (the engine warns yet applies it);
|
|
1320
|
+
// an EMPTY one collapses, a fixed size will not hold it open. Without
|
|
1321
|
+
// an authored size it hugs the children's extent at their positions
|
|
1322
|
+
// (B2-I4). The 2026-08-09 probe measured the BROAD rule instead, an empty sized
|
|
1323
|
+
// container keeps its size too (probe 2026-08-09), carried as a
|
|
1324
|
+
// profile quirk.
|
|
1325
|
+
const explicit = explicitSize(node);
|
|
1326
|
+
if (explicit && (node.children.length > 0 || measurer.emptySizedContainerKept)) return explicit;
|
|
1327
|
+
return hugChildren(node, measurer);
|
|
1328
|
+
}
|
|
1329
|
+
case "item":
|
|
1330
|
+
// A datamodel `item` sizes to its content unconditionally, rather than
|
|
1331
|
+
// taking a generic widget default (L10).
|
|
1332
|
+
return hugChildren(node, measurer);
|
|
1333
|
+
case "marginwidget": {
|
|
1334
|
+
const explicit = explicitSize(node);
|
|
1335
|
+
if (explicit) return explicit;
|
|
1336
|
+
return hugChildren(node, measurer);
|
|
1337
|
+
}
|
|
1338
|
+
default: {
|
|
1339
|
+
// Plain widget/icon/window: explicit size or ZERO, no hug (B4-T1),
|
|
1340
|
+
// unless a `resizeparent = yes` child dictates the size instead (L28).
|
|
1341
|
+
const resizer = resizeParentSource(node);
|
|
1342
|
+
if (resizer) return hugChildren(resizer, measurer);
|
|
1343
|
+
const explicit = explicitSize(node);
|
|
1344
|
+
if (!explicit) return { w: 0, h: 0 };
|
|
1345
|
+
const scale = num(node, "scale") ?? 1; // multiplies the rect (B4-T4)
|
|
1346
|
+
return { w: explicit.w * scale, h: explicit.h * scale };
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
/**
|
|
1352
|
+
* Bounding box of the children at their positions (B2-I4). Anchored children
|
|
1353
|
+
* inside a hugging container are unmeasured; the extent uses position +
|
|
1354
|
+
* natural size only. A plainly hidden child is skipped and the rest still
|
|
1355
|
+
* content-size (L11c).
|
|
1356
|
+
*/
|
|
1357
|
+
function hugChildren(node: WNode, measurer: TextMeasurer): { w: number; h: number } {
|
|
1358
|
+
const [ml, mt] = margins(node);
|
|
1359
|
+
let w = 0;
|
|
1360
|
+
let h = 0;
|
|
1361
|
+
for (const c of node.children) {
|
|
1362
|
+
if (hidden(c)) continue;
|
|
1363
|
+
const s = naturalSize(c, measurer);
|
|
1364
|
+
const pos = c.pairs.get("position") ?? [0, 0];
|
|
1365
|
+
w = Math.max(w, (pos[0] ?? 0) + s.w);
|
|
1366
|
+
h = Math.max(h, (pos[1] ?? 0) + s.h);
|
|
1367
|
+
}
|
|
1368
|
+
return { w: w + ml, h: h + mt };
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
/** A box's laid-out children: hidden ones collapse out unless asked not to (L27). */
|
|
1372
|
+
function boxChildren(box: WNode): WNode[] {
|
|
1373
|
+
if (!collapsesHidden(box)) return box.children;
|
|
1374
|
+
return box.children.filter((c) => !hidden(c));
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
/** Explicit size with percentages unresolved (returns the raw number). */
|
|
1378
|
+
function explicitSize(node: WNode): { w: number; h: number } | null {
|
|
1379
|
+
const size = node.pairs.get("size");
|
|
1380
|
+
if (!size || size.length < 2) return null;
|
|
1381
|
+
return { w: size[0], h: size[1] };
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
// ---------------------------------------------------------------------------
|
|
1385
|
+
// Arrangement, top-down
|
|
1386
|
+
// ---------------------------------------------------------------------------
|
|
1387
|
+
|
|
1388
|
+
type ParentKind = "plain" | "box" | "flow";
|
|
1389
|
+
|
|
1390
|
+
function arrange(
|
|
1391
|
+
node: WNode,
|
|
1392
|
+
content: LayoutRect,
|
|
1393
|
+
parentKind: ParentKind,
|
|
1394
|
+
measurer: TextMeasurer,
|
|
1395
|
+
forced?: LayoutRect,
|
|
1396
|
+
chain?: ExplainChain
|
|
1397
|
+
): LayoutNode {
|
|
1398
|
+
const rect = forced ?? placeInParent(node, content, measurer);
|
|
1399
|
+
const srcPosition = node.pairs.get("position");
|
|
1400
|
+
const srcSize = node.pairs.get("size");
|
|
1401
|
+
const out: LayoutNode = {
|
|
1402
|
+
key: node.key,
|
|
1403
|
+
name: str(node, "name"),
|
|
1404
|
+
rect,
|
|
1405
|
+
// scrollarea (measured B3-R1), scrollbox, and any widget carrying
|
|
1406
|
+
// `scissor = yes` clip their subtree (spec.md "Clipping containers", L17b).
|
|
1407
|
+
// L17c's "clamp the descendant rects in the flatten" stays a RENDERER job
|
|
1408
|
+
// here: these rects are true geometry and the client clips them (the
|
|
1409
|
+
// B3-R1 golden pins the unclamped corner rect, and guiPreview clips it).
|
|
1410
|
+
clip: node.cls === "scrollarea" || yes(node, "scissor"),
|
|
1411
|
+
bg: node.bg,
|
|
1412
|
+
line: node.line,
|
|
1413
|
+
positioned: forced === undefined,
|
|
1414
|
+
// Ghosts are synthetic placeholders: never draggable/editable even though
|
|
1415
|
+
// the item template statements physically exist in the document.
|
|
1416
|
+
editable: node.ownLine && node.line !== undefined && !node.ghost,
|
|
1417
|
+
srcPosition: srcPosition && srcPosition.length >= 2 ? [srcPosition[0], srcPosition[1]] : undefined,
|
|
1418
|
+
srcSize: srcSize && srcSize.length >= 2 ? [srcSize[0], srcSize[1]] : undefined,
|
|
1419
|
+
// A ghost's statements exist, but the copies are placeholders: no index of
|
|
1420
|
+
// theirs names a slot a client could move.
|
|
1421
|
+
srcIndex: node.ghost ? undefined : node.srcIndex,
|
|
1422
|
+
ghost: node.ghost ? true : undefined,
|
|
1423
|
+
onclick: str(node, "onclick"),
|
|
1424
|
+
tooltip: str(node, "tooltip"),
|
|
1425
|
+
children: [],
|
|
1426
|
+
};
|
|
1427
|
+
const colorPair = node.pairs.get("color");
|
|
1428
|
+
const color: [number, number, number, number] | undefined =
|
|
1429
|
+
colorPair && colorPair.length >= 3
|
|
1430
|
+
? [colorPair[0], colorPair[1], colorPair[2], colorPair[3] ?? 1]
|
|
1431
|
+
: undefined;
|
|
1432
|
+
if (node.cls === "textbox") {
|
|
1433
|
+
out.text = textInfo(node, rect, measurer);
|
|
1434
|
+
if (color) out.text.color = color;
|
|
1435
|
+
} else if (node.props.has("texture") || color) {
|
|
1436
|
+
// A widget's own textured fill can carry nine-slice borders directly
|
|
1437
|
+
// (spriteborder is collected into pairs; per-side overrides into props).
|
|
1438
|
+
const border = borderTuple(node.pairs.get("spriteborder"), {
|
|
1439
|
+
l: num(node, "spriteborder_left"),
|
|
1440
|
+
t: num(node, "spriteborder_top"),
|
|
1441
|
+
r: num(node, "spriteborder_right"),
|
|
1442
|
+
b: num(node, "spriteborder_bottom"),
|
|
1443
|
+
});
|
|
1444
|
+
const texture = str(node, "texture");
|
|
1445
|
+
out.fill = { texture, color, border };
|
|
1446
|
+
const alpha = num(node, "alpha");
|
|
1447
|
+
if (alpha !== undefined) out.fill.alpha = alpha;
|
|
1448
|
+
const mask = str(node, "#mask");
|
|
1449
|
+
if (mask) out.fill.mask = mask;
|
|
1450
|
+
if (str(node, "fittype")?.toLowerCase() === "centercrop") out.fill.fit = "centercrop";
|
|
1451
|
+
if (texture !== undefined) {
|
|
1452
|
+
out.fill.mode = fillMode(str(node, "spritetype"), border); // Studio §J, L21a-d
|
|
1453
|
+
const framesize = node.pairs.get("framesize");
|
|
1454
|
+
if (framesize && framesize.length >= 2) {
|
|
1455
|
+
out.fill.framesize = [framesize[0], framesize[1]]; // Studio §L, L22
|
|
1456
|
+
out.fill.frame = num(node, "frame") ?? 1;
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
if (
|
|
1462
|
+
chain &&
|
|
1463
|
+
chain.sink.result === undefined &&
|
|
1464
|
+
node.ownLine &&
|
|
1465
|
+
!node.ghost &&
|
|
1466
|
+
node.line === chain.sink.line
|
|
1467
|
+
) {
|
|
1468
|
+
chain.sink.result = explainPlacement(node, out, content, forced !== undefined, chain);
|
|
1469
|
+
}
|
|
1470
|
+
const sub = chain && descend(chain, node, out);
|
|
1471
|
+
|
|
1472
|
+
switch (node.cls) {
|
|
1473
|
+
case "box":
|
|
1474
|
+
out.children = arrangeBoxChildren(node, rect, measurer, sub);
|
|
1475
|
+
break;
|
|
1476
|
+
case "flow":
|
|
1477
|
+
out.children = arrangeFlowChildren(node, rect, measurer, sub);
|
|
1478
|
+
break;
|
|
1479
|
+
case "grid":
|
|
1480
|
+
out.children = arrangeGridChildren(node, rect, measurer, sub);
|
|
1481
|
+
break;
|
|
1482
|
+
case "marginwidget": {
|
|
1483
|
+
// Margins inset the children's coordinate space; the widget's own rect
|
|
1484
|
+
// is untouched (B4-T3). Symmetric inset on the far sides is ASSUMED
|
|
1485
|
+
// from the vanilla HUD pattern (unmeasured; only the origin is pinned).
|
|
1486
|
+
const [ml, mt, mr, mb] = margins(node);
|
|
1487
|
+
const inner: LayoutRect = {
|
|
1488
|
+
x: rect.x + ml,
|
|
1489
|
+
y: rect.y + mt,
|
|
1490
|
+
w: Math.max(0, rect.w - ml - mr),
|
|
1491
|
+
h: Math.max(0, rect.h - mt - mb),
|
|
1492
|
+
};
|
|
1493
|
+
out.children = node.children.map((c) => arrange(c, inner, "plain", measurer, undefined, sub));
|
|
1494
|
+
// A margin_widget with NO explicit size hugs its children AT the margin
|
|
1495
|
+
// offset: both probes (B3-Q2; 2026-08-09) saw the bg exactly
|
|
1496
|
+
// behind the child, never in the margin strips an origin-anchored hug
|
|
1497
|
+
// would paint. rect came in margin-inclusive from naturalSize; shift
|
|
1498
|
+
// the box, children stay put.
|
|
1499
|
+
if (!explicitSize(node)) {
|
|
1500
|
+
out.rect = {
|
|
1501
|
+
x: rect.x + ml,
|
|
1502
|
+
y: rect.y + mt,
|
|
1503
|
+
w: Math.max(0, rect.w - ml),
|
|
1504
|
+
h: Math.max(0, rect.h - mt),
|
|
1505
|
+
};
|
|
1506
|
+
}
|
|
1507
|
+
break;
|
|
1508
|
+
}
|
|
1509
|
+
default:
|
|
1510
|
+
out.children = node.children.map((c) => arrange(c, rect, "plain", measurer, undefined, sub));
|
|
1511
|
+
break;
|
|
1512
|
+
}
|
|
1513
|
+
return out;
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
/** The chain a node's children see: this node as parent, its clip if it clips. */
|
|
1517
|
+
function descend(chain: ExplainChain, node: WNode, out: LayoutNode): ExplainChain {
|
|
1518
|
+
return {
|
|
1519
|
+
sink: chain.sink,
|
|
1520
|
+
parent: node,
|
|
1521
|
+
parentRect: out.rect,
|
|
1522
|
+
clip: out.clip ? { key: node.key, name: out.name, rect: out.rect } : chain.clip,
|
|
1523
|
+
};
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
/**
|
|
1527
|
+
* "Why is it here": the terms of the anchor sum, or the container that placed
|
|
1528
|
+
* the widget instead, plus the clip rect that bounds it.
|
|
1529
|
+
*/
|
|
1530
|
+
function explainPlacement(
|
|
1531
|
+
node: WNode,
|
|
1532
|
+
out: LayoutNode,
|
|
1533
|
+
content: LayoutRect,
|
|
1534
|
+
forced: boolean,
|
|
1535
|
+
chain: ExplainChain
|
|
1536
|
+
): Placement {
|
|
1537
|
+
const placement: Placement = {
|
|
1538
|
+
rect: out.rect,
|
|
1539
|
+
// A container-placed child was handed its slot as `content`; the rect worth
|
|
1540
|
+
// naming is the container's own, which the chain carries.
|
|
1541
|
+
parentRect: forced ? (chain.parentRect ?? content) : content,
|
|
1542
|
+
terms: forced ? [] : placementTerms(node, content, out.rect),
|
|
1543
|
+
};
|
|
1544
|
+
const parent = chain.parent;
|
|
1545
|
+
if (forced && parent) {
|
|
1546
|
+
const pos = node.pairs.get("position");
|
|
1547
|
+
placement.placedBy = {
|
|
1548
|
+
key: parent.key,
|
|
1549
|
+
name: str(parent, "name"),
|
|
1550
|
+
layout: parent.cls === "box" ? "box" : parent.cls === "flow" ? "flow" : "grid",
|
|
1551
|
+
// The engine logs "Widget cannot have a position in a layout" and drops
|
|
1552
|
+
// it (probe 2026-08-02, L23); naming the dropped value is the point.
|
|
1553
|
+
droppedPosition: pos && pos.length >= 2 ? [pos[0], pos[1]] : undefined,
|
|
1554
|
+
};
|
|
1555
|
+
}
|
|
1556
|
+
if (chain.clip) placement.clippedBy = chain.clip;
|
|
1557
|
+
return placement;
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
/**
|
|
1561
|
+
* The anchor sum, term by term, mirroring placeInParent's formula (B1-B/C/D).
|
|
1562
|
+
* The dx/dy add up to the rect origin exactly, which the test pins: that
|
|
1563
|
+
* equality is what keeps this readout from drifting from the placement it
|
|
1564
|
+
* explains.
|
|
1565
|
+
*/
|
|
1566
|
+
function placementTerms(node: WNode, content: LayoutRect, rect: LayoutRect): PlacementTerm[] {
|
|
1567
|
+
const pa = str(node, "parentanchor");
|
|
1568
|
+
const waOwn = str(node, "widgetanchor");
|
|
1569
|
+
const wa = waOwn ?? pa;
|
|
1570
|
+
const [pfx, pfy] = anchorFractions(pa);
|
|
1571
|
+
const [wfx, wfy] = anchorFractions(wa);
|
|
1572
|
+
const pos = node.pairs.get("position");
|
|
1573
|
+
const terms: PlacementTerm[] = [{ kind: "parentOrigin", dx: content.x, dy: content.y }];
|
|
1574
|
+
if (pa !== undefined)
|
|
1575
|
+
terms.push({ kind: "parentanchor", source: pa, dx: pfx * content.w, dy: pfy * content.h });
|
|
1576
|
+
if (wa !== undefined) {
|
|
1577
|
+
terms.push({ kind: "widgetanchor", source: wa, dx: -wfx * rect.w, dy: -wfy * rect.h });
|
|
1578
|
+
}
|
|
1579
|
+
if (pos !== undefined) {
|
|
1580
|
+
terms.push({
|
|
1581
|
+
kind: "position",
|
|
1582
|
+
source: `{ ${pos[0] ?? 0} ${pos[1] ?? 0} }`,
|
|
1583
|
+
dx: pos[0] ?? 0,
|
|
1584
|
+
dy: pos[1] ?? 0,
|
|
1585
|
+
});
|
|
1586
|
+
}
|
|
1587
|
+
return terms;
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
/** Size + anchor + position for a child of a NON-box parent. */
|
|
1591
|
+
function placeInParent(node: WNode, content: LayoutRect, measurer: TextMeasurer): LayoutRect {
|
|
1592
|
+
let w: number;
|
|
1593
|
+
let h: number;
|
|
1594
|
+
if (node.cls === "box") {
|
|
1595
|
+
// Boxes FILL a non-box parent, explicit size ignored entirely
|
|
1596
|
+
// (B1-E/F, B2-I1, B3-P1). Inside another box they hug, but that path
|
|
1597
|
+
// goes through arrangeBoxChildren, not here.
|
|
1598
|
+
w = content.w;
|
|
1599
|
+
h = content.h;
|
|
1600
|
+
} else if (node.cls === "textbox") {
|
|
1601
|
+
// Textboxes always size via the text rules (autoresize measurement can
|
|
1602
|
+
// override an inherited size like Font_Size_Small's `size = { 0 23 }`).
|
|
1603
|
+
const s = textSize(node, measurer).size;
|
|
1604
|
+
w = s.w;
|
|
1605
|
+
h = s.h;
|
|
1606
|
+
} else if (contentSized(node.cls)) {
|
|
1607
|
+
// container / datamodel item: naturalSize owns the rule (narrow L25: a
|
|
1608
|
+
// non-empty container keeps an authored size; item always content, L10).
|
|
1609
|
+
const s = naturalSize(node, measurer);
|
|
1610
|
+
w = s.w;
|
|
1611
|
+
h = s.h;
|
|
1612
|
+
} else {
|
|
1613
|
+
// A `resizeparent = yes` child replaces this widget's authored size with
|
|
1614
|
+
// that child's content extent (L28).
|
|
1615
|
+
const explicit = resizeParentSource(node) ? null : explicitSize(node);
|
|
1616
|
+
if (explicit) {
|
|
1617
|
+
// Percent sizes resolve against the parent rect (B4-T2).
|
|
1618
|
+
w = node.sizePct[0] ? (explicit.w / 100) * content.w : explicit.w;
|
|
1619
|
+
h = node.sizePct[1] ? (explicit.h / 100) * content.h : explicit.h;
|
|
1620
|
+
const scale = num(node, "scale") ?? 1; // B4-T4
|
|
1621
|
+
w *= scale;
|
|
1622
|
+
h *= scale;
|
|
1623
|
+
} else {
|
|
1624
|
+
const s = naturalSize(node, measurer);
|
|
1625
|
+
w = s.w;
|
|
1626
|
+
h = s.h;
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
// widgetanchor implicitly mirrors parentanchor (B1-B, B1-C); position is
|
|
1631
|
+
// always screen-space +right/+down, added after anchoring (B1-D).
|
|
1632
|
+
const pa = str(node, "parentanchor");
|
|
1633
|
+
const wa = str(node, "widgetanchor") ?? pa;
|
|
1634
|
+
const [pfx, pfy] = anchorFractions(pa);
|
|
1635
|
+
const [wfx, wfy] = anchorFractions(wa);
|
|
1636
|
+
const pos = node.pairs.get("position") ?? [0, 0];
|
|
1637
|
+
const x = content.x + pfx * content.w - wfx * w + (pos[0] ?? 0);
|
|
1638
|
+
const y = content.y + pfy * content.h - wfy * h + (pos[1] ?? 0);
|
|
1639
|
+
return { x, y, w, h };
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
/**
|
|
1643
|
+
* Box (hbox/vbox) child arrangement, per the measured model:
|
|
1644
|
+
* 1. floors = natural sizes; 2. policies resize (expanding: +free/k;
|
|
1645
|
+
* growing acts only without expanding siblings; deficit: preferred and
|
|
1646
|
+
* shrinking each lose deficit/k) (B2-J, B3-P);
|
|
1647
|
+
* 3. residual free space distributes as space-around: each child gets
|
|
1648
|
+
* side = residual/(2n) on both sides (B1-E/F);
|
|
1649
|
+
* 4. cross axis: fill if the cross policy stretches, else centered (B1-E/F).
|
|
1650
|
+
*/
|
|
1651
|
+
function arrangeBoxChildren(
|
|
1652
|
+
box: WNode,
|
|
1653
|
+
rect: LayoutRect,
|
|
1654
|
+
measurer: TextMeasurer,
|
|
1655
|
+
chain?: ExplainChain
|
|
1656
|
+
): LayoutNode[] {
|
|
1657
|
+
const vertical = box.vertical;
|
|
1658
|
+
const [ml, mt, mr, mb] = margins(box);
|
|
1659
|
+
const spacing = num(box, "spacing") ?? 0;
|
|
1660
|
+
const contentMain = vertical ? rect.h - mt - mb : rect.w - ml - mr;
|
|
1661
|
+
const contentCross = vertical ? rect.w - ml - mr : rect.h - mt - mb;
|
|
1662
|
+
if (box.children.length === 0) return [];
|
|
1663
|
+
|
|
1664
|
+
// `ignoreinvisible` defaults to yes: a plainly hidden child is collapsed out
|
|
1665
|
+
// of the layout and its siblings shift up to fill the gap (spec.md, L27). It
|
|
1666
|
+
// still reaches the tree, as a ZERO rect at the cursor, so the preview can
|
|
1667
|
+
// list and select it; nothing of it is drawn, which is what the game does.
|
|
1668
|
+
const laid = boxChildren(box);
|
|
1669
|
+
const kept = new Set(laid);
|
|
1670
|
+
const n = laid.length;
|
|
1671
|
+
|
|
1672
|
+
const naturals = laid.map((c) => {
|
|
1673
|
+
if (c.cls === "box") {
|
|
1674
|
+
// box-in-box hugs (B2-I2)
|
|
1675
|
+
return naturalSize(c, measurer);
|
|
1676
|
+
}
|
|
1677
|
+
return resolvedChildSize(c, rect, measurer);
|
|
1678
|
+
});
|
|
1679
|
+
// `minimumsize = { w h }` floors the main-axis size and is where a shrinking
|
|
1680
|
+
// child stops (spec.md "Minimum sizes in the box distribution", L04c).
|
|
1681
|
+
const minMains = laid.map((c) => {
|
|
1682
|
+
const min = minimumSize(c);
|
|
1683
|
+
return vertical ? min.h : min.w;
|
|
1684
|
+
});
|
|
1685
|
+
const mains = naturals.map((s, i) => Math.max(vertical ? s.h : s.w, minMains[i]));
|
|
1686
|
+
const crosses = naturals.map((s) => (vertical ? s.w : s.h));
|
|
1687
|
+
|
|
1688
|
+
let free = n === 0 ? 0 : contentMain - mains.reduce((a, b) => a + b, 0) - spacing * (n - 1);
|
|
1689
|
+
const mainPolicies = laid.map((c) => policy(c, !vertical));
|
|
1690
|
+
if (free > 0) {
|
|
1691
|
+
const expanders = mainPolicies.map((p, i) => ({ p, i })).filter(({ p }) => p === "expanding");
|
|
1692
|
+
// Without expanding siblings, growing AND preferred take the space; the
|
|
1693
|
+
// growing case is measured (B3-P2), preferred sharing with growing is
|
|
1694
|
+
// unmeasured, treated as the same tier.
|
|
1695
|
+
const growers = mainPolicies
|
|
1696
|
+
.map((p, i) => ({ p, i }))
|
|
1697
|
+
.filter(({ p }) => p === "growing" || p === "preferred");
|
|
1698
|
+
const takers = expanders.length > 0 ? expanders : growers;
|
|
1699
|
+
if (takers.length > 0) {
|
|
1700
|
+
const share = free / takers.length; // floor + free/k (B3-P3)
|
|
1701
|
+
for (const { i } of takers) mains[i] += share;
|
|
1702
|
+
free = 0;
|
|
1703
|
+
}
|
|
1704
|
+
} else if (free < 0) {
|
|
1705
|
+
// Deficit: every shrinkable child loses deficit/k, an equal DELTA with no
|
|
1706
|
+
// shrinking-first priority (B2-J3, B3-P4). A child that reaches its floor
|
|
1707
|
+
// stops there and the REST absorb what it could not give, so the total
|
|
1708
|
+
// still fits (spec.md "Minimum sizes in the box distribution", L04c);
|
|
1709
|
+
// a `fixed` child never shrinks at all (L04b).
|
|
1710
|
+
let owed = -free;
|
|
1711
|
+
let pool = mainPolicies
|
|
1712
|
+
.map((p, i) => ({ p, i }))
|
|
1713
|
+
.filter(({ p }) => p === "preferred" || p === "shrinking")
|
|
1714
|
+
.map(({ i }) => i);
|
|
1715
|
+
while (owed > 1e-9 && pool.length > 0) {
|
|
1716
|
+
const delta = owed / pool.length;
|
|
1717
|
+
const next: number[] = [];
|
|
1718
|
+
for (const i of pool) {
|
|
1719
|
+
const room = Math.max(0, mains[i] - minMains[i]);
|
|
1720
|
+
const take = Math.min(delta, room);
|
|
1721
|
+
mains[i] -= take;
|
|
1722
|
+
owed -= take;
|
|
1723
|
+
if (room > delta) next.push(i);
|
|
1724
|
+
}
|
|
1725
|
+
if (next.length === pool.length) break; // nobody floored: converged
|
|
1726
|
+
pool = next;
|
|
1727
|
+
}
|
|
1728
|
+
// Nothing left that can give: keep the floors and overflow (unmeasured).
|
|
1729
|
+
free = 0;
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
const side = n > 0 ? free / (2 * n) : 0; // space-around (B1-E/F)
|
|
1733
|
+
const crossPolicies = laid.map((c) => policy(c, vertical));
|
|
1734
|
+
const out: LayoutNode[] = [];
|
|
1735
|
+
let cursor = (vertical ? rect.y + mt : rect.x + ml) + side;
|
|
1736
|
+
let i = -1;
|
|
1737
|
+
for (const child of box.children) {
|
|
1738
|
+
if (!kept.has(child)) {
|
|
1739
|
+
const zero: LayoutRect = vertical
|
|
1740
|
+
? { x: rect.x + ml, y: cursor, w: 0, h: 0 }
|
|
1741
|
+
: { x: cursor, y: rect.y + mt, w: 0, h: 0 };
|
|
1742
|
+
out.push(arrange(child, zero, "box", measurer, zero, chain));
|
|
1743
|
+
continue;
|
|
1744
|
+
}
|
|
1745
|
+
i++;
|
|
1746
|
+
const main = mains[i];
|
|
1747
|
+
const stretchCross =
|
|
1748
|
+
crossPolicies[i] === "expanding" || crossPolicies[i] === "growing" || crossPolicies[i] === "preferred";
|
|
1749
|
+
const cross = stretchCross ? contentCross : Math.min(crosses[i], Number.POSITIVE_INFINITY);
|
|
1750
|
+
const crossOffset =
|
|
1751
|
+
(vertical ? rect.x + ml : rect.y + mt) + (stretchCross ? 0 : (contentCross - cross) / 2);
|
|
1752
|
+
// `position` on a box child is DROPPED: the box places its children.
|
|
1753
|
+
// In-game probe 2026-08-02 (px_positioned sat exactly where a plain
|
|
1754
|
+
// sibling does), and the engine logs "Widget cannot have a position in a
|
|
1755
|
+
// layout". Settles L23; the writer's positionIgnoredReason guard matches.
|
|
1756
|
+
const forced: LayoutRect = vertical
|
|
1757
|
+
? { x: crossOffset, y: cursor, w: cross, h: main }
|
|
1758
|
+
: { x: cursor, y: crossOffset, w: main, h: cross };
|
|
1759
|
+
out.push(arrange(child, forced, "box", measurer, forced, chain));
|
|
1760
|
+
cursor += main + 2 * side + spacing;
|
|
1761
|
+
}
|
|
1762
|
+
return out;
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
/** flowcontainer: pack children from the origin, never wrap (B2-K, B3-Q1). */
|
|
1766
|
+
function arrangeFlowChildren(
|
|
1767
|
+
flow: WNode,
|
|
1768
|
+
rect: LayoutRect,
|
|
1769
|
+
measurer: TextMeasurer,
|
|
1770
|
+
chain?: ExplainChain
|
|
1771
|
+
): LayoutNode[] {
|
|
1772
|
+
const spacing = num(flow, "spacing") ?? 0;
|
|
1773
|
+
const out: LayoutNode[] = [];
|
|
1774
|
+
let cursor = flow.vertical ? rect.y : rect.x;
|
|
1775
|
+
for (const child of flow.children) {
|
|
1776
|
+
const s = resolvedChildSize(child, rect, measurer);
|
|
1777
|
+
// flowcontainer is the ONE container that honors a child's `parentanchor`
|
|
1778
|
+
// on the cross axis (spec.md "Container sizing", L13d); widgetanchor still
|
|
1779
|
+
// mirrors it (B1-B/C). Unset anchors keep the measured origin alignment
|
|
1780
|
+
// (B2-K1). The MAIN axis stays the flow cursor.
|
|
1781
|
+
const pa = str(child, "parentanchor");
|
|
1782
|
+
const [pfx, pfy] = anchorFractions(pa);
|
|
1783
|
+
const [wfx, wfy] = anchorFractions(str(child, "widgetanchor") ?? pa);
|
|
1784
|
+
const crossOffset = flow.vertical ? rect.x + pfx * rect.w - wfx * s.w : rect.y + pfy * rect.h - wfy * s.h;
|
|
1785
|
+
const forced: LayoutRect = flow.vertical
|
|
1786
|
+
? { x: crossOffset, y: cursor, w: s.w, h: s.h }
|
|
1787
|
+
: { x: cursor, y: crossOffset, w: s.w, h: s.h };
|
|
1788
|
+
out.push(arrange(child, forced, "flow", measurer, forced, chain));
|
|
1789
|
+
cursor += (flow.vertical ? s.h : s.w) + spacing;
|
|
1790
|
+
}
|
|
1791
|
+
return out;
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
/** One slotted grid child, in the grid's own (0,0-based) coordinates. */
|
|
1795
|
+
interface GridCell {
|
|
1796
|
+
child: WNode;
|
|
1797
|
+
x: number;
|
|
1798
|
+
y: number;
|
|
1799
|
+
w: number;
|
|
1800
|
+
h: number;
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
/**
|
|
1804
|
+
* Grid box slotting, shared by naturalSize and the arrangement so the two
|
|
1805
|
+
* cannot drift. Both kinds fill VERTICALLY by default (down a column, wrapping
|
|
1806
|
+
* into a new column after `datamodel_wrap` items, so datamodel_wrap is
|
|
1807
|
+
* items-per-COLUMN); `flipdirection = yes` transposes the fill to horizontal
|
|
1808
|
+
* and mirrors nothing (the flipped grid still starts top-left);
|
|
1809
|
+
* `maxhorizontalslots` caps the slots per line only while filling horizontally.
|
|
1810
|
+
* (Studio §K v2/v3, in-game 2026-07-17; L14b, L15.)
|
|
1811
|
+
*
|
|
1812
|
+
* fixedgridbox uses `addcolumn`/`addrow` as the CELL SIZE and therefore the
|
|
1813
|
+
* stride (L14a); dynamicgridbox packs items at their OWN size, where
|
|
1814
|
+
* addcolumn/addrow are not the stride (L15).
|
|
1815
|
+
*/
|
|
1816
|
+
function gridCells(grid: WNode, measurer: TextMeasurer): GridCell[] {
|
|
1817
|
+
if (grid.children.length === 0) return [];
|
|
1818
|
+
const horizontal = yes(grid, "flipdirection");
|
|
1819
|
+
const wrap = num(grid, "datamodel_wrap") ?? 0;
|
|
1820
|
+
const maxSlots = num(grid, "maxhorizontalslots") ?? 0;
|
|
1821
|
+
let perLine = wrap > 0 ? wrap : Number.POSITIVE_INFINITY;
|
|
1822
|
+
if (horizontal && maxSlots > 0) perLine = Math.min(perLine, maxSlots);
|
|
1823
|
+
|
|
1824
|
+
const contents = grid.children.map((c) => resolvedChildSize(c, { x: 0, y: 0, w: 0, h: 0 }, measurer));
|
|
1825
|
+
let cellW = num(grid, "addcolumn") ?? 0;
|
|
1826
|
+
let cellH = num(grid, "addrow") ?? 0;
|
|
1827
|
+
if (grid.fixedCells && yes(grid, "setitemsizefromcell")) {
|
|
1828
|
+
// Every cell takes the WIDEST item's size, so ragged rows go uniform
|
|
1829
|
+
// (Studio §K v3, L29). Measured on width; applied per axis here, and an
|
|
1830
|
+
// axis no item can size falls back to addcolumn/addrow.
|
|
1831
|
+
const w = Math.max(0, ...contents.map((s) => s.w));
|
|
1832
|
+
const h = Math.max(0, ...contents.map((s) => s.h));
|
|
1833
|
+
if (w > 0) cellW = w;
|
|
1834
|
+
if (h > 0) cellH = h;
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
const out: GridCell[] = [];
|
|
1838
|
+
let slot = 0;
|
|
1839
|
+
let line = 0;
|
|
1840
|
+
let mainCursor = 0;
|
|
1841
|
+
let crossCursor = 0;
|
|
1842
|
+
let lineCross = 0;
|
|
1843
|
+
grid.children.forEach((child, i) => {
|
|
1844
|
+
const content = contents[i];
|
|
1845
|
+
let cell: GridCell;
|
|
1846
|
+
if (grid.fixedCells) {
|
|
1847
|
+
// An item with NO concrete size anywhere in its chain takes the CELL
|
|
1848
|
+
// size; one with a concrete size keeps it at the cell ORIGIN
|
|
1849
|
+
// (Studio §K v3, L14c).
|
|
1850
|
+
const concrete = content.w > 0 || content.h > 0;
|
|
1851
|
+
cell = {
|
|
1852
|
+
child,
|
|
1853
|
+
x: (horizontal ? slot : line) * cellW,
|
|
1854
|
+
y: (horizontal ? line : slot) * cellH,
|
|
1855
|
+
w: concrete ? content.w : cellW,
|
|
1856
|
+
h: concrete ? content.h : cellH,
|
|
1857
|
+
};
|
|
1858
|
+
} else {
|
|
1859
|
+
cell = {
|
|
1860
|
+
child,
|
|
1861
|
+
x: horizontal ? mainCursor : crossCursor,
|
|
1862
|
+
y: horizontal ? crossCursor : mainCursor,
|
|
1863
|
+
w: content.w,
|
|
1864
|
+
h: content.h,
|
|
1865
|
+
};
|
|
1866
|
+
mainCursor += horizontal ? content.w : content.h;
|
|
1867
|
+
// Cross stride = the widest item of the line (unmeasured beyond the
|
|
1868
|
+
// uniform-item case every calibration grid used).
|
|
1869
|
+
lineCross = Math.max(lineCross, horizontal ? content.h : content.w);
|
|
1870
|
+
}
|
|
1871
|
+
out.push(cell);
|
|
1872
|
+
slot++;
|
|
1873
|
+
if (slot >= perLine) {
|
|
1874
|
+
slot = 0;
|
|
1875
|
+
line++;
|
|
1876
|
+
mainCursor = 0;
|
|
1877
|
+
crossCursor += lineCross;
|
|
1878
|
+
lineCross = 0;
|
|
1879
|
+
}
|
|
1880
|
+
});
|
|
1881
|
+
return out;
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
/** fixedgridbox / dynamicgridbox: slot the children, cells relative to the grid. */
|
|
1885
|
+
function arrangeGridChildren(
|
|
1886
|
+
grid: WNode,
|
|
1887
|
+
rect: LayoutRect,
|
|
1888
|
+
measurer: TextMeasurer,
|
|
1889
|
+
chain?: ExplainChain
|
|
1890
|
+
): LayoutNode[] {
|
|
1891
|
+
return gridCells(grid, measurer).map((cell) => {
|
|
1892
|
+
const forced: LayoutRect = { x: rect.x + cell.x, y: rect.y + cell.y, w: cell.w, h: cell.h };
|
|
1893
|
+
return arrange(cell.child, forced, "plain", measurer, forced, chain);
|
|
1894
|
+
});
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
/** Child size with % and scale resolved (children of boxes/flows/grids). */
|
|
1898
|
+
function resolvedChildSize(
|
|
1899
|
+
node: WNode,
|
|
1900
|
+
parentRect: LayoutRect,
|
|
1901
|
+
measurer: TextMeasurer
|
|
1902
|
+
): { w: number; h: number } {
|
|
1903
|
+
if (node.cls === "textbox") return textSize(node, measurer).size;
|
|
1904
|
+
const explicit = explicitSize(node);
|
|
1905
|
+
if (node.cls !== "box" && !contentSized(node.cls) && explicit) {
|
|
1906
|
+
const scale = num(node, "scale") ?? 1;
|
|
1907
|
+
return {
|
|
1908
|
+
w: (node.sizePct[0] ? (explicit.w / 100) * parentRect.w : explicit.w) * scale,
|
|
1909
|
+
h: (node.sizePct[1] ? (explicit.h / 100) * parentRect.h : explicit.h) * scale,
|
|
1910
|
+
};
|
|
1911
|
+
}
|
|
1912
|
+
return naturalSize(node, measurer);
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1915
|
+
// ---------------------------------------------------------------------------
|
|
1916
|
+
// Text
|
|
1917
|
+
// ---------------------------------------------------------------------------
|
|
1918
|
+
|
|
1919
|
+
function rawTextContent(node: WNode): string {
|
|
1920
|
+
return str(node, "raw_text") ?? str(node, "text") ?? "";
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
/** The shown text: resolved through the env when it can resolve, else the raw value. */
|
|
1924
|
+
function textContent(node: WNode, env?: LayoutEnv): string {
|
|
1925
|
+
const raw = rawTextContent(node);
|
|
1926
|
+
return env?.resolveText ? env.resolveText(raw).text : raw;
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
function textSize(node: WNode, measurer: LayoutEnv): { size: { w: number; h: number }; lines: string[] } {
|
|
1930
|
+
// The game's measured default size when the textbox sets none; 15 is the
|
|
1931
|
+
// default profile's Font_Size_Small (probe 2026-08-09 measured 17 there).
|
|
1932
|
+
const fontsize = num(node, "fontsize") ?? measurer.defaultFontsize ?? 15;
|
|
1933
|
+
const content = textContent(node, measurer);
|
|
1934
|
+
const maxWidth = num(node, "max_width");
|
|
1935
|
+
const explicit = explicitSize(node);
|
|
1936
|
+
// Vanilla `textbox` does not autoresize; text_single opts in (labels.gui).
|
|
1937
|
+
// A fixed-size textbox ignores max_width entirely, that is exactly the
|
|
1938
|
+
// measured text_multi 45x45 behavior (B2-L).
|
|
1939
|
+
const autoresize = yes(node, "autoresize");
|
|
1940
|
+
|
|
1941
|
+
if (autoresize) {
|
|
1942
|
+
if (yes(node, "multiline") && maxWidth !== undefined) {
|
|
1943
|
+
// Word wrap at max_width; box width = widest line, height = lines *
|
|
1944
|
+
// line advance = single-line box height (B3-S2).
|
|
1945
|
+
const lines = wrapWords(content, maxWidth, fontsize, measurer);
|
|
1946
|
+
const w = Math.max(0, ...lines.map((l) => measurer.lineWidth(l, fontsize)));
|
|
1947
|
+
return { size: { w, h: lines.length * measurer.lineHeight(fontsize) }, lines };
|
|
1948
|
+
}
|
|
1949
|
+
let w = measurer.lineWidth(content, fontsize);
|
|
1950
|
+
if (maxWidth !== undefined && w > maxWidth) w = maxWidth; // clamp+elide (B3-S1)
|
|
1951
|
+
return { size: { w, h: measurer.lineHeight(fontsize) }, lines: [content] };
|
|
1952
|
+
}
|
|
1953
|
+
if (explicit) {
|
|
1954
|
+
return { size: { w: explicit.w, h: explicit.h }, lines: [content] };
|
|
1955
|
+
}
|
|
1956
|
+
return {
|
|
1957
|
+
size: { w: measurer.lineWidth(content, fontsize), h: measurer.lineHeight(fontsize) },
|
|
1958
|
+
lines: [content],
|
|
1959
|
+
};
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1962
|
+
function textInfo(node: WNode, rect: LayoutRect, measurer: LayoutEnv): TextInfo {
|
|
1963
|
+
const fontsize = num(node, "fontsize") ?? measurer.defaultFontsize ?? 15;
|
|
1964
|
+
const { lines } = textSize(node, measurer);
|
|
1965
|
+
const textW = Math.max(0, ...lines.map((l) => measurer.lineWidth(l, fontsize)));
|
|
1966
|
+
const lineH = measurer.lineHeight(fontsize);
|
|
1967
|
+
const [fx, fy] = anchorFractions(str(node, "align"));
|
|
1968
|
+
// Horizontal align is exact with zero padding: x = f * (W - textwidth);
|
|
1969
|
+
// vertical centers the line box (B4-T6).
|
|
1970
|
+
const raw = rawTextContent(node);
|
|
1971
|
+
const resolved = measurer.resolveText?.(raw);
|
|
1972
|
+
return {
|
|
1973
|
+
text: resolved?.text ?? raw,
|
|
1974
|
+
raw: resolved && resolved.text !== raw ? raw : undefined,
|
|
1975
|
+
segments: resolved?.segments,
|
|
1976
|
+
fontsize,
|
|
1977
|
+
offsetX: fx * (rect.w - textW),
|
|
1978
|
+
offsetY: fy * (rect.h - lines.length * lineH),
|
|
1979
|
+
lines,
|
|
1980
|
+
};
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
function wrapWords(text: string, maxWidth: number, fontsize: number, measurer: TextMeasurer): string[] {
|
|
1984
|
+
const words = text.split(" ");
|
|
1985
|
+
const lines: string[] = [];
|
|
1986
|
+
let line = "";
|
|
1987
|
+
for (const word of words) {
|
|
1988
|
+
const candidate = line.length === 0 ? word : `${line} ${word}`;
|
|
1989
|
+
if (line.length > 0 && measurer.lineWidth(candidate, fontsize) > maxWidth) {
|
|
1990
|
+
lines.push(line);
|
|
1991
|
+
line = word;
|
|
1992
|
+
} else {
|
|
1993
|
+
line = candidate;
|
|
1994
|
+
}
|
|
1995
|
+
}
|
|
1996
|
+
if (line.length > 0) lines.push(line);
|
|
1997
|
+
return lines.length > 0 ? lines : [""];
|
|
1998
|
+
}
|