xbintsc 0.3.49 → 0.3.61

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.
Files changed (48) hide show
  1. package/dist/src/driver/compiler.js +25 -1
  2. package/dist/src/driver/compiler.js.map +1 -1
  3. package/dist/src/driver/config.d.ts +7 -0
  4. package/dist/src/driver/config.js.map +1 -1
  5. package/dist/src/driver/win-entry.d.ts +21 -0
  6. package/dist/src/driver/win-entry.js +52 -0
  7. package/dist/src/driver/win-entry.js.map +1 -0
  8. package/dist/tests/e2e/gui-example-browser.test.d.ts +12 -0
  9. package/dist/tests/e2e/gui-example-browser.test.js +164 -0
  10. package/dist/tests/e2e/gui-example-browser.test.js.map +1 -0
  11. package/dist/tests/e2e/gui-example.test.d.ts +14 -0
  12. package/dist/tests/e2e/gui-example.test.js +76 -0
  13. package/dist/tests/e2e/gui-example.test.js.map +1 -0
  14. package/dist/tests/e2e/gui-helpers.d.ts +39 -4
  15. package/dist/tests/e2e/gui-helpers.js +38 -9
  16. package/dist/tests/e2e/gui-helpers.js.map +1 -1
  17. package/dist/tests/e2e/gui-layout.test.js +155 -0
  18. package/dist/tests/e2e/gui-layout.test.js.map +1 -1
  19. package/dist/tests/e2e/gui-lifecycle.test.js +10 -1
  20. package/dist/tests/e2e/gui-lifecycle.test.js.map +1 -1
  21. package/dist/tests/e2e/gui-paint.test.js +44 -4
  22. package/dist/tests/e2e/gui-paint.test.js.map +1 -1
  23. package/dist/tests/e2e/gui-script.test.js +8 -4
  24. package/dist/tests/e2e/gui-script.test.js.map +1 -1
  25. package/doc/gui.md +113 -7
  26. package/doc/xbintsc.config.schema.json +4 -0
  27. package/doc/zh-CN/gui.md +74 -6
  28. package/package.json +1 -1
  29. package/runtime/ext_gui/dom_api.cpp +19 -0
  30. package/runtime/ext_gui/dom_api_node.cpp +4 -1
  31. package/runtime/ext_gui/dom_api_node_read.cpp +12 -0
  32. package/runtime/ext_gui/gui.cpp +42 -2
  33. package/runtime/ext_gui/gui_engine.h +15 -0
  34. package/runtime/ext_gui/gui_events.cpp +10 -3
  35. package/runtime/ext_gui/layout.cpp +6 -0
  36. package/runtime/ext_gui/layout.h +4 -0
  37. package/runtime/ext_gui/layout_flex.cpp +13 -5
  38. package/runtime/ext_gui/layout_flow.cpp +134 -4
  39. package/runtime/ext_gui/layout_flow_internal.h +41 -2
  40. package/runtime/ext_gui/layout_inline.cpp +11 -3
  41. package/runtime/ext_gui/paint.cpp +6 -0
  42. package/runtime/ext_gui/script.cpp +12 -2
  43. package/runtime/ext_gui/text.cpp +14 -0
  44. package/runtime/ext_gui/window.cpp +12 -0
  45. package/runtime/rt.h +10 -0
  46. package/src/driver/compiler.ts +26 -1
  47. package/src/driver/config.ts +7 -0
  48. package/src/driver/win-entry.ts +59 -0
package/doc/gui.md CHANGED
@@ -1,7 +1,8 @@
1
1
  # xbintsc GUI extension (self-hosted HTML/CSS renderer)
2
2
 
3
- Status: **M10** — features (M1–M10) are complete: HTML parsing, CSS selector
4
- matching, the cascade, computed styles and layout (block, inline and Flexbox) are
3
+ Status: **M11** — features (M1–M11) are complete: HTML parsing, CSS selector
4
+ matching, the cascade, computed styles and layout (block, inline, Flexbox and
5
+ CSS positioning) are
5
6
  in place, and the engine *paints*: it builds a display list of rectangles, images
6
7
  and shaped text runs and renders them through SDL_GPU. Input events are hit
7
8
  tested and delivered to native TS handlers, `:hover`/`:focus` are matched
@@ -11,6 +12,8 @@ DOM: element handles with stable identity, mutation (`appendChild`, `textContent
11
12
  `classList`, `style`, …) and element-level events with capture/bubbling.
12
13
  **M9** compiles `<script>` bodies ahead of time (inline and `<script src>`) — see
13
14
  `doc/gui-scripts.md`. **M10** adds `requestAnimationFrame` plus a few DOM helpers.
15
+ **M11** adds `position: relative`/`absolute`/`fixed` with their offsets, which is
16
+ what `examples/gui/pelican-bike` — a playable 2D game — is built on.
14
17
  This document records the locked decisions, the architecture, the milestone plan
15
18
  and the current progress of a cross-platform GUI extension that renders an
16
19
  HTML/CSS UI with its own GPU-accelerated engine.
@@ -111,10 +114,17 @@ run(); // drives the main loop until all windows
111
114
  ```
112
115
 
113
116
  Methods implemented on a window handle: `setTitle` / `setSize` / `loadHTML` /
114
- `getHTML` / `setBackground` / `close` / `isOpen` / `on` / `off`. Events emitted:
117
+ `getHTML` / `setBackground` / `close` / `isOpen` / `on` / `off`, plus `driver()`,
118
+ which reports the SDL_GPU backend in use (`"vulkan"`, `"direct3d12"`, `"metal"`,
119
+ … — SDL picks the best one the device supports). Events emitted:
115
120
  `ready` (after the first presented frame), `load`, `close`, and the input events
116
121
  `mousemove`, `mousedown`, `mouseup`, `click`, `wheel`, `keydown`, `keyup`.
117
122
 
123
+ On Windows a GUI program is linked as a **Windows-subsystem** executable, so
124
+ launching it does not flash a console window. That also means stdout/stderr go
125
+ nowhere when it is started from Explorer; set `app.console: true` in
126
+ `xbintsc.config.json` to keep the console subsystem (useful while debugging).
127
+
118
128
  Input handlers receive a single payload object (lifecycle handlers receive none):
119
129
 
120
130
  ```ts
@@ -284,10 +294,29 @@ with absolute (viewport-relative) geometry:
284
294
  measured with the HarfBuzz/FreeType stack (see *Implemented text*).
285
295
  - **Flexbox** — single-line `row`/`column` (and the `-reverse` variants) with
286
296
  `gap`, `flex-basis`/`flex-grow`/`flex-shrink`, `justify-content` and
287
- `align-items` (including `stretch` when the cross size is definite).
288
-
289
- Not yet implemented: margin collapsing, multi-line flex wrapping, `position`
290
- offsets (`relative`/`absolute`/`fixed`), `overflow` clipping and floats.
297
+ `align-items` (including `stretch` when the cross size is definite). Item
298
+ margins are part of the item's outer size on both axes, so a negative
299
+ `margin-top` pulls an item up instead of being ignored.
300
+ - **Positioning** — `position: relative` offsets a box (and its subtree) by
301
+ `top`/`right`/`bottom`/`left` without changing the space it reserved;
302
+ `position: absolute` (and `fixed`, which resolves against the viewport) takes
303
+ the box out of flow and places it against the padding box of the nearest
304
+ positioned ancestor — the viewport when there is none. An `auto` inset keeps
305
+ the static position on that axis, an `auto` width is shrink-to-fit unless both
306
+ `left` and `right` are set (then it fills the space between them), and
307
+ percentages resolve against the containing block. There is no `z-index`
308
+ stacking yet: painting stays in document order.
309
+ Note the consequence of that shrink-to-fit rule: an absolutely positioned box
310
+ with `width: auto` and no explicit size gets **no** available width for its own
311
+ inline content (its preferred width comes from its text or its non-absolute
312
+ children only), so inline runs inside it are laid out at zero available width
313
+ and each one wraps onto its own line. Give such a box an explicit `width`, or
314
+ place its children with `left`/`width` (as
315
+ `examples/gui/pelican-bike` does for its sprite runs), instead of relying on
316
+ line flow inside it.
317
+
318
+ Not yet implemented: margin collapsing, multi-line flex wrapping, `z-index`
319
+ stacking, `overflow` clipping and floats.
291
320
 
292
321
  **Document** (`runtime/ext_gui/document.{h,cpp}`): owns the DOM tree, gathers
293
322
  `<style>` text into one stylesheet, computes styles and layout for a viewport and
@@ -302,6 +331,13 @@ emits a backend-agnostic `DisplayList` with two parallel lists: **rectangles**
302
331
  lets the renderer draw all rectangles, then all text on top, with one draw call
303
332
  per list. `DisplayList::dump()` feeds `paintList()`/`paintCount()`.
304
333
 
334
+ `opacity` is honoured only at its extremes: a box whose computed `opacity` is
335
+ `0` (and its whole subtree) paints nothing, and anything above `0` paints fully
336
+ opaque. A shape carries its colour's alpha only, so there is no compositing of a
337
+ partly transparent box — the `opacity` transitions the CSS parser accepts are
338
+ therefore not visible either. Hiding and revealing a layer with `opacity: 0` on
339
+ the hidden state is what `examples/gui/pelican-bike`'s game-over overlay does.
340
+
305
341
  `runtime/ext_gui/renderer.{h,cpp}` turns that list into two batched vertex
306
342
  buffers per window (one for shapes, one for glyph quads) drawn through two
307
343
  SDL_GPU graphics pipelines:
@@ -406,6 +442,12 @@ Still to do for animation: `@keyframes` animations and `cubic-bezier(...)`.
406
442
  `XT_GUI_FONT` (and `XT_GUI_FONT_MONO`), and cached per `(family class, size)`.
407
443
  Only regular upright faces are used for now; weight/italic selection is a
408
444
  later refinement.
445
+ - The default candidates prefer a **CJK-capable** face (Microsoft YaHei on
446
+ Windows, PingFang on macOS, Noto Sans CJK/WenQuanYi on Linux) before the
447
+ Latin-only ones, because a run is shaped with a *single* face: without this,
448
+ Chinese/Japanese/Korean text renders as blank or `.notdef` boxes. Set
449
+ `XT_GUI_FONT` to a Latin-only face to get its Latin design instead (at the
450
+ cost of CJK coverage).
409
451
  - `xt_text_measure_width` shapes the run with HarfBuzz (so kerning and
410
452
  ligatures are honoured), `xt_text_metrics` returns FreeType's ascent /
411
453
  descent / normal line height. When no font file can be found the module falls
@@ -487,9 +529,23 @@ npm run gui # build runtime/lib/<os>-<arch>/gui.a (fetches SDL3 once)
487
529
  xbintsc run examples/gui/hello.ts --ext gui
488
530
  ```
489
531
 
532
+ Two examples live here: `hello.ts` (HTML/CSS + a reactive counter) and
533
+ [`pelican-bike/`](../examples/gui/pelican-bike), a playable 2D game that uses
534
+ positioned layers, CSS sprites, `requestAnimationFrame` and keyboard input — see
535
+ its README for controls and the geometry report it prints.
536
+
490
537
  Set `XT_GUI_AUTOCLOSE_MS=<n>` to close all windows after `n` milliseconds,
491
538
  which the e2e test (`tests/e2e/gui-*.test.ts`) uses to run headlessly.
492
539
 
540
+ Those suites are skipped on Windows by default (the archive is built there but
541
+ the run is not validated in CI). With a `gui.lib` and a working GPU, opt in with
542
+ `xbintsc_GUI_TESTS=1`:
543
+
544
+ ```bat
545
+ set xbintsc_GUI_TESTS=1
546
+ npx vitest run tests/e2e/gui-layout.test.ts tests/e2e/gui-example.test.ts
547
+ ```
548
+
493
549
  On a headless Linux box, install the SDL3 build headers and run under Xvfb with a
494
550
  software Vulkan driver:
495
551
 
@@ -579,6 +635,16 @@ used by default; Wayland is enabled too but not yet exercised.
579
635
  DOM (`gui.cpp`, `window.cpp`, `gui_engine.h`).
580
636
  - `Element.offsetWidth` / `offsetHeight` (rounded border box, flushes pending
581
637
  mutations) and `Element.contains(other)` (`dom_api.cpp`).
638
+ 11. **M11 — CSS positioning** ✅
639
+ - `position: relative` / `absolute` / `fixed` with `top`/`right`/`bottom`/
640
+ `left`, resolved against the nearest positioned ancestor (the viewport when
641
+ there is none), `auto` insets keeping the static position, shrink-to-fit
642
+ for an out-of-flow `width: auto` and fill-available when both horizontal
643
+ insets are set (`layout_flow.cpp`, `layout.h`).
644
+ - Item margins are honoured in the flex algorithm, so a negative margin
645
+ participates in the layout instead of being dropped (`layout_flex.cpp`).
646
+ - Drives `examples/gui/pelican-bike` and e2e coverage in
647
+ `tests/e2e/gui-layout.test.ts`.
582
648
 
583
649
  ## Progress log
584
650
 
@@ -629,11 +695,51 @@ used by default; Wayland is enabled too but not yet exercised.
629
695
  (callbacks run with the frame timestamp before layout each frame) plus
630
696
  `offsetWidth`/`offsetHeight`/`contains` on element handles; e2e coverage in
631
697
  `tests/e2e/gui-*.test.ts`.
698
+ - **M11** ✅ `position: relative`/`absolute`/`fixed` with `top`/`right`/`bottom`/
699
+ `left`. Layout keeps a containing block per box (the nearest positioned
700
+ ancestor's padding box, published only once that ancestor has its final size,
701
+ so `bottom`/`right` are exact), absolute boxes are laid out after in-flow
702
+ content so they never affect it, shrink-to-fit/fill-available width rules are
703
+ applied, and flex items now account for their margins. e2e coverage in
704
+ `tests/e2e/gui-layout.test.ts`; `examples/gui/pelican-bike` is built on it.
705
+
706
+ ## Known issues
707
+
708
+ - **Intermittent crash at auto-close.** `XT_GUI_AUTOCLOSE_MS` can end a run
709
+ with a native access violation in roughly two runs out of five; a clean exit
710
+ looks identical otherwise. Windows reports it as the NTSTATUS exit code
711
+ `0xC0000005`; on Linux/macOS the process dies with a signal — an access
712
+ violation is `SIGSEGV`, so `spawnSync` reports `status: null` plus a
713
+ `signal`. It only reproduces with a *running animation-frame loop* (a static
714
+ document closes fine) and it is **not** the game's own code: no exception is
715
+ reported, the crash usually lands inside the first second regardless of the
716
+ deadline, and raising `XT_GC_THRESHOLD` hides it, so it looks like a
717
+ GC/teardown race. Interactive runs (no auto-close) are stable for minutes.
718
+ A leading cause was that the engine cached `xt_value`s in C++ containers
719
+ the mark-sweep collector cannot see — the AOT `<script>` registry, element
720
+ handles, element listeners and the queued/currently-running
721
+ `requestAnimationFrame` callbacks. They are now marked by GC root providers
722
+ (`xt_gui_script_gc_scan` and `xt_gui_gc_scan_roots`), which removes that
723
+ class of use-after-free. `tests/e2e/gui-example.test.ts` still treats either
724
+ crash spelling as an acceptable outcome — the archive is built per platform
725
+ and the run is timing-sensitive — and the behavioural assertions live in
726
+ `tests/e2e/gui-example-browser.test.ts` (no GPU, deterministic).
727
+ - **`display: none -> flex` restyle.** A hidden element being shown by a
728
+ *class change* on a flex container has also been observed to crash the same
729
+ way; `examples/gui/pelican-bike` reveals its game-over overlay with `opacity`
730
+ instead.
632
731
 
633
732
  ## Open questions
634
733
 
635
734
  - Whether Linux ships X11, Wayland, or both in the first cut. (Decision:
636
735
  X11 first, Wayland later.)
736
+ - **SVG:** there is no SVG support and none is planned. The HTML parser knows
737
+ elements and attributes, the layout engine knows boxes, and paint knows
738
+ rectangles/text/images, so `<svg>` trees would need a whole second
739
+ geometry+paint path. An SVG *file* works only through the image loader
740
+ (`<img src="logo.svg">`), which needs an SVG rasteriser (`stb_image` does not
741
+ decode SVG); vector art therefore has to arrive as a raster (PNG) or be drawn
742
+ from boxes, as `examples/gui/pelican-bike` does.
637
743
  - Windows: an MSVC-compatible `gui.lib` (COFF objects + `ar -M`/`llvm-ar`) is
638
744
  produced by `scripts/build-gui.ts`, but it has not been validated in CI yet,
639
745
  so the Windows step is provisional (the job stays green) and `package` skips
@@ -60,6 +60,10 @@
60
60
  "bundleId": {
61
61
  "type": "string",
62
62
  "description": "macOS CFBundleIdentifier, e.g. com.example.demo."
63
+ },
64
+ "console": {
65
+ "type": "boolean",
66
+ "description": "Windows only: keep the console subsystem (a console window) instead of the default Windows subsystem, so stdout/stderr are visible while debugging a GUI program."
63
67
  }
64
68
  }
65
69
  }
package/doc/zh-CN/gui.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  > 语言 / Language:[English](../gui.md) | **简体中文**
4
4
 
5
- 状态:**M10** —— 功能(M1–M10)已全部完成:HTML 解析、CSS 选择器匹配、层叠(cascade)、计算样式与布局(块级、行内与 Flexbox)均已就位,引擎还会**绘制**:它构建一个由矩形、图像与排版后的文本 run 组成的显示列表,并通过 SDL_GPU 渲染。输入事件会经过命中测试并投递给原生 TS 处理器,`:hover`/`:focus` 会被动态匹配,`<img>` 依据其固有尺寸确定大小并从纹理绘制,CSS transition 会为绘制属性做动画。**M8** 增加了交互式 DOM:具有稳定标识的元素句柄、变更操作(`appendChild`、`textContent`、`classList`、`style` 等)以及支持捕获/冒泡的元素级事件。**M9** 会提前编译 `<script>` 主体(内联与 `<script src>`)——参见 `gui-scripts.md`。**M10** 增加 `requestAnimationFrame` 以及若干 DOM 辅助方法。本文档记录了一个跨平台 GUI 扩展的锁定决策、架构、里程碑计划与当前进度;该扩展使用自有的 GPU 加速引擎渲染 HTML/CSS UI。
5
+ 状态:**M11** —— 功能(M1–M11)已全部完成:HTML 解析、CSS 选择器匹配、层叠(cascade)、计算样式与布局(块级、行内、Flexbox 与 CSS 定位)均已就位,引擎还会**绘制**:它构建一个由矩形、图像与排版后的文本 run 组成的显示列表,并通过 SDL_GPU 渲染。输入事件会经过命中测试并投递给原生 TS 处理器,`:hover`/`:focus` 会被动态匹配,`<img>` 依据其固有尺寸确定大小并从纹理绘制,CSS transition 会为绘制属性做动画。**M8** 增加了交互式 DOM:具有稳定标识的元素句柄、变更操作(`appendChild`、`textContent`、`classList`、`style` 等)以及支持捕获/冒泡的元素级事件。**M9** 会提前编译 `<script>` 主体(内联与 `<script src>`)——参见 `gui-scripts.md`。**M10** 增加 `requestAnimationFrame` 以及若干 DOM 辅助方法。**M11** 增加 `position: relative`/`absolute`/`fixed` 及其偏移,`examples/gui/pelican-bike`(一个可玩的 2D 游戏)就构建在它之上。本文档记录了一个跨平台 GUI 扩展的锁定决策、架构、里程碑计划与当前进度;该扩展使用自有的 GPU 加速引擎渲染 HTML/CSS UI。
6
6
 
7
7
  ## 目标
8
8
 
@@ -88,7 +88,15 @@ run(); // 驱动主循环,直到所有窗口
88
88
  ```
89
89
 
90
90
  窗口句柄上已实现的方法:`setTitle` / `setSize` / `loadHTML` /
91
- `getHTML` / `setBackground` / `close` / `isOpen` / `on` / `off`。触发的事件:
91
+ `getHTML` / `setBackground` / `close` / `isOpen` / `on` / `off`,以及
92
+ `driver()`——它报告当前使用的 SDL_GPU 后端(`"vulkan"`、`"direct3d12"`、
93
+ `"metal"` 等,由 SDL 挑选设备支持的最佳后端)。
94
+
95
+ 在 Windows 上,GUI 程序会被链接为 **Windows 子系统**可执行文件,因此启动时不会闪出控制台
96
+ 窗口;这也意味着从资源管理器启动时 stdout/stderr 无处可去,调试时可在
97
+ `xbintsc.config.json` 里设置 `app.console: true` 保留控制台子系统。
98
+
99
+ 触发的事件:
92
100
  `ready`(首个呈现帧之后)、`load`、`close`,以及输入事件
93
101
  `mousemove`、`mousedown`、`mouseup`、`click`、`wheel`、`keydown`、`keyup`。
94
102
 
@@ -252,10 +260,21 @@ run();
252
260
  技术栈测量(见 *已实现的文本*)。
253
261
  - **Flexbox** —— 单行 `row`/`column`(以及 `-reverse` 变体),支持 `gap`、
254
262
  `flex-basis`/`flex-grow`/`flex-shrink`、`justify-content` 与 `align-items`
255
- (当交叉轴尺寸确定时包括 `stretch`)。
256
-
257
- 尚未实现:外边距折叠、多行 flex 换行、`position` 偏移
258
- (`relative`/`absolute`/`fixed`)、`overflow` 裁剪与浮动。
263
+ (当交叉轴尺寸确定时包括 `stretch`)。flex 项的外边距参与其外框尺寸,因此
264
+ 负的 `margin-top` 会把该项上移,而不是被忽略。
265
+ - **定位** —— `position: relative` 会按 `top`/`right`/`bottom`/`left` 偏移该盒子
266
+ 及其子树,但不改变它在流中占用的空间;`position: absolute`(以及针对视口解析
267
+ 的 `fixed`)使其脱离文档流,并相对最近的已定位祖先的 padding box 定位(没有
268
+ 这样的祖先时则相对视口)。`auto` 的 inset 在该轴上保留静态位置;宽度 `auto`
269
+ 时使用 shrink-to-fit,除非 `left` 与 `right` 同时给出(此时填满两者之间的空间);
270
+ 百分比相对包含块解析。目前还没有 `z-index` 层叠,绘制仍按文档顺序进行。
271
+ 注意该 shrink-to-fit 规则的后果:一个 `width: auto` 且没有显式尺寸的绝对定位
272
+ 盒子,其内部行内内容得到的**可用宽度为 0**(它的首选宽度只来自文本与
273
+ 非绝对定位的子元素),因此其中的行内 run 会各自换到独立的一行。请为此类盒子
274
+ 给出显式 `width`,或用 `left`/`width` 摆放其子元素(`examples/gui/pelican-bike`
275
+ 的精灵 run 就是这样做的),而不要依赖其内部的行流。
276
+
277
+ 尚未实现:外边距折叠、多行 flex 换行、`z-index` 层叠、`overflow` 裁剪与浮动。
259
278
 
260
279
  **Document**(`runtime/ext_gui/document.{h,cpp}`):持有 DOM 树,把 `<style>`
261
280
  文本汇总为一份样式表,为某个视口计算样式与布局,并提供
@@ -269,6 +288,11 @@ run();
269
288
  把它们分开可以让渲染器先绘制所有矩形,再在其上绘制所有文本,每个列表一次
270
289
  绘制调用。`DisplayList::dump()` 为 `paintList()`/`paintCount()` 提供数据。
271
290
 
291
+ `opacity` 只在两端生效:计算值为 `0` 的盒子(及其整棵子树)不绘制任何内容,
292
+ 大于 `0` 的一律按完全不透明绘制。图形只携带其颜色自身的 alpha,因此没有部分
293
+ 透明盒子的合成——CSS 解析器接受的 `opacity` 过渡因此也看不到效果。把隐藏态写成
294
+ `opacity: 0` 来隐藏/显示一层,正是 `examples/gui/pelican-bike` 游戏结束遮罩的做法。
295
+
272
296
  `runtime/ext_gui/renderer.{h,cpp}` 把该列表转换为每个窗口两个批处理顶点缓冲
273
297
  (一个用于图形,一个用于字形四边形),通过两条 SDL_GPU 图形管线绘制:
274
298
 
@@ -441,6 +465,19 @@ xbintsc run examples/gui/hello.ts --ext gui
441
465
  设置 `XT_GUI_AUTOCLOSE_MS=<n>` 可在 `n` 毫秒后关闭所有窗口,e2e 测试
442
466
  (`tests/e2e/gui-*.test.ts`)用它来无头运行。
443
467
 
468
+ 这里有两个示例:`hello.ts`(HTML/CSS + 响应式计数器)与
469
+ [`pelican-bike/`](../examples/gui/pelican-bike)(一个可玩的 2D 游戏,使用分层
470
+ 定位、CSS 像素精灵、`requestAnimationFrame` 与键盘输入)——操作方式与它输出的
471
+ 几何报告见该目录的 README。
472
+
473
+ 这些测试套件在 Windows 上默认跳过(该平台会构建归档,但 CI 未验证运行)。
474
+ 如果本机有 `gui.lib` 与可用的 GPU,可用 `xbintsc_GUI_TESTS=1` 选择启用:
475
+
476
+ ```bat
477
+ set xbintsc_GUI_TESTS=1
478
+ npx vitest run tests/e2e/gui-layout.test.ts tests/e2e/gui-example.test.ts
479
+ ```
480
+
444
481
  在无头 Linux 机器上,安装 SDL3 构建头文件,并在 Xvfb 与软件 Vulkan 驱动下
445
482
  运行:
446
483
 
@@ -525,6 +562,15 @@ xvfb-run -a --server-args="-screen 0 1280x720x24" \
525
562
  (`gui.cpp`、`window.cpp`、`gui_engine.h`)。
526
563
  - `Element.offsetWidth` / `offsetHeight`(取整后的边框盒,会冲刷待处理
527
564
  的变更)与 `Element.contains(other)`(`dom_api.cpp`)。
565
+ 11. **M11 — CSS 定位** ✅
566
+ - `position: relative` / `absolute` / `fixed` 与 `top`/`right`/`bottom`/
567
+ `left`,相对最近的已定位祖先解析(没有时相对视口);`auto` inset 保留
568
+ 静态位置;脱离文档流的 `width: auto` 使用 shrink-to-fit,两个水平 inset
569
+ 都给出时则填满可用宽度(`layout_flow.cpp`、`layout.h`)。
570
+ - flex 算法现在会计入项的外边距,负外边距参与布局而不再被丢弃
571
+ (`layout_flex.cpp`)。
572
+ - `examples/gui/pelican-bike` 基于它实现,e2e 覆盖见
573
+ `tests/e2e/gui-layout.test.ts`。
528
574
 
529
575
  ## 进度日志
530
576
 
@@ -573,6 +619,28 @@ xvfb-run -a --server-args="-screen 0 1280x720x24" \
573
619
  (回调在每帧布局之前以帧时间戳运行),以及元素句柄上的
574
620
  `offsetWidth`/`offsetHeight`/`contains`;e2e 覆盖在
575
621
  `tests/e2e/gui-*.test.ts` 中。
622
+ - **M11** ✅ `position: relative`/`absolute`/`fixed` 与 `top`/`right`/`bottom`/
623
+ `left`。布局为每个盒子保留包含块(最近的已定位祖先的 padding box,且只在
624
+ 该祖先拥有最终尺寸后才发布,因此 `bottom`/`right` 精确);绝对定位的盒子
625
+ 在正常流内容之后布局,因此不影响它们;实现了 shrink-to-fit / 填满可用宽度
626
+ 两种规则;flex 项现在会计入外边距。e2e 覆盖在
627
+ `tests/e2e/gui-layout.test.ts`;`examples/gui/pelican-bike` 基于它实现。
628
+
629
+ ## 已知问题
630
+
631
+ - **自动关闭时的偶发崩溃。** 设置 `XT_GUI_AUTOCLOSE_MS` 结束运行时,约五次中有两次会以原生
632
+ 访问违例(`0xC0000005`)退出,其余运行与正常退出无异。该问题只在**动画帧循环运行中**复现
633
+ (静态文档可以正常关闭),且**不是示例游戏自身**的代码:没有任何异常信息,崩溃多发生在
634
+ 启动后一秒内、与截止时间无关,而提高 `XT_GC_THRESHOLD` 可以掩盖它,因此看起来是
635
+ GC/析构竞态。交互式运行(不设自动关闭)可稳定运行数分钟。`tests/e2e/gui-example.test.ts`
636
+ 因此把该退出码视为可接受结果,行为断言放在不依赖 GPU 的
637
+ `tests/e2e/gui-example-browser.test.ts` 中。
638
+ - **`display: none -> flex` 的重排。** 通过类名切换让隐藏元素显示在 flex 容器上时,也曾观察
639
+ 到同样的崩溃;`examples/gui/pelican-bike` 改用 `opacity` 显示游戏结束遮罩。
640
+ - **SVG:** 引擎不支持也不计划支持 SVG 元素。HTML 解析器只认识元素与属性,布局只认识盒子,
641
+ 绘制只认识矩形/文本/图像;`<svg>` 子树需要另一套几何与绘制路径。SVG *文件* 也只能走图像
642
+ 加载器(`<img src="logo.svg">`),而这需要 SVG 光栅化器(`stb_image` 不解码 SVG)。因此
643
+ 矢量美术要么以位图(PNG)形式提供,要么像 `examples/gui/pelican-bike` 那样用盒子绘制。
576
644
 
577
645
  ## 待决问题
578
646
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xbintsc",
3
- "version": "0.3.49",
3
+ "version": "0.3.61",
4
4
  "description": "xbintsc - a TypeScript binary compiler (TypeScript -> LLVM IR -> native binary)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -136,6 +136,25 @@ xt_value xt_gui_node_handle(XtGuiWindow *win, const xtgui::Node *node) {
136
136
  return handle;
137
137
  }
138
138
 
139
+ void xt_gui_node_handle_forget(XtGuiWindow *win, const xtgui::Node *node) {
140
+ if (win == nullptr || node == nullptr) return;
141
+ /* The node is about to be destroyed (a removal, or `innerHTML = …` dropping
142
+ * the old children). Drop its slot as well as its handle: the slot is what
143
+ * `resolve_node` reads, and leaving a freed pointer there is a
144
+ * use-after-free the next time any handle is resolved. Existing handles keep
145
+ * resolving to "stale" and no-op, which is the documented tombstone
146
+ * behaviour. */
147
+ auto index = win->node_index.find(node);
148
+ if (index != win->node_index.end()) {
149
+ int slot = index->second;
150
+ if (slot >= 0 && slot < (int)win->node_order.size()) {
151
+ win->node_order[(size_t)slot] = nullptr;
152
+ }
153
+ win->node_index.erase(index);
154
+ }
155
+ win->node_handles.erase(node);
156
+ }
157
+
139
158
  xt_value xt_gui_document_handle(XtGuiWindow *win) {
140
159
  if (win == nullptr) return XT_UNDEFINED;
141
160
  if (!XT_IS_OBJECT(win->document_object)) {
@@ -177,7 +177,10 @@ METHOD(node_replace_child) {
177
177
  xtgui::Node *newChild = resolve_node(xt_arg(argc, argv, 0), nullptr);
178
178
  xtgui::Node *oldChild = resolve_node(xt_arg(argc, argv, 1), nullptr);
179
179
  if (newChild == nullptr || oldChild == nullptr) return XT_UNDEFINED;
180
- return xt_gui_node_handle(win, win->document->replaceChild(node, newChild, oldChild));
180
+ /* `replaceChild` frees the node it replaces, so its slot may not survive. */
181
+ xtgui::Node *replaced = win->document->replaceChild(node, newChild, oldChild);
182
+ if (replaced != oldChild) xt_gui_node_handle_forget(win, oldChild);
183
+ return xt_gui_node_handle(win, replaced);
181
184
  }
182
185
 
183
186
  METHOD(node_remove) {
@@ -25,6 +25,13 @@ static void collect_text(const xtgui::Node *node, std::string &out) {
25
25
  for (const std::unique_ptr<xtgui::Node> &child : node->children) collect_text(child.get(), out);
26
26
  }
27
27
 
28
+ /** Forget the handles of `node` and every descendant: they are about to be
29
+ * freed, and a slot left behind would dangle. */
30
+ static void forget_subtree(XtGuiWindow *win, const xtgui::Node *node) {
31
+ xt_gui_node_handle_forget(win, node);
32
+ for (const std::unique_ptr<xtgui::Node> &child : node->children) forget_subtree(win, child.get());
33
+ }
34
+
28
35
  #define METHOD(name) xt_value name(xt_value self, xt_value env, int32_t argc, xt_value *argv)
29
36
 
30
37
  METHOD(node_tag_name_get) {
@@ -105,6 +112,7 @@ METHOD(node_text_content_set) {
105
112
  std::vector<xtgui::Node *> existing;
106
113
  for (const std::unique_ptr<xtgui::Node> &child : node->children) existing.push_back(child.get());
107
114
  for (xtgui::Node *child : existing) win->document->removeChild(node, child);
115
+ for (xtgui::Node *child : existing) forget_subtree(win, child);
108
116
  if (!text.empty()) node->addText(text);
109
117
  mark_dirty(win);
110
118
  return XT_UNDEFINED;
@@ -132,6 +140,10 @@ METHOD(node_inner_html_set) {
132
140
  std::vector<xtgui::Node *> existing;
133
141
  for (const std::unique_ptr<xtgui::Node> &child : node->children) existing.push_back(child.get());
134
142
  for (xtgui::Node *child : existing) win->document->removeChild(node, child);
143
+ /* The old children are dropped here (only `removeChild` keeps a detached node
144
+ * alive), so forget them *and everything below them* before they are freed:
145
+ * a leftover slot would be a dangling pointer for the next handle lookup. */
146
+ for (xtgui::Node *child : existing) forget_subtree(win, child);
135
147
  std::unique_ptr<xtgui::Node> fragment = xtgui::xt_html_parse(html);
136
148
  for (std::unique_ptr<xtgui::Node> &child : fragment->children) node->append(std::move(child));
137
149
  mark_dirty(win);
@@ -64,10 +64,36 @@ void xt_gui_apply_icon(SDL_Window *window) {
64
64
  SDL_DestroySurface(surface);
65
65
  }
66
66
 
67
+ /* -- garbage collector roots ---------------------------------------------- */
68
+
69
+ /**
70
+ * GC root provider: the engine caches `xt_value`s in C++ containers that the
71
+ * mark-sweep collector cannot see — per-window element handles, element
72
+ * listeners, the document handle and the queued/currently-running
73
+ * `requestAnimationFrame` callbacks. Rooting the window object alone does not
74
+ * reach these (they are not its properties), so without this provider a
75
+ * collection frees values the engine still uses and the next frame or DOM read
76
+ * crashes. The process-wide AOT `<script>` registry registers its own
77
+ * provider (`xt_gui_script_gc_scan`).
78
+ */
79
+ static void xt_gui_gc_scan_roots(void) {
80
+ for (int i = 0; i < g_window_count; i++) {
81
+ const XtGuiWindow &win = g_windows[i];
82
+ xt_gc_mark_value(win.object);
83
+ xt_gc_mark_value(win.document_object);
84
+ for (const auto &entry : win.node_handles) xt_gc_mark_value(entry.second);
85
+ for (const auto &entry : win.node_listeners) {
86
+ for (const XtGuiNodeListener &listener : entry.second) xt_gc_mark_value(listener.fn);
87
+ }
88
+ for (const XtGuiAnimationFrame &frame : win.animation_frames) xt_gc_mark_value(frame.fn);
89
+ for (const XtGuiAnimationFrame &frame : win.running_frames) xt_gc_mark_value(frame.fn);
90
+ }
91
+ }
92
+
67
93
  /* -- initialisation ------------------------------------------------------- */
68
94
 
69
- static int ensure_init(void) {
70
- if (g_sdl_initialized) return 1;
95
+ static int ensure_init(void) { if (g_sdl_initialized) return 1;
96
+ xt_gc_register_root_provider(xt_gui_gc_scan_roots);
71
97
  if (!SDL_Init(SDL_INIT_VIDEO)) {
72
98
  fprintf(stderr, "xt_gui: SDL_Init failed: %s\n", SDL_GetError());
73
99
  return 0;
@@ -102,6 +128,12 @@ static XtGuiWindow *window_by_object(xt_value object) {
102
128
 
103
129
  using namespace guidev;
104
130
 
131
+ const char *xt_gui_driver(void) {
132
+ if (g_device == NULL) return "";
133
+ const char *driver = SDL_GetGPUDeviceDriver(g_device);
134
+ return driver != NULL ? driver : "";
135
+ }
136
+
105
137
  XtGuiWindow *xt_gui_window_from_this(xt_value thisValue) {
106
138
  return window_by_object(thisValue);
107
139
  }
@@ -206,6 +238,14 @@ extern "C" xt_value xt_gui_create_window(int32_t argc, xt_value *argv) {
206
238
  xt_object_set(object, xt_string_from_cstr("__xt_gui_index"), xt_number((double)index));
207
239
  xt_object_set(object, xt_string_from_cstr("__xt_gui_html"), xt_string_from_cstr(""));
208
240
  xt_object_set(object, xt_string_from_cstr("__xt_gui_events"), xt_object_new());
241
+ /* The engine owns a graph of values on the window's behalf — element
242
+ * handles (`node_handles`), their event listeners and the queued animation
243
+ * frame closures. Most of them are referenced from TypeScript too, but not
244
+ * all: a handle the script queried once and dropped (or one only reachable
245
+ * through an ancestor element) would otherwise be collected while the engine
246
+ * still uses it, which is a use-after-free. Rooting the window object makes
247
+ * everything hanging off it reachable. */
248
+ xt_gc_add_root(&record->object);
209
249
 
210
250
  return object;
211
251
  }
@@ -24,6 +24,9 @@ xt_value xt_gui_run(int32_t argc, xt_value *argv);
24
24
  xt_value xt_gui_quit(int32_t argc, xt_value *argv);
25
25
  /** `__registerScript(id, fn)`: record an AOT-compiled `<script>` body. */
26
26
  xt_value xt_register_script(int32_t argc, xt_value *argv);
27
+ /** Which SDL_GPU backend the device is using (`"vulkan"`, `"direct3d12"`,
28
+ * `"metal"`, …), or `""` before the device exists. */
29
+ const char *xt_gui_driver(void);
27
30
 
28
31
  #ifdef __cplusplus
29
32
  } /* extern "C" */
@@ -84,6 +87,11 @@ struct XtGuiWindow {
84
87
  int struct_dirty = 0;
85
88
  /** Callbacks queued for the next frame, plus the id counter. */
86
89
  std::vector<XtGuiAnimationFrame> animation_frames;
90
+ /** The batch currently running: its callbacks have been swapped out of
91
+ * `animation_frames`, and a collection triggered by one of them must still
92
+ * see the rest. Kept on the record (not a local) so the GC root provider can
93
+ * mark it. Empty except while `xt_gui_run_animation_frames` runs. */
94
+ std::vector<XtGuiAnimationFrame> running_frames;
87
95
  int next_animation_frame_id = 1;
88
96
  };
89
97
 
@@ -93,6 +101,10 @@ XtGuiWindow *xt_gui_window_from_this(xt_value thisValue);
93
101
  xt_value xt_gui_document_handle(XtGuiWindow *win);
94
102
  /** Node handle for `node` (created and cached on first use). */
95
103
  xt_value xt_gui_node_handle(XtGuiWindow *win, const xtgui::Node *node);
104
+ /** Forget a node's slot and handle because it is about to be destroyed (a
105
+ * removal that frees it, or `innerHTML = …` dropping the old children). Without
106
+ * this the node table would keep a dangling pointer. */
107
+ void xt_gui_node_handle_forget(XtGuiWindow *win, const xtgui::Node *node);
96
108
  /** Drop every cached handle/listener and bump the document generation. */
97
109
  void xt_gui_handles_reset(XtGuiWindow *win);
98
110
  /** Deliver `type` to element listeners along the propagation path. */
@@ -105,6 +117,9 @@ void xt_gui_flush_dom(XtGuiWindow *win);
105
117
  /** Run every registered `<script data-xt-id>` body found in the document, in
106
118
  * document order, passing `(windowHandle, documentHandle)`. */
107
119
  void xt_gui_run_scripts(XtGuiWindow *win);
120
+ /** GC root provider: mark every AOT-compiled `<script>` function in the
121
+ * process-wide registry. Registered with the collector by `xt_register_script`. */
122
+ void xt_gui_script_gc_scan(void);
108
123
  /** Shared prototype carrying the window methods. */
109
124
  xt_value xt_gui_window_proto(void);
110
125
  /** Release GPU claim + destroy the window and mark the record closed. */
@@ -220,15 +220,18 @@ void handle_event(const SDL_Event *event) {
220
220
 
221
221
  void xt_gui_run_animation_frames(XtGuiWindow *win, double timestamp_ms) {
222
222
  if (win == NULL || win->animation_frames.empty()) return;
223
- /* Swap the queue out first: a callback that calls requestAnimationFrame
224
- * again schedules for the *next* frame, not this one. */
225
- std::vector<XtGuiAnimationFrame> frames;
223
+ /* Swap the queue into a member, not a local: the GC root provider marks
224
+ * `running_frames` too, so a collection triggered while one callback runs
225
+ * cannot free the rest of the batch. Re-queueing from a callback lands in
226
+ * `animation_frames` and runs on the *next* frame, not this one. */
227
+ std::vector<XtGuiAnimationFrame> &frames = win->running_frames;
226
228
  frames.swap(win->animation_frames);
227
229
  for (const XtGuiAnimationFrame &frame : frames) {
228
230
  if (!win->open) break;
229
231
  xt_value arg = xt_number(timestamp_ms);
230
232
  xt_call_with_this(frame.fn, win->object, 1, &arg);
231
233
  }
234
+ frames.clear();
232
235
  }
233
236
 
234
237
  void xt_gui_render_window(XtGuiWindow *win) {
@@ -244,6 +247,10 @@ void xt_gui_render_window(XtGuiWindow *win) {
244
247
  /* Animation-frame callbacks run before layout, so any DOM mutation they make
245
248
  * is picked up by this same frame. */
246
249
  xt_gui_run_animation_frames(win, now);
250
+ /* A callback may have closed the window (directly or through the
251
+ * auto-close guard): `xt_gui_quit_window` released the document and the GPU
252
+ * geometry, so there is nothing left to lay out or paint. */
253
+ if (!win->open) return;
247
254
  if (win->document != nullptr && win->document->advance(delta)) win->geometry.dirty = 1;
248
255
 
249
256
  /* A DOM mutation from a handler invalidates the tree: restyle/relayout
@@ -96,6 +96,12 @@ void LayoutTree::compute(const Node *root, const std::unordered_map<const Node *
96
96
  root_->node = root;
97
97
  root_->style = &root_style_;
98
98
  root_->display = Display::Block;
99
+ /* The initial containing block: absolute boxes with no positioned ancestor
100
+ * resolve their insets against the viewport. */
101
+ root_->cb_x = 0;
102
+ root_->cb_y = 0;
103
+ root_->cb_width = viewportWidth;
104
+ root_->cb_height = viewportHeight;
99
105
  if (root != nullptr) {
100
106
  if (root->type == NodeType::Document) {
101
107
  for (const std::unique_ptr<Node> &child : root->children) {
@@ -59,6 +59,10 @@ struct LayoutBox {
59
59
  float content_x = 0, content_y = 0, content_width = 0, content_height = 0;
60
60
  float baseline = 0;
61
61
 
62
+ /* The padding box of the nearest positioned ancestor (the viewport when there
63
+ * is none): the containing block `position: absolute` resolves against. */
64
+ float cb_x = 0, cb_y = 0, cb_width = 0, cb_height = 0;
65
+
62
66
  LayoutBox *parent = nullptr;
63
67
  std::vector<std::unique_ptr<LayoutBox>> children;
64
68
  };
@@ -19,9 +19,12 @@ float Layouter::layoutFlex(LayoutBox *container, float contentWidth, float defin
19
19
  containerStyle.flex_direction == FlexDirection::ColumnReverse;
20
20
  float gap = px(containerStyle.gap, contentWidth, containerStyle.font_size, 0.0f);
21
21
 
22
+ /* An absolutely positioned child is out of flow: it takes no part in the flex
23
+ * algorithm and is placed against the container's padding box instead. */
22
24
  std::vector<LayoutBox *> items;
23
25
  for (const std::unique_ptr<LayoutBox> &child : container->children) {
24
- if (child->display != Display::None) items.push_back(child.get());
26
+ if (child->display == Display::None || isAbsoluteChild(child.get())) continue;
27
+ items.push_back(child.get());
25
28
  }
26
29
  if (items.empty()) return 0;
27
30
  size_t count = items.size();
@@ -71,6 +74,7 @@ float Layouter::layoutFlex(LayoutBox *container, float contentWidth, float defin
71
74
  for (size_t i = 0; i < count; i++) {
72
75
  float contentW = mainSizes[i] - horizontalEdges(items[i]);
73
76
  if (contentW < 0) contentW = 0;
77
+ setContainingBlock(items[i], containingBlockAncestor(items[i]));
74
78
  layoutBlock(items[i], container->content_x, container->content_y, contentWidth,
75
79
  definiteHeight, contentW);
76
80
  cross[i] = verticalEdges(items[i]) + items[i]->height;
@@ -98,8 +102,8 @@ float Layouter::layoutFlex(LayoutBox *container, float contentWidth, float defin
98
102
  for (size_t k = 0; k < count; k++) {
99
103
  size_t i = reverse ? (count - 1 - k) : k;
100
104
  float crossOffset = crossOffsetFor(containerStyle.align_items, crossSize, cross[i]);
101
- float dx = cursor - items[i]->x;
102
- float dy = container->content_y + crossOffset - items[i]->y;
105
+ float dx = cursor + items[i]->margin_left - items[i]->x;
106
+ float dy = container->content_y + crossOffset + items[i]->margin_top - items[i]->y;
103
107
  translate(items[i], dx, dy);
104
108
  cursor += mainSizes[i] + between;
105
109
  }
@@ -111,6 +115,7 @@ float Layouter::layoutFlex(LayoutBox *container, float contentWidth, float defin
111
115
  std::vector<float> outer(count, 0.0f);
112
116
  float sumBase = 0;
113
117
  for (size_t i = 0; i < count; i++) {
118
+ setContainingBlock(items[i], containingBlockAncestor(items[i]));
114
119
  layoutBlock(items[i], container->content_x, container->content_y, contentWidth, definiteHeight);
115
120
  outer[i] = verticalEdges(items[i]) + items[i]->height;
116
121
  sumBase += outer[i];
@@ -142,8 +147,11 @@ float Layouter::layoutFlex(LayoutBox *container, float contentWidth, float defin
142
147
  float cursor = container->content_y + offset;
143
148
  for (size_t k = 0; k < count; k++) {
144
149
  size_t i = reverse ? (count - 1 - k) : k;
145
- float dx = container->content_x - items[i]->x;
146
- float dy = cursor - items[i]->y;
150
+ float dx = container->content_x + items[i]->margin_left - items[i]->x;
151
+ /* Margins keep the item's outer box on the main axis, exactly like block
152
+ * flow — a negative `margin-top` pulls the item up instead of being
153
+ * silently dropped. */
154
+ float dy = cursor + items[i]->margin_top - items[i]->y;
147
155
  translate(items[i], dx, dy);
148
156
  cursor += mainSizes[i] + between;
149
157
  }