Skip to content

Platform Adaptation Notes ​

Field-tested pitfalls and conclusions per platform; each entry records only the pitfall and the fix. Protocol-interop conclusions all come from real buses and real panels. Linux is organized by distribution → desktop environment, Windows / macOS by version; add new conclusions to the matching section and sync the Chinese version (docs/zh/adaptation.md) in the same batch.

Icon data and generator (803 icons, 2026-09-29) ​

Shape comparison for the icon data (803 iconfont icons, debug build, 2026-09-29): switching from the "code shape" (each icon expanded into match branches of Painter calls) to the "data shape" (per-mille fixed-point path strings interpreted and drawn by fill_icon_path, emitted by scripts/gen_icons.py) — icons.mbt 51k lines / 3.5 MB → 4.3k lines / 0.7 MB; showcase.exe 23.7 MB → 14.9 MB (hello likewise 7.9 → 7.5 MB); library-module incremental compile 6.8s → 0.78s; showcase window map 41ms → 7~9ms. Drawing correctness was verified with full probe-icon grid screenshots compared against the old shape (lines / fills / cut-outs consistent). Visible icons per frame are at most in the hundreds, each costing a few hundred characters of parsing — microseconds, no caching needed. Lesson: a generator emitting "code" versus "data" differs by an order of magnitude — for heavily repeated geometry, always choose the data shape plus a small interpreter.

Cross-platform (build chain / FFI) ​

Build and Linking ​

  • Library packages must not carry a link section (moon would generate a main-less exe and the build fails); all link flags come from scripts/prebuild.py via the --moonbit-unstable-prebuild hook, which emits link_configs auto-propagated to every main package depending on the library module. Never hand-write cc-link-flags in any package.
  • prebuild script constraints: stdout must carry only the final JSON (progress goes to stderr); all flags go in the single link_flags string with hand-controlled ordering (GNU ld is single-pass); the script's cwd is the consumer's project root, so library paths must be absolute.
  • Recent moon deprecates moon.mod.json / moon.pkg.json (moon fmt migrates in one shot); moon doc only accepts the new format, and older moon does not understand it.
  • MSVC misreads BOM-less UTF-8 source as cp936; Chinese comments produce fake preprocessing errors (the reported line has no such directive): CMake adds /utf-8 for MSVC.
  • Recent moon deprecates implicit trait-method promotion: call sites use the explicit static form ViewLike::method(obj); impl Trait for X declaration sites need explicit pub extend X with Trait::{...} (generate the method list from moon check --no-render output, don't hand-copy); black-box tests must qualify in-package symbols as @<package>.xxx. These warnings are missed by incremental builds — a clean full build surfaces them all.
  • The mooncakes package-page README renderer only displays images with absolute external URLs (field-tested across the 0.5.3–0.5.5 releases: package-relative image paths render neither as plain Markdown nor as HTML — HTML blocks are dropped entirely — while an external CI badge displays fine); switch hero images to jsdelivr absolute URLs (cdn.jsdelivr.net/gh/<user>/<repo>@master/..., balanced reachability inside and outside China; @master references lag a few hours behind due to CDN caching). GitHub-side rendering via the camo proxy is unaffected.
  • The toolchain triple (moon / moonc / core) must ship as one matched set (field-tested during the 0.5.3 release): moon upgrade aborted midway in a non-interactive terminal (IO error: not a terminal) after swapping the binaries but before touching ~/.moon/lib/core, leaving moonc 0.10.14 paired with core 0.10.12 — the old core lacks StringView::exact_view (four "no method" errors in sysdata), and the mismatch only surfaced when a version bump in moon.mod forced a full re-check; earlier incremental moon check/moon test greens were false. Nightly version numbers are delisted the same day (the CLI server 403s any named historical version), so pinning via MOONBIT_INSTALL_VERSION is not viable — the only repair is rerunning install/unix.sh with no version to install the latest full bundle (binaries and core replaced in one pass), matching CI. Verify by checking that moon version --all and ~/.moon/lib/core/moon.mod agree, then bumping the version to force a full re-check: 133 tests green.
  • What the mooncakes builder really does with an old-manifest published dependency (field-tested on the 0.1.0 release, 2026-10-15): after native-gui@0.1.0 was published, its package page showed Build failed / Documentation failed for this version (no docs generated, no API reference; the package itself stays installable). The build log (public API: https://mooncakes.io/api/v0/builds/log?module=<owner>/<name>&version=<ver>) shows all four targets — native / wasm-gc / wasm / js — failing with one shared cause: in the published archive of NoahLiu/yoga-mbt@0.1.0 the package manifest is still moon.pkg.json (moon.pkg.json ... is no longer supported. Run moon fmt to migrate), so the builder dies while discovering packages (Failed to calculate build plan / Failed when discovering packages) before ever reaching this module's own code. This is the same pitfall the CI "migrate the dependency cache in place" loop works around — CI migrates the local .mooncakes download cache, but a published package in the registry cannot be migrated, so consumers and the builder are bound to hit it. The fix is to republish that dependency module (its moon.pkg is already in this repo) and only then publish the modules depending on it — the upstream of the publish chain must be fixed first. How to verify: after republishing yoga-mbt, republish native-gui and watch the package page's build_status; the diagnostic entry point is the log API above (the same endpoint behind the signed-in page's button).
  • Pre-publish rehearsal and the republish drill (agreed 2026-10-15): run moon publish from each module root (optionally with --dry-run first); before pushing, bake a local moon package preview archive (<module>/_build/publish/<Owner>-<name>-<version>.zip) and unpack it to confirm zero old .json manifests and that moon.mod / moon.pkg are in place. In a multi-module release chain, publish upstream first — a downstream build reads the upstream archive, so it can only turn green after the upstream has been republished. A published version cannot be rewritten in the registry; the fix is always a version bump (0.1.0 → 0.1.1 here, with every in-repo pin bumped in the same batch — moon.work matches local members by name + version, so a missed pin breaks even local resolution). Archive-level evidence: NoahLiu/yoga-mbt@0.1.0's published zip has the new-format top-level moon.mod but still ships the old src/moon.pkg.json (published before the in-repo migration; the registry copy is immutable), and NoahLiu/native-gui@0.1.0's zip is a repo-root-era snapshot (old repo URL, old description). Temp-directory staging is not needed: moon publish packs just that module from its own root (dependencies are not vendored), and moon.work only affects local resolution, not the archive — an extra copy step only adds two risks (missed files; a publish source that is not the git worktree).
  • Taking in mizchi/markdown@0.8.3 and the transitive-dependency publish chain, settled (MD1, 2026-10): the root moon.mod pins mizchi/markdown@0.8.3; the standalone console probe examples/probe-markdown verifies the parse / render_html / serialize calls (moon run examples/probe-markdown --target native prints PROBE-OK: parse yields 11 blocks including one heading / fenced code / table / blockquote / list each; render_html produces h1/strong/em/code/a/ul/li/pre/blockquote/table with link hrefs preserved; serialize round-trips through re-parse and re-render), with moon check zero warnings and moon test 576 all green. Native support is documented: the registry index shows 0.8.0 as js+wasm only, native arrives in 0.8.1, wasm-gc in 0.8.2, so pinning 0.8.3 (supported-targets = js+wasm+wasm-gc+native) is the first safe point in the support matrix; its src/moon.pkg imports the experimental moonbitlang/core/v128 (an in-package comment says it is only reachable on linear-memory targets) — native is linear-memory, and the probe compiling through is the proof, no extra switches needed. The dependency closure is nominally 5, actually 21 packages: direct deps moonbitlang/async@0.20.3 / moonbitlang/parser@0.3.18 / moonbitlang/x@0.5.1 / mizchi/syntree@0.2.4 / mizchi/moomaid@0.4.0; expanding each package's moon.mod one level down installs 21 external packages into .mooncakes (parser→lexer/moon_config/prettyprinter; moomaid→tui/signals/svg/font/css/crater-layout/crater-core and their sub-deps tui-terminal-buffer/brotli/zlib/pixelmatch/layout). x and syntree carry co-existing upstream multi-version declarations (markdown wants x@0.5.1, parser wants 0.4.39, moomaid wants 0.4.47; syntree 0.2.4/0.2.3); mooncakes' single-version space installs the highest, so as upstream drifts moon picks the maximum for the closure — upgrading markdown must watch for cascades. moon add transitivity measured: a consumer's moon add mizchi/markdown@0.8.3 writes only the direct-dependency line; the transitive closure never enters moon.mod and is resolved and fetched recursively from the registry index at install time (a scratch module printed e.g. "Using cached mizchi/syntree") — after this repo publishes, a consumer adding this module gets the full closure pulled automatically by the builder; the dependency-surface cost is purely install/compile time. Settled: accept the transitive dependencies, do not split into a standalone module (from MD2 the markdown_view bridge to mizchi/markdown travels with the main module). Rationale: the closure is all pure MoonBit source packages — no native stubs, no link surface, untouched by prebuild management, zero ABI / platform-matrix / payload-size risk for consumers — a different nature from ffmpeg-mbt, which was split out for its binary matrix + licensing + size. A downgrade path stays open: if the consumer dependency surface gets too heavy or upstream 0.x APIs churn, sink it into its own module then (the library module reverts to zero markdown deps, mirroring the media three-layer split). Note: an in-module sub-package (the old chain's browser sub-package pattern) cannot solve the dependency surface — mooncakes deps are module-level declarations, so a sub-package's imports enter the publish closure all the same; the browser pattern isolates the link-flags layer, a different mechanism — do not conflate the two.

CI runner environment vs. the development machine (2026-10-15, first time the Linux and Windows jobs reached the test step) ​

  • Environment: GitHub Actions ubuntu-latest / windows-latest. The development machine is Ubuntu 24.04 + X11 + XFCE: session bus present, notification service present, display server present. None of the three is guaranteed on a runner, so tests that were green locally failed on CI — each for its own reason, not one shared cause.
  • Dependency-graph resolution (CI was stuck here for a long stretch, two stacked causes, unrelated to this repo's code): out of the last 100 master runs (back to 2026-10-13 15:29) only this one succeeded; every other run died in the very first step, moon check, before any compilation started, with Failed to resolve registry dependency ... module was not found in the registry. Layer one: a freshly installed runner's registry index has no prowk/moonsqlitefile or mizchi/markdown, and moon prints the password itself — you may need to run moon update; both jobs now open with a moon update step. Layer two only shows once the index is refreshed: since 2026-10-12 the toolchain only accepts moon.mod/moon.pkg, while the published third-party packages still ship moon.mod.json/moon.pkg.json (... is no longer supported). There is no way back on the registry side, so the job migrates the old manifests in place once the dependency cache has landed (running moon fmt in every .mooncakes/**/moon.{mod,pkg}.json directory) — touching only the git-ignored download cache, never the repo sources, never the dependency versions. Each round only resolves the batch of packages that has just been downloaded, so the loop runs until it converges (both jobs passed on round 9 this time). The workspace member modules/yoga-mbt/src/moon.pkg.json was migrated to moon.pkg in the same batch (warn_list unchanged at -6) — the new toolchain rejects its old manifest too.
  • Notifications (a real contract bug, fixed): on the runner the session bus answers but no notification service is installed, so @dbus.notify_caps() returns NfFailed(ServiceUnknown) while notify_supported() says there is no backend. The old core/notify_caps() forwarded the dbus error directly, which made the two probes disagree. Now notify_caps() decides "is there a backend" through notify_backend() first, gives Err(Unsupported) when there is none, and only asks GetCapabilities once the D-Bus backend is confirmed. One-line contract: when notify_supported() is false, notify_caps() must give Unsupported. The dbus/notify_wbtest capability case prints a skip for "bus present, service absent", the same treatment as NfUnsupported, instead of calling it a code failure.
  • Declarative mount (the assertion encoded a dev-machine accident, fixed): mount_window_render(render, ...) is mount_window(render(), ...), so render runs exactly once, regardless of whether the window mounts. The test also demanded "zero runs when no app comes back", which only holds on a machine where window creation succeeds; on a headless machine creation fails but that one run has already happened, so CI went red. Now asserted as "exactly once".
  • The text/layout family needs real fonts - and the runner's problem was "different fonts", not "no fonts": the Linux job's diagnostic step (fc-list count, fc-match, a small C probe measuring "AAAA" in "Sans 14") reports 53 fonts, default DejaVu Sans, 52x23, while the development machine gives 48x23 (Noto Sans CJK). fonts-dejavu-core joined the apt list so at least one family exists, and every assertion frozen to the dev machine's metrics became font-independent:
    1. text/range ("the coloured range lands on the first glyph") used to demand "the red block ends before the black ink starts" - adjacent glyph ink touching breaks it; it now compares "red block narrower than the black block" (the left-edge check stays);
    2. ui/form's link hover underline used to sample "the bottom row, one cell" - which row that is depends on the font's descent; it now compares total ink (that is what "was an underline drawn" means);
    3. ui/textarea's up/down keys used lines ab and cd, so the remembered pixel x mapped differently per font (on DejaVu the second line is narrower and up lands mid-line); both lines are now ab - the test checks the rule, not one font's width relation;
    4. mdview's marker/content baseline tolerance went from ±2px to ±6px, with the reason written down: what that check must catch is "the marker cell never registered a measurement and got centred mid-row", a 10px-class error, not per-font 1px alignment;
    5. ui/basic's "an underline must add ink" now compares ink pixels instead of ink rows (a 1px line landing inside the last row's leading leaves the row count flat) - and it exposed a real bug: in ui/base.mbt, draw_underline computed y = top + baseline + 1.5 and then hit if y >= r.h { return }, so when the backend's line box only reaches the baseline the whole line was dropped - "the style did nothing", which is worse than drawing it one pixel low. The line must stay inside the box (skip only when r.h <= 0, otherwise clamp with min(r.h-1, max(0, y)) against the bottom edge), and the assertion now prints plain/under ink, both row counts and measure_font's w/h/baseline.
  • /tmp is not a universal writable path: render/image_wbtest's "write PNG, read it back" went red on Windows - /tmp/... resolves to C:\tmp (absent) there, so write_to_file returns false. The test now writes to a relative path (the moon test process has the module root gui/ as cwd), the artifact is in .gitignore, and the failure prints the path (this one assertion has been broken by two different causes - missing directory, and an encoder that does not know the format - and the failing line alone cannot tell them apart).
  • Windows manifest-migration loop: PowerShell has no Select-String -QuietPattern parameter (the form is -Pattern … -Quiet). With the wrong name the loop dies on parameter binding in the first round, reporting a PowerShell error, which looks like "migration did not converge" while not one line ran.
  • The Linux workspace resolution needs the ffmpeg dev packages: the Linux apt list gained libavformat-dev and four other -dev packages - not because the main path uses them, but because moon test/moon check inside the workspace also runs modules/ffmpeg-mbt's own prebuild, which exits non-zero when pkg-config cannot find libav* (the hint it prints is exactly that apt command).
  • Verification: CI run 38676488227 (2026-10-15) is green on both jobs - Linux 912/912 (13 packages) plus build plus the xvfb smoke, Windows 666/666 (11 packages; dbus/sys excluded as platform gaps) plus ffmpeg-mbt 24/24, gui-media 6/6 and the hello link; the font-diagnostic readings are as above; manifest migration converged on round 9 on both. The local control experiment env -u DISPLAY moon test core ui dbus sys reproduces the runner's "no display service" shape.

CI runner environment vs. the development machine (2026-10-15, first time the three jobs reached the test step) ​

  • Environment: GitHub Actions ubuntu-latest / windows-latest / macos-latest. The development machine is Ubuntu 24.04 + X11 + XFCE: session bus present, notification service present, display server present. None of that is guaranteed on a runner, so tests that were green locally failed on CI - each for its own reason, not one shared cause.
  • Notifications (a real contract bug, fixed): on the runner the session bus answers but no notification service is installed, so @dbus.notify_caps() returns NfFailed(ServiceUnknown) while notify_supported() says there is no backend. The old core/notify_caps() forwarded the dbus error directly, which made the two probes disagree. Now notify_caps() decides "is there a backend" through notify_backend() first and only asks GetCapabilities once the D-Bus backend is confirmed. One-line contract: when notify_supported() is false, notify_caps() must give Unsupported.
  • Declarative mount (the assertion encoded a dev-machine accident, fixed): mount_window_render(render, ...) is mount_window(render(), ...), so render runs exactly once, regardless of whether the window mounts. The test also demanded "zero runs when no app comes back", which only holds on a machine where mounting succeeds. Now asserted as "exactly once".
  • The text/layout family needs real fonts - and the runner's problem was "different fonts", not "no fonts": the Linux diagnostic step (fc-list count, fc-match, a small C probe measuring "AAAA" in "Sans 14") reported 53 fonts and 52x23, while the development machine gives 48x23 (Noto Sans CJK vs the runner's DejaVu). So every red in that family was an assertion frozen to the dev machine's metrics. Four of them became font-independent:
    1. text/range ("the coloured range lands on the first glyph") used to demand "the red block ends before the black ink starts" - adjacent glyph ink touching breaks it; it now compares "red block narrower than the black block" (the left-edge check stays);
    2. ui/form's link hover underline used to sample "the bottom row, one pixel" - which row that is depends on the font's descent; it now compares total ink (that is what "was an underline drawn" means);
    3. ui/textarea's up/down keys used lines ab and cd, so the remembered pixel x mapped differently per font (on DejaVu the second line is narrower and up lands mid-line); both lines are now ab - the test checks the rule, not one font's width relation;
    4. mdview's marker/content baseline tolerance went from ±2px to ±6px, with the reason written down: what that check must catch is "the marker cell never registered a measurement and got centred mid-row" (a measured 10px-scale fault, rect=(0,111,18,0)), not per-font 1px alignment.
  • /tmp is not a universal writable path: render/image_wbtest's "write PNG, read it back" went red on Windows - /tmp/... resolves to C:\tmp (absent) there, so write_to_file returns false. The test now writes to a relative path (the moon test process has the module root gui/ as cwd), the artifact is in .gitignore, and the failure prints the path (this one assertion has been broken by two different causes across platforms - missing directory, and an encoder that does not know the format - and the failing line alone cannot tell them apart).
  • Windows ran moon test for the first time: 661 of 662 passed; the single failure was that writable-path issue above. All three platforms are now usable as a compile-and-test gate.
  • Verification: the local control experiment env -u DISPLAY moon test core ui dbus sys (551/551) reproduces the runner's "no display service" shape; the font conclusions come from the CI diagnostic readings compared against 48x23 locally.
  • Windows manifest-migration loop: PowerShell has no Select-String -QuietPattern parameter (the form is -Pattern … -Quiet). With the wrong name the loop dies on parameter binding in the first round, which looks like "migration did not converge" while nothing actually ran.

MoonBit cfg(platform=) ​

  • Decision (2026-10-11): platform dispatch drops cfg for good, even if the toolchain later fixes it — MoonBit-side cross-platform selection is always "runtime probe + pattern matching", probed in the shape of render's existing degradation probe (paint a stroke and verify pixels, non-zero session handle); now a hard rule in AGENTS.md.

Native render/text matrix and the three-layer text divergence (new-stack decision, 2026-10-11) ​

  • Platform matrix: Linux = GTK3 window + cairo raster + Pango text (system libraries, dynamic linking, no LGPL obligations); Windows = pure-MoonBit software rasterizer + DirectWrite text + WIC image decoding (all OS components, zero third-party dependencies); macOS = CoreGraphics + CoreText (stub placeholder, backend not yet developed). The sys capability family now has real implementations on both platforms (platform batches ①-⑥, 2026-10-14): wallpaper/idle/launcher/desktop/powerprofile/sysinfo/windowctl/appfind/recent_files/volume/printer/print/browser/vscode — 17 items in total, each with real-hardware readings; sys 188/188; see the platform status table in system-capabilities.md. vcpkg static linking of cairo/pango was evaluated and dropped: the dependency weight (glib/fontconfig/harfbuzz and friends) plus the LGPL static-linking "must provide a relinking path" obligation contradict this library's "native, lightweight, no-strings" positioning.
  • The three-layer text divergence and countermeasures: ① Height — line height is unified as max(engine natural line height, ceil(size×K)), with K a constant measured across the three platforms against the fattest font (Microsoft YaHei); line height becomes a pure function of size, fallback fonts no longer affect it, the surplus is centered with half-leading and the rounding rule is fixed; fonts whose natural height exceeds the floor grow the line in place (graceful degradation, no glyph clipping). ② Width — wrapping/truncation/ellipsis are entirely MoonBit component-layer decisions (the platform only supplies measurement); textarea's wrap_units/layout_lines pure functions stay; CJK advance≈em shows no real difference, Latin differs by one word (wrap/ellipsis boundary points), no content loss. ③ Pixels — the vector layer (rects/paths/AA/25 blend modes/gradients) is the MoonBit rasterizer's single implementation on all three platforms, bit-for-bit identical, assertions shared cross-platform; text metrics/pixels are inherently per-platform (the old stack already had three engines: Pango/DirectWrite/CoreText), so assertions split into "structural (shared) / numeric (per-platform baseline directory)" tiers, and the default font is encoded per platform into the theme (Windows pins the YaHei family, Linux follows fontconfig).
  • Linux dividend: cairo and the MoonBit rasterizer can be pixel-diffed against each other — cairo serves as the reference answer for accepting the self-built engine (the acceptance method of rasterizer batch W4).
  • Platform C stub organization: a single multi-branch stub.c + prebuild macros (see the win/stub.c precedent in the "Windows window foundation W2" section); the render/text stub merges follow the same pattern, with the Windows side pulling in no C render/text libraries (only WIC counts as an OS component).

MoonBit native GUI stack · pure-MoonBit rasterizer (W4, Windows/mac render backend) ​

  • Architecture: render gains four pure-MoonBit files — raster_path.mbt (path commands / affine matrix / adaptive Bézier flattening / arc→Bézier with kappa), raster_fill.mbt (4×4 supersampled nonzero-winding scanline fill + polyline-offset stroking with miter limit 10 / butt caps), raster_blend.mbt (25 blend modes: nine Porter-Duff closed forms in premultiplied space + 16 W3C formulas in non-premultiplied space + gradient evaluation), and raster.mbt (the Raster session, method-for-method mirroring Cairo); the Painter facade becomes a two-backend enum with runtime probing (Cairo::over failing to open a session ⇒ pure path), no compile directives (AGENTS rule 11). The stubs merge into render/stub.c: the real cairo implementation hangs off the WM_STUB_CAIRO macro compiled only by prebuild (Linux), non-Linux keeps placeholders to satisfy the linker, and the probe falls to the pure path when it sees the placeholder. The text layer probes via Painter::cairo_id(): with cairo it paints into the session context; the pure backend returns None (W5's DirectWrite shape paints straight into the Bitmap), and Painter::raster() fetches the pure session.
  • Coverage quantization: 4×4 supersampling, 16 samples per pixel, coverage 0..256 (16 per sample) — the per-pixel difference from cairo's analytic coverage is absorbed by the "parity tolerance band ≤16 (one sample step)"; full-coverage and zero-coverage pixels are exact. Crossing tests use the half-open (ay<=ys)!=(by2<=ys) to avoid double-counting vertices; the nonzero sweep collects spans where winding ≠ 0, and open polylines auto-close for fill (the closing edge IS the wrap edge, so the edge count is n, not n+1 — writing n+1 indexes pts[n] out of bounds; unit tests with all-closed paths never see it, the real window crashed within 45 frames. Lesson: open paths need dedicated cases).
  • Premultiply rounding must be two steps, never merged: source premultiply lives in the 255 domain ((v·a+127)/255), coverage scaling in the 256 domain (·cov/256); merging into one 65536-domain step swallows the 255/256 ratio (opaque full coverage measured 254 instead of 255, off-by-one across the board). The Normal composite s+(d·inv+127)/255 matches Bitmap::paint, so rect paths stay bit-for-bit identical.
  • Known divergences from cairo (inside the tolerance band): ① 16-level quantized AA coverage vs cairo's analytic coverage; ② nearest-neighbor blit vs cairo bilinear (1:1 and opaque pixels are bit-exact; scaled sampling differs — the sample-domain clamping semantic is mirrored: sampling never leaves the source sub-rect, so colors do not bleed); ③ gradient stops interpolated non-premultiplied (CSS/W3C) vs cairo's premultiplied space (semi-transparent stop midpoints differ, opaque stops identical); ④ stroke device width = user width × sqrt(|det|), an approximation for non-uniform scaling; ⑤ skewed-clip degrades to the bounding box. The 25 W3C blend values pass the same exact assertions on both platforms (cairo's operators and the hand formulas agree in the 8-bit domain).
  • MoonBit pitfalls hit this batch: sin/cos live in moonbitlang/core/math (@math.sin; Double has no such methods); uppercase let is illegal — use const; scientific notation 1e-9 fails to parse, spell it 0.000000001 (repeated an old pitfall; suggest grepping 1e-); trailing commas on match arms are illegal, including after tuple arms (a,b,c). Test-side: tests that used Cairo::over directly became "backend probe + structural assertions" shared cross-platform; decode-dependent tests gained a decode_available() probe guard (missing environment skips instead of hard-failing).
  • Verification (2026-10-11, Windows machine): render 43/43 (including the new "path/stroke/arc two-backend parity (AA tolerance)" test), icons 4/4 (all 803 icon paths produce pixels through the pure rasterizer), core 62 / input 10 / mdview 26 still green, theme 7/8 (the remaining font-measurement case belongs to W5); native-window smoke 45 frames with clean exit (every real-window frame goes through the pure rasterizer); whole-repo check 287/68 unchanged. The Linux cairo path is untouched bit-for-bit (stub content moved verbatim); on Linux the parity test IS the "cairo vs pure" acceptance — pending a user-side rerun.

MoonBit native GUI stack · Windows branches of the system-capability stub (W14: files / env / subprocess / file dialog) ​

  • The third-party chensuiyi/subproc had to go — and master had already dropped it: the package describes itself as "Synchronous Linux process management" and relies on Linux-only machinery (pidfd, procfs, poll). Locally, .mooncakes/chensuiyi/subproc/src/native.c:7 dies under MSVC with fatal error C1083: cannot open include file 'poll.h', which blocked the whole sys chain (and downstream chart/ui/examples/native-app) from even compiling on Windows. The other registry candidate, trkbt10/subprocess, covers subprocesses on Unix/macOS only (Windows side is IPC sockets), so it does not fit either. Fix: port master's already-proven three-platform implementation (master's implementation calls its own C stubs proc_spawn/proc_wait/proc_getpid, zero third-party deps) into sys/sys_stub.c as C, symbols yue_sys_proc_*, semantics aligned bit-for-bit: POSIX branch shared by Linux/macOS (fork → setsid/setpgid on macOS → dup2 temp files → execvpe on Linux / execvp + *_NSGetEnviron on macOS, _exit(127) on exec failure, _exit(126) on chdir failure); Windows uses CreateProcessW + CREATE_NO_WINDOW, STARTF_USESTDHANDLES with inheritable handles, environment block sorted by name and terminated with a double wide NUL, empty block = inherit parent environment. Key design difference: only the pid is returned, never a handle — Windows wait reopens it via OpenProcess(SYNCHRONIZE|TERMINATE|QUERY_LIMITED) and closes the handle immediately, which removes an entire cross-call handle table and its leak surface (the original plan of "return hProcess + proc_drop" was scrapped).
  • __uuidof does not exist in MSVC's C mode: in CoCreateInstance(&CLSID_FileOpenDialog, ...), writing __uuidof(FileOpenDialog) fails with error C4233: nonstandard extension used: keyword only supported in C++ (the coclass declarations in the SDK headers are C++-only). In C, take the extern const GUID constants from the headers (CLSID_FileOpenDialog / IID_IFileDialog / IID_IShellItem), which come from uuid.lib — hence scripts/prebuild.py's Windows branch now issues ole32.lib shell32.lib uuid.lib for sys (MSVC takes them as positional arguments; -l triggers D9002).
  • UCRT has no _getwcwd: the name looks like the wide getcwd but is declared nowhere in the SDK — MSVC only emits warning C4013: undefined, default external return int, truncating the returned wchar_t* to int and then dereferencing it: compiles, crashes. Use the Win32 GetCurrentDirectoryW instead (two passes: size 0 to query, then fetch).
  • Environment variables must go wide, and Windows has no "exists but empty" state: getenv/_putenv_s are the ANSI versions and round-trip values through the process ANSI code page (936 here), so a UTF-8 Chinese value comes back as mojibake (鍊糀) — caught live by sys/envx_wbtest.mbt. Switched to GetEnvironmentVariableW/_wputenv_s (query size with 0 first). Related semantic difference: on Windows _wputenv_s(name, L"") is the delete operation, so envx_get(name) reads back an empty-value write as None, not Some(""); the test now asserts per platform. Files follow the same rule: UTF-8 in, wide API out (FindFirstFileW/_wfopen/_wremove) — the A versions misfire on Chinese user-directory names.
  • ShellExecuteW succeeds when the return value is > 32: it returns the legacy HINSTANCE-style status (≤32 are SE_ERR_*), so >32 ? 0 : -1 is the correct judgement. Its open verb carries the system associations (URL → default browser, document → associated app), which is exactly the role xdg-open plays in the POSIX branch.
  • In the placeholder branch, disk I/O that has nothing to do with graphics must still be real: render/stub.c's non-Linux branch had fsw_write_file as return 0 (placeholder to keep linking), while the PNG encoder itself lives in pure MoonBit (png.mbt) and only needs one write — so chart export (ci_save) failed with WriteFailed on Windows every time. Rule of thumb: a placeholder should only cover "the backend capability this platform hasn't implemented yet", not backend-free standard I/O.
  • Three platform splits in the tests: ① in the Windows CRT, /tmp means "tmp under the current drive root", so if that directory does not exist the write fails — temp paths must come from %TEMP%/$TMPDIR per platform (separator too); ② cmd emits \r\n, so assertions on echo output cannot hard-code \n; ③ when tests run from Git Bash, PATH contains /usr/bin/*.exe (echo/cat/sleep/true), so POSIX command names "appear to work" and mask the real differences — from cmd they all fail; hence every round-trip goes through a platform dispatcher (POSIX sh -c, Windows cmd /c). Unix-convention-specific assertions (exit 127 for a missing command, self-inflicted SIGTERM, forced LC_ALL=C, /etc/hostname) run on the POSIX side only; Windows gets the equivalent (CreateProcessW failure surfaces as SpawnFailed) or prints a skip.
  • Without a text layer, self-drawn widgets collapse to size 0: on the pure-rasterizer platform @text.measure returns None and attach_text_measure's fallback is (0.0, 0.0), so width-from-text buttons/labels/links neither measure nor get hit — 20 assertions in ui are of this kind. Handling: add runtime probes at the test layer (ui_text_guard() measures "Ag", ui_decode_guard() decodes one GIF); when the probe fails, print a skip and keep running the rest. They turn green again with W5 (DirectWrite) / W9 (WIC). Same treatment for theme's "control height baseline". No estimated widths were smuggled into the library: that would be silent fake data, contrary to the settled "probe + degrade with an explicit Unsupported" stance.
  • Verification (Windows 10 19045 + MSVC 14.44 + SDK 10.0.26100, moon 0.1.20260920): moon test -p sys 163/163 (first time this package compiles and runs on Windows after dropping subproc), chart 189/189, ui 110/110 (20 probe-skipped), theme 8/8, core 70, render 43, input 10, mdview 26, icons 4 all green; per-package moon check --deny-warn zero warnings; moon check ./examples/native-app --deny-warn passes (previously blocked at the dependency stage); whole-repo red dots stay at 287 errors/68 warnings. text remains red on Windows — that is W5's scope (splitting assertions into "shared structural / per-platform numeric baseline"). The look of the real IFileDialog (filters / multi-select / initial folder) is left for the user to confirm.

MoonBit native GUI stack · batch-A multi-window host on Windows (W15: slot reuse + idempotent wm_init) ​

  • Batch A only fixed the GTK branch; the same defect remained on Windows: d1fb1e8 changed wm_window_new in the GTK branch of win/stub.c to reuse slots by scanning area == NULL, while the _WIN32 branch of the same function still did wm_next_id++ — repeatedly opening/closing secondary windows exhausts the 32 slots and open_window silently returns None. Fixed identically to the GTK branch, with the marker changed to hwnd == NULL (set on a successful create, cleared by the memset in wm_close, so a torn-down slot and a never-used one are equally reusable).
  • What actually blocked multi-window was a non-idempotent wm_init: app_window() calls @win.init_backend() once per window, and Win32's RegisterClassExW returns 0 when the class name is already registered (ERROR_CLASS_ALREADY_EXISTS) — so the first call succeeds and every later one reports "backend unavailable", making App::open_window always return None on Windows (the main window was fine because it is the first call, which is why every single-window smoke passed). Now the whole body returns 1 directly when the waker window already exists, and ERROR_CLASS_ALREADY_EXISTS counts as success. The GTK branch uses gtk_init_check, already idempotent, so this only surfaces on Windows — "the same path called twice" is a shape batch A introduced, invisible to all single-window smokes.
  • Evidence (probe package experiment/multiwin_probe): ① 40 create/destroy rounds, more than the stub's WM_MAX_WINDOWS=32, still succeed (opened=40 failed=0) — the probe creates without showing, so no window flashes on the desktop. ② Two rounds of "open a secondary window → pump 60 ms → close it", printing sub_wid/primary/active_is_sub/count and before/after/main_running, proving dispatch still holds with two real HWNDs: a new window becomes the active one (active_is_sub=true), primary=false, and after closing it the main window keeps running=true with the count back down. ③ A final MULTIWIN-DONE line and exit code 0. Run with moon run experiment/multiwin_probe; headless environments print MULTIWIN SKIP. Visual confirmation (occlusion/redraw between two windows, preedit inside the secondary window) stays with the user per AGENTS rule 6; the examples/native-app start-up smoke on Windows prints NATIVE-APP-MW wins=1 ... and exits 0 (clicking its buttons needs injection, i.e. W7).
  • Side fix of the same class: the _WIN32 branch calls snprintf without including <stdio.h>, and MSVC only warns C4013: implicit declaration, assuming external return int — the same silent shape as the missing _getwcwd. After adding the header the stub compiles warning-free.

MoonBit native GUI stack · three-platform data roots for the history family (W14e, ported from master) ​

  • Ported, not re-invented: the Windows data-root branches for browser history/bookmarks were already in origin/master (bh_browser_defs_win / bh_mac_root / bh_root_win); the original port into this branch only carried the Linux half, which is why bh_supported/bm_supported had degraded to "Linux only". Same approach as W14b: check the other branch first, and if it exists, move it over.
  • Only the data root and directory names differ; the logic is shared: Linux = XDG_CONFIG_HOME else $HOME/.config; Windows = %LOCALAPPDATA%, where Chromium-family profiles sit one level deeper at <vendor>/<product>/User Data (that User Data layer does not exist on Linux; casing follows the installer, e.g. Google/Chrome/User Data, Microsoft/Edge/User Data); macOS = $HOME/Library/Application Support/<vendor>/<product> (no User Data layer). Profile candidates (Default, Profile 1..24), the directory scan looking for History/Bookmarks, and the SQLite/JSON parsing are one shared path across the three platforms, so only the definition table bh_defs() and root resolution branch by platform.
  • Evidence (real Windows machine with Chrome/Edge installed): bh: profiles=2 items=5 and bh: downloads=3 are readings from the actual local browser databases — on Windows this path used to be gated off and always skipped — and the bm_bookmarks end-to-end parse likewise flipped from "skip" to real execution; moon test -p sys 163/163, moon check ./gui/sys --deny-warn and ./examples/native-sys --deny-warn warning-free. One stale test assertion (bm_supported() == (platform=="linux")) moved to the three-platform rule.
  • Rule of thumb: fsx_list_dir/sys_read_text_file go through the wide-character routing just added in this batch, so browser stores under Chinese user directories (C:\Users\张三\…) are readable too — a direct consequence of W14a's "paths go UTF-8 in, wide-character APIs out".

MoonBit native GUI stack · DirectWrite text layer (W5, Windows text lands) ​

  • dwrite.h does not compile in C — use a C++ TU: DWRITE_MAKE_OPENTYPE_TAG / DWRITE_MAKE_FONT_FEATURE_TAG expand static_cast into enum initializers, so C mode fails with a train of C2275/C2065 (measured). Two options: hand-declare the COM interfaces (one wrong vtable slot away from a crash — rejected) or use a C++ translation unit. moon's native-stub accepts .cpp (probe-verified: an extern "C" round trip worked), so the platform split became: stub.c Pango branch (prebuild with -DWM_STUB_PANGO) + stub.c placeholder branch (macOS etc.) + dwrite_text.cpp (Windows, compiled by native-stub, dwrite.h directly visible). Link inputs are issued by prebuild for the text package: dwrite.lib d2d1.lib ole32.lib uuid.lib gdi32.lib. On non-Windows platforms that .cpp compiles to an empty TU, so Linux/mac build chains need a C++ front end (the old shim was C++, so this repo's environment has one).
  • The line box comes from a one-glyph layout's line metrics, not from font design metrics: measured, a 14px default-family DWrite layout reports 19 (font ascent+descent+lineGap rounded to whole pixels), while computing from design metrics gives 17; worse, with an empty family name, taking the collection's first family (not the system default) is off by two more pixels — and that is exactly the number the mixed-font "line box must cover the drawn height" assertion eats. Fix: pt_font_line_height builds a "M" layout and reads DWRITE_LINE_METRICS (height/baseline), making the three paths — measure(), line height, and drawing — self-consistent (LH14=19 matches M14.h=19, baseline 15).
  • Family table sorts in byte order: the MoonBit assertion demands strictly increasing lexical_compare (byte order), while DWrite's collection order varies with loading — so the C side qsorts with strcmp (same convention as Pango's g_strcmp0) and dedupes only exact-equal neighbours; names come from locale slot 0 (en-us), hence the Windows family pinned in the theme uses the English name ("Microsoft YaHei").
  • Range indices must be converted to UTF-16 code units: MoonBit (and the Pango branch) speak UTF-8 byte offsets, while DWRITE_TEXT_RANGE wants wide-character indices — unconverted, ranges in multi-byte text fall outside and are silently swallowed (the "byte offsets, cut on bytes" assertion is red for exactly this). Conversion = count the prefix with MultiByteToWideChar.
  • Drawing = D2D render target + 32bpp DIB + premultiplied over: the same IDWriteTextLayout is first drawn into a DIB (Clear to transparent, antialiasing pinned to grayscale — ClearType is subpixel rendering and would fringe on a transparent background), then composited into the MoonBit buffer with out = src + dst·(1−srcA) component-wise (ARGB32 little-endian matches the DIB layout, no channel swapping). Range colours require brushes created on the render target (D2D brushes are device-dependent objects) before SetDrawingEffect, so colours are snapshotted only on the draw pass; box alignment follows the Pango pt_render_box rules: with a width constraint the width and alignment go to the layout, single-line non-ellipsized rows take a hand-computed slack offset, vertical likewise.
  • No cairo session on the pure backend: text goes offscreen + blit: draw_box_at / AttributedText::draw_at with cairo_id()==None render into their own offscreen bitmap via the buffer variants (pt_draw_box / rt_draw), then land it with Raster::blit — translate/clip/blend are all computed by blit.
  • Along the way, a real Raster::blit bug got fixed: it fed the device-space rect from rect_device into mt and then composed the CTM again — the CTM was counted twice, so with a translate in effect the source lookup landed outside the source rect and not a single pixel was written (measured ink=0; identity transform worked, which is why it had stayed hidden). Fix: build mt in user space (e/f from the user-space dx/dy) and keep the device rect only for the scan window. render 43 / ui 110 / chart 189 all green afterwards.
  • Line-height K floor landed: line_height = max(engine natural, ceil(size×K)) with K = 1.36, measured on this machine from Microsoft YaHei at 12..32px (natural ratios 1.29..1.36, fattest sample 14px→19); mixed-font rows take the floor from the largest size in the row; the ascent (baseline) is untouched and the extra space the floor adds is absorbed by vertical centering in the box (i.e. half-leading). The two white-box assertions that encoded "natural only" (family_wbtest) were updated to the new rule.
  • Default family probed per platform into the theme: @theme.default_font_family() uses "Microsoft YaHei" when present (process-level cache), otherwise falls back to the engine default (fontconfig on Linux, CoreText on macOS); role_font in ui goes through it.
  • Verification (on Windows): text 29/29 (was 8/29), render 43, ui 110 (the 20 text/decode probe skips flipped to real runs), chart 189, theme 8, mdview 26, core 70 all green; native-app start-up smoke prints all readouts and exits 0; per-package --deny-warn warning-free; whole-repo red dots stay at 287/68. The planned "split the text assertions into structural/numeric tiers" turned out unnecessary: all 29 compare against the platform's own measurements and pass on both platforms (recorded so nobody redoes it).

MoonBit native GUI stack · Windows IME pure channel (W6) ​

  • Protocol surface matches the GTK branch item by item: commit/preedit are UTF-8 byte streams (capacity 255, truncated beyond), preedit_caret is a byte offset too, and reading a commit requires commit_done to clear it; in WM_IME_COMPOSITION, GCS_RESULTSTR fills commit (into the text state) while GCS_COMPSTR fills preedit (self-drawing only, never into the text state).
  • Three platform differences (all measured or interface facts): ① WM_IME_SETCONTEXT must strip ISC_SHOWUICOMPOSITIONWINDOW — this stack draws its own preedit, and without the strip Windows paints a second composition window on top of ours; ② the composition caret (GCS_CURSORPOS) is a character offset while the MoonBit side (like GTK) speaks UTF-8 byte offsets — convert via a WideCharToMultiByte prefix, otherwise the caret lands wrong for every multi-byte composition; ③ legacy IMM32 has no surrounding-text API (that is TSF), so wm_im_set_surrounding is a deliberate no-op — the GTK side feeds committed text so the IM context can infer context, while Windows IMEs take context from their own composition state (documented divergence; no fake interface on the MoonBit layer).
  • Caret and candidate window: wm_im_set_cursor stores the rect and re-feeds it on WM_IME_STARTCOMPOSITION (some IMEs only read the position then); ImmSetCompositionWindow(CFS_POINT) and ImmSetCandidateWindow(CFS_CANDIDATEPOS) both anchor to the caret's bottom edge (y + h), which yields the familiar "candidate window at the end of the composition string" placement. wm_im_reset goes through ImmNotifyIME(NI_COMPOSITIONSTR, CPS_CANCEL) and clears the local preedit buffer — after reset an IME does not necessarily push another composition message, so a stale string would otherwise keep being painted (same lesson as the GTK side).
  • Evidence: the experiment/ime_win_probe probe (create window → report caret rect → reset → read commit/preedit → pump 30 message rounds → clean exit) prints the expected readings (commit=none, empty preedit) with zero crashes; experiment/multiwin_probe and the examples/native-app smoke stay green after the new WndProc branches (exit code 0). The real-Pinyin end-to-end (preedit underline look, candidate window placement, commit landing in the focused box) requires injected keystrokes and belongs to W7's SendInput probe plus user verification on real hardware — this machine already has Microsoft Pinyin installed (zh-Hans-CN, the only IME), so the path is viable.

MoonBit native GUI stack · system clipboard (W16) ​

  • The capability lives in sys (new clipboard.mbt + the _WIN32 branch of sys_stub.c): Windows uses CF_UNICODETEXT (OpenClipboard/GetClipboardData/GlobalLock to read; GlobalAlloc(GMEM_MOVEABLE) + SetClipboardData to write — after a successful set the ownership belongs to the system, so never GlobalFree, the classic Win32 clipboard trap); Linux/mac go through commands (xclip -selection clipboard / pbcopy·pbpaste). Chinese content must take the wide path (win_utf8_wide plus wide→UTF-8); the A-version APIs mangle it through the ANSI code page (same as the W14 environment-variable finding).
  • Escaping hell avoided on the command line: pr_run performs no shell parsing, so the explicit form is sh -c 'printf %s "$1" | xclip -selection clipboard' sh <text> — the text travels as argv slot 4 (the script's $1) instead of being spliced into the script body, so quotes, newlines and Chinese need no escaping.
  • One link library had been missed: the clipboard APIs live in user32.lib, while the sys package's Windows link flags held only ole32.lib shell32.lib uuid.lib — the omission shows up as a string of LNK2019: __imp_SetClipboardData errors, now patched (recorded: on Windows, every newly used API group needs its .lib checked).
  • Wiring goes through a kernel slot, not kernel-to-platform: Editor::set_clipboard(get, set) in input gives the kernel an external clipboard hook — copy/cut also publish, Ctrl+V fetches live (falling back to the built-in clip when unavailable), and the kernel itself still depends on no platform layer (headless-testable). Off by default: the app opts in with ui's use_system_clipboard(true) (one line each in examples/native-app and examples/showcase) — a Ctrl+X in tests must not touch the user's clipboard; when the probe reports unavailable the internal clipboard quietly stays in charge, i.e. degrade with an explicit path (the Linux probe convention is in the "Linux clipboard" entry: GtkClipboard first, xclip only for processes that never ran gtk_init).
  • Tests: a new sys round-trip case (Chinese + newline, set → get) that saves the original content and restores it after asserting (the probe leaves no trace in the user's clipboard); on this Windows machine moon test -p sys is 165/165 (2 new), with ui 110/110 and input 10/10 regression-free. Real cross-application pasting (copy out, paste in Notepad, paste back) is left for user verification.

MoonBit native GUI stack · post-mortem of the Windows "black window + not responding" (W17a, five root causes plus a perf batch) ​

  • Cause 1: wm_wait never dispatches. The Win32 branch waited with MsgWaitForMultipleObjectsEx and never called wm_pump (the GTK branch pumps after its poll) — with nobody dispatching, the WndProc never runs, so input, WM_CLOSE and WM_PAINT are all lost. Check: Windows flags the window as "not responding", a graceful close waits for the suicide timer (measured 15.9s; 0.9s after the fix), and SendKeys appears to do nothing. Fix: pump right after the wait.
  • Cause 2: wm_present only stored a pointer. It recorded the MoonBit pixel pointer and blitted it later from WM_PAINT — but that pointer belongs to the local Bitmap created inside paint_frame, which becomes GC bait the moment the function returns; the blit then reads dangling memory (whole window pure black). Check: a black screenshot while every drawing readout and geometry line looked perfect. Fix: the stub keeps its own frame copy (WmWindow.frame, a buffer grown on demand), freed in wm_close.
  • Cause 3: key-code convention mismatch. The Win32 stub mapped control characters the GDK way (0x01000000|u) while the kernel's KS_* are X11 values (Tab=0xFF09, Return=0xFF0D, Backspace=0xFF08, Escape=0xFF1B, Delete=0xFFFF) — so the host never recognized Tab or Return (measured: Tab inserted into the entry as a printable character, Enter-to-submit dead). Fix: emit X11 keysyms for control characters and plain Unicode code points for printable input (CJK included).
  • Cause 4: release events were dispatched to widgets. The kernel's Editor::handle_key takes only (keysym, mods) — it cannot tell a press from a release, so letting the release through means "one extra character per key lift" (compounding cause 3). Fix: App::route dispatches presses only (the host's own Tab/focus-stack handling likewise looks only at presses).
  • Cause 5 (caught by this batch's own regression sweep): the new viewport culling mapped the absolute widget rect through the CTM, double-counting the parent origin — every subtree with oy != 0 was wrongly culled (check: ui dropped to 102/110; the widget-scene probe showed "0 ink in the lower half"). Fix: intersect using the local rect relative to the current origin (rect - (ox,oy)) — the CTM's origin sits at the parent's content origin.
  • Cause 6 (a latent out-of-bounds only exposed once input truly worked): the "right-click prefers an ancestor" branch of dispatch_on_path was written to increment before indexing — after walking the last path item i equals the length, and path[i] is out of bounds; on real hardware this surfaced as "right-clicking any widget panics with PanicError". Linux never saw it because in those cases the right-click was consumed at a deeper level and handled left the loop early. Fix: ancestor-priority now indexes first and increments at the end of the loop body; regression case added in widget_wbtest — "right-click ancestor priority: walking the full path stays in bounds, order root→leaf" (including paths with only the root level). Check: inject two left and two right clicks on real hardware — the process survives, clicks=1 lands, and no Panic appears in the log.
  • Cause 7 (Chinese IME could not compose): the message pump lacked TranslateMessage. The Win32 wm_pump did only PeekMessage + DispatchMessage — but legacy IME (IMM32) hooks its composition processing onto the TranslateMessage path: without it you get bare keys only (letter key-downs are consumed by the IME, leaving just key-ups) and never see a single WM_IME_STARTCOMPOSITION/WM_IME_COMPOSITION (measured: only WM_IME_SETCONTEXT arrived), so Pinyin can never compose. After adding it the probe immediately reports preedit=[ni'h'ao …] and commit=[你还奥]. Check: run experiment/key_probe and watch the preedit/commit lines while typing.
  • Cause 8 (Markdown ordered-list numbers sank below the baseline): the marker cell was a zero-height node. md_marker set only a width, has no children and registers no measure — so its height is 0; with hbox's default align_items=Center a zero-height cell gets placed at the row's midpoint (measured rect=(0,111,18,0) while the body text sits at 102..120), pushing the number half a line down with its centre exactly on the text baseline. Fix: register a text measure on the marker cell (line box from the same source as the body) and top-align it to the first line (valign=Start); regression test "ordered list: number and text share the baseline" in markdown_wbtest.
  • Performance (same batch): the text path went 9.4ms → 0.35ms per segment via three changes: ① the D2D render target moved from DC to a WIC bitmap (a DC target costs 0.4ms just to BindDC per call, 1.8ms all-in per segment); ② process-level caches for canvas and brushes, grow-only; ③ a Raster::blit fast path for "integer translate + 1:1 whole-bitmap + Normal", with source bytes passed through a 256×256 lookup table that reproduces the generic path's unpremultiply-then-premultiply — bit-for-bit identical, so the pixel assertions stayed green. Whole-tree repaint fell 550ms → ~300ms (viewport culling), and the example's tick went 100ms → 500ms so each repaint cycle's time blocking the message pump falls back inside the safe window. Full-window repaints are still in the hundreds of milliseconds (the charts page is heaviest) — queued as a follow-up perf batch.
  • Two WIC canvas traps: ① do not wrap an external buffer with CreateBitmapFromMemory and let D2D draw into it — WIC caches internally and your buffer never sees the pixels (symptom: ink=0 while the call reports success); use a self-owned bitmap and Lock(WICBitmapLockRead) to read back after drawing. ② Clearing the canvas with a fully transparent pen via FillRectangle is a no-op under source coverage semantics; you must Clear — which honours the axis-aligned clip, so only the current region gets cleared even though the cached canvas is larger.
  • How it was proven: experiment/key_probe (create window → pump → print key codes, driven by PowerShell AppActivate + SendKeys) for the keyboard path; experiment/text_bench as the text-path perf guard; screenshots to check window content (PowerShell screen capture to PNG on this host, judged by eye); temporary PAINT-PROF/STEP-PROF instrumentation inside App::step/paint_frame located the bottleneck and was removed afterwards. Full regression: core 70 / input 10 / ui 110 / render 43 / text 29 / chart 189 / theme 8 / mdview 26 / sys 165, whole-repo red dots unchanged at 287/68.

MoonBit native GUI stack · Windows base capabilities, first batch (W20a menu bar / W20b global hotkey / W20d message box) ​

  • Environment and the toolchain-free verification path: Windows 10 19045 + VS BuildTools 2022 (MSVC 14.44.35207, under C:\Program Files (x86)\Microsoft Visual Studio\...; C:\Program Files\... is an empty directory that wastes the search) + SDK 10.0.26100. vcvars64.bat is blocked by a reg.exe policy, so compiling a pure-C probe by hand means setting INCLUDE/LIB yourself, with Windows-style paths (/c/... gets treated as a source file):
    bash
    MSVC='C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools\VC\Tools\MSVC\14.44.35207'
    SDK='C:\Program Files (x86)\Windows Kits\10'
    export INCLUDE="$MSVC\include;$SDK\Include\10.0.26100.0\ucrt;$SDK\Include\10.0.26100.0\shared;$SDK\Include\10.0.26100.0\um;$SDK\Include\10.0.26100.0\winrt"
    export LIB="$MSVC\lib\x64;$SDK\Lib\10.0.26100.0\ucrt\x64;$SDK\Lib\10.0.26100.0\um\x64"
    MSYS_NO_PATHCONV=1 MSYS2_ARG_CONV_EXCL='*' "$MSVC/bin/Hostx64/x64/cl.exe" \
      /nologo /W4 /utf-8 /O2 /DWIN32 /D_WINDOWS /DUNICODE /D_UNICODE <probe>.c \
      /Fe:<probe>.exe user32.lib gdi32.lib imm32.lib advapi32.lib
    The pure-C probe experiment/menu_probe/menu_backend_probe.c #includes ../../gui/win/stub.c directly — wm_menu_bar_new returns a handle-table index (1..31), not a real HMENU, so reading the menu structure back requires the static wm_menus[] table in the same TU (the first version passed the index as an HMENU to GetMenuItemCount and got -1 everywhere).
  • A pure-C probe that includes the stub must define -DUNICODE/-D_UNICODE explicitly: without it IDC_ARROW etc. are MAKEINTRESOURCEA forms (LPSTR) and the ...W parameters emit C4133 (stub.c:388-396, 646); with it there are no new warnings (the C4996 for wcsncpy/wcsncat inside stub.c is pre-existing). moon's native-stub already defines UNICODE (recorded in the W2 section), so the production chain never sees those two C4133s.
  • AppendMenuW caps a single menu at ≈1168 items (measured here; the bulk loop starts returning 0 at item 1169), well below the 2048-entry command-id pool. Two rules follow: ① a command id must only advance the cursor after the item is actually attached — the original code did cmd = next++ before AppendMenuW, so a failed add still burned an id; repeated failures (menu over the cap) exhaust the quota early and then collide with ids still in use by other menus (WM_COMMAND carries only the id, so a collision dispatches the click to the wrong item). Fixed to cmd = next, with next = cmd + 1 only after AppendMenuW succeeds (wrapping to BASE at the pool end). ② WM_MENU_CMD_MAX=2048 must stay deliberately larger than the per-menu cap: pool ≥ per-menu cap is what guarantees "filling one menu never wraps", so a single menu never collides with itself; tightening the pool to the measured cap would make a full menu hit the wrap. The probe's wrap case was rewritten accordingly — fill 2048 ids across menus, then add an item to a fresh menu that still has room and check it gets the wrapped-back 1000 (one menu can never drain the pool; the first assertion was unreachable).
  • \t inside the label is the accelerator hint: AppendMenuW right-aligns everything after \t (verified by reading it back with GetMenuItemInfoW, which also checks the Chinese UTF-8→UTF-16 round trip and the \tCtrl+Alt+Q hint string). A real accelerator table (TranslateAccelerator) is not wired yet; keyboard navigation once a menu is open / shortcuts currently rely on W20b's global hotkeys.
  • The message box (W20d) is the stack's second FFI shape — "blocking": MessageBoxW shows and modally blocks in one call until the user clicks a button, returning the button number — no callback, no window slot; the uType four-field bit combination (icon + buttons) is computed by a MoonBit-side pure function and passed in (the probe measured icon=64 btn=0 raw=327744 on real hardware). Orthogonal to the "register + poll in the pump" async shape used by menus and hotkeys.
  • Global hotkeys (W20b) go to the thread message queue, not a window: RegisterHotKey's WM_HOTKEY lands in the registering thread's queue and never reaches the WndProc — same scheme as menus, with the stub's wm_hotkey_poll pulled from the MoonBit pump. Accelerator-string parsing ("CmdOrCtrl+Shift+M" → modifier bits + virtual key code) is a three-platform-shared pure function (CmdOrCtrl maps to Ctrl on Win, Cmd on mac), 10/10.
  • Evidence: the pure-C probe experiment/menu_probe/menu_backend_probe.c prints MENU-BACKEND-OK (menu bar / submenu / command ids / separator / check / nesting / Chinese label / bad parent handle / handle-table exhaustion / id wrap all pass); experiment/menu_probe's MoonBit side verifies the model tree (MENU-OK); core/menu_wbtest.mbt 10/10, win/hotkey_wbtest.mbt 10/10 and win/message_box_wbtest.mbt 8/8 all really run and pass.

MoonBit native GUI stack · Windows icon loading (W20f: native .ico + PNG via WIC) ​

  • Three sources, three entry points: .ico/.bmp go through LoadImageW(LR_LOADFROMFILE) (a native Win32 icon format, no decoding needed); PNG/JPEG/GIF have no native entry point and can only be decoded from memory with WIC (CreateStream → InitializeFromMemory → CreateDecoderFromStream → CreateFormatConverter to GUID_WICPixelFormat32bppBGRA → CopyPixels); already-decoded pixels go through a 32bpp top-down DIB + CreateIconIndirect. wm_icon_load_file dispatches on the extension and returns 0 if both paths fail.
  • A 32bpp HICON's alpha must be premultiplied, and render's Bitmap is exactly that convention (little-endian B,G,R,A premultiplied) — CreateIconIndirect blends with the color bitmap's alpha, so premultiplication is what avoids halos; the two layouts line up naturally, so pixels are a byte-for-byte memcpy into the DIB. Verification caveat: GetObject's bmBits on a DIB section is not guaranteed readable (measured: an unusable pointer, memcmp always fails) — to read pixels back use GetDIBits with a negative biHeight (top-down request, matching the input row order).
  • CLSID_WICImagingFactory is a macro that points at Win8-only Factory2: wincodec.h does #define CLSID_WICImagingFactory CLSID_WICImagingFactory2 when _WIN32_WINNT >= 0x0602; for older systems take CLSID_WICImagingFactory1 explicitly (identical vtable prefix, IID_IWICImagingFactory still v1).
  • All the GUID constants are exported from windowscodecs.lib (verified with dumpbin /linkermember: CLSID_WICImagingFactory1, IID_IWICImagingFactory, GUID_WICPixelFormat32bppBGRA are all there), so win's Windows link line only adds windowscodecs.lib ole32.lib — no uuid.lib needed (__uuidof remains unavailable in C mode, the same pitfall as the W14 file dialog).
  • C4013 implicit declaration shows up on this path too: wm_icon_load_file's WIC fallback calls wm_icon_from_png, defined after it, and compiling stub.c standalone gives MSVC C4013: undefined; assuming extern returning int — compiles but the reading is not trustworthy (same family as the accent-colour pitfall). A forward declaration clears it. Lesson: for mutually-calling internal static/global functions, settle the definition order or forward-declare explicitly rather than leaning on implicit declarations.
  • Evidence: the pure-C probe experiment/icon_probe/icon_backend_probe.c (#includes stub.c, feeds wm_icon_* directly, reads back with GetIconInfo/GetDIBits) ends with ICON-BACKEND-OK: BGRA→HICON size/bit-depth/pixel round trip, .ico from disk, PNG via WIC (an 8×8 RGBA translucent red decodes to 32bpp), handle-table exhaustion at 31 slots returning 0, and idempotent free all pass. The PNG asset is hand-written by experiment/icon_probe/make_png.py using only stdlib zlib (no Pillow). moon test ./gui/win is 24/24 (6 new pure-function tests: extension detection, format dispatch, handle-0 semantics, size validation).

MoonBit native GUI stack · Windows window enhancement (W20e: retitle / window icon / min size / borderless drag region) ​

  • WM_GETMINMAXINFO's ptMinTrackSize wants the whole-window size, not the client size: the MoonBit side passes a minimum client size (set_min_size(w,h)), and the C side expands it by the non-client frame (borders + title bar) for the current style with AdjustWindowRectEx(&r, st, FALSE, ex) before filling ptMinTrackSize. Probe measurement: a minimum client width of 400 under WS_OVERLAPPEDWINDOW yields a 416-wide frame (8 px each side), and the AdjustWindowRectEx convention agrees with WM_GETMINMAXINFO.
  • A zero axis must be explicitly zeroed: set_min_size allows one axis to be unconstrained (0 = no limit on that axis). But 0 must not be fed into AdjustWindowRectEx's rect — that computes a fake minimum of "a zero client area still occupies 2× frame width". The implementation writes mmi->ptMinTrackSize.x = w->min_w > 0 ? (r.right - r.left) : 0; and checks both axes; the wm_set_min_size entry also clamps negatives to 0 first. When only one axis is set, the other must fall back to 0 or the window gets stuck on that axis.
  • WM_NCHITTEST must break, not return 0: on a hit in the self-drawn title-bar drag region it return HTCAPTION (handing window dragging to the system), but outside the region it must break back to default handling — the default return values of WM_NCHITTEST carry meaning (client area HTCLIENT, resize corners HTBOTTOMRIGHT, …), and return 0 swallows them: measured symptom, the frame can no longer be dragged and client-area mouse behaviour goes wrong. The hit test converts lParam's screen coordinates to client coordinates with ScreenToClient before comparing rects (lParam packs screen coordinates, not client — easy to misread) and uses a left/bottom-closed, right/top-open range (>= x && < x+w). Note the packed coordinates are 16-bit signed ((int16_t)LOWORD/HIWORD), so negative multi-monitor coordinates still restore.
  • Read the window icon back with WM_GETICON: wm_set_icon(id, icon_slot, which) maps which 0/1/2 to ICON_BIG / ICON_SMALL / both and calls SendMessageW(hwnd, WM_SETICON, …) directly. The probe reads back with WM_GETICON(ICON_BIG) and getting the same HICON handle proves the write took effect. The icon handle table belongs to the caller (W20f's slot table); the window only uses the HICON, so both the C comment and the facade docs state "the caller must keep the icon un-freed while the window shows it" — otherwise the window references a dangling handle. Passing icon_slot=0 clears the icon.
  • Borderless = restyle, then recompute the frame from the client size + SWP_FRAMECHANGED: ON drops WS_CAPTION|WS_THICKFRAME|WS_MINIMIZEBOX|WS_MAXIMIZEBOX|WS_SYSMENU|WS_BORDER|WS_DLGFRAME and switches to WS_POPUP (keeping WS_MINIMIZEBOX so minimizing still works); OFF restores WS_OVERLAPPEDWINDOW. Two key steps: ① capture the current client rect with GetClientRect first, recompute the frame with AdjustWindowRectEx under the new style, then SetWindowPos(..., SWP_NOMOVE|SWP_NOZORDER|SWP_FRAMECHANGED) — without SWP_FRAMECHANGED the non-client area is not recomputed and the window size jumps; ② make it idempotent via w->borderless, returning early on a same-value call (otherwise every call runs SetWindowPos for nothing). Probe verification: after borderless there is no WS_CAPTION in GWL_STYLE, and the client width is still 640 (unchanged).
  • Runtime retitle goes through SetWindowTextW: wm_set_title converts UTF-8 to UTF-16 then calls SetWindowTextW; the probe reads back with GetWindowTextW to check the Chinese title's UTF-8→UTF-16 round trip.
  • Evidence: the pure-C probe experiment/window_probe/window_enhance_probe.c (#includes stub.c, creates a real window then reads SendMessageW(WM_GETMINMAXINFO/WM_NCHITTEST) directly and GetWindowTextW/WM_GETICON/GWL_STYLE back) ends with WIN-ENHANCE-OK (all 40 assertions pass): SetWindowTextW takes effect (Chinese), ptMinTrackSize.x == 416 (client 400 + frame), multi-monitor negative coordinates hit / open-interval boundary, in-region returns HTCAPTION and out-of-region returns HTCLIENT, WM_GETICON(ICON_BIG) equals the icon passed in, no WS_CAPTION after borderless with the client width unchanged, and an out-of-range id neither crashes nor writes the buffer. Example wiring: examples/native-app gained a window-enhancement section (retitle / self-drawn icon / borderless toggle / drag-band toggle) and an exit status line NATIVE-APP-WIN title='…' min=… drag=… bl=… icon=…, and moon build produces native-app.exe. moon test ./gui/win 24/24, moon check ./gui/win ./examples/native-app --deny-warn warning-free, the C backend clean under MSVC /W4.

MoonBit native GUI stack · Windows system tray (W20c: Shell_NotifyIconW + W20a menus for the right-click popup) ​

  • A tray icon is "one HWND + one uID": Shell_NotifyIconW posts callback messages to the window in NOTIFYICONDATAW.hWnd, with wParam = uID and lParam = the mouse message. This side therefore creates its own hidden message window (yue_wm_tray class + HWND_MESSAGE, lazily and idempotently), and the slot number is the uID (1-based, slot 0 unused; WM_MAX_TRAYS=8).
  • WIN32_LEAN_AND_MEAN strips out shellapi.h: Shell_NotifyIconW / NOTIFYICONDATAW / NIM_* / NIF_* all live there, so it must be included explicitly (same treatment as imm.h). The link line gains shell32.lib (it is not in user32/gdi32) — missing it means LNK2019 __imp_Shell_NotifyIconW.
  • The callback message is WM_APP+2, and again "C never calls back into MoonBit": WM_APP+1 is already taken by wm_wakeup's waker window (that case WM_APP + 1: in stub.c), so the tray takes WM_APP+2. Hits (WM_LBUTTONUP/WM_LBUTTONDBLCLK → left, WM_RBUTTONUP → right) go into a process-level ring queue that the host drains each tick with wm_tray_poll — the same scheme as menus and hotkeys, avoiding the trampoline-arity pitfalls. Non-click messages (e.g. WM_MOUSEMOVE) are not enqueued.
  • The right-click menu reuses W20a's HMENU handle table, so "tray menus" and "window menus" share one dispatch chain: wm_menu_popup_new() builds a popup root (differing from wm_menu_bar_new only in CreatePopupMenu vs CreateMenu), core's menu_translate_into fills it as usual, and popping uses TrackPopupMenuEx(menu, TPM_RIGHTBUTTON|TPM_RETURNCMD|TPM_NONOTIFY, x, y, tray_hwnd, NULL) — TPM_RETURNCMD hands the selected item back as the return value, and pushing it into wm_menu_push rejoins dispatch_menu_command()'s callback table without relying on the tray window's WM_COMMAND routing. Both Microsoft conventions are followed: SetForegroundWindow(tray_hwnd) before showing, PostMessage(hwnd, WM_NULL, 0, 0) after — otherwise the menu may fail to dismiss (focus is not handed back).
  • szTip is a fixed 128-wide-character array, so long text must be truncated: the tooltip write uses wcsncpy_s(nid.szTip, 128, wtip, _TRUNCATE) — a bare wcsncpy draws C4996 (the same family as the two W20a menu-label sites). The _s variant was chosen deliberately, and the whole tray section is warning-free under MSVC /W4.
  • Backend selection: Linux SNI first, the Windows stub here, Err(Unsupported) if neither. SNI availability uses the new @dbus.sni_watcher_present() (bus reachable and NameHasOwner(org.kde.StatusNotifierWatcher)), a stronger test than the existing @dbus.sni_available() (which only checks the "bus reachable" candidate condition) — on a stock GNOME without the AppIndicator extension the bus is up but the watcher is absent, so the tray is genuinely unavailable. The GTK branch deliberately returns 0: Linux trays go through the SNI protocol stack, not GTK3's GtkStatusIcon (deprecated and not honored by panels), the same reason W20f's icon loading returns 0 there.
  • The facade lives in core/tray.mbt, so core gains sys and dbus dependencies (the former for desktop_environment() reading XDG_CURRENT_DESKTOP, the latter for SNI). This matches the W20a decision to put the menu model in core: core is the "cross-platform capability facade" layer, and platform backends only do "model → native" translation. The old chain's same-named TrayError lives in the retiring yue/error.mbt; the new-stack definition lives in this file.
  • The exit-readout pitfall (fixed along the way): the example's exit status lines print after the window has closed, by which time the backend slot has been memset — NATIVE-APP-WIN title='' min=0x0 … was all zeros because it was reading a destroyed window. Fixed by snapshotting the live readouts into a Ref[String] each tick and printing the last "while alive" snapshot at exit (measured: title='' min=0x0 → title='MoonBit App 宿主 (批次1)' min=900x600). Rule of thumb: to report runtime state at exit, sample it while running — do not expect to read it after teardown.
  • Evidence: the pure-C probe experiment/tray_probe/tray_backend_probe.c (#includes stub.c, feeds wm_tray_* directly) ends with TRAY-BACKEND-OK, all 28 assertions passing: wm_tray_supported()==1, a real Shell_NotifyIconW(NIM_ADD) succeeds, tooltip Chinese UTF-8→UTF-16 round trip plus a 299-char truncation under 127 without overrunning, wm_menu_popup_new returning a real HMENU attached to the tray, posting WM_TRAY_CB to the tray message window to verify left kind=0 / right kind=1 / matching slot / non-click messages not enqueued, wm_tray_popup's guard paths (invalid slot, no menu) returning 0 immediately without blocking (TrackPopupMenuEx blocks, so a real popup needs a human click — hence guards only), slot-table exhaustion (0 after 7 slots), and idempotent removal returning the count to 0. moon test ./gui/core is 99/99 (6 new pure-function tests: basename_no_ext across path shapes/empty/trailing dot/multiple dots, non-empty desktop name, the three TrayError shows; plus 1 lifecycle test: create → tray_count()==1 → tooltip read-back matches → attach menu (popup handle filled in) → remove → tray_count()==0, auto-skipping rather than faking on machines with no tray backend). examples/native-app gains a tray section (five buttons: create / retip / attach menu / popup / remove, plus a state counter) and an exit status line NATIVE-APP-TRAY supported=… backend=… count=… clicks=… note=…, with dispatch_tray_event() called each tick in the main loop. Real-hardware smoke: exit 0, NATIVE-APP-TRAY supported=1 backend=- count=0 clicks=0 note=- (nothing created since no button was clicked; supported=1 confirms the Win32 tray backend is available). Whole-repo moon check red dots hold at 287/68, and gui/{core,win,dbus,ui,render,text,theme,icons,input,mdview,chart} are all green.

MoonBit native GUI stack · Windows desktop notifications (W21a: tray balloon NIF_INFO + a freedesktop cross-platform facade) ​

  • Win32 "notifications" have no standalone API — a notification is a balloon attached to a tray icon: NIM_ADD creates the tray item, then NIM_MODIFY with NIF_INFO plus szInfoTitle/szInfo pops the balloon (since Vista the system turns these balloons into Action Center toasts). This section therefore reuses W20c's tray slot table and message window, distinguishing them with the new WmTray.kind: kind == 0 = tray icon (receives clicks), kind == 1 = notification balloon (pops only, receives no clicks). The notification id is thus naturally the owning tray slot number (>=1).
  • Notification slots must be explicitly excluded from the click queue: wm_tray_wndproc gained a wm_trays[slot].kind == 0 guard when handling WM_TRAY_CB, so a kind == 1 slot drops clicks outright — a balloon's click semantics are undefined (Windows handles toast clicks itself, not via uCallbackMessage), and enqueueing anyway would dispatch to a slot that is not a tray icon. The probe posts WM_LBUTTONUP/WM_RBUTTONUP to a notification slot and confirms the queue stays empty.
  • "Dismissing a balloon" has no standalone API; follow the official convention of sending NIF_INFO again with an empty szInfo: wm_notify_close first sends that empty-body NIM_MODIFY (the system retracts the balloon), then NIM_DELETE removes the tray item — both steps are needed, and NIM_DELETE alone does not guarantee the balloon disappears immediately. It is idempotent: a repeat close hits the "slot already empty" guard and returns.
  • Two platform differences degrade explicitly rather than pretending to align (documented in the core/notify.mbt header and here): ① uTimeout is ignored by the system since Vista, so a balloon's dwell time is set by the system's accessibility settings and timeout_ms exists only for signature parity with the Linux side — it has no effect here; ② a Win32 balloon has no "progress hint", whereas Linux's freedesktop value hint lets the desktop draw a progress bar, so the facade pastes the percentage into the body (notify_body_with_progress, a visible degradation rather than a silent drop).
  • kind's three states map to NIIF_*: 0 info / 1 warning / 2 error → NIIF_ERROR / NIIF_WARNING / NIIF_INFO (the system status icon at the balloon's left). This is a hard-coded one-line ternary, and Win32 has no interface to read dwInfoFlags back, so the probe can only verify that all three kinds dispatch successfully; the mapping itself is covered by code review (stated honestly in the probe's header comment).
  • szInfoTitle is a fixed 64-wide-character array and szInfo a 256-wide-character one: writes always use wcsncpy_s(..., _TRUNCATE) (a bare wcsncpy draws C4996, same family as W20a/W20c). WmTray.ntitle[64] keeps a copy of the title for read-back (wm_notify_title, UTF-8 output).
  • Tray-icon slots and notification slots share one WM_MAX_TRAYS=8 table, but their counts are fully decoupled: wm_tray_count() counts only kind == 0 and wm_notify_count() only kind == 1. The probe verifies "1 tray + 3 notifications = 4 slots used", "only 3 more succeed once the table is full", and "removing the tray icon does not affect the notification count". Allocation goes through the unified wm_tray_alloc(kind, icon_slot, tip), with wm_tray_new delegating at kind=0.
  • Facade layering: win/notify.mbt is the thin Windows-side wrapper (notify_supported/notify_send/notify_update/notify_close/notify_count/notify_title plus the NOTIFY_INFO/WARNING/ERROR constants); core/notify.mbt is the cross-platform facade — backend selection is Linux freedesktop D-Bus (dbus/notify.mbt) first → Win32 tray balloon → Err(NotifyError::Unsupported) if neither. The cross-platform API is notify_send(app_name, summary, body, icon?, timeout_ms?, replaces_id?, progress?, kind?, icon_handle?), where icon is a Linux theme icon name and icon_handle a Win32 W20f icon handle (0 = use the system status icon). When replaces_id > 0, the Win32 side tries notify_update first (re-sending on the same slot replaces it).
  • Evidence: the pure-C probe experiment/notify_probe/notify_backend_probe.c (#includes stub.c, feeds wm_notify_* directly) ends with NOTIFY-BACKEND-OK, all 37 assertions passing: wm_notify_supported()==1, a real Shell_NotifyIconW(NIM_MODIFY) balloon, Chinese title UTF-8→UTF-16 round trip plus a 399-char truncation under 63 without overrunning, notification-slot clicks not entering the tray queue, all three kinds dispatching successfully, 1 tray + 3 notifications sharing the table with decoupled counts, a new send returning 0 once the table is full (7 slots), closing decrementing by one and idempotent, and invalid/tray slots returning 0 or no-op for the notification APIs. moon test ./gui/core is 108/108 (9 new: notify_clamp_kind clamped to 0..2, notify_clamp_pct clamped to 0..100, notify_body_with_progress leaving negative progress alone / appending to the body / clamping the percentage, the two NotifyError shows, backend name matching the enum, and notify_caps in both the with/without-backend states; plus 1 lifecycle test: send → update progress → close → idempotent re-close, auto-skipping on machines with no notification backend). examples/native-app gains a notification section (five buttons: info / warning / error / 60% progress / close, plus a state counter) and an exit status line NATIVE-APP-NOTIFY supported=… backend=… sends=… id=… count=… note=… (the count= is snapshotted each tick for the same reason as W20c's exit-readout pitfall). Real-hardware smoke: exit 0, NATIVE-APP-NOTIFY supported=1 backend=win-balloon sends=0 id=0 count=0 note=- (nothing sent since no button was clicked; backend=win-balloon confirms the backend selection). Whole-repo moon check red dots hold at 287/68, and gui/{core,win,dbus,ui,render,text,theme,icons,input,mdview,chart} are all green.

MoonBit native GUI stack · self-drawn message box (W21b: @ui.message_box_t, coexisting with the W20d native tier) ​

  • The self-drawn and native tiers are two roads that complement rather than replace each other: @win.show_message_box (W20d) goes through MessageBoxW — system look, system modality, a blocking return code, but Win32 only (a stub on macOS); @ui.message_box_t (this batch) is fully self-drawn, honours the theme, is non-blocking (each button calls back with its code), and is one code path across all three platforms. Callers pick per need, and the semantic surfaces line up (title / text / informative sub-line / multiple buttons / response callback).
  • Response codes are supplied by the caller, not borrowed from Win32's 1..7: buttons is a list of (label, response code) and the component passes the code straight back. The native tier's Ok(1)/Cancel(2)/…/No(7) is MessageBoxW's return-value convention, and the self-drawn tier must not be tied to it — the two numbering schemes are deliberately not interoperable, which is why the status output splits into NATIVE-APP-MSGBOX (native) and NATIVE-APP-MSGBOX2 (self-drawn).
  • ✕ / Esc / clicking the mask = dismiss without a code (via on_dismiss): MessageBoxW's ✕ maps to IDCANCEL, but the self-drawn tier does not borrow that (see above); if "cancel counts as a result", the caller adds its own cancel button. close_on_mask defaults to false — a message box usually demands an explicit answer, and closing on a stray click into empty space invites mistakes.
  • Enter = the first button (same meaning as the native tier's default_index=0). For Esc/Enter to arrive at all, the box must take over focus: on open it allocates an id from 700000 upward via msgbox_alloc_focus_id() and calls focus.register + focus.focus on @core.host_active(), with the panel itself as the focus widget (it sits deeper than the scrim, so keys reach it first); on teardown it unregisters and hands the pre-open focus back. Starting at 700000 avoids the low ids callers commonly use (the example uses 1..25) — focus ids are a global namespace, so a component allocating its own must leave room. After the focus stack became "realigned from the widget tree every round" (see 《Keyboard focus was dead everywhere》 at the end of this file) this register still holds: in the layer tier the panel is in this window's tree anyway, so realignment only keeps it.
  • An enum variant must not be called None (a compile-time collateral hit this batch): MsgBoxKind was first written with a None variant, and immediately every Store::new(None) elsewhere in the same package started inferring as MsgBoxKind::None, producing 6 type errors in datetime_wbtest. The cause: in positions with no expected type such as Store::new(None), a same-named constructor gets picked up. Renaming it to Default cleared it. Lesson: before adding an enum to a package, check whether the variant name already has an expected-type-free use in that package (None above all, then names that clash with SemanticType such as Warning).
  • Relation to dialog_t: both share the same overlay machinery in core/overlay.mbt (full-window scrim / centring / close-on-outside-click / relayout on open), differing in semantic surface — dialog_t is a content dialog (arbitrary content tree + confirm/cancel exits) while message_box_t is a message box (kind-driven icon + informative sub-line + any number of buttons each carrying a code). Content dialogs still go through dialog_t; this batch did not merge the two.
  • Evidence: ui/message_box_wbtest.mbt adds 8 tests (3 pure: the five kinds' icon names / distinct colours / default-button code; 5 runtime: Store-driven open-close + panel centring + the four-region structure + button callback code and close + ✕/Esc taking dismiss while Enter takes the default button + both close_on_mask states + an empty informative leaving no sub-line + empty-label buttons skipped), using a headless widget tree with a manually installed overlay host (as in overlay_wbtest) — no real window needed. moon test ./gui/ui is 118/118 (was 110). examples/native-app gains a self-drawn message-box section (three buttons: info / warning / error, each driving its own box) and an exit status line NATIVE-APP-MSGBOX2 shots=… last=… ovl=… (the ovl= is the last snapshot of the live overlay count, for the same reason as W20c's exit-readout pitfall); real-hardware smoke: exit 0, NATIVE-APP-MSGBOX2 shots=0 last=- ovl=0 native_ovl=0 (nothing popped since no button was clicked). moon check ./gui/ui ./examples/native-app --deny-warn is warning-free, and whole-repo red dots hold at 287/68.

MoonBit native GUI stack · drag-and-drop facade (W21c: core/drag.mbt session model + @ui.drag_source/drop_target) ​

  • In-app drag and drop is fully self-drawn and one code path across all three platforms: the old chain's drag source / drop target were platform callbacks (GTK gtk_drag_* / Win32 IDropTarget) requiring three separate handlers for enter/update/leave; in-app drag and drop in this stack touches no platform interface — the session model lives in core/drag.mbt (process-level) and the two declarative hooks in ui/drag.mbt. Cross-application system-level drag (dragging out of the window / accepting OS files) still ships with the platform batch; the facade's interface shape is settled, so the UI side will not change once a backend lands.
  • The payload is our own type, not a platform image type: DragData{Text,Html,Paths,Img}; Img holds raw PNG bytes rather than a platform image handle — the model layer carries no platform types, which is what lets payloads travel across backends/processes. DataKind{Text,Html,Image,FilePaths} each get a stable id (1/2/3/4), and drag_encode/drag_decode pack [kind:i32 LE][payload] (Paths joined with \n) — the stable ABI for future cross-process/cross-backend payloads.
  • The session is a process-level singleton and drop targets are resolved in window coordinates: only one drag runs at a time (drag_current). The landing point is found by Widget::drop_target_at, scanning back-to-front for the deepest visible widget carrying on_drop; the hover highlight is drawn by the painter reading @core.drag_hover() — which removes the enter/update/leave trio and with it the "missed leave callback" state-sync problem (the old chain's chronic issue).
  • Press-capture semantics differ from the wrapper's identity (a trap hit this batch, do not conflate them): on Down, route_pointer records the captured id as the deepest widget on the hit path (same meaning as a Web pointerdown target); the drag source wrapper's on_pointer receives the event by bubbling (the content widget does not handle the pointer itself, so it travels up). Hence @core.drag_source() records the wrapper id while the captured id is the content widget id — test assertions must distinguish the two (one was first written asserting equality with the wrapper id).
  • Do not put buttons inside a drag source: pointer events go deepest-first, so an interactive child swallows the press first and the drag never starts. A drag source should contain display items only (text / icons / thumbnails). This is a usage constraint, not a bug.
  • payload() is lazy: it is only called once the threshold is crossed (4px by default); below the threshold the press is released as an ordinary click, so selection/clicks inside the content are unaffected. When payload() returns None the drag does not materialise, again falling back to an ordinary press.
  • derive(Eq) on a value type trips the implicit_impl_as_method warning (this batch's compile-time trap): the correct form is a hand-written pub impl Eq for DataKind with fn equal(self, other) -> Bool plus pub extend DataKind with Eq::{not_equal, equal}. Note the implementation function is fn equal (writing op_equal first makes the compiler report no method op_equal in trait Eq). DragData does not go through Eq (it holds Bytes/Array) nor implement Debug, so tests observe it via strings/ids.
  • Another implementation trap (recorded): Bytes has no op_set and raw[4:...] yields a BytesView that cannot be sliced directly — assemble bytes with FixedArray::make + Bytes::from_array; split("\n") yields an Iter[StringView] and needs .map(fn(s){ s.to_owned() }) to become an Array[String].
  • Evidence: core/drag_wbtest.mbt has 6 tests (id round trip, DragData::kind/as_text, encode/decode round trip and bad input, drop_target_at deepest hit, session begin→move→drop, no-target/reject/cancel, and a constant no-target result when there is no root); ui/drag_wbtest.mbt has 4 tests (press → cross threshold → drag → move onto target → release delivers, below-threshold press behaving as an ordinary click / release over blank not delivering, accept rejecting so the callback does not run, and payload returning None producing no session), all driven through the host's @core.route_pointer (including press capture) — no real window needed. moon test ./gui/core is 115/115 and moon test ./gui/ui is 122/122. examples/native-app gains a drag-and-drop section (two sources "text payload / paths payload" plus two targets "accept all / paths only") and an exit status line NATIVE-APP-DRAG drags=… drops=… active=… last=…; real-hardware smoke: exit 0, NATIVE-APP-DRAG drags=0 drops=0 active=0 last=- (nothing dragged, so the counters are 0). moon check ./gui/core ./gui/ui ./examples/native-app --deny-warn is warning-free, and whole-repo red dots hold at 287/68.
  • Not done this batch, not pretending otherwise: cross-application system-level drag (dragging out / accepting OS files, deferred to the platform batch), drag-time visual feedback (a ghost thumbnail following the pointer — currently only the target highlight is drawn), the macOS backend, and the real-hardware feel of dragging (tracked in _人工测试清单.md).

MoonBit native GUI stack · autostart at login (W21d: sys/autostart.mbt, facade in MoonBit, C only for Windows) ​

  • Division of labour: the cross-platform surface lives in MoonBit — Linux (and other XDG desktops) assembles $XDG_CONFIG_HOME/autostart/<app_id>.desktop and writes it to disk (pure string assembly plus the existing text-file read/write entries in fsx.mbt, so no platform interface at all); only Windows needs C — HKCU's Run key is reachable only through the registry API (RegOpenKeyExW / RegQueryValueExW / RegSetValueExW / RegDeleteValueW), see the autostart section of sys/sys_stub.c. macOS is explicitly Unsupported this batch (it needs a LaunchAgents plist; deferred to real hardware).
  • The value's shape is a platform convention, not an arbitrary string: a Linux .desktop Exec must be wrapped in double quotes as a whole (with \ and " backslash-escaped inside and % written %%); a Windows REG_SZ value likewise needs the whole string quoted — a path with spaces and no quotes gets truncated at the first word when launched at login (i.e. it autostarts a program that does not exist). The quotes are assembled on the MoonBit side (autostart_escape_exec / autostart_run_value) and the C stub writes and reads them verbatim, with no second pass of processing.
  • Idempotency is part of the contract: a repeated enable is an overwrite (the .desktop on Linux, the same-named Run value on Windows), and disable also succeeds when the entry was never there (Linux relies on yue_sys_remove_file's probe-then-delete; Windows treats both RegDeleteValueW's ERROR_FILE_NOT_FOUND and "the key itself is missing" as success).
  • app_id validation is a shared precondition of both landing points: the app_id doubles as the .desktop filename and the Windows Run value name, so the DBus well-known-name character set is reused (≥2 dot-separated elements, each starting with a letter/underscore, only [A-Za-z0-9_-], total length ≤255). sys does not depend on dbus in reverse, so the rule is implemented locally in sys/autostart.mbt (autostart_valid_app_id). A misconception worth recording: an element must not start with - or a digit, but may contain them — com.example.my-app2 is valid while com.2app and com.-app are not. A test first treated "contains -" as invalid (asserting com.ap-p to be false, though it is in fact valid); the run corrected it.
  • Link surface: the Reg* family lives in advapi32.lib, while sys's Windows link line previously carried only ole32/shell32/uuid/user32 (those serve the file dialog's COM). This batch adds advapi32.lib to the sys entry (scripts/prebuild.py-managed; no link parameters inside the package).
  • Two things the pure-C probe provides itself (experiment/autostart_probe/autostart_backend_probe.c): ① the probe ships its own moonbit_make_bytes — a pure-C probe does not link the moon runtime, yet yue_sys_* returns moon byte strings; a self-consistent layout suffices (the length sits in the 4 bytes before the data pointer, matching the real moonbit_object header), with the real ABI interaction covered by the whitebox tests. ② The stub.c include order defeats _CRT_SECURE_NO_WARNINGS — the probe includes <windows.h> first (which already pulls in string.h), and UCRT decides at that instant whether to warn about deprecated names, so the stub's two wcscpy/wcscat sites produce 2 C4996 warnings in the probe build. That is an artifact of the probe's include order, not a code problem: the real build has moon's native-stub include sys_stub.c first, so the macro takes effect and no such warning appears.
  • Evidence: the pure-C probe ends with AUTOSTART-BACKEND-OK, all 36 assertions passing: yue_sys_exe_path matching an independent GetModuleFileNameW call character for character (including the .exe suffix and backslashes), ok=0 when unwritten, a write followed by the probe's own RegQueryValueExW read confirming it landed under Software\Microsoft\Windows\CurrentVersion\Run with type REG_SZ and a byte-identical value, overwrite / Chinese-path round trip / empty value (length 0 yet present) / a 1207-character long value, NULL arguments returning -1 from all three entry points, an independent read after removal showing ERROR_FILE_NOT_FOUND, and a second idempotent removal still succeeding. The probe really writes the user's registry (that is its point); it deletes its own exclusive value com.moonbit.native-gui.autostart_cprobe both on entry and on exit, cleaning up regardless of outcome (checked afterwards with reg query; no residue). sys/autostart_wbtest.mbt has 8 tests: 6 pure (directory resolution in three states / Exec escaping / .desktop content and parsing in both states / Run value shape and its inverse / app_id validation / the error Show) + 1 "an invalid app_id is rejected first on every platform" + 1 real lifecycle (cleanup → not enabled → enable → enabled → the landing point reads back (the .desktop body on Linux, the Run value on Windows, inverted and compared against the exe) → repeated enable is idempotent → disable → not enabled → idempotent disable again), skipping rather than faking when autostart_supported() is false. moon test ./gui/sys runs 174, 173 pass, 1 red — that one is the pre-existing fsx_wbtest.mbt:109 (the sys_make_dirs case was already red on Windows: pr_available() is true there, yet the case used the POSIX /tmp plus pr_run("rm"/"mkdir"); re-checked with git stash on a pristine tree, same single red, unrelated to this batch). Since fixed — sys_make_dirs is now the C thin FFI yue_sys_make_dirs and the case itself is platform-portable; sys is 178/178. examples/native-sys gains "enable autostart / disable autostart" buttons and a status line, plus the exit readout NATIVE-SYS-AUTOSTART supported=1 enabled=0 id=… exe=… on_hits=0 note=… (is_enabled reads the disk/registry live, so it still works after the window closes, unlike window readouts); real-hardware smoke: exit 0 (no button clicked, so enabled=0 and no registry residue). moon check ./gui/sys ./examples/native-sys --deny-warn is warning-free; the C side under MSVC /W4 keeps only the 2 C4996 warnings explained above; whole-repo red dots hold at 287/68.
  • Not done this batch, not pretending otherwise: macOS (a LaunchAgents plist, left as Unsupported), launch arguments and delayed start (Windows could use the ** prefix or schtasks with a delay, Linux could add X-GNOME-Autostart-Delay — not written here), capability probing beyond is_supported, and the real-hardware feel of autostart (tracked in _人工测试清单.md).

MoonBit native GUI stack · user idle detection (W21e: sys/idle.mbt, read-only seconds, no binary state) ​

  • The interface reports seconds only, with no active/idle binary: idle_seconds() -> Result[Double, IdleError] returns the seconds since the last input, and threshold decisions are left to the caller — screen-awake, lock-screen and power-saving each want very different thresholds, and a number picked for the caller in the library would just be worked around. Conversion and clamping (negatives to zero) live in the pure MoonBit functions idle_clamp_ms / idle_ms_to_seconds; the stub returns only the raw milliseconds.
  • Platform data sources come in three tiers (all in the idle section of sys/sys_stub.c): Windows uses GetLastInputInfo + GetTickCount; Linux uses the X11 XScreenSaver extension (XSS); macOS is not implemented this batch (always false / Unsupported).
  • Windows: the difference must be an unsigned modular subtraction, not an int64 subtraction (this batch's key rule). GetTickCount() is 32-bit and wraps about every 49.7 days. The correct form is DWORD elapsed = GetTickCount() - li.dwTime; — unsigned subtraction is inherently modulo 2³², so it stays correct across the wrap point; converting each side to int64 first yields a huge negative number at the wrap (a negative idle time once converted to seconds). The pure-C probe pins this semantic down with synthetic values (0x10 - 0xFFFFFFF0 == 0x20).
  • Linux: runtime dlopen, with no X link dependency. sys's Linux link line carries only pthread/dl/m/stdc++ — a headless consumer should not be tied to X libraries — so XSS goes entirely through dlopen("libX11.so.6"/".so") + dlopen("libXss.so.1"/".so") and dlsym for XOpenDisplay / XDefaultRootWindow / XScreenSaverAllocInfo / XScreenSaverQueryInfo / XFree / XCloseDisplay. The XScreenSaverInfo struct is declared locally in the stub (rather than including X11/extensions/scrnsaver.h, which would drag in X headers); symbols are fetched through a void* hop (memcpy) to dodge -Wcast-function-type. The result is cached in a static g_idle_x11 (a tried flag guards against repeated probing).
  • A Wayland session gets "unsupported" rather than a wrong number: with no X server (DISPLAY empty) idle_supported() is false outright. Under XWayland, input events do not reach the X server, so XSS readings only ever overstate — better to report nothing than a value that would mislead the caller (exactly why "seconds only" pairs with Result: treating failure as 0 seconds reads as "just moved").
  • Supportability and this call's outcome are two separate tiers: idle_supported() is a runtime capability probe (Windows always true; Linux needs DISPLAY non-empty and both libraries dlopened; macOS false). Inside idle_seconds(), ok=1 gives Ok; otherwise it consults idle_supported() — true means Err(Failed("空闲查询失败")) (idle query failed, e.g. an X connection dropping mid-flight), false means Err(Unsupported). Keeping them apart is what lets the caller distinguish "this machine has no such capability" from "this particular query failed".
  • Evidence: the pure-C probe experiment/idle_probe/idle_backend_probe.c ends with IDLE-BACKEND-OK, all 13 assertions passing: supportability always 1, the reading non-negative and within int32 (no truncation), matching an independently recomputed GetTickCount() - li.dwTime (within ±2000ms, tolerating the call gap and GetTickCount granularity), consecutive reads non-decreasing, the modular-subtraction semantic on synthetic values (wrap included), five consecutive reads all succeeding, and the ok out-parameter written every call. The probe only reads system state and writes nothing (one fewer self-provided artifact than W21d: the idle interface returns no moon byte string, so it needs no bundled moonbit_make_bytes). Build recipe in the probe header (add user32.lib — GetLastInputInfo lives in user32; and because #include sys_stub.c pulls in the registry functions, link advapi32.lib along with it). sys/idle_wbtest.mbt has 4 tests: 3 pure (millisecond clamping / milliseconds-to-seconds / error Show) + 1 "supportability probe" (asserts true on Windows, merely prints elsewhere) + 1 "reading non-negative and two consecutive reads monotonic (a decrease can only mean an input zeroed it in between, and then the new value is necessarily <0.05s)". moon test ./gui/sys runs 178, 177 pass, 1 red — that one is the pre-existing fsx_wbtest.mbt:109 (unrelated to this batch; re-checked on a pristine tree in the previous batch). Since fixed — see the revisit of the "C stub on demand and minimal" entry above. examples/native-sys gains a "read idle" button and status line, and the exit readout NATIVE-SYS-IDLE supported=1 seconds=2486.141 hits=0 note=…; real-hardware smoke: exit 0, and NATIVE-SYS-GEO includes idle=1786,368,110x32 (the button laid out). moon check ./examples/native-sys is warning-free; the repo-wide red count stays at 287/68.

MoonBit native GUI stack · cross-platform global-hotkey facade (W22: core/hotkey.mbt, the last of the three service facades) ​

  • W22's scope, tightened: under "unify the tray / notification / hotkey facades", the tray (core/tray.mbt) and notifications (core/notify.mbt) facades already landed in W20c/W21a, and this batch does not refactor their existing APIs (changing error types and probe names would be a purely breaking change with zero caller benefit). What was genuinely missing was that hotkeys had no cross-platform facade at all — W20b landed only in the win package (free functions such as register_global_shortcut plus the parse_accelerator pure function), leaving Linux/macOS callers with no way to write isomorphic code. So W22 = add core/hotkey.mbt, shaped like the other two facades: probe hotkey_supported() + structured error HotkeyError{Unsupported, Failed(String)} + pump hook hotkey_poll().
  • Current backends and the degradation contract: Windows is a real implementation (RegisterHotKey + a thread queue, already verified in W20b); Linux's X11 XGrabKey and macOS's Carbon/AppKit hotkeys are not wired, and always return Err(Unsupported) — "not built" and "failed" are two separate tiers, so callers can hide UI accordingly, the same contract as the tray's absent SNI watcher and notifications' absent backend.
  • Accelerator strings in, id lookup in the facade: registration takes a string ("Ctrl+Alt+F9" / "CmdOrCtrl+Shift+M", where CmdOrCtrl maps to Ctrl/Cmd per platform — parsing delegates to win's pure function rather than reimplementing it in core); hotkey_poll() returns Hotkey{id, spec} on a hit — Win32's poll only returns an id, so the accelerator-string lookup table is held by the facade itself (process-level, same lifetime as win's registry; an id outside the table gets an empty spec, meaning it was not registered through this facade).
  • Platform probing goes through the runtime @sys.sys_platform() (the C stub ffi_sys_platform returns 1/2/3 → "windows"/"macos"/"linux"), consistent with AGENTS rule 11 "runtime probe + match, no #cfg"; the is_mac criterion comes from the same source (it decides the CmdOrCtrl mapping).
  • Two small toolchain pitfalls (both already hit in the code): ① Array has no find — the lookup table's linear scan is a hand-written loop (for h in arr { if … return Some(h) }); appending uses arr.push(x), never arr + [x] (the parser treats + as infix and reports unexpected token infix 1, an error message far from the root cause). ② inspect(value, content="…")'s content is a string compared against the value's stringified form — a boolean must be given "true"/"false"; passing a descriptive word like "supported" always fails (hit once in this batch and once in the taskbar batch, recorded here once).
  • Evidence: gui/core/hotkey_wbtest.mbt has 2 tests (an unregistered id's lookup returns an empty string; "assert only when connected" — when the backend is available, an empty accelerator string must go through Err(Failed) rather than crashing or silently succeeding, other platforms only print skip); moon test ./gui/core 118/118 (the original 116 plus 2 new). examples/native-app's hotkey section switched from calling @win.* directly to @core.hotkey_* (two sites: registration and polling; a poll hit now takes the spec straight for display), --deny-warn warning-free, and the smoke run survived 100 seconds with no panic and no CRITICAL.
  • Not done this batch, and not claimed as done: the macOS hotkey backend (the facade is ready; landing the backends will not change callers); the unification refactor of the tray/notification APIs (explicitly out of scope, reason in the first bullet).

MoonBit native GUI stack · Linux global-hotkey backend (W22's Linux tier: X11 XGrabKey, 2026-10-16) ​

  • Scope: in win/stub.c the GTK branch's wm_hotkey_* trio moves from placeholder stubs to a real implementation, and wm_hotkey_supported is added; the facade (core/hotkey.mbt) and the parsing (win/hotkey.mbt) see zero semantic change. The "two code spaces" convention takes effect this batch: the C ABI register gains a third parameter, both vk and keysym are passed and each platform stub picks its own (Windows takes vk, X11 takes keysym); the MoonBit-side register_global_shortcut now takes the whole Hotkey.
  • A self-opened X connection instead of hooking the GTK pump: one process-level XOpenDisplay connection; XGrabKey lands on its root window, grabbed KeyPress events enter only this connection's queue, and wm_hotkey_poll drains them non-blockingly with XPending — neither the gdk_window_add_filter nor the periodic XCheckIfEvent originally envisioned in the TODO is needed; the pump never enters the GTK main loop.
  • Variant grabs are a requirement, not defensive coding: registration calls XGrabKey for four variants — base, base|Lock, base|Mod2, base|Lock|Mod2. The TODO's original "mask with & ~(Mod2|Lock) at grab time" does not work (clearing mask bits only loosens modifier matching — with NumLock on the event state contains Mod2, and a mismatch loses the whole key); an injected event on real hardware arrived with state=28 (Control|Mod1|Mod2), direct proof — this machine keeps NumLock on, so without variant grabs the hotkey would never fire. ScrollLock (Mod5) gets no grab variants and is masked out when matching.
  • BadAccess is asynchronous: when the chord is owned by another client, XGrabKey reports no error in place — only a temporary XSetErrorHandler trap plus XSync reveals it; failure of any variant rolls the whole thing back with XUngrabKey and reports registration failure (the facade gives Err(Failed), never silence). Bare keys (no modifiers) are refused, matching Windows' RegisterHotKey convention.
  • The VT-switch reserved-space pitfall (symptom → root cause): the probe reused W20b's Ctrl+Alt+F9 — registration succeeded yet injected events never fired; a minimal C repro (bare XGrabKey + XTEST injection) did not fire either; switching to Ctrl+Alt+H hit on the first try — Ctrl+Alt+Fn is the VT-switch chord, and the server consumes the event before passive grabs see it; the grab registers but never receives. The Linux tier's default/demo chords avoid the Fn family (Windows has no such constraint); this also explains why W20b's F9/F10 chords were harmless on Windows.
  • Wayland stance: with WAYLAND_DISPLAY non-empty wm_hotkey_supported is permanently 0 — an XWayland grab only fires while an X11 window holds focus and never achieves true globality, so it reports "not built" instead of pretending success; the way out is the roadmap's "session axis" (portal / desktop-daemon protocols).
  • Verification (Ubuntu 24.04 + X11 + XFCE): moon run experiment/hotkey_probe — ① seven parsing comparisons; ② register / idempotent unregister / slot reuse (Some(1)/Some(2)/re-register yields Some(1)); ③ the new facade polling stage: xdotool key ctrl+alt+h (XTEST, the real delivery path) → HOTKEY-CORE-FIRE id=1 spec=Ctrl+Alt+H, the full chain = inject → X server → passive grab → pump drain → facade lookup. moon check warning-free, library tests 982/982. Along the way: HotkeyError gained Show/extend (isomorphic with the other two families' error types, so the probe can interpolate \{e}).

MoonBit native GUI stack · taskbar progress (W20g: win/taskbar.mbt, the Windows half of the §6.3 seam item "taskbar progress / dock badge") ​

  • Write-only, no read-back: Win32's ITaskbarList3 has no getter — TBPF_* progress state cannot be read back (the same "mapping is not reversible; code review is the backstop" situation as W21a's kind→NIIF_* mapping). So the surface only has writers (set_taskbar_progress / set_taskbar_state / clear_taskbar_progress), and the probe (taskbar_supported) only reports whether the COM instance came up — it never returns a current progress value. Callers that need to display progress keep their own number (deliberately a different tier from notify_title, which can read back because NIF_INFO's szInfoTitle is readable).
  • The COM instance is a process-level singleton; the target window is distinguished by hwnd: the ITaskbarList3 interface itself is window-agnostic (the hwnd argument of SetProgressValue is the target), so the stub keeps one static singleton (wm_taskbar plus a wm_taskbar_tried guard against repeated probing) and takes the window handle from the window slot (an out-of-range id or a NULL hwnd returns 0, the same contract as every other wm_* entry). Initialization details: CoInitializeEx(COINIT_APARTMENTTHREADED) falls back to CoInitialize on RPC_E_CHANGED_MODE; S_FALSE (this thread already initialized) counts as success; a failed HrInit must Release before reporting unavailable; there is no CoUninitialize at exit — the COM object is reclaimed with the process, and other COM users remain on the app exit path (W14 file dialogs, W20f WIC).
  • GUID symbols come from uuid.lib, not ole32: C mode has no __uuidof, so the two constants CLSID_TaskbarList / IID_ITaskbarList3 must be resolved at link time — ole32.lib only exports the basic COM GUIDs; these two live in uuid.lib (the same pitfall as W20f's CLSID_WICImagingFactory family living in windowscodecs.lib: which library a GUID constant lands in has to be confirmed one by one). Hence gui/scripts/prebuild.py's Windows branch adds uuid.lib to the win entry (the probe's manual build line does the same).
  • Progress values go through Int64: byte-count-style progress values overflow 32 bits, so the MoonBit side uses Int64 for value/total and the C side passes them through as ULONGLONG; when total <= 0 the SetProgressValue semantics are meaningless, and the documented convention is "use the Indeterminate state for indeterminate progress".
  • Evidence: the pure-C probe experiment/taskbar_probe/taskbar_backend_probe.c ends with TASKBAR-BACKEND-OK and all 20 assertions passing: wm_init succeeds, supported == 1 and the singleton is non-NULL (these two together verify both the uuid.lib GUID symbol resolution and the CoCreateInstance + HrInit steps), a real window is created (wm_window_new + wm_show, closed with wm_close at the end), all five state ids and the value/total write paths succeed, a 4GB-1 / 8GB large value succeeds, the guard paths (out-of-range id=0 / id=9999, an unbuilt window whose slot exists but whose hwnd is NULL, illegal state=5 / state=-1) all return 0 without crashing or writing, the teardown clears back to NoProgress before closing, and the same id returns 0 after the window closes (hwnd already NULL). gui/win/taskbar_wbtest.mbt has 4 tests: ids aligned with TBPF_*, id round-trip, unknown ids falling to NoProgress (matching the C-side default branch), and "assert only when connected" — when the backend is available, a nonexistent window handle must return false rather than silently succeeding (other platforms only print skip). moon test ./gui/win 28/28 (the original 24 plus 4 new).
  • Example wiring: examples/native-app gains a "taskbar progress (§6.3)" section — seven writer buttons (10% / 50% / 100% / error / paused / indeterminate / clear) plus a live_counter that reads the most recent operation's result label live each frame (a failed write lands in the label rather than staying silent); the exit status line is NATIVE-APP-TASKBAR supported=… last=…. moon check ./examples/native-app --deny-warn is warning-free; the smoke run survives (110 seconds with no panic and no CRITICAL — note that native-app's self-destruct point is 10 minutes, so unattended runs always need a timeout, see the _人工测试清单.md header).
  • Not done this batch, and not claimed as done: Linux's taskbar progress has its own Unity LauncherEntry (DBus com.canonical.Unity.LauncherEntry) path and macOS's is the dock badge (NSDockTile), both deferred to a later cross-platform facade batch — the GTK branch and the placeholder branch only provide compilable failing stubs with supported always 0; the real look of the taskbar button (green / red / yellow bar, indeterminate marquee) can only be judged by a human and belongs to section O of _人工测试清单.md.
  • The probe's two C4996 warnings are a pre-existing artifact: wcsncpy / wcsncat in the wm_im_* section — the probe's own #include <windows.h> disables the stub's _CRT_SECURE_NO_WARNINGS (the same note as W21d); the real build (moon's native-stub) has no such warning.

Choosing where examples write temp files: the world beyond TEMP (2026-10-16, browser-example redo batch) ​

  • Symptom: examples/browser's scheme_root() only honored the TEMP env var and fell back to a relative path libyue-browser-scheme/ when it was missing. Linux desktops have no TEMP (a Windows convention), so every run of the example dropped a temp directory into the current working directory — i.e. the repo root when launched from there, leaving a dirty un-committed directory behind (the leftover libyue-browser-scheme/ in the repo root was exactly that).

  • Root cause: the temp-dir choice was hardcoded for a single platform. "Fall back to the current directory when unset" is nearly harmless on Windows (TEMP is essentially always present) but on Linux it makes the worst place to write the default.

  • Fix (scheme_root in examples/browser/main.mbt): per-platform split — Windows reads TEMP, then TMP, then C:/Windows/Temp (never a relative path); Linux/macOS read XDG_RUNTIME_DIR (per-user runtime dir, cleaned at logout), falling back to /tmp. The directory was renamed native-gui-browser-scheme along with the repo rename; the old leftover directory is simply deleted.

  • Verification: after a smoke run on Ubuntu 24.04, /run/user/<uid>/native-gui-browser-scheme/ holds the three protocol-page files (index.html / page2.html / style.css); nothing new appears in the repo root; git status clean.

  • Rule: when an example must write intermediate files to disk, the landing point must be one of the system temp areas (XDG_RUNTIME_DIR → /tmp on Linux; TEMP → TMP on Windows). Never fall back to the current directory — moon run's working directory belongs to the user, not the example.

  • How it came up: the TODO.md line "retire the old chain's modules/gui-media" was read as "ffmpeg is going away". Checking the tree confirmed ffmpeg is not going anywhere (the new-stack yue/media still imports NoahLiu/ffmpeg-mbt/src — it is the decode backend), but it surfaced a real problem: yue/media lived inside the main module, and for its import of ffmpeg-mbt to resolve the root moon.mod had to declare NoahLiu/ffmpeg-mbt@0.1.0 — so every consumer of the main module pulled ffmpeg into its dependency closure, cancelling the original "media as a separate module = optional dependency" design.

  • The mechanism (a hard moon constraint): an import in moon.pkg requires the module dependency to be declared in moon.mod first (otherwise: containing module is not imported). Therefore "the media package lives in the main module" and "module-level optionality" are mutually exclusive: optionality can only be had by moving media wholly out of the main module. Keep the two layers apart — package-level optionality (a moon.pkg import is the compile/link boundary) was never lost: not importing yue/media means -lav* never reaches the link line. What was lost is the dependency-closure / build-time layer; they are not the same thing.

  • One measurement correction: this repo is a moon workspace (moon.work lists modules/ffmpeg-mbt), so building any in-repo package runs ffmpeg-mbt's prebuild — measured: moon build ./gui/core and moon build ./modules/yoga-mbt/src both print the ffmpeg(prebuild): line, and neither has anything to do with media. That is not caused by the root moon.mod; workspace membership alone triggers it (it runs whether or not moon.work changes; .mooncakes holds no NoahLiu/, so every NoahLiu/* resolves from a local workspace member). This batch's payoff is therefore confined to external consumers: they sit outside this workspace and resolve only moon.mod, so once that line is gone ffmpeg-mbt never enters their closure. In-repo/CI still runs it (CI installs the ffmpeg dev packages anyway; no added burden).

  • What was done: ①git mv yue/media modules/gui-media/media — the media package moves into the existing standalone module NoahLiu/gui-media (which was split out for optional media in the first place); ②the miniaudio system-library link flags the media package needs (-lpthread -ldl -lm -lstdc++ -latomic) move out of the main repo's scripts/prebuild.py into the new module's own modules/gui-media/prebuild.py, issued by package name NoahLiu/gui-media/media (graceful empty link_configs off Linux, same stance as ffmpeg-mbt / the main prebuild); ③modules/gui-media/moon.mod gains NoahLiu/yoga-mbt@0.1.0 (the media package takes layout nodes via @yoga) plus the prebuild option; ④the root moon.mod drops NoahLiu/ffmpeg-mbt@0.1.0.

  • The example has to move with it (the b01d1dd lesson): examples/native-web was the only package in the main module importing yue/media. Left there, the main module would have to depend back on the media module — recreating the "main ↔ media" publish cycle that once left both unpublishable and forced the examples into modules/gui-examples. So the whole example moves to modules/gui-examples/native-web (the module documented as "the standalone example module that needs media"); its moon.pkg changes NoahLiu/native-gui/yue/media to NoahLiu/gui-media/media (the alias still derives as @media, so zero source changes), and modules/gui-examples/moon.mod gains NoahLiu/yoga-mbt@0.1.0. No new page: native-web's header states it is "the accumulating verification example for webview and media — no new page for the same family", and splitting it out would violate that rule.

  • The consolidation policy follows: the "verification example consolidation" notes in TODO.md now point the webview + media landing point at modules/gui-examples/native-web instead of examples/native-web (the historical evidence lines keep their original wording — that is what happened then).

  • Evidence: ①moon tree for the root module no longer lists NoahLiu/ffmpeg-mbt (it was explicitly listed before; after the change the first level is just the three yoga-mbt / subproc / markdown branches); ②repo-wide moon check red count stays at 287/68 with every error still in the old-chain packages (events.mbt/splitter.mbt/types.mbt/props.mbt/video_view.mbt…), showing the move did not touch them; ③both moved packages re-check green after a forced re-check (touching sources then moon check); ④the new module's prebuild run directly — echo '{}' | python modules/gui-media/prebuild.py → {"link_configs": []} (non-Linux degradation); ⑤grep shows no package in the main module referencing yue/media and no MEDIA_PACKAGE residue in the main prebuild. One Windows-side gain (it was already broken): on Windows the root module could not build at all — the native-web → yue/media C stub needs libavformat/avformat.h and audio_stub.c uses CLOCK_MONOTONIC; measured: moon build ./examples/native-web fails outright. After the move the root module contains no package that cannot build on Windows.

  • Not done this batch, not pretending otherwise: miniaudio.h is still two copies (modules/gui-media/src/miniaudio.h and modules/gui-media/media/miniaudio.h, same md5 8d5bf2a0b46efa835bde4d0eac5a0cd8) — folding them awaits retiring the old chain's modules/gui-media/src, blocked on migrating systemprobe's media page; the media package is still unusable on Windows/macOS (POSIX clock + ffmpeg backend unwired); and media's real behaviour on Linux (does audio actually come out, how seek feels) cannot be evidenced on this host — it stays with the manual checklist.

  • Not done this batch, not pretending otherwise: macOS (would need CGEventSourceSecondsSinceLastEventType / IOKit HID, left as Unsupported), idle on a native Wayland session (would need org.freedesktop.ScreenSaver or ext-idle-notify-v1; this batch reports "unsupported" only), the setter side of "keep the screen awake" (XScreenSaverSuspend / SetThreadExecutionState — this batch only reads idle, it does not inhibit sleep), any notification mechanism for idle-threshold changes, and the real-hardware feel (tracked in _人工测试清单.md).

Media audio backend goes cross-platform: a three-way monotonic clock (2026-10-12) ​

  • How it came up: the previous batch moved the media package into its own module and filed "media cannot build on Windows" under "POSIX clock + ffmpeg unwired". This batch takes care of the half that is ours — audio_stub.c is media's audio backend (the miniaudio device layer plus a PCM queue) with zero ffmpeg dependency, so whether it compiles and runs can be pinned down on its own, without waiting for the ffmpeg line.
  • Symptom (measured under MSVC): audio_stub.c reports error C2065: "CLOCK_MONOTONIC": undeclared identifier, plus warning C4013: "clock_gettime" undefined. CLOCK_MONOTONIC is a POSIX-only constant that MSVC's <time.h> does not provide (and clock_gettime itself only lands in some UCRT versions).
  • Fix (one stub.c, multiple branches, platform macros — precedent win/stub.c; AGENTS rule 11): mbt_now_ms now branches three ways — _WIN32 uses QueryPerformanceCounter / QueryPerformanceFrequency (QPC is Windows' monotonic high-resolution counter; not GetTickCount, whose granularity is ~15.6 ms and which is nudged by system-time changes); __APPLE__ uses mach_absolute_time + mach_timebase_info (not clock_gettime: CLOCK_MONOTONIC needs macOS 10.12+, and mach is both more conservative and equally monotonic); everything else POSIX keeps clock_gettime(CLOCK_MONOTONIC) byte-for-byte unchanged. All three return "milliseconds since some arbitrary fixed epoch", used only for differences (elapsed = now - wall0) where the absolute value is meaningless — so a platform-dependent epoch does not affect the playback clock.
  • Two overflow guards: ①the Windows branch splits into (ticks / freq) * 1000 + ((ticks % freq) * 1000) / freq so ticks * 1000 cannot overflow 64 bits (QPC counts from boot; at 10 MHz a year is ≈ 3.15e14, so ×1000 still has order-of-magnitude headroom, but the split is strictly safer); ②the macOS branch uses __uint128_t to bridge t * numer / denom, guarding the intermediate product when numer/denom is not 1:1 (as on Apple Silicon).
  • Both copies changed together: media/audio_stub.c and src/audio_stub.c were byte-identical (md5 2b807752…). At one point the change left one extra comment line in the src/ copy — which made them no longer byte-identical; that line was reverted on the spot and the two are identical again (both md5 856a4c98e3879e77aa5108f7bb4dcfd8). Reason: the "fold to one copy" step promised when the old chain retires must stay mechanically decidable, and silent drift between the two breaks that.
  • Evidence (pure C probe experiment/media_probe/audio_backend_probe.c, which directly #includes the platform backend): last line AUDIO-BACKEND-OK, 48 assertions pass, 0 failures — ①clock: consecutive / five-in-a-row reads are non-decreasing and positive, and Sleep(60) advances it by 61 ms (within [40,400]); ②device-open arguments: accepted only when channels ∈ [1,8] and rate > 0; 0/9/-1/rate=0/negative rate all return NULL; ③queue and volume semantics (independent of whether a device exists): push appends, queued_bytes accumulates 400→600, an invalid push leaves the queue untouched, clear zeroes it and is idempotent on the second call, played_bytes is non-negative, volume clamps into [0,1] (0.5/1/0.25 read back exactly, -1→0, 2→1); ④both device-lifecycle branches run (no environment assumed): with a real device on this host → start=0/is_started=1/second start = -1/played_bytes really advances (measured 35280 bytes)/pause=0/second pause = -1; with no device (CI/headless) → start = -1 and the state stays 0; ⑤NULL-argument defence across the board (failure codes where they are due, no crashes where none should be). The probe also reads QueryPerformanceFrequency directly and prints 10000000 Hz, proving the QPC high-resolution branch is the one taken, not a coarse-grained fallback. Build recipe in the probe header (-I <moon>/include for moonbit.h; add ole32.lib uuid.lib for miniaudio's WASAPI backend; #pragma warning(disable:4244) silences only miniaudio's own single assignment narrowing, not our warnings — the probe body is warning-free under /W4).
  • A first-hand measurement: miniaudio.h (a 4.2 MB single header) does compile under MSVC — apart from one ma_uint64 → ma_uint32 (C4244) on its own line 80532 it produces no warnings. The audio backend therefore has a real landing path on Windows; the clock was the only missing piece.
  • A boundary that must be stated: after the change audio_stub.c compiles and runs on Windows (the probe is the proof), but moon test ./modules/gui-media/media still cannot run — the test executable links ffmpeg_stub.c as well, and that needs libavformat/avformat.h (the ffmpeg-mbt line is unwired off Linux). So this batch's Windows payoff is a compilable, runnable audio backend, not "the media package now works on Windows". The Linux branch is byte-for-byte untouched, so behaviour is unchanged (this host cannot run Linux — that is a code-level claim, not a measurement).
  • Not done this batch, not pretending otherwise: ffmpeg-mbt's Windows/macOS backends (wiring a system decoder or shipping DLLs — a separate line); the media package therefore still cannot be built or tested as a whole off Linux; the macOS branch (mach_absolute_time) cannot be evidenced on this host and rests on a code-level argument only; audible output and seek feel still belong to _人工测试清单.md.
  • One measured conclusion along the way (so nobody "fixes" the wrong place): the media prebuild's "non-Linux → empty link_configs" is correct — do not add ole32.lib/uuid.lib to it, nor macOS frameworks. ①Windows: taking audio_stub.c (which #includes miniaudio) and linking with no extra libraries at all succeeds (measured: /Fe:probe_nolibs.exe links with zero libraries and still passes all 48 assertions at runtime), because miniaudio defaults to runtime LoadLibrary (MA_NO_RUNTIME_LINKING undefined) and emits no link-time symbols — the earlier probes carrying ole32.lib uuid.lib proved that pair unnecessary, not required. ②macOS: miniaudio's own wording is "the macOS build should compile cleanly without the need to download any dependencies nor link to any libraries or frameworks" — the default is runtime framework linking too (only an explicit -DMA_NO_RUNTIME_LINKING demands -framework CoreFoundation -framework CoreAudio -framework AudioToolbox, and that macro's automatic definition lives in the DOS branch, not the Apple one); the command line needs only -lpthread -lm, both of which macOS pulls in from libSystem by default. So of the three platforms, only Linux needs explicit system libraries (-lpthread -ldl -lm -lstdc++ -latomic).

First example-migration batch: showcase skeleton + basic/feedback pages (G7, 2026-10-12) ​

  • Scope: examples/showcase moves off the old chain (the @yue root package + @declarative + @components) onto the new stack (the Node layer in core + ui + theme); this batch lands the skeleton + the basic-components page + the feedback-components page. The other 13 of the old chain's 15 pages (icon library / form / events & layout / navigation / data display / charts / code & docs / Store comparison / system integration / window / browser / canvas & rich text) were removed from the working tree (git rm; the sources are recoverable from history before 91204ca at any time) and will come in batch by batch, basics first, heavy controls later — the browser page carries a WebKit heavyweight control and the icon-wall page builds 803 icon widgets, so neither is a good place to start.
  • Four mechanical differences (old chain → this stack; recorded once so no page has to rediscover them): ① style=[...] does not exist. New-stack components take explicit parameters only (padding/row_gap/width…); spacing is a property of the layout container, not of the control. The old walls of style=[("marginTop", 8.0)] lines all go away and are expressed by the parent's row_gap. ② Visibility and lazy mounting moved a layer: View::set_visible → Widget::set_visible (internally the safe display:none yoga path) and Node::mount_into(View) → Node::mount_into(Widget); Node.mount's closure also returns a Widget instead of a View. Semantics unchanged. ③ "Fill the remaining height" cannot be written on the control: the old chain wrote style=[("flexgrow", 1.0)]; the new scroll container (scroll_v) takes no grow parameter, so the viewport widget is grown inside the handle callback (node.set_flex_grow(1.0) — handle receives the viewport itself). ④ The nav page is ours to write: nav_page was always an example-local helper (examples/showcase/ui/section.mbt) and this stack has no equivalent either, so it was reimplemented on the same mechanism in shared/section.mbt — set_visible for switching, mount only on first selection (mounting all 15 pages up front is not affordable at startup; that mechanism has to stay).
  • Pitfall 1: the local shared layer must not be called ui/. On the old chain the component library was @components and the example's own shared layer was @ui, so there was no clash; on the new stack the component library is ui, and two packages both deriving to @ui is an outright ambiguity. Fix: examples/showcase/ui/ → examples/showcase/shared/ (deriving to @shared), with the @ui.App/@ui.page/@ui.section call sites updated along the way. Do not paper over it with an explicit alias for the component library in moon.pkg — the example would then read wrong for the long run.
  • Pitfall 2: popover_t's width/height are named parameters. The old call was positional — popover_t(trigger, content, 180.0, 60.0); the new signature is popover_t(trigger, content, width?, height?, handle?), so two positional Doubles fail to compile with 4080 (which requires 2 positional arguments, but is given 4). Whenever a new-stack signature has extra optional parameters, check whether they are named ones — copying old call sites is exactly how this bites.
  • Pitfall 3: the old chain's @yue.VERSION is unusable. It lives in the root package (the old-chain root package, i.e. exactly where the repo's 287 red dots are), and importing it drags that whole red package into the dependency closure. The example keeps a local SHOWCASE_VERSION constant instead, noting that the authoritative value stays moon.mod + yue/version.mbt (whose build-time consistency check is scripts/prebuild.py's job).
  • Two component-layer consolidations (write the new shape; do not hunt for the old function): alert_closeable folded into alert(closable=true, on_close=), and result takes only (title, subtitle, semantic type) — it no longer takes an actions array; the caller lays the actions out alongside ("components give shape, content belongs to the caller" is the general stance of this component layer).
  • One API clarification worth carrying forward: toast_layer on this stack returns the push function only — no "layer node + push function" pair. The toast strip opens on the overlay layer (created on the first push, taken down with the last), so a page no longer keeps an empty tree resident and toasts always sit above dialogs and other overlays. Migrating code should delete the toast_node that the old @components.toast_layer() destructured rather than mounting it.
  • Verification: moon check ./examples/showcase ./examples/showcase/shared ./examples/showcase/pages --deny-warn is warning-free; repo-wide moon check red dots hold at 287/68 (the 14 deleted old pages were already counted there); the real-hardware smoke exits 0 with SHOWCASE-READY title=组件库演示板 pages=2 follow=1 and SHOWCASE-DONE page=基础组件, the window title is right, it responds, and CloseMainWindow exits cleanly. The libpng warning: iCCP: cHRM chunk does not match sRGB line on exit is pre-existing (examples/native-app prints it too) and unrelated to this batch.
  • Not done this batch, not pretending otherwise: the remaining 13 pages; the menubar / tray / single-instance wiring (both dialog_ts are mounted in the window root and openable from the feedback page, while menubar, tray and single instance are handled "as the system-integration / window pages come in"; examples/native-app already demonstrates them); and the real-hardware feel (sidebar hover, scroll position surviving section switches, follow-vs-manual theme switching) which belongs to _人工测试清单.md.

Declarative Layer and Self-Drawn Components ​

  • Never call set_background_color inside mouse callbacks: runtime CSS rewrites swallow the immediately following press ("works on the second click"). Interactive states (hover / pressed) are expressed in on_draw; set_background_color is allowed only at mount time and in theme-subscription callbacks.
  • A flex container's attach order is also its layout order and z-order: interleave widgets (handles / dividers) must attach in exact layout position and stay permanently visible — users find them by looking, not by hovering.
  • markdown_view style-range accounting and mdast measured semantics (MD2, 2026-10): the old hand-written parsers (md_parse_inline / md_parse_blocks) are deleted; yue/markdown.mbt now consumes mizchi/markdown (mdast). ①Rich-text range endpoints must be accumulated per rendered-text segment in UTF-16 code units (md_utf16_len) — never counted in code points, never derived from mdast Spans. MoonBit's for ch in str iterates code points while str.length() counts UTF-16 code units (measured: "a👍b" iterates 3 times, length 4); AttributedText ranges are UTF-16-semantic, so styling after an emoji (non-BMP, 1 code point = 2 code units) shifts wholesale when accumulated in code points. Span-based locating is not viable at all: rendered text ≠ source text (markers stripped, soft breaks turned into spaces, link URLs dropped) — ranges can only be measured over the rendered text. Flattening recurses the inline-nesting tree with an explicit style-carrying stack (md_flatten_inline), merging adjacent same-style segments to cut range count; rendering and tests share one accounting function md_span_ranges (wbtest asserts the bold range after an emoji is [4,6), not the code-point-misaligned [3,5)). ②Three mizchi/markdown 0.8.3 parsing semantics: FencedCode.code carries a trailing \n (line splitting must drop the trailing empty line); parse_code_block_info only understands the lang:file {meta} form and does not split space-separated info strings ("rust title=x" lands whole in lang), so the language must be taken as the first word; blank lines between paragraphs produce BlankLines blocks, so AST traversal cannot assume "block index = document order intuition" (a test tripped on children[2] actually being a blank block). Rendering-surface decisions: HtmlBlock / footnotes not rendered by default, tables degrade to row lines (cells joined by two spaces, bold header), task lists consume ListItem.checked (☑/☐), Blockquote sub-blocks recurse wholly into the accent bar, ordered lists honor start. Verification: moon check zero warnings + moon test 595 all green (19 new wbtests covering conversion / flattening / ranges / info / line splitting, plus the five-item CommonMark-deviation acceptance set before/after: setext headings / ~~~ fences / info strings / indented code blocks / nested lists).
  • markdown_view GFM rendering surface completed (MD3, 2026-10): ①Strikethrough does not ride on a native attribute — the MoonBit layer draws it itself. The upstream AttributedText offers only SetFontFor / SetColorFor (vendor libyue attributed_text.h; the GTK Pango backend has strikethrough but the wrapper never exposes it and the shim has no entry either); a fork patch would require rebuilding the three platforms' prebuilt libraries, outside this batch's boundary. md_rich_label switches from Label to self-drawing at mount (themed_container + draw_attributed_text) and measures each segment independently with the same font (get_bounds_for) to build a segment pixel-coordinate table; strikethrough segments draw their horizontal line from the table (mid-line height, foreground colour), and link hit-testing plus the hover cursor use the same table — the cross-layer contract: "a rich-text line's ranges are UTF-16 endpoints plus pixel-x intervals: style ranges go to AttributedText, hit ranges go to mouse events". In the single-line (wrap=false) case the per-segment width accumulation matches whole-run layout to sub-pixel precision, so click hits naturally tolerate small offsets (under multi-line wrap the table breaks down; this component's rich-text lines never wrap so it is not involved — if wrap arrives later, switch to per-line layout). ②Link clicks: MDSpan carries a url (Link takes it directly; RefLink/RefImage look it up in ParseResult.definitions by exact label match, degrading to a URL-less link style when absent); if an on_mouse_down's view_x lies in [xs, xs+ws) it calls open_url (reusing the system capability — xdg-open on Linux); on_mouse_move switches to the hand cursor and add_tooltip_for_rect registers a per-url address hint (registered once at mount; theme switches do not change the geometry, so no re-mount). ③Footnote two-pass: a prescan of the body (recursing through nested lists / blockquotes / table cells / definition lists) numbers references in order of appearance, skipping FootnoteDefinition itself (references inside definitions are not numbered, keeping end-of-document content from polluting the order); a FootnoteReference flattens to a superscript "[n]" (smaller-size range; inline mixed font sizes in AttributedText work under Pango / CoreText / DirectWrite), and after the main content markdown_view draws a separator plus only the referenced definitions (unreferenced ones stay unrendered, per GFM semantics). ④Images: shown only when a block is "a paragraph that is exactly one image" (inline-mixed images degrade to alt — rich-text lines cannot embed inline images); Image::new_from_file takes local paths / a file:// prefix (stripped); is_empty degrades to alt; there is no network image loading, so any network url degrades to alt; proportional scaling with a 560 width cap (markdown_view's default width; overflow when an outer box is narrower, matching today's single-line text overflow behaviour). ⑤Evenly divided table grid: cell widths use the percentage-string channel ("33.33%", measured usable; precedent: the showcase events page's width "50%"), column alignment goes through the cell container's alignItems (center has precedent; flex-end unmeasured — an unrecognized value is ignored by yoga and falls back to left, no crash surface); cell rich text reuses md_rich_label. Not mixed with table_t — table_t is a Store-driven data panel (fixed column widths / plain-text cells), while in-document tables have an unknown column count and rich-text cells; a self-drawn grid fits better. ⑥Task lists: checkbox_t with an empty title (intrinsic width 30 overridden by a fixed width) plus inline content; the checked state is ListItem.checked; clicking toggles visuals only (the document source never changes). ⑦The showcase demo text and copy string share one source: demo_md_lines() is the single source; rendering takes join("\n") and the copy string derives MoonBit literals line by line (escaping backslashes / double quotes), so what you copy equals what you see on the page. Verification: moon check zero warnings + moon test 600 all green (24 markdown_wbtest cases; new assertions for strikethrough flags / links carrying urls / reference links resolving definitions / missing definitions degrading to literal text / footnote numbering and superscripts / block-level ignoring of definitions / image path conversion / table cell and column-alignment mapping); visual items on real hardware (strikethrough line position / link click and hover / table alignment and grid / image display / task toggling / footnote superscripts and end-of-document list) are for the user to verify on the showcase "code & docs" page's Markdown section.
  • Subscriptions must be unsubscribable, or rebuild-style components accumulate dead subscriptions without bound (CORE2): Store/Signal subscription closures are held by the signal kernel and theme subscriptions by theme_box.subs, with no removal entry — every component that rebuilds rows or remounts a subtree leaks a whole generation per rebuild: transfer leaks one set per move; table_t leaks "row container + one themed_container per cell + bind_fg" per rows Store set (over a thousand per refresh on a 1000-row table); each snackbar push leaks two; each bind_node remount leaks the old subtree's bind_label Store subscription. Dead subscriptions cost little memory, but every set / theme_apply then runs against destroyed views (set_color / schedule_paint on removed widgets), and the closures keep the captured view subtrees alive forever. Fix on both the kernel and usage sides: ①subscriptions carry ids — Signal::subscribe returns a handle and Signal::remove(id) removes it; on Store, subscribe_id(f) takes a handle and remove(handle) removes it (Store::subscribe keeps returning Unit: MoonBit forbids implicitly discarding non-Unit values, so changing its return type would touch a hundred-plus call sites repo-wide). ②Theme subscriptions get the same treatment: on_theme_change returns a handle, off_theme_change(handle) removes it. ③Deep or bulk subscriptions use the subscription bag sub_bag_begin() / sub_bag_end() / sub_bag_discard(closure list): open a bag before a rebuild, and every subscription registered during it (Store + theme, including themed_container / bind_fg / bind_label at any depth of the subtree) pushes its unsubscribe closure into the bag; the old generation is discarded wholesale before rebuilding — transfer's render_all, table_t's rebuild, bind_node's rebuild and the snackbar's dismiss timer all go through it. The bag is a stack (nesting-safe), the same "collection-time global context" pattern as the existing sig_collector in signals.mbt. Verification: moon check zero warnings + moon test 657 all green (8 new: Store unsubscribe and idempotence, Signal handle removal, three bag generations and nesting, theme off and bag, empty-stack bag, theme bag on component rebuild, carousel_stop).
  • Timers started at mount need a reclamation channel; the two timer APIs have different contracts (CORE2): a timer started when a component mounts has no "view was removed" notification, so without reclamation it keeps ticking against a destroyed subtree. The two libyue timer contracts differ: set_timeout returns an id that clear_timeout(id) cancels immediately (self-rescheduling carousel chains use this); set_timer is a periodic callback with no cancellable id — the only way to stop it is returning false from the callback (the shim yue_mbt.cpp's SetTimer does not hand back an id). Hence carousel_t gains a rotation? : CarouselHandle handle plus carousel_stop(handle) (stops auto-advance and cancels the pending timeout; manual arrows / dots are unaffected), while video_view_t's frame-advance timer follows the documented contract — vid_stop(handle) sets running=false so the next tick returns false and stops, and must be called before unmounting (at most one extra tick). Both "must stop before unmounting" contracts are written into the usage docs (components-ui / system-capabilities, zh and en in sync).

Self-drawn canvas and chart rendering ​

  • 8-digit hex colors have the alpha first (#AARRGGBB — a libyue ParseHexColor source-level convention, the opposite of CSS #RRGGBBAA; yue/color.mbt's parse_hex agrees). Appending a tail neither errors nor changes opacity — the two hex digits land in the blue channel, showing up as color drift. Instance one: the floating scrollbar fade changed the alpha string from "d9" to "73", which actually moved blue 0xd9→0x73 with red/green untouched, so the fading thumb turned yellow. Instance two: chart area fill color + "26" — accent #009688 becomes #00968826, parsed as AARRGGBB = alpha 0x00 (fully transparent) + RGB(150,136,38), so the area fill had never rendered at all. Fixed by routing everything through with_alpha(hex, aa) (prepends the alpha); any set_fill_color/set_stroke_color needing opacity must use it — no hand-appended suffixes.
  • An offscreen Canvas::new + get_painter supports all geometry drawing (fill / stroke / arc / clip) without initialize(), so drawing benchmarks run on headless CI too; but the text paths (draw_text / AttributedText::get_bounds_for) segfault outright — the GTK text stack needs initialize first. Benchmarks therefore come in two tiers: "pure-function pipeline + geometry-call mirror" without a display, and a real full-frame run including text annotations only after initialize when DISPLAY is present; both tiers record into charts_wbtest.mbt's output.
  • stroke() under the painter's default stroke color is a no-op (draws nothing): a benchmark that forgets set_stroke_color shows stroke cost as ~0 and passes bogusly. Always set an explicit color before timing strokes.
  • Under software rasterization (GTK/X11, no GPU path) a path stroke costs ~1.8µs per segment, ~13µs for near-vertical segments (slope >30); fill_rect ~1µs each (axis-aligned fast path), draw_text ~0.11ms per call, line_to itself ~12ns (path building only). A 1000-point × 4-series line chart drawn entirely with path strokes costs 45–60ms per frame — far over the 5ms acceptance line.
  • Fix (shared by the whole chart family, see yue/charts.mbt): in dense mode (more points than the plot's pixel columns) decimate per column (min/max preserving extremes) and switch to rect paths — area mode fills one rect per column down to the anchor line (the fill's top edge is the line), line mode draws a min..max vertical bar per column; only sparse mode (points <= columns) uses a true polyline plus polygon area. Per-frame cost is decoupled from window size and grows only with plot width.
  • libyue's Arc cannot express a counterclockwise arc: on GTK PainterGtk::Arc is a plain cairo_arc, and cairo normalizes ea < sa into a "plus 2π clockwise long arc"; the Win public API is hardwired clockwise too (the underlying ArcPixel has an anticlockwise parameter that is never exposed). The shim's "ccw as negative span" conversion therefore never takes effect on either backend — using ccw for a ring's inner arc wraps the hole into the long arc and fills a solid pie (verified by real-machine screenshot: donut and gauge centers were not hollow, with "总计"/percentage painted on the solid face). Fix: approximate the inner return arc with a polyline (32 segments per full circle, chord error <0.4px, identical on all three platforms); for pure strokes (icon arcs) simply swap the start/end angles, which is equivalent. Changing PainterGtk::Arc to cairo_arc_negative in the fork would be the root fix, but that needs a vendor-* release, so it is deferred.
  • Chart acceptance benchmarks (release, Ubuntu 24.04 XFCE X11, offscreen canvas + initialize, real full frame incl. text): line 1000 points × 4 series (adversarial steep sawtooth) 3.08ms / smooth data 1.81ms; bar 200 categories 0.38ms; donut 50 sectors 0.70ms; gauge 0.15ms; scatter 10000 points 3.03ms. Pure-function pipeline (range / ticks / decimation / coordinate mapping) for the line chart: 0.028ms. Long-run push: 4800 pushes (10 min at 2Hz × 4 series) total 2.89ms with the window length pinned at 1000 and never growing — live data is bounded at 4 × 1000 × 8B = 32KB; the increment is GC-reclaimed window-swap garbage, which does not accumulate across continuous pushes.
  • Line area anchor: the zero line when 0 is inside the range, the plot bottom for all-positive data, the plot top for all-negative (a straddling column emits two rects, above and below).
  • Painter::DrawText (canvas draw_text) defaults to wrap=true: long text in a fixed-height row or narrow box gets wrapped by the platform layout engine and overflows the row bounds. Three measured symptom classes: process-table command lines (often 100+ chars) wrapped and bled into the next row; chart y-axis large values ("21414.7") stacked vertically as multi-line garbage overlapping each other; end-value labels of multiple line series overlapping when their last values were close. Fix: the shim gained yue_mbt_painter_draw_text_ex exposing TextAttributes' wrap/ellipsis (pure ABI translation), MoonBit's draw_text gained optional parameters, and table cells now draw uniformly with wrap=false + ellipsis=true single-line truncation (done by the platform layout, so no per-cell measurement); fmt_axis switches to k/M/G units from |v| >= 1e4 (one decimal, trailing zeros trimmed) so labels stay within 5 characters and never trigger wrapping; end labels are now collected, spread by y (14px minimum gap, clamped back into the canvas as a whole), then drawn.
  • Chart / virtual-table responsiveness (the fill switch): no fixed width — the column container's default stretch spreads horizontally and follows window resizes. In the table's fill mode, column geometry is re-laid-out on every paint from the actual width: fixed columns keep their (possibly drag-resized) widths while elastic columns share the remainder (dragging swaps width between the two conservatively, so it never conflicts with the elastic re-layout). Self-drawn row containers that anchor to w - offset already follow their parent's width.
  • Table header decorations (sort arrows, column-boundary lines, hover highlights) must be painted in the header container's (head) on_draw, not in a per-cell container (head_cell): on GTK, libyue Painter path fills (begin_path + fill) do not render at all inside a single header cell (about one column wide × 32px, with a Label child widget) — the code runs, nothing errors, but no pixels appear; the identical code renders fine on the whole-row header container or the table's big canvas container. fill_rect and path stroke render at every container size, so the symptom is easily misread as "wrong coordinates". Reproduce by painting fill_rect / path fill / path stroke side by side in one window. Fix: both table_t and table_v_t keep all header decoration in a single on_draw on the head container, leaving cells with interaction only. Root cause pending fork-level investigation (suspected to relate to the GTK draw region / clipping of small containers).
  • Mouse events at header column boundaries: the pixels just right of a boundary line belong to the next cell, so pressing exactly on a visible boundary lands on the next cell's left edge (triggering a sort instead of a drag). Fix: the drag hot zone recognizes both edges (right 4px → boundary (j, j+1); left 4px → boundary (j-1, j)), with no handle on the last column's right edge.
  • The Painter has no dash API: dashed lines (markLine and the like) are always hand-drawn segment simulations (ci_dash_line, 6px on 4px off), identical on all three platforms and independent of platform dash support.
  • The chart hover overlay is self-drawn rather than reusing base components (the selection rationale): Popover is click-triggered, positioned relative to a view, and a standalone bare window — hover-following would flicker; the native tooltip is a single-line system style, insufficient for multi-line + theming. So the overlay is painted inside the chart container's own on_draw (painted after the chart puts it on top, no z-order issue); ci_tooltip_pos clamps its position inside the canvas; mouse reachability is verified through the same path as bar_chart_t/donut_chart_t.
  • Projection choice for the map and flight lines (geo_map_t, selection and implementation rules): equirectangular — lon [-180,180] maps linearly across the plot width and lat [-90,90] maps linearly vertically (north up); inputs are clamped before projecting and outputs never leave the plot area (geo_project). Mercator and Lambert were not chosen: Mercator's 85° clamp and latitude stretching hurt China's latitude band and extrapolating past latitudes diverges; Lambert's two standard parallels need per-dataset tuning; equirectangular lets every geometry be pinned by single probe-point assertions (all three typical datasets — North America / China / world — verify). The projection rect is the merged bounding box of the region rings and flight-line endpoints, inscaled by its own aspect ratio and centred (geo_fit_rect, shape-preserving) — not a fixed rect: local maps then do not shrink into a corner, and flight ends outside the region stay fully visible; degenerate axes (same lon / same lat) get a 1° minimum span, and non-positive w/h degrade to size 0. Centroids use shoelace signed area (|A| as the axis) plus area-weighted in-ring point averaging (geo_ring_centroid / geo_region_centroid): a ring may be a MultiPolygon of several rings, so the ring with the largest |signed area| is the main ring; concave shapes (Shandong Peninsula / Liaoning) come out steadier than the bbox centre or the first point; the angle-free implementation keeps elliptic-integral approximations from drifting on thin rings. Region fill colours take the theme's primary and nudge lightness ±0.06 by an FNV-1a hash of the region name (geo_name_hash, modulo 13 / 12 normalized) — same name same colour, invariant under theme or zoom changes; table indices are not used, since they move with data order and the light/dark contrast would flicker. Flight-line arc control points are computed in projected canvas coordinates (geo_flight_ctrl takes the from/to midpoint plus a perpendicular offset of bend × endpoint distance), which makes the arcs' visual bulge uniform; computing control points in lon/lat directly distorts high-latitude arcs because a degree of longitude is shorter at high latitudes. Bézier sampling geo_flight_points defaults to 48 segments, first point = from and last = to (inclusive endpoints keep the arrow direction right). Map data is not built in: geojson_parse reuses the hand-written JSON parser from vscode_history (vsc_json, pure MoonBit), accepting only FeatureCollection / Feature / bare Polygon / MultiPolygon; nonconforming geometry (missing coordinates, rings under 3 points, non-numeric coordinates) gives GeoError::BadGeometry; a Feature with null geometry is legitimately skipped.

Layout Geometry (Yoga flexbox) ​

  • Layout assertions pass 16/16 (±1px); composition rules: content area = container − 2×padding; gap does not stack with margin; percentages resolve against the parent content width.

  • Composite widgets (Tab / Scroll / Group) are measure-less leaf nodes in the yoga tree: their outer size must be given explicitly (e.g. flex:1) or they collapse (Tab freezes its minimum size at construction, driving the page area to zero).

  • tabs_t once hard-coded the outer width to 360px and gave content pages no flex: any Scroll / Table placed inside collapsed to zero (measured: the sysmonitor overview page rendered completely blank except the tab strip). Fix: outer drops the fixed width in favor of flex:1 (stretch gets the width in a column parent; fills when the parent has a definite height) and content pages get flex:1 too. When the parent has no definite height flex does not grow, so embedded usage (the showcase section demo) is unchanged.

  • After changing styles at runtime (set_style), calling update_layout on the root container does not refresh the subtree on GTK at all (yoga state has changed, bounds stay put), and calling it on a single node only re-lays-out that node and its siblings under the same parent — other subtrees (e.g. table body rows) are unaffected. A four-group controlled probe (immediately after mount / 500ms later, update root / leaf, width / flexbasis) pinned the only reliable form: call it on every container whose style changed. table_t column dragging used to call it on the root — the handle moved (bounds follow col_w) while column widths never changed (the style took no effect); the splitter happened to have only one wrapper container to restyle, so calling it on outer looked general but sat in the same unexploded pit. Fix: apply_col_widths calls update_layout on both header cells and both cells of every row individually.

  • GUI automation: prefer the keyboard (Tab focus + Space activate); xdotool key --window sends XSendEvent events that GTK drops — use XTEST (no --window); coordinate clicks are unreliable due to WM decoration offsets.

  • The Linux/macOS tabs_t page-switch collapse is cured (native ≥ v0.15.6-mbt.19): the lesion sits in yoga itself interacting with display:none, a three-layer mechanism — ① the flex shorthand (flex:1) resolves in this yoga vintage to grow=1 with a derived basis of 0 (resolveFlexBasisPtr); ② hiding a page goes through display:none, where YGZeroOutLayoutRecursively wipes the layout wholesale with = {}, knocking computedFlexBasis back to undefined; ③ after re-showing, the flex basis computation (Yoga.cpp YGNodeComputeFlexBasisForChild) rewrites the derived basis-0 whenever the resolved value is defined and computedFlexBasis is undefined — and every later round then takes the "already computed" branch and never content-measures again, so under an auto-height owner chain (the scroll content column) with no free space to distribute, the page container stays at zero height for good (probe measured the re-shown page at 1px, outer 65→36 collapsed to head height). Pages never hidden stay healthy only by the coincidence that a content measure once entered the basis cache — not a stable guarantee. The fix (fork patch lb091188/yue 93078300: set a zeroedByDisplayNone flag in the display zero-out pass, and for a flagged node the basis computation takes the content-measure branch that round; explicit flexBasis styles unchanged) heals only the shorthand path; the collapse of an explicit flexbasis 0 is inherent semantics (every round rewrites 0 from the resolved value, the content-measure branch never runs), which is why tabs_t splits by platform — Windows keeps the explicit basis 0 (treating its show/hide mid-state compression), Linux/macOS return to the plain flex shorthand. Regression tool: moon run examples/probe-collapse (four timed switch samples; shorthand pages must hold a steady 22 height). Pit: the CI clang command line carries -Wshadow -Werror (the local -Wall -Wextra combo does not include shadow — patch code all-green locally, all-red on CI; check shadow first), and the fork patch lands in patches/ (the prebuilt workflow runs git -C third_party/yoga apply after Bootstrap); yoga is a submodule and stays out of fork commits.

  • The boundary of the mbt.19 patch: showcase re-collapse and the shorthand ban on outer (closed by next-day real-hardware re-verification): after mbt.19 shipped, real-hardware re-verification of showcase still collapsed — the patch only heals the node itself zeroed by display:none and re-shown; it cannot heal a flex-shorthand ancestor that was never hidden but gets dirty-recalculated by a sibling's visibility flip: tabs_t's own outer also carries the flex:1 shorthand, and once a page switch dirties it, the content-measure round under its auto-height owner (the scroll content column) resolves the derived basis-0 against the available size into 0 (not undefined), which the owner then takes as a zero-height content contribution — outer collapses to its margin height and the whole subtree goes to zero (gauge measured 1px); everything looks fine initially only because the first natural-measure round sees an owner size of nan, where basis-0 resolves back to undefined and the content measure runs. A minimal pure-yoga repro (one availableHeight=undefined round, then a fixed-300 round plus switches) reproduces both shapes faithfully, and printf tracing added in Yoga.cpp captured the three-step collapse chain: outer mainOwnerSize 300→10→0 (10 being the margin height). The fix stays out of native: tabs_t's outer switches to flexgrow + flexshrink written separately — grow/shrink do not participate in the content measure and the basis takes auto semantics (CSS flex:1 1 auto): content height under an auto owner, fill/compress as usual under a definite owner; the sole difference from the shorthand is basis auto. Rule: any container that must live both ways — fill under a definite-height owner, content-size under an auto owner — must not use the flex shorthand; write grow/shrink separately. The shorthand only fits where the owner height is always definite, or where the node itself is the visibility-switch target (protected by the mbt.19 patch). Regression tool extended with moon run examples/probe-collapse2 (full showcase structure repro: sidebar + sibling page toggling + scroll chain + dual-shape tabs; the gauge group must hold a steady 18). Probe-side note: never use button_t as decorative sidebar furniture — during window teardown the GTK focus-out callback can hit destructed resources and intermittently segfault (gdb stack OnFocusOut → Store<Responder>::get), unrelated to the collapse; the probe switched to labels, and the button_t focus-chain lifetime is fixed (yue_mbt_quit disconnects signals uniformly; see the "GTK-related" exit-segfault entry).

  • flexWrap does not fire in an over-wide container: Yoga's default flexShrink is 0 (CSS's is 1). A 19-segment segmented control ran 2400px in one row and was cut off by the window; the inner hbox carried flexWrap yet never broke — wrapping presupposes a constrained container width, but as a child of the outer container its shrink defaults to 0, so it took its full content width and the constraint never reached it. Fix: the inner layer gets an explicit flexShrink 1 + flexWrap wrap + rowGap (row spacing when wrapped); pages with few segments never wrap and look unchanged. General rule: a container expected to "wrap when too wide" must set shrink explicitly whenever it is itself someone's child.

  • Final decision on aligning with web semantics: keep useWebDefaults off, shrink stays default 0, and contraction is opted in with an explicit 1. Yoga deviates from CSS in four kernel defaults (flexShrink 0 / implicit flexBasis 0 / flexDirection column / alignContent flex-start); the official switch YGConfigSetUseWebDefaults aligns all at once (the fork hook is one line at nu's State::State() global config), but it is a global config: yoga layouts inside native widgets flip too, and native components have no wrapper to exempt them one by one — dropped. A subsequent "declarative-layer defaulting" trial (container_node uniformly defaults flexshrink 1, affecting only declarative hbox/vbox) still regressed too broadly in real-hardware testing: fixed-50px self-drawn icon canvases got squashed and deformed by row compression, images scrambled across rows — the declarative layer is equally full of "fixed-width self-drawn control + slack space" layouts, and the benefit (wide wraps without explicit shrink) is far outweighed by the cost (every fixed-width control needs a lock). Reverted; final decision: shrink keeps Yoga's original default 0; containers that must shrink or wrap (segmented and friends) explicitly take 1. Regression evidence: the probe-collapse family's gauges stay steady, the showcase basic/icon-library/chart page screenshots match pre-change, hello-themed/sysmonitor smokes survive. Spot-checking component defaults (control_height 32, font scale 12-18, semantic colour sets) shows they sit close to AntD/Element and desktop GUI convention; no alignment action needed. Methodology: for default-flipping changes, patrol real pages before deciding — the showcase itself is the best regressor.

  • Lazy mounting × the flex shorthand: a new pathology (tabs start at height 0 and recover only after one switch round): after pages/segments moved to "mount on first selection", the Linux branch of tabs_t's content pages (flex 1.0 shorthand) blew up under the new mounting order — in the full-mount era the first natural measure saw owner=nan so the content measure ran and everything looked fine (derived basis-0 resolution mechanics: see the "mbt.19 patch boundary" entry); lazy mounting attaches the tabs only after the parent chain's layout is stable, so the first basis evaluation triggered by the child joining sees a definite owner size, the shorthand's derived basis-0 resolves straight to 0 and enters the "already computed" cache, and the initially visible page was never hidden — outside mbt.19's "zeroed by display and re-shown" protection. Result: the initial page has height 0 until one tab switch (which triggers the re-show protection). Fix: the Linux-branch content pages write grow/shrink separately (basis auto content measure, the same two-mode semantics as outer); the Windows explicit-basis-0 branch is kept. Lesson: mbt.19's re-show protection covers only the "hidden→shown" path; "mounted visible after the layout is stable" is its blind spot, and the ban on the flex shorthand for two-mode containers holds equally for deferred mounting. Regression: the showcase tab section has height from the start and switches normally, probe-collapse2 gauges stay steady, sysmonitor (mount-at-launch scenario) overview is complete. Family-wide sweep: of the 19 flex 1.0 shorthands inside yue, the 4 combining a column main axis with an auto-height parent chain (progress_line, the divider's horizontal branch, the slider's auto-width branch, the carousel panel) must collapse the same way and were uniformly rewritten to grow/shrink; the rest sit on row main axes (definite width) or definite-size mount-at-launch contexts and do not collapse, so they stay. Verification: the progress segment shows its 25% fill on first mount, dividers draw their 1px line, and the carousel panel's height were all screenshot-confirmed. Testing-methodology lesson: when driving verification with xdotool, mind single-instance merging (a new instance wakes the first and exits, so screenshots catch the old-code window) and X window-id reuse — always pair "PID filtered by exe path ↔ wmctrl window", and first-mount-class bugs must be verified on a brand-new instance.

  • yoga-mbt, first batch of the pure-MoonBit flexbox engine (feature/yoga-mbt branch — a separate line from the embedded C++ Yoga): a layout layer pre-positioned for MoonBit-izing libyue, a standalone sub-package (modules/yoga-mbt, zero FFI, no prebuild). The positioning difference first: the earlier decisions in this section ("shrink defaults to 0 / useWebDefaults off") govern the C++ Yoga inside the native widget tree and do not transfer; yoga-mbt, per the user's requirement, aligns only to the single CSS Flexbox L1 standard and defaults entirely to web semantics (row / shrink 1 / basis auto / align stretch / min:auto). Reference material: W3C css-flexbox-1 §9 in full (w3.org/TR/css-flexbox-1; the §9 algorithm steps sit in li#algo-* list items) plus the official Yoga source (github facebook/yoga, yoga/algorithm/CalculateLayout.cpp and FlexLine.cpp; raw.githubusercontent timed out, so git clone --depth 1 --filter=blob:none --sparse git@github.com:... over SSH was used). Three substantive deviations decided by the spec: ① §9.7's freezing algorithm — the spec starts the target size from the flex base, pre-freezes inflexible items (factor 0 / clamped by min-max), and each loop selectively freezes by the sign of the total violation (positive freezes the min-violators, negative the max-violators, zero freezes all); Yoga's own comment admits its two-pass scheme deviates from the spec — this engine follows the spec; ② shrink weighting uses the inner base size (base − own padding/border, per the spec text) while Yoga uses the border-box base; ③ with a single line and a definite container cross size, the line cross size simply takes the container's inner cross size (§9.6), and align-content has no effect on a single line — the earlier implementation that patched with align-content:stretch would misplace non-stretch align_content on single-line containers. Measured pitfalls (pure MoonBit side, moon 0.1.20260904 + moonc 0.10.x): line-continuation operators must sit at the end of the previous line (a leading +/- reports unbound ~+); no +=/-= compound assignment; bare 0..n is illegal — 0..<n; a 1e15 scientific-notation literal fails to parse — spell 1000000000000.0; fold requires the label fold(init=0.0, ...); guard is a reserved word; generics are fn[T] f(...), not fn f[T]; top-level constants use const NAME or lowercase let; a pub priv struct type is invisible outside (a pub fn signature referencing it reports 4046 "public definition cannot depend on private type"), so the need for private fields actually forces pub struct; a mut field "never assigned" is an error (0015), and mutating an inner field through a reference does not count as mutating the outer one; the new moon.pkg DSL does not know warn_list (Unexpected key) — to switch a package-level lint off, keep the old JSON file name moon.pkg.json with {"warn_list": "-6"} (unused_constructor is a false positive for a library whose constructors exist for consumers; this disables it explicitly); test assertions are assert_eq(a, b) without the ! suffix and usable only in a context with an error type (inside test blocks). Verification: moon check zero warnings + moon test -p NoahLiu/yoga-mbt/src 19/19 all green (expected values hand-computed from the spec; the first version's 5 wrong assertions were treating stretch-fill and the min:auto floor as bugs — same behaviour as browsers, so the expectations were fixed, not the code).

  • yoga-mbt absolute positioning (YG2a): the containing block is the parent container's padding box (width/height = border box − both borders, padding included); inset and margin percentages resolve against the containing block's corresponding axis; sizing rule: explicit width/height > both insets on the same axis (stretched = containing block − both insets − both margins, floored at content box 0) > content measure; positioning rule: an inset on the axis wins (the far side takes containing block − right/bottom − margin − size), both-auto on an axis falls to the static position — lay out a phantom item by the parent's justify-content (main axis) / align-items (cross axis), ignoring siblings (space-distributing justify values resolve to 0). One pitfall: sizes from content measure are not additionally clamped to available space (available space reaches the measure function only in AtMost form, same as Yoga) — an initial min(avail) clamp crushed the "auto-size parent + fixed-content leaf" case to 0 (worst when the auto parent's padding box is 0); verification: moon check zero warnings + repo-wide moon test 687/687 (7 new: out-of-flow with left/top, same-axis both-side inset stretch, all-auto static-position centering, right/bottom anchoring, not inflating the container's content size, recursive embedded subtree, percentage inset against the padding box).

  • yoga-mbt baseline alignment (YG2b) + a cross-axis margin placement defect: the API is set_baseline(f : (avail_w, avail_h) -> Option[Double]), and the return value means baseline distance from the content box top (symmetric with set_measure; the engine adds its own padding/border); containers set no callback — baselines are derived by the engine per spec §8.5: if the first line has a baseline-aligned item, take the line's common baseline (max ascent), otherwise the baseline-carrying in-flow item earliest on the cross axis (nested containers propagate theirs upward). The line cross size gets §9.4's front half: collect items whose "inline axis parallel to the main axis + align-self baseline + both cross margins not auto + real baseline", take max(baseline-to-outer-cross-start) + max(baseline-to-outer-cross-end), and max that with the existing "largest outer cross size"; placement per §9.6 "largest ascent sits at the line's cross start" = maxAscent − margin-start − baseline. Three settled points: ① even with a definite container cross size on a single line, the line cross size still takes the container's inner cross size (the §9.6 short-circuit wins) — baselines move items, not lines; ② no baseline alignment when the main axis is column (the spec's collection requires items whose inline axis parallels the main axis; a column container's cross axis is horizontal), placed at the start = browser behaviour; ③ items without a baseline are excluded from the baseline group and placed as flex-start — the spec only says "synthesize from the border box when there is no baseline" without stating which edge (css-align-3 synthesizes the first baseline at the block-start edge; browsers historically used the bottom edge for replaced/empty blocks — a real divergence), so this engine chooses "do not synthesize", matching Yoga's dimbound branch and staying fully compatible with the pre-change "treat Baseline as FlexStart" behaviour; consumers wanting the browser's "image bottom sits on the baseline" can return their own height from the baseline callback. Measured defect (surfaced by this batch's new assertions, same origin as the baseline math): cross-axis margins never participated in child placement — pos_cross was just "line offset + align offset" while free_c had already used margin-inclusive outer sizes, so a margin_top: 30 item stuck to the line start (measured 0 != 30) and align-items: flex-end + margin_top: 20 swallowed the margin whole (measured 130 != 150); the main axis was fine (pos_main accumulated m.start). Fix: placement is now "line offset + align offset + margin start" (auto margins resolve to 0, so the auto branch is unaffected). One more open item surfaced: when a container's auto main size is content-derived, content_main_sum sums only children and misses the container's own padding/border, and the multi-line branch hardcodes it to 0 (a wrap container's auto main size collapses to 0) — deferred to the next batch (YG2b′, numeric case and fix in that entry); this batch holds the corresponding assertion out. Verification: moon check zero warnings + moon test -p NoahLiu/yoga-mbt/src 36/36 + repo-wide moon test 697/697 (10 new: cross-axis margin placement and stretch, margin with flex-end, two items baseline-aligned, baselines determine line cross size (indefinite container), stretch items excluded from baseline grouping, baseline-less items placed flex-start, margin-top counted in ascent, multi-line baselines scoped per line, nested-container baseline propagation, column containers skip baseline alignment); reference: W3C css-flexbox-1 §9.4 (algo-cross-line) / §9.6 (algo-cross-align) / §8.5 (Flex Container Baselines).

  • yoga-mbt container auto size missing its own padding/border + multi-line main size collapsing to 0 (YG2b′): during YG2b a probe measured (parent padding-top:10 / padding-left:10 + one 100×50 child, container without width/height) auto size 100×50 in both row and column directions; browsers give 110×60. Root cause: the "own size" step used content_main_sum (children's size_main + margin only) and own_cross_content (line cross sizes only) directly as the container's border-box size, though width/height semantics are explicitly border-box — its own padding/border was never added; child placement was always right (layout_left = own_border.left + own_pad.left + x), so the symptom is "size one notch short, children correctly placed", easily misread as a broken measure function. The second hole sits in the same place: the multi-line branch hardcoded content_main_sum to 0.0, so a wrap container without an explicit main size collapses that whole axis to 0. Fix: the main axis takes the max over lines of each line's outer size (size_main + both margins + that line's column/row gap), the cross axis sums the line cross sizes, and both then get pad_border_main / pad_border_cross added; the nlines == 1 branch stays equivalent to the old value (an auto-main container never joins flex resolution, so size_main ≡ hyp_main and switching to "max per line" changes nothing for single lines). Verification: moon check zero warnings + moon test -p NoahLiu/yoga-mbt/src 40/40 + repo-wide moon test 701/701 (4 new: row-container auto size includes padding, column-container same, border-inclusive 47×23, wrap container "main = widest line + cross = summed line heights" = 90×50 with the two rows' children at top 10/30; and the nested-container b.height == 35 assertion pulled out during YG2b is reinstated).

  • yoga-mbt aspect-ratio (YG2c) — ratio semantics settled as border-box: the user's rule is "ratio = border-box width / border-box height", consistent with the engine's existing width/height = border-box and differing from CSS's default content-box ratio (noted in the README; no padding subtraction anywhere in the conversion). Four hook points: ① §9.2 flex base — item with a ratio + content basis + definite cross axis → main basis = cross axis converted through the ratio (a column container divides instead); ② §4.5 auto minimum size — main-axis min:auto takes the smaller of "content size" and "transferred size suggestion", otherwise the ratio is pushed back by the content floor (measured: content width 200 + height 10 + ratio 2 → should be 20; writing min:auto = content yields 200); ③ §9.4 hypothetical cross size — once the main size is settled (including the §9.7 flex-resolution result), cross = main converted through the ratio, then clamped by min/max; ④ the container's own size — when one axis is definite (explicitly or dictated by the parent) and the other auto, the converted axis counts as a definite size in all later computation (written into inner_main / inner_cross), so wrap limits, flex resolution, line cross sizes and stretch all run on it; skipping that step gave the wrong result "a width:320 + aspect-ratio:2 container collapses to content height 50 and its children stretch to 50 too". One straight-from-spec behaviour worth stating: align-items: stretch (this engine's default) overrides the ratio's cross-axis conversion — §9.4 algo-stretch keys on "the computed cross size property is auto" and footnotes "this step does not affect the item's main size, even when it has a preferred aspect ratio", so ratio items in a stretch container take the line cross size; keeping the ratio needs non-stretch alignment or an explicit cross size (same as browsers; covered by unit tests). Absolutely positioned children deliberately skip ratio conversion this batch (that path still sizes only by explicit dimensions / same-axis both-side insets / content), recorded in the README's not-implemented list. Verification: moon check zero warnings + moon test -p NoahLiu/yoga-mbt/src 47/47 + repo-wide moon test 708/708 (7 new: width→height, height→width, column container height→width, container self-sizing by ratio with children filling, stretch overriding ratio (and restoring after changing align_self), ratio feeding the min:auto ceiling, non-positive ratio treated as unset); reference: W3C css-flexbox-1 §9.2 / §4.5 / §9.4 and css-sizing-4's preferred aspect ratio.

  • yoga-mbt RTL (direction, YG2d) + two pre-existing defects in flip placement: direction semantics are settled as per-node, without CSS's direction inheritance — the engine's layout_into only receives the parent's available size and has no parent-chain style context, while on the libyue side themes/controls know which container to set rtl on; the inheritance chain is left to the wrapper layer. The implementation touches only the "resolution and mapping" layer: two derived flags, rev_main = is_reverse(flex_direction) != (rtl && the main axis is a row) (a row container flips once for direction and once for row-reverse; a column container's main axis is the block axis, unaffected by direction) and rev_cross = rtl && the main axis is a column (flip only when the cross axis is the inline axis); axis_margin gains a cross_reverse parameter deciding whether the cross start reads margin-left or margin-right — the physical edges are always margin-left/right; only the meaning of "start/end" rotates with direction — and auto-margin allocation follows the edge it belongs to (Yoga's same semantics). A row container's cross axis is the block axis, so baseline grouping, align-items vertical placement and rtl are fully independent; an rtl column container, with pos_cross (including the line offset) flipped whole after wrapping, lands the first column on the right and the next to its left (proven by unit tests). This batch's RTL flip reference surfaced two pre-existing defects: ① row-reverse / column-reverse computed negative offsets under an auto main container size — the flip reference used inner_main_dim.unwrap_or(0.0), which is None when the container's main size is content-derived, so a 100-wide item landed at -100 and the second at -200 (measured by probe); fixed to "prefer the container's definite inner main size, else the content-derived maximum line main size (line_main_max, incidentally hoisted from the own-size step to before the placement step)"; ② an all-auto abspos item's static position always landed left/top — YG2a's static position computed only justify/align offsets and never followed the main-axis flip; in a column-reverse container it should hug the bottom but hugged the top; fixed so the static position's start follows its axis's flip flag (margins likewise take the owning start's physical edge). Verification: moon check zero warnings + moon test -p NoahLiu/yoga-mbt/src 60/60 + repo-wide moon test 721/721 (13 new: 3 LTR snapshots locking the flip-reference and static-position behaviour + 10 rtl: row container starts at the right, rtl×row-reverse returns to left, column container cross start on the right, start takes the right margin, justify flex-end landing at the physical left, physical margins participating in the main axis, margin auto absorption, abspos static position hugging right in row/column containers, rtl column-container wrap order); the 47 pre-existing LTR assertions keep every value — the direction layer regressed nothing. Reference: W3C css-flexbox-1's definition that "the main-start is decided jointly by direction and flex-direction" (the flex-direction vs Writing-Modes inline/block-axis relation) + css-position-3's static position.

  • yoga-mbt abspos children consuming aspect-ratio (YG2e, closing YG2's standard surface): the abspos sizing priority was "explicit width/height > same-axis both-side inset stretch > content measure"; the ratio conversion inserts one step in between — first resolve "the axis that is known" by the old rules (explicit, or stretched by left/right or top/bottom insets), then, when the other axis stays auto, convert it through the ratio with the item's own padding/border as the floor, and clamp by min/max; when both axes are auto it still content-measures (same as in-flow items — a ratio conjures no size from nothing). All-auto side placement keeps YG2d's direction-flip flags, untouched by this batch. Verification: moon check zero warnings + moon test -p NoahLiu/yoga-mbt/src 63/63 + repo-wide moon test 724/724 (3 new: width→height, height→width, both-side-inset stretch width then ratio height).

  • yoga-mbt enums not constructible across packages (E4036) + the performance baseline (YG3a, first engineering batch): a blocking pit first — in this toolchain (moon 0.1.20260920 / moonc 0.10.14) a pub enum exposes only the type and match-readability outside the package; constructing variants is read-only outside: @lib.Green or @lib.Color::Red in an external package reports E4036 Cannot create values of the read-only type; pub(readonly) enum syntax is unsupported and a per-variant pub reports "No individual visibility for enum constructor". The correct form is pub(all) enum (basis: the official E4036/E0006 explanations in moon check --explain plus a two-package minimal repro). For yoga-mbt this means: the engine's nine enums — FlexDirection / AlignItems / AlignContent / JustifyContent / FlexWrap / PositionType / Display / Direction / Value — are all values consumers feed into setters, and under the old form an external package could not set a single style; unit tests never exposed it (tests share the package) — it exploded only when the new external bench package ran moon check, a guaranteed stumble before YG4 integration. Fix: all nine become pub(all) enum; Value's existing fixed/percent/auto/undefined helpers stay (both spellings work for consumers). The same batch established the performance baseline, tool modules/yoga-mbt/bench (moon run bench / moon run --release bench; tree = root + 20 groups × (1 group head + 10 rows) × 5 nodes per row = 1121 nodes, rows with align-items: baseline + grow + gap + padding, leaves with measure, text with baseline callbacks; set rounds to 0 for the "process + tree build" baseline): measured here on Ubuntu 24.04 x86_64 — 500 rounds of calculate_layout: debug 4.57s / release 2.21s; the 0-round baseline ≈ 0.106s, so per round debug ≈ 8.9ms (8.0µs/node), release ≈ 4.2ms (3.8µs/node). The next optimization target is pointed at by the code structure itself: the same node goes through layout_into twice, once in "base measure" and once in "cross re-measure"; Yoga solves it with a 16-slot measurement cache — yoga-mbt's cache shape and invalidation policy are settled in the next entry (YG3b). Verification: moon check zero warnings + moon test -p NoahLiu/yoga-mbt/src 63/63 + repo-wide moon test 724/724, and with the bench package participating in moon check as an external consumer, "enums constructible across packages" now has a standing regression.

  • yoga-mbt measurement cache (YG3b): measure_content runs twice per layout for the same node (§9.2 base measure, §9.4 cross re-measure), and containers repeat further through the parent chain — the main redundancy in the baseline. Shape: the cache hangs on Node (measure_cache : Array[((avail_w, avail_h), (w, h, baseline))]; keys and values use only tuples and base types — a field of pub struct Node must not reference a priv type or E4046 "public definition cannot depend on private type" recurs), and a hit returns the baseline together with the size (the baseline is the intermediate a container propagates up for §9.4 grouping, same source as the size — omitting it would let rtl/baseline scenarios read a stale value). Invalidation settled: the cache is valid within a single calculate_layout; the entry point clears the whole tree in one pre-order pass — this engine has no dirty flags and consumers may freely change styles between two layouts (measured: padding-left 10→50, baseline 10→18); Yoga's "version/dirty" scheme would introduce a change-notification surface, while one O(n) pass is negligible next to a single layout, traded for zero invalidation holes. Payoff (same machine, same bench tree, 500 rounds): debug per round 8.9ms → 5.1ms, release 4.2ms → 2.6ms (1121 nodes) — about 1.7×; the remaining cost sits mainly in "the final placement round with pinned sizes" — that round must really run (child coordinates land); going further is YG4's dirty-region incremental relayout, not the engineering batch. Verification: moon check zero warnings + moon test -p NoahLiu/yoga-mbt/src 65/65 + repo-wide moon test 726/726 (2 new invalidation regressions: re-layout after a padding change does not take the old cache; re-layout after a baseline change likewise; zero regression across the existing 63 assertions means the cache does not alter layout results).

  • yoga-mbt pixel-grid rounding (YG3c): a root-node switch set_pixel_grid(interval) (0.0 / negative disables; effective only when the node is the layout root); calculate_layout appends a post-pass that lands left/top/width/height on the grid; with the switch off every fractional value is preserved and the 65 existing assertions must not regress. Rounding is settled as snap by absolute edges, then derive relative coordinates: when adjacent items share a physical edge, both read the same absolute coordinate, so the snap agrees and a 100/3 split gives 33 / 34 / 33 (the sum equals the total width and no 1px seam is left between neighbours) — snapping "relative coordinate + own size" per item would record 33 thrice and lose 1px. One pitfall: when recursing the parent's start down, passing only "the snapped parent start" is not enough — you must also pass the parent's pre-snap absolute start: a child's layout_left is relative to the parent's raw coordinates, and reconstructing the child's absolute edges from the snapped parent start charges the parent's own half-grid error to the child — measured: the middle container's absolute start 33.33→33 with two children of raw width 16.67, so the second child's absolute start became 33 + 16.67 = 49.67, absolute end 66.33, rounding to 50/66 → width 16 (should be 17; the 16 != 17 assertion exploded it). After the fix the parameters split into raw_* (reconstructing the children's true absolute edges) and snap_* (deriving the children's relative coordinates); error appears only on each edge itself and is never transferred from the parent. Not done: Yoga's parent-end snap-back compensation (pixelGridSnapError and friends) — when a container has trailing padding/border, an item snapped to the inner edge's end may finish within one grid unit of the parent's inner edge; recorded in the README as a known limitation, to be added if visible seams appear on real hardware. Verification: moon check zero warnings + moon test -p NoahLiu/yoga-mbt/src 69/69 + repo-wide moon test 730/730 (4 new: whole-pixel split 33/34/33, half-pixel grid 33.5/33/33.5, no grid keeps 100.0/3.0, nested items compensated by absolute coordinates mid.left 33 / width 34 and inner 0/17 + 17/17).

  • yoga-mbt intrinsic sizes min/max/fit-content (YG3d, closing YG3): sources first — both Yoga and the spec were checked, nothing from memory. Spec side: css-flexbox-1 §9.9 "Intrinsic Sizes" is three sentences and delegates the definitions wholesale to css-sizing-3 (§2.1 auto box sizes gives the four sizes stretch-fit / max-content / min-content / fit-content; §5.2 intrinsic contributions says "a contribution is the content-box size of an auto float containing only that item in a zero-width / infinite-width containing block" and leaves the concrete algorithm to the spec of the corresponding display); a flex container's own intrinsic-sizing rules are not in L1 (checked both w3.org/TR and drafts — collected … contribution appears in L1 only in §9.3 line collection), only in the css-flexbox-2 draft (which even splits ideal / web-compatible at §9.10.1). So this engine implements the css-sizing-3 general rules as a single algorithm, not L2's two (recorded in the README). Yoga side (checked against an SSH sparse clone of facebook/yoga's yoga/algorithm+yoga/style+yoga/enums): ① Unit has only MaxContent / FitContent / Stretch — no min-content keyword, and the SizingMode comment itself admits "Missing min-content"; ② min-content exists only as a §4.5 internal quantity — computeMinContentMainSize calls a leaf's measure function with "requested axis AtMost 0 + the other axis Undefined" (text returns the longest word), and for containers recurses directly, looking only at the container's own main axis (children's main summed, cross takes the max), ignoring flex-wrap and ignoring gap; ③ under MaxContent sizing mode the measure function's available space is rewritten to YGUndefined. This engine takes "Yoga's probing conventions + the spec's composition rules": leaf probing keeps Some(0.0) / None (compatible with the existing set_measure signature, no API change), container composition is wrap-aware (a wrap container's min-content main size = the widest single item, then a greedy line-packing at that width for the cross contribution; nowrap sums the items). Two semantic corrections: ① §4.5 min:auto's content size suggestion was approximated by "the measured value at the current available space" → now true min-content; ② §9.2 flex-basis: auto with an auto main size used the same approximation → now max-content (otherwise a wrapped item's basis drifts with available space and shrink never sees the overflow; measured: a 100-wide container holding a wrap sub-container (items 60/50/40) — before: basis 90, no shrink; after: basis 150, shrinking to the available 100; at 55 available the min:auto=60 floor holds). New values min_content() / max_content() / fit_content() can enter width / height / min-* / max-* / flex-basis, and abspos items consume them too (one funnel at axis_size). Two stumbles: ① intrinsic probing must not refresh the node's baseline — the first version's leaf probe went through measure_content, and the unconstrained probe call overwrote the real layout's Node.baseline, so baseline grouping read a "single-line baseline"; fixed with a dedicated probe path calling the measure function directly. ② the hot path pays for cache-key allocation — measure_content built its key tuple before the linear compare, and the new intrinsic recursion raised per-node probes from 2 to 4–6, pushing per-round release from 1.30s (500 rounds) to 2.54s; bled down step by step (measured release 500 rounds: 2.54 → 2.07 by having containers compute only the main axis, skipping cross contributions → 1.93 by comparing entries one by one and building keys only at write time → 1.81 by bypassing measure_content for leaf probes plus a short-circuit "skip the corresponding intrinsic probe when an explicit size already decides that axis" — values that would be probed then discarded are no longer probed). Bench conventions (timing the built executable directly, 0-round baseline 0.106s — careful comparing with YG3a/YG3b's moon run numbers, not the same method; this batch's A/B is same-machine same-method): per round debug 4.9→7.3ms, release 2.4→3.4ms (1121 nodes) — real intrinsic sizes cost about +40%; further recovery needs "cache persistence across layouts + dirty-region incremental relayout", which belongs to the integration layer (YG4), not this batch. Verification: moon check zero warnings + moon test -p NoahLiu/yoga-mbt/src 79/79 + repo-wide moon test 740/740 (10 new: max-content not squeezed by available space, min-content = longest word, fit-content three-way clamp, min-width: max-content holding shrink, flex-basis: max-content joining grow, column container min/max-content width = widest child + padding, wrap-container basis max-content with the min-content floor, nowrap container min-content summing children, abspos item's max-content, intrinsic cache not reused after a child change; zero regression across the existing 69 assertions). Reference: css-flexbox-1 §9.9 / §4.5, css-sizing-3 §2.1 / §5.2, and facebook/yoga's yoga/algorithm/CalculateLayout.cpp (computeMinContentMainSize / measureNodeWithMeasureFunc) with yoga/style/StyleSizeLength.h.

MoonBit native GUI stack (G0 IME probe) ​

  • Environment: Ubuntu 24.04 + X11 (XDG_SESSION_TYPE=x11) + XFCE, moon 0.1.20260920; system libraries gtk+-3.0 3.24.41 / pangocairo 1.52.1 / cairo 1.18.0 / x11 1.8.7; the fcitx5 and ibus daemons are both installed, GTK_IM_MODULE=fcitx. The probe lives in experiment/ime_probe, two modes in one binary: mode B = a pure GtkIMContext channel (text state kept once, in MoonBit); mode C = a visible GtkEntry + CSS theming. Four build-chain pitfalls: ① moon's link_configs carries link-time fields only — adding c_flags to an entry is silently ignored, native-stub compiles without system include paths, and #include <gtk/gtk.h> is a fatal error; fix: mirror the shim — scripts/prebuild.py's build_ime_probe_stub() compiles the stub with pkg-config --cflags into build/libime_probe_stub.a, passed only through link_flags. ② the archive must follow ld naming as libime_probe_stub.a, or -lime_probe_stub will not find it. ③ moon does not relink when a native static library changes: after editing the stub, moon run still launches the old exe (the SIGSEGV scene is identical) — the same "delete the artifacts after changing the shim" pit, reproduced here on the -l static-library path; delete build/libime_probe_stub.a and the package's exe before rerunning. ④ CI runs moon build --target native with full-package linking on all three platforms, so a Linux-only probe breaks ip_* symbols on Windows/macOS; fix: add ime_probe_stub_portable.c, giving every entry an empty implementation under #if !defined(__linux__) (ip_init returns 0 and the MoonBit side prints "no probe on this platform" then exits); this file goes through moon's own native-stub and is wholly empty on Linux. Our own bug: ip_report_im_module called G_OBJECT_TYPE_NAME on pr.im == NULL in mode C → SIGSEGV (exit code 139); mode C has no self-built context at all (the IM module hangs off the GtkEntry) — a smoke run of mode B alone never shows it, confirming again that "a branch counts as verified only once it really fired". Three measured findings (input for G5): ① g_main_context_iteration(NULL, TRUE) stepping singly drives GTK windows and signals — no gtk_main needed, the main loop stays wholly in MoonBit, and both Esc and the 1500ms auto-exit quit cleanly with no teardown crash; ② GtkEntry's size negotiation does push back — offered a 22px box, the theme default grows the widget to 32~33px (requested_h=22 allocated_h=33); after min-height: 0px in CSS it lands at 24px (22 + 1px borders top and bottom), i.e. "can be squeezed, but will not honor the requested value"; ③ the Pango readings are directly usable — 14px measured line height 17px, prefix width 59px, caret rectangle 83,22 1x17 pushed to the IM channel (the input for candidate-window placement). Pending real-hardware items: commit / inline preedit / candidate window hugging / shortcut pass-through / whether Chinese can actually be typed — run per the experiment/ime_probe/README.md checklist under both fcitx5 and ibus GTK_IM_MODULE settings; conclusions land back in this section. Verification: moon check zero warnings + repo-wide moon test 740/740 + moon build --target native 68 tasks all pass + local two-mode smoke processes alive and exiting cleanly.

MoonBit ↔ C ABI ​

  • Trampolines must match the C function pointer prototype bit-for-bit, including arity: C calls callback(closure, args...) and the trampoline's first parameter receives the closure. Misalignment hides itself — row counts look right and some callbacks fire; a callback is verified only once it has actually fired.
  • extern "c" returning nullable types: older toolchains segfault outright (report success/failure via a Ref[Int] out-parameter); on moon 0.1.20260904 + moonc v0.10.12, -> Bytes? works — a C-side NULL maps to None correctly, verified in both debug and release against real missing-file and directory (EISDIR) paths (sysmonitor's read_text_file). Other nullable types (handles etc.) are untested; the out-parameter pattern remains the fallback.
  • FFI pointer parameters need #borrow (compiler-enforced); widget parameters take the handle type View, never the MoonBit wrapper struct (otherwise handles are invalid at runtime and silently dropped).
  • Closures across the ABI: capture-free top-level function literals compile to real C function pointers; capturing closures use the "function pointer + closure pointer" two-parameter form.
  • Callback closures are kept alive process-wide by a registry and not reclaimed per window (negligible leak for single-window tools — settled).
  • Toolbar / Vibrant have no Linux static-library symbols (the link would fail) and are not exposed; the Browser empty-cookie-list crash is fixed since fork mbt.7.
  • Changing a shim signature requires the same batch in .cpp / yue_mbt.h / ffi.mbt; both a missed signature sync and an entirely missed declaration manifest as a mangle split (_Z-prefixed symbol vs plain-C reference) — locate with nm, and run a ".cpp definitions × header declarations" cross-scan before packaging.
  • A standalone translation unit (e.g. yue_accent_mac.mm) that does not include yue_mbt.h must mark its function definitions extern "C" explicitly: without it they export C++-mangled (__Z25yue_mbt_system_accent_macv) while yue_mbt.cpp references the plain-C name from the header — link-time undefined. Only macOS exposes this (Linux/Windows never compile the .mm), and neither does the vendor/CI prepare stage (a static archive never checks undefined) — only a real macOS link (moon test/build) hits it; with no local mac, CI is the only defense line. Actually fixed ahead of the 0.5.0 release (symbol re-verified via strings on the re-vendored archive as _yue_mbt_system_accent_mac).
  • The XFCE panel prioritizes IconPixmap over IconName: set_icon_name and set_pixmap are mutually exclusive — setting one must clear the other.
  • Custom-protocol reject encoding and C-side payload pre-validation (CORE1, high severity): the None reject path of Browser::register_protocol used to encode [1,0,0,0] — the C side read the first 4 little-endian bytes as ok=1 (success) and kept reading mime_len/content_len past the end of the 4-byte buffer (native heap over-read), then built std::string(p+8, mime_len) from a garbage length: undefined behavior. And the reject never actually took effect (libyue's WebKitGTK side rejects a request only when the handler returns nullptr, via finish_error — see OnProtocolRequest in the yue fork's nativeui/gtk/browser_gtk.cc). Fix: the MoonBit reject payload is now [0,0,0,0] (ok=0, so C returns nullptr without reading further bytes), with the encoding extracted into the in-package pure function encode_protocol_payload for white-box tests; the C side pre-validates against the MoonBit Bytes length before decoding segment by segment — ok==0 or a payload shorter than 12 bytes is rejected, as are negative or out-of-payload mime_len/content_len (length comparisons go through int64 to avoid 8+mime_len+4 overflow), and a nullptr payload is also defensively rejected. The Bytes length is the 4 bytes preceding the data pointer (the moonbit_object header's meta, which is the length for VAL_ARRAY; layout follows moonbit.h's Moonbit_array_length — moonbit.h itself is not included because its extern "C" memcpy declaration conflicts with glibc), and that assumption was empirically verified with a minimal C probe linking libmoonbitrun.o + the libruntime archive directly (make_bytes(23/4/0) all read back the correct length). Lesson: for a self-encoded ABI payload with length fields, the C side must be able to obtain the total length itself and pre-validate first — never trust fields after the peer "claims success"; cross-ABI contract tests must mirror the C decode logic (length pre-check, then segment reads) rather than only asserting the encoder's shape. Verification: moon check with zero warnings + moon test 636 all green (new yue/browser/browser_wbtest.mbt with 8 cases: None payload byte-exact as [0,0,0,0]; Some byte-exact across ok/mime_len/mime/content_len/content; UTF-8 multi-byte lengths counted in bytes; the 12-byte minimal payload for empty mime/content; the legacy [1,0,0,0] shape and an 8-byte ok=1 short payload both rejected; out-of-payload mime_len/content_len rejected; round-trip with long content containing quotes and newlines); the shim change went through prebuild's incremental rebuild, and after deleting the showcase exe under _build, moon build examples/showcase relinks with zero warnings (nm confirms the exe references yue_mbt_browser_register_protocol); showcase gains a demo-deny reject-path button and code snippet — the real-hardware load-failure behavior is for the user to confirm under Linux WebKitGTK (Windows WebView2 silently ignores custom protocols anyway).

Unified style value model (StyVal trait) and MoonBit trait object limits (0.5.2) ​

  • Unified style value model: the open trait StyVal (yue/style.mbt) implements apply_to(Self, View, String) for the builtins Double/String; the mixed-typed array Array[(String, &StyVal)] takes pixel and string values in one parameter, dispatching internally to the two C channels ffi_view_set_style_prop_float/_str (the C ABI has no overloads; the split is absorbed inside yue). Where the expected type is known, array literals auto-box (also propagated inside tuples), so existing numeric-only call sites migrate unchanged.
  • trait-object impl limits for builtin types (moonc v0.10.12 measured, /tmp probes): ① one impl block may define exactly one method — multi-method impl T for X with fn a.. fn b.., across lines or on one line, does not exist; ② an impl block must define every trait method at once — a two-method trait with a type implementing only one reports "does not implement trait, although an impl is defined: method X is missing", and &Trait references then report "trait not found"; ③ "declare = _ + package-level default impl + per-type override block" does not work for builtin types (for struct types it is the documented form — see the J trait example in moonbit-docs); even writing both methods in the override block fails the check; ④ conclusion: implementing your own trait for builtin types needs one impl block per type defining all methods (core's Show/Hash take the same shape); therefore StyVal can only be single-method and "reading the value back from the trait object" (an as_num channel) is not viable.
  • Version-sensitive APIs of the core library: StringView::exact_view does not exist in moon 0.1.20260904's core (no method), while on 0.1.20260920 it exists and sub in turn is flagged deprecated — such APIs exist mutually exclusively as nightly core evolves; follow the current nightly's canonical names, and on version switches take moon check output as the standard.
  • CI-failure investigation path and nightly traits (measured): when CI is red across all three platforms, the anonymous API cannot fetch logs (the job logs endpoint 403s), but GET /repos/:repo/check-runs/:job_id/annotations is readable; when the annotations are empty and only "Process completed with exit code 1" shows, walk the job's steps list (.../actions/runs/:id/jobs) to find the red step. moon nightly rolls extremely fast (0.1.20260918 404s within days; 0.1.20260920 was 403/delisted the same evening), so a version-pinned install-script download gets swept by the rolling delisting — CI must not pin a nightly version number, only follow the latest alias.
  • bind_node remount breaks Windows mouse hit-testing at runtime (real hardware, three-version evolution conclusion): bind_node removes+adds the subtree when the signal changes; on Windows, once a click chain triggers a remount, every later click in the whole window is routed to the remounted widget (probe v3 event flow: clicking row 1 / blank space both fire row 2's callback, and the root container receives nothing at all), and deferring the remount by one tick does not dodge it either — the harmful part is remove+add itself, not synchronous stack timing. Hit-testing goes through ContainerImpl::FindChildFromPoint's GetClippedRect (= size_allocation_ layout rect); after remove, the newly added subtree's value abnormally expands; the libyue-level root cause awaits a fork fix. Workaround: scenarios whose click chain remounts its own landing point switch to "prebuild both states + set_visible toggling" — visibility goes through the is_visible_ flag + display:none (the fork's yoga-display-none-flex-basis.patch maintains it specifically), no view destroyed or rebuilt; verified by probe row 5; bind_node's doc gained the warning. Linux's GTK layout sync does not have this problem, and bind_node runs normally there.
  • swap_node: the Windows-safe alternative to bind_node (0.5.2, verified on real hardware): for two-state switching (icon button swap / whole-view swap), swap_node(sig, a, b) prebuilds two node trees and the signal chooses visibility — no view destroyed or rebuilt, zero hit-test risk; the showcase light/dark switch moved to it and Windows real-hardware multi-round switching plus later operations all behave (with the bind_node version every click in the whole window misfired and even the title bar could not close it). Cost: both subtrees stay resident (negligible for low-frequency scenarios). Once the fork root-fix lands, bind_node returns to all platforms and swap_node stays (zero-rebuild, low overhead).
  • theme_apply's Windows branch must not be deferred (lesson archived): following the hypothesis "a synchronous repaint inside a click stack disturbs message state", it was once switched to the Linux-style post_task/post_delayed_task deferral — the black-box test (components_wbtest calling theme_apply) then crashed at process exit when the 60ms delayed task ran EnumWindows/RedrawWindow against a destroyed window, failing Windows CI with exit -1 twice in a row; and the hypothesis itself was already disproved by the probe (the real culprit is bind_node remounting). Windows keeps synchronous ffi_repaint_all; Linux keeps its original two-tick deferral.
  • bind_node remount breaks Windows mouse hit-testing at runtime (real hardware): bind_node's signal change goes through remove+add remount; on Windows the yoga root is not recomputed (native v0.15.6-mbt.19 only repairs the zeroed node itself, not tree-shape changes), so the new subtree's bounds stay dirty and hit-testing always lands on it — symptom: "after clicking the light/dark switch, every click in the window triggers the theme switch and all other operations are dead"; the keyboard is unaffected because it follows focus. Fix: bind_node's rebuild tail calls refresh_layout on the mounting container under Windows (whole-window relayout + root repaint, the same fallback as tabs page switching). Linux's GTK size request automatically covers the new subtree, so no such step is needed. Same family: the toast layer dynamically removing entries is pure deletion (within mbt.19's repair surface) — currently fine.
  • After moving the repository wholesale, the CMake cache refuses to configure (real hardware): CMakeCache.txt under build/ records the configure-time absolute directory; after the repo is copied from the old path to a new one, cmake reports "current CMakeCache.txt directory is different than..." and refuses to configure, aborting prepare.py. Fix: before cmake_build, prepare detects a CMakeCache-recorded CMAKE_CACHEFILE_DIR/CMAKE_HOME_DIRECTORY that differs from the current path and clears build/ to reconfigure (shim rebuilt along the way) — no manual directory deletion needed.
  • Windows usernames with apostrophes/spaces cause LNK1104 (real hardware): on Windows, moon assembles the cl/link command line with broken quoting for absolute paths containing a single quote — library paths under C:\Users\noah'liu\... lose the apostrophe, and the two space-separated paths yue_mbt.lib and yue_prebuilt.lib fuse into one link input, giving LNK1104 "cannot open file …lib …lib"; prepare.py/cmake are unaffected (paths pass through verbatim) — only moon's link_flags concatenation breaks. Fix: the prebuild Windows branch issues library/manifest paths relative to the module root (build/yue_mbt.lib, lib/windows-x64/…); the moon link subprocess's cwd is the module root (running moon build from the module root is the premise), and relative paths contain only safe characters, sidestepping the issue naturally; Linux/macOS pass argument arrays without command-line concatenation, so they keep absolute paths.
  • Same family, second case: an apostrophe path silently deletes the whole link_flags line (2026-10-15, Windows 10.0.19044 local machine, reproduced on the new stack): moon re-splits the link_flags issued by prebuild by shell word lexing, so a single quote in a path (C:\Users\noah'liu\...) is an unbalanced quote — the entry is dropped wholesale with no error at all; the symptom is not "cannot find this file" but every library in that entry is gone. Both absolute-path entries in this repo got hit: ① gui/scripts/prebuild.py's web/lib/WebView2LoaderStatic.lib for the web package (the only path-bearing entry; the other 4 pure system libraries unaffected) was dropped whole ⇒ shlwapi.lib and the static Loader vanished together, and linking reported a string of LNK2019 for CreateCoreWebView2EnvironmentWithOptions/GetAvailableCoreWebView2LoaderVersion; ② modules/ffmpeg-mbt/prebuild.py's five ffmpeg import libraries, same shape same result (av_* unresolved). Fix: wrap each path in link_flags in double quotes (f'"{lib}"') — balanced quotes make the lexer treat the apostrophe as an ordinary character; stub-cc-flags follows the same rule (-I"${build.FFMPEG_INC}", otherwise the apostrophe path splits into two arguments on the cl side). Acceptance check: run echo '{}' | python gui/scripts/prebuild.py and confirm the web entry is a complete quoted path; grep the built exe for WebView2 to prove the static Loader really linked (for ffmpeg the check is "the test exe links out"; the runtime 0xc0000135 is the separate "dlls not shipped" issue recorded in the README). Different root cause from the previous entry: that one broke at command-line assembly for the link subprocess, failing outright with LNK1104; this one loses the entry during moon's own link_flags parsing, so the build looks green all the way until only symbol errors remain — much harder to attribute. General rule: on Windows, quote every path-bearing prebuild argument (link_flags / stub-cc-flags) in double quotes; never assume moon escapes for us. Relativizing is even better where possible, but the web/ffmpeg entries must reference absolute paths across modules, so relativizing is not viable there.
  • Vendored library cascade mis-linking (shallow clone) — the root cause of the whole CI line red (closed by measurement): the prebuild's _native_dir() cascade prefers lib/<platform>/ vendored and falls back to build/, and its freshness test _sources_newer relies on git pathspec commit times; under actions/checkout's default fetch-depth=1 shallow clone the pathspec-limited log degrades to HEAD's time, t_shim==t_lib always holds → the shim is judged no newer than the library → vendored is always taken — and ensure_native_lib returns with zero compilation when the vendored is in place. New symbols where the shim leads the vendored packaging (e.g. view_set_bounds/scroll_refresh_content_size) are all undefined at CI link time, while a local full clone's git-time comparison uses build/ and never reproduces it. Fix: _native_dir() now gives top priority to build/ whenever prepare artifacts exist (stamp + library) — dev/CI-chain artifacts are freshly compiled against the current shim and can never be older than vendored; vendored only serves mooncakes users with no build/; _sources_newer conservatively falls back to build/ under shallow-clone equality or mtime races. Lesson: shim-leading-vendored is the normal state; when in doubt the cascade must recompile rather than mis-link an old library.
  • After the layout parameters were folded in, Windows single-line input vertical centering no longer reads numeric values: switched to a pure layout solution — the Entry control is narrowed to the line height (entry_ctrl_height, text centers naturally inside the control) and the shell's justifyContent=center centers vertically, adapting automatically to any height value (percentages included); the old entry_vcenter "narrow + equal-distribute margins" path is deleted.
  • select_t/dropdown_menu popups take the width live at click time via get_bounds(field) (after a user style overrides the field width, the popup follows automatically); the popup root container's width/height must still be given at creation time — a late set_style gets swallowed by the popup's allocation timing (see the GTK section).

Directory enumeration and in-process environment variables (the shim's POSIX / Win32 branches) ​

  • Directory enumeration (yue_mbt_list_dir, serving fsx_list_dir; the shim is compiled under prebuild.py's management): POSIX uses opendir/readdir (skipping . and ..), Windows uses FindFirstFileW. FindFirstFileA is not usable — the A version is ANSI-codepage, so non-ASCII paths (Chinese user directories) come out garbled; paths convert with base::SysUTF8ToWide, the pattern appends L"\\*", and entries convert back via SysWideToUTF8. The return value is flat UTF-8 text with entry names separated by '\n' (matching this package's multi-string return convention, same encoding as browser cookie / clipboard get_data); an extreme filename whose own name contains '\n' gets split apart — a known boundary of the flat-text encoding that docs must state. The ok out-parameter distinguishes "empty text from failure" from "empty directory" (empty directory: ok=1 with empty text, a legitimate success).
  • In-process environment variables (yue_mbt_setenv / yue_mbt_unsetenv, serving fsx/envx): POSIX calls setenv/unsetenv directly; Windows uses _putenv_s, which always overwrites — the overwrite=0 branch must first probe with std::getenv, otherwise the "keep the existing value" semantic is silently broken. Deleting an environment variable on Windows is _putenv_s(name, "") (removes the variable, not sets it to empty), aligned with POSIX unsetenv; a name that was never there also records ok=1 (idempotent). Scope is the current process in both cases (later-spawned children see it), with no touch to persistent system configuration and no effect on the parent process.
  • fsx folds these two kinds of thin primitives into one module (shared by appfind / browser_history / envx): the point is "list entry names only, never read content", fixing appfind's old trade-off of "reading whole large executables just to test existence" — appfind used read_binary_file for existence, now it checks directory entries through fsx_list_dir.

Package split: single yue package → core + six sub-packages (0.5.11) ​

  • Environment: moon 0.1.20260920 / moonc v0.10.14, module NoahLiu/native-gui; prebuilt native libs in place.
  • Symptom: before the split the single yue package held ~60k lines — FFI, native widgets, the themed component library, 20+ charts, markdown, the declarative layer and 30 system-capability files in one package. MoonBit compiles per package with no tree shaking inside a package, so webview-based or core-only consumers were forced to compile the whole toolkit into their binary.
  • Root cause (mechanism): MoonBit cross-package references must be qualified @alias.name — two proofs: the official moonbitlang/async src/fs sub-package references the root package as @async.xxx, and this repo's yue/media.mbt references the traybus package as @traybus.xxx. The split therefore means: one moon.pkg per sub-package, every in-package core reference qualified with @yue. (word-boundary codemod that must skip .field accesses, ::method suffixes and string literals), and external dependency declarations sinking to package level (mizchi/markdown → yue/markdown; subproc and moonsqlitefile → yue/system). No prebuild change was needed: link_configs concatenates flags across the dependency closure, so a new package importing only core automatically gets -lyue_mbt; webkit stays confined to the yue/browser entry and on-demand linking is not broken.
  • Fix (batch order is the cycle-avoidance plan): ① markdown moves first — it calls components' code_view, so moving it later would cycle core→components→core; ② components — the pre-surgery lifts the hand() cursor helper from components.mbt into core as pub (charts_interactive references it cross-package and charts moves later), and ~17 private theme getters plus bind_fg/bind_entry_theme/ctrl_height, referenced by 20+ files, get pub-ified on demand; ③ icons; ④ charts (charts_wbtest.mbt carries its own extern "C", so its moon.pkg needs a native-only targets entry); ⑤ declarative — two pre-surgeries: the Node protocol (struct Node + attach) is extracted from declarative.mbt into core's yue/node.mbt (otherwise four packages' unqualified Node references all break), and signals.mbt's trailing bind() (which calls bind_label) moves into the declarative package with it; ⑥ system — the cross-platform open_url (system.mbt) and the tray tray.mbt stay in core (markdown's link clicks depend on open_url; procrun goes with the system layer); ⑦ version close-out 0.5.11: moon.mod, yue/version.mbt and the modules/gui-media pin synchronized in three places (prebuild validates consistency at build time; a mismatch makes moon build fail outright).
  • Verification and the lesson: each batch ran moon check zero-warning + full moon test + moon build of examples/showcase and sysmonitor; an independent reviewer read-only-audited the git diff (symbol-for-symbol equivalence, moon.pkg hygiene, no strays). Lesson: the gate missed systemprobe — after the batched commits, the pushed tree failed systemprobe's build with 15 broken links (its import adaptation stayed uncommitted in the working tree); hello/hello-themed were unaffected, so the two-example gate noticed nothing, and only a git stash test on the committed state found it. Gate package-split work against every example, and check each commit's scope with git status (uncommitted files from a parallel work stream are easily swept in by whole-file commits).

MoonBit native GUI stack G1 (paint contract) · toolchain syntax forms and buffer choice ​

Environment: Ubuntu 24.04 + X11 + XFCE, moon 0.1.20260920 / moonc v0.10.14. Adds render (pure MoonBit, zero FFI).

  • Pitfall: pub method name(self : T, ...) fails to parse — method is a reserved keyword (Warning 0035 says so). Fix: always write pub fn T::name(self : T, ...).

  • Pitfall: match expr with { } reports unexpected token with (match ... with { appears 0 times in the existing repo code). Fix: write match expr { }; note with stays legal in record update { s with tx: 1.0 } — the two constructs differ.

  • Pitfall: Bytes is read-only (no op_set), and this version has no ByteArray type (The type/trait ByteArray is not found). Fix: mutable byte buffers use FixedArray[Byte] (already used by yue/png_rgba.mbt:12-19), and in-place writes need no mut binding. The pixel bitmap field is therefore FixedArray[Byte]; G2/G3 take its data address when handing off to Cairo/X11.

  • Pitfall: self.field[i] = v (index assignment through a field) promotes the whole method to requiring T &mut, forcing mut p at every call site — unacceptable for a painter meant to serve ~10k lines of existing component call sites. Fix: copy the handle before writing pixels (let d = self.data; d[i] = v); state rebinding (self.state = {...}) likewise requires mut, so the mutable fields move onto the inner PainterState's mut fields while the outer state field is never rebound (same shape as yue/signals.mbt:247's self.node.dirty = false), keeping p.fill_rect(..) usable on an immutable handle.

  • Pitfall: structs are reference-semantics, so save() pushing self.state onto the stack means restore() gets the same object back and nothing reverts. Fix: push a field-by-field snapshot (snapshot) and write field by field on pop (load_state); covered by a unit test asserting the two pixel sets separately after translate + restore.

  • Pitfall: named construction Color { r: ... } fails to parse. Fix: write bare { r: .., g: .. } with the type from context (see yue/view.mbt:513-521).

  • Pitfall: a test-only dependency across moon.work members declared only in moon.pkg.json's wbtest.import reports Package ... not found, and the entry's alias field is silently ignored (the default alias is the last path segment, hence @src in bench). Fix / trade-off: to let render's tests use yoga-mbt, the module dependency would have to be declared in the root moon.mod, changing the published dependency graph; this batch leaves it alone, and the "layout tree → pixels" positioning assertions move to the batches that actually wire yoga (G2/G7).

  • Convention fork (recorded, pending a call): 8-digit hex is parsed on this stack as CSS #RRGGBBAA (alpha last), while libyue's Color convention is #AARRGGBB (stated in the parse_hex comment of yue/color.mbt; mixing them shows up as colour drift, not an opacity change). The new stack does not follow libyue's convention.

  • Pitfall (toolchain side effect, do not repeat): repo-wide moon fmt on this version rewrites ~80 files unrelated to this batch (the repo is not fmt-clean) and migrates moon.pkg.json into moon.pkg — where the new DSL does not know warn_list (see modules/yoga-mbt/HANDOFF.md §6.11), so yoga-mbt/src's warn_list: -6 gets silently moved out. Fix: run moon fmt only on this batch's new files, then check git status and revert anything else with git checkout -- <explicit paths>.

  • Verification: moon check zero warnings + repo-wide moon test 748/748 (8 new pixel-level assertions in render: half-open interval coverage, source-over blending, translate+restore, clip only tightens, one inner stroke ring, a narrow rect fills whole, the 100/3 split shares edges without seams, illegal hex and a zero-size bitmap leave things unchanged).

MoonBit native GUI stack G2 (GTK3 window layer) · five measured pitfalls in the loop pump and present path ​

Environment: Ubuntu 24.04 + X11 + XFCE, moon 0.1.20260920 / moonc v0.10.14, GTK3 3.24.x. Adds win (GTK3 backend) and examples/native-window.

  • Pitfall ① (window never paints, frames=0): after gtk_widget_show_all the GDK map request still sits in the output buffer, the X server never sees the map, and poll waits for an event that will never come. Fix: gdk_display_flush(...) after show plus one round of gtk_main_iteration_do(FALSE) (do not use the deprecated gdk_flush).
  • Pitfall ② (busy poll saturating CPU, dt=0): calling g_main_context_wakeup before poll makes GLib's internal wakeup pipe readable, poll returns immediately, and the outer loop degrades to spinning. Fix: never call wakeup inside the wait; cross-thread wakeup only enters via the external wm_wakeup() (whose effect is exactly to make the current poll return early).
  • Pitfall ③ (hardened GLib signatures): this machine's g_main_context_query/check take an extra n_fds argument versus online examples (query's 5th is the buffer capacity; check's 4th is n_fds, not n_ready). Practice: verify against /usr/include/glib-2.0/glib/gmain.h instead of copying documentation examples.
  • Pitfall ④ (time base for relative delays): CLOCK_MONOTONIC counts from boot — a large number (21405569ms measured here). If Loop's last_now starts at 0, every timer queued before the first advance fires immediately — the skeleton animation ran one tick and the 5-second quit timer stopped the loop on the spot. Fix: Loop::start(now_ms) establishes the time base explicitly; unstarted loops take base 0 (covered by a unit test).
  • Pitfall ⑤ (known pitfall reproduced: moon does not relink on native static library changes): after editing win/gtk_stub.c, moon build reports "no work to do" and runs the old exe (same as "delete the artifacts after changing the shim"). Fix: delete build/libyue_win_stub.a and the example's _build output first.
  • Also recorded (convention): the present path copies the caller's RGBA buffer into the backend's staging before swizzling to BGRx, and never modifies the caller's buffer in place — the offscreen snapshot shares the same source, so an in-place swizzle would misalign the channels of the next snapshot and flip back and forth on repeated presents.
  • Verification: moon check gui/{core,win,render} warning-free; moon test -p NoahLiu/native-gui/core 9/9; examples/native-window local smoke: 52 frames, exit code 0, no teardown crash; yoga-mbt 79/79 unchanged.

MoonBit native GUI stack G3a (Cairo rasterizer) · bitmap format and parity between the two backends ​

  • Format decision (key): the render Bitmap's internal layout now matches CAIRO_FORMAT_ARGB32 exactly (little-endian B,G,R,A with premultiplied alpha). Payoff: Cairo can draw straight into our own buffer (cairo_image_surface_create_for_data), the window present is a direct copy, and the whole chain carries one format with zero conversions. Cost and countermeasure: the hand-written Bitmap::paint now does source-over in premultiplied space (same arithmetic as Cairo) and Bitmap::pixel unpremultiplies, so the public semantics (independent RGBA components) and all G1 assertions stay unchanged.
  • Simpler present: wm_present drops the RGBA→BGRx swizzle and the staging copy, building an ARGB32 surface over the caller's buffer directly (held by #borrow during the session, so the buffer cannot move).
  • stroke_rect has one meaning on both sides: Cairo's stroke is centered, which contradicts the layout layer's border-box convention, so the C side reproduces inset semantics with four inner band fills; when the UI needs Cairo's centered stroke, open a separate method — do not change this contract.
  • Invariant (under test): the same rect and colour (including alpha) rendered through the pure-MoonBit rect painter and through Cairo are pixel-for-pixel identical (including #00000080 over white = 127,127,127,255); a diagonal fill produces intermediate alpha, proving the anti-aliased path is actually used.
  • Verification: moon test -p NoahLiu/native-gui/render 14/14 (6 new Cairo assertions: pixel parity, triangle fill, antialiased intermediate alpha, clip+translate, inner-stroke semantics on both sides, translucent blend parity); moon check gui/{core,win,render} warning-free; examples/native-window smoke 52 frames, exit code 0 (the demo now draws a Cairo circle and bezier arc, running the whole chain end to end).

MoonBit native GUI stack G3b (Pango text layer) · FFI out parameters and cross-package field visibility ​

  • Pitfall (SIGSEGV, no test output): using Array[Int] as a C out-array (C writes out[0..2] through an int32_t *out) segfaults — MoonBit hands C the array object, so writing out[0] corrupts its length header and everything touching that array afterwards dies. The repo's established form is one Ref[Int] per out value (ok : Ref[Int], yue/ffi.mbt:452); switching to three Ref[Int] params made it 7/7. Rule: to return several values from C, use several Ref[Int] — never borrow an Array[Int] pointer.
  • Pitfall: constructing or reading struct fields across packages requires pub(all) struct (the RectF shape in yue/geometry.mbt:20); plain pub struct reports Cannot create values of the read-only type. Same story as pub(all) enum.
  • Pitfall: parameters with default values must be labelled (family? : String = ""); family : String = "" errors with "Only labelled arguments can have default value".
  • Conventions: font size takes absolute size in logical pixels (pango_font_description_set_absolute_size(desc, px * PANGO_SCALE), not points); baseline is the first baseline measured from the top of the layout box, matching yoga-mbt's set_baseline definition so wiring needs no conversion; ellipsize/wrap semantics follow the teacher's attributed_text_gtk.cc:82/128-132.
  • Test philosophy: no exact pixel widths/heights (they follow the machine's font configuration — the G0 probe's "14px line height 17px" is this machine's number); assert monotonicity (longer text wider, bigger font taller), constraint behaviour (max_width clamps the width, wrapping grows the height) and "pixels really landed" counts instead, so another machine cannot produce a false red.
  • Verification: moon test -p NoahLiu/native-gui/text 7/7; moon check gui/{core,win,render,text} warning-free; examples/native-window smoke 52 frames, exit code 0 (mixed CJK/Latin text drawn in the window); repo-wide red unchanged at 290 errors.
  • Definition settled: line_height takes the font metrics' ascent+descent (pango_context_get_metrics), and line_ascent takes the ascent (= first baseline). Never derive line height from a sample string's box height — that turns "does the text have a descender" into a line-height difference, and layout box heights would jitter with content.
  • Reconciliation with the G0 probe: the probe's recorded "14px measured line height 17" was actually the first baseline (this layer's line_ascent(14) = 17 matches it); this layer's line_height(14) = 22 (ascent+descent). Both numbers are right, the label was wrong; G5 should size text boxes from the 22 path.
  • Pitfall: PangoFontMetrics is not a GObject — it must be released with pango_font_metrics_unref; misusing g_object_unref SIGSEGVs outright (and crashes inside the test driver with no test output at all, easy to misread as an FFI parameter problem).
  • Pitfall (a new face of a known one): after changing a native static library, moon test executables are not relinked either — previously recorded only for moon build exes, reproduced here under _build/native/debug/test/<pkg>: after fixing the SIGSEGV the tests still crashed, because they ran the old linked artifact. To diagnose, first reproduce with a plain main target (a fresh target is always newly linked): consistently "main fine, test crashes" points at a stale artifact; the fix deletes _build/native/debug/test/<pkg> along with the library.
  • Verification: after removing the artifacts, moon test -p NoahLiu/native-gui/text 7/7; moon check gui/{core,win,render,text} warning-free; render 14/14, core 9/9, yoga-mbt 79/79 unchanged.

MoonBit native GUI stack G4a (editor kernel and key codes) · kernel semantics and toolchain syntax ​

  • Kernel semantics settled: the single authority for text state lives in input; the input method is just one of its clients (no second copy of the text state as in the "channel holds the text" design). All coordinates are UTF-8 byte indices, and caret/anchor only land on code-point boundaries (back off over continuation bytes 0xC0..0xBF), same source as the G0 probe; switching to code-point indices would break Pango width measurement and IME preedit offsets at once. get_text_in_range aligns start backwards, end forwards — better to include one extra code point than return half a character. The kernel never touches the system clipboard: cut/copy return text and paste_text accepts text, so the whole logic is testable without a display. Tab/Shift+Tab return "not consumed" for the upper focus stack; printable keys are landed by keysym_to_text (Latin-1 and UCS keysym paths), the remaining functional keys are left to G5's IME channel.
  • Pitfalls (this toolchain's syntax, all measured): ① the implicit-method form fn helper(self : Editor, ..) is not recognized (unbound) — private helpers also need fn Editor::helper(self : Editor, ..); ② top-level pub name : Int = v fails to parse, use pub const NAME : Int = v (uppercase); ③ move is a reserved keyword (Warning 0035) — the method is named move_caret; ④ Int.to_char() returns Char?, and interpolating it warns "Option does not have a meaningful string representation" — match out the Char first; ⑤ variants of pub enum X derive(Eq, Show) { A B } cannot be constructed elsewhere; payload-less enums need pub(all) enum, and this batch went further and replaced the two-case enum (Consumed/NotConsumed) with a plain Bool to shed one syntactic layer; ⑥ } else match ... { is illegal — a match used as a branch body must be wrapped in { }.
  • Pitfall (out of bounds): floor_boundary allows k == buffer length (the end is a legal boundary), so the loop must short-circuit on k < n before indexing, otherwise Array::at panics (SIGABRT observed).
  • Verification: moon test -p NoahLiu/native-gui/input 10/10 (CJK-mixed round trip, backspace deletes a whole character with no residue, selection replacement, word jumping in three directions, shift extend/collapse, copy leaves state untouched while cut deletes, undo/redo round trip, handle_key routing and Tab's attribution, Ctrl+A/Ctrl+Z/Ctrl+Left, range alignment); moon check gui/{core,input} warning-free; render 14/14, text 7/7, yoga-mbt 79/79 unchanged.

MoonBit native GUI stack G4b (key/pointer events and focus wiring) · four GTK-side rules ​

  • Pitfall: gdk_keyval_to_lower(ev->keyval) erases the shift case — X11 keyvals already encode shift ('a' and 'A' are different keysyms), so the kernel's keysym_to_text yields the correct character when the raw keyval is kept. Keep the original keyval in the event; do not lowercase.
  • Pitfall: modifier bits cannot pass through GDK's raw values (Shift=1, Control=4, Mod1=8, Mod2=64, Meta=16); they must be remapped explicitly onto this stack's MOD_SHIFT=1 / MOD_CTRL=2 / MOD_ALT=4 / MOD_META=8, otherwise every Ctrl+Left / Shift+Tab combination misfires.
  • Prerequisites for receiving keys: gtk_widget_set_can_focus(area, TRUE) + gtk_widget_add_events(area, GDK_KEY_PRESS_MASK | ... | GDK_SCROLL_MASK), plus gtk_widget_grab_focus after show_all; missing any one step leaves a live window that reports not a single key event — the same class as G2's "no flush, waiting on an event that never arrives": the symptom is silence, not an error.
  • Conventions: the event queue is a 64-slot FIFO per window, and when full it drops the new event rather than overwriting (losing one event is better than corrupting the order); events are read as "wm_take_event returns the code, then wm_ev_* reads the last taken one" instead of many out parameters — MoonBit cannot hand C a writable array (see the G3b SIGSEGV entry).
  • The focus stack lives on the MoonBit side: core/focus.mbt owns only order and wrap-around (Tab from last to first, Shift+Tab from first to last, unregistering the focused item hands focus to its successor); the platform exposes one action, Window::request_focus. Logical focus mirrors libyue's FocusManager (containers hold no system focus).
  • Caret placement rule: click positioning measures prefix widths code point by code point and takes the right-most boundary at or before the click; Editor::boundary_after was promoted from private to public so upper layers advance the same way.
  • Verification: five packages moon check warning-free; tests core 14/14, input 10/10, render 14/14, text 7/7, yoga-mbt 79/79; examples/native-window local smoke: 49 frames, exit code 0, both boxes' text state intact. Real-machine items await the collaborator: typing letters/digits, backspace and arrows, Tab/Shift+Tab wrapping between the two boxes, click-to-place caret, 500ms caret blink, CJK punctuation and compose keys (IME belongs to G5).

MoonBit native GUI stack G0 · real-hardware evidence: im_focus is the precondition for the pure channel working; the preedit gap was in how the probe hooks it ​

Environment: Ubuntu 24.04 + X11 + XFCE, GTK_IM_MODULE=fcitx (fcitx5). Executed by the collaborator on real hardware (AGENTS rule 6).

  • Symptom: in probe mode B every keystroke landed as an immediate commit and preedit stayed 0 — the IME looked like it never took over. fcitx5 was fine; GtkIMMulticontext falls back to the built-in GtkIMContextSimple (Latin-1 only, no compose string or candidates) when the context is not activated.
  • Root cause: the probe only called gtk_im_context_set_client_window, which makes the context usable, but never called gtk_im_context_focus_in, so it stayed inactive and filter_keypress consumed nothing.
  • Fix: gtk_im_context_focus_in(pr.im) at realize, and sync in/out on the toplevel's focus-in/out events (losing focus must call focus_out, otherwise the IM side keeps an unfinished composition — checklist item 5). With this added, the Chinese commit path works on real hardware.
  • Closed out (added the same day): inline preedit achieved; the pure channel's four conditions are all met — ① focus_in/focus_out synced with focus; ② key handling installed on the focused widget (window-level wiring gets both the ordering and the consumption wrong — exactly the difference from GtkEntry behaviour); ③ gtk_im_context_set_surrounding every tick (committed text only, cursor in UTF-8 bytes); ④ use_preedit=TRUE — its meaning is "use the preedit string for inline feedback", not "the IM draws it itself"; reading it backwards cost two false negatives.
  • Another real-hardware pitfall, unrelated to the IM: the composition string may be assembled in exactly one place. The probe used to splice the preedit into the display string on the MoonBit side and splice it again while drawing in C, so the same text appeared twice; the fix moves splicing and the underline into the draw side (body text unlined, composition underlined, matching GtkEntry), with MoonBit pushing committed text only.
  • One misdiagnosis to record: a "frontend version mismatch" suspicion was refuted by the mode-C control (a real GtkEntry previews fine in the same environment) — run a reference implementation against real-hardware symptoms before attributing causes.
  • ibus not installed: the two ibus rounds of the checklist are marked "no ibus daemon in this environment, untested".
  • Verification method: moon build experiment/ime_probe + both modes smoke to exit code 0 (assistant side); real hardware (fcitx5) evidenced by the collaborator: one inline underlined composition, [preedit] changed in the stream, [commit] 中文 intact, the candidate window hugging the end of the composition string.

MoonBit native GUI stack G4 · first root cause behind the real-hardware report "cannot type / click, Tab does not switch focus": event code 4 unmapped ​

  • Symptom (collaborator's real hardware): the self-drawn box accepted no characters, clicks did not move focus, Tab/Shift+Tab did nothing — while window animation and repaint were normal.
  • Root cause: the backend's wm_event uses return value 4 to mean "an input event is pending", but the MoonBit-side Window::poll_event mapping only covered 1/2/3, so 4 fell into the _ => NoEvent arm — the main loop therefore believed "no events" and ended the drain immediately, and take_input was never called. Rendering runs on timers, so animation kept moving; the symptom was exactly "only input is dead" with no error anywhere.
  • Lesson (rule level): event codes are an implicit contract between the two ends — a new event type must change both the C return value and the MoonBit mapping, and the default arm must not swallow unknown codes; from now on, when adding an event type, print (or assert on) unrecognized codes first instead of letting _ => absorb them silently.
  • Still open at the time: after fixing the mapping, the example's 5-second auto-exit did not fire (the process sat at 0% CPU blocked in the wait). The hypothesis that Loop::advance starves later timers by "breaking on the head alone" was ruled out — a new headless case, "a self-rescheduling short-period tick must not starve a longer-period timer", passes (core 15/15). The next step injects keys and clicks into the example window with xdotool for objective verification (this path works on this host and can replace eyes).
  • Verification: moon check win warning-free; the five-package tests core 15/15, input 10/10, render 14/14, text 7/7 all green.

MoonBit native GUI stack G4 · the two real causes of "not a single character can be typed": key handler on the wrong layer, and NumLock read as Meta ​

Symptom: after G4 landed, the self-drawn box accepted no characters at all, while Tab seemed to "sort of move" (focus really was switching), window animation and repaint were normal, and nothing logged an error. Objective verification by injecting xdotool key into the example window (this host, not relying on eyes), printing code/keysym/mods/focus/len per event, pinned two causes:

  • (1) Key handling was hooked to the toplevel window instead of the focused widget: the original g_signal_connect(w->window, "key-press-event", ...). The G0 probe (ec2849d) had already settled the hook shape on real hardware: key handling belongs on the focused widget, because window-level wiring gets both the ordering and "can it truly eat the event after consumption" wrong (fatal once an IME is involved). Now connected on w->area (the drawing area), the same shape as GtkEntry.
  • (2) GDK_MOD2_MASK was being treated as Meta — fatal and very easy to misjudge. wm_mods() set MOD_META whenever state & (GDK_META_MASK | GDK_MOD2_MASK) matched, but MOD2 on X11 is NumLock, which is on on any normal desktop, so every key event arrived with mods=8; the kernel's handle_key sees alt/meta nonzero, classifies it as a chord, and inserts nothing, silently dropping every printable key. Fix: Meta comes only from GDK_META_MASK | GDK_SUPER_MASK; lock bits like NumLock/CapsLock never enter this stack's modifier set.
  • Localization technique worth reusing: "did the event reach C" — fprintf(stderr, "[c] key ...") in the C callback; "where did it die after MoonBit" — print code/keysym/mods/focus/editor byte-count at the dispatch point. This time a single mods=8 field pinned cause (2); reasoning from behaviour alone kept pointing at "events never arrived".
  • One disproved hypothesis, recorded: after fixing the mapping the example briefly did not auto-exit, and I first suspected Loop::advance starving later timers by "breaking once the head is not due". Written as a headless case it passes (core 15/15), so the guess was dropped; the actual stall was the draw-vs-GdkWindow lifetime issue (see the G2/G4 timing entry) compounded with too short a wait in my own test loop — not the scheduler.
  • Objective verification: injecting a b c Tab d e f yields NATIVE-WINDOW-OK ... a='helloabc' b='第二个框def' — the first three keys into the first box, Tab moves focus, the last three into the second box, with mods=0. Five packages moon check warning-free; core 15/15, input 10/10, render 14/14, text 7/7, yoga-mbt 79/79; repo-wide red still 290 errors. Still needs eyes: click-to-place caret and the 500ms blink appearance (xdotool can inject a click but cannot see the drawn caret).

MoonBit native GUI stack G5a (Linux pure-channel IME) · the four probe conditions landing in win ​

The recipe came from the G0 probe (ec2849d, four conditions) and this batch implements it into the new stack's own window layer: win creates a GtkIMMulticontext (use_preedit=TRUE), runs filter_keypress first on every key (consumed ⇒ eaten, never forwarded to the kernel), reads the commit text and composition string back to MoonBit as "take one + read field by field", and feeds surrounding text plus the caret rectangle every tick; input gains set_preedit/preedit_text (the composition is display-only, never merged into the text state — there is still exactly one copy of the text); the example draws the composition after the committed text with an underline and adds the composition width to the candidate-window rectangle.

  • Pitfall 1 (pinyin goes into the text verbatim, the IM looks dead): calling set_client_window before show — the drawing area is not realized yet, gtk_widget_get_window returns NULL, the multicontext attaches to no IM module, and filter_keypress never consumes. It must run in the realize signal (same as the probe), paired with focus-in/out-event for focus_in/focus_out.
  • Pitfall 2 (core dump: free(): double free detected in tcache 2): the UTF-8 string delivered by the commit signal is owned by GTK and freed after the handler returns; calling g_free(text) there is a double free. The probe's handler indeed does not free either.
  • Pitfall 3 (CJK never appears): calling gtk_im_context_reset alongside the per-tick surrounding feed breaks the composition — reset means "discard the current composition". Feed only gtk_im_context_set_surrounding(im, text, len, caret_bytes) (the four-argument legacy call, same as the probe; do not take the newer SelectionData-based API).
  • Pitfall 4: the preedit-changed signal signature is (GtkIMContext*, gpointer); the string must come from gtk_im_context_get_preedit_string(ctx, &str, &attrs, &caret) (connecting as (ctx, text, data) hands you a bogus pointer); caret is a byte offset inside the composition, the same unit as this stack's UTF-8 byte caret.
  • Toolchain addenda: FixedArray[Byte] → String goes through @utf8.decode_lossy(FixedArray::unsafe_reinterpret_as_bytes(buf)) (neither @utf8.decode_utf8 nor buf.to_bytes() exists in this version); Double → Int is x.floor().to_int() (no Int.floor_of_double, no +. operator, and a line-continuation operator must sit at the end of the previous line).
  • Objective verification (xdotool injection, GTK_IM_MODULE=fcitx): typing nihao + space yields the exit line a='hello你哈哦' — no pinyin-letter residue, and seen=6 counts only the unconsumed keys, so filtering and "consume-and-eat" both behave as intended; the process exits cleanly. Five packages moon check warning-free; core 15/15, input 10/10, render 14/14, text 7/7, yoga-mbt 79/79; repo-wide red still 290 errors.
  • Pending eyes / follow-ups: the look of the preedit underline and whether the candidate window hugs the end of the composition (this machine can only prove the rectangle was pushed), the ibus set (probe-side conclusion: no inline preview under ibus is suspected to be a libpinyin display-style setting, unrelated to this layer — recorded separately), Windows ImmAssociateContext and macOS.

MoonBit native GUI stack · Painter contract completion, first batch (facade + blend modes + gradients) ​

  • Facade decision: Painter is promoted from a pure-rectangle subset to a full contract facade. new(bitmap) opens a Cairo session; rectangles, paths, transforms, clipping, blending and gradients all go through Cairo. When Cairo is unavailable — a non-Linux stub implementation, or all 16 session slots exhausted — it falls back to the pure-rectangle implementation, where path calls are no-ops. Rectangle drawing is bit-for-bit identical on both paths: a new matrix-equivalence case runs rounding, inner stroke, clip intersection, translation and translucent overpaint once per path and compares.
  • Session discipline: there are 16 Cairo session slots (CR_MAX); one session per frame requires Painter::end() to reclaim it. A leak makes later Painters silently degrade to the stub implementation. Added a reclamation case that opens and closes 20 Painters while still holding sessions. Nine existing tests never reclaimed, leaking 14 sessions in total; only after adding end() did headroom return to the safe zone.
  • Blend modes: the 25 BlendMode values map 1:1 onto Cairo operators — the first 16 are W3C blending, the last 9 Porter-Duff. A standalone C probe confirmed on this machine, cairo 1.18 with pixman, that all 25 operators really take effect, including the pigment and HSL families, so no custom blending algorithm is needed. Translucent sources follow the W3C formula, (1-α)·dst + α·Blend(dst,src); measured 0.5 α multiply equals 0.5·dst + 0.5·dst×src. On a transparent base, Multiply degrades to the source color. The 25-mode measurement table is committed as assertion lines: blend and HSL rows carry a ±2 tolerance against cross-pixman scaling differences, Porter-Duff rows use exact values.
  • Pitfall — rounding must happen in device space: with rounding done in user space, device coordinates after a CTM translation such as translate(1.3, 0.6) land on fractional pixels, so Cairo anti-aliases a grey edge and diverges from the pure implementation. The facade-vs-degrade matrix-equivalence case went red first and green on exactly this fix. The fix: convert with cairo_user_to_device, round with floor(v + 0.5) in device space, then temporarily switch to the identity matrix and fill an integer rectangle. When the matrix contains rotation or shear, that is off-diagonal non-zero, no rounding is applied and anti-aliasing handles it as-is.
  • Pitfall — the clip region is graphics state: a temporary identity matrix plus cairo_rectangle plus cairo_clip must not be wrapped in cairo_save/cairo_restore. Clipping belongs to the saved state, so restore reverts the clip just set. Restore manually with cairo_get_matrix/cairo_set_matrix. The final clip is stored in device space, so later CTM changes do not affect it.
  • Pitfall — different default line widths: a Cairo session defaults to 2.0 while the contract defaults to 1.0. If Painter::new does not sync them, strokes that never call set_line_width, such as the inner band of stroke_rect, come out one step thicker. The "stroke an inner ring" case located it.
  • Pitfall — a function that does not exist: cairo_pattern_get_status does not exist; the correct name is cairo_pattern_status. The typo surfaces at link time as undefined reference rather than at compile time.
  • Pitfall — build artifact path capitalization, supplement to the G3b entry: this repository's test artifacts live under _build/native/debug/test/NoahLiu with a capital first letter. Deleting the lowercase path silently does nothing and tests keep running the old binary. Earlier in this batch a 20-pixel difference survived the device-space rounding fix; it was a stale artifact. The correct path to delete after changing the native library: _build/native/debug/test/NoahLiu/native-gui/<pkg>.
  • Gradients: Gradient::linear/radial plus add_stop. Offsets clamp to [0,1], invalid hex is ignored, a gradient with no stops is ignored as a whole, and set_fill_color overrides a gradient. Implementation is a one-shot pattern: create, add stops one by one, set as source, release the handle. Measured semantics: linear samples at pixel centers, (x+0.5)/axis_length; beyond the endpoints, pad reproduces the end color. Radial is concentric and symmetric across the four corners.
  • Verification: moon test -p NoahLiu/native-gui/render 25/25 — 11 new: Porter-Duff exact values, 25-mode measurement table, translucent formula, transparent-base degradation, mode independent of color-setting order, linear gradient sampling and pad, radial symmetry, empty gradient ignored plus solid override, facade path with anti-aliasing, save/restore reverting transform and style together, session slot reclamation. Five packages moon check zero warnings, core 15/15, input 10/10, text 7/7, yoga-mbt 80/80. examples/native-window smoke: 49 frames, exit code 0 — the example now goes through the facade and uses a gradient glow, a multiply square and a path circle. Repo-wide still 290 errors / 68 warnings, no increase.

MoonBit native GUI stack · Painter second batch (offscreen Canvas and images) · sampling domain and archive rebuild ​

  • Image decoding: via GdkPixbuf, an existing GTK3 dependency, so no new runtime dependency; GdkPixbufLoader sniffs the format from content. Pixels convert to this stack's Bitmap layout: ARGB32 premultiplied, little-endian B,G,R,A. Premultiplication rounding matches Bitmap::paint, (c*a+127)/255, so encode → decode → re-encode is byte-stable; translucent colors such as #12345680 round-trip exactly. The decoder takes only the first static frame. The old chain's Image was a GdkPixbufAnimation, so GIFs animated; animation is a known gap and is not pretended to be supported.
  • Sizing follows the teacher: get_width/get_height return the logical size, pixels divided by scale; get_scale_factor returns the recorded ratio. This matches libyue image_gtk.cc:75-77, ScaleSize(.., 1.f / scale_factor_).
  • Pitfall — the blit sampling domain must stay inside the source sub-rectangle: building a pattern from the whole source bitmap and only writing the source rectangle into the pattern matrix makes bilinear sampling pull colors from outside the sub-rectangle, so sprite sheets and icon atlases bleed. In the test, "take the top-left quadrant and tile it at 4x4" failed first: the right edge sampled the neighbouring green block. The fix: create a sub-surface with cairo_surface_create_for_rectangle(s, sx, sy, ssw, ssh), so the sampling space moves with the sub-rectangle origin, then add PAD extension so edges do not open into transparent seams.
  • Pitfall — ar rcs appends renamed members: after splitting the source into cairo_stub.c plus image_stub.c, the stale librender_cairo_stub.o left in the old archive entered the library together with the new object, producing multiple definition at link time. prebuild's compile_stub now deletes the old archive before ar rcs, and the cache acceptance check is "any source updated rebuilds everything" instead of the previous single-source mtime comparison.
  • Asset move fallout: the PNG encoder moved from the root package yue/png_rgba.mbt into render/png.mbt. The public entry is renamed png_encode_rgba; internal pngr_* names stay. Six existing cases moved with it. The root package's private helper utf8_bytes does not exist in the new package, so it becomes @utf8.encode plus moonbitlang/core/encoding/utf8 in moon.pkg. After the move, errors dropped from 290 to 287 — one fewer file and one fewer test in the root package.
  • File writing: Canvas/Image write_to_file currently supports only "png". Writing goes through the C-side fsw_write_file fallback, a temporary channel before the system layer lands; paths are taken as UTF-8 bytes.
  • Verification: moon test -p NoahLiu/native-gui/render 40/40 — 9 new: PNG round-trip opaque/translucent, empty-image semantics and no pixels landing on an empty image, logical size following scale, resize upscaling keeping a solid color, draw_canvas 2x across four quadrants, draw_canvas_from_rect sub-rectangle without bleeding, blit respecting multiply blend, PNG written then read back pixel by pixel; six PNG encoder cases arrived with the move. Five packages moon check zero warnings, core 15/15, input 10/10, text 7/7, yoga-mbt 80/80. examples/native-window smoke: 49 frames, exit code 0 — adds an offscreen canvas sprite upscaled 4x. Repo-wide 287 errors / 68 warnings, down from 290, no increase.

MoonBit native GUI stack · Icon drawing port (803 thousandths-fixed-point paths) ​

  • Scope and fidelity: lines 1-4197 of master's icons/icons.mbt moved wholesale into the new icons package: the 803-variant IconKind, the path_num/fill_icon_path interpreter, draw_icon, the icon_path_data block, and the kind_index/all_icons/icon_name tables. Only two kinds of change: the type reference @yue.Painter → @render.Painter and the header note. The data block matches the original line for line — diff reports only the 7 header lines and 2 type-reference lines. The declarative wrappers icon/icon_button_t depend on the declarative layer and are left to the component batch, G7.
  • Semantics copied, not reinvented: coordinates are fixed-point integers of normalized values ×1000; command set M/L/C/Z; each sub-path closes M…Z; one fill for the whole path, iconfont outline style; safe margin 0.88, v × s × 0.88 / 1000 + center; a single command is read and dispatched immediately with no caching, parsing takes microseconds.
  • Test coverage: ① the three tables — inventory, names, path data — correspond one-to-one. A count array with every cell exactly 1 replaces a hash set, avoiding a container dependency; name uniqueness uses squared comparison, sufficient at 803 items. ② Full scan of the path-string character set: only M/L/C/Z, digits, -, ,, with a leading M and a trailing Z. ③ draw_icon lands pixels, and the center of the painted-pixel bounding box stays within ±2px — this checks centering, not a specific shape. ④ Full scan of all 803 icons at 24px with no empty image, parsing plus rasterization, finishing inside 0.7s.
  • Environment note, to avoid misdiagnosis: the seen counter on the example's exit line includes pointer motion — motion with code 4 also enters the input queue. During a smoke run with the mouse resting on the window, seen is non-zero while the text does not change. That is expected, not stray keystrokes.
  • Verification: moon test -p NoahLiu/native-gui/icons 4/4; moon check icons zero warnings; examples/native-window smoke: 49 frames, exit code 0 — the example adds a row of five vector icons.

MoonBit native GUI stack · Font and rich-text subsystem (Font / TextFormat / AttributedText) ​

  • Surface: Font — family name, logical pixel size, 9 weight levels, italic; the weight values are CSS 100..900, passed straight to Pango's PangoWeight — plus TextAlign/TextFormat/TextAttributes/SizeF/AttributedText. This batch covers the whole-run attribute surface only: font, color, alignment, wrapping, ellipsis, get_bounds_for, set_text/get_text/clear. Range attributes, set_font_for/set_color_for, need ranges attached to a Pango attribute list and are left to the next batch.
  • Draw entry points sit in text at the package boundary: draw_in_box/draw_font, not a Painter::draw_text method. MoonBit cannot add methods to a foreign type, and render must not depend on text in reverse. When the component layer wires up, call sites change from the old contract's p.draw_text(..) to @text.draw_in_box(p.bitmap, ..), a fixed-point rewrite under G7.
  • Pitfall, semantic level, caught by probe: wrap=false previously had no effect at all. Pango wraps with the default WRAP_WORD as soon as pango_layout_set_width is set. Measured: 120px wide without a wrap flag, long text still folded into 4 lines at 88px. The fix: pass the width to Pango only when wrapping or ellipsis is requested; a single line without ellipsis draws at natural width and may overflow the box, and in-box alignment is computed locally, using the same code path as vertical alignment. This also corrects the older documentation claim that "long text in a fixed-height line overflows the line bounds" — it was wrapping.
  • Pitfall — an FFI prototype changes in three places together: adding weight/italic to pt_font_line_height touched only the MoonBit extern and the portable stub; the real implementation, in the Pango branch of text/stub.c, still took 4 arguments. The extra argument landed in the out_h pointer slot under the calling convention. The symptom was not a crash but a line-height assertion going false for no visible reason. Rule: an FFI signature change means moon extern plus real implementation plus stub branch, all three in one batch.
  • Pitfall — derived traits are a deprecated path in this toolchain: pub(all) enum X { .. } derive(Eq) triggers the implicit_impl_as_method deprecation warning, because equal/not_equal are implicitly promoted to ordinary methods. Writing derive(Eq) after the enum name is a hard compile error; the correct position is after }. To hold the zero-warning gate, this batch derives no traits; tests compare enums through a package-local id mapping, align_id/weight_value.
  • Verification: moon test -p NoahLiu/native-gui/text 14/14 — 7 new: wrap=false single-line natural width against the wrap=true height, ellipsis width clamped, bold not narrower than regular, in-box horizontal/vertical alignment landing points and centering, AttributedText attribute surface and bounding box, new_with/format round-trip. moon check text zero warnings. examples/native-window smoke: 49 frames, exit code 0 — the example adds a centered bold title, exercising Font/TextAlign/AttributedText.

yoga-mbt · Corpus-derived item closed: child flex-basis shadowed by container properties (Chrome cross-check) ​

  • Origin: corpus item 93078300, an open item — explicit flex-basis: 0 plus min-height: 0 under an auto-height column container gave 60 from the engine, where CSS derivation says 0.
  • Chrome cross-check technique, reusable: local HTML plus the browser MCP's getBoundingClientRect() to read real heights; the spec text is extracted from the drafts.csswg.org page DOM via document.querySelector('#anchor'), which is more reliable than WebFetch — that only returns the table of contents.
  • Conclusion 1, Blink's reading: auto main-axis size = the sum of each item's hypothetical main-axis size, where the flex base is clamped by min/max; growth does not participate at all. Evidence rows: basis 100 / content 300 / grow 1 → 100. The Ideal algorithm in spec §9.9.1 would scale by the flex factor and the Web-compatible path would take the max-content contribution, 300; neither matches, so the implementation follows Blink. basis 0 + min 0 → 0; basis 0 + min:auto → 60, the content floor; basis auto + max-height 30 → 30; basis 40% with an indefinite container main axis and no reference → 60, falling back to content.
  • Conclusion 2, root cause, one typo: when building a flex item, the basis was read from the container's s.flex_basis instead of c.style.flex_basis. Child flex-basis therefore never took effect anywhere in the layout chain, which is why any basis setting produced the same result. The container's auto main-axis size also ignores child basis; it now uses a new main_contribution: resolve the basis first and clamp by the min/max it determines, falling back to intrinsic size for auto or a percentage with no reference. The min: auto content floor applies only in §4.5 flex resolution, not again in container sizing — otherwise every container measure would recompute intrinsic size.
  • Verification: the 7-row Chrome matrix, rows A/C/D/F/G/I/L, matches row by row and is committed as standing cases in patches_regression_wbtest.mbt. yoga-mbt 81/81, including the display:none corpus case and all existing assertions. Bench unchanged: 1121 nodes × 500 rounds release, 1.93s before the fix and 1.95s after, measured A/B.

MoonBit native GUI stack · Theme system, first batch (palette derivation formulas + change subscription) ​

  • Surface: Theme with a 20-field palette, the default_theme/dark_theme presets, theme_from_accent using the EP theme workbench formula — accent keeps its hue, saturation floor 0.45, lightness clamped by mode: light 0.34..0.52 / dark 0.55..0.72; hover -0.09 in light and +0.08 in dark; *_light mixes 12%/14% white in light mode and 30%/28% panel base in dark mode; semantic colors take fixed wheel hues 142/36/4/210, borrowing the accent's saturation when it is ≥0.4 with lightness following the mode; neutrals do not shift with the accent — plus theme_current/on_theme_change/off_theme_change/theme_apply, 20 getters, control_height (32) and entry_ctrl_height.
  • Three deliberate omissions, differences from the old chain: ① native control CSS push, the old apply_native_theme_css — every control in this stack is self-drawn and the palette is the only source at draw time, so "native look follows the system theme" does not exist here. ② ffi_repaint_all as a blanket repaint fallback — repaint responsibility lands on each widget's subscription path with the component batch. ③ theme_from_system, following system dark/light and accent, needs system integration reads and is not wired. sub_bag_begin/end batch unsubscribe arrives with the relink layer.
  • Color math landing point: theme/palette.mbt — hex parsing, rgb_to_hsl, hsl_to_rgb, mix_hex, lighten_hex, darken_hex. Hex parsing reuses @render.Color::parse, i.e. the CSS reading with 8-digit alpha at the end; the divergence from libyue's #AARRGGBB is already recorded under G1. Theme strings use the 6-digit form only, so they are unaffected.
  • One measured pitfall inherited from master: in mix_hex(a, b, t), t is the share taken from the former, and the endpoints t=0/1 plus invalid input must return the input string unchanged without a hex6 round-trip. hex6 outputs lowercase hex, which would rewrite #2D68C4 into #2d68c4 and break comparison against the original string. Assertions are written in lowercase: theme_from_accent("#FF0000").primary == "#ff0000".
  • Test orientation: no assertion on concrete derived color values, since changing the base palette would turn everything red. Only formula invariants are asserted — a gray accent's saturation raised to the floor, a white accent's lightness clamped to the ceiling, dark mode lifting a dark accent to the floor, hover's lightness direction, semantic hues within ±1° on the wheel, the mixing direction of the light-base variants, an invalid accent falling back to the base palette. On the subscription surface: notification uses a snapshot, so a listener registered inside the callback does not join the current round; unsubscribe is idempotent; an unknown handle is a no-op; getters follow after theme_apply.
  • Verification: moon test -p NoahLiu/native-gui/theme 7/7; moon check theme --deny-warn zero warnings; repo-wide still 287 errors / 68 warnings; other packages and yoga-mbt untouched.

MoonBit native GUI stack · Widget host, first batch (layout tree ↔ widget tree, hit testing and event routing) ​

  • Landing point: in core, per plan §2's package table — the loop and focus stack live in this package, the platform backend in win, the drawing contract in render — so this layer knows no platform handle at all. This is also yoga-mbt's first appearance as a dependency of a main package: moon.mod's import gained "NoahLiu/yoga-mbt@0.1.0". The module is already in the workspace, but omitting this line reports "containing module is not imported".
  • Coordinate convention, converted in one place only: yoga's left/top are offsets relative to the parent's border box. relayout/relayout_at fold them into absolute window coordinates in a single post-layout pass, collect_abs, and write them back onto the widgets. Hit testing and the draw traversal consume absolute rectangles only; events are translated to widget-local coordinates before a handler sees them, where local (0,0) is the widget's border-box top-left. Drawing translates and clips layer by layer, so a widget's on_draw writes local coordinates and never clips itself.
  • Propagation rule: from the hit widget upward along the ancestor chain, the first handler returning true consumes the event — one rule for pointer, wheel and key. The host stores no parent pointers; bubbling uses the hit path, hit_path or a path found by focus id, so re-mounting a subtree cannot leave a dangling parent reference.
  • Pitfall ① — a widget cannot support ==: Widget has closure fields, so Eq cannot be derived and Option[Widget] == None is a compile error reported on the impl Eq for Option constraint. Approach: each widget carries an auto-increment id; identity comparison and test assertions go through id, with a helper returning -1 when nothing is hit.
  • Pitfall ② — a hidden subtree keeps stale rectangles: the layout engine stops recursing at display:none, so descendants retain their layout_* values from the previous round. If collect_abs does not skip hidden subtrees, a hidden descendant can still be hit or drawn from a stale rectangle. Fix: when a widget is invisible, zero the rectangles of the whole subtree before returning.
  • Pitfall ③ — local is a reserved word: let local = ... triggers Warning [0035] reserved_keyword; this toolchain reserves local for future use, so under the zero-warning gate it must be renamed.
  • Real-window evidence — coordinate alignment is not a paper conclusion: the example adds a 200×28 demo band of four 50×28 cells; each cell's on_draw paints a block and its on_pointer counts presses only. xdotool mousemove --window <id> X 250 plus click 1, once each at window x 200/250/300/360, gives the exit line NATIVE-WINDOW-OK band_h=1/1/1/1 — exactly one press per cell, no wrong cell and no double count — with frames=49, queue=0, exit code 0 in the same run.
  • Not in this batch, G6 remainder: dirty-region incremental relayout plus measure/intrinsic caches persisting across layouts; the App host, translating win's raw events into this layer's PointerEvent/KeyEvent and owning the present loop, lands with the declarative layer G7.
  • Verification: moon test -p NoahLiu/native-gui/core 22/22 — 7 new: absolute rectangle folding including parent padding, hit taking the deepest and later-painted first while out-of-bounds, zero-size and hidden are not hit, pointer localization with three-level bubbling and consume-then-stop, local coordinates (12,45)→(12,15) on an offset tree, wheel bubbling to the container and not routed outside bounds, key located by focus id and bubbling with unreachable after hiding, and the draw traversal's translation and clipping — an overflowing child leaves no ink — plus hidden subtrees not drawn. moon check gui/{core,input,render,text,win} and the example under --deny-warn all zero warnings; yoga-mbt 81/81; repo-wide still 287 errors / 68 warnings.

MoonBit native GUI stack · sys package opened (JSON parser port + package-local tests) ​

  • Landing point: the new stack's system-capability package is named sys per plan §2. The first asset is the zero-dependency JSON parser, moved wholesale from master's yue/vscjson.mbt. Public names VscJson/vsc_json_parse/vsc_json_get/vsc_json_str/vsc_json_int64/vsc_hex_val/vsc_codepoint_at are unchanged; consumers only swap the package prefix from @yue. to @sys.. The root package loses one file and the error count is unchanged, since that file was already clean.
  • Coverage added: in the old chain this parser had no dedicated tests — coverage was scattered across the vscode_history/browser_bookmarks cases. Against the baseline "every new-stack package writes its own wbtest", 7 cases were added: scalars and type gates, Int64 millisecond timestamp precision, chained access on nested structures, string escapes — regular, \u, surrogate pairs, lone surrogate → U+FFFD, unterminated, malformed \u; all 9 syntax errors rejected with a loose-whitespace control; the GeoJSON point path; hex and codepoint helpers.
  • Two measured conventions found while writing the tests: ① this toolchain's String.unsafe_get(i) returns a UTF-16 code unit, not a code point; vsc_codepoint_at("😀", 0) gives (0x1F600, 2), which is exactly why this surrogate-merging helper exists. ② The 2^53 gate for converting an integral float to Int64 is tested on the rounded double value, and the literal 9007199254740993.0 itself lands on 2^53 in binary, so it passes; asserting "out of range rejected" needs magnitudes such as 1e20.
  • Verification: moon test -p NoahLiu/native-gui/sys 7/7; moon check sys --deny-warn zero warnings; repo-wide still 287 errors / 68 warnings.

MoonBit native GUI stack · Rich-text range attributes (rt_* sessions) · three measured Pango attribute facts ​

  • Implementation shape: AttributedText stores data only — text, format, base font/color, and Array[TextRange]. Each measure or draw builds a session in the C-side rt_* slot table, RT_MAX 16, and calls rt_free immediately after use. No long-lived handle, so a MoonBit object can never be collected while C still holds a pointer. Text and font names are copied into owned memory at the entry point, since a MoonBit Bytes pointer is valid only for the duration of the call.
  • Range convention: UTF-8 byte indices [start, end), the same source as the editing kernel. libyue uses character ranges, a divergence on record. Out-of-bounds is clamped when the tree is built; a range whose start is not below its end is skipped; invalid hex never enters the table. set_text keeps the range table per the old semantics. The public surface adds range_count(), which the component layer uses to decide whether to take the rich-text path.
  • Three measured Pango-side facts: ① pango_layout_insert_attr needs Pango 1.52+ and this machine's headers have no declaration for it; the C side compiled it as an implicit declaration returning int and truncated the pointer, so the symptom would have been a segfault from nowhere. Switched to PangoAttrList plus pango_layout_set_attributes, available since 1.44. ② Foreground color and foreground alpha are two separate attributes: pango_attr_foreground_alpha_new takes a single guint16, not RGBA four arguments, so a translucent range needs foreground_new and foreground_alpha_new inserted together. ③ pango_attr_font_desc_new copies the description itself, so the local one is released with pango_font_description_free right after construction.
  • Override order: attributes of the same type apply in insertion order, later overriding earlier. Verified through the pixel bounding box of "paint the whole run red, then set the first character back to black", not by reading Pango documentation.
  • Verification: moon test -p NoahLiu/native-gui/text 20/20 — 6 new: color range landing on the right glyphs with the rest keeping the base color; multibyte text proving the split is on bytes not characters; size range raising the bounding box; later overriding earlier; out-of-bounds, empty and invalid colors landing no pixels and not crashing; empty text and range retention after set_text. moon check text --deny-warn zero warnings. The example's title recolours the leading 7 bytes MoonBit; smoke 49 frames, exit code 0. Repo-wide still 287 errors / 68 warnings, yoga-mbt 81/81.

MoonBit native GUI stack · App host and TextEditorHost closed out (core/app.mbt) ​

  • Layering landing point: the host is in core, since plan §2 assigns App/Window and dirty-region repaint to core. It gathers window, Loop, widget tree, input-method channel and present in one place, so examples and components stop copying the loop themselves. The editor seam is five callbacks on the focused widget — on_key / on_commit / on_preedit / im_context / im_cursor — rather than a host object, because input already depends on core and a reverse import would form a cycle. The four signatures recorded under G5b, process_key/commit/preedit/set_cursor_rect, map one-to-one; only the host shape changes, from holding a host object to dispatching callbacks by focus.
  • Raw event conventions, with win's code coming from the C-side WmEv: 0/1 key press/release, 2/3 button press/release, 4 pointer motion, 5 wheel. Two data slots reach MoonBit named after C's dx/dy: the button number via button (dx), the wheel's vertical delta via axis (dy, positive = scroll up); on wheel events dx is horizontal. Translation is isolated in raw_to_event so it is testable headless, since the rest of the host needs a real window.
  • Click takes over focus as a widget-tree function: Widget::focusable_at(x,y) walks the hit chain from deepest upward and returns the first widget declaring a focus_id; on Down the host updates the focus stack accordingly, the same origin as Web where clicking a form control focuses it. Widgets never touch the focus stack themselves. When focus is already on that widget, no input-method action is taken — otherwise a click inside the same field would break the composition in progress.
  • Two measured input-method issues, both cross-field leakage, found on real hardware in this batch: ① a window has one GtkIMContext, so when logical focus moves to another widget gtk_im_context_reset is required; otherwise the composition accumulated in the previous field commits into the next one. Injecting a b Tab c measured t2='第二个框岸边', where 岸 and 边 were clearly typed in the first field. Added Window::im_reset() — extern plus real stub plus stub, three places in one batch. It is called only at the instant focus actually changes; calling it every beat breaks composition continuously. The G5 rule "never call reset" refers to per-beat calls. ② Reset alone is not enough: committed text lands in the C-side buffer while the key event is being handled, so in a rapid scripted sequence a Tab event queues behind several commits, and draining one beat late routes the old widget's commit to the new widget. The switch point must therefore, keyed on the old id, drain commits, clear the old composition, then reset — App::flush_ime_for_switch(prev) runs before the focus stack changes. After the fix the same injection gives t1='hello岸边' c1=11 t2='第二个框曾', each field keeping its own text.
  • Four toolchain notes: ① loop is a reserved word, so App's scheduling field is sched; local was recorded in the previous batch. ② PointerKind has closure fields and cannot derive Eq, so decisions use match. ③ FocusStack::focus(id) is a mutating setter; highlighting in the draw path must use the new read-only is_focused(id). The example originally called focus(id) inside on_draw, so every repaint gave focus to whichever widget drew it last — the exit line read focus=3 and the first field never received keys. Query/assignment ambiguity is prevented by naming and read-only helpers. ④ Editing files with a script: str.replace(...,1) that matches nothing is a silent failure; the 8 Widget has no field errors at the start of this batch came from exactly that. Assert the hit count.
  • Injection technique, carried over with two additions: the keyboard must go through XTEST, i.e. xdotool key without --window; with --window it is XSendEvent and GTK discards synthetic events outright, see the "GUI automation" entry of this document. The mouse must be one atomic command, xdotool mousemove --window <id> X Y click 1; split into mousemove --window followed by a separate click 1, this round measured clicks=0, the same root cause as the recorded "XFCE click-to-focus blocks synthetic clicks". Also: when testing that letters reach the field verbatim, drop the IM bridge with env -u GTK_IM_MODULE -u XMODIFIERS, otherwise local fcitx5 double-pinyin turns a b into Chinese. Conversely, when testing IM cross-field leakage, keeping the default environment is the valid control.
  • Acceptance coordinates must come from the same frame as the layout being clicked — this check removed a false defect on 2026-10-12. A recorded defect claiming "hit area misaligned with rendering", where clicking a button's rendered centre triggered its neighbour and drift grew to the right, did not reproduce: measuring box boundaries from the screenshot of the same run and taking centres gave seven centres hitting themselves. The error was in when the coordinates were taken — the screenshot came from a different layout round, since the same example shifted its button row by tens of pixels before and after the host provided full-page scrolling. Clicking a new layout with old coordinates therefore "drifts", and because drift is proportional to horizontal position it looks worse to the right. Convention: injected click coordinates must come from the same run and the same layout as the evidence screenshot — take the window origin with xwininfo, screenshot the same window, measure box boundaries, then click. Hit and draw are same-source in this stack, both reading Widget::rect, and scrolling only adds an offset to the yoga layout, so there is no code path where the two fall out of frame; when this symptom appears, suspect the measurement first.
  • Verification: moon test -p NoahLiu/native-gui/core 28/28 — 6 new: the raw event translation table; commit bubbling along the ancestor chain and consume-then-stop; preedit delivered only to the focused widget without bubbling; im_context/im_cursor reachability and unreachable after hiding; a widget with no im_context declaration not joining the input-method round; focusable_at returning the nearest focusable on the hit chain and unreachable when hidden. Eight packages — core/input/render/text/win/icons/theme/sys — all zero warnings under --deny-warn; yoga-mbt 81/81; repo-wide still 287 errors / 68 warnings. New example examples/native-app, the accumulation page for this class of GUI test: smoke exit code 0, frames=49. Injection evidence: a b Tab c → t1='helloab' c1=7 t2='第二个框c' focus=2, typing plus Tab cycling; clicking the button centre (116,128) → clicks=1 with focus taken; Enter while holding focus → clicks=2; clicking the field's leftmost point, window x=17 → widget-local x=1 → caret goes to 0, then typing z gives t1='zhelloab' c1=1. This is the first objective check of click-to-position, previously visible-only. Still needs the user's eyes: caret blink, preedit underline, whether the candidate window sits at the end of the composition string.

yoga-mbt · Dirty-region incremental relayout + caches persisting across layouts (YG3e, closing the 40% cost left by YG3d) ​

  • Three acceptance checks, none dispensable: ① Change-notification surface — the 39 style setters plus append_child/clear_children/set_measure/set_baseline/set_pixel_grid all begin with mark_changed(self), which bumps a global clock and records it in the node's rev. A missed mark raises no error; it silently returns stale results, the hardest class of bug to find. ② Subtree version — at the entry of calculate_layout, scan_subtree_rev(root) writes each node's "largest rev inside its subtree" top-down. This is O(node count) integer comparison with no measurement and no math. With it, "this subtree has not changed since the last layout" means subtree_rev == laid_rev, with no parent pointer: a parent pointer plus children is a reference cycle, which this toolchain's collector cannot break, so in a long-lived GUI it would be a steady leak. ③ Argument fingerprint — layout_into records the previous (avail_w, avail_h, pinned_w, pinned_h), and skipping requires both version and fingerprint to match. After a parent relayouts, the available space or pinned size handed to a child may change, so the subtree must be recomputed even when it did not change itself.
  • Two implementation pitfalls: ① The reused value must be layout_into_impl's return value, before pixel-grid rounding, never layout_width/height — the latter is rewritten by the round_to_grid post-pass, so reusing it makes a gridded tree drift a little lower every round. round_to_grid still traverses the whole tree unconditionally, since rounding is idempotent. ② Cache invalidation moved from "clear the whole tree at entry" to a version gate: cache_fresh(node) clears a node's two caches only when cache_rev != subtree_rev. A change deep in a leaf invalidates its ancestors' intrinsic size caches, handled through subtree version rather than backtracking cleanup. A cache_slots = 16 cap was added: while the available width keeps changing this means "lose reuse, keep correctness", otherwise one cache entry per width grows without bound.
  • Bench split into three scenarios — same program, the pre-change engine taken from git archive HEAD, same machine, release, µs/round @1121 nodes: steady with repeated arguments 3652 → 4; one_leaf changing one leaf per round 3656 → 78; resize changing the root width every round and forcing a full relayout 3664 → 3602, so no regression on the full path. The same three in debug: 7622→14 / 7570→176 / 7572→5940. All three scenarios' check values, the sum of layout results, are bit-for-bit identical before and after — an independent cross-engine correctness signal, harder than any self-asserted assertion. Timings use moonbitlang/core/env's millisecond clock, kept inside the bench package so the engine itself stays dependency-free.
  • Meaning for the host: App::paint_frame calls root.relayout(w, h) every frame, which is the steady path at 4µs per thousand nodes on a clean tree, so "relayout every frame" becomes viable in a GUI. Once G7 wires text set_measure/set_baseline, one_leaf — changing one line of text — is the actual shape.
  • Test orientation and four toolchain writings: incremental layout's only correctness standard is "equal, node by node, to a from-scratch full layout of the same state", and all 10 cases assert exactly that, building the reference tree fresh in the same state and laying it out once, with no comparison of intermediate states. What was hit: ① top-level identifiers must be unique within a package — the test's snap collided with the rounding snap already in algorithm.mbt, reported as toplevel identifier declared twice rather than an ordinary duplicate-name note. ② The third clause of a C-style three-part loop accepts only i = i + 3; i += 3 is a syntax error. ③ An empty array let a = [] reports unresolved_type_variable under the zero-warning gate; write it out as let a : Array[(Double, Double, Double, Double)] = []. ④ A UInt64 duration passed through .to_double() into a function taking UInt64 is blocked by the type gate, so the unit conversion goes inside the callee. One more rule from the same batch: the reparent case first asserted left == 50 from "new parent's left margin 50", while the actual value is 150, since inline box_a is 100 wide plus the 50 margin — the fix was comparing against a reference tree built directly in the final structure instead of guessing numbers.
  • Verification: moon test -p NoahLiu/yoga-mbt 91/91 — 81 existing with zero regression plus 10 incremental: same arguments reused without changing the result; a single-point change equal to full layout; sibling subtrees not re-measured; root width change matching the reference; pixel grid plus skipping still snapping to the grid; hide-and-reshow leaving no stale coordinates; a node's own style change invalidating its intrinsic caches; structural changes not being masked; a subtree moved across parents; and exceeding the cache slot cap matching full layout every round. moon check src --deny-warn and moon check bench --deny-warn zero warnings. The eight main packages are unaffected — core 28/28 and the rest all green under --deny-warn — and repo-wide is still 287 errors / 68 warnings.

MoonBit native GUI stack · Font family enumeration and fallback + mixed-font line height (text third batch) ​

  • Enumeration implementation: the C side builds the family table once into a process-local cache — pango_font_map_list_families, then sort plus adjacent dedup, joined into a GString separated by \n, with g_string_free(out, FALSE) handing over ownership. MoonBit reads it back as "read length, then read byte by byte", since this toolchain cannot hand a writable buffer to C. Sorting is not fastidiousness: fontconfig's enumeration order follows load order and one family appears several times under different locale names, so without sorting no stable assertion is writable — 682 entries on this machine.
  • Availability testing lives in C: pt_family_available walks the cache line by line with g_ascii_strncasecmp. Case-insensitive family name comparison is fontconfig/Pango's convention; reimplementing the same ASCII folding in MoonBit would drift from the backend.
  • Three fallback conventions: ① the generic families serif/sans-serif/monospace always count as available — they are absent from the enumeration table and Pango resolves them to concrete families. ② resolve_family(candidates) takes the first available entry of a CSS font-family list and returns "" when none is available; an empty family name means "follow the system default" and is used for both measure and draw. ③ Font::resolve falls back to the empty name only when the family is unavailable, keeping available names and generic families verbatim, since keeping the requested name is what makes the measurement reflect that family's metrics.
  • One measurement corrected the expectation: an unavailable family name does not make measurement fail — Pango falls back to the default family and still returns numbers, so line_height_multi([base, Font::new("不存在的族", 13.0)])) matches passing "". The value of fallback is therefore a stable line-height convention plus a diagnosable family name, not crash prevention. Assuming "it returns None / it crashes" while writing the test would produce a false-green assertion.
  • Mixed line height settled: the line box is max ascent + max descent aggregated per font, where each inline box contributes its own font and the two maxima may come from different fonts — not "the line height of whichever font has the largest size". In implementation, pt_font_line_height's existing ascent/descent are split into FontMetrics; line_height_font/line_ascent_font become projections of it, removing two duplicated C-call assemblies; line_height_multi(fonts) aggregates; AttributedText::line_height() feeds in the base font plus every range font. Two hard properties are asserted: line height covers the drawn height from get_bounds_for, and the base font's line height cannot contain 28px content in a range — a too-short box clips glyphs, which is exactly the acceptance check the component layer needs.
  • Six toolchain notes, all hit while writing tests: ① MoonBit's String ordering is shortlex — length first, then code units — so < and compare are not lexicographic: "Agency FB" < "Aharoni" is false and "b" < "aa" is true. Use String::lexical_compare for lexicographic order and compare_ignore_ascii_case to ignore case. The family-table assertion hit exactly this: C had sorted lexicographically, yet list[i-1] < list[i] failed across the board. ② Int::to_char() returns Char?, so unsafe_to_char is required; interpolating the optional silently yields "Some('x')", with one deprecated warning at compile time and nothing else. ③ This toolchain's Buffer has no usable push/push_char; writing a character is write_char_utf8, and short strings are simpler with +=. ④ Array[Byte] has no freeze/to_fixed_array path to FixedArray[Byte]; when C needs it as Bytes, start from FixedArray::make(n, b'\x00') and write by index. ⑤ @utf8's package path is moonbitlang/core/encoding/utf8; moonbitlang/core/utf8 reports Cannot find import. ⑥ MoonBit has no single-quoted strings — '' is a lexer error.
  • Verification: moon test -p NoahLiu/native-gui/text 28/28 — 8 new: family table non-empty, ordered, deduplicated and free of the separator; availability covering case flips, generic families and the empty name; resolve_family taking the first available and falling back to empty when none; Font::resolve and resolve_fonts preserving size, weight and style; font_metrics same-source as the two existing conventions with 28px ascent greater than 13px; line_height_multi's aggregation order-independent and idempotent; in AttributedText::line_height color ranges not participating while size ranges do; line height covering drawn height. moon check text --deny-warn zero warnings and eight packages zero warnings; core 28 / input 10 / render 40 / icons 4 / theme 7 / sys 7 / yoga-mbt 91 all green; repo-wide still 287 errors / 68 warnings. examples/native-app gains a font row — the same text in two boxes, with box heights taken from the base and the mixed line_height() respectively, the right box visibly taller: smoke exit code 0, status line fam=682 use='monospace' lhm=42; the same round's xdotool injection is field-for-field identical to the previous batch, clicks=2 t1='zhelloab' c1=1 t2='第二个框c' focus=1. Needs the user's eyes: whether the right box fully contains the 28px glyphs and the left box carries no surplus whitespace.
  • Only one thing changes: master's Node { mount : (View) -> View } becomes Node { mount : (Widget) -> Widget }. The semantics — a constructor only builds the tree, and mount instantiates widgets and attaches them to the parent — and the existential-type shape where children are naturally heterogeneous, both unchanged word for word. Foundation pieces: node_of wrapping an existing widget as a node; node_of_new for create plus a configuration callback, where the callback returns the widget so chaining further subtrees stays easy; mount_children; and App::mount(children) as the content entry, marking dirty once after mounting.
  • Three relink decisions: ① In-place replacement — the new generation mounts at the end of the parent first, then move_child puts it back at the old generation's previous index, so sibling order and coordinates are unaffected by the replaced item. To support this, the layout engine gained Node::insert_child(index) / Node::remove_child_at(index), with out-of-range clamping on insert and None-with-no-change on removal, and the widget layer gained Widget::insert_child / remove_child / index_of / move_child, keeping the widget table and node table in sync and identifying removal by Widget.id, since widgets with closure fields cannot compare with ==. ② The old chain's two Windows workarounds are unnecessary: master's bind_node used post_task to defer one beat plus a whole-window refresh_layout after relinking, guarding against yoga not recomputing the root after a native remove+add, which left hit-testing permanently on the old rectangle — verified on real hardware as "one click kills all clicking in the window, irreversibly". This stack re-takes hit rectangles from the layout result every frame, App::paint_frame → relayout → collect_abs, so there is no such hole. Injection evidence: clicking three times on the declarative row and then on the button still increments the count by one. ③ Each generation's subscriptions go into the disposal bag: sub_bag_discard(gen) before rebuild, sub_bag_begin/end collecting the current generation during mount, so dead subscriptions do not accumulate with rebuild count. A side benefit is that nested bind_node is also correct — an inner sig.subscribe registered inside its own mount lands in the outer bag and leaves when the outer generation does.
  • Repaint loop: set_repaint_hook / invalidate() is a process-level single slot; App::new installs its own schedule_paint, which sets dirty and calls @win.wake(). With no hook installed, as in headless tests, invalidate() is a no-op, so the component layer may call it unconditionally. Known limit: one host per process, and multi-window waits until the host layer can locate a widget's owning App. There is no multi-window consumer today, so no speculative implementation is written.
  • Port discipline: the signal kernel and Store moved line for line from master's yue/signals.mbt / yue/store.mbt into core/signal.mbt / store.mbt; both were already free of platform dependencies and the public names Signal/Store/batch/sub_bag_* are unchanged. 19 existing tests came across whole: automatic dependency collection, a conditional branch depending only on the signals it actually read, diamond dependency convergence, batch merging, exact unsubscribe by handle, and whole-generation unsubscribe in a bag with a no-bag fallback.
  • Two writing pitfalls: ① Multiple _wbtest.mbt files in the same package share top-level helper names; a duplicate is not reported as declared twice but makes the earlier definition unused — redefining sized in node_wbtest.mbt immediately made widget_wbtest.mbt's copy report Unused function 'sized'. Grep the package's existing helpers before writing tests instead of making a second one. ② If node_of_new's configuration callback is declared to return Unit, using a widget-returning helper such as sized(w, ..) in the body is a direct type mismatch; returning Widget both drops the let _ = and lets the callback build further subtrees.
  • Verification: moon test -p NoahLiu/native-gui/core 53/53 — 28 existing, 19 ported signal/Store, 6 declarative: mount_children order and coordinates; bind_node replacing in place with sibling coordinates following; the old generation's subscriptions unsubscribed as a whole bag, leaving only 1 subscription on the source after 5 rebuilds; the parent always holding exactly one tree and never half; swap_node pre-building two trees and toggling visibility only, with the hidden tree's rectangle zeroed; invalidate a no-op with no hook. yoga-mbt 92/92 — new in-place insert/remove: removing a middle item and re-inserting matches the reference tree node by node, and out-of-range leaves the tree unchanged. Eight packages --deny-warn all zero warnings; repo-wide still 287 errors / 68 warnings. Real-window evidence: examples/native-app gains a declarative row where a click does Store.update and an in-place relink — decl=3 kids=6/6, so the root child count stays constant and there is neither a leak nor a duplicate mount — with clicks=1 in the same round, the button hit still working after three relinks, t1='hellox' c1=6 for typing plus Tab cycling, exit code 0. Needs the user's eyes: the declarative row's background alternating with click parity, which is what proves a new tree really replaced the old one rather than the text merely changing.

MoonBit native GUI stack · Widget interaction foundation (hover/press ownership, pointer capture, callbacks must not capture themselves) ​

  • State lives in the host; widgets only read: Widget gained hovered/pressed, maintained by the host on every pointer event from hit and capture — set_hover(id) / set_press(id), each traversing the tree only when ownership changes — and widgets read them directly in on_draw. The old chain's hover_group polled, since libyue delivers no move events and had to ask where the mouse was on a timer. This stack has complete routing, so enter and leave are both events: when the hover owner changes, the previous one receives a synthesized Leave. A tooltip shape with "enter but no leave" does not hold here.
  • A press enters the capture period: the dispatch decision is the pure function route_pointer(root, ev, hover_id, capture) -> (new hover, new capture, consumed, changed), and App only stores and writes back the two states. Rules: the Down hit target is marked pressed and becomes the capture owner, after which every Move/Up is delivered to the capture owner along its path to the root, with local coordinates folded as usual; Up clears the pressed state, releases capture, then re-evaluates hover from the release position; during capture hover stays put. Why it is required: without capture, once the pointer leaves the widget it never receives Up again. A stuck pressed state is only the mild symptom — measured as the widget staying permanently highlighted — while drag selection and "drag the insertion point" in a text field lose any basis. This is the equivalent of Web's setPointerCapture.
  • Callbacks must not capture the widget itself — the reference-cycle discipline: on_draw's first parameter became Widget and im_cursor now takes a Widget, so a widget reads its own rect/hovered/pressed from the argument and the handle no longer enters the closure. The reason is that widgets in this stack hold their own callback tables, so a closure capturing the widget on top of that is a reference cycle, which reference counting cannot break. A throwaway example hides the problem, while with bind_node one leaked tree per relink is a steady leak. All draw callbacks in examples and tests were updated with the signature.
  • One expectation corrected by measurement: a display:none sibling moves up, since a column container's auto height counts visible items only. The area a hidden item used to occupy is therefore hit by the sibling that moved up, not by the parent container — the first version asserted "hits the parent widget" and route_pointer returned the sibling's id directly. The implication for the component layer: no extra hover-state cleanup is needed for hidden items, because live items take the position naturally.
  • One addition to the injection technique: write a drag sequence as an atomic press, xdotool mousemove --window <id> X Y mousedown 1, then move with xdotool mousemove --window <id> X2 Y2 and release with xdotool mouseup 1 — no --window, so XTEST releases at the current position. Measured: press inside the band, release outside it, and the capture band still receives the release.
  • Verification: moon test -p NoahLiu/native-gui/core 59/59 — 6 new: hover following with Leave synthesized; during capture both motion and release delivered to the presser with hover staying put; clicking empty space not entering capture and not crashing; a hidden widget not participating in hover while a sibling moving up takes the hit; by_id and dispatch_pointer_to delivering by identity with coordinates playing no part in the decision; wheel following the same bubbling rule. Eight packages --deny-warn all zero warnings; yoga-mbt 92/92; repo-wide still 287 errors / 68 warnings. Real-window evidence: examples/native-app gains a "capture band" whose draw state reads w.pressed/w.hovered directly — press inside the band, drag to (200,245), drag to (300,100) and release → band=d1/u1/m3, so the release made outside the band was still received — with the button's clicks=1 and the declarative row's decl=2 kids=7/7 unchanged in the same round, exit code 0. Needs the user's eyes: the band keeping its pressed color, orange, through the drag and returning to normal after release, with the hover color no longer chasing the pointer.

MoonBit native GUI stack · Component layer, first batch (new package ui: text measurement feeding back into layout + container/label/separator/image/icon/button) ​

  • Why not written into yue/components: that directory is the old chain's component implementation, referencing the @yue root package and @declarative, and is the main source of the repo's 287 errors. Each new-stack batch rebuilds only the semantics worth keeping; placing it in the same package would make the per-package gate moon check <package> --deny-warn permanently unexecutable, since the old files' errors would be reported together. The new component layer therefore starts a separate ui package, the old package is replaced or deleted batch by batch, and the accounts settle when G7 closes.
  • Text measurement fed back into the layout engine, the piece G7 owed: attach_text_measure(w, src) sets both set_measure, taking size from measure_font, and set_baseline, taking it from line_ascent_font, on the node. Text therefore takes part in a real align-items: baseline layout for the first time, and a label's width and height are the true sizes Pango reports. Two assertions corrected by measurement: ① a column container's default align-items: stretch pulls the label to the full row width — to observe the measured width the parent must use FlexStart or the label must carry an explicit width; the first attempt asserted the measured width and got 240 instead of 70. ② Under baseline alignment the top-edge difference equals the difference of the two ascents, with the larger size sitting higher; the difference was written backwards once. A min-content query, with the requested axis Some(0.0), is currently treated as unconstrained — a documented approximation to be refined once ellipsis self-sizing is wired.
  • Pointer callbacks also take the widget itself: on_pointer's first parameter became Widget, under the same reference-cycle discipline as on_draw. A button needs self.rect to decide whether the release still lands inside itself — capture guarantees the button receives a release made elsewhere, but only its own rectangle distinguishes inside from outside.
  • Two naming divergences from master, documented with no compatibility layer: ① type in button(type="text") is a MoonBit reserved word and a syntax error as a parameter name; it becomes kind : ButtonKind with Primary/Border/Text. ② The disabled color scheme temporarily uses a fill_hover base with text_secondary text; a real disabled palette joins Theme with the theme batch, since none of its 20 fields covers it today.
  • Theme following is one process-level subscription: components call watch_theme() on mount, idempotently, and a theme change triggers one invalidate(). Per-widget subscription is deliberately not done — invalidate is global anyway, and per-widget subscriptions would leave one dead subscription per relink while buying no precision. Colors are read fresh inside on_draw rather than fixed at tree build, so a theme switch shows in the next frame.
  • The counterpart of text binding: label_dyn(sig : Signal[String], ...) occupies the old chain's bind_label position — text changed in place, measure callback re-set, node marked dirty, repaint requested, no subtree rebuild. The subscription is retired into the generation's disposal bag through @core.sub_bag_register, which was promoted from package-private to public for that purpose, since all component-layer cleanup goes through it.
  • Verification: the new package moon test -p NoahLiu/native-gui/ui 7/7 — label size coming from measurement and re-measured on signal; the top-edge difference of two sizes under baseline alignment; a button's release inside versus outside; disabled consuming nothing and not calling back; hover changing colors while the text still lands inside the box; container/group/separator geometry with the horizontal line spanning the full width; an icon occupying space by size and really producing pixels, plus a missing image file not crashing. Nine packages — core/input/render/text/win/icons/theme/sys/ui — --deny-warn all zero warnings; core 59 / input 10 / render 40 / text 28 / icons 4 / theme 7 / sys 7 / yoga-mbt 92 all green; repo-wide still 287 errors / 68 warnings. Real-window evidence: examples/native-app gains a bottom row built entirely from @ui constructors — label, vertical separator, icon, an 加一 button and a 禁用 button — and the window grows to 420×372. Injecting xdotool mousemove --window 135 278 click 1 twice gives comp=2; clicking the disabled button next leaves the count unchanged with decl=0, so disabled really does not consume events; kids=8/8 keeps the root child count constant across repeated relinks; exit code 0. The existing evidence in the same round — typing, Tab, click-to-position, capture band — did not regress. Needs the user's eyes: the component row's hover/press colors, whether the vertical separator really spans the row height, and the baseline appearance of icon and text on one row.

MoonBit native GUI stack · Form controls, first batch, and two rendering defects only a screenshot reveals ​

  • Defect 1 — text ignored the widget's origin: the text layer, pt_draw*/rt_draw, built its own surface from Bitmap.data and drew glyphs onto it, a path that does not pass through the Painter's CTM and clip. The host's per-layer translate + clip_rect therefore had no effect on text at all, and on the real window every widget's glyphs piled up at the window's top-left — see the before/after screenshots in the "组件首批" entry. The fix hands the Cairo session over: the render stub gains void *cr_session_context(int64_t id) returning that session's cairo_t*, and the text stub gains pt_draw_box_cr and rt_draw_cr, which on the caller's context do cairo_save → set_source_rgba → move_to → pango_cairo_show_layout → cairo_restore. save/restore preserves the caller's source and clip and leaves nothing polluted. The MoonBit counterparts are Painter::cairo_id(), @text.draw_box_at / draw_at / draw_text_at and AttributedText::draw_at. The older draw_to/draw_in_box/draw(target,…) stay, since offscreen self-drawing and the chart buffer still need them.
  • Defect 2 — nested drawing accumulated offsets twice: Widget::paint did p.translate(self.rect.x, self.rect.y) layer by layer while rect is an absolute rectangle, so from the second layer up it added the ancestors' offsets again and the second row's text landed outside the window. Changed to paint_at(p, ox, oy): translate only the difference from the already-translated origin, and pass this widget's absolute origin down to its children.
  • Why earlier batches missed them: in batch 1's drawing case the middle widget happened to sit at (0,0), where absolute equals relative, so the test passed naturally; and the example's status line reports counts and text, never pixels. Conclusion: for a GUI, "exit code 0 plus correct status" is not "correct picture." The accumulation page therefore keeps two machine-checkable paths: the NATIVE-APP-RECTS geometry line, giving each root child's x,y,wxh, from which injection coordinates are taken instead of guessing at a screenshot; and import -window <id> x.png whenever the picture itself must be inspected.
  • Linking across static libraries: the text stub references symbols from the render stub, so moon test -p .../gui/text failed at link time with undefined reference to cr_session_context, invisible at compile time. Per AGENTS rule 1, link flags change only in scripts/prebuild.py: text's link_flags gained -lrender_cairo_stub, consumer first in the order.
  • Disabled state must be a cell, not a boolean: the test caught a real bug in the stepper — with disabled fixed into the closure at tree build, once a value reached the boundary the -/+ buttons' disabled state stayed frozen at the moment of mount. Changed to Enabled { mut off : Bool }, read fresh in drawing and events; composite widgets — the checkbox row, the switch, both sides of the stepper — share one cell, and set_disabled(cell, v) changes it at any time.
  • Drag state is held by the component itself: the slider uses dragging : Ref[Bool] rather than reading the host's pressed, because the host's pressed is only set in a real event stream while the component must be able to complete a drag under any delivery path — otherwise headless cases could not exercise dragging at all.
  • Two coordinate conventions, found across four cases in this batch: ① dispatch_pointer / dispatch_pointer_to take window coordinates, converted to local only at delivery; passing local coordinates in a test silently misses, which presents as "the click did nothing". ② The coordinate-based dispatch_pointer cannot deliver a drag that started outside the widget; out-of-bounds motion must go through root.dispatch_pointer_to(w.id, ev), the equivalent of the host's capture-period delivery.
  • Seven toolchain writings: a match with several subjects must be written as a tuple, match (a, b) { (Some(x), y) => ... }; Double::clamp takes labelled min=/max=; Int::to_char() returns Char? and needs unsafe_to_char, otherwise it silently interpolates "Some('x')"; String.split yields Iter[StringView] and StringView::to_string is deprecated in favour of to_owned; Array[Byte] has no freeze/to_fixed_array, so when C needs it as Bytes start from FixedArray::make; a missing closing ] in Option[(A, B) -> R] produces an unrelated unexpected token infix; continuation lines cannot start with + or &&, since MoonBit breaks statements at line ends, and type is a reserved word that cannot be a parameter name.
  • Verification: moon test -p .../gui/ui 15/15 — 7 new: checkbox click, toggle-again, out-of-bounds and disabled-not-consuming; radio group writing per item with clicking the selected item changing nothing; switch size fixed and drawing following the Store's current value; slider press taking the value, out-of-bounds drag, step snapping, disabled not consuming; progress bar filling by value; stepper clamping and auto-disabling at the bounds; Store subscriptions retiring with the disposal bag, sub_count=0 after 5 relinks versus one permanent subscription without a bag. Two rendering regressions also added: text landing with the widget's origin and being clipped, and rich text drawn through the session landing inside the translated box. text 29/29; nine packages --deny-warn all zero warnings; core 59 / render 40 / icons 4 / theme 7 / sys 7 / yoga-mbt 92 all green; repo-wide still 287 errors / 68 warnings. Real-window injection with coordinates taken from NATIVE-APP-RECTS: form=true/false/90/1 — checkbox to true, switch to false, slider dragged to 90 snapping to step 5, stepper +1; decl=3 kids=9/9, so the declarative in-place relink did not regress; band=d3/u3/m3, three releases outside the band all received; clicks=2 t1='zhelloab' c1=1 for typing, Tab and click-to-position; exit code 0. Screenshot review: title, both input fields, the count button, the mixed line-height boxes, the component row and the form row all correctly placed.

MoonBit native GUI stack · Entry full semantics (ui/entry.mbt) ​

  • Shape: the single authority for text state remains @input.Editor; the component keeps no second copy. Store[String] is only its outward projection — after an edit it writes back to the Store, and when something external sets the Store the value flows back into the kernel through a subscription, retired via subscribe_id plus the disposal bag so relinks leave no dead subscription. The seam to the host is the five foundation callbacks, and the four input-method conditions are satisfied centrally by App::pump_ime; the component only supplies im_context, committed text plus byte caret, and im_cursor, the candidate window's placement rectangle.
  • Focus highlight is now synced by the host: added Widget.focused and Widget::set_focused_by_focus(focus_id), with App::paint_frame syncing once before each frame's drawing. Same reasoning as hovered/pressed: the focus stack records ids only, while a component needs "is that id me". Having each component ask the focus stack itself would both fail to reach the stack and leak core's focus structure into the component layer.
  • A read-only whitelist must be written by keysym family: the first version allowed 65288..65300 as navigation, so Backspace, 65288, passed as navigation and the read-only field still deleted characters — caught by the test. The real reading: arrows, Home, End and PageUp/PageDown are at 65360..65367, while Backspace 65288 and Delete 65535 are editing keys. Settled as: allow Ctrl/Shift combinations, 65360..65367 and Tab; Backspace, Delete and printable characters are never swallowed.
  • Double-click detection: two Down events less than 400ms apart and less than 4px apart, using @core.now_ms(), which forwards @win.now_ms(). The side effect is on record: rapid repeated clicks are classified as double-clicks, so a headless case doing "press-release-press" to test dragging takes the word-selection branch on the second press, and the assertions must be written to that semantics.
  • CJK word selection is where the current approximation shows: is_word_byte counts everything >= 0x80 as in-word, matching the kernel, so a double-click in Chinese selects the whole contiguous run of Chinese. Measured on the real window: double-clicking at the end of 组件框ab selected the entire 组件框ab and the replacement left Q. The ASCII side behaves normally — space-delimited, so in 组件框ab c a double-click selects only c. Whether to converge on Blink's dictionary word segmentation is deferred until the Blink cross-check technique reaches text; recorded, not guessed.
  • Drag selection: press records the anchor, and during capture each Move extends the selection with select_range(anchor, byte_at_x(x)), continuous even when the pointer leaves the field — the second consumer of the host's capture period. Up ends the drag, and later Move events no longer track, asserted by the caret not changing.
  • A shortcut in the observation channel: the kernel handle is not exposed, since text authority lives in the component; headless cases observe through two public paths instead — w.im_context() giving text plus caret, and the behaviour "typing replaces the selection", which is itself the selection check. Cleaner than opening a pile of getters for the sake of testing.
  • Verification: moon test -p NoahLiu/native-gui/ui 25/25 — 10 new entry cases: keystrokes writing back to the Store with Backspace and undo; commit landing in the text while preedit only enters drawing; click at the far left/right positioning; double-click word selection; drag to select all with no tracking after release; Shift+arrow extending the selection plus Ctrl+A/C/V on the internal clipboard; read-only allowing navigation and copy; password masking keeping the original text in the Store; placeholder producing pixels while the Store stays empty; external Store changes flowing back into the kernel with focused synced by the host. Nine packages --deny-warn all zero warnings; repo-wide back to 287 errors / 68 warnings, having briefly reached 69 from one unused variable in a test. Real-window injection, with examples/native-app gaining an @ui.entry row at focus_id=4: click into the field → type ab c → double-click at the end → type Q → Home+Shift+End+Delete+Ctrl+Z → status line t3='组件框ab Q', focus=4, exit code 0. Screenshot review confirms the component field placed and outlined normally. Needs the user's eyes: caret blink, driven by the caller's clock through the component's caret_on?, preedit underline, and whether the candidate window sits at the end of the composition string.

MoonBit native GUI stack · Display family, first batch (ui/display.mbt: label / badge / avatar / separator / alert / card / descriptions / empty / result / polyline progress) ​

  • One difference from master, on record: the CSS-style style=[("marginRight", 8.0)] channel is not wired yet and comes with the theme batch's style channel, so this batch keeps typed parameters only. The semantic color enum SemanticType — Primary/Success/Warning/Danger/Info — with type_color/type_light_color keeps the same names, and colors are read from theme's five main and five light levels, which the theme batch already provides, so no new fields were needed.
  • Two rules for self-drawn components, hit in this batch and written into the code comments: ① Draw callbacks must not change layout — the first version of statistic called set_width with the measured width inside on_draw, making paint a writer of layout: layout and drawing trigger each other within one frame, and the dirty-region incremental relayout's fingerprint check is disturbed by the writer itself. Width must be fixed at mount from measurement. ② A widget must be tall enough for its own glyphs — the polyline progress's percentage column first reserved the same 8px as the bar, so 11px text was clipped in half, visible only in a screenshot; text nodes get at least the glyph height and containers stretch to the tallest child.
  • The close hot zone of a dismissible alert: the whole alert consumes the press so the click does not fall through to what is underneath, but on_close fires only in the top-right 24×24. The hot-zone test uses local coordinates, ev.x >= rect.w - 24 && ev.y <= 24, which still holds under capture since the release returns to this widget.
  • Verification: moon test -p NoahLiu/native-gui/ui 33/33 — 8 new display-family cases: five semantic colors pairwise distinct and all valid hex; label and badge sized by content showing 99+ past the cap; avatar's first character producing pixels; a text separator spanning full width with lines on both sides; an alert with a description being taller and close firing only in the hot zone, a click outside not counting; a card with a title taking two extra rows, title plus divider; descriptions row count by entry count; empty/result really producing pixels; polyline progress having two columns with the fill following the Store. Nine packages --deny-warn all zero warnings; repo-wide still 287 errors / 68 warnings. Real-window evidence: examples/native-app gains a display-family block with a geometry line giving coordinates — clicking the alert outside the hot zone gives closed=0, clicking the top-right twice gives closed=2, with the component button's comp=1 and kids=11/11 in the same round, exit code 0. Screenshot review: three label colors, badges 7/99+/dot, the avatar 刘, the 或 separator, the alert's left color bar and description, progress bar and percentage all correct.
  • New channel: process-level delay — set_timer_hooks/set_timeout/clear_timeout in core/loop.mbt. A component wants "run once after a while" and must not know which host it belongs to, so it is a process-level single slot like set_repaint_hook: App::new forwards both functions to its own Loop, timer/cancel, and components only call @core.set_timeout(ms, f). With no host, set_timeout returns 0 and does nothing — the component keeps working, it just does not advance by itself. Headless tests can therefore install their own channel, take the callback and dispatch it manually, so the carousel's self-scheduling chain becomes deterministically testable instead of waiting on a real clock.
  • Three carousel shape decisions: ① Panels are all pre-built and page switching only flips Widget::set_visible — same reasoning as swap_node: not destroying a subtree leaves no dangling callback and needs no remount. ② The arrow's hover background reads the host-maintained Widget.hovered directly, dropping master's on_mouse_enter/on_mouse_leave — interaction state is set centrally by the host and components only read it. ③ Hover-pause relies on the Move/Leave this widget receives from the parent chain, since events bubble from the deepest hit widget upward and the first true consumes. If the page content consumes both motion and leave, the pause gets no signal — not speculation but a necessary consequence of the routing rule, which is why carousel_pause(handle, v) exists for callers to take over directly.
  • Retirement of the self-scheduling chain: a one-shot delay whose callback schedules the next beat, with the handle stored in CarouselHandle.timer; carousel_stop both sets running=false and calls clear_timeout to cancel the pending beat. The test measured the boundary semantics: after stop, the beat already queued still runs once, but the running guard makes it neither switch pages nor schedule again — so cancelling the handle cannot be the only safeguard and the running flag must exist alongside it.
  • Timeline's vertical line convention, EP as-is: the first row starts at its dot, the last row ends at its dot, middle rows run through. Row height is fixed and drawing writes local coordinates only, since the host translates and clips layer by layer and widgets need no clipping of their own.
  • Two toolchain/test measurements: ① Constructors of the same name across payload-free enums report The constructor Primary is ambiguous — this repository has Primary in both ButtonKind and SemanticType — so annotate the element type on the array literal instead of writing fully qualified names that pollute the assertion. ② The indicator dot is only 8×8, so taking the injection point at "half the row height", y+12, lands outside the hot zone; the symptom is "clicking does nothing" with no error at all. Before injecting by coordinates, re-read that widget's own rect — this example therefore added the NATIVE-APP-CAR/NATIVE-APP-AUTO geometry lines, giving the arrows, the last indicator dot and the auto-advance panel directly.
  • Verification: moon test -p NoahLiu/native-gui/ui 37/37 — 4 new closing display-family cases: timeline rows equal height with no line above the first dot or below the last, and dot and line in different colors; carousel pre-building three pages showing only the current one, with arrows and indicator dots switching and wrapping; auto-advance going through the host channel, paused not advancing but keeping the chain alive, stop cancelling the pending beat; empty entries not crashing. moon test -p NoahLiu/native-gui/core 60/60 — 1 new: with no channel installed set_timeout returns 0 and does nothing, and once installed set_timeout/clear_timeout forward as a pair while handle 0 forwards no cancellation. Nine packages --deny-warn all zero warnings, yoga-mbt 92/92, repo-wide still 287 errors / 68 warnings, no increase. Real-window evidence: examples/native-app gains a "display family close-out" block with the window grown to 560×900 — injecting right arrow, then last indicator dot, then left arrow gives car=2, a unique index that proves both arrows and the indicator each worked; the baseline auto-advance reads auto=6; parking the pointer on the auto-advancing carousel without moving gives auto=2, hover-pause measured. Screenshot review: timeline's three-color dots and continuous lines, both carousels' arrows and highlighted indicator dots all correct.

MoonBit native GUI stack · Date family, first batch (value model + self-drawn calendar panel, "today" from platform calendar time) ​

  • New platform reading wm_local_date — two C stubs in win plus an extern in ffi.mbt plus the public API in window.mbt, forwarded by core as local_ymd(). A calendar needs calendar time, while the stack's existing now_ms() is CLOCK_MONOTONIC, a monotonic clock used as the loop's time base; deriving a date from it gives a stable but wrong number. The C side uses time() plus localtime_r through the system time zone, packed as year*10000 + month*100 + day. Failure and the portable stub return 0, which core turns into (0,0,0), and only then does the component layer's today_date() fall back to 1970-01-01. The layering is "the platform provides the reading, upper layers decide the fallback"; this layer does not guess a date.
  • today is an explicit parameter: calendar_t(today? : DateYMD = today_date()) defaults to the platform and takes a fixed date in tests. Otherwise an assertion like "which cell does 1 October land in" drifts with the day it runs, which is no assertion at all.
  • The 42-cell grid math extracted as the pure function month_grid(y, m) -> Array[(DateYMD, Bool)], one more than master: the panel only draws and handles clicks, and the grid rules — the previous month's tail before the first cell, the next month's head after the month end, month cell count equal to the month length — are testable without a widget. The date picker and the range picker then share the same function instead of each re-deriving it.
  • All 42 cells built once; changing month changes data only: the panel structure is constant, so hit rectangles cannot go out of position through adding or removing child widgets. Widget::hit_path takes rectangles from the layout result every frame, but rebuilding a subtree would still break the pairing of a press and a release across generations. rebuild_grid writes only each cell's Ref[DateYMD]/Ref[Bool] and then calls invalidate().
  • Selection read fresh from the Store, no cached copy: state_of reads value.get() on every paint, falling back to the widget's own sel only when there is no Store, so an external v.set(...) replaces the solid square immediately with no "subscribe then refresh manually" step. Master's refresh() callback chain is replaced by read-at-draw-time in this stack; subscriptions only request a repaint, follow_store.
  • Select on release, cross-month cells not selectable: a cell's Down consumes only when in_month, so a cross-month cell does not even take the press and the event keeps bubbling; Up additionally requires the release still landing in that cell — capture delivers the release back to the pressed cell, and out of bounds it does not count as a click.
  • Two toolchain measurements hit while writing tests: ① (x) -> Bool { ... } is not a closure literal, it is a function type annotation; Array::filter needs fn(x) { x.1 }. Getting it wrong produces a cascade of unrelated cannot infer / unbound / Parse error messages. ② On this toolchain, derive(Eq, Show) on a value type has Show deprecated, pointing at Debug, while derive(Eq) and derive(Debug) both raise the implicit_impl_as_method deprecation warning — not worth it under a zero-warning gate. Assertions therefore go through string observation, format() and cell state names, which adds no derives to components and produces more readable failure output.
  • Verification: moon test -p NoahLiu/native-gui/ui 44/44 — 7 new date-family cases: leap years, month lengths and weekdays through three Sakamoto anchors, 2026-10-11 Sunday and 2000-01-01 Saturday; month stepping including ±12 and 15 months; formatting zero-padding, time clamping and range endpoints in either order; the 42-cell grid, October's first cell 2026-09-27, cell 5 being 10-01, last cell 11-07, month count 31, a leap February having 29 cells, and January's cross-year first cell landing in the previous December; local today must be a real year, proving calendar time rather than the monotonic clock; the panel writing to the Store on click with cross-month cells unselectable and release outside not counting; the selected date following after a month switch with the selected cell drawn as a solid square, compared against whether that spot is still the panel background since the panel base is opaque; an external Store change switching the selected cell; range cell mapping for endpoints, middle and half-open ranges. Nine packages --deny-warn all zero warnings, yoga-mbt 92/92, repo-wide still 287 errors / 68 warnings, no increase. Real-window evidence: examples/native-app rearranged 6b/6c into three columns with the window changed to 900×700 and the geometry line gaining NATIVE-APP-CAL — injecting right arrow, last indicator dot, left arrow, then "cell 14 → right arrow to November → the same cell again" gives the status line car=2 auto=6 date='2026-11-15', kids=12/12, exit code 0. Screenshot review: three columns side by side, the calendar header 2026 年 10 月, September's tail greyed out, indicator highlight all correct.

MoonBit native GUI stack · Navigation family, first batch (segmented / breadcrumb / pagination / steps / collapse panel) ​

  • master's stateful_item becomes "read at draw time plus host interaction state": the old form kept two Refs per item, hover/focused, set and repainted inside enter/leave/focus callbacks. In this stack hover and press ownership are maintained centrally by the host from hit results — Widget.hovered/pressed/focused — so background and text color callbacks take only this widget and read as they draw; components no longer handle enter/leave. Two benefits: rebuilding a subtree cannot leave "the pointer has left but hover is still true", and the subscription surface drops from two per item to one per group, since follow_store exists only to request a repaint.
  • Selection always through the Store, read at draw time: segmented/breadcrumb/pagination all "set on click, get on draw", so one Store attached in several places highlights everywhere in step, with no registry for "notify each widget after a change".
  • Pagination arrow clamping lives in the host callback rather than in disabled buttons: ‹/› stay clickable on the first and last page and the value clamps to [1, pages]. Different from Web's disabled form, but one less piece of state — with mutable pages there is no recomputing disabled — and a headless test can assert directly that eight consecutive left-arrow clicks still sit at 1.
  • Collapsing sets set_visible(false), display:none, without destroying the content: content subtrees are pre-built and resident, so opening and closing flip visibility plus invalidate() only. The test proves collapsing really stops occupying space by asserting the whole block's height falls; asserting visibility alone would miss "hidden but still taking height". Each panel's independent open state is held by its own Ref[Bool], so panels do not interfere.
  • One assertion I wrote wrong, recorded to prevent repeating it: the steps widget interleaves "connector before the square", so the child order is 方块0 文字0 连线1 方块1 文字1 …; guessing squares at indices 0/2/4 lands on text nodes, which have no background, so two samples come out the same color and the assertion l1 != l3 fails as false. Pixel assertions on self-drawn components must re-read the actual child order first, not guess the structure from the visual order.
  • Verification: moon test -p NoahLiu/native-gui/ui 49/49 — 5 new navigation-family cases: segmented setting the Store on click with segment width following the text, and the selected segment's background differing from unselected; breadcrumb separator taking space, clicking a non-last item changing the value and clicking the last item not; pagination clicking a page number landing directly, arrows stepping, and repeated clicks at both ends clamping to 1/5; steps having three mutually distinct background colors with the connector colored by the current step and a pending block changing color after a Store change; collapse panels opening only the first by default, each block toggling independently, and the whole block's height falling after collapse. Nine packages --deny-warn all zero warnings, yoga-mbt 92/92, repo-wide still 287 errors / 68 warnings, no increase. Real-window evidence: examples/native-app laid this batch into the fourth column with the window at 1240×840 and the geometry line gaining NATIVE-APP-NAV, giving the four injection rectangles directly — injecting "segmented second item → breadcrumb second item → page 3 → second block's header row" gives seg='分段乙' crumb='列表' page=3 step=1 fold='11', where fold='11' proves the second block opened without dragging the first along; kids=12/12 in the same round, the earlier car/date readings unchanged, exit code 0. Screenshot review: segmented highlight, breadcrumb separators, solid page number, steps' three states with connectors, collapse arrow directions all correct.
  • A pitfall in the verification technique, nearly misjudged as "components not working": consolidating three batches into one injection round of 10 clicks made the last three ineffective — not because the navigation family was unwired, but because the example's 5-second auto-exit beat the injection. The acceptance check is "the same coordinates work when injected in separate rounds, and only the first few land when merged". The accumulation page's live window therefore moved from 5s to 9s; every future block must check live time against click count × per-click interval, and exit code 0 is not evidence that "all interactions passed".

MoonBit native GUI stack · Navigation family, second batch (side menu / grouped menu / tree / tabs) ​

  • Rounded corners implemented in the component layer rather than waiting on the Painter: the facade has no rounded rectangle, so fill_round_rect composes a path inside ui from four line segments plus four 90° arcs, with arc angles always increasing — Painter::arc's ccw parameter is unavailable on both backends, so a counter-clockwise need is expressed by increasing start and end angles. The menu's 4px corners and the selection accent bar therefore match EP's appearance without adding a new render-layer contract.
  • Tree and collapse panel share one shape: pre-built children toggled by set_visible, with collapse meaning display:none — not measured, not occupying space, not drawn, not hit — and expansion flipping the flag only. Node rows are appended in pairs of "row plus child container", so tests take the row at children()[2i] and the container at children()[2i+1]. A leaf row does not even consume Down when has_kids is false, and the event keeps bubbling, so there is no "click did nothing but the parent's hover got eaten".
  • Tab visibility is driven by a Store subscription, and the new page is shown before the old one is hidden: that order was a Windows-only workaround in master, since the reverse let yoga allocate the outer box at zero content in the intermediate state. This stack relayouts from the root every frame and has no such hole, but "show first, then hide" is harmless to keep and consistent with swap_node's pre-built shape. The tab header row enters the tree before the page area, so visual order equals child order, pinned in the test by head.rect.y < page.rect.y.
  • Two places where the tests were written wrong, both of the "inject by coordinates" class and worth memorizing: ① Sample pixels must be taken at each control's own y. The three side-menu items share width and x, so using the first row's y to sample the second row still samples the first row, the selected one, and "an unselected item should match the container background" failed as "both are light blue". The symptom looks like a broken component; the assertion sampled the wrong point. ② A hidden widget's rect is zeroed — zeroing the whole invisible subtree is G6's convention — so "click an item inside a collapsed group" does not hit that item but another widget near the origin; here it hit group one's header row and expanded it again, presenting as "it changed though I did nothing". To verify "collapsed means unclickable", test hit_path/hit_test explicitly in window coordinates rather than adding an offset to the widget's own rect.
  • Verification: moon test -p NoahLiu/native-gui/ui 53/53 — 4 new second-batch cases: side menu item width following the container, clicking an item setting the Store, and the selected item's background differing from unselected with sampling per row; grouped menu headers affecting only their own group, the whole column moving up after collapse requiring coordinates to be re-taken, and items selectable again after re-expansion; tree children pre-built hence collapsed, clicking the parent row expanding, clicking a leaf doing nothing, clicking again collapsing; tabs pre-building three pages showing only the current one, switching both on tab click and on external Store change, with the header row above the page area. Nine packages --deny-warn all zero warnings, yoga-mbt 92/92, repo-wide still 287 errors / 68 warnings, no increase. Real-window evidence: examples/native-app adding a fifth column with the window at 1620×840 and the geometry line gaining NATIVE-APP-NAV2 with the menu's second item, the tree parent row and the second tab page rectangles — a merged 13-click round, three carousel clicks plus three calendar clicks plus four first-batch navigation clicks plus this batch's three, gives car=2 auto=11 date='2026-11-15' seg='分段乙' crumb='列表' page=3 step=1 fold='11' menu='报表' tab=1 tree=1, kids=12/12, exit code 0. The tab click must precede the tree expansion, since expanding pushes the tabs down. Screenshot review: the menu's light-blue rounded background with the left accent bar and the 报表 icon, tree triangle directions, tab indicator bar and page content all correct.

MoonBit native GUI stack · Transfer box closed out (navigation family 10/10) and two coordinate disciplines for rebuild-style lists ​

  • Selection moved to two component-owned tables, rows holding no subscriptions: master's transfer subscribed to the data Store once per row built, retiring the whole bag with sub_bag_begin/end/discard before each rebuild so dead subscriptions did not accumulate with moves. In this stack a row reads only its own Ref[Array[String]], with the selection stored by value, so a rebuild is "clear children and re-mount from the Store's current value" and there is no row-level subscription to leak at all; the bag is left for the two column Store subscriptions, one per column. The cost of removing that layer of mechanism is that the selection is not part of a Store; the gain is that rebuild count and subscription count are decoupled.
  • Store.set does not check whether the value actually changed: an empty move, with an empty selection, still calls from.set(remain) plus to.set(merged), both Stores notify and both columns rebuild again. That is Store's existing semantics — no deduplication, see core/store.mbt — and the component adds no "skip set when equal" check, since that would also block the legitimate "same value, still refresh the view" use. Tests must therefore be written to this semantics: re-take coordinates after any click that may trigger a set.
  • Two disciplines for "inject by coordinates plus rebuild-style list", hit in this batch: ① A freshly rebuilt row has no rectangle until the next layout, so in headless tests any operation that may rebuild must be followed by a manual root.relayout(...); the real window is unaffected because the host relayouts every frame. Missing it presents as "clicking does nothing", since a zero rectangle plus an offset hits somewhere else. ② In a merged injection round, any click that changes layout must come after the widgets below it in the same column. Here clicking the tree parent row first, expanding it by +64px, then clicking the transfer made both transfer clicks miss, leaving xfer=3/1 unchanged; moving the tree click last gave xfer=2/2. The geometry line reports the last frame's rectangles, so the injection order must match it, otherwise it clicks a new position with expired coordinates.
  • Verification: moon test -p NoahLiu/native-gui/ui 54/54 — 1 new transfer case: click a row to select, arrow moving items, selection cleared after the move so a second arrow click moves nothing, moving back in reverse, and an external data change rebuilding the column, including both relayout disciplines above. Nine packages --deny-warn all zero warnings, examples/native-app zero warnings, yoga-mbt 92/92, repo-wide still 287 errors / 68 warnings, no increase. Real-window evidence: examples/native-app adding the transfer to the fifth column with the window at 1620×900 and NATIVE-APP-NAV2 gaining the xrow/xbtn injection points — after reordering the 15 merged clicks to "click then move", car=2 auto=11 date='2026-11-15' seg='分段乙' crumb='列表' page=3 step=1 fold='11' menu='报表' tab=1 tree=1 xfer=2/2, exit code 0. Screenshot review: both columns outlined, squares at the row head, and the ›/‹ button directions correct.

MoonBit native GUI stack · Scroll container (yoga gaining relative-position offsets + ui/scroll.mbt) ​

  • Scroll translation lives in the layout layer, not the widget layer: position: relative plus top: -offset moves the whole content block up, and a relative offset changes placement only, not normal-flow occupancy — the container stretches to the content's natural height and later siblings are unaffected, exactly the semantics scrolling needs. Both hit testing and drawing consume layout results, since the host takes absolute rectangles from collect_abs every frame, so the component layer needs no second coordinate conversion. To get here, yoga-mbt gained relative-position offsets: top/left take priority, the other end of the same axis is ignored when both are given, auto does not offset, percentages resolve against the parent's content box; absolutely positioned items do not take this offset, since their top is an inset measured from the padding box. All three are pinned in relative_wbtest.mbt.
  • Content must have flex-shrink: 0: in CSS flex items shrink by default, so when the content is the only child it is compressed to the viewport height and never scrolls. This class of "a scrollable container whose content neither deforms nor scrolls" only shows up when you actually scroll once, which is why the case pins the geometry first with "20 rows × 20 high = content 400 / viewport 100 / max 300".
  • The scrollbar is an absolutely positioned child of the viewport, not a layer of color painted on it: it sits at the right edge above the content, so on the hit path it is the deepest widget and a button inside a content row cannot steal its press or drag. The form where it is painted in the viewport's on_draw gets swallowed by content children's hits, and this "visible but unclickable" only shows when you actually click. The thumb length is the viewport-to-content ratio with a 16px floor so it stays grabbable, and dragging maps thumb center to pointer: offset = clamp((y - thumb/2) / (bar height - thumb)) * max. Dragging to the bar's midpoint measured scr=171/320, bit-for-bit with the hand calculation.
  • Geometry copied into state at the first frame's draw: viewport height and content height both come from layout results and can only be read in the draw callback, as self.rect.h and the content child's rect.h, so ScrollState.view_h/content_h is refreshed each frame by the viewport's on_draw, and both the bar ratio and out-of-bounds clamping consume them. The cost is that max() is still 0 before the first frame after mounting, when the wheel does nothing; on the real window every frame draws so it goes unnoticed. Headless tests must paint once before asserting scrolling, which is written into the case comment.
  • One diagnostic mnemonic, which cost ten minutes here: a missing closing bracket in a type, children : Array[@core.Node>, reports Inline wasm syntax error: unknown operator ?STRING plus The type alias Array expects 1 argument(s), but is here given 5, with the error spanning the whole signature and far from the site. When an error is semantically unrelated in that way — "Array argument count wrong", "Inline wasm" — re-read the signature's bracket pairing before speculating about language features. Control experiment: replacing the body with an empty one still reproduces the error, which locates the problem in the signature.
  • Verification: moon test -p NoahLiu/native-gui/ui 58/58 — 4 new scroll-container cases: viewport/content geometry coming from layout with the wheel changing offset and clamping at both ends; content moving up as a block with the scrollbar above it, proven on the hit path; clicking the track jumping and dragging the thumb, with no tracking after release; content shorter than a viewport neither scrolling nor drawing a bar, proven per-pixel that the row background shows through. moon test -p NoahLiu/yoga-mbt 96/96 — 4 new relative-position cases: top translating without changing siblings or container size; left being negative while auto does not offset; on one axis top/left winning and only bottom/right measuring in reverse; percentages resolving against the parent content box with absolute items not taking the offset. Nine packages --deny-warn all zero warnings; repo-wide still 287 errors / 68 warnings, no increase. Real-window evidence: examples/native-app gaining a sixth column "scroll container" — a 190×120 viewport holding 20 rows of 22, geometry line gaining NATIVE-APP-SCR — wheel down three notches giving scr=90/320 with the geometry line's row0=1182,437, that is the content's first row moved up 90 = 527-90; dragging the bar from top to middle giving scr=171/320, bit-for-bit with the hand calculation; exit code 0. Screenshot review: rows 7-11 visible after scrolling, rows clipped at the viewport edge, the right-edge thumb moving with the offset.

MoonBit native GUI stack · Overlay base (core/overlay.mbt) and the first three pieces (popover / dropdown / tooltip) ​

  • Three decisions for the host's overlay layer: ① An empty layer is invisible — a display:none widget is on no hit path, so with no overlays open clicks fall through to content unchanged. Once the first layer opens it becomes visible and relayouts immediately, since the layer was previously invisible with a zeroed rectangle and its own origin cannot be computed without a pass; only then can overlays be placed as "window coordinates minus layer origin". ② Dismissal on outside click happens on release — both press and release are consumed by the layer so the click does not leak into content, but dismissal runs only on release: on press, the same click's release would re-hit the trigger and reopen the overlay just closed. Measured: the second click closed and reopened the menu, making the state look unresponsive. ③ Overlay content is built when opening and detached when closed — not in the content tree, not occupying space, no hidden state to sync. This is a different road from the pre-built plus visibility-toggled switch components: overlays are transient.
  • popover_t/dropdown_menu listen for the release outside the trigger: the trigger's inner widgets run first, since a button consumes the press, and the toggle happens when the release bubbles up, so wrapping a button or a label both work. The normal "click the trigger again while open" path is consumed by the layer, so the component's overlay_alive branch is a fallback for programmatic delivery or a removed layer.
  • tooltip_t uses host-maintained hover state rather than bubbling motion: a child may consume motion itself — this repository's button does, Move => !is_off() — after which an enclosing component receives no notification at all. Measured on the real window, "no tooltip appeared after 300ms of hovering" had exactly this cause. Widget::set_hover was therefore changed to parent-chain semantics, with a parent counting as hovered while the pointer is on a child, matching CSS :hover, and on_hover_change was added, fired by the host only when hover ownership flips. The tooltip component starts and stops its timer on that callback and works around whoever it wraps. The core case pins "three levels on the hit chain all count as hovered, siblings do not, repeated setting does not repeat the callback".
  • One difference from master, on record: the old chain's tooltip_t was a GTK native tooltip via ffi_view_set_tooltip. The self-drawn stack has no native tooltip channel, so it became "open a self-drawn bubble after a hover delay" — the appearance stays controllable, following theme and corner radius, at the cost of owning the delay and the dismissal, going through @core.set_timeout and doing nothing without a host.
  • Verification: moon test -p NoahLiu/native-gui/ui 62/62 — 4 new overlay cases: an empty layer not blocking content, clicking the trigger opening a layer and clicking again dismissing it, the overlay landing 2px below the trigger; clicking outside dismissing without leaking that click to content; the dropdown's 4 items including a 1px separator with the selection callback index and dismissal; the tooltip appearing only when the delay elapses, dismissing on leave, and not appearing if the pointer leaves before the delay. moon test -p NoahLiu/native-gui/core 61/61 — 1 new: parent-chain hover semantics plus on_hover_change firing on flips, with repeated setting not repeating the callback. Nine packages --deny-warn all zero warnings; repo-wide still 287 errors / 68 warnings, no increase. Real-window evidence: examples/native-app gaining a "first overlay batch" column with the geometry line gaining NATIVE-APP-OV — injecting "click dropdown → click item 2 → click popover → click outside → hover on the tooltip target" gives pick=2 ov=1, where ov=1 means the tooltip bubble was still open at exit, with the older readings unchanged, car=2 auto=14 date='2026-11-15' … xfer=2/2, exit code 0. Screenshot review: dropdown arrow pointing up, panel of four rows including one separator, overlay painted above content.
  • One environment fact on record: this machine's monitor turned portrait mid-session, 1440×2560, so the example's seven side-by-side columns, about 1800px, exceeded the screen and the rightmost columns became unclickable. The accumulation page was therefore split into two rows of 4 plus 3 columns, window 1400×1260. When a requested window size exceeds the screen the WM silently clamps it, so taking coordinates purely from the geometry line injects into invisible regions, presenting as "some columns not responding at all".

MoonBit native GUI stack · Overlay family close-out (dialog / toast / context menu) and three measured conventions ​

  • Dialogs open on the overlay layer, a difference from master, on record: the old chain made the mask an absolutely positioned layer inside the page, one Node resident in the page tree, toggled by set_visible. This stack opens on the layer instead — a dialog must by nature cover all content and every other overlay, which is exactly the layer's semantics, and no hidden tree stays resident. The mask uses overlay_show_full, a wrapper with all insets 0, plus explicit width/height: 100%: relying on an inner align-items: center to fill is unreliable, since when content is wider than the window the mask size follows the content — measured with a 420 panel in a 400 window, where the mask came out 420 wide and could not cover the window.
  • node_of_new's finish runs during construction, so initialisation that needs the host already installed, such as "open a layer immediately from a Store", must use the explicit mount form, { mount: fn(parent) { ... } }. In the test, dialog_t(visible=true) constructed before the host was installed made the open call land on an empty slot and fail silently, overlay_count == 0. On the real window the App installs the host while building the window, so only tests hit it, but a component treating "construction depending on a process-level single slot" as normal is fragile; this is now done at mount time.
  • Right-click dispatch changed to "ancestors first": in dispatch_on_path, button == 3 walks from the root down to the deepest. A context menu is a container-level capability, so a wrapper must get it before the inner widget — otherwise the wrapped button consumes the press, since this repository's buttons consume Down regardless of button, and the menu never opens. All other buttons keep "deepest first".
  • Toasts open on the layer and the first one opens it, a difference from master, on record: the old chain returned "layer node plus push function" and the caller mounted the layer into the page tree. This stack makes the top strip a layer entry, opened on the first push and closed when the last one is withdrawn, so toasts always sit above dialogs and the page needs no resident empty tree. The entry point is a single push function, a no-op without a host. Stacking order — later opened on top — is guaranteed by layer entry order, and the case asserts directly on the two entries' handle sequence.
  • Verification: moon test -p NoahLiu/native-gui/ui 67/67 — 5 new: dialog opening on press; confirm and cancel both dismissing before calling back, with the Store going false; ✕ dismissing and mask clicks dismissing per close_on_mask; toast pushing one entry with width following the text, auto-withdrawing on time and closing the layer once empty; right-click popping at the pointer and calling back with the selected item; toast opening above the dialog. Nine packages --deny-warn all zero warnings; repo-wide still 287 errors / 68 warnings, no increase. Real-window evidence: examples/native-app adding a "second overlay batch" column to the second row with NATIVE-APP-OV2 — injecting "click dialog → click confirm → click toast → right-click → click menu item" gives dlg=1 ctx=1 toast=1, with ov=0 at exit since the toast withdrew after 2 seconds, exit code 0. Screenshot review: dialog's translucent mask plus centred panel — title, ✕, body, cancel and confirm — and the top-centred green-check toast strip.

MoonBit native GUI stack · Four date pickers (ui/datepicker.mbt, date family 5/5) ​

  • Popups always open on the overlay layer, the overlay base being used the moment it landed: both the calendar and the time panels are a single overlay_show_at layer, so dismissal on outside click and re-clicking the field are handled by the layer and the field respectively, and the component does not manage "dismissed from outside" itself.
  • The calendar panel and the grid reuse datetime.mbt directly: build_calendar, all 42 cells built once with month changes rewriting data, and range_state_of, solid endpoints with a light run between, are shared by all four pickers, so date_range_picker is only "two-stage click plus write Store", a dozen lines. That is the payoff of extracting the grid math into a pure function.
  • Field interaction settled: the whole field consumes the press, entering capture, and the release distinguishes three things by local x — clicking ✕ clears, offered only when clearable, hovered and holding a value, hot zone width-40..width-20; if open, dismiss; otherwise open the layer. Clearing does not fire on_change, matching master, whose callback signature takes a non-empty value.
  • Two differences from master, on record: ① the old chain's popup was a native Popover; this stack uses a self-drawn panel on the overlay layer. ② The old chain's time entry was two input_number fields for hour and minute; this stack uses steppers — hour ±1, minute ±5, wrapping — because a self-drawn stack has no native input semantics and keyboard editing would need its own focus and input-method channel; for keyboard-editable multi-line text use entry and the upcoming textarea_t.
  • Verification: moon test -p NoahLiu/native-gui/ui 71/71 — 4 new: date picker showing the placeholder, opening the calendar on click, filling and dismissing on selection, dismissing when the field is clicked again, clearing via ✕ after hover with clearing not calling back; date range writing the whole span on two-stage clicks and dismissing; time range stepper changing values with confirm writing back and cancel not changing; date-time range with four fields each writing their own, dates through the calendar and times through the stepper. Nine packages --deny-warn all zero warnings; repo-wide still 287 errors / 68 warnings, no increase. Real-window evidence: examples/native-app adding a "date family pickers" column to the second row, NATIVE-APP-DP — injecting "click the date field → click the 1 October cell" gives dpk='2026-10-01'; "click the range field → 3 October → 7 October" gives drg='2026-10-03~2026-10-07'; "click the time field → click + → click confirm" gives trg='10:00'; exit code 0. Screenshot review: the calendar popup landing below the field, the time popup's hour/minute steppers with cancel and confirm.
  • One injection-coordinate discipline, hit again in this batch: the time popup's two steps, clicking + and clicking confirm, missed entirely on the first pass, because the "container height to centre" calculation was off by one row — it landed on the "minute" row and the confirm click hit blank space right of the button. Coordinates inside a popup must not be derived; re-read them from a screenshot or print that widget's rect. Recomputing with "panel padding + row index × row height + half height" hit on the first try.

MoonBit native GUI stack · Table, first batch (ui/table.mbt: model + table_t) ​

  • Sort state belongs to the component, sort policy to the user: on header click the component toggles Store[TableSort] and draws the arrow — a new column ascending, the same column flipping, a third click on the same column cancelling with column = -1 — but does not sort the caller's data. Numeric order, lexicographic order or a custom comparison are business decisions, and the component doing it instead would be wrong; master's comment says the same and this batch keeps it. The sort arrow has two forms: a solid triangle for the current sort column, ascending or descending, and a grey ↕ hint on sortable columns while hovered.
  • Column width allocation extracted as the pure function table_widths: fixed columns take their declared width, flexible columns, <= 0, share the remaining width, and when total width is short flexible columns take 0, so the table overflows rather than compressing fixed columns. Pinned directly by unit test without drawing a table.
  • Rows are a rebuild-style list, same shape as transfer: a data Store change clears children and re-mounts from the new value, rows holding no subscriptions; selection and sorting affect drawing only, read in on_draw, so neither clicking to select nor toggling sort triggers a rebuild. Selection stores source data indices, not display order, so after reordering the selected row follows the data — steadier than master's display-position Ref.
  • Four cell kinds: plain text aligned per column; semantic tag with a light base and primary text, same convention as the display family's tag_of_type; color swatch with swatch plus text; multi-line, at most two lines within the row height.
  • Verification: moon test -p NoahLiu/native-gui/ui 75/75 — 4 new: column width allocation across the three situations; header column count, zebra striping, clicking a row writing the selection Store with the left accent bar appearing, clicking again cancelling, and the row callback carrying the source index; the header sort state machine's four steps with an unsortable column unresponsive to clicks; tag and swatch cells producing pixels plus a whole-table rebuild on data change. Nine packages --deny-warn all zero warnings; repo-wide still 287 errors / 68 warnings, no increase. Real-window evidence: examples/native-app gaining a table block, NATIVE-APP-TB — injecting "click the header twice, ascending then descending, then click row 2" gives tsort=0/false tsel=1, exit code 0. Screenshot review: header triangle pointing down, zebra striping, selected row with light blue background and left accent bar.
  • One discipline, with three same-class typos in this batch: a nested generic missing one closing bracket, @core.Store[Array[TableRow]> where ]] is correct, reports The type constructor Store expects 1 argument(s), but is here given N plus Expected upper case identifier for type name, with the error spanning the signature and unrelated to the real cause, a missing ]. Adopted as a fixed self-check after this batch: grep for ]> once nested generics are written. Two of the three were located by the control experiment "replacing the body with an empty one still reproduces the error, so the fault is in the signature".

MoonBit native GUI stack · Virtual list and virtual table (ten-thousand-row shape) ​

  • Mount only the visible window: fixed viewport, fixed row height; whenever the scroll offset changes the window is remounted from "first row = offset/row height − overscan", with one placeholder above and one below stretching the inner total height to rows × row height. Hit and draw depend only on mounted rows; 200 rows measured with only 10-13 row widgets mounted.
  • A placeholder is not a translation; both must be present — a real-hardware defect: examples/native-app's virtual table scrolled into "a white screen spreading downward from the top". The first version inserted an upper placeholder of first × row height when first > 0 and did no relative positioning on the inner block, on the stated grounds that the placeholder also places the visible rows at the correct scroll position. That reasoning is wrong: without translation, row i always lands at i × row height below the inner origin, that is exactly one offset lower than correct, and the host clips to the widget's rectangle, clip_rect in widget.mbt, so the larger the offset the more visible rows are pushed past the viewport's bottom edge while its top empties out — precisely "whitening downward from the top". A placeholder only makes up the difference "first row of the window → content origin"; the actual offset must be carried by the content block's position: relative plus top: -offset, the same mechanism as scroll_view/sticky_list. The old form only looked fine when offset was a small exact multiple of the row height.
  • Pins must pin geometry, not mount count: this defect survived a long time because the three existing unit tests asserted only "which rows are mounted and whether the inner total height is right", never that "a row's absolute rectangle lands inside the viewport". The added regression pin scrolls 1.5 rows, since a non-integer offset exposes it most readily, asserting the content block's upward shift equals the offset and the first row's top equals the viewport top minus half a row, then scrolling deep to the bottom asserting five rows remain visible in the viewport. Acceptance checks for geometry-bearing components are always written as rect comparisons.
  • The scrollbar extracted from scroll_v for reuse, scroll_bar(state): track and thumb ratio, track-click jumping, press-drag and stop-on-release are written once and composed by both scroll_v and virt_list. The extraction hit a "script-style extraction" pitfall: slicing by line numbers duplicated the setup code, since the range from let bar to append already contained it, and --deny-warn immediately reported unused dragging — re-run the gate after extracting, since duplicated code and unused variables cannot hide.
  • Never rebuild the window inside on_draw: the draw traversal is reading kids, so changing child widgets disturbs the iterator. Viewport height is therefore only copied into state in on_draw, and window rebuilds happen in event callbacks, ScrollState.apply, and in data subscriptions. Before the first frame has a real viewport height, the declared height is the fallback, since otherwise the first frame mounts only a few rows by overscan and looks empty.
  • When data shortens, clamp the offset before remounting: the subscription first writes content_h from the new row count, pulls an out-of-range offset back to the upper bound, then rebuilds the window; skipping that leaves the view parked on blank space after deleting data.
  • Virtual table = fixed header plus virt_list: the header does not scroll, and row widgets share one constructor with table_t — the header and rows were extracted from table_t into table_header/table_row_widget, both using them, after which table_t itself is left as "compose the header plus a rebuild-style row list". Header widths subtract the 8px scrollbar and the left and right borders so the columns line up with the data rows.
  • Verification: moon test -p NoahLiu/native-gui/ui 78/78 — 3 new: a thousand rows mounting only the visible window, and after scrolling four notches the window's first row equals offset/row height − overscan = 4 with inner total height rows × row height; dragging the scrollbar changing the offset and shortened data clamping the offset back to the bound; the virtual table's header fixed and unscrolling, rows mounting only the visible window, header sorting and row selection on the same conventions as table_t. Nine packages --deny-warn all zero warnings; repo-wide still 287 errors / 68 warnings, no increase. Real-window evidence: examples/native-app gaining a virtual table block, NATIVE-APP-VT — injecting "wheel down three notches then click the list's middle" gives vt=1/1 and NATIVE-APP-VT mounted=13 total=200, where mount count far below total is machine-checkable evidence that virtualization works, exit code 0. Screenshot review: rows 1-6 after scrolling, selected row with light blue background and left accent bar, right-edge thumb moving with the offset.
  • Fix-batch verification: moon test ui 133/133, including the new pin; moon test core ui render text theme icons sys input win dbus chart mdview web 865/865; cd gui && moon check --deny-warn zero warnings. Items needing real-hardware confirmation, awaiting user evidence: the process pages of moon run examples/native-app and moon run examples/sysmonitor — after a dozen-plus wheel notches the row content must keep following the scrollbar with no blank at the top edge; clicking a row to select and dragging the scrollbar must land consistently.

MoonBit native GUI stack · Table column resizing and sticky list (table 2/2, container and scrolling close-out) ​

  • Column widths are live: table_t/table_v_t each hold a Ref[Array[Double]] of widths; dragging a header changes it, and both header cells and the already-mounted row cells change width on the spot with no tree rebuild. The virtual table relies on a rev : Store[Int] that makes virt_list remount the whole window, since visible rows are only a dozen or so and rebuilding is cheap. Drag semantics are "the two adjacent columns trade against each other", clamped to [MIN_COL_W, total − MIN_COL_W] with a 40px floor; the last column's right edge has no handle.
  • One seam only the real window exposed: header cells consume the press themselves for sorting, so edge dragging on a sortable column failed entirely — the parent widget, the header, never received the press. Fix: a cell does not consume when local x is within 3px of either edge, handing the boundary back to the header. The unit test therefore deliberately pins a sortable column; written with an unsortable one it passed while the real window could not drag.
  • The sticky list's "current group" is inferred by reading layout results, not computed from the offset: group height depends on content, since row count and wrapping can both change, so after each frame's layout read the group widgets' rectangles and take the last group whose start is <= offset into StickyList.cur_group; the sticky bar only draws it. The bar is an absolutely positioned floating strip inside the viewport, using the same relative-position translation as scroll_v, and scrolling plus the scrollbar reuse the shared piece.
  • Verification: moon test -p NoahLiu/native-gui/ui 80/80 — 2 new: header boundary dragging changing column widths with adjacent columns trading off, out-of-range clamping to the minimum width, in-row cells following, and no tracking after release, using a sortable column; the sticky list changing the sticky title when scrolling past a group boundary, the content block moving up as a whole, and the scroll maximum being content height minus viewport height. Nine packages --deny-warn all zero warnings; repo-wide still 287 errors / 68 warnings, no increase. Real-window evidence: examples/native-app gaining a sticky list block and table dragging — pressing the header's 140px boundary and dragging right 80px gives head0=24,521,200x40, total width 240 minus the minimum column width 40, bit-for-bit with the hand calculation; wheel down five notches gives sticky='组二' with NATIVE-APP-STK max=238; exit code 0.

MoonBit native GUI stack · Split layout and text family, first batch (hsplit/vsplit, TextRole/label_t/measure_text_height) ​

  • Split-handle dragging changes styles only: the old chain, master's yue/splitter.mbt, wrote set_bounds directly and double-wrote width/flexbasis styles — a GTK-era workaround for layout property changes at runtime not taking effect, see its probe-splitter entry. This stack relayouts from the root every frame and a style change takes effect next frame, so styles only. During a drag the host's pointer capture applies, since a press enters capture, so motion outside the handle is still delivered to it and displacement is computed in window coordinates.
  • Total length read from the three widgets' actual rectangles, without capturing the parent: clamping needs "the two sides of the handle summed together", and the parent container must not be captured into a child's callback, since parent and child pointing at each other is a reference cycle. Changed to first.rect + handle constant + second.rect, semantically equivalent and cycle-free.
  • The first pane starts in percent and switches to a fixed value after dragging: the initial percent(ratio*100) lets the first pane scale with the container without "measure once then compute", and after any drag it becomes fixed(px), owned by the user from then on; the two ends have min_first/min_second floors clamped during dragging.
  • TextRole and measure_text_height: a role folds size plus color into one — Title/Section/Body/Secondary/Accent — and role_color reads the theme at draw time rather than fixing it. measure_text_height(text, role, width) supplies an explicit height for labels that grow at runtime: the natural height a container queries need not match the height computed by wrapping at the box width during drawing, so an explicit height is steadiest — a pitfall recorded on the old chain, kept at the same convention here.
  • Verification: moon test -p NoahLiu/native-gui/ui 85/85 — 5 new: measuring height by width, a wider box giving visibly more height, and larger role sizes giving taller rows; role sizes and colors, with Accent using the primary color; a themed label wrapping at max_width with an explicit color overriding the role color, proven per pixel; horizontal split handle dragging changing the first pane's width with two extreme clamps and no tracking after release; vertical split the same. Nine packages --deny-warn all zero warnings; repo-wide still 287 errors / 68 warnings, no increase. Real-window evidence: examples/native-app gaining a "split layout and text family" block with NATIVE-APP-SPL — pressing the handle and dragging right 100px gives first=24,1236,788x116 / split=788, 688 plus 100, matching the hand calculation; exit code 0.
  • One window-size review discipline, hit again in this batch: the example page has grown to about 1400px while the window was still 1260, so the lower half of the page was outside the window; injection points landed in invisible regions and nothing moved. Growing the window to 1500 first made it hit. Every time a block is added to the accumulation page, check the window height against the last item in NATIVE-APP-RECTS — a geometry line having coordinates is not proof the point is clickable.
  • Which pixel column the focus ring lands in: stroke_rect(0.5, 0.5, w-1, h-1) with line width 1 puts the stroke centre at 0.5, covering the second cell of the x∈[0,1] column, so sampling at ox reads the background and misreports "the focus ring was never drawn" — this batch's first case failed falsely exactly this way. Sample at ox+1, or scan a column first to confirm where the ring is.
  • Out-of-bounds clicks do not hit the widget; do not use them as a clamping case: dispatch_pointer performs hit testing first, rect.contains, so an event with x beyond the widget's width is never delivered to it and the clamp inside the callback cannot be verified. "Clicking the far right still gives the last star" must use the innermost right point instead, x=120 for a 126-wide rating; to genuinely exercise the out-of-bounds path use dispatch_pointer_to(id, ev), which is how the host's pointer capture works.
  • on_change fires only on interactive input: master attached the callback to value.subscribe, so external writes also called back. This stack standardises on "callback = the user's one action", the same convention as the date pickers, with external writes only repainting. Both semantics are defensible, but mixing them makes a caller think "writing the value myself delivered my own callback", so it is on record.
  • A form item's error row always reserves its height: the error text row is a fixed 18 high, present even with empty text, so text appearing or disappearing causes no layout jump. Proven on the real window by NATIVE-APP-FORM item= rectangles being bit-for-bit identical before and after clicking "danger and report error". The text itself uses label_dyn(err.signal()): the Store's signal feeds the label directly, and text changes go through relabel for an in-place re-measure with no subtree rebuild.
  • Button width convention differs from master: master measured text width manually via get_bounds_for at mount and added +32 to set the width, then centred horizontally itself during drawing. Text measurement here already feeds the layout engine, attach_text_measure plus 12 left and right padding, so width is a layout result and drawing only centres within the box; that manual measurement is not needed.
  • Verification: moon test -p NoahLiu/native-gui/ui 90/90 — 5 new: button color resolution across four variants plus hover, with an explicit override swallowing hover; a themed button's width following the text, release outside the box not counting as a click, the focus ring drawn only while focused, disabled not consuming; link width equal to text width, underline on hover, calling back only on release; form item label fixed width plus the error row's 18 reserved, empty text measuring 0 wide and re-measuring in place after a change; rating star vertex geometry, click writing the value and calling back, hover previewing without changing the value, leave clearing the preview. Nine packages --deny-warn all zero warnings; repo-wide still 287 errors / 68 warnings, no increase. Real-window evidence: examples/native-app gaining a "remaining form family" block with NATIVE-APP-FORM — injecting "click solid button → click link → click danger button, writing the error text → click the 4th star" gives btn=2 lnk=1 rate=4 ferr=1, with item=24,1475,245x39 bit-for-bit identical to the unclicked round, exit code 0. Screenshot review: four button variants — solid with white text, light base with primary text, no base with primary text, red base with white text — the link's hover underline, right-aligned labels, five hollow stars.
  • Check window height against the last geometry item: this batch's block sits at the bottom and the page had grown to 1542, so with a 1500 window the rating row was outside and clicks did nothing; growing to 1660 hit first try. Also watch the race between the auto-exit timer, 12 seconds, and "sleep first, then find the window" — sleeping past the deadline gets a destroyed window and reports BadWindow.

MoonBit native GUI stack · Dropdown select and color picker (select_t/color_picker_t, form family 13/15) ​

  • Ask an overlay handle for liveness, not for non-zero: the id returned by overlay_show_at increases monotonically and is never reused, and dismissal on outside click is done by the layer, so the component's own open_id never resets. Testing open_id.val != 0 therefore treats an invalidated handle as still open, and the symptom is "after clicking outside to close it, the field needs two clicks to reopen" — the first only clears the stale handle. Use overlay_alive(open_id.val) for the open decision, which internally asks whether overlay_rect_of(id) still exists. The != 0 guard in close_pop stays harmless, since hiding an invalid id is a no-op. popover_t/dropdown_menu were written this way from the start, while the four date pickers and this batch's two used the weaker check; all are now unified, each with a regression "after outside-click dismissal, one click on the field reopens".
  • Drawing a widget onto a bitmap must cancel its absolute placement: w.paint(p) translates by rect.x/rect.y, and a bitmap sized to the widget is only w×h, so a popup row whose absolute y is in the tens to hundreds lands outside the buffer and pixel assertions become constant — the symptom was a false failure of "selected row same color as unselected". Use w.paint_at(p, w.rect.x, w.rect.y), zeroing the translation so bitmap coordinates equal widget-local coordinates.
  • The popup list is wrapped in a scroll_v: panel height follows a formula, items×28+8 capped at 300, so many options never push the popup outside the window. Scroll geometry is still copied into ScrollState at the first frame's draw, see the scroll-container entry, so headless cases must relayout once before clicking.
  • container's padding applies to left/top only: it follows Widget::set_padding(left, top), not CSS's four-sided padding. Measured on the swatch container: 176×72 rather than 176×80, with the bottom band covered by the popup panel's own fixed height. Reading it as four-sided writes the geometry assertion one cell off.
  • Three convention differences from master: ① the popup is a self-drawn panel on the overlay layer, not a native Popover, and "click outside dismisses" is the layer's job, so the component stops tracking it. ② Master dismissed on field blur; here whether the field is focusable depends on the caller supplying focus_id, and dismissal goes only through "click outside" and "click the field again". ③ The dropdown's ✕ clearing also calls on_change(""), matching master, while the date picker's clearing does not, also matching master — both conventions are kept separately and are not an internal inconsistency.
  • Verification: moon test -p NoahLiu/native-gui/ui 93/93 — 3 new: dropdown opening with list geometry, the popup landing 2px below the field at the same width with rows 28 high, an option click writing back and dismissing, the selected row's light base and right check producing pixels, ✕ clearing with a callback; dropdown "after outside-click dismissal one click reopens"; color picker swatch grid geometry, two rows of 5 plus 1 and a 176×80 panel, the selected cell having an extra stroke and white check versus an unselected one, differing by more than 20 per pixel, and clicking a swatch writing the value and dismissing. The date picker suite gained the stale-handle regression in step. Nine packages --deny-warn all zero warnings; repo-wide still 287 errors / 68 warnings, no increase. Real-window evidence: examples/native-app's "remaining form family" block gaining a row with the dropdown and color picker, geometry line gaining NATIVE-APP-FORM sel= col= and status line sel= col= ovmax= — injecting "click dropdown field → click item 2 → click color field → click cell 2 → click dropdown ✕" gives sel='选项乙/', selected then cleared, col='#2E9E5B', and ovmax=1, the peak overlay count, recorded once per beat because it had already returned to zero at exit, so it proves a layer really opened; exit code 0. Screenshot review: the 5×3 swatch grid landing below the field and painted above content.

MoonBit native GUI stack · Multi-line input (ui/textarea.mbt, form family 15/15) ​

  • focus_id is only a declaration; the focus stack is aligned by the host against the widget tree, convention changed 2026-10-15: what was recorded back then was "a widget carrying focus_id only means focusable, and App does not change focus on press if that id is not in the focus stack, so the example's new fields 21..24 had to call app.focus.register(id) to receive keys" — that discipline never made it into the library, and as a result nothing in showcase could type; see the keyboard-focus entry at the end of this section. The current convention: declaring focus_id puts it in the stack, via Widget::focus_ids → FocusStack::sync, aligned once before Down and once before Tab, and register is left for temporary focus targets that are not in the tree.
  • The correct way to inject keys with xdotool: xdotool key --window WID ... uses XSendEvent and GTK does not accept synthetic events, producing the false picture "every mouse injection lands, every keyboard injection does nothing". Change to xdotool windowactivate --sync WID plus xdotool windowfocus WID, then key/type without --window, which is XTEST to the currently focused window, and the events arrive.
  • A Chinese input method swallows letters and arrows: in the real window type "cd" did not reach the field, the letters going to fcitx to build preedit, while Return passed. The check is to look first at whether preedit appears; to prove ASCII typing, switch the input method to English mode first, reading the state with fcitx5-remote -n and not using -t, which toggles. The multi-line field's keyboard semantics are therefore covered mainly by white-box cases with dispatch_key delivered directly, while the real window evidences only click positioning, Enter newline and the wheel.
  • Up/down arrow landing takes "the rightmost code-point boundary not past x": written first as "strictly less than", which lost one character when moving between two equal-length lines, measured caret=1 instead of 2. Matching the single-field byte_at_x is the correct convention.
  • Advancing byte by byte then backing up a code point must check the end first: while at > ln.start && (bs[at] & 0xc0) == 0x80 reads out of bounds when at == text length, and the symptom was the test driver reporting only SIGABRT (core dumped) with no assertion visible in the stack. Added the at < n guard.
  • Do not use j = hi as the break inside a segment loop: wrap_units's inner while exited by pushing the cursor to the end, which swallowed the entire line into one segment — externally, no width wrapped at all. Replaced with an explicit exit flag, let mut go = true.
  • The appearance of caret following: when the cursor lands in the last visible half-line the block moves up, measured 16px, so the first row is clipped in half. That is the normal result of ensure_visible, not a drawing bug, and should not be reported as one during screenshot review.
  • Verification: moon test -p NoahLiu/native-gui/ui 98/98 — 5 new: wrap break units — space, Latin word, CJK per character, with range truncation; display row layout — breaking on newlines, empty text giving one line, greedy wrapping leaving no leading space at a break, rows joined head to tail, Chinese breaking into three rows per character, an over-long word not split but overflowing; typing and Enter, up/down arrows moving by display row while remembering x, Home/End going to row start/end, Shift+Home extending then replacing, Backspace at row start joining rows; click positioning to a given row with Chinese counting the row start in bytes, drag selection across rows deleting whole lines, read-only refusing editing keys; a scrollbar only appearing once content exceeds the viewport, plus thumb and track swapping positions at the bottom and the caret scrolling into view changing the top row's pixels. Nine packages --deny-warn all zero warnings; repo-wide still 287 errors / 68 warnings, no increase. Real-window evidence: examples/native-app's form block gaining a multi-line field, geometry line gaining ta= and status line ta= tacur=, with the example content changed to 8 lines — injecting "click row 3 inside the field, window y=1644" gives tacur=12, that row's start byte, bit-for-bit with the hand calculation; injecting Return gives ta='行 1/行 2//行 3/…' with tacur=13, one extra empty line; scrolling back to the top with the wheel, screenshot review shows three rows fully visible, the right-edge thumb back at the top and the caret-following 16px shift visible in an intermediate frame.

MoonBit native GUI stack · Declarative layer L1 constructors (ui/declarative.mbt: vbox/hbox/bind_label/hover_group/mount_window…) ​

  • hover_group needs no polling mechanism in this stack: the old chain, yue/declarative/hovergroup.mbt, ran a global 100ms pointer poll, because GTK pointer events do not bubble and a child's GdkWindow takes events exclusively, leaving the ancestor container without enter. This stack's host hover state is parent-chain semantics already — a parent counts as hovered while the pointer is on a child, matching CSS :hover, see the tooltip entry — so the component just reads Widget.hovered: no timer, no pointer query, and no "do not fire callbacks while the group is stationary" optimisation to make. Recorded as a mechanism difference, not "same feature implemented".
  • bind_label's forced relayout is free in this stack: the old chain's set_text only marked dirty without relayout, so a longer label was truncated and had to be followed by update_layout. Here a text change goes through relabel, re-setting the measure callback and marking the node dirty, so the next frame's layout follows the new text automatically. Real-window evidence: one button click takes the label from 36px to 87px, lblw=36/87, with no subtree rebuilt.
  • mount_window provides no center / on_close: the old chain's window centring and close callback went through the native window channel. Here window position is the WM's decision and closing only makes App::run() return, with cleanup after the caller's run(). Fewer honest parameters beat two fake ones.
  • Constructors sharing a component's name no longer wrap a second layer: label/button/entry/checkbox/container/group/separator/image/icon/radio_group/slider/progress are already declarative constructors in ui returning @core.Node, so another same-named function only adds a signature to maintain. This batch adds three kinds only: renames — vbox/hbox/text_edit/picker/scroll/tab/date_picker; foundation entries — mount_window, bind/bind_label; new capability — hover_group. overlay_scroll is simply scroll_v here, since the scrollbar has always been an absolutely positioned child of the viewport; gif goes with the icon/animation batch; cursor_group needs a window-layer cursor channel, new FFI, so it is its own batch.
  • Three toolchain writings measured in this batch: ① test "name" and the opening brace must be on the same line; splitting them makes the parser treat the body as a separate top-level block, throwing the error site dozens of lines away with unrelated meaning, The value identifier tabs is unbound plus Parse error, unexpected token id. ② Option::is_none() is deprecated on this toolchain, pointing at x is None; do not write it into new code. ③ A match guard is not v when cond but MoonBit's | cond; for a simple two-way branch, falling back to if/else is less trouble.
  • Verification: moon test -p NoahLiu/native-gui/ui 103/103 — 5 new: vbox/hbox direction and spacing coming from parameters; bind_label changing text in place and re-measuring width after a value change, plus producing pixels; bind running a derived signal with subscriptions retiring in the disposal bag; hover_group's child hovering changing the whole group's background, with release outside the group not counting as a click; old-name constructors landing on the new components — picker opening an overlay and filling back, scroll producing a scrollbar with max>0, tab switching pages by Store. Nine packages --deny-warn all zero warnings; repo-wide still 287 errors / 68 warnings, no increase. Real-window evidence: examples/native-app gaining a "declarative L1" block, geometry line gaining NATIVE-APP-DC and status line dcl= lblw=, with the window grown 1760→1900 because the page lengthened again — injecting "click the 加一 button inside the card" gives dcl=1 lblw=36/87, so one Store change moves both label text and width, with label=34,1377,87x19; parking the pointer on the card's blank area and screenshotting shows the whole block taking the hover background.

MoonBit native GUI stack · Window-layer cursor channel (wm_set_cursor + Widget.cursor + cursor_group) ​

  • In a fully self-drawn stack the cursor is one window-level layer: with no native widgets there is no "each widget carries its own cursor", and a shape can only be set on the drawing area's GdkWindow. The chain is therefore: a component declares a request on the widget, Widget.cursor, numbered @core.CURSOR_* — 0 default, 1 hand, 2 text I-beam, 3 horizontal resize, 4 vertical resize; the host takes "the deepest non-zero request on the hover path" on Move, and only calls FFI, Window::set_cursor, when the shape differs from the one already sent. Taking "deepest non-zero" rather than "deepest" lets a cursor_group wrapping a whole group fall back naturally: children that make no request use the group's, and a child that does wins.
  • One mechanism difference from master: the old chain's cursor_group had to set the cursor recursively on the group root and every descendant, because GTK applies cursors per native event window, so a container setting alone cannot cover children with their own windows. Here one setting on the group itself suffices; the cost is that a child wanting to keep its own shape must state a request of its own, and stating it wins.
  • Two C-side guards: gtk_widget_get_window(area) is NULL before realize, when there is no window to attach to, so it is skipped and the next hover retries; WmWindow.cursor_kind records the current shape, so repeated requests for the same shape do not create another GdkCursor, since gdk_cursor_new_from_name allocates each time and hover jitter would burn it. When the name resolves to nothing, a theme missing the glyph, it falls back to default.
  • Two toolchain writings: ① pub(all) enum PointerKind does not carry Eq automatically, so pev.kind == Move does not compile, reported as Type PointerKind does not implement trait Eq; write match pev.kind { Move => true _ => false }. ② pub(all) struct Rect's fields are likewise public, so tests read w.rect.x directly with no extra getter.
  • Verification: moon test -p NoahLiu/native-gui/core 62/62 — 1 new cursor_at: inside a group falling back to the group, a child that stated a request winning, and both outside the group and empty space returning default. moon test -p NoahLiu/native-gui/ui 104/104 — 1 new: hand on button/themed button/hover group, text shape on entry/textarea, horizontal/vertical on hsplit/vsplit handles, and cursor_group providing an overridable shape while children stay default. Nine packages --deny-warn all zero warnings; repo-wide still 287 errors / 68 warnings, no increase. Real-window evidence: examples/native-app sampling the shape the host has sent once per beat, status line gaining cur=手/文本/横向/纵向 — injecting "stop in order on the solid button → the multi-line field → the split handle → empty space" gives cur=8/8/8/0, the first three shapes each sampled across 8 beats, with the vertical one at 0 because the example has no vsplit; exit code 0. The actual cursor shapes — whether the hand is the familiar pointer and the text shape is an I-beam — are for the user to re-verify on real hardware, since injection can only prove the host really sent different numbers per region.
  • A disabled state's cursor request must be computed on hit; fixing it at tree build is guaranteed wrong — real-hardware feedback: a disabled button still showed the hand. Widget.cursor is a one-shot Int written at tree build, while disabled state in this stack is a cell, Enabled, the settled convention in state.mbt, since a boolean fixed at build time makes every later set_disabled unable to move the frozen field. button()/icon_button_t() wrote CURSOR_HAND unconditionally, so disabled and enabled were indistinguishable on the cursor surface. The fix is not adding a "recompute the cursor" entry to every component but making the request itself computable: Widget gained cursor_fn : Option[(Widget) -> Int], which overrides the static cursor when present, and cursor_at now evaluates per node through the new public method Widget::cursor_request(). Returning 0 still means "this widget requests nothing", so the query continues up the ancestors and cursor_group's group semantics are unaffected. ui/state.mbt gained the private helper enabled_cursor(cell, on), returning CURSOR_DEFAULT when disabled and on when enabled. Changed: ui/basic.mbt's button/icon_button_t, ui/form.mbt's checkbox/switch_t/slider/button_t; the stepper's two sides go through button() and are covered automatically.
  • Lesson: changing a field's semantics requires grepping the whole repository for that field's assertions, not just implementation call sites — ui/declarative_wbtest.mbt:151 asserts directly on a button's static cursor and went red on the spot, 0 != 1, found by the user running moon test core ui. Grepping only the implementation side would miss it, since the assertion reads .cursor and is unrelated to the component's construction points. Checklist for this class of change: implementation write points → assertions reading that field → the field's public accessors, where adding cursor_request() was exactly to give assertions a semantically correct entry.
  • Verification: moon test core ui 243/243 — new: 1 core case, with cursor_fn the computed result wins, overriding the static cursor, and a state change in the same tree takes effect on the next query; 1 ui case, the button cursor following the disabled cell, disabled requesting no hand and restoring enabled bringing it back immediately; plus one existing declarative_wbtest assertion corrected. Real hardware: a disabled button shows the ordinary arrow on hover and the hand returns immediately once enabled again.

MoonBit native GUI stack · Editable combo box (combo, declarative L1 close-out) ​

  • Composition: all editing semantics live inside that entry; this component adds only the candidate layer. Filtering follows input, since entry's on_change reopens the overlay; Enter taking the highlighted item uses entry's on_enter hook; ↑/↓/Esc come from keys bubbling along the ancestor chain: entry does not consume those three function keys — the kernel's handle_key reaches keysym_to_text, gets None, handled=false, and the host keeps asking upward — so the enclosing combo.on_key receives them. This is the stack's first real division of labour on "ask the widget first, then its ancestors" for one key press, and it is worth recording: for a component to catch keys its child does not consume, no callback parameter is needed on the child, since the parent's on_key is that channel.
  • Open on release, same pattern as popover/dropdown: entry consumes the press, since it must place the caret, and the same click's release bubbles to the wrapping layer, where combo opens the layer on Up. Clicking anywhere in the field therefore means "position the caret plus open candidates", with no extra arrow hot zone.
  • Highlighting does not rebuild the overlay: a candidate row's selected state is a closure reading live, fn() { hi.val == idx }, so ↑/↓ only change hi and call invalidate() without remounting the popup content; only a changed filter result reopens it, close() plus overlay_show_at.
  • Overlay ids increase monotonically, and miscounting in a test crashes: every overlay_show_at issues a new id and closing does not recycle numbers, so an assertion about "the Nth time a layer opens" counts openings, not the current layer count. Counting wrong gives overlay_root_of(nonexistent id) a None, the test helper falls back to an empty widget, and the next children()[0] goes out of bounds — the symptom is the test driver reporting only SIGABRT (core dumped) with no assertion in the stack, measured once in this batch.
  • One difference from master: the old chain's combo was a native editable combobox, GTK ComboBoxEntry; this stack is entirely self-drawn, with the overlay on the overlay layer and outside-click dismissal also the layer's job.
  • Verification: moon test -p NoahLiu/native-gui/ui 105/105 — 1 new: click the field opening the layer with the full candidate list → dispatch_commit of 甲 filtering to two rows and reopening automatically → the highlighted row's light base and check producing pixels, with the down key moving the highlight to another row → Enter filling the highlighted item, closing the layer and calling back → reopening with only one row left, Esc closing without changing the value → filtering to an empty set not opening. Nine packages --deny-warn all zero warnings; repo-wide still 287 errors / 68 warnings, no increase. Real-window evidence: examples/native-app's form block adding combo to the dropdown row, geometry line gaining cmb=, status line cmb='…', focus id 25 registered — injecting "click the editable combo field → click item 3" gives cmb='组件丙', exit code 0. A second round of four clicks, "open → item 2 → click the field again → click the old row-3 position", ends at cmb='组件乙': on the second opening the current text is already 组件乙, filtering leaves one row, and a click at the old third-row position naturally falls outside the popup — exactly the expected behaviour of "filter as you type", not a lost click.

MoonBit native GUI stack · Scaled drawing fix and GIF animation (cr_blit rewrite + Animation + gif/icon_button_t) ​

  • cairo_pattern_set_matrix's scaling path was measurably wrong — the real bug fixed in this batch: the old form put "source pixel → device pixel" into the pattern matrix, translate(d0,d1) plus scale(k), while Cairo's semantics are the opposite of that intuition. With a (20,20)-(99,99) square in a 120×120 source scaled to 72, the pattern-matrix route gave (33,33)-(71,71), pushed to the target rectangle's bottom-right and clipped, while the CTM route — identity → translate → scale → set_source_surface → filling with the sub-rectangle size — gave the correct (12,12)-(59,59). At equal size, target matching source, the two routes coincide, which is why the error stayed hidden: draw_image's and draw_canvas's scaling branches had no case coverage at all. The diagnostic order is worth copying: first prove, on the MoonBit side with a fully opaque composite, that it does draw — all 2304 cells hit; then switch to a source with a transparent region to reproduce "drawn wrong"; finally move the whole blit into a standalone C program comparing three formulations, parent surface plus matrix, CTM transform, sub-surface plus matrix, locating it in one pass.
  • Changing the stub library leaves moon run artifacts unlinked too, previously recorded for the test exe only: build/librender_cairo_stub.a was 13:50 while native-app.exe was still 13:39. MoonBit sources did not change, so moon considered the artifact current — white-box tests all green while GIFs stayed blank in the real window. Delete _build/native/debug/build/NoahLiu/native-gui/examples/<name>, noting that moon run's executable lives under build/, not execbin/. Acceptance check: compare the .a and .exe mtimes; "just ran it" does not mean "is new".
  • GdkPixbuf's animation iterator starts lazily and only knows absolute times: the first call to gdk_pixbuf_animation_iter_advance(iter, t) only anchors the animation start at t and necessarily reports "frame unchanged"; afterwards switching is decided entirely by the loader from accumulated time. So this layer's virtual clock advances only "current frame duration plus 1ms" per beat, at the same rate as real time, and components ask repeatedly at their delay_ms cadence. Written earlier as "push four beats inside one call", the animation ran four times too fast.
  • This machine's gif loader cannot read tDelay, measured as NULL against PIL's reported 400ms, so it falls back to 100ms-per-beat polling; because frame switching is decided jointly by the virtual clock and the loader, it still changes frame every 400ms in practice — 12 seconds of real window giving gif=30 frames, about 2.5 frames/second, matching the asset's per-frame durations.
  • Both loop and static are MoonBit reserved words: the parameter name loop~ fails to parse, changed to looping~, and the struct field static triggers a reserved_keyword warning, changed to is_static.
  • Sampling point for the icon button's hover background: a fill_round_rect with radius 4 clips the corner cell, so the pixel assertion takes the top edge midpoint, (w/2, 2); (1,1) reads "no change".
  • Verification: moon test -p NoahLiu/native-gui/render 42/42 — 1 new scaling regression, with the same source image's opaque bounding box at target sizes 120/72/48 being (20,20)-(99,99)/(12,12)-(59,59)/(8,8)-(39,39), plus draw_image_from_rect's sampling domain not overflowing. moon test -p .../gui/ui 107/107 — 3 new: the animation component self-advancing by frame, pause breaking the chain, play resuming, a static image not scheduling rotation, and 48×48 scaling really producing ink; icon button sizing, hover changing the base, release outside the box not counting as a click, disabled not consuming. core 62/62, yoga-mbt 96/96, nine packages --deny-warn all zero warnings; repo-wide still 287 errors / 68 warnings, no increase. Real-window evidence: examples/native-app gaining an "icons and animation" block, geometry line NATIVE-APP-MEDIA, status line gif=/ib= — injecting "click the icon button" gives ib=1 and gif=30, the self-advanced frame count, exit code 0. Screenshot review: icons following the theme color, the ✕ icon button, and the 48×48 GIF dots really drawn inside the box, previously blank.

MoonBit native GUI stack · Theme following the system (theme_from_system + App::follow_system_theme) and the style-channel decision ​

  • The theme package does not depend on the platform layer; the following logic lives in the window layer: theme takes only two reading functions as parameters, theme_from_system(sys_color_scheme, sys_accent), while the real probes are in win and the polling plus theme_apply in core/app.mbt, as App::follow_system_theme(interval_ms) returning a stop function. The theme package therefore stays platform-free, so core depending on it forms no cycle, and the follow policy is testable headless with fake readers covering four states. Same reasoning as local_ymd: platform probes are forwarded through core, so examples and the integration layer need not import win.
  • What is read is the desktop environment's gsettings, not a native widget stylesheet: org.gnome.desktop.interface's color-scheme first, counting only when the value contains dark or light, falling back to the gtk-theme name, normalised and searched for dark/light; the accent reads org.gnome.desktop.wm.preferences button-settings if it holds a literal #rrggbb, or accent-color, where GNOME returns names such as blue/teal and this layer maps them to hex. g_settings_schema_has_key must come before any read: this machine's schema has no accent-color key at all, and calling g_settings_get_string directly prints a GLib critical warning. The GSettings object is created once and kept, since creating and destroying it each beat would re-walk dconf.
  • Measured reading on this machine, Ubuntu 24.04 plus XFCE: GNOME schemas are installed — gsettings list-schemas lists 38 org.gnome.desktop.* — with color-scheme='default', neither dark nor light, so it falls to gtk-theme='Orchis-Teal-Light-Compact', which contains light after normalisation, reported as "light"; accent-color has no such key so the accent is an empty string. theme_from_system therefore takes "light plus built-in accent", matching the base palette, and the real-window status line evidences sys='light/', fing=#2D68C4/#F5F6F7. Acceptance check: the follow loop calls theme_apply only when the fingerprint, primary/bg_page, changes, so a desktop that yields nothing produces the same value every beat with zero repaints.
  • Style-channel decision: this stack does not do a CSS-style stylesheet. master's five helpers — themed_container/set_panel_bg/bind_bg/bind_fg/bind_popover_bg — existed because native widgets' background and text colors were set imperatively as fixed values that did not update with the theme. Components here read the palette in on_draw, and a theme change repaints globally through one process-level subscription, watch_theme, so the five helpers are structurally unnecessary. Callers overriding size, spacing or background have two more direct routes: the components' typed parameters — width=/bg=/padding=… — or handle=fn(w){ ... } to reach the underlying widget and set yoga styles directly, which is this stack's style escape hatch and more type-safe than maintaining a string style table. sub_bag batch unsubscribe was long since implemented in core/signal.mbt and is covered repeatedly by the component batches, whole-generation retirement in rebuild-style lists.
  • Verification: moon test -p NoahLiu/native-gui/theme 8/8 — 1 new: the fingerprint relations across the four states of dark/light and accent — reading nothing and reading light share a fingerprint, dark differs from light, an accent provided goes through the derivation formula and matches theme_from_accent, and after theme_apply_system both getters and the fingerprint follow the new snapshot. ui 107/107, core 62/62, render 42/42, yoga-mbt 96/96, nine packages --deny-warn all zero warnings; repo-wide still 287 errors / 68 warnings, no increase. Real-window evidence: examples/native-app starting follow_system_theme(interval_ms=1000) and stopping it at 3 seconds to verify the stop function's ownership — status line gaining sys='light/' and fing=#2D68C4/#F5F6F7, frames=118 and other readings unchanged, exit code 0. Whether the interface recolours immediately when the system theme genuinely switches is for the user to re-verify on real hardware, observing with gsettings set org.gnome.desktop.interface color-scheme prefer-dark temporarily; the assistant does not change desktop settings.

MoonBit native GUI stack · Charts, first batch (chart: algorithm layer + line/bar/donut/gauge/scatter) ​

  • A new package rather than editing yue/charts in place: the old directory references the @yue root package and the old Painter contract and belongs to the repo's error backlog; the new stack must keep the per-package gate "this package --deny-warn zero warnings", which becomes impossible once mixed, for the same reason as yue/components → ui. Three layers in the new package: algo.mbt, pure functions same-source as master — rolling window, coordinate conversion, 1-2-5 ticks, unit stepping, bar slot and hit, sector angles, gauge radians, least squares, range padding, truncation; draw.mbt, drawing consuming only pure-function results; components.mbt, the @core.Node components.
  • The pixel acceptance check changed: chart components lay a panel base first in on_draw, so "count of inked pixels, alpha>0" always equals the whole area — this batch's 5 cases all failed falsely for exactly that reason. New check: compare two bitmaps pixel by pixel, diff_of, used both for "the picture changed after new data" and for "restored after leaving", the latter asserting the difference is exactly 0. This holds for every "base first, then content" component, not only charts.
  • Dense polyline drawing cost decoupled from window size, master's decision kept: decimate by pixel column, taking (x, y_min, y_max) per column so extremes survive; area mode fills one rectangle per column down to the anchor line, the fill's top edge being the polyline with no separate stroke, while line mode draws a min..max vertical bar per column. Only sparse data, point count ≤ column count, takes a true polyline plus polygon area. Basis: under software rasterisation each stroked segment costs about 1.8µs, and 1000 points × 4 series fully stroked exceeds the 5ms acceptance line.
  • Donut hit is newly written as donut_hit, sectors tested in polar coordinates with center and radius formulas same-source as draw_donut, giving -1 inside the hole and outside the circle. Bar hit still uses master's bar_hit, sharing the slot-width formula with drawing so highlight and bar never disagree.
  • Reuse ui's foundation instead of re-copying: follow_store/watch_theme/text_width/fill_round_rect/default_handle were made pub for the chart package. They were package-private inside ui, so the chart package would have had to copy subscription logic, and watch_theme's "subscribe once per process only" is exactly the part that must not be duplicated.
  • Four toolchain writings measured in this batch: ① pub const names must be uppercase — pub const pi reports "Expected upper case identifier for name of const, found lower case. Did you mean let?"; use pub let for a lowercase name. ② for i = 1..<n { } is unsupported: a range is legal only in for x in a..<b, so the classic for is for i = 1; i < n; i = i + 1. ③ StringBuilder's write methods are write_string/write_char; there is no add/append. ④ The Ordering type cannot be referenced directly on this toolchain — writing a comparator reports "The type Ordering is undefined" — so ordering a few elements fell back to insertion sort.
  • Verification: moon test -p NoahLiu/native-gui/chart 11/11 — 6 pure-function: window dropping the oldest and out-of-range being a no-op, degenerate range and padding ratio, 1-2-5 step with a negative interval start, unit stepping k/M/G, bar slot and both hit forms, sectors accumulating positives only with a single sector keeping 0.0002 radians, gauge segment coloring and polyline segment count, regression degeneration, 8-digit color strings, label truncation. 5 component: line changing output with new data and dense decimation, bar hover changing the base and restoring on leave, donut drawing plus four-state hit, gauge's two smooth paths — jumping straight to the value versus approaching per beat — scatter with a trend line. Nine packages --deny-warn all zero warnings; repo-wide still 287 errors / 68 warnings, no increase. Real-window evidence: examples/native-app gaining a charts block — line plus bar on one row, donut plus gauge plus scatter plus a push-point button on another, geometry line NATIVE-APP-CH, status line ln=/chp= — injecting "click the push button three times" gives ln=27/23 chp=3: window 24→27 points, last value 7→23, bit-for-bit with the hand-calculated pushes*7%29+2, exit code 0. Screenshot review: all five charts present — line with area fill and the latest value labelled at the right end, bar with opposite colors for positive and negative plus categories, donut with a centre summary plus a right legend showing percentages, gauge's 68% arc with the centre number, scatter with a least-squares trend line.

MoonBit native GUI stack · sys batch 3 (hardware family, six pieces: brightness / volume / nightlight / printer / powerprofile / monitor) ​

  • Dependency injection removes the DBus coupling: brightness's write path, logind online probe plus SetBrightness, went through @traybus in master. In the new stack the two places became process-level injection slots: brightness_set_logind_probe(()->Bool) and brightness_set_action((subsystem,name,value)->String), an empty string for success and a non-empty error string. Once the DBus line is wired, traybus injects the real implementation. The read path — device enumeration, current value, maximum, pure sysfs — does not use the slots. Same pattern as ci_tooltip's callback inversion: the semantic layer defines the slot, the platform layer injects it.
  • monitor moved up ahead of the session family: nightlight's display enumeration depends on monitor, xrandr, and monitor depends only on platform plus pr_run with no DBus, so by dependency order it belongs to this batch.
  • Three pieces kept on the DBus line: sensor, iio-sensor-proxy, and bluetooth, org.bluez, both use @traybus's general D-Bus property reads; disk uses @traybus.gdbus_call_system in 50 places, UDisks2. Their core is bus calls rather than command lines, so this batch does not force them apart.
  • Verification: moon test -p NoahLiu/native-gui/sys 41/41 — 11 new: monitor's xrandr parsing matrix, plus the pr_run command surface and parsing pure functions ported same-source across six files; brightness's set going Unsupported while no slot is injected, and the read path against real local sysfs. moon check sys --deny-warn zero warnings; repo-wide still 287/68. Real-hardware re-verification: volume increase and mute, brightness reading the current value, powerprofile listing and switching, printer enumeration, nightlight color temperature taking effect, monitor resolution list.

MoonBit native GUI stack · sys batch 2 (sys/dialog.mbt: file dialogs) ​

  • The GTK side belongs to the win stub, the semantic layer to the sys package: the GtkFileChooserDialog wrapper lives in win/gtk_stub.c under the wm_file_* prefix, with a slot table under the same discipline as the window stubs, since a dialog needs a GTK parent window and the file is already compiled into the GTK link unit by prebuild. sys/dialog.mbt is a thin semantic wrapper only: filters passed through as strings, option bits, run/read-result/destroy. The sys package does not import win — run_for_window's parent is a plain handle id, Int64, so there is no symbolic dependency at the MoonBit level, but linking needs -lyue_win_stub, and scripts/prebuild.py issues link_configs to the sys package separately, with the same GTK library list as win.
  • Querying a chooser that never ran segfaults, measured locally: gtk_file_chooser_get_uri raises SIGSEGV directly while its internal file system model is uninitialised. The stub adds a wm_dlg_accepted flag and permits queries only after gtk_dialog_run returns ACCEPT, giving an empty string in every other case. Same GTK assertion family: set_current_name, and set_filename, is legal only for the SAVE action, so calling it on an OPEN dialog prints Gtk-CRITICAL.
  • The degraded path with no display: wm_file_dialog_new calls gtk_init_check first and returns handle 0 on failure, which is the wbtest case with no X connection and CI. Every interface no-ops on handle 0 without crashing; with a display, as locally, objects really are created and the tests assert both paths.
  • The third crime scene of "stub changed without relinking": after editing gtk_stub.c, moon test reported undefined reference — build/libyue_win_stub.a's mtime was newer than the source, and this batch's test artifacts were not relinked either. The acceptance check is comparing build/*.a against the test exe's mtime; delete both _build/native/debug/test/.../gui/sys and the stub library to force a rebuild. Same root cause as the G-batch stub lesson — moon's incremental build being unreliable for C source changes is by now the normal state.
  • get_uri returns a percent-encoded string and Chinese directories break (2026-10-16 user feedback on real hardware): wm_file_dialog_get_result used to take gtk_file_chooser_get_uri and strip the file:// prefix by hand, so choosing the 下载 directory returned /home/lkyh/%E4%B8%8B%E8%BD%BD, and the example handing it to fsx_list_dir got FsError::OpenFailed. Paths with spaces, # or % behave the same, and the save dialog's initial directory takes the same fall. Changed to gtk_file_chooser_get_filename, the already-decoded local UTF-8 path from GTK; for trash or remote URIs with no local filename it returns an empty string, the same convention as Windows' SIGDN_FILESYSPATH. The wm_dlg_accepted guard from the previous item stays, since querying without running still segfaults. Verification: moon test ./sys 187/187, with mtime checked per the "stub changed without relinking" entry — gui/build/libyue_win_stub.a newer than gui/win/stub.c, and the sys test exe newer than that .a. Real-hardware re-verification: choosing a Chinese directory and a directory with spaces → the playlist shows entries.
  • Verification: moon test -p NoahLiu/native-gui/sys 30/30 — 1 new: dialog create/configure/release on both paths, with a display really creating, configuring and destroying objects, and with no display every interface no-opping; modal run and really selecting a file are left to real hardware. moon check sys --deny-warn zero warnings; repo-wide still 287/68. Real-hardware checklist: triggering the open dialog from the app → selecting a file → result path correct; the save dialog's initial name taking effect; the filters dropdown grouping correctly.

MoonBit native GUI stack · sys batch 1 (sys: fsx / envx / procrun) ​

  • The sys stub goes straight through native-stub: directory enumeration with dirent, environment variables with setenv/unsetenv, text read/write with fopen, and file removal use only standard headers, so they do not need the "prebuild compiles a static library plus a portable stub" arrangement used by win/render/text — that exists because those need GTK headers, which native-stub cannot reach through pkg-config. The single file sys_stub.c splits by platform with #if at the top, the Windows branch being a stub with the FindFirstFileW wide-character routing deferred to M2, and moonbit_make_bytes is declared extern and links the runtime symbol directly, as in ime_probe_stub.
  • sys_getenv reads through the C layer rather than core/env: after an in-process setenv the value must be immediately readable, master's test convention, while core/env's environment table is a start-up snapshot in some implementations. Reads needing live semantics go through the stub; pure reads may still use core/env. The stub also landed sys_read/write_text_file, sys_remove_file, idempotent, and sys_platform(), decided by compile-time #if, the equivalent of master's @yue.platform().
  • procrun changed only its dependency surface: @yue.read_text_file/ffi_remove_file/platform → this package's three sys_*; @subproc, chensuiyi/subproc, is already a main-module dependency, and pr_run's whole chain — spawn, bounded reclaim, capturing through a temp file, C locale — is unchanged.
  • Porting pitfalls: the old wbtest's root-package helpers utf8_bytes/from_utf8_bytes → @utf8.encode/decode_lossy; after replacing ffi_getenv_bytes's output ok Ref assertion pattern with the Option-returning sys_getenv, the leftover inspect(ok.val) on a neighbouring line had to go too — changing a signature without clearing assertions is a compile-time blow-up.
  • Verification: moon test -p NoahLiu/native-gui/sys 29/29 — fsx: enumerating a real directory including a temp file, .. excluded, the unopenable branch; flat text decoding; in-process write-read-modify-delete round trip with overwrite=false preserving, idempotent delete and a=b rejected. envx: single-key read against core/env, the common set collected only where present plus a full XDG_* sweep, error branches. procrun: pr_available/pr_run with a real subprocess round trip — echo, non-zero exit with stderr captured, timeout kill, the /bin/sh script path. moon check sys --deny-warn zero warnings; repo-wide still 287/68.

MoonBit native GUI stack · Markdown view (new package mdview, component library closed out) ​

  • A new package rather than reusing the old directory: master's yue/markdown depends on the @yue root package, the error backlog; the new stack follows the "one folder one package" convention at mdview, with mizchi/markdown@0.8.3 already a main-module dependency so the new package imports it directly. The pure-function layer — flattening, range metrics, footnote scanning, line splitting — was ported with zero difference from master; the rendering layer switched to the component surface, @ui.hbox/vbox/label/separator/checkbox/code_view, @render.Image, @text.AttributedText.
  • Link opening changed to an on_link callback: master's rich-text row click called @yue.open_url directly. In the new stack open_url belongs to the system-capability line, the desktop integration family still to be ported, so markdown_view gained an on_link? : (String) -> Unit parameter threaded through MDCtx into the rich-text row closure — the first version used a process-level single slot and was changed to ctx threading because multiple instances overwrote each other. A struct's function field must be applied as (ctx.on_link)(u); writing ctx.on_link(u) reports "has no method".
  • Rich-text rows' theme following is cached by fingerprint: rebuilding an AttributedText cannot happen every frame in on_draw, since animation frames would rebuild repeatedly, nor only at construction, since link and code colors must follow the theme. on_draw compares @theme.theme_fingerprint_of(theme_current()) and rebuilds only when the fingerprint changes.
  • Block spacing strategy changed: master gave each block its own marginBottom through style strings. @ui.hbox/vbox here has only four-sided padding and no padding_bottom parameter, so block spacing is handed uniformly to the outermost vbox's row_gap, with individual blocks' bottom space via node.set_margin_bottom(@yoga.fixed(...)).
  • Verification: moon test -p NoahLiu/native-gui/mdview 26/26 — 24 pure-function/flattening/range/GFM assertions ported same-source: UTF-16 metrics and emoji, footnote numbering order, reference-style link definitions, strikethrough and superscript ranges, fence info's first word, line splitting, image path stripping file://, and the table alignment mapping rewritten as is pattern assertions since the yoga enums do not implement Eq. 2 component mounting: a full GFM document — headings, bold/italic/strike, links, task list, ordered list, blockquote, code block, table, rule, definition list, footnote — with a subtree over 5 nodes and height grown by content; a link click callback not crashing. moon check mdview --deny-warn zero warnings; repo-wide still 287/68. The real window's geometry line confirms layout, md=24,2745,420x403, with appearance left to the user on real hardware. With this, all ten component families plus Markdown are closed out.

MoonBit native GUI stack · code_view syntax highlighting (ui/codeview.mbt) ​

  • Tokenizer ported verbatim, component reshaped: Token/lang_keywords, the 8-language keyword tables/tokenize_lang/classify_word are same-source and unmodified from master's components_text.mbt, since the toolchain writings were already the new convention. code_view changed from "AttributedText pre-layout plus draw_attributed_text" to "measure each token at construction with @ui.text_width → draw each segment in on_draw with @text.draw_box_at". The old chain's whole-run coloring, working around Windows' AttributedText range-color defect, is structurally unnecessary here, since self-drawing produces directly; line height comes from @text.line_height_font.
  • Counting pixels of a single solid color is a fragile assertion: 13px small monospace glyphs, such as the keyword "fn", can end up with no solid-color pixel in the glyph core after subpixel anti-aliasing — measured purple=0 in this batch while a 6-character Chinese string gave green=22. Component pixel assertions therefore count total ink against the background; per-color assertions are reliable only for large solid areas, chart sectors and color bands.
  • Verification: moon test -p NoahLiu/native-gui/ui 110/110 — 3 new: the tokenize assertions ported same-source, keywords, types, qualified names, calls, ranges, strings, comments, multiple languages; component mounting with the dark base covering ninety percent, ink >150, height linear in row count. moon check ui --deny-warn zero warnings; repo-wide still 287/68. The real window's geometry line confirms layout, code=24,2745,400x153, moonbit with line numbers plus two python blocks, with appearance left to the user on real hardware.

MoonBit native GUI stack · Specialised charts batch 4 (chart: effectscatter / themeriver / geo, chart line closed out) ​

  • Two clashing meanings of 8-digit color strings, measured in this batch: the old chain's charts used #AARRGGBB, alpha first, see the "yue color values and three drawing pitfalls" entry, while this stack's @render.Color::parse reads 8 digits as RGBA, alpha last. eff_alpha_color feeding 8-digit input straight to @theme.parse_hex treated the alpha byte as red — #ff009688 parsing to r=255, which is the root cause of "#ffff0096" != "#ff009688". Fix: for 8-digit input, strip the leading two alpha digits and parse the remaining six, matching the old chain's "RGB is never rewritten" semantics, with the new alpha recomposed from the parameter.
  • Ripple animation moved from "callback returns false to unregister" to a real handle: the old chain's set_timer had no cancel id and stopping relied on the callback returning false. This stack's @core.set_timeout/clear_timeout return real handles, so EffAnim holds running + timer and eff_stop cancels the pending callback immediately — the same continuation pattern as carousel auto-advance, where the callback schedules the next beat.
  • GeoJSON parsing goes through @sys, with vsc_json_parse already ported there, and RectF became a local GeoRect, shaped like treemap's TmRect with each defined on its own. argb_hex sat in the old chain's root package among the errors, so the chart package implements it locally with Int::unsafe_to_char assembling hex, noting that Char::from_int is deprecated.
  • Three test lessons, all hit in this batch: ① ink_of on a "panel base laid first" component always equals the whole area — the charts batch's pitfall — and the headless default theme's bg_panel is pure white #FFFFFF, so white counts as ink; assert content through characteristic-color sampling or diff_of. ② themeriver reserves 240px for the legend, so the canvas must exceed 298 to have a drawing area, against the component's default width 560; a narrow canvas test exits early on the whole drawing with no error at all. ③ Hand-written JSON in tests should be run through an external validator — this batch's coordinate array had one extra ], the parser correctly returned None, and it was briefly misjudged as a parser bug. When all chunked bisections pass but the complete string fails, suspect the string first.
  • Verification: moon test -p NoahLiu/native-gui/chart 189/189 — 43 new: three wbtest files ported same-source, 40, plus 3 component mountings — ripple changing with data with eff_stop leaving is_running=false; river changing with a new series, canvas 560; map parsing plus regional flight lines producing pixels plus clearing changing. Package --deny-warn zero warnings; repo-wide still 287/68. The real window's geometry line confirms layout, eff=24,2542,300x170, with visuals left to the user on real hardware. With this, the chart line is closed out: 15 specialised charts plus five general chart types plus the interaction layer.

MoonBit native GUI stack · Specialised charts batch 3 (chart: sunburst / graph / parallel / pictorialbar) ​

  • Same routine as batch 2: a conversion script handles dependency name replacement, and three classes of segments are rewritten by hand — the "no data" block, draw_text with conditional alignment, and the component layer's mount blocks. pictorialbar's 4 places omitted the wrap= parameter, which the regular expression required, so they were missed; calls that leave out a default parameter also need hand edits. The sunburst's centre summary, 20px SemiBold plus a 「总计」 caption, uses the same @text.draw_box_at form as draw_donut_core.
  • Same-field structs in one package make a literal ambiguous as "Multiple possible record types": SunItem/TreeItem/TmItem all have the shape {name, value, children}, so a literal cannot disambiguate — go through make plus children.push, whose array field contents are mutable. The same applies across packages, a batch-2 pitfall reproduced here and settled as the general convention.
  • Positional passing to optional parameters is restricted: GraphNode::make(name, value? : Double) and GraphEdge::make(s, t, weight? : Double) cannot take optional parameters positionally — make("甲", 30.0) reports requires 1 positional arguments — so always name them: value=30.0, weight=5.0. SunItem::make's value? : Double? is a double Option, so a number must be wrapped as Some(v).
  • graph's layout cache moved onto the disposal bag: the old chain cleared the cache inside subscribe plus schedule_paint; here follow_store handles repaint only, and cache invalidation has its own subscribe_id registered with sub_bag_register, the same pattern as gauge_t.
  • Verification: moon test -p NoahLiu/native-gui/chart 146/146 — 48 new: four wbtest files ported same-source, 44, plus 4 component mountings — sunburst changing with data, graph's layout cache invalidating after a data change so the picture changes, parallel coordinates changing rows, pictorialbar producing pixels in both forms. Package --deny-warn zero warnings; repo-wide still 287/68. The real window's geometry line confirms layout, gph=282,2346,260x190, with visuals left to the user on real hardware.

MoonBit native GUI stack · Specialised charts batch 2 (chart: kline / sankey / treemap / tree) ​

  • Mechanical port plus hand edits: the four files went through the conversion script for dependency name replacement, @yue.* → @theme/@render/@core/this package's functions. Three places were rewritten by hand — the "no data" block, draw_text with a conditional align, and the component layer's mount blocks. draw_text's align= accepts any expression, align=if first_col { @text.End } else { @text.Start }, which a converter matching literals misses; such segments are always hand-edited.
  • treemap's RectF became a local TmRect: the old chain reused the root package's RectF with x/y/width/height, while this stack's core Rect uses w/h field names and means a layout rectangle. The chart package therefore carries its own same-shape TmRect, 15 replacements, taking on no cross-semantics dependency. from_hsl is available in @theme.palette.
  • tree_chart_t's orientation : String became vertical : Bool: the old chain's string parameter, "horizontal"/"vertical", was a historical API shape; component parameters in this stack are always typed, aligning with bar_chart_t's horizontal?.
  • TreeItem/TmItem must be constructed across packages through make: a pub(all) struct's literal construction outside its defining package reports "There is no record definition with the fields", hit while building tree data in native-app. Cross-package construction is uniformly TreeItem::make(name, value=…) plus children.push(…), since children is an array field with mutable contents.
  • Verification: moon test -p NoahLiu/native-gui/chart 98/98 — 34 new: four wbtest files ported same-source, 29, plus 5 component mountings — K line producing pixels in both up-red and down-green, sankey drawing and changing with data, treemap hover brightening and restoring with the flat canvas and drawing same-source, treemap producing pixels in both forms. Package --deny-warn zero warnings; repo-wide still 287/68. The real window's geometry line confirms layout, NATIVE-APP-CHSP sankey=332,2190,300x150. Screenshot review was limited: a 2500-tall window already exceeds what XFCE allows for moving, windowmove -1346 clamped by the WM to -1278, so the target block's bottom left the screen and the clamped path lost GTK's expose, capturing an empty backing. Visual appearance is left to the user on real hardware, native-app's specialised chart block, second row.

MoonBit native GUI stack · Specialised charts batch 1 (chart: boxplot / funnel / heatmap / radar) ​

  • One chart per file, ported same-source: master's charts_boxplot.mbt/charts_funnel.mbt/charts_heatmap.mbt/charts_radar.mbt became boxplot.mbt/funnel.mbt/heatmap.mbt/radar.mbt — pure-function layer verbatim, drawing layer switched to @render.Painter/@text.draw_box_at with colors read live from @theme, component layer switched to the node_of_new form. The four wbtest files came across correspondingly, each with a component mounting producing pixels assertion: ink_of > N, plus diff_of > 50 after new data, plus empty data or insufficient dimensions not crashing. A regression where the pure functions are right but the component is unwired is now caught on the first run. The old chain's AttributedText "no data" was uniformly replaced with the fixed-box @text.draw_box_at form, same as draw.mbt.
  • All dependencies already in the new stack: mix_hex/lighten_hex/parse_hex/rgb_to_hsl are in @theme.palette, and bar_slot/draw_hgrid/series_color/fmt_axis/truncate_to are reused from this package. mut_view().sort_by(fn(a,b){ -1/0/1 }) works on this toolchain, an Int-returning comparator, unrelated to the Ordering unavailability pitfall.
  • Verification: moon test -p NoahLiu/native-gui/chart 64/64 — 27 new: boxplot quantile interpolation, five-number summary, two-sided outliers, zero IQR; funnel ordering preserving rank, layer geometry, trapezoid widths, proportions; heatmap range, color-band interpolation, cell coordinates; radar angles, polygons, range normalisation, layout — plus the four component mountings. moon check chart --deny-warn zero warnings; repo-wide still 287 errors / 68 warnings. Real-window evidence: examples/native-app gaining a "specialised charts" block, window grown to 1400×2300, geometry line NATIVE-APP-CHSP — exit code 0. Characteristic-color sampling from the screenshot shows all four charts: boxplot with a 68px success box plus 28px danger outlier dots, funnel's first layer in primary at 3508px, heatmap's light primary 5773px plus deep primary 1944px, showing the color-band gradient, radar's two series at 169/168px. Appearance is for the user to re-verify on real hardware.
  • Screenshot practice supplement: when a window is moved too far off screen, X discards the off-screen backing, so import -window captures uniform grey 242. Move the target block on screen while keeping most of the window visible — around a client origin of -1200 locally with a 2300-tall window — or pixel sampling from the screenshot is meaningless.

MoonBit native GUI stack · Chart interaction layer (chart: tooltip + DataZoom/VisualMap/mark/export + clickable legend + charts_it three interactive charts) ​

  • Port surface and file correspondence: master's charts_tooltip.mbt/charts_interactive.mbt/charts_it.mbt were ported same-source into tooltip.mbt — the CiTip overlay state with out-of-bounds-safe placement, CiPlot drawing area, nearest-point ci_nearest_idx and sector ci_sector_at hit, the ci_tooltip interactive canvas component; interactive.mbt — the full CiZoom window math, CiVisualMap color bands, ci_mark_line/ci_mark_area, ci_save offscreen export, legend hit and visibility bit helpers, the ci_legend component; and charts_it.mbt — line_chart_it/bar_chart_it/donut_chart_it. The three wbtest files came across correspondingly.
  • Legend color shape changed: no color strings stored, but (name, original index) plus color_at read live. master fixed (name, color string) into a Store, so a theme change left the whole palette stale and required rebuilding items via on_theme_change. ci_legend here takes color_at : (Int) -> String and reads at on_draw — series colors, donut colors and bar positive/negative colors all come from the theme live — so a theme switch through the process-level watch_theme repaint yields new colors automatically, and the on_theme_change rebuild chain is structurally unnecessary. The bar's positive/negative split is a closure capturing the data Store's current value.
  • on_scroll's callback signature has no widget first parameter, Option[(ScrollEvent) -> Bool], unlike on_draw/on_pointer, whose first parameter is the widget to avoid reference cycles; a scroll callback cannot reach it. Wheel zoom scaling needs the canvas width and height, so the solution is holding a size Ref inside the constructing closure, refreshed every frame by on_draw, which does have the widget parameter, and read by on_scroll as the most recent frame. A visible component has always drawn at least one frame, so there is no window in which (0,0) could be read.
  • ci_tooltip gained an on_leave callback: hiding the overlay clears only CiTip, but the caller's own hover state — the polyline's guide line and landing point, the sector's outward expansion — is drawing state beyond hit, and stays stale if not cleared. The white-box "restored after leaving" assertion failed on the first run, diff=276/1012, exactly because the guide line and expansion were still drawn. All three interactive components pass on_leave=fn(){ hover.val = -1 }.
  • Drag capture is no longer managed by the component: master's set_capture/release_capture are structurally unnecessary here, since the host's route_pointer makes the Down hit target the capture owner automatically, and every later Move/Up goes to it, so a release is never lost when the pointer leaves.
  • draw_donut split to expose draw_donut_core, sectors plus centre summary with colors through a color_at parameter: the interactive version's legend is ci_legend, clickable and visibility-toggleable, so it cannot reuse the original draw_donut's embedded legend, and draw_donut itself became a thin shell, core plus embedded legend. ci_sector_at, general sector hit with a thickness parameter, coexists with the simplified donut_hit written for fixed thickness in the first batch, since the interactive ring's thickness is configurable and hit geometry must follow it same-source.
  • PNG export really works on Linux in this stack, render/png.mbt being a pure implementation with no platform shim: master's ci_save always failed off Windows, its shim not exposing an export API, so the wbtest could only check format without touching disk. Here the wbtest writes to /tmp and asserts Ok, and ci_save_chart's offscreen Painter must call end() when done, since Cairo session slots are finite.
  • xdotool's XTest wheel injection does not work on GTK3 — measured in this batch, a limitation of the injection tool rather than the code: xdotool click 4/5 produces button4/5 events at the X level, received by xev and marked synthetic NO, but GTK3's X11 backend uses XInput2 and does not translate XTest core button events into scroll events. Reproduced conclusively with a 20-line GTK probe connecting only scroll-event plus fprintf: not one entry into the stub's wm_on_scroll. The wheel-zoom component chain is covered by the white-box dispatch_scroll direct call, injecting dy:1 → narrower window → pixels change, and real-hardware wheel feel and appearance are left to the user. Programmatic wheel injection would need uinput; /dev/uinput exists while ydotool and evemu are not installed.
  • Triple coordinate alignment for real-hardware screenshots, a pitfall on 10px targets: ① xdotool mousemove --window gives client-relative coordinates, the same system as GTK event coordinates, so injection needs no conversion. ② import -window on a window taller than the screen captures only the visible part, with the screenshot origin at the screen rather than the window — locally a 2080-tall window against a 1080 screen, so after moving the window to negative Y, screenshot y = client y − 866. ③ Frame and client differ by a title bar, 34px on XFCE. Sampling with the wrong offset first produced the false conclusion "the legend swatches were not drawn" — a 10px swatch missed by 34px is entirely absent. Align first, deriving the client origin from the mouse landing point via getmouselocation, before concluding "not drawn". Likewise, injecting into a window taller than the screen requires xdotool windowmove to negative coordinates first, bringing the target block on screen; XTest coordinates are clamped to the screen's bottom edge, so a point off screen means no injection at all.
  • Verification: moon test -p NoahLiu/native-gui/chart 37/37 — 26 new: the three tooltip wbtest files ported, overlay size estimation, out-of-bounds-safe placement, nearest point and map_idx being mutual inverses, sector four quadrants with ring-band tolerance, CiPlot fractional and degenerate cases; the full zoom math — normalisation, setting the window, the wheel anchor invariant, pan bounds, visible interval; VisualMap's two-color and three-color bands with invalid colors falling back; legend hit accumulating geometry with a width threshold; visibility bits filling, flipping and filtering with offset addressing; slice narrowing; ci_save_chart size and format checks plus PNG really written to disk; the three interactive components mounted with injection — line showing overlay and guide line on hover and restoring on leave, legend click hiding a series changing the picture, bar scaled by dispatch_scroll direct call, donut sector expanding on hover and restoring; pure functions — plot margins same-source as drawing, donut geometry clamping the inner radius, items keeping original indices with palette cycling, window scaling taking the longest series. Nine packages' tests all green — core 62/input 10/render 42/text 29/icons 4/theme 8/sys 7/ui 107/chart 37 —, ten packages --deny-warn all zero warnings, yoga-mbt 96/96, repo-wide still 287 errors / 68 warnings, no increase. Real-window evidence: examples/native-app gaining an "interactive charts" block, window grown to 1400×2080, geometry line NATIVE-APP-CHIT — exit code 0. After injecting "wheel on the bar canvas, ineffective, see above → click the donut legend's first item → hover the donut sector → hover the line canvas", pixel sampling matched item by item: legend swatches all present including the bar's negative item red (214,69,80), a hidden item's swatch turning border grey (216,220,225), the first sector at 12 o'clock, primary blue, gone with the remaining sectors redistributed so that position became success green, the tooltip's dark base (49,49,51) to the right of the hover point, and a landing-point color on the hovered line. Wheel zoom, tooltip following feel and legend click feel are for the user to re-verify on real hardware.

MoonBit native GUI stack · Platform input mapping layer (parallel line #19, first batch: core/input_map.mbt) ​

  • Why the tables first and the backends later: the Windows and macOS backends cannot be validated on this machine, no real hardware, so per the "do not write unverifiable code" discipline the platform-independent conventions were landed first as pure functions plus regression: modifier folding for the three platforms, two key-code tables, Win32 virtual keys and macOS kVK_*, shift folding, button numbers, and the routing table for "who gets this key first". Backends then fill from the table instead of each deciding the semantics again, which would produce three versions.
  • Three conventions: ① keysyms use X11 values, the Linux backend being GDK keyval already, with the other two platforms aligning to it, and printable ASCII being its character code. ② Letters and digits yield the unshifted base code, with shift and caps folded by win_apply_shift: X11's keyval already carries shift semantics while Windows' virtual key codes do not, so without this step "Shift+A" would be read as 'a'. ③ Symbol keys, the OEM row, are not hard-coded into a layout table: Windows goes through WM_CHAR/Unicode, macOS through charactersIgnoringModifiers, and a hard-coded table would be wrong often and unmaintainable.
  • One measured semantic correction, written into the decision table: cursor and selection keys must count as kernel keys with Ctrl too — Ctrl+Left/Right jumping by word and Ctrl+Home/End going to start/end are exactly the commonest editor actions — but not with Alt, since Alt+Left/Right is a host action such as "back". The order is itself the semantics: chord → cursor key → Tab/Shift+Tab, focus cycling → shortcut chord → printable key, passing the input method first → everything else to the host.
  • Authority ownership must be written in two places' comments: Linux's modifier folding currently lives in wm_mods in win/stub.c, which must fold on the C side and cannot call back into MoonBit, and it is bit-for-bit identical with gtk_mods_to_mods. Both comments name the other, so changing one requires changing the other; the executable definition of the table is on the MoonBit side, pinned by regression.
  • macOS's kVK_* covers special keys only: Return/Tab/Space/Backspace/ForwardDelete/Home/End/PageUp/PageDown/arrows/F1-F12/keypad — letters and digits are deliberately unmapped, since mac characters must come from the event's Unicode text and layout resolution is the system's job. Also mac's kVK_Delete(51) is Backspace and kVK_ForwardDelete(117) is Delete; this is the easiest pair to remember backwards, so both are pinned in assertions.
  • Verification: moon test -p NoahLiu/native-gui/core 78/78 — 7 new: three-platform modifier bits including "the GDK lock bit is not a modifier" and "Windows button bits do not leak into modifier bits"; the Win VK table — letters, digits, control, function, keypad, unknown yielding 0; shift folding — letters by shift xor caps, digits and symbols through a table, unlisted keys returned as-is; the mac VK table with Backspace and Delete pinned separately; button numbers folding Win MK_* and mac buttonNumber into 1 left, 2 middle, 3 right; the routing table's six rules; the trigger conditions for reset and surrounding. moon check core --deny-warn zero warnings; repo-wide still 287 errors / 68 warnings. This batch has no real-hardware evidence, everything is pure functions, and backend wiring follows the Windows foundation and mac hardware.

MoonBit native GUI stack · The native tier for overlays (batch E remainder: core/overlay_native.mbt) ​

  • The tier decision looks at one thing only: whether the containing window has mounted native child surfaces, Window::has_child_surfaces(), read from the C-side slot table wm_window_surface_count. With a web area present the whole layer switches to real windows; without one it stays on the layer. Putting the check in the C-side slot table rather than MoonBit bookkeeping avoids a counter that can miss decrements. All six overlays — tooltip, dropdown, context menu, dialog, toast, hover card — go through overlay_show_at/overlay_show_full, so the tier switch takes effect in one place.
  • The native table is not put inside OverlayHost: the host belongs to App, and the host holding App would be a reference cycle. The popup table is a process-level Map[handle, App], and overlay_hide/overlay_clear plus exit cleanup all go through it.
  • Two GTK facts behind "open the frame, measure the content, then shrink the frame": ① the drawing area's gtk_widget_set_size_request is the window's minimum content size, set from the initial size at creation, so calling gtk_window_resize without changing it cannot shrink below the initial value and a measured overlay would keep a dead margin; ② set_resizable(FALSE) makes programmatic gtk_window_resize ignored entirely — a borderless window cannot be dragged by the user anyway, so setting TRUE has no side effect. Both are handled together inside wm_window_set_size.
  • "Dismiss on blur" misfires during mapping and is off by default, established by a control experiment: with it enabled the popup closed itself after about two seconds, while the content's own dismiss control had never been clicked, proven by hides=0; turning the switch off keeps the same sequence open. Root cause: a transient plus keep-above window receives a run of focus-out during mapping. Until there is a "counts only after having had focus" check, an overlay staying resident and dismissing from its content is preferable — "vanishing right after opening" is worse than "not dismissing on outside click". The switch overlay_set_dismiss_on_blur(on) stays and can be enabled once the check exists.
  • A new event constructor hits a "frozen" baseline page: adding LostFocus to @win.Event took repo errors from 287 to 288, because examples/native-window, the G2–G5 baseline with no further features, matches events exhaustively. The check is to notice the error-count change and find who grew; adding one empty branch returns to baseline. "Frozen" does not mean exempt from convention changes.
  • Verification: moon test -p NoahLiu/native-gui/core 81/81 — 3 new: with no child surface going the layer tier while overlay_show_at/hide/count behave as usual; sizing counting only the visible children's right and bottom edges; with no native-tier overlay, both the blur and shrink-frame paths being no-ops. moon check core/win and four examples --deny-warn zero warnings; ui 110/110, web 11/11, media 18/18 unchanged; repo-wide back to 287 errors / 68 warnings. Real-window evidence, clicking "overlay above web" in a bridged window that has a web area: NATIVE-WEB-OVL opens=1 tier=1 native=1 layer=0, and the screenshot shows self-drawn content — a title plus a 「收起」 button — above WebKit-rendered body text, exactly what the layer tier cannot do.
  • Not in this batch, not pretending completion: a usable check for outside-click dismissal, the switch above stays off; Esc dismissal; popup anchor following the owner's scrolling and movement; per-component size hints, currently relying on the three-frame shrink.

MoonBit native GUI stack · Native popup windows (batch E: win/popup.mbt + core/popup.mbt) ​

  • Why it must be a real window: self-drawn pixels always sit below a native child surface, such as a webview area, because z-order is decided on the native side and the Painter cannot cover it. So "dropdowns, context menus, tooltips, toasts and dialogs must cover a web page" has exactly one route: the window system, the overlay discipline of plan §4.5. This batch provides that channel, not another self-drawn layer.
  • Five GTK settings must be applied together, and before show: set_decorated(FALSE) plus set_type_hint(GDK_WINDOW_TYPE_HINT_POPUP_MENU) plus set_transient_for(owner) plus set_skip_taskbar_hint/skip_pager_hint(TRUE) plus set_keep_above(TRUE), with set_resizable(FALSE) as well. The type hint and the transient relation take effect at mapping, so setting them after show is too late.
  • One hard measured pitfall: gtk_window_get_position always returns (0,0) under the window manager's reparenting. It reports coordinates relative to the parent window, and a top-level is wrapped into the WM's decoration frame, so relative to that frame it is 0,0. The screen origin must be asked of the GdkWindow: gdk_window_get_origin(gtk_widget_get_window(w), &x, &y). The acceptance check is not "an error" but the popup landing at the requested local coordinates regardless of where the owner is — in a multi-window shape every window's menu landing at the same screen coordinate looks like "the popup does not follow the button". After the fix, measured owner_origin=2,34 with a request at local (420,300) landing at (422,334), bit-for-bit with xwininfo.
  • Placement as a pure function: popup_place(owner_x, owner_y, x, y, w, h, screen_w, screen_h) — past the screen's right or bottom edge, flip to the other side of the anchor, so an edge-hugging menu opens in the reverse direction; if flipping still yields a negative, clamp back to 0; a screen size of 0 means "clamp only, no flip". All three boundaries are in assertions, including the easy-to-write-wrong "if the flipped position is still on screen, use it rather than clamping to 0".
  • A popup is just an attached window and needs no second lifecycle: App::open_popup goes through batch A's registry, so it shares the loop and the pump naturally, close_window() detaches only itself without affecting the host, and App::shutdown tears both down. Example evidence: after picking a menu item, xdotool search --name '^popup$' returns 0 while the main window remains, with NATIVE-WEB-POP opens=1 closed=1 choice='乙' wins=1 owner_origin=2,34 win_size=1100x700, and the screenshot clearly shows a borderless popup above WebKit-rendered page text.
  • This batch gives the channel plus one self-drawn popup example only: the six pieces in core/overlay.mbt — tooltip, dropdown, context menu, dialog, toast, hover card — still use the self-drawn layer; wiring them to "native tier when a webview exists" is next. The check: switch to a native window only when the containing window has a mounted pane, otherwise stay on the layer, since with no web page there is no reason to open another window.
  • Verification: moon test -p NoahLiu/native-gui/core 71/71 — 1 new placement regression covering seven boundary cases. moon check win/core and three examples --deny-warn zero warnings; ui 110/110, web 11/11, media 18/18 unchanged; repo-wide still 287 errors / 68 warnings. Real-window evidence as above: a 240x150 popup landing at (422,334), IsViewable, closing itself on selection, the main window unaffected. Needs the user's eyes: the borderless appearance and how keep-above behaves across multi-monitor and window switching.

MoonBit native GUI stack · Media player (batch D: new package yue/media, CPU blit + software decoding) ​

  • Engine ported, not rewritten: modules/gui-media's audio_player.mbt, 416 lines, and video_player.mbt, 476 lines, measured to have zero @yue dependencies, only the component wrapper layer touching the UI, so the engine and 15 existing regression cases moved wholesale into the new yue/media package with only the test asset path and one public entry changed. The old chain's pitfalls — audio and video drifting apart, progress climbing from zero after a seek, crashes when loading a new source, played-to-end needing to differ from the initial state — came across with those tests. Half the value of this batch is catching what the teacher already got right.
  • Asset path convention changed: a native test executable's cwd is the workspace root, not the package directory, whereas the old package under modules/gui-media/src wrote ../ffmpeg-mbt/src/testdata/. The new package must write modules/ffmpeg-mbt/src/testdata/av_1s.mp4, and the acceptance check is the relative path printed verbatim in the error.
  • Frame blit byte order: ffmpeg's sws target is AV_PIX_FMT_RGBA, R,G,B,A, while render's Bitmap is internally Cairo ARGB32 in little-endian layout, B,G,R,A, premultiplied. Blitting a frame must therefore swap R and B; on opaque pixels premultiplied equals unpremultiplied, so alpha is written as 255. Pinned byte by byte in rgba_to_bitmap, with the wbtest asserting 10 20 30 00 → 30 20 10 ff, and the real-window evidence being testsrc's colour bars showing no swapped colors.
  • This toolchain has no FixedArray::of_list / Array::freeze / Bytes::of_list: test bytes can only be built with FixedArray::make(n, b'\x00') written by index plus Bytes::from_array. Also String::split yields a StringView iterator, needing to_owned() for a String; replace takes labelled parameters, old=/new=; the constructor form is StringBuilder(), since Buffer::new does not exist; and an unused parameter such as y must be written _y.
  • Layering between UI and engine: video lives in yue/media, importing @core/@render/@text/@theme inside the package, and not in ui — otherwise every button-only application would link ffmpeg and miniaudio. The engine files audio.mbt/video.mbt/subtitle.mbt stay free of UI dependencies, the media layer does not depend on webview, and both depend only on the window, layout and event layers, building-block principle 1.
  • Clock and repaint: the engine's tick(), formerly vp_tick, opened as the public tick in this batch with frames_decoded() added, is driven by a self-continuing @core.set_timeout(33, ...), not advanced inside drawing. Each tick calls the observation hook on_tick(position, frame count, playing), which the example uses to prove frames are advancing and the clock is running rather than one static image. The image object is rebuilt only when frames_decoded() changes, since rebuilding an Image every frame would repeatedly create cairo surfaces.
  • Seek debouncing made into a partition pure function: player_bar_hit splits the control bar into leftmost 34px play button, right 150px time text and middle progress track; during a drag only the display position updates, with the real seek happening on release. The old chain's problem was seeking on every drag step, which stalled the picture for a whole beat given long keyframe distances. Two boundary assertions are worth recording: both ends of the track clamp rather than overflow, at 449px giving >0.99 instead of ==1.0, and a negative coordinate falling into the play-button zone, since partitioning precedes the ratio.
  • A media file that cannot be opened is not silent: when video's make fails it draws an explanation panel and hands the reason out through on_error, probing plus degrade with an explicit Unsupported, and the example's err='' is the evidence that no degradation happened.
  • Not in this batch, recorded in TODO rather than pretending completion: playback speed — audio needs time stretching, and doing video alone breaks audio-video sync, exactly what this document repeatedly warns about; embedded subtitle tracks, mov_text/ass, this batch taking external SRT/VTT text only; fullscreen; the volume bar UI, the engine has set_volume but no control was drawn; frame stepping.
  • Verification: moon test -p NoahLiu/native-gui/yue/media 18/18 — 15 ported: audio play/pump/seek/loop, video decode/clock/duration/frame size/silent advance without an audio track. 3 new: subtitles in four parts, two timestamp spellings, SRT and VTT both handled, inline markup stripped, hit boundaries including start and end with multiple cues joined; frame blit byte order and short-packet tail pixels; control bar partitioning. moon check yue/media/./examples/native-web --deny-warn zero warnings; core 70/70, ui 110/110, web 11/11, render 43/43, text 29/29 unchanged; repo-wide still 287 errors / 68 warnings. Real-window evidence — examples/native-web's "open a player window", a third window playing av_1s.mp4 plus inline SRT — NATIVE-WEB-MEDIA frames=10 pos_ms=1000 playing=0 err='': a 1-second clip decoding all 10 frames, the clock reaching 1000ms, stopping automatically at the end, no degradation. The screenshot shows colour bars with no swapped colors plus the white text 「第二条字幕(0.6~1.0 秒)」 overlaid on the picture plus the control bar's progress full and 0:01 / 0:01. Needs the user's eyes: whether playing with sound really produces audio, this machine having a miniaudio device; the feel of seeking by dragging the progress bar; long-clip speed and frame stepping, not in this batch.

MoonBit native GUI stack · Native fields untypable after clicking into a web page (two root causes: GTK focus not reclaimed + preedit not drawn on an empty field) ​

  • Symptom, user's real hardware: after clicking into the webview page, clicking back into a native input field and typing produces nothing.
  • Reproduced with xdotool injection plus temporary C-side probes, revealing two independent defects:
  • ① Our drawing area never reclaimed GTK keyboard focus: gtk_widget_grab_focus ran once when the window showed, after which the WebKit child took focus on click, and a GtkDrawingArea does not take focus back because it was clicked — GTK does not do that for it. Probe evidence: after clicking back into the field, not a single [focus] in and no [key] either; keys all went into the web page, even Ctrl+Z, which is why "some operations still seem to work" is an illusion. Fix: the host calls request_focus() unconditionally on pointer press. It previously asked only when the focus stack really changed hands, and a change of hands is exactly what does not happen, the field still being in the stack.
  • ② On an empty field showing a placeholder, the preedit string was not drawn at all: ui/entry.mbt's drawing wrote "draw placeholder" and "draw text plus preedit" as one if/else, so with no text an empty field took the placeholder branch and the preedit branch was skipped entirely. Pinyin was working normally — probe [preedit] '爱' kept arriving — while nothing appeared on screen, presenting as "typing does nothing". Fix: the placeholder is drawn only when there is neither text nor preedit, with preedit as its own branch. This defect is unrelated to the web page and reproduces on an empty field alone; the multi-line textarea does not have it, since its preedit is drawn outside the placeholder loop.
  • One tightening made along the way: when keyboard focus is taken away, Event::LostFocus, the input-method session is handed back to the system — the in-flight commit goes to the current widget, the preedit display clears, im_reset interrupts the engine-side unfinished composition, and the de-duplication cache resets. The focus stack is untouched, so returning to this window restores the widget's focus as before.
  • Probe methodology, hit and recorded: an application's println is block-buffered when stdout is redirected to a file, and everything is lost if the process is killed — the first reading led to the false conclusion "the application emitted no log at all", and stdbuf -oL was needed to see real data. C-side stderr is unbuffered, so when the two sides' logs look asymmetric, suspect buffering before drawing conclusions.
  • Verification: [focus] in appears after clicking back, with [key] following; both absent before the fix. An empty-field typing screenshot shows the preedit 爱 with its underline; the full round trip — clear, click into the page, click back, type — shows the preedit 阿宝. core 82/82, ui 110/110, input 10/10, web 15/15 all green, per-package --deny-warn zero warnings, repo-wide still 287 errors / 68 warnings. Needs the user to re-verify: whether the originally reported scenario, jumping inside a web page and returning to a native field to type, is now working.

MoonBit native GUI stack · Window-level key fallthrough channel (App::add_key_fallthrough) ​

  • Gap: the host delivers keys only to the focused widget and the ancestors of it that declare on_key, the Key branch of App::route, so with empty focus the key is dropped outright. "No keyboard unless something is clicked" is therefore the framework's current behaviour, and window-level shortcuts of the player kind had no entry at the component layer.
  • Convention: when the focus chain does not consume a key, or there is no focus at all, fallthrough handlers are asked in registration order and the first returning true truncates. Three boundaries — ① while focus is in an input field the editor consumes the key, so the channel does not steal characters and needs no extra check; ② it applies only within this window's event loop, and is not the system-level hotkeys of sys/win, the X11 grab line; ③ the channel has no unregister entry, since this layer has no widget unmount hook yet, so the handler must do its own liveness check — asking Widget::is_painted(), whether this subtree is still drawn this frame, and returning false when it is not, so a detached component stops eating keys.
  • Implementation cost: one more field on App required the three hand-built fake-window literals to be updated as well, since record literals cannot omit fields; repo-wide only core's three wbtests and App::new were affected.
  • Verification: moon test ./core 149/149, with the new key_fallthrough_wbtest.mbt adding two cases — no focus receiving a key and release not entering the channel; the focus chain consuming not being stolen, and multiple handlers truncating in registration order. moon check ./core --deny-warn zero warnings. Real hardware — whether space and Enter are taken only by the currently focused player — is verified by the user along with the control-surface batch.

MoonBit native GUI stack · Ownership of the present buffer (fix: garbage when opening a second large window / flicker in self-drawn areas) ​

  • Symptom, user's real hardware: opening a second window such as the "bridge window" or the "local video window" produced garbage on screen and flicker in self-drawn regions; with only the main window it was invisible.
  • Root cause: wm_present pointed w->pending straight at the MoonBit-side Bitmap buffer, while the actual blit happens in the next draw callback, since GTK can only obtain the GdkWindow's cairo inside the draw callback. Once the callback was queued, that Bitmap had no referring owner on the MoonBit side — in App::paint_frame it is a local value, replaced next frame — and when GC collected it depended on allocation pressure. After collection, an expose reading that address read freed memory, producing garbage, and each expose re-reading once looked like flicker.
  • Why precisely a second large window: with a single window and a small picture the buffer is usually blitted again before being collected. One more 1000x620 animated window adds tens of allocations per second, GC pressure rises, and the window falls into the interval "already collected but still to be exposed". Covering it and moving away, forcing an expose of the old region, reproduces it most easily.
  • Fix: the backend owns its frame buffer — WmWindow gained backing/backing_cap, wm_present goes through wm_store_frame, reallocating on demand plus one memcpy, with pending pointing at its own buffer, and wm_close releases it. A slot is reused by the next window, so memset alone would be both a leak and wrong. Both the GTK and Win32 branches were changed in the same shape to avoid two semantics; the Windows side cannot run locally, so it is code-reviewed only, to be verified on hardware with the W line.
  • One convention: a buffer that must live until the next callback across an FFI boundary must be owned by the side that will be called back. Writing a comment — it previously said "ownership is with MoonBit" — does not solve the problem: ownership was documented clearly, but nobody was responsible for keeping it alive to that moment. The same shape was checked in web's JS replies and yue/media's frame blit; both allocate and free on the C side, malloc/g_free, so the hazard is absent.
  • Verification: after the change, moon build ./examples/native-web relinked, with build/libyue_win_stub.a rebuilt. Real window run: "open the local video window → screenshot → cover with the main window and move away to force an expose → screenshot again". A 23-minute real clip kept decoding, NATIVE-WEB-LOCAL present=1 frames=1369 pos_ms=56397 speed=1.25x err='' — a 1000x620 animated window allocating tens of times per second, exactly the shape most likely to fall into "collected but still to be exposed" — with zero X errors in the log and both screenshots matching the video with no streaks or dirty regions. The user has re-verified on real hardware: the bridge window and the local video window no longer show garbage, and self-drawn areas no longer flicker. Repo-wide still 287 errors / 68 warnings.

MoonBit native GUI stack · Lifetime of handles across FFI (fix: after closing a window the log fills with GTK/WebKit assertions) ​

  • Symptom, located by a control experiment rather than guessing: with the same example, closing only the bridge window left the log clean, while closing the main window, the one with the web area attached, produced a fixed 5 CRITICAL lines at exit — webkit_web_view_get_title/get_uri failing the WEBKIT_IS_WEB_VIEW(webView) assertion, immediately followed by gtk_widget_get_parent/gtk_widget_destroy failing GTK_IS_WIDGET. The assertions do not crash the process, so they had never been treated as a problem.
  • Root cause: window teardown goes through the window layer's wm_close, which zeroes every native child slot belonging to that window and destroys the GtkWidget tree — WebKitWebView being a grandchild, gone with it — but nobody told the capability layer. So Webview's handle still held the old slot number and pumping stayed true: the scheduled pump and the example's status polling kept calling wb_get_title/wb_is_loading with that handle, while the stub's wb_get(id) checks only "does the slot have a view" and cannot see "that view was destroyed with the window", so it handed out a dangling pointer. Finally wb_destroy, the close at exit, used it once more for gtk_widget_get_parent/destroy, producing those two GTK_IS_WIDGET lines.
  • Fix, one choke point, both branches in the same shape: put the liveness check on the side that knows when teardown happens. In the stub, wb_view_alive(v) means the slot has a view and wm_surface_container(v->surface_id) != NULL — the container comes from win's slot table, becomes NULL when the window is torn down, and is the only reliable liveness signal. Every wb_get passes this gate, so all engine queries and actions become no-ops automatically after the view dies, with no per-function check to add; wb_destroy uses the same gate — with no container it only g_free plus memset to return resources and never touches the pointer. MoonBit gained the public Webview::is_alive(), and the pump's beat, finding the view gone, sets pumping=false, handle=0L and drops eval_waiters, stopping the scheduling instead of spinning on.
  • One convention, same family as "ownership of the present buffer": a handle across FFI and a buffer across FFI are the same class of problem — only the tearing-down side knows when it is gone, so the invalidation check must come from it. handle != 0L on the MoonBit side proves only "was created", never "still exists". Putting the liveness check in the stub's single retrieval function, wb_get, is far more reliable than writing it in front of twenty methods. Self-check for the same shape, filtered by "did we build another C slot table queried by id": win's wm_windows[] is memset cell by cell after teardown, and every slot access was counted — anything touching w->window/w->area already null-checks and returns, the rest read only counters such as ev_count/commit_len, which are 0 after memset and naturally safe, so there is no gap. yue/media's FFI surface passes opaque pointers, MbtAdev, rather than slot numbers, with the lifecycle owned by MoonBit's own close; a different shape, not in scope.
  • Verification: moon test -p NoahLiu/native-gui/web 17/17 — 1 new: with a non-zero handle but no view, is_alive() is false, and after retire_if_dead closes the gap, handle=0/pumping=false/eval_waiters cleared. web and the example --deny-warn zero warnings; repo-wide still 287 errors / 68 warnings. Real-window check, the same control experiment rerun: open the bridge window → close the bridge window → close the main window, log with 0 CRITICAL lines, five before the fix; process exiting itself, alive=0, and the exit line readings normal, last='标题 本地桥接页'. The user re-verified on real hardware (2026-10-12): closing windows one by one manually, clicking the title bar X, until only the main window remained and then closing it produced zero CRITICAL lines as well. Two regression cases confirm the liveness check did not block live views: the bridge page still loads from the protocol and reads its Chinese title; the main window's protocol line reads reg=1 loads=1 files=2 unreg=1 fails=1 fail='WebKitNetworkError/399 yuedemo://local/index.html', field for field identical to before the fix.

MoonBit native GUI stack · Embedded subtitle track (batch D2c: ffmpeg-mbt exposing the subtitle stream + one duration lie) ​

  • The subtitle stream simply did not exist: modules/ffmpeg-mbt located only the video and audio streams, two av_find_best_stream calls in vf_open, and subtitle packets were treated as "not a stream I want" in the decode loop and dropped with av_packet_unref. This batch adds a third: VfFmt.sstream plus a separate VfSDec, vf_sub_open/vf_sub_next/vf_sub_pts_s/vf_sub_end_s/vf_sub_close, and on the MoonBit side subtitle_cues(path) reading the whole track at once into Array[SubCue].
  • A separate container read is required: if the subtitle decoder shared the AVFormatContext with video and audio, both would advance the same av_read_frame cursor, so after reading subtitles the video would start from the middle or hit EOF immediately. subtitle_cues therefore opens its own context and closes it when done. The cost is one extra container open — subtitles are sparse and read in one pass, cheaper than per-frame queries — and the gain is that calling mid-playback does not disturb the main timeline, written as a headless assertion: after reading subtitles, open another instance and keep decoding, frame pts advancing as usual.
  • One duration lie, caught on real data rather than guessed: mov_text decodes AVSubtitle.end_display_time always as 2000ms, the default when the decoder leaves the entry duration unset. Taking it literally makes the first subtitle cover the second and linger a second past when it should disappear. The check came from the printed table: 0.2..2.2 / 0.6..2.6 wrong against 0.2..0.6 / 0.6..1.0 as the asset intends. Fix: take whichever is shorter between the packet duration and end_display_time, with the packet duration read before av_packet_unref, which zeroes it afterwards. The mp4 sample duration is authoritative and the measured result falls back to the asset's intent.
  • The ASS full-line prefix is stripped at the FFI layer: some text subtitle decoders, mov_text among them, do not fill rect->text and give only the whole ASS dialogue line, ReadOrder,Layer,Style,Name,MarL,MarR,MarV,Effect,text. That is the decoder output's shape, not its content, so normalisation belongs to the transport, subtitle_ass_text. The check is conservative: at least eight commas, a non-empty numeric ReadOrder, and the three margin fields numeric or empty. Style name, person name and effect are free text and cannot serve as checks, or a subtitle body that happens to contain eight commas would be cut mid-sentence — pinned by a positive and a negative case. Layout markup such as {\an8} and <c.xxx> still belongs to yue/media's strip_cue_tags, the same cleaning road as external subtitles, so the two sources are fully identical on the rendering side.
  • Two sources in the player plus one observation hook: if video's subtitle is provided it is used, an explicit intent taking priority, and only when empty does it try the container's embedded track. on_subtitle(cue count, embedded?) is shaped like the existing on_tick/on_speed, and the example writes both values into the exit line, NATIVE-WEB-MEDIA ... subs=2 emb=1 and NATIVE-WEB-LOCAL ... subs=0, so "this clip has no subtitle track" and "the subtitle track failed to decode" are distinguishable in the evidence — the latter reports its reason through on_error instead of silently returning an empty table.
  • Verification: moon test -p NoahLiu/ffmpeg-mbt/src 6/6 — 2 new: subtitle_ass_text stripping the prefix plus three "should not strip" negatives; the real container av_1s_subs.mp4 read whole with start and end seconds, Chinese text, no disturbance to the main timeline, and "no track = Ok([])" separated from "cannot open = Err". moon test -p NoahLiu/native-gui/yue/media 24/24 — 2 new: embedded track converted to Cue with transport and cleaning conventions aligned; end-to-end hit by position. yue/media/web/core/ui/ffmpeg-mbt and examples/native-web --deny-warn zero warnings; repo-wide still 287 errors / 68 warnings. The test asset is a 13KB three-stream clip kept in the repository — av_1s.mp4 stream-copied plus two mov_text tracks, made with ffmpeg -map 0:v -map 0:a? -map 1:0 -c copy -c:s mov_text. The user re-verified on real hardware (2026-10-12): the embedded-subtitle window showing two cues appearing and disappearing at 0.2~0.6 / 0.6~1.0, the external-subtitle window unchanged, and the long-asset window reading subs=0 without error.
  • Not in this batch, not pretending completion: ASS style rendering — colors, positioning, multi-track selection, currently text overlay only; the subtitle track list and switching, a multi-track container taking only the av_find_best_stream choice; audio perception re-verification under speed changes.

MoonBit native GUI stack · Player playback speed (batch D2b: pure MoonBit resampling + clock on the same axis) ​

  • Speed with zero C: the audio side needs no new stub — the decoded interleaved s16 PCM is linearly resampled by 1/speed before being pushed to the device, media_resample_s16, pure MoonBit, while the device still consumes at the original sample rate, so half the samples arrive and finish in half the time. The cost is pitch following: this is speed change, not speed change keeping pitch, which would need a phase vocoder. The video side changes one place: vp_target_pts(base, elapsed_ms, speed) = base + elapsed × speed, both sides on the same axis, so "picture fast, sound slow" cannot happen — exactly the desynchronisation the old chain repeatedly warned about.
  • One pre-existing defect exposed by speed: the tick used to decode at least one frame per tick, comparing pts >= target only after decoding, so low-frame-rate video and any speed below 1 ran ahead of the clock — the picture advancing at 30 frames/second while the clock ran at 0.5 speed. Fix: move the gate before decoding, checking the decoded frames' pts and not decoding when already ahead of the clock. This also delivers what the tick comment had long promised, "on low frame-rate video the picture is held across ticks".
  • Changing speed re-anchors the clock: set_speed reads the current position first, then sets the speed, assigns that position to base_pts/pos_base and the current wall time to wall0/played_base. Without re-anchoring, the position would jump by the ratio of new to old speed. The audio position convention follows: the queue holds resampled samples, so "consuming N samples = advancing N×speed source samples", and that term in AudioPlayer::current() is multiplied by speed.
  • Streams without timestamps are not pretended to be supported: the fallback path, one frame per tick stepping by the stream's frame rate, has no pts to chase, so speed affects the clock only and not the picture. This is written into tick's comment rather than buried in the code.
  • Speed taken from a table rather than incremented in place: bar_speeds = [1.0, 1.25, 1.5, 2.0, 0.75], and a click only advances the index, player_speed_at(i) handling negatives and wrap-around, so += 0.25 can never drift out of the ladder. Clamping lives in the engine, media_clamp_speed, [0.25,4.0], with NaN returning 1.0 rather than the fastest or slowest step. The control bar gained a sixth partition, speed, so the track's start moved from 114px to 154px and the volume bar's yielding threshold rose from 400 to 440 — the check "drawing and hit testing read the same constants" proved itself for the second time in this batch.
  • An observation hook rather than guessing: video gained on_speed(v), shaped like on_tick/on_error, and the example writes the step into the exit line NATIVE-WEB-MEDIA ... speed=1.5x, so the evidence needs no read of the engine's internals.
  • Not in this batch, not pretending completion: embedded subtitle tracks, needing ffmpeg-mbt to expose the subtitle stream first; real audio output and listening re-verification, whether sync holds at speed and whether pitch following is acceptable.
  • Verification: moon test -p NoahLiu/native-gui/yue/media 22/22 — 3 new: the speed ladder with wrap-around and negative indices plus short labels; media_clamp_speed and the three vp_target_pts formulas; media_resample_s16 sample counts at 2x and 0.5x, interpolation midpoints, stereo channels not crossed, and the three pass-through cases. The real-decode case gained a get_speed clamping round trip, and the control bar partition test moved to six segments. moon check yue/media/./examples/native-web --deny-warn zero warnings. Real-window evidence — examples/native-web's "open a player window", clicking the speed zone of the control bar twice, 1x→1.25x→1.5x — NATIVE-WEB-MEDIA frames=10 pos_ms=1000 playing=0 speed=1.5x err='': the step reported by the on_speed hook rather than read from the engine, with the 1-second clip still decoding all 10 frames and reaching 1000ms at 1.5x before stopping itself, no degradation. The screenshot shows the control bar ▶ |< >| 1.5x with full progress, the volume bar and 0:01 / 0:01. The user re-verified on real hardware (2026-10-12): audio and video stayed in sync at speed, pitch following was acceptable, and dragging the volume bar really got louder.

MoonBit native GUI stack · Player control surface completed (batch D2a: frame stepping, volume bar, fullscreen) ​

  • Frame stepping must decode to the landing frame on the spot: seek clears the current frame to None waiting for the next tick, and while paused tick() returns immediately, so after stepping the picture stayed on a black frame and looked like "the button did nothing". Fix: the segment in tick that decodes forward from the decode point to the target time, capped at 8 frames, was extracted as vp_settle, and stepping = pause first → move the position by 1/fps → seek → settle. All acceptance checks live in the headless case: after stepping frames_decoded() increases, current() advances, current_frame() is non-empty, and is_playing() is still false, since stepping must not start playback.
  • The control bar went from three partitions to five — play, previous frame, next frame, progress track, volume, time text — with drawing and hit testing reading the same BAR_* constants, since writing the partitioning in two places eventually desynchronises. A narrow-bar yielding check was added: below w < 400 the volume bar is neither drawn nor responsive, with the track's right end computed from the time text, otherwise you get "visible but unclickable" or the reverse. Both ends of the track still clamp rather than overflow, 114px giving =0.0 and 354px giving >0.99.
  • Volume and seek debounce in opposite directions: seek is committed only on release, since every real seek must return to a keyframe and that is expensive, while volume applies at every step — hearing it while dragging is the expectation, and it does not trigger decoding. The volume bar draws engine.get_volume(), the clamped value inside the engine, rather than a local copy, since a local copy disagrees with the display when set clamps. For this, VideoPlayer gained get_volume; AudioPlayer had one long ago and both conventions now match.
  • Fullscreen is a window-level action: the widget layer cannot reach the window, so the chain is Window::set_fullscreen → App::set_fullscreen, requested by the host, the example, through a button. The C side is only two lines, gtk_window_fullscreen/unfullscreen, ABI-only, per the "stubs are on demand and minimal" convention. After toggling, the size change flows through the ordinary allocate event and the host does not compute new rectangles.
  • Not in this batch, not pretending completion: playback speed — the audio side needs either time stretching or miniaudio pitch, both touching the device thread, so it is another batch with the pitch cost documented; embedded subtitle tracks, needing ffmpeg-mbt to expose the subtitle stream first; real audio output re-verification.
  • Verification: moon test -p NoahLiu/native-gui/yue/media 19/19 — 1 new frame-stepping case: n=0 not moving; after stepping, frame count, position, having a frame and still paused all holding; both ends clamping; get_volume returning the clamped value. The control bar partition test moved to five segments plus narrow-bar yielding. moon check win/core/yue/media/./examples/native-web --deny-warn zero warnings. Real-window evidence — examples/native-web's "open a player window", injecting >| ×2, |< ×1, the volume bar's middle and the fullscreen button — NATIVE-WEB-MEDIA frames=21 pos_ms=1000 playing=0 err='': a 1-second clip normally finishing at 10 frames then decoded to 21 by stepping, proving stepping really decodes, with err='' meaning no degradation. After clicking fullscreen, xdotool getwindowgeometry gives Position: 2560,0 Geometry: 2560x1440, the attached window really filling the second screen, and the screenshot shows the control bar ▶ |< >| plus progress track plus the volume bar, its fill visible after dragging to the middle, plus 0:00 / 0:01. Needs the user's eyes: the visual jump of frame stepping, the volume bar's drag feel and whether it really gets louder, and the position of subtitles and controls in fullscreen.

MoonBit native GUI stack · Local content channel and fine-grained navigation reasons (batch C remainder: web/scheme.mbt) ​

  • Protocols are registered per context, not per view: webkit_web_context_register_uri_scheme's callback signature is (WebKitURISchemeRequest*, gpointer) — no view parameter — so protocol ownership is naturally context-level, and the old chain's process-level static registration loses its meaning once profiles hang at pane level. This batch's slot table dedupes on (context, scheme): within one context a scheme registers with the engine once, and re-registering only changes the root directory.
  • "Unregister" is replacement, not revocation: WebKit has no unregister-uri-scheme. Unregistering clears the scheme's root directory so every subsequent callback goes to webkit_uri_scheme_request_finish_error, and the page gets "protocol present, content unavailable". That is safer than leaving the old directory, which would let a switched profile still read the previous profile's files, and more honest than pretending the registration can be revoked — the return value means only "replacement succeeded".
  • Static files under the root only, with path traversal blocked at the stub: a request path containing .. returns an error directly; nothing is absolutised and no symlink escape is followed. MIME is looked up by extension — html/htm/css/js/mjs/json/svg/png/jpg/jpeg/gif/woff2/txt — with everything else application/octet-stream.
  • One convention, settled 2026-10-12: C stubs are on demand and minimal — written only where MoonBit cannot reach: GTK3 window layer, Cairo, Pango, WebKitGTK, miniaudio/ffmpeg, dirent/getenv/spawn, socket. In C only ABI translation and slot tables; decisions, parsing and degrade semantics stay in MoonBit. Before adding a function, ask whether existing MoonBit channels can be composed. This batch made one real trade-off: directory creation added no C, and sys_make_dirs was composed from the existing subprocess channel, pr_run("mkdir", ["-p", path]), with defaultapps_wbtest already doing the same in this repository. The cost is that it holds only where pr_available() is true, so the function's documentation says "the probe is the convention" rather than letting callers believe it works everywhere.
  • Revisited the same day and corrected — the convention governs capability, not "no C allowed in sys": compose only when the two routes are genuinely equivalent; when they are not, add C. Once directory creation was landed as a composition, the cost materialised in measurement: it held only on POSIX, because Windows' PATH has no mkdir, so fsx_wbtest.mbt:109 had been red on Windows since the day it shipped. It is now a thin FFI, yue_sys_make_dirs, CreateDirectoryW level by level on Windows and mkdir(2) level by level on POSIX, joining the family that was always C — sys_read_text_file, sys_remove_file, fsx_list_dir. That is not a new exception to the convention but the flattening of its only outlier, along with dropping a subprocess dependency: no pr_available() requirement, and no fork plus two temp files per call.
  • CreateDirectoryW does not return "already exists" for the drive prefix (measured on real hardware in this batch): creating a directory level by level treats "C:" as a segment, which returns ERROR_ACCESS_DENIED(5) rather than ERROR_ALREADY_EXISTS(183); accepting only 183 reads the drive root as a failure and the whole call returns 0. The method is probe first, then create: GetFileAttributesW valid means this level exists and is skipped, with 183 tolerated only for a creation race. POSIX has no such trap — mkdir on an existing directory is EEXIST.
  • Writing a file does not create parent directories, and the symptom shows up elsewhere (hit in the real window in this batch): the page area showed only not found, looking like the protocol failing, while in fact the root /tmp/native-web-scheme did not exist at all and both sys_write_text_file calls failed. The fix is sys_make_dirs, but the acceptance check must be split first: the example's exit line gained a files= count — registration succeeded / load issued / files actually present — because without it one is guessing which link broke.
  • Two fine-grained navigation reasons: ① WEBKIT_LOAD_REDIRECTED emits kind 15, since callers must distinguish "address changed" from "reloading". ② The load-failed payload is structured as uri|domain|code|message, with domain given as a name through g_quark_to_string, and MoonBit's nav_error_parse splitting it into NavError. Error text may contain |, so the first three segments are taken positionally and the remainder all belongs to message; too few segments gives None rather than guessing fields.
  • One seam defect only the real window could catch: WebEvent has LoadFailed, but the kind table in Webview::dispatch never listed 14, so every failure arrived as Unknown(14, raw) and the caller could never read the reason. The headless suite claimed a "kind→event case-by-case mapping" and still missed it, because the table being walked had itself skipped 14: each half had tests, the mapping between them had none. Now 2/3/4/14/15 are all pinned in that test, plus one end-to-end case — dispatch 14 → LoadFailed → nav_error_parse splitting four segments.
  • Pages installed with load_html do not enter forward/back history (measured on the real window; do not read this as back being broken): the bridge page was installed with load_html, then a click on a nonexistent domain — the example's a.example, since .example is an RFC 2606 reserved domain and DNS must fail — read can_back=0 can_fwd=0 with go_back() a no-op. But the root cause is not "a failed navigation cleared history" — testing the four steps separately makes it clear:
    after-load_html  url=https://local.test/  can_back=0   ← load_html does not enter history
    after-ok-nav     url=https://example.com/ can_back=1   ← a successful navigation enters history
    after-fail-nav   url=https://a.example/1  can_back=1   ← a failed navigation does not clear history
    after-go_back    url=https://example.com/ can_fwd=1    ← it really went back
    A failed navigation still leaves a back-stack state; that reading was 0 only because the bridge page's history had nothing besides the load_html entry. Cross-platform consistent: WebView2's documentation for NavigateToString states plainly "the back or forward history will not be modified", the same shape as the Linux measurement; Apple makes no promise for macOS's loadHTMLString:baseURL:, to be verified on mac hardware and already recorded in TODO. Two conventions: ① a no-op determined by engine semantics must be stated honestly in the UI — the example's button now logs either 后退 → url or 后退:无处可退(can_go_back=0;load_html 不入历史) — otherwise it simply looks like a bug. ② To give a local page real history, use the custom protocol above to give it a real URL and load_url, not load_html.
  • The custom protocol's public surface tightened to "names declared in advance" (2026-10-12, strictest platform wins): Linux allows registering a new scheme per context at any time, but WebView2's list hangs on the environment options, before creating the environment, once per process, and WKWebView's on the configuration before the view is created. Treating "add names at runtime" as public semantics means writing code that fails on Windows/mac. Now: webview_predeclare_schemes([...]), before the first webview → register_protocol(scheme, root) accepting only declared names, with the root directory changeable at any time and unregister_protocol still replacing with an error handler. The list is sealed at view creation by webview_seal_schemes(); an empty list fills in the default name app, so zero configuration works. Linux's dynamic capability stays in the stub, not exposed. Verification: web 16/16 — new case: creating the view seals the list, an undeclared name's registration is refused, and declaring after sealing returns false leaving the list unchanged; real window NATIVE-WEB-SCHEME reg=1 loads=1 files=2, the example calling webview_predeclare_schemes(["yuedemo"]) before creating the view, with the protocol page working as before.
  • Protocol registration must also accept "before mount" (2026-10-12, caught while converting the bridge window to a protocol page): a pane's view is built on the frame when it first receives a real rectangle, and register_protocol judged on handle != 0L, so anything before mount returned false — the caller had to schedule its own wait. load_html already had pending_html, so one matter had two rules. pending_protos was added: (scheme, root) queued before mount and applied by mount_at right after the view is built; unregister_protocol first withdraws a pending registration, returning true when it withdrew something and still false when nothing was registered — "not mounted" is not "arbitrary success". Acceptance check: the example's first run fell onto the load_html fallback branch and printed 协议未就绪, which was exactly this seam.
  • The protocol channel returns MIME only, so text types without a pinned charset arrive garbled (screenshot from real hardware in the same batch): wb_mime_of returned bare text/html for .html, WebKit had no other basis to guess an encoding and decoded as ISO-8859-1, so a Chinese page came out garbled across the screen, 桥接页大标题 becoming mojibake. Fix in two places: the stub returns charset=utf-8 for text extensions — html/css/js/json/txt — and the page adds <meta charset="utf-8">. Each alone suffices, but only the HTTP header is reliable over a protocol, since <meta> takes effect only after decoding reaches that byte, while the first screen's bytes were already decoded with the wrong table. Verification: web 16/16, and web plus examples/native-web --deny-warn zero warnings; the real-window screenshot shows the protocol page's Chinese correct, and the event area's 后退 → yuedemo://local/bridge.html proves the protocol page is in history and back really works — control: the same page installed with load_html had back as a no-op — with the exit line navs=5 … dom_ok=1 text_bytes=68 … last='标题 本地桥接页'.
  • The protocol channel supplies MIME only, so text types must carry charset=utf-8 themselves (mojibake caught in the real window): the same bridge page HTML was fine through load_html and became whole-page 桥接页 through yuedemo://. Root cause: the two channels differ in encoding basis — webkit_web_view_load_html takes UTF-8 text as its input, so the engine decodes as UTF-8, while a protocol reply passes only a content type, and bare text/html without an encoding declaration is decoded by WebKit as ISO-8859-1; the page's <meta charset> is visible only after tags are parsed, and the tags were already decoded wrongly. Fix closed once at the stub: wb_mime_of returns ...; charset=utf-8 for html/css/js/mjs/json/txt, leaving binaries and images alone. Convention: over a protocol, MIME is the only encoding basis for text; do not count on the page's <meta>, though the in-page declaration is still written, since it is needed when the file is opened in another browser.
  • Registrations made before mount must be accepted, otherwise the natural "register first, then load" order falls on the caller to wait: the view is built on the frame when it first receives a real rectangle, and register_protocol returned false while unmounted. The bridge window in the example took exactly this path: right after win.mount, registration returned false, it fell back to load_html, and the page's back button was permanently a no-op. Now it shares the shape with pending_html: unmounted means recorded in pending_protos, applied one by one by mount_at after the view is built, and unregister_protocol can withdraw a registration not yet applied, returning true when it withdrew something and false when there was nothing to withdraw — "not mounted" is not "arbitrary success". The example's check changed accordingly: the bridge page now goes through the protocol, since only a real URL has real history, and on registration failure it no longer degrades silently — the event area states 协议未就绪,退回 load_html(此页不入历史).
  • Verification: moon test -p NoahLiu/native-gui/web 16/16 — 4 new headless: nav_error_parse's four segments with | inside message and with too few segments; nav_parse_int's minus sign and stopping at a non-digit; unmounted registration accepted, unregister withdrawable, double unregister false; the kind-14 seam above. moon test -p NoahLiu/native-gui/sys 164/164 — 1 new sys_make_dirs: create level by level, write, read back; idempotent; empty string rejected; writing fails when the parent is missing. moon check sys/web/./examples/native-web --deny-warn zero warnings. Real-window evidence — examples/native-web's "batch C remainder" row, with the two checks run separately — ① loading the protocol page: NATIVE-WEB-SCHEME reg=1 loads=1 files=2 unreg=0 title='本地协议页' plus NATIVE-WEB-STAT ... title='本地协议页' url='yuedemo://local/index.html', the screenshot giving two hard pieces of evidence: the page title is the protocol page's <title> and the body paragraph carries a blue background, so style.css also came over yuedemo:// rather than inline. ② Reloading the same URL after unregistering: unreg=1 fails=1 raw=0 fail='WebKitNetworkError/399 yuedemo://local/index.html' title='', with the page area drawing the text given by finish_error verbatim — on screen the single line scheme unregistered — proving "unregister = replace with an error handler" really works and that the failure reason is a structure, not a string. One enum fact found along the way: WEBKIT_NETWORK_ERROR_FAILED = 399, not the intuitive -1. ③ Four pieces of evidence after the bridge page moved to the protocol, injecting clicks and reading the event area from screenshots: 导航开始 yuedemo://local/bridge.html → clicking link 甲 gives 加载失败 g-resolver-error-quark/0 https://a.example/1 → clicking back gives 后退 → yuedemo://local/bridge.html, this time really going back, whereas the load_html version was permanently a no-op → the page area's Chinese title, body and links all correct, with 抓正文 ok=1 title=本地桥接页 链接=0, DOM capture running against the protocol page and the title read back as UTF-8. The user re-verified on real hardware (2026-10-12): the protocol page's Chinese renders correctly, clicking a reserved-domain link reports a structured reason, and back really returns to the bridge page. One convention left for whoever comes next: when neither window is given its own data directory they share one context, so the main window's "unregister this protocol" clears the bridge window's root directory as well — re-registering restores it, and the example's comment says so.

MoonBit native GUI stack · JS bridge / DOM capture / navigation surface (batch C: the webkit stub and contract of web) ​

  • Asynchronous events all go "queue in C, drain from MoonBit" - no C-to-MoonBit trampolines: navigation, title, JS results, page invoke, permission, download and process termination are all engine-side async signals; giving each a closure trampoline means managing 12 callback lifetimes and thread affinities by hand. The shape here is one 32-slot ring per view (wb_next_event yields the kind, wb_event_num/wb_event_text read the payload), and a full queue drops the oldest - events are hints, not a ledger, and blocking on them would stall the main loop.
  • The pump is not hooked into painting: a result callback may mount or unmount widgets (e.g. redraw a list after capturing links), and mutating the tree mid-paint is exactly what this stack avoids (Widget::paint_at is translating and clipping layer by layer). So mounting starts a self-rescheduling @core.set_timeout(30, ...) pump, and close stops it.
  • load_html must not be dropped before mounting (hit on the real window in this batch): a pane mounts on the frame where it first gets a real rectangle, so calling load_html right after win.mount(...) found handle 0 and the page area stayed at about:blank. The acceptance check: navs=1 with only about:blank in the events and every capture empty. Same fix as load_url: record pending_html and apply it in mount_at right after the view is created.
  • Brace balance in the capture scripts must be pinned: the four variants each appended their own }}) and one extra closing brace slipped in, so the page replied SyntaxError: Unexpected token '}'. Marking that ok=false in the parser is correct (no silent success), but the generator needs a lock: a single wrapper dom_wrap(fields) (variants only write their own fields) plus assertions that {/} and (/) counts match and the script ends with }).
  • 2.52 has exactly one permission entry point: the decide-policy enum has no WEBKIT_POLICY_DECISION_TYPE_PERMISSION_REQUEST (only NAVIGATION_ACTION / NEW_WINDOW_ACTION / RESPONSE), so permissions must come from the "permission-request" signal; and webkit_permission_request_allow(request) takes one argument (not the old (request, gboolean)). Returning TRUE from the handler means "we dealt with it", so default-deny is "report the event + return TRUE", and allowing is "explicitly allow, then still return TRUE".
  • g_signal_lookup before connecting optional signals, and expose the result as a mask (wb_signal_mask(): 1 download / 2 process-terminated / 4 permission / 8 new-window). Whichever bit is missing is a missing piece of evidence - never let "the callback never fired" and "it is not implemented" collapse into the same symptom.
  • Two API shapes that read backwards: ① under webkit2gtk-4.1 (this build links libsoup-3.0, whose headers and libraries come along with pkg-config --cflags/--libs webkit2gtk-4.1) webkit_cookie_manager_get_cookies_finish returns a GList, not a GSList, whose elements are SoupCookie; ② from 2.40 webkit_web_view_evaluate_javascript_finish hands back a JSCValue* directly (only the old run_javascript_finish gave a WebKitJavascriptResult*), and jsc_value_to_string on a JS string returns the content without quotes - so the structured payload is the JSON text itself, with no extra layer to strip.
  • Profile hangs off the pane: a non-empty data_dir in wb_create goes through webkit_website_data_manager_new(base-data-directory, base-cache-directory) + webkit_web_context_new_with_website_data_manager + webkit_web_view_new_with_context; empty uses the default context with its own user content manager. browser_profile_persistence() is always Persistent on Linux - the "macOS 14+ plus a bundle id" API boundary is Apple's - and the probe stays, so callers never branch on platform.
  • Two example-side counting pitfalls: ① with one shared counter set for four capture variants, the last variant (structure) has no body text or links, so plain assignment erased the earlier evidence - take maxima instead; ② grabbing a top-level window that owns a child X window (WebKit) with import -window <id> yields striped garbage; capture the composited screen with import -window root -crop WxH+X+Y.
  • Contract names follow the old chain (load_url/get_url/get_title/reload/stop/go_back/go_forward/can_go_back/can_go_forward/is_loading/load_html/set_user_agent/execute_javascript/execute_javascript_with_result/add_user_script/add_raw_binding/remove_binding/has_bindings/get_cookies_for_url), with current_url as an alias of get_url. remove_binding/remove_all_user_scripts always return false with the reason written down: WebKit has no unregister entry point, and that must not read as a defect of this stack. window.open is intercepted and reported by default, with an opt-in "navigate in this pane" - this stack does not do one-window-many-webviews.
  • Verification: moon test -p NoahLiu/native-gui/web 11/11 (9 new headless: capture-script shape and brace balance; structured parsing of all four JSON variants; parse failure keeps ok=false plus the raw text; every call safe while unmounted with capture_dom/add_raw_binding/result-carrying eval returning false/-1; kind→WebEvent mapping item by item; JS results paired by sequence and delivered exactly once; invoke on both paths (on_invoke plus the on_event side channel); persistence probe consistent with engine availability). moon check web and ./examples/native-web clean under --deny-warn; core 70/70 and ui 110/110 unchanged; whole-repo backlog still 287 errors / 68 warnings. Real-window evidence (examples/native-web, the "开一扇桥接窗" button = a second window hosting a load_html local page, no network needed): NATIVE-WEB-JS binding=1 evals=1 dom_ok=4 dom_bad=0 links=3 text_bytes=68 meta=1 headings=2 elements=14 invokes=1 navs=2 perms=1 wins=1 crashes=0 cookie_bytes=2 last='invoke {"msg":"来自页面","n":7}'; the composited screenshot carries three hard facts - the page body reads "已被原生改过" (native→page execute_javascript really mutated the DOM), the native event area lists "页面回话 {...} / 权限请求(默认拒绝)WebKitGeolocationPermissionRequest / 新窗请求已拦下 https://b.example/2", and the bridge window count stays 1 (the new window was intercepted rather than spawning a second webview). Re-verified by the user (2026-10-12): in-page scrolling and link clicks work, and our pure-channel IME yields while the webview holds focus and comes back after clicking into a native entry.

MoonBit native GUI stack · panes and the webview channel (batch B: win/surface.mbt + new package web) ​

  • Window content became a GtkFixed holding "drawing area + positionable children", not a GtkOverlay: an overlay needs pass_through to let events through the container, and event ownership for a container that itself has children is unreliable there (the container being transparent does not imply its children behave). GtkFixed is an ordinary container - a pointer over a child belongs to that child, anywhere else to the drawing area, with no extra mechanism. The price is tracking the size ourselves: set_size_request on the drawing area from the fixed's size-allocate, gated by "only when the size actually changed" - set_size_request triggers another allocation round, so unconditionally setting it self-oscillates.
  • The surface table must record its owning window: WmSurface{container, window_id, kind, x,y,w,h}, and wm_close(window) clears every surface belonging to that window. Otherwise the window is torn down first and the capability layer (the webkit stub) still calls gtk_fixed_put with a dangling GtkWidget* - segfault. Surface slots are recycled the same way as the window slots in batch A (scan for container == NULL).
  • Pointers travel C-to-C, MoonBit only carries integers: win exports wm_surface_container(int64) -> GtkWidget* and web/webkit_stub.c declares it extern (same executable, static link). That keeps web's MoonBit layer from importing win's MoonBit layer, so the dependency rule "webview depends only on the window/layout/event layers" holds.
  • A missing webkit SDK is a degradation, not an error: scripts/prebuild.py probes webkit2gtk-4.1; found -> compile with -DWB_HAVE_WEBKIT and hand -lwebkit2gtk-4.1 to web; not found -> compile the macro-less stub implementation (wb_available() returns 0). Either way the wb_* symbols must exist, otherwise it is a link-time failure rather than a run-time degradation. The stub's branch shape mirrors win: #if defined(WB_HAVE_WEBKIT) / #elif !defined(__linux__) / otherwise an empty translation unit, so nothing is defined twice against prebuild's .a. Link order puts -lyue_web before -lyue_win_stub (the former references the latter's symbol).
  • Sub-pixel values must be rounded, not truncated: pane rectangles come from yoga as Doubles and are synced to the surface every frame. With truncation a 0.4 px drift makes "the rectangle changed" permanently true, so every frame pays an extra FFI call plus a native relayout. The rule lives in the pure function webview_rect_key (round(), same treatment for negative coordinates) and the dedup compares the rounded key.
  • ChildKind is a register, not an open set: only Webview = 1 today. The child surface keeps exactly the "embed a native control" meaning (plan §4.5); allowing custom values would turn "not a general sub-surface" back into an empty phrase.
  • Verification: new package web headless 2/2 (rounding and dedup key; probe/constructor agreement with zero handles before mounting; close safe when never mounted); moon check win / web / ./examples/native-web clean under --deny-warn; core 70/70 and ui 110/110 unchanged; whole-repo backlog still 287 errors / 68 warnings. Real-window evidence (examples/native-web, this host X11/XFCE + WebKitGTK 2.52.6): split layout gives NATIVE-WEB-STAT supported=1 loading=0 title='Example Domain' url='https://example.com/' with NATIVE-WEB-GEO pane=262,71,848x605 sidebar=12,71,240x605 (two non-overlapping panes); injecting "toggle full/split + load + one click inside the page area" yields loads=1 toggles=1 mode=full pane=12,71,1098x605, and the screenshots confirm WebKit really renders the page and reflows to the new width after switching to full - one pane mechanism producing both layouts. Left for the user's eyes: scrolling and link clicks inside the page, and whether our pure-channel IME really yields while the webview holds focus (and comes back after).

MoonBit native GUI stack · multi-window host (core/host.mbt: one host, N windows) ​

  • Shape: one window = one App (its own platform window + widget tree + FocusStack + IME/hover/capture state). Windows share exactly two things - the Loop (one process-wide time base, built once by host_loop()) and the platform pump (@win.wait/wake were already process-level). The registry only answers "which windows exist, which is current, does closing one quit the host"; App::step now drains events per window, pumps the IME per window and paints the dirty ones, always iterating a host_windows() snapshot (a window can close mid-drain and mutate the table).
  • The closing verdict: the first window in the registry is the primary one; only closing the primary - or the last remaining - quits the host (host_close_action(primary, remaining) is a pure function, headless-testable). Closing a secondary window just removes it. The teardown order stays "stop dispatch first, then destroy": running=false -> deregister -> window.close(). App::shutdown destroys every window still registered (secondaries may be open); handles already recycled to -1 make wm_close idempotent.
  • Two process-wide single slots had to be upgraded or multi-window draws/queries wrong: ① the invalidate() repaint hook became a broadcast (mark every live window dirty + one wake) - a Store shared by two windows then repaints both, and an over-marked window only wastes one frame; ② the overlay host slot became a resolver (set_overlay_provider(() -> OverlayHost?)) so popups open on the current window (a new window becomes current on creation, whichever window receives input becomes current, and closing the current one falls back to the last registered). set_overlay_host(h) survives as the static form for headless tests.
  • A latent design flaw this batch exposed: overlay handles used to restart at 1 per host, so "handle 1" meant different layers in different windows. Handles are now process-wide increasing (overlay_cid()) plus overlay_ids()/overlay_top_id(). The symptom was not a compile error but a test crash: a dozen call sites in ui hardcoded overlay_root_of(1), and once ids accumulated the lookup returned None and children()[0] aborted with Array::at out of bounds (SIGABRT). Rule: handles are allocated at run time - use the return value or top_id(), never a literal.
  • raise is a MoonBit keyword: pub fn OverlayHost::raise(...) does not report "reserved word"; the whole function header fails to parse and the errors cascade into The value identifier self is unbound plus unexpected token if, you may expect pub|fn|..., which looks like a broken body. Renamed to raise_layer; the tell is that the first error points at the header line.
  • The GTK stub must recycle window slots: wm_window_new used to only wm_next_id++, so opening and closing secondary windows would exhaust the 32 slots (and App::open_window would silently return None). It now takes the first slot with area == NULL - area is always set at creation and wm_close memsets the record, so "closed" and "never used" are equally free.
  • Cross-window injection pitfall: clicking raises another window (XFCE defaults to click-to-raise). In the first round the "close this window" click seemed dead (closes=0), looking like a broken button; in fact the earlier click on the main window had raised it over the About window, so that click landed on the main window. Rule: xdotool windowactivate --sync <target> before every cross-window click, and judge by an observable state (does xdotool search --name 关于 still list it) rather than by a counter alone.
  • Two example-side rules: ① each About window owns its own Store (sharing one makes "which window got the typing" meaningless and the exit readouts overwrite each other); ② the smoke self-quit timer moved from 12 s to 25 s - the multi-window flow (open -> click and type in the second window -> close -> reopen) does not fit in 12 s.
  • Verification: moon test -p NoahLiu/native-gui/core 70/70 (8 new headless registry tests: host_loop returning the same Loop - proved with pending() since started() cannot prove identity; primary/secondary bookkeeping and active fallback; the three closing verdicts; close_window idempotence and a secondary not stopping the primary; invalidate broadcasting; overlays landing on the current window with independent stacks and handles per window; mount raising only its own layer; shutdown destroying all windows and resetting the registry). ui 110/110, chart 189/189, mdview 26/26, render 42/42, text 29/29, input 10/10, theme 8/8, sys 163/163, dbus 64/64, yoga-mbt 96/96 all green; moon check ./examples/native-app and ./examples/native-sys clean under --deny-warn; whole-repo backlog still 287 errors / 68 warnings. Real-window evidence (examples/native-app, the "多窗口(批 A)" panel at the top plus the "关于(第二窗)" window): NATIVE-APP-MW wins=1 opened=1 closes=1 main_clicks=1 sub_clicks=2 sub_entry_bytes=2 sub_focus=101 and the geometry line NATIVE-APP-MW-GEO open=688,262,160x32 sub_btn=16,53,464x32 sub_entry=16,85,280x32 sub_close=16,117,464x32; the injected sequence was "open -> two clicks in the second window -> type and commit in its entry -> the main window's control click -> close the second window -> open another", with xdotool search --name 关于 going from present to absent while the main window id stayed alive the whole time (closing one window does not affect the host), and the About window screenshot showing "本窗点击 2 / 在窗 2" plus the committed text in its entry (one tree per window, focus stack per window). Left for the user's eyes: repaint while dragging/occluding two windows, and the preedit underline inside the About window.

MoonBit native GUI stack · single instance and the logind brightness bridge (dbus: instance closing + bridge.mbt) ​

  • Single instance = bus-name claiming plus a Wake inbound call: the first instance takes RequestName(app_id, DO_NOT_QUEUE) and gets PrimaryOwner (a repeated claim yields the idempotent AlreadyOwner); a second instance that gets AlreadyExists/InQueue instead calls the first instance's org.moonbitlibyue.Instance.Wake('as' argv) and exits. The first instance answers the Wake before firing its callback (is_wake_call is checked first in bus.mbt's inbound branch - the second instance's call_sync has a deadline, so slow work like raising a window must not make it wait). Example evidence: launching a second copy while the first runs prints "已有实例,已唤起并退出" with exit=0 in the second, and "收到第二实例的唤起" in the first.
  • The brightness write path is wired one way only: dbus depends on sys (environment, subprocesses, sysfs reads) and not vice versa, so bridge.mbt injects the logind capability into sys's two slots at startup (brightness_set_logind_probe for the probe, brightness_set_action for the (subsystem, name, value) -> error string action) and sys never imports D-Bus. Measured here: after injection @sys.brightness_supported() flips from the fake default to true (logind_available=true), while brightness_devices() still reads only sysfs (0 devices on this desktop) - the injection line cleanly separates the read path's and the write path's dependencies.
  • Verification: moon test -p NoahLiu/native-gui/dbus 64/64 (2 new: the brightness gate matching logind's availability after injection, and a repeated same-process name claim yielding AlreadyOwner); moon check ./examples/native-sys --deny-warn clean; real-window evidence as above (pool=:1.483, second instance exit=0). Left for the user on a real machine: brightness_set_percent actually changing the screen needs a machine with a backlight (this host is a desktop with no /sys/class/backlight device).

MoonBit native GUI stack · SNI tray and DBusMenu (dbus: sni + icon + instance + filemanager) ​

  • Migration surface: the old chain's traybus files sni.mbt (731 lines, the StatusNotifierItem object side plus a DBusMenu server), icon.mbt (75, the program-generated default icon bitmap), instance.mbt (221, single-instance name claiming/waking) and filemanager.mbt (98, FileManager1 ShowItems) ported line by line into dbus. The tray hooks previously stripped from bus.mbt came back in the same batch: the Conn.items/menus registries, re-registration of every item after a NameOwnerChanged(arg0=watcher) watcher restart, carrying tray items along during reconnect migration, and the three-way inbound dispatch (single-instance Wake replies first and fires after -> a path hitting items/menus goes to SNI's handle_call -> the CallReq fallback last). This is the "foundation strips semantics, capability modules put them back" way of closing out.
  • Tests came along, inspect became assert: sni_wbtest (212) / instance_wbtest (93) / filemanager_wbtest (24), with 40-odd old inspect(x, content=…) calls replaced this batch.
  • Real-bus evidence (protocol half of rule 5): on this host the XFCE panel (xfce4-panel) is the StatusNotifierWatcher. After clicking "托盘图标" in the example, the watcher's RegisteredStatusNotifierItems went from 9 to 10 entries (the extra one being this process's item), and it is unregistered symmetrically before exit; the sni_wbtest live cases create several tray items, verify their paths and menu objects never collide, and confirm both registries are clean after unregistration.
  • A test-environment pitfall (acceptance check first): running the create-unregister tray tests repeatedly occasionally yields 托盘注册失败: org.freedesktop.DBus.Error.NoReply, or NameHasOwner returning something other than true. The cause is not the code: a test process has no main loop, so the panel's synchronous property queries to our items can only be serviced incidentally inside a call_sync pump; after several rounds a single xfce4-panel query wedges and subsequent registrations time out. The acceptance check is "a re-run is clean, and dbus-send still lists RegisteredStatusNotifierItems"; the fix is not to leave the test process unpumped for long (the example's host pumps every 200 ms and never sees this). A temporary debug print was used to pin the branch and then removed.
  • Verification: moon test -p NoahLiu/native-gui/dbus 62/62 (12 new: sni 6 / instance 4 / file manager 2), three consecutive runs with zero skips; moon check ./examples/native-sys --deny-warn clean, moon build passes; real-window evidence pool=:1.479, five-button geometry line tray=747,391,110x32, exit code 0. Left for the user's eyes: after clicking "托盘图标", does an icon appear in the XFCE notification area, does right-clicking it show the "刷新探测 / 退出" menu, and does clicking the icon increment the "托盘点击" counter in the window.

MoonBit native GUI stack · D-Bus capability modules, first batch (dbus: UPower/logind/network/screensaver/inhibit/MPRIS + notifications) ​

  • Migration surface: the old chain's traybus files upower (197 lines) / logind (105) / netmon (191) / screensaver (94) / inhibit (146), plus yue/system/media.mbt (707 lines, MPRIS), ported line by line into dbus (package prefixes swapped only; media's pr_run/pr_available now point at @sys). Their tests came along in the same batch, and every old inspect(x, content=…) became assert_eq/assert_true - those inspects asserted nothing on the native backend (recorded twice before), so porting them unchanged would have meant "all green, zero coverage"; this batch alone contained 54 of them.
  • MPRIS polling moved to an injected schedule channel: master's media_watch_status hooked @yue.set_timer directly (its callback returned a Bool to decide whether to keep ticking); the new stack gives it a schedule : (Int, () -> Unit) -> Unit parameter (the GUI layer passes @core.set_timeout, headless tests pass a fake channel that collects the callbacks) and re-arms internally via arm() - the same layering reason as clipboard_watch and the theme probe: a capability package does not know about an event loop.
  • Notifications are new work (master's implementation lives in the C++ libyue with only a thin MoonBit wrapper, so the new stack had to rewrite it): notify_caps uses GetCapabilities (no side effects; the capability list is not translated into an enum, the raw entries stay available), while notify_send/notify_close use Notify/CloseNotification. Progress is not a method of its own - per the freedesktop spec it means re-sending Notify with replaces_id set to the same notification and a standard value hint (INT32, 0..100; this layer clamps above 100 and treats negatives as "no progress"), which desktop daemons render as a progress bar; notify_progress has exactly that shape, and the argument order plus hint shape are pinned by the pure function nf_notify_args.
  • Setting-class discipline: sending a notification really pops a bubble on the desktop, so tests stop at "capability probe + argument construction + error normalisation (unknown method / no service)"; the actual send is left to the button in the example for the user to click (the same rule as open_url/reveal).
  • Example wiring and real-window evidence: examples/native-sys gained a "窗口 / 剪贴板 / D-Bus" panel (session unique name / notification capability count / system bus "not connected (connects on first use)"), a "发通知(带进度)" button, and the host pump (dbus_set_post_delayed wrapped around app.timer, plus dbus_pump() every 200 ms). A full run ends with pool=:1.434 (the connection is alive the whole time, proving the injected pump really feeds the bus), notification capabilities = 7, exit code 0, and a screenshot confirms all five rows.
  • Verification: moon test -p NoahLiu/native-gui/dbus 50/50 (27 new: UPower 4 / logind 2 / network 3 / screensaver 2 / MPRIS 12 / notifications 4), moon check ./examples/native-sys --deny-warn clean. Left for the user on a real machine: clicking "发通知(带进度)" should pop a bubble whose progress grows with each click (this host's xfce4-notifyd already reports 7 capabilities over the real bus); and the four monitors (UPower/logind/network/screensaver) need real-world events - plugging/unplugging power, locking the screen, dropping the network - to observe.

MoonBit native GUI stack · D-Bus foundation (new package dbus: wire + dbus + bus + socket stub) ​

  • Migration surface: the old chain's yue/traybus/ wire.mbt (833 lines of wire-format codec) and dbus.mbt (419 lines, the GDBusValue generic call layer) are ported line by line (only sys_f64_to_bits/from_bits gain new FFI names), while bus.mbt (784 lines) is adapted: connect / SASL / Hello / send / receive / dispatch / reconnect all stay, but the SNI and single-instance semantics embedded in the generic layer are stripped out (items/menus fields, the watcher special case, the inbound Wake branch) - those come back with their own capability modules.
  • The biggest layering difference: no glib fd watch. The old chain used g_unix_fd_add to hang the bus fd on the GTK main loop and libyue's PostDelayedTask for reconnect backoff; this stack bans glib and does not depend on the GUI layer either, so there are two injection points instead: the host calls dbus_pump() periodically (default timeout_ms=0, a non-blocking poll, fine on every frame/tick), and reconnect backoff goes through dbus_set_post_delayed(fn(ms, f){...}). With neither injected, synchronous calls still work - call_sync and gdbus_call_* carry their own pump_until pump (handshake, Hello and registration all use it), so a pure CLI scenario needs no event loop at all; the price is that "nobody pumps, no async signals", which the package header states outright.
  • The socket stub uses only standard headers (dbus_stub.c, compiled through native-stub, no link flags written): socket(AF_UNIX, SOCK_STREAM|SOCK_NONBLOCK|SOCK_CLOEXEC) + connect (tolerating EINPROGRESS) / read / write / single-fd poll / close / getuid / Double bit patterns (memcpy reinterpretation guarded by a static_assert on 8 bytes). Its error-code conventions are bit-for-bit identical to the old shim (read: >0 bytes / 0 peer-closed / -1 EAGAIN·EINTR / -2 otherwise; write: >=0 written / -1 not writable yet / -2 otherwise; poll: revents bits 1=IN 4=OUT 8=ERR 16=HUP), so none of the upper-layer branching changed.
  • SASL EXTERNAL needs no explicit credential passing: the kernel attaches SCM_CREDENTIALS at connect time and the server reads SO_PEERCRED; the client only sends AUTH EXTERNAL <hex(uid)> (the old shim has no sendmsg either - this was verified, not assumed).
  • One place the public surface is cleaner than the old chain: inbound method calls used to require a handler with same-package access to the priv Conn/Msg in order to reply; the new package exposes CallReq{bus, sender, path, iface, action, args, reply, reply_error}, where reply derives its signature from the argument values (gdbus_args_wire), and signal subscribers receive decoded GDBusValue arrays only - wire-format value types never leak, so capability modules and outside callers see the same shape.
  • Three toolchain findings (measured this batch): ① struct literal fields use field: value; writing = gives Unexpected '=' in struct expression; ② a function-typed struct field is not a method - req.reply_error(...) fails with Type CallReq has no method reply_error, so bind it first (let f = req.reply_error) and call that; ③ ; is not a comment (nor is ;;); using it inline triggers a cascade of parse errors.
  • Real-bus evidence (the bus half of rule 5): moon test -p NoahLiu/native-gui/dbus 23/23 - session handshake succeeds (local unique name :1.392), ListNames really returns and contains org.freedesktop.DBus, an unknown-method call really yields the D-Bus error name and text, signal AddMatch really registers, dbus_pump idles without blocking or declaring death, and the system bus ListNames returns 96 names. The panel half (a tray icon actually appearing in the XFCE notification area) and notification popups remain for the user on a real machine, to be covered by the SNI/notify batches.
  • Later batches: SNI tray + DBusMenu (with real-panel verification), notifications (org.freedesktop.Notifications and set_progress), UPower/logind/NetworkManager/screensaver/MPRIS monitors, single instance, and the real logind SetBrightness injection for brightness.

MoonBit native GUI stack · cumulative system-capability page (examples/native-sys) ​

  • One page for all of sys: five panels (system & machine / desktop & hardware / apps & file types / windows & clipboard / recent & history) are all read-only probes; of the three buttons only "重新探测" (re-probe) is read-only - that is the one injection clicks - while "打开链接" and "打开目录" are setting-class and only act once clicked. Exit prints two lines: NATIVE-SYS-GEO probe=… open=… reveal=… (injection points) and NATIVE-SYS-STAT os=… de=… mon=… wp=… vol=… prof=… mime=… def=… apps=… recent=… hist=… dl=… bm=… ffx=… vsc=… wins=… clip=… probes=… opens=… reveals=… changes=…, matching the on-screen readings one by one. A run on this host: os=ubuntu de=XFCE mon=9 wp=ok vol=65 mime=text/plain def=sublime_text.desktop apps=179 recent=156 hist=5 dl=5 bm=166 vsc=1 wins=9 probes=3, exit code 0.
  • @core.App's root container defaults to Row: calling only app.mount([...]) without app.root.set_column() lays the panels side by side and pushes them out of the window (the first version of this page had the button's rect at x=1442 in a 1200-wide window, so every injected click missed). examples/native-app starts with root.set_column(); a new page must do the same, and a panel row that really is horizontal needs an explicit direction=@yoga.Row.
  • An injection count lower than the click count is not a lost event: one re-probe re-reads whole databases (Chrome's History is 37 MB here), the UI thread blocks while doing it, and a click arriving too soon is split - press before the block, release during/after it - so it does not count. Acceptance check: three clicks spaced 1.6 s give probes=3, clicks inside 1 s give 1 - injection scripts must leave a beat for heavy work.
  • How the clipboard watcher is wired: @sys.cbw_start_watch(fn(ms, f) { let _ = app.timer(ms, f) }, cb, interval_ms=1000) - the host supplies the scheduling channel and sys never learns about an event loop (headless tests drive it with a fake channel, see the previous entry). This host has no xclip, so the page honestly shows ClipboardError::Unsupported; that is not a page defect.
  • Verification: moon check ./examples/native-sys --deny-warn clean, moon build ./examples/native-sys passes; sys 163/163, core 62/62, ui 110/110, chart 189/189, mdview 26/26, render 42/42 all green; whole-repo backlog still 287 errors / 68 warnings, unchanged. Left for the user's eyes: whether "打开链接" raises the default browser and whether "打开目录" opens /tmp in the file manager - injection only proves the button really handed the action to the system call, not the window on the other side.

MoonBit native GUI stack · system integration, the three bus-free status items (sys: sysinfo + windowctl + clipboard_watch) ​

  • The file-reading stub must not size buffers from ftell alone (a real bug fixed this batch; it affected every read that went through this stub): pseudo-files under /proc/meminfo, /proc/uptime and /sys/class/dmi/id/* report 0 bytes for lseek(END) - their content only appears when actually read. The old shape (fseek(END) → ftell → allocate that size → fread) hit exactly this: pseudo-files returned Ok("") (not an error), so callers saw a parse failure and blamed the data format. yue_sys_read_text_file now uses ftell only as a hint and loops fread to EOF (one path for regular files and for tens-of-megabyte SQLite databases), and the two live assertions (MemTotal/MemAvailable, uptime seconds) flipped from red to green. Acceptance check: a pseudo-file read that "succeeds but is empty" points at how the length was taken, not at permissions.
  • Window / clipboard / clock capabilities go through commands: windowctl parses wmctrl -l -p (list), -i -a (activate by id) and -c (close by title); clipboard_watch polls xclip -selection clipboard -o read-only - a missing xclip surfaces as exit code 127 and is normalised to Unsupported (matching "tool absent" rather than "command failed"); sysinfo only reads /etc/os-release, /proc/meminfo, /proc/uptime and /sys/class/dmi/id/. Activating, closing and watching are setting-class: white-box tests cover command construction and output parsing only, the actions stay with the caller.
  • The clipboard watcher's timer is injected by the caller: master's cbw_start_watch hooked libyue's set_timer directly (no cancel id; it stops via a parking flag). sys does not import the GUI core layer, so scheduling became a parameter schedule : (Int, () -> Unit) -> Unit; the GUI layer passes fn(ms, f) { let _ = @core.set_timeout(ms, f) } while a headless test passes a fake channel that collects each tick's callback - which makes re-arming and "stop prevents the next arm" testable tick by tick (same layering rationale as the theme batch's "probe passed in as a function").
  • Six bus-dependent modules stay on the DBus line: power (UPower), session (logind/screensaver), online (NetworkManager), keepawake (logind inhibit), powerctl (gdbus_call_system) and locale_sys's localed half (the timedatectl half is bus-free).
  • Verification: moon test -p NoahLiu/native-gui/sys 163/163 (36 new: sysinfo 14 including two live /proc assertions; windowctl 14 with a real wmctrl -l -p list of 8 windows; clipboard_watch 8 covering fake-channel tick dispatch, stop preventing the next arm, and the missing-xclip normalisation). sys clean under --deny-warn; whole-repo backlog still 287 errors / 68 warnings, unchanged.

MoonBit native GUI stack · system integration, history family (sys: browser history / bookmarks / Firefox / VS Code) ​

  • The SQLite dependency must be declared at package level: prowk/moonsqlitefile is already in moon.mod's import, yet using @moonsqlitefile.* from sys failed with Package "moonsqlitefile" not found in the loaded packages - a module-level import does not substitute for a package-level one, so sys/moon.pkg needs "prowk/moonsqlitefile" @moonsqlitefile. The old-chain yue/moon.pkg has no such line (those files sit inside the repo-wide red-dot backlog), so copying the old chain's package config walks off a cliff.
  • Reading SQLite needs a raw-bytes entry: added sys_read_bytes_file, sharing the same stub call as sys_read_text_file (yue_sys_read_text_file already slurps the file into Bytes; the difference is only whether MoonBit decodes it). The text version cannot feed SQLite: decoding with decode_lossy and re-encoding is not byte-identical (invalid UTF-8 sequences get eaten by replacement characters), and the database page header fails immediately.
  • The time bases differ per source: Chrome/Edge/Brave History uses microseconds since 1601-01-01 UTC (constant 11644473600000, <=0 means unknown → 0, sub-microsecond truncated); bookmark date_added uses the same base but stored as a numeric string; VS Code entries use Unix milliseconds. Mixing them yields dates in 1601 or 50 millennia ahead.
  • Profile probing is "collect only what exists, without reading the database": directory enumeration only looks for an entry named History/Bookmarks, and only the data path reads the matched database in full - listing profiles no longer slurps tens of megabytes just to test existence. Known boundary: this layer has no way to enumerate profile directories, so it probes fixed candidate names (Default + Profile 1..24) and misses custom-named profiles.
  • Live end-to-end actually runs now (new strict smoke for bh_browsers/bh_history/bh_downloads): Chrome's 37 MB History is really read, two profiles are hit, returned timestamps land in 2026, the ordering invariant (time descending, ties by url ascending) holds, and the downloads table reads; VS Code's state.vscdb plus recents run end to end; this host has no Firefox (~/.mozilla/firefox/*/places.sqlite absent) so it takes the legitimate skip branch. Same discipline as the previous batch: a detected database that fails to read must fail; Err(_) => () is only allowed when the host genuinely has no such data.
  • Verification: moon test -p NoahLiu/native-gui/sys 127/127 (41 new: browser history 12, Firefox 10, bookmarks 9, VS Code 10, each combining pure-function assertions with a live smoke). sys clean under --deny-warn; whole-repo backlog still 287 errors / 68 warnings, unchanged.

MoonBit native GUI stack · desktop integration, second half (sys: recent files / recently-used.xbel) ​

  • Data surface: the GTK family (Thunar / Nautilus / GIO, xdg-desktop-portal-gtk) all append to $XDG_DATA_HOME/recently-used.xbel (default ~/.local/share/); this layer only reads, never writes. The parser is a hand-written thin scanner (it only takes attributes off bookmark plus bookmark:application / mime:mime-type), and everything error-prone lives in three pure functions: XML entity decoding (named entities plus &#NN;/&#xHH;, with bad codepoints and surrogate values kept literally), URI percent-decoding (consecutive %XX groups as UTF-8, while 100% / %E / %zz stay literal) and ISO 8601 → Unix milliseconds (first three fractional digits, no timezone means UTC, leap-day validation). This host really yields 156 items.
  • inspect(x, content="…") in the old wbtests asserts nothing on the native backend (recorded once before; this batch is the second scene): ported as-is the suite goes "all green, zero coverage". Conversion rules: booleans → assert_true/assert_false; numbers → assert_eq, and Int64 literals need the L suffix (XBEL millisecond timestamps exceed Int's width, assert_eq(items[0].added, 1776757238510) does not typecheck); a debug array text like [1, 2, 3] becomes an array literal; content="<should parse ok>" is not an assertion at all but the caption of an error branch, so it becomes a println plus an explicit failing assertion.
  • A smoke case's error branch must not skip silently: the old rf_recent / rf_by_app shape was Err(_) => () ("no data on this host, so skip"), which also hides the real regression of "the file exists but cannot be read". It is now Err(e) => { assert_false(rf_supported()); println(...) } - the Err branch is only legal when this host genuinely has no such file.
  • Two XBEL tolerance semantics pinned by tests: a fake <bookmark> hidden inside a comment is not collected (skip_declaration walks to -->); when a bookmark carries two mime:mime-type nodes the first wins; an XBEL truncated mid-write only collects the already-closed entries.
  • Verification: moon test -p NoahLiu/native-gui/sys 86/86 (13 new: entity decoding, ISO conversion with leap days, calendar day counts, percent decoding, URI restoration, the full XBEL sample's six cases, non-XBEL and truncated input, mapping and tie-ordering, exact per-application filtering, rf_take bounds, rf_data_dir's three states, and two live smoke reads). sys clean under --deny-warn; whole-repo backlog still 287 errors / 68 warnings, unchanged.

MoonBit native GUI stack · desktop integration, middle half (sys: wallpaper + default apps, plus a child-process environment fix) ​

  • pr_env_blob was not passing DISPLAY / DBus, so desktop-side commands could only fail in a child process (this single line is why the previous batch's monitor tests had to skip): [pr_run] builds the child environment from a whitelist (previously only PATH / XDG_RUNTIME_DIR / HOME plus LC_ALL=C); xrandr --query exits 1 without DISPLAY, and xfconf-query / gsettings cannot start without DBUS_SESSION_BUS_ADDRESS. After passing through DISPLAY / XAUTHORITY / WAYLAND_DISPLAY / DBUS_SESSION_BUS_ADDRESS, on the same host the monitor cases really read DP-0..5 plus HDMI-0 (primary, 1920x1080) and HDMI-2, and the read-only wallpaper smoke test reads /usr/share/xfce4/backdrops/Dolomites_by_Felix_Hartlieb.jpg. Acceptance check: if a command works in the shell but exits non-zero through pr_run, the whitelist is missing a variable - do not blame the tool version first.
  • Wallpaper property selection is tied to connected outputs: XFCE's last-image is picked in three passes ("workspace0 on a connected output → first workspace0 → first parseable"), the first using [mon_list]'s connected set to discard stale entries left by unplugged monitors; when xrandr is unavailable it degrades to "first workspace0". The write path picks its target property by the same rule (this stack's "drawing and hit-testing share one source" discipline, applied here) and creates the monitorNone-1 fallback entry when nothing exists. The third pass no longer considers connection, it just takes the first parseable entry in list order.
  • The default-app write path really writes and reads back in tests: da_set_default's landing point is $XDG_CONFIG_HOME/mimeapps.list, so the test points XDG_CONFIG_HOME at /tmp/da_wbtest_config (save / change / assert / restore) and asserts three states (missing file → new section; same mime re-set → the original line is replaced with no second line left; another mime → appended inside the section). The user's real configuration is untouched while the whole read-write chain is exercised.
  • Two toolchain findings (measured this batch): ① same-named constructors across several enums in one package need disambiguation - Unsupported/CommandFailed/ParseFailed collide between WallpaperError/MonitorError/VolumeError/FileManagerError/AppFindError; writing them bare in a test gives 4124 (The constructor ... is ambiguous), so tests must use WallpaperError::ParseFailed(...). Inside the implementation files match self { Unsupported => ... } infers the type and needs no change. ② A payload-free enum does not get Show either: println("\{de}") does not compile (Type DesktopEnv does not implement trait Show), so diagnostics go through the dedicated desktop_name().
  • Verification: moon test -p NoahLiu/native-gui/sys 73/73 (14 new: wallpaper 9 - the three-pass selection over this host's real xfconf property list, the monitorNone-1 fallback, gsettings/KDE output parsing and argument construction, JS single-quote escaping, text helpers, argument guards and Show texts, read-only smoke on the live desktop; default apps 5 - mimeapps section parsing with non-deduplicated repeated keys, the four merge cases, association merge/dedup/ordering, a real write/read round trip against a temporary XDG_CONFIG_HOME, and the live xdg-mime probe). sys clean under --deny-warn; whole-repo backlog still 287 errors / 68 warnings, unchanged. wp_set is setting-class: this batch only verifies argument construction and does not change the desktop appearance (same discipline as master); changing the wallpaper is left for the user on a real machine.

MoonBit native GUI stack · desktop integration, first half (sys: desktop detection + app lookup + open-external) ​

  • Migration surface, file by file: master's yue/system.mbt (open_url/reveal_in_file_manager) + yue/system/appfind.mbt + yue/traybus/detect.mbt (desktop environment detection) land as sys/launcher.mbt + sys/appfind.mbt + sys/desktop.mbt. The desktop detection is carried as its own pure functions inside sys rather than importing @traybus: the old-chain traybus package is part of the repo-wide red-dot backlog, and sys feeds it (wallpaper/default apps dispatch on the desktop) so importing it would create a cycle. When the DBus line lands, reuse this one copy instead of writing a second detect_desktop.
  • Detached spawn gets its own stub: master used the shim's g_spawn_async, while sys/sys_stub.c only uses standard headers, so this is fork → setsid → fork → execvp plus a CLOEXEC pipe reporting back whether exec succeeded. Not going through [pr_run] and waiting is deliberate: xdg-open usually stays alive after handing the window off, so waiting would pin the calling thread; the double fork also guarantees no zombie (this function waitpids the intermediate child, the real grandchild is reparented to init).
  • Real bug found and fixed this batch (write end of the pipe closed too early): the intermediate child used to close(fds[1]) before forking the grandchild, so the grandchild had no write end to report through when execvp failed → the intermediate child read EOF (all write ends closed) and called it a success → "command does not exist" came back as Ok. The assertion "a nonexistent command must be Err" is exactly what caught it (red on the first run). Correct order: the intermediate child forks the grandchild first (it inherits the write end), then closes its own write end and keeps only the read end; because the write end is CLOEXEC, a successful exec closes it automatically and the intermediate reads EOF, while a failed exec falls through to write("E").
  • Ok from open_url/reveal_in_file_manager means only "handed to the system"; whether something actually opens is the platform's business. Arguments never go through a shell (argv passed directly), so shell metacharacters in URLs and paths are not an injection surface.
  • The "select" half of reveal is an acknowledged gap: master first asks the FileManager1 bus for ShowItems and only falls back to opening the parent directory when the service is absent; this stack has no bus channel yet, so every call currently takes the fallback path (parent directory opens, selection lost). ShowItems comes with the DBus line.
  • appfind's ordering keeps master's mut_view().sort_by shape: in this toolchain a comparator that returns the -1/0/1 literals works fine - the earlier "Ordering is undefined" note only applies when the type name Ordering (or Ordering::Less) is written out explicitly, not to a comparator returning Int. Ordering semantics unchanged: compare name by codepoint, then id; appfind_str_cmp compares codepoint by codepoint instead of String's default shortlex order (which would put "New" before "Beta").
  • Platform surface: the Windows start-menu (.lnk) and macOS (.app) branches did not come with this batch - the Windows side of sys_stub.c is still a stub implementation, and open_url/reveal/appfind_* return Unsupported off Linux; the platform-dispatch batch can add master's pure functions (appfind_win_*/appfind_mac_*) directly.
  • Two toolchain findings (measured this batch): ① a Uint64 literal suffix like 500u64 does not parse (unexpected token id (lowercase start)) - convert the monotonic-clock difference with .to_int() before comparing; ② a payload-free pub(all) enum does not get Eq either (the earlier note was about PointerKind with a closure field; this extends it to plain enums) - desktop_detect() == before will not compile, so compare with match or via desktop_name().
  • Verification: moon test -p NoahLiu/native-gui/sys 59/59 (18 new: desktop detection 3 - single-value mapping, colon-separated multi-value first-recognised, and a real-environment save/change/assert/restore round trip; app lookup 10 - the INI subset parser, entry filtering with the zh_CN fallback, dedup merge and ordering, directory assembly's three states, real-disk collection with silent skips, NoScanDir, a live appfind_installed scan of this host, and appfind_executable hitting /usr/bin/sh plus argument guards; open-external 5 - parent_dir/relative-path normalisation, the real cwd, detached spawn's success/failure/non-blocking cases (sleep 3 returns in under 500 ms), and the two error enums' Show plus the platform gate). core 62/62, ui 110/110, render 42/42, mdview 26/26, theme 8/8; twelve packages clean under --deny-warn; whole-repo backlog still 287 errors / 68 warnings, unchanged. Left for real-machine re-verification (observable once examples/native-sys lands): whether open_url really raises the default browser, whether reveal_in_file_manager opens the parent directory window, and the app count reported by appfind_installed.

Linux ​

Distributions ​

Ubuntu 24.04 ✅ mainline ​

  • System dependencies: build-essential cmake pkg-config libgtk-3-dev libpango1.0-dev libfontconfig1-dev libx11-dev libwebkit2gtk-4.1-dev.
  • AppIndicator runtime is gone, so libyue's built-in tray is unusable → replaced by yue/traybus/ (pure-MoonBit SNI direct to the panel).
  • The webkit2gtk package name varies by distribution (4.0 / 4.1); prepare.py probes with pkg-config and accepts either.

Other distributions ❓ untested ​

  • First porting step: check each dependency's pkg-config name, then run prepare.py.

Desktop environments (tray / menu behavior) ​

XFCE ✅ ​

  • Autostart .desktop findings: the Exec path comes from readlink on /proc/self/exe (during development it points into the _build output, so the entry breaks as soon as the artifact moves — only a fixed install path is reliable; AppImage likewise points at its mount point); the full file passes desktop-file-validate with zero warnings; the enabled check honors both system-side disable switches (Hidden=true and X-GNOME-Autostart-enabled=false), and disable is idempotent (a missing entry counts as success).

  • Context menus are rendered by the panel mirroring DBusMenu; the app is not asked to draw via SNI ContextMenu.

  • xfce4-panel 4.18 sends only the batch EventGroup / AboutToShowGroup, not the singular versions: implementing only the singular forms gets silently rejected by UnknownMethod — the menu opens but clicks do nothing.

  • Desktop notifications must go through Notification::Show(); NotificationCenter::AddNotification never issues the DBus Notify call on Linux and fails silently.

  • Global shortcuts are exclusive XGrabKey registrations; a taken key combination makes Register return -1 (no crash, silent failure) — callers must check and offer another key.

  • With desktop focus, xfwm4 captures the keyboard and global shortcuts do not fire (they work when an application window has focus).

GNOME ✅ (X11 and Wayland sessions) ​

  • The AppIndicator extension reads the Menu property at registration time to build its proxy: an empty menu returning / permanently kills the menu client (clicking the icon does nothing). traybus always returns the real /MenuBar, exporting it even when empty, filled via LayoutUpdated.
  • With ItemIsMenu=false, both left and right clicks emit Activate.
  • Global shortcuts segfault on Wayland sessions (upstream casts the root window through a GDK X11 macro): a GDK_IS_X11_DISPLAY guard is in place; non-X11 sessions get Register = -1.
  • Mouse button semantics are yue-unified 1=left 2=right 3=middle (GDK's 2/3 are swapped); components judge by yue semantics.
  • Environment variables for launching GUI apps over SSH: X11 session XAUTHORITY=/run/user/1000/gdm/Xauthority; Wayland session XAUTHORITY=/run/user/1000/.mutter-Xwaylandauth.* and WAYLAND_DISPLAY=wayland-0; both need DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus.

KDE ✅ (Plasma 5.27) ​

  • The panel consumes SNI directly; new icons go straight into the visible tray area; tray / menu / quit all pass.
  • Protocol behavior is the opposite of XFCE: only singular Event / AboutToShow, no batch versions; traybus implements both.

Deepin ✅ (23 / 25, DDE) ​

  • dde-dock implements StatusNotifierWatcher; SNI direct connection works; new icons land in the collapsed tray area by default and can be dragged out.
  • libyue's Popover (transparent window + pointer grab) does not render on DDE and slows the pointer: the shim checks XDG_CURRENT_DESKTOP (23=DDE, 25=Deepin) and falls back to a borderless plain window.
  • Binaries built on the host (Ubuntu 24.04) run directly: the glibc symbol ceiling is 2.38 and deepin ships the webkit2gtk-4.1 runtime; for cross-distribution shipping, check the glibc symbol requirement first (objdump -T | grep GLIBC_).
  • TextEdit::Delete() deletes the selection, it does not clear; use set_text("") to clear.
  • DDE clipboard-manager interactions produce occasional CHECK / CRITICAL log noise; functionality is unaffected.

KDE / MATE / Cinnamon / Budgie / LXQt ❓ untested ​

  • All support SNI at the protocol level and traybus implements the protocol; awaiting per-environment verification.

DBus wire protocol (traybus) ​

  • The DBus array length prefix excludes alignment padding before the first element; counting it makes dbus-daemon disconnect as a protocol violation.

  • The variant signature in the header SIGNATURE field is "g" (u8 length-encoded); encoding it as "s" passes unit tests but real buses reject it.

  • The SNI Menu property must always return the real menu object path — never /, even when empty.

  • Unit-test self-consistency ≠ interop: protocol issues are located with dbus-monitor on the real bus; GNOME panel-side exceptions show up in journalctl.

  • Single-instance RequestName must carry flags=4 (DO_NOT_QUEUE): the default 0 queues the request, so the second instance's already-running check hangs until the first instance exits — semantics fully broken. Real-bus findings (Ubuntu 24.04 XFCE): reply 3 = held by another connection (instance exists → wake it and exit); after SIGKILL of the first instance the bus reclaims the name automatically and a new connection gets reply 1 (becomes the primary); reply 4 = this connection already owns it (idempotent). The full chain (RequestName flags=4 → EXISTS → Wake('as') → RETURN) was verified by dbus-monitor capture on the real bus: Wake to RETURN in 39µs; after SIGKILL of the first instance a third instance claims normally.

  • The second-instance wake dispatch must hook Conn::handle's kind==1 branch in bus.mbt before sni.mbt's handle_call: all inbound calls funnel into handle_call, and without the interception Wake lands in the UnknownMethod fallback so the first instance never sees it. Wake naming convention: interface org.moonbitlibyue.Instance, object path /org/moonbitlibyue/Instance, member Wake ('as' = the second instance's command line, passed through moonbitlang/core/env args(), including argv[0]; callers decide what to keep).

  • FileManager1 (reveal-in-file-manager) findings on XFCE: the NameHasOwner probe works; ShowItems wire format is "ass" (array of file URIs + an empty startup id); file URI percent-hex uses uppercase (RFC 3986 allows either case, uppercase is the convention), unreserved characters (A-Za-z0-9-._~) and '/' stay raw, everything else is %XX-encoded per UTF-8 byte — locked by unit tests. When the service is offline or the call fails, the fallback is xdg-open on the parent directory — selection is lost, a semantic degradation the usage doc must state.

  • External-open spawns go through g_spawn_async (G_SPAWN_SEARCH_PATH + stdout/stderr to DEV_NULL); glib reaps the child automatically, no zombies; no shell involved, argv passed directly. xdg-open's behavior for nonexistent paths or unopenable URLs varies by desktop, so the library's Ok only means "handed to the system" — the platform-side outcome does not travel back.

  • Screen-saver inhibition (Inhibit/UnInhibit) picks the service by real NameHasOwner presence, not by desktop-name guessing: the candidate list is [org.freedesktop.ScreenSaver, org.xfce.ScreenSaver]; measured on Ubuntu 24.04 XFCE only org.xfce.ScreenSaver is online (held by xfce4-screensaver), org.freedesktop.ScreenSaver is unheld. The two are interface-isomorphic: object path and interface name derive from the service name by dot→slash, Inhibit("ss" = app name + reason) -> u cookie, UnInhibit("u" = the original cookie) must match bit for bit (real-bus capture: Inhibit returned cookie 1516211641, UnInhibit 2 seconds later carried the same value, empty successful reply). GNOME / KDE service holdings pending real-machine notes.

  • System bus (measured Ubuntu 24.04): socket is /run/dbus/system_bus_socket — connecting by this default with DBUS_SYSTEM_BUS_ADDRESS unset works; when the variable is explicitly set, take the first unix:path= among the comma-separated entries, and if none matches (e.g. a tcp: address) return an explicit Err carrying the original text — silently falling back to the default socket would reconnect to a bus that really has UPower, turning the degradation acceptance check into a false pass. BecomeMonitor is rejected on the system bus (both dbus-monitor and busctl monitor), so capture is unavailable; verification goes through the probe's own round trips plus busctl call to cross-check reply shapes.

  • Multi-bus connections coexisting (B5 infrastructure): the shim's fd watch went from a process-wide single slot (each watch overwriting the previous) to an fd→callback dispatch table plus unwatch; the MoonBit side routes fd→Conn through a registry. Dual connections (session SNI + system UPower) verified to coexist without clobbering. When a glib source callback returns 0, glib destroys the source itself — the shim table entry must be erased in step (otherwise a later unwatch calls g_source_remove on a dead id and warns).

  • Wire-level 'd' (DOUBLE) and 't' (UINT64) must land as a set: in a real UPower GetAll reply UpdateTime is 't' and Percentage/Energy are 'd'; missing 't' sent the variant's unknown-signature old defensive branch ("return empty string but leave the read position alone"), misaligning every subsequent element and aborting inside the read_string slice (unit tests stayed green — self-crafted shapes never hit it). The defense is now two layers: unknown variant signatures push pos to the message end (parsing degrades to garbage values instead of misalignment), and read_string/read_sig gained bounds checks. Adding a type is an eight-site whole-chain change: DVal/Sig/sig_align/parse_one_sig/sig_of/sig_char/encode/decode_in.

  • Double↔IEEE 754 bit conversion lives in the shim (moonbitlang/core has no Double::to_bits/from_bits — confirmed): yue_mbt_sys_f64_to_bits/from_bits are pure bit reinterpretations (static_assert sizeof(double)==8), and the wire layer's 'd' piggybacks on the Int64 8-byte little-endian read/write. Note: once traybus's whitebox test target references such externs, its link needs -lyue_mbt — but link_configs propagate to "targets depending on the package", and traybus does not depend on yue (it's the reverse), so prebuild.py must carry a separate identical entry for NoahLiu/native-gui/yue/traybus (the single static-lib member references the whole gtk set, so trimmed flags won't link).

  • UPower reading path (measured Ubuntu 24.04, desktop machine): DisplayDevice (/org/freedesktop/UPower/devices/DisplayDevice, interface org.freedesktop.UPower.Device) aggregates the main battery — IsPresent=false → Ok(None); mind the interface-name split: devices are …UPower.Device, the toplevel is …UPower, and PropertiesChanged's arg0 filter naturally keeps device-level signals out of toplevel subscriptions. OnBattery lives on the toplevel object; power-source events subscribe to toplevel PropertiesChanged and parse the changed dict directly in the callback (signal dispatch sits on the drain stack — call_sync inside a callback would re-enter the receive path; forbidden).

  • Disconnect self-healing (B5, not yet exercised by a real disconnection): mark_dead is idempotent (keyed on the fd registry), reconnects via the shim's post_delayed_task (extern'd directly, bypassing the traybus→yue reverse dependency) after 500ms, at most 3 attempts; on success it replays AddMatch rules, migrates the subscription table and tray items, and re-registers them (during the disconnect the watcher already dropped the old registrations when our unique name vanished). In a pure-CLI context (main loop not running) the reconnect callback never fires and gives up naturally.

  • logind event subscriptions (B6): PrepareForSleep(b) lives on the Manager interface; Lock/Unlock live on each session object's Session interface (empty-body signals, one AddMatch per member, path unbounded — all sessions match, so with multiple logged-in users someone else's lock fires too; ownership is the caller's to check via sender). Degradation measured (private bus without logind): suspend_resume_supported()==false and session_lock_watch returns Err(SystemError::Unsupported), never silent. Rapid suspend/resume cycles may deliver two Resuming events in a row (no library-side debouncing); network/DBus may not be ready right after wake — retry logic in callbacks should defer.

  • Lock events need a dual signal source (real-machine bugfix): XFCE's xfce4-screensaver does not call logind when locking (no Lock/Unlock signals; calling Session.Lock via busctl directly fires them fine, proving the subscription chain is healthy — the locker just never notifies). It emits ActiveChanged(b) on its own org.xfce.ScreenSaver name instead. Fix: session_lock_watch subscribes to both sources — logind Lock/Unlock (system bus, GNOME/KDE path) plus the screensaver service's ActiveChanged (session bus, candidates [org.xfce.ScreenSaver, org.freedesktop.ScreenSaver] picked by real presence) — with a state machine deduping multi-source reports (same-state repeats don't dispatch; only transitions do). The ActiveChanged subscription key carries the service-name prefix, so it cannot clash with the logind subscriptions.

  • XFCE "blank-after-1-minute setting has no effect" and the keep-awake verification path (real-machine diagnosis): the power manager GUI's "blank after 1 minute" relies on DPMS or the screensaver's blanking, and on the measured host all three dimming chains were off (xfce4-screensaver /saver/enabled=false, X DPMS Disabled, xfce4-power-manager carrying no dpms/blank keys at all) — the GUI shows a value but nothing executes it, so the keep-awake switch is unobservable. Before verifying keep-awake, enable one executor (the screensaver blank is recommended: same component as the locker, so inhibition and lock events pair up); org.xfce.PowerManagement's inhibit interface is offline here (NameHasOwner=false), so DPMS blanking under XFCE cannot be inhibited via DBus — avoid that path.

  • Windows power/session event window (B6, code landed, real-machine verification pending): one message-only window receives both WM_POWERBROADCAST (PBT_APMSUSPEND; both PBT_APMRESUME and PBT_APMRESUMEAUTOMATIC count as resumed) and WM_WTSSESSION_CHANGE (WTS_SESSION_LOCK/UNLOCK, NOTIFY_FOR_THIS_SESSION); the WNDPROC PostTasks back to the main loop (B1 message-window pattern reused). The C side is single-window single-callback shared by sleep and lock — the MoonBit side registers one global dispatcher keyed on the event code, so later registrations must not clobber earlier ones. A WTSRegisterSessionNotification failure only disables lock events and is not treated as overall failure. wtsapi32.lib is the plan's only link-list change; prebuild.py's WINDOWS_LINK_LIBS and shim/CMakeLists.txt must land in the same commit (the two lists carry no consistency-comment backing).

  • NetworkManager online status (B7): State and Connectivity are both toplevel properties; a Properties.Get reply body is a single "v" (containing a u32) — a different shape from GetAll's a{sv}. Both change signals are subscribed: the legacy StateChanged(i) carries the new value directly, while PropertiesChanged(a{sv})'s changed dict may carry either property; subscription callbacks sit on the drain stack, so they only update the cache and notify with merged values — never call_sync to re-query (the first cache build happens inside the registration action, legal off the drain stack, worst-case 1.5s block). Normalization: State 70/50 with Connectivity 4/3 is Online; captive portal (Connectivity=2) counts as Offline ("online" = internet-reachable). Measured Ubuntu 24.04: State=70/Connectivity=4 → Online, matching busctl bit for bit; private-bus degradation gives Err(NetworkError::Unsupported). Windows NLM GetConnectivity bitmask: IPV4_INTERNET (0x40) or IPV6_INTERNET (0x400) present means Online, polled every 5s (listener-based later); netlistmgr.h needs no extra link libraries.

  • NM StateChanged signal parameter is Int32 (i), not u32 (real-machine pitfall, corrected): the D-Bus spec makes the State property u and the StateChanged signal parameter i — different types for the same concept; the wire layer decodes i as VI32, and the old implementation matched only VU32, so the signal was silently dropped — signals arrived, callbacks never fired, and after the panel disconnected or "enable networking" was switched off (State 70→20/10) the UI kept showing the initial Online (unit tests alone can't catch this: the hand-crafted signal body happened to use u). Fix: statechanged_of matches both VI32/VU32; bool and other types are still dropped as before. State inside Properties.Get and PropertiesChanged remains u — untouched. The dispatch chain itself was verified alive on a harmless property write (NM 1.46 WwanEnabled toggle, zero network impact on machines without WWAN hardware, PropertiesChanged arriving in real time); the UI flip after a signal was left to real-machine offline/online verification.

  • Network callback registration must sit outside the platform branches (located via two-level live observation): on_network_status_change dispatches uniformly through net_dispatch iterating g_net_cbs, but the registration g_net_cbs.push(cb) used to live only in the Windows branch — on Linux every signal arrived and the cache updated value by value (State 70→10→20→40→60→70 matching nmcli monitor line for line) while callbacks never ran, leaving the UI stuck on the initial value; a two-level probe (traybus nm_on_change layer vs the online dispatch layer) pinpointed it. Fix: push moved outside the platform branches so both platforms register uniformly. Other event registrations (on_suspend_resume/on_power_source_change) embed their callback closures directly and don't go through a global array, so they were never affected; net_dispatch dedupes against the network_status() baseline backfilled at registration, so the first same-state signal after registration not dispatching is expected.

  • Generic D-Bus call layer (yue/traybus/dbus.mbt, a cross-cutting foundation shared by the media / disk / bluetooth / sensor / time-zone modules): a thin layer over wire.mbt's wire format and bus.mbt's process-wide dual connections, offering "method calls on any service plus property read/write" through six public entries — gdbus_call_session / gdbus_call_system / gdbus_get_property_session / gdbus_get_property_system / gdbus_set_property_session / gdbus_set_property_system (the connection comes from the process-wide singleton, opened on demand; synchronous calls time out at 1.5 seconds, matching the bus's existing convention, and a reply timeout yields gdbus.timeout). a{sv} support is this layer's reason to exist: MPRIS Metadata and udisks GetAll property dicts are all a{sv}, and without it every module would decode that dictionary itself - duplicated and error-prone. The value model GDBusValue covers four containers: GVArray (element signature, element table) / GVDict (a{sv}, Map serialized in insertion order) / GVVariant (wrapper preserved) / GVStruct. On the encoding side GVArray's element signature is supplied in full by the caller ("s"/"{sv}"/"(ii)" and any other complete type); when wrapping a GVStruct the field signatures are concatenated and the whole thing pre-checked through parse_signature, with no timeout cost - a bad signature is reported locally as gdbus.encode instead of waiting 1.5s for a bus timeout. On the decoding side VArray("{sv}") collapses into GVDict with the variant wrapper stripped (downstream code wants values, not categories); Byte and the 16-bit integers fold into GVInt32, and a 'g' signature folds into GVString. The parameter tree forbids GVNone recursively (its signature could not be derived), and every entry runs a whole-tree scan through gdbus_args_ok first. The property-read path already unwraps the variant (a Properties.Get reply body is always a single "v", so the inner value is taken directly); the write path passes a bare value and this layer adds the variant wrapper. The error model is GDBusError{ name, message }: name carries the D-Bus error name verbatim (UnknownMethod / ServiceUnknown and the like), local errors use the gdbus.io (cannot connect / disconnected) / gdbus.timeout / gdbus.encode prefixes, so callers can route "service offline" away from "reply timeout" by prefix alone.

  • udisks2 (org.freedesktop.UDisks2, system bus) interface notes: enumerate objects through GetBlocks on /org/freedesktop/UDisks2/Manager, which returns a{oa{sv}} (device path → that block object's full property dict), then Properties.GetAll per block for the Filesystem interface as needed; the MountPoints property is aay (array of byte arrays), not as - parsing it as strings empties every entry and mount state can never be read, so each byte array must be decoded as UTF-8. Mount/unmount go through the Filesystem interface's Mount(options : a{sv})/Unmount(options : a{sv}), and the first argument may be an empty GVDict; the reply carries mount-point strings (possibly several for a multi-mount device). When udisks2 is offline, lsblk --json serves as an enumeration fallback only (char-typed fields need ansi stripping).

  • bluez (org.bluez, system bus) interface notes: enumerate objects with ObjectManager.GetManagedObjects, whose reply shape is a{oa{sa{sv}}} (path → interface name → that interface's property dict), then filter Adapter1 / Device1 out of it; Adapter1's Powered / Discovering and Device1's Name / Alias / Paired / Connected / Trusted all come out of those dicts. Scanning is Adapter1.StartDiscovery (no arguments), pairing Device1.Pair, connecting Device1.Connect - all three are empty-argument methods with empty replies. No bluetooth adapter is a legitimate state: when org.bluez is offline or no Adapter1 is enumerated, bt_supported() gives false and the query APIs degrade with an explicit Unsupported rather than an error.

  • iio-sensor-proxy (net.hadess.SensorProxy, system bus) interface notes: HasAccelerometer / HasAmbientLight / LightLevel / AccelerometerOrientation are all properties of one interface on one object, /net/hadess/SensorProxy (unlike udisks / bluez, which spread across many interfaces and objects). ClaimLight/ReleaseLight (or ClaimAccelerometer/ReleaseAccelerometer) must wrap any read: an unclaimed sensor keeps reporting its stale value, and the claim must be released afterwards or other consumers (auto-brightness, for instance) never see new values. AccelerometerOrientation is a string, not an enum integer - values like "normal"/"left-up"/"bottom-down".

  • Windows NLM GetConnectivity called on the UI thread can freeze for seconds and drag the native layout assert into a crash (measured on a Win10 19045 host; reproduced deterministically with a poor network environment): showcase aborts steadily 3.7–4.7s after launch (0xC0000409 = fast-fail), with yoga's toplevel assert "availableHeight is indefinite so heightMeasureMode must be YGMeasureModeUndefined" on stderr right before; the crash rate drifts 0%–100% with network health (when the network is healthy the query is millisecond-fast, matching the "smoke survived" notes at commit time — easy to misread as a code regression). Locating path: page bisection on the same exe (removing only the System Integration page → 0 crashes) → disabling only the initial network query + callback registration → 10/10 stable → prewarming and delayed queries both still crashed (any in-process query freezes, timing irrelevant). Root cause: the GetConnectivity slow path can take seconds per call; the synchronous initial-value query at registration froze the UI thread for seconds, and the moment it returned yoga processed the backed-up invalidations through ScrollView's content-measurement GetPreferred* path (which calls YGNodeCalculateLayout with a NaN height — fatal for containers with a defined height style). Fix: shim adds yue_mbt_netwin_start/netwin_cached — a background MTA thread owns COM and the polling loop (interval 5s default) and writes a process-level cache; the UI thread only reads the cache (network_status returns QueryFailed until the cache is ready; on_network_status_change no longer backfills the initial value synchronously; g_net_last becomes None = unknown, first value always dispatches). The cross-thread COM apartment problem dissolves because the interface pointer is created and used on the same thread. Verification: moon check zero warnings, 128/128 tests, showcase smoke 12/12 alive (same environment was 7/10 crashes before the fix).

  • Multi-item DBusMenu object paths must be derived from the item path (CORE2, whitebox + real-bus regression): the SNI Menu property used to return /MenuBar unconditionally, and the menus registry (menu object path → Item) collided on that key — creating a second tray item did c.menus.set("/MenuBar", item2), silently replacing the first item's menu route, so the panel only ever saw the last registered item's menu (the first item's layout queries landed on the second item or hit UnknownObject); unregistering could not clean the stale key either. Fix: menu_path_of(item_path) derives the path (/StatusNotifierItem → /MenuBar, /StatusNotifierItem_N → /MenuBar_N, the first item keeps the ksni convention), unregister and the create-failure path clear both the items and menus tables symmetrically, and Conn::next_item_path now scans for the first free path (the old length()+1 numbering reused an occupied path after a middle item was unregistered, colliding with its menu route too). Real-bus regression (sni_wbtest, auto-skips without a panel watcher): two items get distinct paths/menu paths, gdbus_call_session(own unique name, each menu path, GetLayout) returns each item's own labels (never the other's), both tables are empty after unregistering, and a fresh item restarts from /StatusNotifierItem; with the pre-fix code stashed in (menu_path_of always /MenuBar) the related cases fail precisely. The panel-side behavior of two icons each opening their own menu still needs on-device visual review (AGENTS rules 5/6).

  • Disconnect self-healing must not unconditionally open a new connection (CORE2, whitebox + real-bus regression): try_reconnect used to call connect_session/connect_system and overwrite g_conn/g_sys_conn as soon as it found a saved connection — if an on-demand path (sni_available / Item::create / system_conn) had already rebuilt a live connection after the disconnect, that connection plus its fd and glib fd watch leaked (the g_conns registry routes by fd, and after the overwrite nothing could unwatch/close it), with subscriptions and tray items split across two connections so signals only reached the orphaned one. Fix: the migration logic is consolidated into Conn::adopt_saved(bus), called by connect_session/connect_system whenever any new connection is established (on-demand rebuild and self-healing share the path), and try_reconnect first checks whether g_conn/g_sys_conn is already live — if so it only tops up the migration (idempotent) and opens nothing. Two older issues fixed along the way: migrated paths already present on the target connection are not re-registered (avoiding a double icon from a repeated RegisterStatusNotifierItem on the same connection), and Item::unregister also clears the item from the saved (disconnected) connection, so it no longer resurrects after a reconnect. Tray-item re-registration is posted back to the main loop via sys_post_delayed(0, ...): register_item_path's internal call_sync pumps the bus, and running it synchronously would let predicates such as sni_available / Tray::is_supported block for up to 1.5s when called from a GUI callback, and would re-entrantly dispatch inbound calls arriving during the pump (the same discipline as never calling call_sync from a netmon/UPower callback). Real-bus regression: mark_dead simulates the disconnect → on-demand rebuild → after try_reconnect g_conn is still the same connection (unique name unchanged) and g_conns gains no entry; with the pre-fix behavior (unconditional new connection) it fails precisely.

  • Measured sysmonitor figures (Ubuntu 24.04 XFCE X11, same methodology as the performance baseline at the top of this file: startup median, steady-state RSS, release binary): startup (exec → window map) over 5 runs 77/78/81/82/88ms, median 81ms (the hello baseline of 70ms is an idle system; this run carried 1042 processes); steady state with the process page foregrounded at 1Hz uses 2-3% CPU (sampling + derived data + 1000-row table rebuild + repaint total ≈ 25ms/second), RSS 84.9MB flattening at 85.8MB after 100s; binary 7.72MB (hello counterpart 7.03MB). The 1000-row process page meets acceptance: full sampling of 1053 processes takes 14.94ms/pass (release, ≈14µs per process — two /proc reads each), a 1.5% duty cycle at 1Hz.

  • The 1000-row table uses table_v_t virtual scrolling (only visible rows draw): refresh goes "full data sampling → filter/sort derived data → rows Store set → table load + schedule_paint", never rebuilding the view tree; selection is remapped by pid so it doesn't drift as the sort order changes every second. There is no C++ counterpart binary, so the combined "wrapper + data layer" overhead is attributed directly from the figures above; the UI drawing portion is on par with the hello baseline under the same methodology.

  • Panic in the IconPixmap property callback on the empty-pixmap state (2026-10, user's real-machine stack + before/after control): after clicking "切主题图标(Linux)" in showcase's tray demo, set_icon_name clears the bitmap by design (XFCE's panel prioritizes IconPixmap over IconName; without clearing, the old bitmap keeps drawing after a rename), leaving w=0/h=0/empty buffer. The panel receives NewIcon and re-queries IconPixmap; Item::property calls downscale_pixmap(0, 0, …) to build the extra 16×16 entry — the old clamp guard if y*h/nh > h-1 { h-1 } is true when h=0 (0 > -1), clamping sy to -1, and px[src+c] panics with a negative index inside the DBus callback (exit 134). The user's real-machine stack: traybus.downscale_pixmap(icon.mbt:63) ← Item::property(sni.mbt:539) ← Conn::handle_call(sni.mbt:660) ← drain ← on_bus_ready. Fix: downscale_pixmap early-returns (0, 0, empty) for degenerate sizes (any of w/h/nw/nh ≤ 0, or a buffer shorter than wh4) and the two never-triggered clamps were removed (for valid inputs y*h/nh is always < h); the main pixmap_value entry already tolerated the empty state, panels fall back to IconName, and the SNI semantics are unchanged. Before/after control (same probe, same path: create tray → set_icon_name → panel/manual IconPixmap re-query): before the fix, exit 134 with a stack identical to the user's report frame for frame; after the fix, the property returns [(0,0,[]), (0,0,[])] and the process stays alive (also re-verified with a manual gdbus Properties.Get). Regression: icon_test gains 3 cases (0×0 empty pixmap, degenerate target size, short buffer — all return empty), moon test 659 all green. Lesson: pure functions on the DBus property-callback stack must also defend against degenerate inputs — a panic there kills the message pump; "clamp to h-1" guards treat -1 as a legal value when the size is 0, so early return beats clamping; the cleared state (0×0) is a legitimate business state that every consumer must tolerate.

System monitor data layer (/proc, /sys — sysmonitor example) ​

  • /proc and /sys pseudo-files always report stat size 0 (fseek/ftell cannot learn the length): whole-file reads must loop with incremental fread and a doubling buffer (16MB cap); opening a directory succeeds but fread fails with EISDIR — detect via ferror. Implemented in the app's own native stub examples/sysmonitor/stub/sysmon.c; the MoonBit side goes through read_text_file.
  • An app-owned native stub may live in a subdirectory: "native-stub": ["stub/sysmon.c"] resolves relative to the moon.pkg directory; all symbols are within libc's default link set — zero shim / fork / vendored / link-flag changes. The test target links the stub automatically, so wbtests can read real /proc files.
  • /proc/stat column order is user nice system idle iowait irq softirq steal guest guest_nice: the 9th column (guest) is already folded into user/nice by the kernel — adding it double-counts. Usage = (Δtotal − Δidle − Δiowait) / Δtotal; iowait is not CPU-busy. When the sampling interval is shorter than one tick (USER_HZ, usually 10ms), Δtotal ≤ 0 and the result is 0; a zero first sample makes the first screen show the since-boot average.
  • /proc/cpuinfo model field differs by platform: x86 uses model name, ARM boards only have Processor / Hardware — three-level fallback; core count = number of processor lines (logical CPUs incl. hyperthreading, matches nproc).
  • Windows /proc/cpuinfo core-count correction (>64 cores, final review): GetSystemInfo's dwNumberOfProcessors only covers the calling thread's group (≤64), so multi-group machines must sum ActiveProcessorCount over GetLogicalProcessorInformationEx(RelationGroup). The original guard and offsets were all wrong: the relationship constant was 3 (RelationProcessorPackage; RelationGroup is 4), ActiveGroupCount read at it+8 (really it+10: 8-byte record header + 2-byte MaximumGroupCount), the GroupInfo base/stride assumed 32+64g (really it+32 base with a 48-byte stride per group), and the guard used 32+64g (really 32+48g) — for a single group the API returns Size=8+24+48=80 < 96, so total stayed 0 and the correction never fired, leaving >64-core machines at the current group's count. Authoritative layout (Microsoft Learn winnt.h): GROUP_RELATIONSHIP {WORD MaximumGroupCount; WORD ActiveGroupCount; BYTE Reserved[20]; PROCESSOR_GROUP_INFO GroupInfo[]} (24-byte header, union starts at record+8), PROCESSOR_GROUP_INFO {BYTE MaximumProcessorCount; BYTE ActiveProcessorCount; BYTE Reserved[38]; KAFFINITY ActiveProcessorMask} (48 bytes), and SYSTEM_LOGICAL_PROCESSOR_INFORMATION_EX's Size is the whole record length (including the Relationship+Size header), used as the stride to the next record. Verified: a harness fills a 2-group (64+32) buffer from the real structs and asserts the sum is 96; the single-group and truncated-Size degenerate cases each assert the fallback of 64. Real multi-group numbers still need Windows on-device confirmation.
  • /proc/meminfo units are always kB; MemAvailable only exists on kernels ≥ 3.14 — fall back to MemFree; used = total − available (includes reclaimable cache).
  • The moon run wrapper process does not forward signals to its child: smoke-testing exit behavior requires killing the built exe child process — killing only the wrapper PID leaves an orphan window.
  • Measured full-process sampling (release, Ubuntu 24.04): 580 processes × 2 file reads (stat + cmdline) = 9.57ms per pass, ~1% CPU at 1Hz refresh; RSS comes from stat's page count × page size (equal to status's VmRSS), saving a third read per process.
  • /proc/[pid]/stat comm can contain spaces and nested parentheses (process name "(foo (bar))") — split at the LAST ')' in the line; comm is truncated to 15 chars, so the full command line is read separately from cmdline (NUL-separated; empty falls back to [comm] for kernel threads).
  • getpriority's nice = -1 is a legal value and is ambiguous with the error return: report success/failure via a Ref[Int] out-parameter (kill / setpriority still use the errno return).
  • Windows has no /proc and no nice semantics: the stub keeps the same ABI at compile time and returns an "unsupported" sentinel (-1000) at runtime, which the MoonBit layer turns into a Chinese notice — the whole process page degrades cleanly; three-platform CI builds are unaffected (macOS takes the POSIX branch naturally).
  • GPU utilization / VRAM / temperature on the NVIDIA proprietary driver must not go through in-process NVML: after dlopen'ing libnvidia-ml.so.1, nvmlInit_v2 collides intermittently with the host runtime (measured on this RTX 3070 + Ubuntu 24.04 machine: startup segfaults about 5 out of 6 runs, does not reproduce under gdb, timing-sensitive; dlopen+dlsym without init stays clean). The route is now a popen nvidia-smi batch query (--query-gpu=pci.bus_id,utilization.gpu,memory.used,memory.total,temperature.gpu,name --format=csv,noheader,nounits, one line per card), matched against the sysfs enumeration by "take the last colon-separated segment and strip leading zeros" (nvidia-smi reports 00000000:01:00.0 where sysfs has 0000:01:00.0); the price is one subprocess per sampling pass (measured tens of milliseconds, acceptable at 1Hz). When nvidia-smi is absent (no N card / no driver), sysfs generic nodes are the natural fallback (amdgpu / nouveau gpu_busy_percent, mem_info_vram_*); hwmon temperature serves both. NVIDIA's proprietary driver exposes no hwmon, so its temperature can only come from nvidia-smi.
  • diskstats lists both whole disks and partitions (nvme0n1 alongside nvme0n1p1/p2/p3); an LVM mount's device name (/dev/mapper/ubuntu--vg-ubuntu--lv) does not match its diskstats name (dm-N) — resolve via /sys/block/dm-*/dm/name to find dm-N, then take the first slaves entry (measured: dm-0 → nvme0n1p3) to attribute IO rates to the mount row.
  • hwmon temperature indices skip numbers (coretemp exposes only some cores' tempN_input); labels may be absent (acpitz has none — fall back to the chip name); millidegrees can be negative (battery sensors); NVIDIA dGPUs commonly expose no hwmon temperature (measured: 0x2488 has none), so GPU temperature shows "—" under the hwmon convention.
  • statvfs capacity uses f_bavail (available, minus reserved blocks) rather than f_bfree, matching df's Use% convention; the struct is flattened across the ABI into three int64 out-params (total/free/avail).
  • /proc/mounts pseudo-filesystems (~20 kinds: proc/sysfs/cgroup2/devtmpfs/efivarfs …) have no meaningful statvfs capacity — the capacity table skips them via an fstype blacklist, keeping only real /dev/ device lines; multiple mounts of one device (btrfs subvolumes / LVM snapshots) are deduplicated by device, first occurrence wins.
  • Directory enumeration (/sys/class/hwmon, /sys/class/net, /sys/bus/pci/devices, /sys/block/*/slaves) goes through a generic opendir/readdir stub (newline-separated entry names); together with read_text_file these are the data layer's only two IO primitives.
  • The /dev/fuse control mount (appears once the file manager brings up gvfsd-fuse; mount point /tmp/fuse) returns a valid statvfs with f_blocks=0: filtering by the fstype blacklist alone is not enough — also skip total<=0, or the disk page shows a "0 MB / 0 MB" noise row (verified: the S5 whitebox assertion total>0 failed because of it).
  • Windows data layer (SYS1): the Win32 sources are virtualised into Linux-shaped /proc and /sys text at the stub's read entry points, so the MoonBit data layer and its parsing pure functions need zero changes. Decision: rather than opening platform branches in MoonBit (two implementations per sampler, and pure-function tests could never cover the Win32 path), the C side generates isomorphic text on the fly for read_text_file/list_dir/statvfs/list_pids requests against Linux paths — the Linux path is the cross-layer contract, and unit tests keep feeding literals and stay green. The seven routes and their deviation list:
    • CPU: /proc/stat from GetSystemTimes (global; kernel includes idle and must be subtracted) plus ntdll NtQuerySystemInformation(SystemProcessorPerformanceInformation) per logical core (an undocumented information class, but the ABI has been stable for decades and Task Manager uses the same source; if the query fails only the aggregate line is emitted and the per-core bars stay empty); units are normalised to 10ms ticks (100ns ÷ 1e5) to line up with clk_tck=100; /proc/cpuinfo model and clock come from the registry CentralProcessor\0 ProcessorNameString/~MHz (compiler-independent, and it avoids x86 conditional compilation compared to a CPUID intrinsic), core count from GetSystemInfo (≤64) corrected by summing active processors over GetLogicalProcessorInformationEx(RelationGroup) groups (GetSystemInfo caps at 64 on multi-group systems).
    • Memory: GlobalMemoryStatusEx → MemTotal/Free/Available and SwapTotal/Free; ullAvailPhys is Windows' "available" (it serves as both MemAvailable and MemFree), and the commit charge (ullTotalPageFile = commit limit) serves as swap — matching Task Manager's "Commit" rather than the pagefile pool.
    • Disks: /proc/mounts from GetLogicalDrives per drive (GetDriveType accepts only FIXED/REMOVABLE, skipping optical-drive read noise and mapped network drives); the line convention is dev/C: C:\ ntfs ... — the device column is forced to carry the /dev/ prefix so parse_mounts accepts it, and the mount point holds the real Windows path (the MoonBit statvfs_info passes it back verbatim, the stub's GetDiskFreeSpaceExW takes it directly, and the UI naturally shows "C:"); /proc/diskstats from PDH \PhysicalDisk(*)\Disk {Read,Write} Bytes (PdhAddEnglishCounterW registers the English names, because localized Windows objects/counter names differ and a hard-coded localized path always fails; path validity still needs on-device confirmation), taking raw cumulative counters (not the per-sec variants) so a single Collect is enough, dividing by 512 into sectors for the existing MoonBit sector_rate difference; instance names (e.g. "0 C: D:") are split on spaces and only tokens shaped like "C:" are kept (disk numbers and "_Total" drop out naturally), and every drive letter on a disk emits its own identical line — PDH counts per physical disk while the mounts side emits per drive letter, so each letter must be attributable to its disk (taking the last token only recorded the last letter on a multi-partition disk and left the others at None forever). The instance-name shape for multi-partition disks needs on-device confirmation.
    • Network: GetIfTable2 for the full table, InterfaceAlias as the card name, "Software Loopback" normalised to "lo" (main.mbt's rate aggregation then excludes it automatically, as on Windows); InOctets/OutOctets serve as the rx/tx counters. The table carries a 200ms static cache — each card's rx and tx read the path separately, so without a cache 1Hz would pull the whole table 2×N times per second.
    • Processes: K32EnumProcesses (kernel32 exports carry the K32 prefix, so GetProcAddress is required) enumerates pids; pid→ppid/thread count/image name come from a single CreateToolhelp32Snapshot (250ms TTL), then per pid OpenProcess+GetProcessTimes (100ns÷1e5 = 10ms tick, the same unit as clk_tck=100) + K32GetProcessMemoryInfo (WorkingSetSize÷4096 = page count, matching page_size=4096); cmdline = the full path from QueryFullProcessImageNameA, and when OpenProcess is denied on system processes time/memory read 0 and the command line falls back to [comm]. nice is always 0: Windows priority classes do not map onto Linux nice semantics and a mapped value would mislead; normalising priority into tiers is SYS2's scope (SYS2 has landed: the nice field now carries the tier representative value derived back from GetPriorityClass).
    • GPU: DXGI CreateDXGIFactory1 enumerates adapters with virtual pci addresses 0000:00:NN.0 (unique after MoonBit's norm_pci takes the tail segment and strips leading zeros); vendor/device are emitted as "0x%04x" lowercase for the existing vendor_name table; WARP software adapters (Flags bit 0) are skipped; VRAM total = DedicatedVideoMemory (integrated cards reporting 0 get no virtual mem_info_vram_total, so no VRAM curve appears), used = IDXGIAdapter3::QueryVideoMemoryInfo(Local segment).CurrentUsage; the COM headers are not included via dxgi.h — the vtable slots are declared by hand in interface-inheritance order (the COM binary ABI is stable) and the IIDs are custom constants taken from the SDK's published values, avoiding MinGW header version drift and any dxgi.lib link surface. Utilization = PDH \GPU Engine(*)\Utilization Percentage, summed over instances whose pid_*_luid_0xHEX_0xHEX_* name matches the adapter LUID's low 32 bits (aggregating many engines and processes); this counter type needs two samples before it yields formatted values, so the query handle is kept process-wide across ticks and the first tick is always 0; the instance-name format and aggregation rule need on-device confirmation.
    • Three corrections to the hand-declared COM, from the SDK (SYS1 final review): ① all three IIDs were wrong — IDXGIFactory1 was written as 770AAE78-F26F-4DBA-83A9-50BC11D9EE9B (real 770AAE78-F26F-4DBA-A829-253C83D1B387), so CreateDXGIFactory1 refused it and returned E_NOINTERFACE → factory==NULL → no GPU enumerated at all; IDXGIAdapter3 was written as 645967A4-1392-4316-9CEC-9CAF7113F420 (real 645967A4-1392-4310-A798-8053CE3E93FD), so QueryInterface always failed → used VRAM never available; IDXGIAdapter1 (no call site today) was written as 290462F0-AC1E-4212-AA25-4F95B4968B12 (real 29038F61-3839-4626-91FD-086879011A05). ② The IDXGIFactory1 vtable omitted CreateSoftwareAdapter and swapped Make/GetWindowAssociation: the SDK order (the IDXGIFactory block in win32metadata dxgi.h plus the IDXGIFactory1 append block) is EnumAdapters, MakeWindowAssociation, GetWindowAssociation, CreateSwapChain, CreateSoftwareAdapter, then IDXGIFactory1 adds EnumAdapters1 and IsCurrent — so the declared index 11 "EnumAdapters1" was really CreateSoftwareAdapter, which was called with Module=(HMODULE)i (taking a reference count on a module handle) and the 16-iteration loop never found a hardware adapter. ③ The IDXGIAdapter slot names were corrected to the SDK too (EnumOutputs; RegisterHardwareContentProtectionTeardownStatusEvent / UnregisterHardwareContentProtectionTeardownStatus, not RegisterVideoMemoryBudget*). Lesson: a hand-declared COM's IIDs and slots must each be checked against an authoritative source (win32metadata generation/WinSDK/RecompiledIdlHeaders dxgi.h/dxgi1_4.h, Microsoft Learn struct docs) — "custom constant" does not mean "from memory". Verification: with no Windows host here, the check is self-validating (an expected IID/slot table derived from the SDK headers + compile-time _Static_assert(offsetof(slot)==index) under gcc + a run-time memcmp; the harness is not committed), and the pre-fix code fails it precisely. The semantics still need Windows real-machine acceptance (GPU page cards / VRAM curves).
    • Temperature: NVIDIA only, via the nvidia-smi subprocess, virtualised into /sys/class/hwmon/hwmonN/{name,temp1_input,temp1_label} (one entry per card, millidegrees) so the sensor page parses it unchanged; AMD/Intel temperature is unavailable and not fabricated (no WinRing0-class kernel driver), so the hwmon directory returns NULL on that machine and an empty sensor page is a legitimate state. nvidia-smi's first column is not DXGI's virtual address, so it is matched by the sixth column's model name against desc.Description and rewritten to close the gap (the model name is the only string both share; if the driver renames the product, attribution fails and that card loses utilization/VRAM/temperature, degrading to static DXGI info).
    • Subprocess primitives: CreateProcess + CREATE_NO_WINDOW + an anonymous pipe for output (hStdOutput/hStdError share it) rather than _popen — under a GUI process, _popen's console child flashes a black window; WaitForSingleObject with a 3s timeout and TerminateProcess as backstop. nvidia-smi results carry a 500ms static cache (at 1Hz the hwmon and gpu_list consumers each read once, avoiding two subprocesses per second).
    • Link discipline: pdh/dxgi/iphlpapi/psapi/ntdll all go through LoadLibrary+GetProcAddress, while kernel32/advapi32 are MinGW/MSVC default libraries and are called directly — zero link-flag changes, so prebuild keeps full custody.
    • monotonic must be real (QueryPerformanceCounter): every rate metric divides by wall-clock differences, and the placeholder's constant 0 would make the denominator always 0.
    • Verification: this machine has no mingw/MSVC so the Windows branch cannot really be compiled; syntax and symbol surface are checked with "a minimal Win32 stub header + gcc -fsyntax-only -Wall -Wextra" at zero warnings (the stub header is not committed), and real correctness is left to the CI Windows runner compiling for real plus Windows real-machine acceptance of the five pages (see the TODO.md SYS1 real-machine list): specifically the PDH PhysicalDisk/GPU Engine counter paths under Chinese Windows, the GPU Engine LUID aggregation numbers, nvidia-smi ↔ DXGI model-name matching, and the empty sensor page on a machine without an N card.
  • Windows process-management semantics unified (SYS2): kill / priority / error codes are normalised to Linux errno semantics inside the stub, so the MoonBit layer's errno_text and Result need zero changes. Decision: ① the first cut maps kill to TerminateProcess only (SIGTERM/SIGKILL map the same, so the UI collapses to one button; termination is asynchronous after TerminateProcess is issued, so a short WaitForSingleObject(3s) makes "ended N processes" truthful; the gentler WM_CLOSE route was deferred). ② Priority maps the four tiers IDLE/NORMAL/HIGH/REALTIME: set clamps to the nearest tier (≥10 IDLE / -9..9 NORMAL / -16..-10 HIGH / ≤-17 REALTIME), get returns the tier's representative value (19/10/0/-10/-13/-20; BELOW/ABOVE_NORMAL clamp to the neighbouring tier), and the virtual /proc/[pid]/stat nice field changes from constant 0 to the representative looked back from GetPriorityClass (so the process page's nice column and its sorting become real). ③ Win32 error codes are translated to errno numbers inside the stub: ERROR_ACCESS_DENIED→1 (EPERM), ERROR_INVALID_PARAMETER→22 (EINVAL), ERROR_PRIVILEGE_NOT_HELD/ERROR_SHARING_VIOLATION→13 (EACCES), ERROR_FILE_NOT_FOUND/PATH_NOT_FOUND→2 (ENOENT), NOT_ENOUGH_MEMORY/OUTOFMEMORY→12 (ENOMEM), INVALID_HANDLE→9 (EBADF), ALREADY_EXISTS→17 (EEXIST), BROKEN_PIPE→32 (EPIPE); anything unlisted becomes EIO=5. ④ OpenProcess on an exited pid always reports ERROR_INVALID_PARAMETER (87): without a special case, "process does not exist" would be shown as "invalid parameter", so kill/get/set all special-case it into ESRCH (3), matching Linux kill on a missing process. ⑤ REALTIME needs SeIncreaseBasePriorityPrivilege; for a normal user SetPriorityClass reports ERROR_PRIVILEGE_NOT_HELD→EACCES, aligning with Linux setpriority returning EACCES for negative values on a non-owned process. ⑥ UI copy becomes platform-aware (the pure functions kill_button_labels / priority_ui / priority_result / priority_col_header take platform explicitly so unit tests can drive it): Windows shows a single "End task (terminate immediately)" button, the renice message shows the tier name plus the value read back after setting (the real value after discrete clamping), and the priority column header says "Priority"; Linux keeps the two-step kill and continuous nice. ⑦ Unavailable sources such as temperature show "—" per slot: processor cards "12% · —", GPU cards "35% · —" / "— · 55℃" (the old code omitted the slot, so users could not tell "no data" from "not refreshed yet"), and the sensor page gets an explicit "no temperature source available" empty row. ⑧ Platform detection gains yue_sysmon_is_windows (fixed at compile time, one copy per branch) rather than importing @yue.platform(), keeping the data layer self-sufficient.
    • Pitfall: defining a stub header by the shape of your own code hides real SDK struct-member errors (SYS2 fixing SYS1's leftover): SYS1 read the PDH raw value as RawValue.FirstValue.largeValue, but the real pdh.h has PDH_RAW_COUNTER.FirstValue as a LONGLONG (no .largeValue suffix; the anonymous-union shape belongs to PDH_FMT_COUNTERVALUE's FmtValue) — SYS1's minimal stub header had been written to match the code, so -fsyntax-only passed and the CI Windows runner would necessarily fail. SYS2 corrected it to RawValue.FirstValue and wrote the stub header to the real SDK shape (FirstValue a LONGLONG, FmtValue the anonymous union); conclusion: a stub header only validates the symbol and syntax surface, struct member names and layout must still be checked member by member against the real SDK, and zero -fsyntax-only warnings does not mean it will really compile.
    • SYS2 verification: no Windows host here, so the Windows branch passes gcc -fsyntax-only -Wall -Wextra at zero warnings against a minimal Win32 stub header defined to the real SDK shape (not committed); after touching the stub, moon build examples/sysmonitor is warning-free (sysmon.o really recompiles); moon check zero warnings + moon test 608 all green (sysproc_wbtest adds 8: the errno_text translation targets, the four tier boundaries, the kill/priority button and result copy, the column header, the with_temp "—" placeholders, and get/set on a missing pid normalising to ESRCH); real termination and REALTIME elevation behaviour must be verified on a Windows machine (list in TODO.md SYS2).
  • macOS data layer (SYS3): the Mach / libproc / getfsstat / getifaddrs / system_profiler sources are virtualised into Linux-shaped /proc and /sys text at the stub's read entry points, so the MoonBit parsing layer and its pure functions need zero changes (the same SYS1 decision: "the Linux path is the cross-layer contract"); GPU goes through a system_profiler -json subprocess with the JSON parsed in MoonBit pure functions; disk IO rates and temperature have no source, so the UI shows "—. The seven routes and their platform splits:
    • Platform split: the stub has three platform structures (_WIN32 / __APPLE__ / Linux); kill / priority / statvfs / monotonic / page_size share one POSIX implementation (macOS and Linux agree semantically, so the two-button process-management UI and continuous nice apply unchanged); the CPU / memory / process / network / mount five routes go to virtualised text; the MoonBit layer opens exactly two branches (on_macos() = @yue.platform() == "macos"): gpu_list parses system_profiler output, and DiskInfo tolerates a missing /proc/diskstats. Mach / proc_info / statfs / if_data64 are declared from the xnu source layouts (the symbols sit in libSystem's default link range) to sidestep SDK header version drift — the same idea as the hand-written vtable and custom PROCESS_MEMORY_COUNTERS on the Windows branch.
    • CPU: host_processor_info(PROCESSOR_CPU_LOAD_INFO) gives per-logical-core cumulative user/system/idle/nice ticks, the aggregate line sums the cores, and the column order matches /proc/stat (iowait/irq/softirq/steal have no counterpart and stay 0); the tick unit is mach's own and only feeds ratios, independent of clk_tck; the returned buffer must be released with vm_deallocate(mach_task_self_()) (one block per tick at 1Hz, and leaks accumulate). Model from machdep.cpu.brand_string (Apple Silicon implements it too) → fallback hw.model; clock from hw.cpufrequency (Hz→MHz, absent on some machines = 0); core count from hw.ncpu.
    • Memory: hw.memsize for total; host_statistics64(HOST_VM_INFO64) for free / inactive, available = free + inactive (Activity Monitor's rule); swap via sysctl vm.swapusage (the xsw_usage layout is not published by the SDK, so it is declared from the general definition: total/avail/used as uint64 plus pagesize and encrypted).
    • Disks: getfsstat(MNT_WAIT) rewritten into /proc/mounts-shaped text, with all filtering kept on the MoonBit side — is_pseudo_fs gains the BSD pseudo filesystems (devfs / fdesc / nullfs / volfs / union / synth; autofs is shared) and is_system_mount gains the macOS system volumes (/private is the swap backing volume, plus /System/Volumes/{Preboot,Update,VM,Hardware,iSCPreboot}, while /System/Volumes/Data holds user data and stays); mount points may contain spaces (user volume names), so the stub escapes them as \040 per kernel convention and the MoonBit side restores them with unescape_mount — unrestored, whitespace splitting takes the wrong columns and the row disappears from the capacity table after statvfs fails; capacity reuses the shared POSIX statvfs. IO rates have no source (IOKit is deferred): failing to read /proc/diskstats no longer fails the whole tick (the old early return blanked the entire disk page), DiskInfo.read_bps/write_bps change from Double to Double?, None renders "—" and pushes no point onto the IO curve (no fake 0 line); mounts that cannot be attributed to diskstats (NFS and friends) are also None, which is more honest than the old constant 0 B/s.
    • Network: getifaddrs (with a 200ms TTL cache — each card's rx and tx read the path separately, so without a cache every tick pulled the whole table twice), ifa_data pointing at struct if_data64, using ifi_ibytes/ifi_obytes as the counters; one entry per address family deduplicated by name; loopback "lo0" normalised to "lo" (main.mbt's rate aggregation excludes by "lo", as the Windows branch does for Software Loopback).
    • Processes: proc_listpids(PROC_ALL_PIDS) to enumerate (NULL/0 to learn the size plus an 8KB margin for processes born between the two calls), skipping pid 0 (kernel_task, which has no command line or timings); proc_pidinfo(PROC_PIDTBSDINFO) for ppid / nice / state / comm and PROC_PIDTASKINFO for thread count and nanosecond timings (÷1e7 → 10ms tick, with yue_sysmon_clk_tck hard-coded to 100 on the macOS branch so the units agree); RSS bytes ÷ page size; proc_pidpath's full path as cmdline (NULL on permission denial / kernel_task, and the MoonBit layer falls back to [comm]); states mapped from BSD p_stat (SIDL→I / SRUN→R / SZOMB→Z / SSLEEP→S / SSTOP→T). PROC_PIDTASKINFO was chosen over proc_pid_rusage because one call yields thread count / both timings / RSS.
    • GPU: the system_profiler SPDisplaysDataType -json subprocess — fork + anonymous pipe + poll timeout (8s) then SIGKILL rather than popen (a shell child flashes a window under a GUI process, the same reasoning as Windows' CREATE_NO_WINDOW), execv with the absolute path /usr/sbin/system_profiler to avoid a PATH search allocation; the first run takes seconds, then the result is cached process-wide (model / total VRAM are static, like enumerating DXGI once on Windows), and failure retries with a 5s backoff; the raw JSON is handed back and parse_system_profiler_gpu parses it in MoonBit (splitting _items by brace depth while ignoring braces inside strings, model from sppci_model falling back to _name, VRAM from spdisplays_vram, vendor sniffed from the name). Utilization and temperature have no source (no public GPU-utilization API; SMC / IOReport private keys are deferred), so busy / celsius stay None and the UI shows "—" per slot.
    • Temperature: not shown in the first cut (SMC / IOReport private keys deferred); /sys/class/hwmon naturally returns None on macOS, and the sensor page's empty row plus the processor card's "· —" reuse SYS2's existing convention.
    • Link discipline: every new symbol is inside libSystem's default link range, so zero link-flag changes; extern declarations must link on all three platforms — non-Apple uses a same-ABI placeholder returning NULL (the MoonBit gpu_list macOS branch only calls when on_macos() is true, so the placeholder is never reached at run time), otherwise the Linux / Windows test builds fail at link time (this batch did exactly that and had to add the placeholder).
    • Pitfall: verify Mach constants and struct layouts against the xnu source before declaring them by hand: PROCESSOR_CPU_LOAD_INFO is 2 not 1, MNT_WAIT is 1 not 2, MAXCOMLEN is 16, the 64-bit statfs has f_mntonname before f_mntfromname, and if_data64's counters are ifi_ibytes/ifi_obytes (there is no ifi_oobytes) — writing these wrong from memory never fails to compile (your declaration becomes the truth), and -fsyntax-only only checks syntax and symbols while struct offsets must still be checked member by member (the same lesson as SYS2); host_statistics64's count means "below REV0's count → KERN_FAILURE, above → filled as an older version", so pass 40 integers per the current xnu full layout — extra fields are safe.
    • Pitfall: String::unsafe_get returns a UInt16 (a UTF-16 code unit), not a Char: while scanning JSON / escaped strings character by character, c == '\\' does not typecheck — either compare via to_int() or copy the untouched runs in blocks (write_stringview + slices, surrogate-pair safe); mixing the two invites "has type UInt16, wanted Char".
    • Pitfall: a host clock that jumped backwards makes moon treat .o files with future mtimes as current, so touch-ing sources does not trigger a rebuild — when moon build reports "up to date" after a stub change, delete the corresponding .o under _build first.
    • SYS3 verification: no macOS machine in this environment. On the Linux host moon check is warning-free and moon test 614 all green (syshw_wbtest adds 6: unescape_mount's restore and invalid sequences, parse_mounts against the macOS getfsstat shape (spaced volume names / BSD pseudo filesystems), is_system_mount's system volumes, parse_vram, json_string_value / json_items_objects splitting and nesting, parse_system_profiler_gpu's dual-card parse and tolerance; after DiskInfo's rate fields became Option, the S5 / S6 live-sampling assertions were relaxed to both shapes accordingly); the macOS branch passes gcc -fsyntax-only -Wall -Wextra -D__APPLE__ at zero warnings (syntax and symbol surface only — the struct layouts were checked field by field against xnu, semantics left to real hardware); after touching the stub, moon build examples/sysmonitor is warning-free; the non-mac placeholder symbols were confirmed exported in the Linux build artefacts. The seven data routes and process management must be verified on a real mac (list in TODO.md SYS3): specifically the per-core numbers from host_processor_info (a wrong order reads idle as user), system_profiler -json's key names and attribution (sppci_model / spdisplays_vram / spdisplays_vendor — test on Apple Silicon and on Intel/AMD cards), the getfsstat mount table against df (including spaced volume names and /System/Volumes/Data), proc_listpids' process count against ps, and kill / setpriority taking effect for real.
  • Linux GPU utilization completed (SYS4: DRM fdinfo aggregation gives whole-card utilization without root, for i915 / xe / amdgpu). The earlier framing "ask for administrator privileges to read the GPU" is replaced, on this machine's evidence, with "complete the data source without root + make the elevation boundary explicit". Decision: ① permission evidence (measured here as uid 1000 on Ubuntu 24.04): /sys/class/hwmon/hwmon*/temp*_input and amdgpu's gpu_busy_percent are both 0444, and DRM fdinfo comes through the standard /proc interface (this user's own fdinfo files are r--r--r--), so nothing the panel normally needs requires root; the only restricted path, /sys/kernel/debug/dri (debugfs, drwx------ root), is the i915 debug view (per-engine / per-client detail) and carries no metric the panel wants — so "request elevation to read the GPU" had no data behind it and polkit / pkexec integration is not planned (a separate batch if the user insists on the elevation route). ② Utilization source priority: nvidia-smi (N cards) > sysfs gpu_busy_percent (amdgpu, a driver-side whole-card average covering all clients) > DRM fdinfo aggregation (i915 / xe / amdgpu fallback); the VRAM / temperature conventions are unchanged. ③ fdinfo aggregation rule: enumerate /proc/<pid>/fdinfo, parse drm-driver / drm-pdev / drm-client-id / drm-engine-* (all engines summed), merge by (device key, client-id) (dup'd fds and fds passed over a unix socket are the same drm_file with identical counters, so take the maximum once), and convert the difference between two samples against wall clock into whole-card utilization (clamped 0..100) — the same source as intel_gpu_top / nvtop. ④ Attribution: drm-pdev (kernel ≥6.5, a PCI slot address shaped like the sysfs device directory name) wins; older kernels lack it, so the fallback matches /sys/bus/pci/devices/<addr>/uevent's DRIVER= name (two cards on the same driver merge into one value — a known boundary). ⑤ The scan only runs when a card has neither a sysfs utilization source nor nvidia-smi coverage: AMD / NVIDIA machines skip the whole tick, Intel machines scan every tick (the same cost as nvtop / intel_gpu_top). ⑥ No number before the first sample (from the next frame), and a client first seen in a tick is not counted; the data-layer entry moved from gpu_list() to GpuMonitor::tick() (previous samples live with the App; the macOS branch is unchanged).
    • Version boundaries (checked against the torvalds/linux source version by version): drm-engine-* / drm-driver / drm-client-id since v5.19 (amdgpu / i915 "Convert to common fdinfo format v5", 2022-05-26; absent in v5.18, present in v5.19); drm-pdev since v6.5 (the dev_is_pci branch of drm_show_fdinfo in drm_file.c; absent in v6.4, format %04x:%02x:%02x.%d); the cycles / maxfreq keys since 6.2 (not consumed here). Ubuntu 22.04's 5.15 kernel has no engine keys → busy is None and shows "—"; 24.04's 6.8 has all three.
    • Known boundaries (honest rule: show "—" when a source is unavailable): ① fdinfo only covers this user's processes — other users' (root services / other logged-in users) /proc/<pid>/fdinfo/<fd> reads return EACCES (measured: /proc/1/fdinfo/1 permission denied), so whole-card utilization is "as far as this user can see" and will under-report; full accounting needs root (no polkit for now). ② Scan cost measured: the full /proc fdinfo walk touches 9123 files / 4.2MB, about 56ms per tick (measured by a C program with the same read loop as the stub, on a 554-process desktop; Python at the same rule took 78ms). ③ A short-lived client that opens and closes between two scans loses its busy time along with the client (the fdinfo counters live on the client). ④ Parallel engines (gfx + compute at once) can sum above wall clock, clamped to 100. ⑤ Non-monotonic counter updates are allowed by the kernel spec and are treated as a zero delta.
    • Pitfall: parsing must exclude drm-engine-capacity-<name>: with several video engines, i915 prints the parallel engine count (e.g. drm-engine-capacity-video: 2), and collecting by has_prefix("drm-engine-") would add that engine count (2) as nanoseconds into busy time; the value side likewise takes the first whitespace-separated field before parse_u64 — engine values carry an ns suffix, side keys carry units (drm-memory-vram: 1024 KiB), and amdgpu prints an extra space after the colon in drm-memory-gtt: ; parsing without taking the first field always fails.
    • Pitfall: /proc directory enumeration includes non-process entries such as cpuinfo / self / irq, so filter to pure numbers before walking fdinfo; amdgpu omits idle engines entirely (source has if (!usage[hw_ip]) continue;), so entries list only nonzero engines and the sum is unaffected; i915 gen<8 returns early without engine keys → parses as None and busy shows "—".
    • SYS4 verification: zero stub changes (pure MoonBit side, reusing the read_text_file / list_dir primitives). On the Linux host moon check is warning-free and moon test 623 all green (syshw_wbtest adds 9: parse_drm_fdinfo's amdgpu / i915 / None shapes, is_drm_engine_key and drm_device_key, drm_clients_total merging (dup fd / cross-process / two devices / missing client-id), drm_busy_percent (first frame / fallback / clamp 100), is_pid_entry, parse_uevent_driver, and a two-tick live fdinfo sample); after deleting the sysmonitor .o under _build, moon build examples/sysmonitor is warning-free. Real-machine verification on i915 / xe / amdgpu is performed by the user (this machine runs the NVIDIA proprietary driver, has no fdinfo usage counters, and cannot exercise this path): ① Intel iGPU utilization compared against intel_gpu_top in the same window ② AMD dGPU sysfs gpu_busy_percent versus fdinfo (sysfs must win) ③ attribution correctness on a dual-card machine (drm-pdev first) ④ driver-name fallback attribution on an older kernel (<6.5) ⑤ whether short-lived GPU clients (benchmarking loops repeatedly starting and stopping) under-report utilization ⑥ the magnitude of the gap against sudo intel_gpu_top ⑦ the scan's effect on the 1Hz duty cycle.
  • Linux GPU model names (SYS5): AMD / Intel cards get their commercial model name from pci.ids — sysfs carries only numeric ids (vendor / device nodes) and the driver binding name, so the commercial name lives in no /sys node and a plain-text database without root is the only source; when it misses, fall back to the device id verbatim. Decision: ① source priority: nvidia-smi hit (N cards, where its name is more specific than pci.ids — smi's "NVIDIA GeForce RTX 3070" vs pci.ids' "GA104 [GeForce RTX 3070 Lite Hash Rate]") > pci.ids lookup by vendor:device > device id text ("0x7480", which at least separates cards); the "(未知)" placeholder from a failed sysfs read becomes empty so the UI omits the model slot. ② Path differences: the hwdata package at /usr/share/hwdata/pci.ids (on many distributions a symlink into misc) and the pciutils package at /usr/share/misc/pci.ids, tried in order; when neither is reachable (database not installed / Flatpak sandbox / non-Linux) the "not found" fallback applies. ③ Caching: the whole pci.ids text is read once when first needed and kept (about 1.4MB, inside the native-stub's 16MB single-file cap), and parse results are cached per PCI address — measured, one full-file search costs about 14ms (moon test debug build; Intel's vendor sits at line 27868 of 38108, so early exit still walks 70% of the file), which cannot be repeated per card per tick at 1Hz; vendor:device is static hardware info so one lookup at the first tick is enough, and when the database is unreachable the cached value is the fallback and the next start retries. ④ UI: the model name is folded into the sensor page's section title (the existing gpu_label path, page_sensors.mbt unchanged); overview cards show compact numbers only and leave the model to the detail page.
    • pci.ids format measured (2024.03.31, 38108 lines): unindented lines are vendors (4 hex id + two spaces + name), one Tab = a device of that vendor, two Tabs = a subsystem (not consumed here), '#' starts a comment; 2378 vendors, 18685 devices, 16258 subsystems; the file has no CRLF, no trailing whitespace, and every id is exactly 4 lowercase hex digits; the 7 blank lines all sit in the header comment area and inside the class section (modern versions do not blank between vendor blocks); the trailing class section ("C 00" onwards) is shaped like vendor lines but its ids are not 4 hex digits, so it cannot match naturally; "ffff Illegal Vendor ID" is the last vendor, has no device lines, and is followed immediately by the class section — looking it up exercises exactly the "left the section, return early" path.
    • Pitfall: empty-id normalisation must not zero-pad: norm_pci_id("") padding "left-pad with zeros to 4 digits" would yield the fake valid id "0000" (caught by the unit test's first run), so an empty string must stay empty; a failed sysfs read leaves vendor / device as the "(未知)" placeholder, and is_hex4 must reject it before the fallback applies.
    • Pitfall: keep the model name verbatim, including internal spaces and brackets (e.g. "Navi 33 [Radeon RX 7700S/7600/7600S/7600M XT/PRO W7600]") — never split on whitespace and rejoin (this version has 0 names with double spaces, but slicing the whole run is the correct approach); slicing indexes by UTF-16 code unit (StringView::exact_view's basis), not by iter2's codepoint index — every structural character is ASCII, so surrogate pairs can only appear inside names; a device entry with an id but no name (never in a real database) is not a hit — keep scanning and fall back to the id.
    • SYS5 verification: zero stub changes (pure MoonBit side, reusing the read_text_file primitive). On the Linux host moon check is warning-free and moon test 628 all green (syshw_wbtest adds 5: norm_pci_id / is_hex4, pci_ids_device_name's indent-level parsing (subsystems skipped / no cross-vendor leak / vendors with no devices / class section / early exit leaving the section / invalid ids), gpu_model_name's four shapes (smi first / database hit / miss and absent entry fall back to id / placeholder id blank), GpuMonitor::gpu_model's persistent cache and smi priority, and a live pci.ids sample (take the first vendor/device pair positively, plus a dead vendor with no device lines negatively)); after deleting the sysmonitor .o under _build, moon build examples/sysmonitor is warning-free; on this machine (Ubuntu 24.04, NVIDIA proprietary driver) three real entries parse correctly (1002:7480 → Navi 33, 8086:3ea0 → WhiskeyLake-U GT2, 10de:2488 → GA104). Real-machine verification on AMD / Intel is performed by the user: ① the sensor page's section title shows the commercial model name for an AMD dGPU (compare with lspci -nn) ② same for an Intel iGPU ③ on a dual-card machine each card shows its own model ④ when not found (a very new card / missing entry) the device id is displayed rather than blank ⑤ the fallback shape on a minimal install (no hwdata / pciutils) and inside a Flatpak sandbox ⑥ path differences on other distributions (Fedora / Arch hwdata, openSUSE, etc.) ⑦ how a long model name wraps or overflows in the section title.
  • sysmonitor UI copy discipline (from the full UI-polish pass): interface text states only "what it is / how to use it" — never data conventions or implementation paths (e.g. /proc paths, "two-sample difference", "milli-degree conversion", or a "nvidia-smi planned later" note); rates, capacities and axis values always switch units dynamically (B→K→M→G) so numbers stay short; the large 24px statistic cards especially must never wrap and overflow.
  • sysmonitor overview card pattern, modeled on Mission Center: icon + title, a spec subtitle (hardware specs such as CPU model / total capacity / mount point live in the card subtitle — not in a window-level subtitle row), a current-value line (usage % · temperature, used / total · swap), and an in-card sparkline (series window + end dot; fixed 0-100 range or peak-adaptive, dual series overlaid for network rx/tx). Cards share width via flex and equal height per row via stretch, following window resizes; each card is self-contained and readable without the rest of the window.
  • xrandr's output format changes across versions (measured while enumerating displays): since 1.5.2 the refresh-rate markers ('*' current / '+' preferred / 'i' interlaced) print '+' as a standalone token placed after the rate it marks (59.95 + 75.00, and xrandr --verbose confirms 59.95 is that +preferred mode), while older versions append it to the rate (59.95+). mon_fold_marker_tokens re-attaches standalone marker tokens to the preceding token, normalising to the attached shape before parsing.
  • Two correctness defects in the process data (EX2, high severity: all negative nice values display 0, and a newly appearing pid pegs CPU% at full scale). ① the nice field of /proc/[pid]/stat is signed (-20..19), but the parser used parse_u64, which returns None for a leading minus → unwrap_or(0) silently turned every negative nice into 0: the process page's nice column reads wrong and ByNice sorting reads wrong too (this host has 142 negative-nice processes, all kworker threads at -20 — an everyday population, not an edge case). Fix: choose the parser by field sign inside the same parse — unsigned fields (utime/stime/starttime/rss_pages) use parse_u64, signed fields (nice/ppid/threads) use the existing parse_i64, with field indices and reading rules unchanged. ② ProcMonitor::tick's previous sample used self.prev.get(pid).unwrap_or(0): for a pid that appeared between samples (a new process, or a child spawned by moon test itself) there is no previous sample, yet treating "previous tick = 0" made the delta cover the process's whole since-boot accumulated ticks, so CPU% jumped straight to the cores×100 clamp (white-box control measured 400 != 0, a single-tick full-scale reading) and only fell back to the truth on the next tick. Fix: the previous sample became optional behind the pure function proc_tick_cpu_pct(prev : Int64?, cur, clk_tck, wall_s, cores), which returns 0 for the tick when prev is missing (the same rule as "no sample before the first frame"); tick now passes self.prev.get(pid). With a previous sample present it dispatches to the existing proc_cpu_pct and behavior is unchanged.
    • Pitfall: both parse_u64 and parse_i64 collapse failures into unwrap_or(0), so None and "genuinely 0" are indistinguishable at the call site — using the wrong parser on a signed field raises nothing and only yields a wrong value, which is why this class of defect survives so long; picking the parser explicitly by field sign beats one generic num().
    • Pitfall: for a delta-based CPU% metric, "no previous sample" must be distinguished from "previous sample is 0". The first is "unknown", the second is "really started at 0"; unwrap_or(0) merges the two, and for a monotonically accumulating counter (ticks / bytes / sectors) that necessarily produces a since-boot full delta, which the clamping then disguises as "full scale" instead of an obvious outlier — hard to attribute by eye.
    • Verification: zero stub changes (pure MoonBit side). moon check --deny-warn warning-free and moon test 644 all green (sysproc_wbtest adds 3: parse_proc_pid_stat with a real negative-nice sample (this host's /proc/4/stat kworker/R-rcu_gp, nice=-20 asserted field by field, plus nice=-1 / 19 / a bare minus fallback), sort_procs' ByNice ordering with negatives (three tiers -20/0/19 no longer mixed), and proc_tick_cpu_pct giving 0 on the tick with no previous sample, compared against with-sample full load / half load / clamp / invalid wall clock / negative delta); after deleting the sysmonitor .o under _build, moon build examples/sysmonitor is warning-free; white-box control — stashing the pre-fix code and running moon test -p sysmonitor makes the negative-nice case fail precisely (0 != -20) and restoring the old proc_tick_cpu_pct semantics makes the tick case fail precisely (400 != 0); with the fix back, 24/24 pass; the real-/proc end-to-end case (forcing short-lived processes to fill the 2s window) measures the new pid's tick CPU% at 0.

System capability data layer (volume / brightness / browser history / VS Code history / app lookup) ​

This batch adds five modules (yue/volume, brightness, browser_history, vscode_history, appfind), measured on Ubuntu 24.04 + XFCE + X11, PipeWire audio stack, Chrome with multiple profiles, and VS Code (the official OSS distribution). What follows is what real-machine testing produced:

  • Volume verified on both backends (wpctl first, pactl as fallback): wpctl get-volume @DEFAULT_SINK@ prints Volume: 0.42 [MUTED], and the sink display name comes from wpctl status (hierarchical text, the first entry under Audio → Sinks); the pactl path needs three queries (get-default-sink + get-sink-volume + get-sink-mute). pactl's human-readable output is localized per locale — in a Chinese locale it prints 「音量:」 rather than "Volume:" — so parsing written against English text fails on a localized machine; the child environment must pin LC_ALL=C (vol_env_blob also passes PATH, XDG_RUNTIME_DIR and HOME: PipeWire/Pulse socket discovery depends on XDG_RUNTIME_DIR, and without it the tool cannot reach the service and exits non-zero). Sample assertions use text captured from the real machine (volume_wbtest; the pactl sample is LC_ALL=C output — localized text in it counts as a parse failure).
  • Browser history reads the Chrome-family SQLite files directly (moonsqlitefile pure parsing: no SQLite FFI, no locking, no copying the source database): ① the time base is microseconds since 1601-01-01 UTC (same origin as a Windows FILETIME), Unix ms = µs/1000 − 11644473600000, and the measured sample 13435769563030519 → 2026-10-06T14:12:43.030Z; ② although urls.id is INTEGER PRIMARY KEY, on disk the column stores Null (a rowid alias is not written out), so reading by column position must not assume a type; ③ rows with a nonzero hidden column are redirects and other hidden entries and must be filtered; ④ while Chrome runs, new writes land in the -wal file, and the current implementation passes only the main database to open_database (no WAL merge — moonsqlitefile also offers open_wal_database); ⑤ the bytes read during probing are handed straight to the open call, so one database is never read twice (History databases commonly reach tens of MB).
  • VS Code local history: ① the <hash> directory name in User/History/<hash>/entries.json is a base-31 rolling hash over the resource URI (seed 149417, wrapping into Int32), printed as a signed lowercase hex value — negative directory names really exist (measured -10b5e510), and recomputing three cases (positive / negative / vscode-userdata scheme) all hit; ② some entries.json records carry no source field (measured), so parsing must not assume it; ③ storage.json's windowsState has lastActiveWindow first plus an openedWindows array, whose window objects come in two shapes — folder (a plain directory) or workspaceUri (a .code-workspace) — and the URIs are percent-encoded (space %20, Chinese %E4%B8%AD) and must be decoded; ④ the end-to-end case only runs where VS Code user data exists (gated by vsc_supported), and all pure logic is asserted white-box against in-memory samples.
  • Brightness: setting goes to logind's SetBrightness (system bus, session/auto objects, through @traybus), enumeration and the current value read /sys/class/backlight sysfs read-only — when logind is offline set reports Unsupported while devices/get keep working; keyboard backlight lives in the leds subsystem (device names shaped like inputN::kbd_backlight). sysfs value parsing tolerates surrounding whitespace and a trailing newline.
  • App lookup (appfind): desktop entries have no directory-enumeration primitive, so appfind_installed_with takes the listing function from the caller, and the default convenience entry point was dropped because it always returned nothing and its name promised otherwise; appfind_executable decides "hit" by whole-file readability (read_binary_file) — slurping a large executable just to test existence is an accepted trade-off (fixing it needs a new native stat primitive, so the shim stays untouched for now).
  • Default-application lookup (defaultapps, closing an omission in the checklist): ① queries go through xdg-mime query filetype/default, and association enumeration reads the three mimeapps.list layers directly (~/.config → ~/.local/share/applications → /usr/share/applications, i.e. the XDG mimeapps precedence), with a thin read_text_file parser for every path that does not depend on a command; ② repeated same-name keys in mimeapps.list are a candidate list, the opposite of the "first key wins" rule for .desktop INI — several lines for one MIME under [Default Applications] register candidates in last-wins order, so da_parse_mimeapps does not dedup same keys and da_default_for's fallback path takes the section's first value of that MIME's last candidate; ③ xdg-mime query default for an unregistered type returns an empty string with exit code 0 (not a nonzero exit), so "no default" can only be judged from empty output, never from the exit code; ④ setting is a read-modify-write of the [Default Applications] section in ~/.config/mimeapps.list: replace the line if present, append inside the section if not, add the section at the end if that is missing, writing through write_text_file (same-package FFI, as in autostart); ⑤ libyue's native Notification has no progress interface (the vendored notification.h exposes only title/body/info/silent/image/actions, and the shim has no matching FFI), and the Linux standard for notification progress is the freedesktop hints value field (reachable over the traybus D-Bus) — this batch confirmed the native notification surface is sufficient and built no progress bar; if it becomes necessary it goes through D-Bus Notify + a{sv} hints.
  • Real-machine entry point for browser history / VS Code history: the NoahLiu/gui-examples/systemprobe example (charts and system capabilities on one board) — moon run NoahLiu/gui-examples/systemprobe and press "读取系统能力" to see all five modules' live results.

Media and desktop-control command routes (pr_run as the single wrapper, with an MPRIS bus fallback) ​

Every command-class module added in this batch goes through pr_run in yue/procrun.mbt (spawn → timed reclaim → stdout/stderr captured through a temp file) rather than spawning on its own; the environment pins the C locale, exit code 0 normalises to Ok and nonzero to Err carrying stderr, so callers never inspect codes themselves. Meaning of a missing command: on Unix the shell surfaces it as exit code 127 and each module's own *_supported() probe normalises it into Unsupported (pp_profiles / prt_default / nl_supported / win_list each probe their own command), so callers classify by the error value instead of guessing from text. The command routes and their degradation chains:

CapabilityPrimary routeFallback routeProbe (read-only, no side effects)
Night colour temperature nightlightredshift -P -O <K> (2500K..6500K, one-shot set)xrandr --output <output> --gamma R:G:B (red nudged 0.85..1.00, blue decayed 1.00..2.20, green fixed at 1.00)redshift -V exits 0; otherwise xrandr --query works (no connected output → Unsupported, headless)
Display configuration monitorxrandr --query, fully parsed (refresh rates as integer centihertz)none (Unavailable → Unsupported)same single read-only --query probe
WallpaperXFCE xfconf-query -c xfce4-desktop -p /backdrop/.../last-image (read / write)GNOME gsettings get/set org.gnome.desktop.background picture-uri; KDE qdbus6/qdbus org.kde.plasmashell evaluateScript (not verified on real hardware)desktop detection reuses @traybus.detect_desktop (XDG_CURRENT_DESKTOP); an unknown desktop → UnknownDesktop carrying the original text
Window management windowctlwmctrl -l list / -i -a <id> / -ic <id>none (non-Linux or missing wmctrl → Unsupported)a missing command surfaces as 127 → Unsupported
Clipboard watch clipboard_watchxclip -selection clipboard -o (read-only polling)nonesame; a read failure (not installed / no text in the selection) silently skips that tick
Power profile powerprofilepowerprofilesctl list / get / setnonemissing command → PpUnsupported; list output keeps the backend order (a recommended profile listed first stays first)
Printer printerlpstat -p / -d / -o <printer> + lp [-d printer] -n copies filenonelpstat missing (CUPS not installed) → PrtUnsupported
Disk volumes diskudisks2 D-Bus (system bus)lsblk --json (enumeration only; mount/unmount unavailable)NameHasOwner or a reachable Manager call

MPRIS (media control) is the only member of this family with an inverted route — bus first, command as backstop. Interface notes: player instances are filtered out of the session bus org.freedesktop.DBus.ListNames reply by the org.mpris.MediaPlayer2. prefix (proxy instances such as playerctld included), the object path is always /org/mpris/MediaPlayer2, the interface org.mpris.MediaPlayer2.Player; Play/Pause/PlayPause/Next/Previous/Stop are all no-argument methods with empty replies, and state is read from org.freedesktop.DBus.Properties.PlaybackStatus plus the Metadata string fields title/artist/album. playerctl is only consulted when the bus call fails (available when playerctl --version exits 0); the fallback names the instance with -p <player> and reads with metadata --format <US>title<US>artist<US>album<US> (delimited by US; a title containing the separator is split on the first segment — an accepted trade-off). Probe rule: media_supported trusts ListNames, and playerctl is only consulted when the bus is unreachable. Playback control is setting-class and is never triggered inside a probe.

Audio and video (FRAME → draw_image render route) ​

  • Audio: integrates CorvusCinereus/miniaudio@0.4.0 (a MoonBit wrapper of miniaudio; small artefacts, ABI surface = engine + clip). Link verification: inside the same prebuild.py custody scheme, after moon add the whole-repo moon build passes and coexists with the subproc/moonsqlitefile native stubs without conflict; the package itself declares no supported_targets in its moonbuild (a third-party package warning, not a problem of this project). Headless real-machine smoke (this XFCE + PipeWire host): the engine builds, /usr/share/sounds/alsa/Front_Center.wav loads, and volume reads/writes hold around 0.5; play/stop are setting-class and were not executed in tests.
  • Video frame render route: libyue's Image has no raw-pixel constructor (checked against vendor/libyue/include/nativeui/gfx/image.h: the constructors are Image(NativeImage, scale_factor) / Image(FilePath) / Image(Buffer, scale_factor), and Buffer only accepts PNG/JPEG), and Canvas/Painter expose no pixel-write interface. So frames travel "frame-source callback → pngr_encode_rgba into an in-memory PNG → Image::new_from_png decode → draw_image scaled to fill". The PNG encoder is pure MoonBit (signature/IHDR/IDAT/IEND + CRC32 + Adler32 + zlib stored blocks, with the CR table and running sums in Int64 — MoonBit's >> on Int is an arithmetic shift while the reflection table needs a logical shift, and >> on a non-negative Int64 is exactly the 32-bit logical shift).
  • PTREF: in the headless test environment (moon test), Image::new_from_png / new_from_file always return an empty image — without a GUI context (GTK not initialized) libyue's decode never succeeds; measured on this machine against real PNGs (/usr/share/icons/hicolor/48x48/apps/*.png), is_empty=true as well. The PNG encoder's wbtests are therefore all byte-level self-checks (magic / IHDR fields / IDAT stored block count / total length consistent / adler tail), and real-machine decode verification of Image moved to the systemprobe example.
  • Video component and decoder stay decoupled: a VideoFrameSource callback (frame number → RGBA) is injected, and moonav1 (a pure-MoonBit AV1 decoder on mooncakes) is the next frame-source implementation to wire; H.264 needs ffmpeg, for which no MoonBit binding exists yet, so it waits.
  • Frame advance uses @yue.set_timer (no cancel handle: the handle holds a Ref switch, and after stop the callback returns false to unregister — the same EffAnim pattern as charts_effectscatter); set_progress and the media poller follow the same shape.

Media status watch (MPRIS polling deltas) and notification progress (a body text bar) ​

  • Environment: Ubuntu 24.04 + XFCE + PipeWire (pipewire-pulse). MPRIS's org.freedesktop.DBus.PropertiesChanged signal is subscribable, but subscribing needs a permanent read loop, which does not fit this library's model of "synchronous request-reply socket transactions + set_timer" (the generic D-Bus layer gdbus_call is a round trip with no async reader thread). So media_watch_status polls deltas: it reads PlaybackStatus + Metadata every 1000ms by default and fires on_change when play state / title / artist / album changes; the first round only establishes a baseline; rounds that fail to query (bus error / playerctld gone) are silently skipped without affecting later rounds.
  • Target resolution: with an empty player argument, each round retries resolving the first player instance (skipping the round when media_players fails) and locks on once an instance appears; switching players while playing does not follow automatically, which the usage doc states.
  • Stop semantics: set_timer has no cancel handle (a callback can only return false), so the handle holds a Ref[Bool] and media_watch_stop sets false, unregistering on the next frame with no recovery — the same EffAnim pattern as charts_effectscatter. Accepted trade-off: an exception thrown by on_change kills that timer (no try/catch, which is MoonBit's unused_try trade-off) — documented.
  • Real-machine smoke: this host has no MPRIS player, so the watch was started and stopped immediately: on_change fired 0 times and nothing raised (the smoke case in media_wbtest.mbt; 12/12 pass).
  • Notification progress: the freedesktop notification protocol has no progress field (Windows / macOS present progress differently again), so the cross-platform-consistent answer is a character bar in the body plus a percentage. notification_progress_text clamps percent to [0,100], treats NaN as 0 via percent != percent, prints only the percentage when width<=0, and rounds the cell count (add 0.5 then to_int); Notification::set_progress composes body = original text + newline + progress line.

Firefox places.sqlite (read-only, no WAL merge) ​

  • places.sqlite is usually in WAL mode: the newest visits are still in places.sqlite-wal / -shm while the main file is an older snapshot. The current implementation hands only the main database bytes to moonsqlitefile's open_database, so there is no WAL merge and records checkpointed into the main file are unreadable — a known boundary the doc must state: "readings are a snapshot of the main file as of the moment it was read". The missing piece (not done): moonsqlitefile also offers open_wal_database, which needs the -wal file handed to the same Database.
  • Tables and columns: moz_places is the only table needed, taking url (TEXT; keyed by id but row parsing does not depend on it), title (TEXT, may be NULL), last_visit_date (INTEGER, Unix-epoch microseconds — not the same base as Chrome's 1601 microseconds, so ffx_time_to_unix_us converts to ms as us/1000) and hidden (INTEGER; nonzero rows are bookmarks/starred entries and must be filtered, same rule as Chrome).
  • Row parsing reads by column position but makes no type assumptions: url may be a Blob (SQLite's dynamic typing means title/url can both be stored as BLOB), so every BhValue branch needs a fallback (ffx_parse_places_row returns None for a non-text value and skips the row).
  • Profile location: ~/.mozilla/firefox/profiles.ini supplies the Path= directory names (both [Install*] and [Profile*] sections can carry Path, so collect all of them), falling back to fixed candidate names when it is missing (default / default-release / default-esr / default-nightly / dev-edition-default) — profile directories with a random prefix (e.g. a1b2c3d4.default-release) are only reachable through profiles.ini, a hard boundary while this package has no directory enumeration (fsx_list_dir landing would close it by scanning).
  • The entry type reuses browser_history's HistoryItem (its fields match Chrome history exactly), and ordering / truncation / column extraction reuse bh_sort_history / bh_take / bh_value_text / bh_value_int rather than duplicating a second set.
  • Multi-profile aggregation follows the Chrome rule: the same url in different profiles appears once per profile, undeduplicated — deduplication would hide the fact that two accounts sit in two profiles.

System information (si_machine / memory / uptime) and system-level security boundaries ​

  • /sys/class/dmi/id/product_serial is root-readable on most distributions; when it cannot be read return Err with the path rather than papering over with an empty string; /proc/meminfo units are always kB, MemAvailable exists only on kernels ≥3.14 and falls back to MemFree; uptime is a float of seconds and is returned directly.
  • Sensor reads (iio-sensor-proxy) must be wrapped in ClaimLight/ReleaseLight, because an unclaimed sensor keeps reporting stale values; AccelerometerOrientation is a string, not an enum integer.

Display protocols ​

  • X11 ✅ mainline; Wayland unsupported — verify GUI behavior in an X11 session (tray / shortcuts are session-guarded).
  • User idle seconds come from the X Screen Saver Extension (XSS) XScreenSaverQueryInfo, dlopen'd at runtime ("libXss.so.1"/"libXss.so") instead of build-time linking: keeps libxss-dev out of the distribution chain (prebuilt static libs ship via mooncakes; an extra dynamic dependency may be absent on consumer machines). XScreenSaverInfo is minimal — the shim hand-mirrors the struct (idle-milliseconds field at offset 24); a failed load takes the same path as "no X" and returns Unsupported.
  • In an XWayland session input events never reach the X server, so the XSS idle reads absurdly high (hours right after user activity): an explicit WAYLAND_DISPLAY probe returns Unsupported — better no number than a wrong one; env -u DISPLAY (no X session) is likewise Unsupported; neither crashes. Measured cross-check (Ubuntu 24.04 XFCE X11): idle_seconds vs xprintidle same-instant double read differs by 17ms (acceptance check ±2s); after an xdotool-simulated mouse move, 0.317s vs xprintidle 320ms.
  • Screen locking does not count as input: the XSS idle keeps growing while the screen is locked (lockers report no input). "User idle" and "screen locked" are orthogonal dimensions — do not approximate lock detection with an idle threshold (lock events come from logind, landing in a later batch).
  • Idle-reading demo interaction: a click is itself an input event and resets the XSS idle counter — a "click the button to read the current idle" demo contradicts itself (it always reads 0.x seconds; command-line probes and xdotool-based external reads never exposed this). The correct shape is a switch that starts a periodic refresh (one read per second), with the timer stopping itself on the next tick once the switch turns off (libyue timers have no cancel id — the callback checks the switch state and suicides).

GTK specifics ​

  • A Table inside a Notebook tab segfaults during size measurement (negative allocation): keep it in a plain container or a standalone window.
  • Content widgets (Group / Scroll) extend View, not Container: attach content via SetContentView; AddChild is rejected by the type check.
  • Upstream NUContainer defects (patch patch_linux_container_events): ① the event window raises on map, stealing hits from native child widgets (tabs unclickable, wheel dead) → gdk_window_show_unraised; ② container preferred sizes hardcode 0 and Scroll's size_request is 0×0 → width follows the viewport, height resolves to the content's natural yoga height. Do not report yoga's dynamic natural size to GTK: allocation pollutes yoga state and requisition negotiation oscillates without converging.
  • The visibility guard at the top of UpdateChildBounds misses GTK's first size-allocate (it happens before map): layout must run unconditionally.
  • Slider::SetValue sets the ignore flag even for identical values, swallowing the user's first callback: set it only when the value actually changes.
  • ProgressBar::SetValue means 0..100 on both Linux and Windows; the yue layer is unified on 0..1, so the conversion branch must cover both platforms.
  • View::GetBoundsInScreen stacks coordinates wrongly under Scroll / nested containers (patch patch_linux_view_bounds_in_screen): GTK screen coordinates must be "client-area origin (gdk_window_get_origin) + offset within the client area (gtk_widget_translate_coordinates)"; gtk_window_get_position includes the title-bar decoration — mixing it with translate is off by exactly one decoration size.
  • Table checkbox-column indicators scale with row height (XFCE theme): set indicator-size=16 explicitly on checkbox columns and cap the renderer height at 20.
  • Drag-out data must use the Data(std::vector<base::FilePath>) constructor (the string constructor silently degrades to Text); absolutize relative paths first (g_filename_to_uri rejects relative paths).
  • The drag preview hotspot is hardcoded to (0,0) upstream; the patch centers the image on the cursor (patch_linux_drag_icon_hotspot).
  • Whether a drop is accepted is decided by drag-motion (handle_drag_update); handle_drag_enter is only an entry notice. Register the Image data type too — dragging in from image viewers / browsers yields image content, not file paths.
  • libyue's Entry::SetText swallows on_text_change: the GTK side guards with an is-editing object-data flag that filters the changed signal during programmatic sets (loop prevention), so the visible text changes but consumers get no callback — input_t's clear ✕ therefore "cleared the text but never refreshed the filtered list". Fix: the clear handler explicitly invokes on_input("") once. Any path that programmatically changes Entry / TextEdit text and then relies on the callback must invoke it manually.
  • Drag-out initiation: calling gtk_drag_begin synchronously desyncs GTK's drag state machine (nested gtk_main never exits, drag works once), so it must be deferred until the event queue drains, initiated with the press event, and the drag_context backfilled; drag-failed needs a defensive cleanup (fork mbt.12).
  • Creating a Browser page (WebKitGTK) aborts the process immediately: Could not create GBM EGL display: EGL_NOT_INITIALIZED. Aborting... — WebKitGTK 2.5x's DRMDeviceManager initializes the main DRM device when a WebView is created and RELEASE_ASSERTs to death when the GBM EGL display cannot be obtained. On the NVIDIA proprietary driver without libnvidia-egl-gbm, GLVND only has the X11 backend (10_nvidia.json has no GBM), so neither card1 nor renderD128 yields a display (measured: eglGetPlatformDisplay(GBM) returns NULL; note that in-process probe results don't match WebKit's actual path — an in-process probe on renderD128 initialized successfully while WebKit still aborted, so don't gate on probe results). showcase mounts a Browser page on its first screen, so it always crashed. Library-side fix: the shim's app_init (Linux) sets WEBKIT_DISABLE_DMABUF_RENDERER=1 (overwrite=0, an explicit user setting wins), pushing WebKit onto the legacy rendering path — no more crash, full Browser functionality (only web content loses one layer of GPU acceleration; the cairo self-drawn UI is untouched). Verified: bare moon run examples/showcase opens its window, stays alive, zero aborts. Root fix at the system level: install libnvidia-egl-gbm (NVIDIA's GBM EGL backend), after which WEBKIT_DISABLE_DMABUF_RENDERER=0 restores hardware rendering. Engineering note: after changing the shim, moon does not necessarily relink (the exe isn't in its dependency graph) — run moon clean or delete the exe to force a relink and confirm with nm exe | grep <new symbol>.
  • Follow-up (measured 2026-10-14, answering "is the Linux webview integrated at all?"): ① it is — this machine has pkg-config --modversion webkit2gtk-4.1 = 2.52.6, prebuild compiles the real branch (nm gui/build/libyue_web.a shows 46 undefined webkit_* references, impossible for the stub branch), and 2026-10-12 left a set of real-hardware readings (Chinese text correct on protocol pages, back-navigation genuinely returns, in-page scrolling and link clicks work, the IME yields while the webview has focus and comes back afterwards). ② The protection above, however, did not make it into the new stack: grep WEBKIT_DISABLE_DMABUF_RENDERER across the repo returns nothing (it lived in the deleted shim's app_init). ③ It does not reproduce on the current machine: the driver now ships a GBM backend (/usr/lib/x86_64-linux-gnu/gbm/nvidia-drm_gbm.so present, RTX 3070), and a C probe that creates no window — with the variable unset — survived webkit_web_view_new() plus load_uri("about:blank") plus about two seconds of main-loop iterations (PROBE ALIVE, exit 0), so the setenv was deliberately not re-added (it would only cost web content its GPU compositing). ④ Acceptance check left for whoever comes next: after a machine or driver-stack change, if the browser page dies "at creation" again, this lost protection is the first suspect, and reproducing it needs no GUI — construct a WebView in a console process without mapping a window. The system-level fix remains installing libnvidia-egl-gbm.
  • The overlay-scrollbar default needs a process-environment guard: the GTK_OVERLAY_SCROLLING environment variable outranks gsettings inside GtkSettings (distro scripts / users exporting =0 to force classic scrollbars is a common practice), and GTK re-reads the process environment every time a ScrolledWindow is created — writing the gtk-overlay-scrolling GtkSettings property during theme init alone cannot beat the env-var path. app_init (Linux) calls setenv("GTK_OVERLAY_SCROLLING","1",1) before gtk_init (overwrite=1: the library default and set_overlay_scrollbar(true) win over the global preference; a caller turning a single scroll area off explicitly still goes through the per-widget API), so machines running such scripts are immune; the later GtkSettings write stays as a fallback.
  • Scroll range stays at the skeleton height after lazy mounting (the second root cause of unscrollable pages, proven by a probe bisection matrix): on GTK the ScrolledWindow's scroll range does not come from content measurement — nativeui's nu_container always reports GTK preferred 0 (it never uses the GTK layout system), so the viewport's adjustment upper depends entirely on a one-time snapshot of the content's natural height taken at set_content time by the fork's Scroll::PlatformSetContentView (written as a size request); worse, re-setting the same content makes that function read the old snapshot (not -1) and skip the recompute, and remove-then-add writes the stale value back. Whenever content arrives after set_content (lazy mounting) or grows noticeably, the scroll range stays frozen at the skeleton height — "the content is clearly taller yet nothing scrolls and the wheel is silent". All 111 showcase sections are lazily mounted, and the long icon-wall section (9 rows) exposed it first, though the defect is page-wide. Four-column probe bisection settled it: always-mounted text/icon walls max=428/518 (scrollable), lazily attached walls max=0 (the holder measured 1120/1210 and GetPreferredSize 1164/1254, both correct) — independent of content type, purely mount timing; re-running set_content does nothing (the old snapshot short-circuits) while an explicit set_content_size with a large value takes effect immediately (the adjustment chain is sound; the frozen size-request snapshot is what fails). Fix: the shim gains yue_mbt_scroll_refresh_content_size (on Linux it writes gtk_widget_set_size_request(width unchanged, height = Container::GetPreferredSize(), i.e. yoga run on the spot); a no-op on win/mac — Windows' ScrollImpl::Layout already re-queries the content's natural size each round and macOS derives the range from the documentView frame, so neither has a snapshot problem), plus Scroll::refresh_content_size() in the yue layer; mount sites call it once mounting completes (showcase's section_body is wired). End-to-end probe: after page switch / section switch / a second show-hide round / switching back, max_y recovers exactly to content height − viewport (598=1290−692) and stays correct when the viewport changes. This complements the "cross-axis scroll-range bug" (a read-side defect): this one is the write side, and both fixes live in the shim. Regression tool: moon run examples/probe-iconscroll (four-timing end-to-end sampling; a long section's max_y must stay >0). The same snapshot mechanism also bites non-lazy cases: set_text growing content after mount (systemprobe's system-capability report, dozens of text lines) and mount_into attaching a new subtree after mount (the video player mounted into a placeholder container) leave the scroll range unupdated as well — "after the result loads the bottom is clipped and nothing scrolls, after the player mounts you cannot scroll to the end". The mounting side calling refresh_content_size() after the change restores it; systemprobe's multi-page refactor injects the refresh closure into the page-body builder and wires report set_text, player mounting and command-preview text (measured 2026-10).
  • A report label does not grow after set_text (clipping — the second half beyond the scroll-range snapshot, 2026-10 user real-machine report + probe control proven): on systemprobe's system-capabilities page, clicking "读取系统能力" showed only the first 1-2 lines of a dozens-of-lines report (user screenshot: the volume section kept only its sink line; "音量: 45% 静音: 否" and everything after was invisible). Root cause: a GTK label's (nu_label) yoga height is measured once at mount (from the placeholder text); Label::SetText only changes the pango text and does not mark yoga dirty — the new text is clipped to the old height. refresh_content_size() only writes the container's existing preferred height to the GTK viewport, so it cannot save the label's own height (it fixes the scroll range, the first half above). Probe control (same page-shell structure: scroll → vbox → long-placeholder label): set_text + refresh alone rendered only line 1 of a 16-line report; adding set_visible(false) → set_visible(true) before refresh rendered all 16 lines. The toggle works because the shim's yue_mbt_view_set_visible performs a root-level Layout() recalculation (same code on all three platforms, no platform branches), forcing yoga to re-measure the label; both calls happen within one tick, so there is no visible flicker. Fix: systemprobe's shared layer gains report_set(label, text, refresh) (set_text + visibility toggle + refresh), and every report write site on the system-capabilities / command-preview / video pages routes through it. Verification: real-machine click on "读取系统能力" rendered the full report (volume 45% / brightness / browser history / downloads / VS Code / binary lookup — six sections), 62979 differing pixels before and after the click; moon check zero warnings + moon test 657 all green. Lesson: a GTK label's "growing" and its "scroll range" are two separate things — the former needs a visibility toggle (or an equivalent relayout) to force a yoga re-measure, the latter is what refresh_content_size is for; the toggle cures half, and the other half once content exceeds the viewport is the next entry.
  • All three paths to a GTK label's natural height are untrustworthy (deepening the "visibility toggle fixes it" entry above; only exposed when dynamic content exceeds the viewport, 2026-10 user feedback + probe + D-Bus double corroboration): the earlier fix (visibility toggle forcing a yoga re-measure) cured only half — while the content fits the viewport (a 16-line report < viewport) everything looks fine, but once it exceeds the viewport nothing scrolls and the label is still clipped (40 lines rendered 19), which the user reported as "the report updated and refresh ran, yet there is no scrollbar". Three layers of root cause: ① a GTK label's natural height is measured by yoga's MeasureLabel → AttributedText::GetBoundsFor, whose pango_layout_set_height(text_, size.height() * PANGO_SCALE) does not guard NaN — when yoga passes an undefined height (GetPreferredSize's NaN measurement), NaN→int yields garbage and pango measures the whole layout as one line (a temporary shim print measured preferred_h=92, i.e. button plus one placeholder line); ② the viewport's definite height (382) flows down holder→vbox and MeasureLabel's AT_MOST mode truncates the text to 341 (40 lines allocated 19) — the content can never exceed the viewport; ③ refresh_content_size writes ①'s wrong value (92) into the viewport, the adjustment upper never moves and get_max_scroll_position_y stays 0. Mechanism corroboration: in the same probe, set_content_size(600,1000) gave max=618 (=1000−382) on the next tick, proving the adjustment chain itself works and the value refresh writes is the culprit. Fix (pure MoonBit side, no shim/fork changes): yue gains measure_text_height(text, role?, width?) (an AttributedText probe in the role's font measured at a large width and height, avoiding both the NaN and AT_MOST traps); report_set becomes set_text → measure natural height → set_style(label, "height", h+2) explicit height → visibility toggle (root relayout) → refresh. With an explicit height yoga never calls the measure function, so both traps are avoided at once. Verification: probe (same page-shell structure, 40-line report) before the fix label_h=341/max=0; after, label_h=699 (full text height), body_h=773, max=391 (=773−382), five wheel clicks give pos=263, and a screenshot confirms all 40 lines render and scroll correctly. Boundary: the explicit height is not recomputed on window resize (width changes → wrapping changes → stale height); acceptable for a demo page, and a later on_size_changed hook could re-set it. Lesson: all three paths to a GTK label's natural height are untrustworthy — a yoga re-measure gives one line for an undefined height and a truncated value for the viewport's definite height, and mounting measures the placeholder text; dynamically growing text must always go "measure with an AttributedText in the same font + set an explicit height", never leaving the natural height to any automatic path.
  • Cross-axis scroll-range bug (the root cause of unscrollable pages, verified bit-for-bit with probes): the fork's Linux ScrollImpl::GetMaximumScrollPosition uses the viewport width as the vertical page_size — measured max_y = content natural height − viewport width (window 900×600: pure drawn-rows content 1064 → maxy 164, charts+rows 1088 → 188, pure labels 1160 → 260, all exact hits), with the horizontal axis wrong symmetrically. Consequences: programmatic scroll range collapses to a fraction of the real one, and the floating thumb never appears (the library hides it at maxy ≤ 0.5). Initially-visible and shown-after-hidden are equally wrong (briefly misdiagnosed as a hidden-mount measurement issue, disproved by an always-visible control group reading the same value; the queue_resize/update_layout remeasure attempts had no effect — GTK's measurement was correct all along, nativeui's reading is what is wrong). Fix: on Linux the shim's yue_mbt_scroll_get_max_position_x/y now reads upper − page_size straight from gtk_scrolled_window_get_h/vadjustment (other platforms still pass through to nativeui); after the fix 1160−600=560 is exact, and sysmonitor's right pane scrolls by wheel (5871 differing pixels before and after).
  • A hover group's (hover_group) verdict cannot rely on container enter/leave: GTK pointer events do not bubble — they are delivered to the deepest hit GdkWindow and end there — and child containers' (NUContainer) / native widgets' (Entry etc.) event windows monopolize pointer events, so ancestor containers never see enter (sysmonitor's cards receive enter only because their children are window-less labels; once a child is a container or a native widget the stream breaks). True "event pass-through" would need either upstream fork changes (each view broadcasting events up the ancestor chain) or platform-wide hooks (XI2/WH_MOUSE) — both invasive and costly to maintain, so not taken. The workable mechanism is pointer-position polling: all groups share one global 100ms timer (a set_timeout chain that stops entirely when no groups exist), one pointer query plus one screen-rectangle compare per group per tick, all microsecond-level; cursor_screen_x/y and get_bounds_in_screen share screen-root coordinates and agree under multi-monitor tiling including negatives (verified bit-for-bit with a probe). enter serves only as a hit accelerator; leaving is entirely the poll's job. A visible background must be self-drawn in on_draw (set_background_color and the backgroundColor style are both unreliable for draw-less containers — theme CSS and child widget windows suppress them; paint the hover/idle backgrounds directly in the group container's draw, and transparent child regions show through naturally). Measured: hovering a child widget (an input) turns the whole card to fill_hover (239,241,243) exactly, and leaving restores it.
  • The self-drawn floating scrollbar (overlay_scroll) was first demoted to an explicitly opted-in component because of the cross-axis bug, then picked back up for Windows after real-machine acceptance: it was once the declarative scroll() default form (to unify the look across platforms against Windows' classic bars), but the thumb depends on the maxy/on_size_changed chain and turned unstable once the cross-axis bug stacked on top, so it fell back to the platform's native scrollbars; after the cross-axis fix the Windows form was restored on its own after real-machine acceptance — on Windows scroll() with overlay=true and no explicit policy wraps a host container and routes through attach_overlay_thumb, while non-Windows and explicit-policy paths keep the native bar (overlay=true requests the overlay style: guaranteed by the environment variable on Linux, with no overlay counterpart on Windows); the overlay_scroll component and Windows page-level scrolling share this path.
  • Reading the system accent color (GTK3 has no accent API; yue_mbt_system_accent steps through three): ① GNOME 47+'s GSettings org.gnome.desktop.interface/accent-color — when the schema exists but the key does not (e.g. Ubuntu 24.04's gsettings-desktop-schemas), g_settings_get_string aborts instead of returning empty, so probe with g_settings_schema_has_key first (abort reproduced with a probe on this machine); ② @define-color theme_selected_bg_color in the current theme's CSS: GTK themes express their primary colour as the selection background, searched in ~/.themes → $XDG_DATA_HOME/themes → $XDG_DATA_DIRS/themes → /usr/share/themes under gtk-3.0/{gtk,gtk-contained,gtk-dark}.css, with the dark preference deciding which file is parsed first (Orchis themes define the light accent in gtk.css and the dark variant in gtk-dark.css); ③ empty when neither hits. Verified with a probe (Ubuntu 24.04 + XFCE + Orchis-Teal-Light-Compact): returns #009688, matching the theme CSS bit for bit; XFCE has no accent setting, so step ② is what captures the theme's primary colour.
  • Container-level hover highlighting (switching the background in on_mouse_enter/leave) must flicker whenever a child widget is involved: GTK dispatches enter/leave by native window boundary, so entering a child window counts as leaving the parent window; libyue compensates (responder_gtk.cc OnMouseEvent: leave is deferred one tick and a child's enter cancels the pending one, GTK only) but has two holes — ① when a child widget has no mouse handler attached at all, PlatformInstallMouseMoveEvents never runs, so there is no enter mask and no enter can ever be emitted to cancel the pending leave, which then fires as scheduled (label and icon bands inside a card); ② an empty container (a self-drawn sparkline canvas, for instance) does not satisfy "defer leave only for Containers with children", so the parent leave triggers immediately. Measured on sysmonitor's left resource cards: every time the pointer crosses a child widget inside a card, the hover background goes out once. Workaround: a self-drawn container's hover feedback should use cursor (hand pointer) plus a persistent selected state (outline / light background) and not depend on enter/leave; if a hover-style effect is genuinely required, draw the whole card as a single self-drawn canvas (as table rows do) or change the library's event dispatch. Related: hover cursors also apply per native window, so setting one on a container does not cover children with their own windows (hbox rows / self-drawn canvases) — set it on each child (NUSetCursor is a safe no-op for window-less widgets; one nu::Cursor can be shared by many views through scoped_refptr, so reuse a single instance). Pass-through generalised: the cursor_group component shares hover_group's global poll tick — while the pointer is inside the group bounds, the shim's yue_mbt_view_set_cursor_deep recursively SetCursors the group root and all its descendants, and leaving restores Default; what gets restored is the default cursor, not each child's original one (the library offers no read-back, so do not wrap a group whose children must keep their own cursors).
  • Changing layout styles at run time triggers no relayout (the root cause of an unmovable splitter, characterised across 8 probe scenarios): on GTK, after mounting, changing any layout property through SetStyleProperty (width / flexbasis measured) never lands — a root update_layout call, a leaf call, a bare set_style and natural layout cycles (bounds read 600ms later) are all ineffective; style values present at initial mount do apply (the first full size-allocate chain reads them). On Windows, run-time flexbasis does work (verified with the 5f272b4 synthetic drag), so this is a platform difference and cross-platform components must not assume styles are writable at run time. Found along the way: inside a Scroll even the initial flexbasis does not land (the first pane drops to minwidth) while width is fine. Fix: the shim adds yue_mbt_view_set_bounds (a straight pass-through to View::SetBounds) plus a generic set_view_bounds on the MoonBit side; splitter dragging and the initial ratio now "write the three geometries directly with set_bounds + write the first pane's width/height styles as well" — the direct write takes effect immediately and the style values let a later full-chain relayout on window resize take over from the last width. Verification: the examples/probe-splitter probe (kept in the repo) compares two phases — the style path fails in all 8 scenarios, set_bounds succeeds in all 6 exactly (direct / inside a Scroll × two sizes); 131 tests pass, showcase smoke survives, and drag feel was re-verified on the real machine. Method: a mechanism probe that changes parameters step by step from a timer and dumps bounds on alternating ticks characterises the layout chain with no simulated mouse needed.
  • On Linux, Label is the self-drawn widget NULabel, so no GtkLabel selectable channel exists: libyue's Label is a custom GtkWidget on GTK (nativeui/gtk/nu_label.cc — cairo self-drawn text, preferred size hardcoded to 0, no built-in text selection/copy), so gtk_label_set_selectable(GTK_LABEL(GetNative())) hits the wrong object and is silently ignored (gtk_label_get_selectable always reads back 0, proven with a runtime probe). Selectable text must come from a self-drawn component (hit calculation + selection highlight + Ctrl+C into the clipboard), not from a native channel. A set_selectable API was once added on the assumption that GetNative returns a GtkWidget; after verification failed the whole change was withdrawn, and experiment/input_probe stays in the repo as the chain probe.
  • Two defects in the Linux on_wheel hookup (fixed in 0.5.10): a bad downcast between the sibling classes Window/View, and GtkWidget's missing wheel mask by default: yue_mbt_view_on_wheel used CastToView(view) — Window derives from Responder, not View, so the static_cast misaligned the object and View::GetNative() read a member at the wrong offset; g_signal_connect then received a garbage instance, GLib reported "instance of invalid non-instantiatable type" and the signal was never attached (two visible CRITICAL lines at run time are exactly this). Separately, GtkWidget/GtkWindow do not include GDK_SCROLL_MASK by default, so without an explicit gtk_widget_add_events the scroll-event is never delivered. Fix: dispatch by GetClassName (Window uses Window::GetNative, everything else takes dynamic_cast<View*>'s GetNative) and always add GDK_SCROLL_MASK|GDK_SMOOTH_SCROLL_MASK (NUContainer's event-window mask goes through nu_container_add_event_mask, which unions). Related lesson: in the shim, any view-level interface that needs GetNative() must not route a Window argument through CastToView (Responder-level Signal Connect happens to be safe because both Responder classes share a header layout, but that must not be relied on).
  • NUContainer (a self-drawn container) receives no mouse events when it hosts wgpu rendering; Window-level signals are reliable (settled by a staged probe): after presenting a wgpu surface into a Container's GdkWindow (even merely creating the surface without rendering), that container's on_mouse_down/move/wheel all go silent; staged experiments (render/surface/one-frame modes) cross-located against no-wgpu and Window-level controls — X-layer events are intact (Window's on_mouse_down still fires), the break sits in NUContainer's input-only event-window hit/forward layer, and the same symptom appears on an empty container without wgpu for synthesized clicks (reproduced with XTEST; native-widget clicks in real-hardware hello-themed are unaffected, because a native Button owns its own GdkWindow). Workaround: in render-host scenarios, attach all mouse interaction to Window-level signals (the client-area coordinate system is the same, so coordinates are equivalent when the container fills the window); for the wheel, attach both window and host (window-level wheel was not observed for XTEST-synthesized events and real-machine behavior is unverified — attaching both lets whichever works count, and they never both fire). Diagnostic tool: three-native examples/input_diag (staged modes via INPUT_DIAG_MODE=render/surface/frame). The root cause (NUContainer's event window interacting with the wgpu surface) is still to be dug out and sits on the fork's investigation list.
  • Segmentation fault on an explicit quit() (root-cause chain nailed with gdb; fixed by disconnecting all signals before quit): when the program calls quit() itself (menu exit / a callback path, with no delete-event), the windows and widgets are held by the shim's handle registry ViewStore and only die during the exit static-destruction phase; ~Window then makes GTK dispatch focus-out synchronously, which travels OnFocusOut → MoonBit trampoline → schedule_paint → Store<Responder>::get and hits the unordered_map mid-destruction → SIGSEGV (about 1 in 20 runs when a focusable button_t is in the window; pure labels never trigger it, and it does not reproduce under gdb because it is timing-sensitive). Fix: shim yue_mbt_quit walks ViewStore before MessageLoop::Quit and disconnects every application-layer signal on every view (7 Responder-level + 4 View-level + the Window's on_focus/on_blur; on_close is deliberately left alone because quit usually runs inside its callback chain and Emit already holds a slot copy, so leaving it is safe); during exit, signal Emits do nothing. Implementation note: Window derives from Responder rather than View (sibling classes), so branch on GetClassName and static_cast each on its own — converting View* straight to Window* is refused at compile time. Verification: explicit-quit probe ×50 with zero faults (before: 1 in 20 segfaults), no-focus probe ×30 clean, window-close path (wmctrl -ic) ×12 all rc=0, 135 tests pass; the reproducer and probes stay in experiment/exit_crash/. The root fix (an is_quitting flag short-circuiting OnFocusOut in the fork) is listed as an optional follow-up vendor batch.
  • Growth after a run-time set_text is truncated (label width never follows the text, settled by probe bounds measurements): bind / bind_label and any "subscribe to a signal then Label::set_text" path clip as soon as the text gets longer than at mount — the length exceeds the allocation width captured at mount, and GTK clips drawing to the widget's allocation; only a later real window resize recovers it. Three layers of root cause: ① libyue's Label::SetText only does YGNodeMarkDirty + queue_draw (a pure repaint, no layout); ② even when someone calls Layout() (a full yoga recompute whose new deep-node values are computed), delivery of the bounds depends on each container's UpdateChildBounds, and the root only SetBounds its direct children; ③ GTK short-circuits a size_allocate for an unchanged allocation — intermediate containers whose own size did not change (an hbox, say) do not propagate, so a label deeper in that subtree whose measure changed never receives a new width. libyue's Container::Layout() non-root branch already has a "if the parent did not deliver, do it yourself" self-heal (walking the dirty chain calling UpdateChildBounds level by level), but its upward propagation stops at a non-Container parent (a Scroll), and the shim's yue_mbt_view_layout walked to the root and called root->Layout() itself, which bypassed exactly that self-heal. Fix (shim): extract layout_root_down — after Layout() on the root, force UpdateChildBounds() on every Container along the root → start-point path (idempotent delivery; the visibility guard on non-Linux platforms stays as is), and yue_mbt_view_refresh switches to it too. The order must be outside → in: a Scroll's content container is an independent yoga root (not hung on the big tree; IsRootYGNode decides by "no YG parent"), and only its UpdateChildBounds triggers the local recompute; delivering inside → out means the inner level runs first with pre-recompute values and the new measured width still never reaches the target — under showcase's in-section scroll architecture (every page lives inside a Scroll), the first fix (inside → out) still lagged one round in the probe, and flipping the order made it synchronous. Fix (MoonBit): the three set_text sites in bind_label's subscription callback, stat_card's number (num) and form_item's validation text (err) now call update_layout. Verification: the examples/probe-bind probe (kept in the repo, including the Scroll-wrapped shape) alternates short/long text on a timer and dumps bounds each round — before the fix the long text stayed at 25px (always clipped) and lagged one round; after, both inside and outside a Scroll give 243 long / 25 short immediately; 65 tests pass and showcase smoke survives. Observation lessons: ① GTK's size_allocate is asynchronous to the current callback, so a synchronous print always reads the previous frame's allocation — never conclude from same-frame values; ② after a shim change moon does not necessarily relink an existing exe (the .a is not in its dependency graph), so delete the exe to force a relink; the first day's fix therefore "looked ineffective" on showcase when it was simply the old exe.

Windows 10 / 11 ✅ ​

First full local-chain verification environment: Windows 10 19045 + VS BuildTools 2022 (v17.14) + SDK 10.0.26100 — prepare.py build → moon check / test / build → hello launch smoke all pass (previously Windows had CI-only verification; some branches had never been compiled against a real Windows SDK).

Toolchain ​

  • Requires VS Build Tools (VCTools workload + the ATL component for base/win/atl_throw.h); run moon / cmake from an x64 Native Tools Command Prompt or after vcvars64. Installer quiet / passive mode requires elevation, else Exit 5007.
  • Release asset names are libyue_{v}_win.zip / _mac.zip (not windows / darwin).
  • On case-sensitive volumes compilation fails with C1083 on webview2.h: the SDK ships only WebView2.h (capital W); prepare.py adds a lowercase alias after extraction. On the same volume shutil.copyfile's samefile check is unreliable — delete the destination before copying.
  • Platform-specific code must guard its includes and implementations in the same batch: a bare gtk/gtk.h, or a function guarded at the call site but not at the definition, explodes on the other platform's compiler with C1083 / C2065.
  • The power/session message code (B6) hit three SDK facts on its first real-machine compile: the PBT_APMRESUME macro does not exist (resume only ever sends the unconditional PBT_APMRESUMEAUTOMATIC, plus PBT_APMRESUMESUSPEND afterwards only for user-input resumes — the latter is a subset of the former, and matching both fires the callback twice per wake); SDK 10.0.26100 has folded the PBT_* constants into winuser.h and there is no standalone pbt.h at all — explicitly including it fails with C1083; WTSRegisterSessionNotification / NOTIFY_FOR_THIS_SESSION are declared in wtsapi32.h while the WTS_SESSION_LOCK message codes live in winuser.h — a missing include therefore reports only the functions as undeclared while the message codes compile fine, which misleads you into thinking the headers are okay.
  • shim platform differences: guard dlfcn.h with __linux__; MSVC needs _USE_MATH_DEFINES for M_PI; base::FilePath is std::wstring under UNICODE builds — go through FromUTF8Unsafe / AsUTF8Unsafe; Windows has no Popover and no SetOverlayScrollbar / Clipboard::Selection / Tray::SetTitle etc., all degraded to no-ops in the shim; operator new/delete redirect to malloc/free (moon builds its runtime with MOONBIT_ALLOCATOR=SYSTEM); a widget's HWND comes from dynamic_cast<nu::SubwinView*>(GetNative())->hwnd() — GetNative() itself is not an HWND.
  • Native child HWNDs do not follow the container after scrolling (they float over scrolled content): force View::Layout(), and the scroll wrapper registers on_scroll with the callback deferred via a 0ms timer until layout completes; an input's inner shadow is WS_EX_CLIENTEDGE, so borderless must clear STATICEDGE / CLIENTEDGE / WS_BORDER; DatePicker shows only the year unless given an explicit width; glyph-based small icons are inconsistent across platforms — draw them with Painter vectors inside components.
  • cc-link-flags is spliced verbatim into the cl command line; GNU-style -L/-l triggers D9002. The correct form is link inputs (build/yue_mbt.lib setupapi.lib …): cl forwards .lib position arguments to link, and system libraries resolve via the LIB environment variable.
  • Path separators must be forward slashes: backslashes are eaten by moon's argument parsing and link fails with LNK1104.
  • The official CMakeLists system-library list is incomplete; copying it verbatim yields 144+ LNK2019. The prepare.py list is complete.
  • The CRT must match moon's static /MT: CMake multi-config generators ignore CMAKE_BUILD_TYPE, so cmake --build must pass --config Release (prepare.py automates this), else LNK4098 plus unresolved __imp__*.
  • The console window on exes is gone via an in-library link mechanism (current stack, measured 2026-10-12 on Windows): gui/win/win_gui.c carries #pragma comment(linker, "/SUBSYSTEM:WINDOWS") plus /ENTRY:mainCRTStartup under #if defined(_WIN32) && defined(_MSC_VER), and sits in gui/win/moon.pkg's native-stub list next to stub.c. The pragma lands in the object's .drectve section; moon compiles native-stub C sources straight into the exe build (the cl command line lists win_gui.c), but the linker only honours .drectve of archive members it actually extracts — so the file also exports wm_win_gui_marker, referenced by win/window.mbt's init_backend() as ignore(wm_win_gui_marker()). That reference is the extraction anchor, and core/app.mbt's app path calls init_backend(), so consumers get GUI(2) with zero configuration. /ENTRY:mainCRTStartup is not optional: switching the subsystem makes WinMainCRTStartup the default entry point while moon emits a console-style main(), so without it the link fails on an unresolved WinMain. Appending the flags instead cannot work — user link_flags are spliced before /link, so cl discards /SUBSYSTEM-class link options outright (D9002). release-bin.yml therefore verifies rather than rewrites: the step fails the build unless every release exe's OptionalHeader.Subsystem is 2. Evidence: moon build examples/{hello,hello-themed,showcase} --target native --release --strip produces three files whose Subsystem reads back as 2 with no post-processing; test exes are largely unaffected because only those whose code reaches init_backend() become GUI(2) (measured: core.whitebox_test.exe, ui.whitebox_test.exe, the rest CONSOLE(3)), and the library gate stays 830 tests / 825 passed (the 5 reds are the pre-existing dbus session-bus and fsx_wbtest.mbt:109 cases). Under the GUI subsystem stdout is visible only through pipes / redirection — which is exactly how the test harness captures it.
  • After swapping yue_mbt.lib, moon build reports "no work to do": delete the produced exe under _build to force relinking.
  • prepare.py mode-switch pitfall (prebuilt↔source): cmake -D only overrides the CMakeCache when explicitly passed — a stale ON from a previous prebuilt configure survives into source mode, so none of the globbed sources get compiled, yue_mbt.lib ends up with just the single shim obj, and the final link fails with 360 library-wide unresolved symbols. Fix: always pass the flag explicitly as ON/OFF for both modes. Quick check: lib /list build\yue_mbt.lib and count the objs — a full Windows source build has 27.
  • prebuild's link_configs Windows branch once omitted the separate entry for yue/traybus: traybus does not depend on yue (it is the reverse), so propagation "to targets that depend on the package" never reaches it, and moon test linking the traybus test exe failed with 12 LNK2019 yue_mbt_sys_* symbols; the Linux/macOS branches already carried the separate entry, and Windows was brought in line.

Manifest ​

  • moon's link-flag splicing behavior (measured on Windows): for each main package it splices the flags of every package carrying link_configs in the dependency closure once, and for blackbox test targets it splices the tested package's own flags one extra time (tested package's entry ×2). Listing a .lib twice is harmless; listing manifest.res twice puts two same-name MANIFEST resources into the exe → CVT1100 link failure. The earlier conclusion that "newer moon embeds its own MANIFEST that conflicts with our res" was wrong: mt verified that moon-linked exes contain no manifest resource at all — the "other copy" of the conflict was always our own duplicated res (it started firing for any target that splices both flag sets once the Windows link config for traybus was added).
  • Channel exploration conclusions: link options like /MANIFEST:EMBED /MANIFESTINPUT: placed into link_flags are discarded by cl as compile options (D9002 — options before /link do not reach the linker); #pragma comment(linker,"/manifestdependency") requires the linker to run with /MANIFEST, which moon's link does not (no external .manifest file appears next to moon-linked exes either); prebuild's stdin carries only an environment snapshot and module_root, with no target/package info, so per-target differentiated configs are impossible.
  • Final scheme: manifest.res stays out of the default link_flags — dev / test / moon run need zero configuration. The cost of running without a manifest is more than visual: on a real Win10 19045 machine, clicking a button in a message box killed the process — TaskDialogIndirect is only exported by ordinal 345 of comctl32 v6; without a manifest the v5.82 comctl32 is loaded, whose export table also has an ordinal 345 pointing to an unrelated function, so libyue's by-ordinal lookup resolves a non-null garbage pointer and calling it is plain UB (a python ctypes probe confirmed the resolved non-null address; a MoonBit probe replicating the exact same usage ran clean three times in a row — UB is nondeterministic, a single non-repro does not disprove it). Fork fix (mbt.13): MessageBox checks comctl32's DllGetVersion before resolving the ordinal and only calls it on major version ≥ 6; below v6 it falls back to the classic MessageBoxW (icons mapped from the TD__ICON values to MB_ICON, MB_OKCANCEL with OK returning the first custom button's response when two or more buttons are registered, MB_OK otherwise with cancel semantics), so a dialog always shows regardless of the manifest; OnClose is uniformly posted back to the UI thread (the old empty-resolution path calling it straight from the background thread was also a cross-thread hazard). The first degradation build closed silently on a null resolution, which real-machine feedback reported as "clicking the message box does nothing", hence the MessageBoxW fallback; a second round of feedback reported "icons and buttons but no text" — TaskDialog keeps the main text in pszMainInstruction and only the informative text in pszContent, while SetText writes only the former, so the fallback must concatenate both fields into MessageBoxW's single text line. Distribution-style builds set YUE_MBT_KEEP_MANIFEST=1: the res rides the yue entry, and moon build's main packages splice each entry exactly once, embedding exactly one manifest (Common-Controls v6 + supportedOS, verified by reading it back with mt) — with v6 a real TaskDialog shows; do NOT set this switch for a full moon test (the blackbox tested-package duplication guarantees CVT1100). release-bin.yml is configured accordingly.
  • After an environment variable changes link_flags, moon occasionally keeps relinking with the stale config: if setting/unsetting the variable seems to change nothing, fall back to moon clean (or delete the produced exes under _build).

Runtime differences ​

  • 2026-09-25: the whole tree rolled back to the 09-23 evening state f3ba8bf (the user's call): the entire fix chain made from 09-24 onward for "form-page native children following the scroll / page-switch streaks / tooltips never showing" (first-generation 0ms resync → fork recursion into UpdateChildBounds → dropping WS_CLIPCHILDREN → allocation translation → pixel blit → band repaint ordering → page-switch relayout coalescing, 30+ commits, including fork ba479418/e373e60a/f4528cb8/cf308737, the browser package split, and three tooltip generations) proved a net burden across rounds of real-machine review — every round introduced new diseases (jank / blue ghosts / streak recurrence / ghosting / floating inputs / tearing) without curing the old ones, so the tree was rolled back wholesale per the user's decision; native pinned back to v0.15.6-mbt.12. Core lesson: sync-layer fixes for mixing native HWNDs into a self-drawn world failed on the real machine repeatedly — each such fix must pass a single-point real-machine verification before the next one is layered on; bundling several fixes per round is forbidden. Full root-cause narratives of the rolled-back approaches live in the git history of 09-24~09-25 (both repos). Accepted known state after the rollback: native tooltips never show on Windows (the native path needs a comctl32 v6 manifest), native inputs follow scrolling via the update_layout fallback, and page-switch/scroll streaks remain (inherent to WS_CLIPCHILDREN). After the rollback the user hand-picked items to restore (same day): the browser package split (with the lib-form-differentiated linking fix), vector self-drawn result symbols, the cursor_group component, the network-status label() fix plus the Linux initial-dispatch fix, the Windows live-resize bitmap placeholder (pure shim subclass), and the single-line Entry wheel forwarding (fork 7f57e87f, delivered via the v0.15.6-mbt.18 prebuilt; the unselected fork ba479418 was removed in 3f8e8935 before cutting that release). Not restored: the self-drawn tooltip bubble, the prepare backport machinery, and every scroll/page-switch family fix. The Windows overlay-scrollbar form was restored later (its own batch after real-machine acceptance): only the form itself was ported (host wrapper + self-drawn thumb + flex/basis 0 inside the host branch); the 09-23 baseline's update_layout follow fallback stays untouched and the non-Windows branch is unchanged. The original commit's bundled "remove the fallback + unconditional basis 0" (the collapse root that aab1a74 had to cure) did not come along.
  • MoonBit concrete-type misuse in FFI handle parameters: MessageBox::run_for_window once passed the Window struct straight into an extern expecting a View handle (the C side received a MoonBit heap address instead of the registry id), so CastTo<nu::Window> failed silently — the message box ended up parentless and the synchronous path deadlocked. Detection: the MoonBit-generated C prototype shows struct ...Window* for the parameter instead of void*. Rule: any extern declaration taking a handle must use View (the external type) and let MoonBit call sites unwrap the concrete struct via .view(); the async show_for_window and the sync run_for_window must follow the same convention. Also note: moon's incremental check is unreliable — after changing the shim/library, moon build may report "up to date" and not relink, so delete the produced exe under _build to force it (otherwise you probe stale-library behavior).
  • Visibility-toggled pages disappear after switching (set_visible-driven tabs like tabs_t, verified on a real machine): libyue win's View::Layout() propagates only to an IsContainer() parent (if (GetParent() && GetParent()->IsContainer())), and Scroll is not a Container — after a subtree mounted under a Scroll's content toggles visibility (set_visible → yoga display switch), the propagation chain breaks at the Scroll, the yoga root never recalculates, and the page being shown gets a 0-height allocation; when the page content has no explicit height it is squeezed to the sum of its paddings (measured: page height 50→16, the whole content block vanishes). GTK hides this behind full size-allocate relayout; it is Windows-only. Fix: the shim's yue_mbt_view_layout (update_layout) now walks the parent chain to the root container and calls Layout() there, and tabs_t's sel.subscribe adds one update_layout(outer) after the visibility flips. A probe (page-content on_draw self-report + bounds dump) confirms every switch paints correctly with a stable page height. Note: only trees routed through a Scroll's content break the chain — a direct flex child of the root always recalculates.
  • Visibility-toggle vanishing, residual three-layer case (the showcase structure: page-container set_visible switching + scroll + section + tabs_t, still reproduces on a real machine): on tab switch, the dirty self-heal branch of Container::Layout (the one carrying a TODO comment in view.cc) allocates outer containers from the yoga mid-state of the display switch — sibling flex:1 page containers get a compressed height from the scroll content's mid-state measurement (measured: 210→56), and it cannot self-correct afterwards: the self-heal propagation breaks at Scroll (not a Container), and the root-level recalc's SetBounds chain breaks at intermediate containers whose size did not change (identical size → ViewImpl::SizeAllocate early-returns → children's UpdateChildBounds never fires). Consequence: the WM_PAINT dirty rect is clipped by the compressed page container into a 24-high sliver that does not intersect the page area (74,278,512,50), so DrawChild skips the whole block on child_dirty.IsEmpty() — on_draw never fires while bounds look fine. Tried and measured ineffective: root recalc ×N, reversed visibility order (true before false), root recalc inlined into set_visible (shim side), leaf-level visibility (only page content toggles), page_c flexbasis 0 (CSS flex:1 1 0 semantics). Conclusion: the root cause lives in libyue win's yoga integration itself (dirty self-heal using stale layout + the Scroll chain break), which external shim/yue-layer patches cannot penetrate; a fork-side fix is needed (candidate directions: force YGNodeCalculateLayout before allocating in UpdateChildBounds, or keep Scroll content measurement out of the mid-state). The 20babb5 fix fully covers the case where tabs_t sits directly in a scroll's content (no page-container layer); the showcase case awaits the fork-side approach.
  • Scrollbar form: libyue's Windows scrollbar is a self-drawn classic style (the Scrollbar class: track + arrow buttons + permanent layout space), and Scroll::SetOverlayScrollbar is a no-op on Windows (the API is excluded by #if !defined(OS_WIN) in the header) — GTK's floating form has no counterpart here. The unifying answer is the yue-level attach_overlay_thumb: set policy to Never to hide the platform bar, and a self-drawn thumb positioned via yoga absolute (static right/top/bottom) spanning the right edge's full height, with the visible segment painted in on_draw from Ref state — scroll updates only schedule_paint with zero yoga relayout; appear/fade uses two clear_timeout-cancellable timer steps (d9→73→hidden), dragging relies on the implicit mouse capture acquired on press (moves keep arriving outside the thumb — see the WM_CAPTURECHANGED note under the splitter entry), and wheel events on the slim thumb are forwarded to the Scroll manually via on_wheel. Landing point: the declarative scroll() default form (Windows, overlay=true with no explicit policy) wraps a host container and routes through it (the style parameters move to the host with the Scroll filling it — the thumb's absolute positioning is anchored to the host; attaching the thumb to the page container directly would be skewed by padding/margins); the overlay_scroll component and Windows-side page-level scrolling share this path. The Windows on_scroll→0ms-timer forced relayout of native HWNDs is kept.
  • System accent color: DwmGetColorizationColor returns the "window colorization color" — a blend of the accent color with the system base color, visibly off from the Settings accent color under default configuration (reference machine Win10 19045 returned yellow-green 0xFFB7AC00 while the Settings accent color is teal 0xFF00B7C3); read the registry HKCU\Software\Microsoft\Windows\DWM\AccentColor first (0xAABBGGRR, written since Win10 1803+), falling back to the colorization color only when missing / 0 / 0xFFFFFFFF — after the fix the probe returned 0xFF00B7C3, bit-for-bit equal to the Settings page. The fallback value's alpha bits encode "intensity", not opacity; take RGB only.
  • GetSystemPowerStatus semantic losses (battery query): ACLineStatus 255 (unknown) counts as not-online; BatteryFlag 128 (no battery) / 255 (unknown) both count as no battery; BatteryLifeTime drifts with AC/battery mode and is often -1, so remaining time is never reported (the Linux UPower side folds State 1/4/5 all into "on power", aligning the two platforms). Also: at full charge on AC the BatteryFlag is High without the Charging bit (measured: percent=100 charging=false), so "full but still on power" shows as non-charging on Windows; there is no change event for the charge level itself (only plug/unplug), so slow drain/charge needs polling to stay fresh (the showcase system page uses a 30s set_timer plus instant refresh on plug events, both through the same query path).
  • AttributedText ranged font / color: upstream Windows supports only whole-text (ranged calls CHECK-crash; GDI+ has no rich text). Fork mbt.9 builds a segmented layout engine (run storage / flowing line breaking / measure-draw from one source) and the MoonBit-side degradation guards are removed — three platforms now agree. Gotcha: Gdiplus::Font::GetHeight's overload takes (const Graphics*); passing a reference does not compile.
  • Color::Get(Border) hits NOTREACHED and returns a garbage color: the shim maps Border to GetSysColor(COLOR_WINDOWFRAME).
  • Blurry self-drawn text: libyue's GDI+ brush hardcodes grayscale antialiasing; an idempotent prepare.py patch switches it to TextRenderingHintClearTypeGridFit.
  • System notifications: WinRT toast looks up the notifier by AUMID and silently fails without an AppUserModelID; the shim sets the AUMID automatically before the first notification and writes the registry DisplayName.
  • Browser prefers WebView2 (falls back to IE when the loader / runtime is missing); WebView2 follows the system proxy — on machines with a broken proxy set LIBYUE_WEBVIEW2_ARGS=--no-proxy-server (a prepare.py patch injects AdditionalBrowserArguments from the variable); the demo:// custom protocol does nothing under WebView2 (the IE path works).
  • win32 Group / Scroll do not grow with content: give them explicit heights; ScrollImpl's scroll range only honors SetContentSize, so a prepare.py patch queries the content yoga tree for natural size when none was set explicitly.
  • Key codes and modifiers: the Windows KeyboardCode uses Win32 VK values, normalized to the constant table at the events.mbt entry; Windows' native modifier bits are Shift=2 / Ctrl=4 / Alt=8, and the shim's NormalizeModifiers gained an OS_WIN branch mapping to the unified 1/2/4/8.
  • Ghost tray icons: abnormal exits skip the CRT static-destruction chain and leave the icon behind; the shim installs four process-level hooks (atexit / SetConsoleCtrlHandler / SetUnhandledExceptionFilter / SIGABRT) that re-issue NIM_DELETE by "owner window + icon ID range"; a taskkill /F hard kill cannot be rooted out in-process. A blank tray icon means check the asset first (it was once a 1×1 placeholder).
  • The Popover substitute (borderless / non-activating / topmost window): the popup needs WS_EX_NOACTIVATE (else clicking the popup steals focus); under nested scrolling GetBoundsInScreen yields garbage offsets, so anchor coordinates come from the native child HWND's GetWindowRect; close is destructive on Windows — a reusable popup switches to SetVisible(false); outside-click dismissal installs a WH_MOUSE_LL hook that PostTasks a close when the press lands outside the popup rectangle; the popup background ignores themes, so Popover::set_background_color was added end to end. libyue's SetVisible / IsVisible are unreliable on a non-activating topmost window — use Win32 SetWindowPos + SW_SHOWNOACTIVATE directly.
  • autocomplete keyboard navigation: the Windows branch attaches on_key_down on the Entry (↑↓ highlight / Enter select / Esc dismiss); single-line EDIT has no vertical-centering style, so entry_vcenter narrows the control by font line height and splits the leftover into margins; RichEdit text stays black regardless of theme — Entry::set_colors (EM_SETBKCOLOR + CHARFORMAT2) hooks it into the theme chain.
  • Dark mode for native controls: real Win32 common controls (RICHEDIT50W / SysListView32 / SysDateTimePick32 etc.) all ignore the system dark theme; RichEdit can be darkened via the message channel; Table cannot (its custom draw repaints a white base over external messages); the official answer is the self-drawn component library, with native dark mode as a known boundary.
  • Hit testing runs opposite to painting: upstream FindChildFromPoint iterates children in insertion order, so a late-attached full-screen mask paints on top but its events fall through to the earlier container (dialog uncloseable, clicks pass through) — fork mbt.6 reversed the traversal to match paint order. Any "visually-topmost view receives no events" — check the hit-test direction first.
  • The wheel is consumed outright by the outermost Scroll and never dispatched, so nested scroll areas and self-drawn canvases never scroll: a prepare.py patch (patch_win_wheel_dispatch) dispatches to the child under the cursor via FindChildFromPoint, and shim yue_mbt_view_on_wheel gained a Windows branch converting WM_MOUSEWHEEL deltas.
  • Text measuring and drawing disagree: GetBoundsFor uses GenericDefault (with overhang) while DrawString uses GenericTypographic, so manual x=(width−measured)/2 centering always sits left; positioning must use align=Center/End and leave it to the platform — measuring is only for computing container widths.
  • Two splitter traps: an explicit SetCapture on Windows triggers WM_CAPTURECHANGED, which tears down the implicit capture (skip if capture is already held); the flexbasis:"50%" percentage string parses only on GTK — use pixel values everywhere.
  • The Entry infinite-recursion case: two mutually recursive functions in the non-Linux branch overflowed the stack, and MSVC C4717 had already warned — treat "logically doomed" warnings as errors. For "no window" GUIs use Get-Process <name> | Select MainWindowHandle; MoonBit println is fully buffered through pipes and lost if the process dies — instrument via stderr.
  • mount_window activates the window automatically after the handle callback; consumers no longer call activate manually.
  • Platform info / locale / scaling / clipboard / timers / global shortcuts / global mouse polling / canvas (GDI+) all verified working.
  • An offscreen Canvas cannot be created before initialize on Windows: a single Canvas::new crashes with 0xc0000005 (minimal repro involves no drawing at all). Root cause: Canvas / DoubleBuffer / Painter construction depends on nu::State — GDI+ (GdiplusHolder), the default font, and NativeTheme are all established with State, which initialize() creates; a bare GdiplusStartup fallback inside canvas_new still crashed (State has more null dereferences), and the correct fix is running the same yue_mbt_app_init() as initialize when g_state is null. Contrast with Linux: the cairo image surface has no State dependency, so offscreen geometry runs without initialize (only the text path needs the GTK stack — see "Self-drawn canvas and chart rendering"); Windows could not even start geometry, and after the shim fallback the "offscreen Canvas usable anywhere" semantics now agree across all three platforms. charts_wbtest's offscreen geometry benchmark passes on Windows accordingly (17/17).
  • A platform-independent function mistakenly stubbed in a platform branch: wire's 'd' codec goes through yue_mbt_sys_f64_to_bits/from_bits (a pure bit reinterpretation implemented with memcpy), but the implementation sat inside the OS_LINUX branch with a non-Linux stub returning 0 — on Windows three pure in-memory wire tests failed with everything decoding to 0 (wire codec tests need no bus and run on non-Linux too). Fixed by moving them outside the platform branches. The rule: go through the stub list asking "is this really platform-specific" — pure computation / pure in-memory logic never gets stubbed (same family as the macOS note on the #else fallback swallowing macOS).
  • The single-instance message window is the first self-owned WNDPROC/window-class code in this repo (grep found no prior usage): the class name derives from the app_id (moonbit_libyue_instance_<app_id with dots replaced>), and the window must be created on the main thread that runs the libyue loop. WM_COPYDATA is dispatched synchronously by SendMessage into the WNDPROC (it does not traverse the message queue); the receiver posts back to the main loop via MessageLoop::PostTask before invoking the MoonBit callback, so no application code runs inside the peer process's SendMessage stack. The mutex uses the Local\ session namespace (no elevation needed); a second same-name CreateMutexW within one process hits ERROR_ALREADY_EXISTS, guarded idempotent via a static handle. [Pending real-machine verification] actual dispatch of the message window under the libyue loop, SetForegroundWindow success rate under foreground-lock, and the title-based fallback when mixed with older builds.
  • The UI thread's COM apartment must be STA or file dialogs hang/crash: the common file dialog IFileDialog::Show requires STA — with COM never initialized on the UI thread, CoCreateInstance(CLSID_FileOpenDialog) fails outright (null dereference inside the lib), and when an application call path initializes the thread as MTA first (typically an old in-UI-thread CoInitializeEx(MTA) network query), Show hangs forever — observed as "app freezes the moment you open/save a file". Fix: shim yue_mbt_app_init calls State::InitializeCOM() right after creating State (ScopedCOMInitializer STA + OleInitialize, idempotent); afterwards CoInitializeEx(MTA) on the UI thread only yields RPC_E_CHANGED_MODE and local COM objects keep working. Complementary to "move NLM queries to a background MTA thread": the background thread's COM init is thread-local and does not replace the UI thread's own STA. Verify: file dialogs open and return paths on a real machine.
  • No global repaint on theme switch on Windows leaves stale pixels ("ghosts"): the GTK side of theme_apply rebuilds CSS and repaints every window synchronously as a fallback; the Windows side used to only notify theme-subscribed custom views, so unsubscribed regions (and the area behind hidden/moved native child windows) kept old pixels until a mouse-over invalidated them locally. Fix: shim yue_mbt_repaint_all gains a Windows branch — EnumWindows filtered to this process's visible ownerless top-level windows, RedrawWindow(RDW_INVALIDATE|RDW_ERASE|RDW_ALLCHILDREN) to invalidate everything including native children, invalidation only (WM_PAINT goes back to the message loop); theme_apply's windows branch calls it. Verify: toggling dark/light repeatedly leaves no residue on headers/pages.
  • Zero-height custom-drawn views silently render nothing (table_t text column invisible): ViewImpl::Invalidate returns early on empty sizes, and a childless on_draw container with no height style measures to 0 in yoga and never paints — table_t's CellText cell is exactly that (text drawn via on_draw, no children), so the whole text column vanished while header/zebra/checkboxes stayed fine; GTK's clipping behaves differently which is why it never showed there (the mirror-image of the earlier "GTK small-cell path fills don't render" trap). Fix: cell_view sets minHeight=row_height on the CellText container. Lesson: an on_draw container needs a non-zero size source (explicit height or children); "no children + auto height" equals "not painted" on Windows; an offscreen canvas pixel probe rules out the paint pipeline first (colored AttributedText is bit-exact fine on a Windows canvas).
  • GDI+ blend modes: only Normal/Copy take effect: PainterWin::SetBlendMode maps only Copy to CompositingModeSourceCopy, everything else falls to SourceOver — GDI+ Graphics has exactly these two compositing modes, so Multiply/Screen/Difference/Xor are silent no-ops. Offscreen pixel probe: the Multiply overlap (128,128,255) is bit-identical to SourceOver (mathematically expected #8028FF). This is a platform capability gap; the lib stays untouched (a full blend set needs D2D); the showcase canvas demo reports "only Normal takes effect on this platform" on Windows, and the platform wording of Image::write_to_file in docs is corrected accordingly (Windows GDI+ encoder handles png/jpeg; the mac release package never compiled it and always fails).
  • The native Tab emits on_selected_page_change when the first page is added (internal initial selection, not a user switch): registering the callback before adding pages makes it fire spuriously during mount (declarative tab() used to let switch-counter demos start at nonzero); registering after the add loop avoids it.

MoonBit native GUI stack · Windows build baseline (W0/W1, first batch of Windows enablement) ​

  • Environment: Windows 10 19045 + VS BuildTools 2022 (MSVC 14.44) + SDK 10.0.26100, moon 0.1.20260920; new-stack branch feature/yoga-mbt. Goal: verify every completed new-stack item (all [x] in TODO.md) on Windows, with the Windows foundation (Win32 backend) first.
  • moon native works out of the box on Windows, no vcvars needed: run moon test from a plain shell (Git Bash / CMD) — moon locates the MSVC toolchain itself and compiles/links (evidence: cl/link error codes C2059/C1083 and lib.exe's Chinese output). This differs from the old-stack note "run moon from a Native Tools Command Prompt" — that applied to the old prepare.py/cmake chain, not the new stack's build.
  • A hard failure in any workspace member's prebuild blocks every moon command: modules/ffmpeg-mbt/prebuild.py exits non-zero when pkg-config is missing, and every check/test across the five moon.work members runs it first; the main scripts/prebuild.py already degrades gracefully off Linux (empty link_configs). Fix: ffmpeg's prebuild gains a "non-Linux → empty link_configs, exit 0" branch (Linux behavior unchanged), matching the main script; its consumer (gui-media) is a transition-period red dot anyway and not on the new-stack verification path.
  • GLib types in non-Linux placeholder stubs are syntax errors under MSVC: win/gtk_stub_portable.c used gint32 as placeholder return types — the whole file sits inside #if !defined(__linux__) so it never compiled on Linux and the issue never surfaced; MSVC reports 6× C2059 (no glib.h, gint32 undefined). Changed to int32_t (same 32-bit ABI, extern prototypes unchanged). Lesson: placeholder stubs must be written to the standard "compiles for real on Windows" — MSVC is the first real reader a placeholder stub gets.
  • chensuiyi/subproc's C stub uses POSIX poll.h and cannot compile on Windows: sys's procrun.mbt builds on @subproc (its own comment says "Linux only"), so sys and chart (which depends on sys) all fail to compile. The Windows subprocess layer (spawn→timed reclaim→output capture) belongs to W14 and will be redone with CreateProcess, replacing the MoonBit-side @subproc dependency; until then sys/chart are unverifiable on Windows.
  • Per-package test scoping behaves the same on Windows as Linux: running bare moon test inside a workspace member directory drags the main package's red dots into the build; scope with -p (already recorded in HANDOFF.md, confirmed identical on Windows).
  • Verification (2026-10-11, this machine): moon check 287 errors / 68 warnings — verbatim identical to the Linux baseline; pure-MoonBit packages all green — yoga-mbt 96/96, core 62/62, input 10/10, mdview 26/26, theme 7/8 (the failing "control-height baseline" case depends on Pango font measurement, which the placeholder text backend cannot satisfy — deferred to the text-backend batch), icons 2/4 (the two failures are "actually paints pixels" assertions, deferred to the render-backend batch); ui panics on TextMetrics::unwrap null (same text batch), sys/chart fail to compile on subproc (W14).

MoonBit native GUI stack · Windows window foundation (W2, unified stub + Win32 backend) ​

  • Architecture decision: the platform stub is merged into a single three-branch win/stub.c — _WIN32 real Win32 implementation, WM_STUB_GTK real GTK implementation (prebuild compiles it on Linux with -DWM_STUB_GTK + pkg-config header paths into libyue_win_stub.a), and placeholders for the rest; the old gtk_stub.c/gtk_stub_portable.c pair is gone. The physical constraint behind the merge: moon's native-stub compiles every .c listed in moon.pkg on all platforms, while GTK header paths are only reachable via prebuild — so the GTK branch must hang off a "prebuild-only macro"; the Linux native-stub compile (no macro) falls into no branch and produces an empty TU, avoiding double definitions against the .a. This is the equivalent of the old two-file structure, converged into one file. An empty TU is proven fine in this chain (the old portable file already compiled empty on Linux).
  • moon's native-stub sees windows.h directly on Windows (probe package actually compiled and linked): moon locates the MSVC toolchain itself and sets up the SDK include paths, so the Win32 stub goes through native-stub with no prebuild help; prebuild's Windows branch only emits system-library link flags (user32.lib gdi32.lib imm32.lib advapi32.lib, MSVC positional-input style).
  • MSVC's IDC_* cursor macros default to the ANSI variants: without UNICODE, IDC_ARROW is the MAKEINTRESOURCEA form (LPSTR), and passing it to LoadCursorW's LPCWSTR parameter trips C4133; force #define UNICODE/_UNICODE at the top of the stub file to go all-W.
  • Key mapping aligned with GDK: the character face is taken in WM_KEYDOWN via ToUnicode (ASCII/Latin-1 verbatim, otherwise 0x01000000|unicode, matching gdk_unicode_to_keyval, so both of keysym_to_text's Latin-1/UCS paths accept it); for Ctrl combos the VK_CONTROL bit must be removed from the GetKeyboardState snapshot before conversion — with the control bit present ToUnicode yields control characters (\x01) and the kernel's Ctrl+A check (keysym=='a') breaks; cleared, it matches GDK's "keyval is the base character, modifiers ride in mods" semantics. AltGr (Ctrl+Alt) therefore cannot reach special characters — deferred to the IME/later batches. Dead keys (ToUnicode returning negative) push no event; composition happens naturally on the next key via layout state. Alt+F4 is handed back to DefWindowProc to produce WM_CLOSE (same semantics as "the window manager requests close"); other system keys (F10 menu, Alt+Space) are swallowed.
  • Blitting needs no byte swap: render's Bitmap is cairo ARGB32 (little-endian bytes B,G,R,A premultiplied), which is byte-compatible with GDI's 32bpp BI_RGB top-down DIB (negative biHeight) — StretchDIBits blits directly; present only records the pointer + InvalidateRect, the actual blit happens in WM_PAINT (same shape as the GTK stub's "blit only in the draw callback", avoiding the dead-window window). WM_ERASEBKGND returns 1 to prevent flicker.
  • Loop recipe: wait with MsgWaitForMultipleObjectsEx(0,NULL,timeout,QS_ALLINPUT,MWMO_INPUTAVAILABLE) (returns immediately when undispatched messages exist, negative timeout = INFINITE); cross-thread wakeup is an HWND_MESSAGE message-only window + PostMessageW(WM_APP+1); the pump drains with PeekMessageW + DispatchMessageW and deliberately does no TranslateMessage (WM_CHAR is not consumed; the character face is taken in WM_KEYDOWN).
  • Slot and creation ordering: WM_SIZE arrives during CreateWindowW, so the slot id must land in WM_CREATE via CREATESTRUCT.lpCreateParams (setting GWLP_USERDATA only after the call drops the first WM_SIZE); client-area semantics via AdjustWindowRect (frame stretched so the client area is exactly width×height). DPI via SetProcessDpiAwarenessContext per-monitor v2 (through GetProcAddress so old systems missing the export don't fail to load), whole stack in physical pixels, WM_DPICHANGED repositions by the suggested rect with WM_SIZE doing the bookkeeping.
  • Verification (2026-10-11, this machine): moon build examples/native-window links the full chain; smoke NATIVE-WINDOW-OK frames=45 (5s timer exit, exit 0, 45 presented frames ⇒ present→WM_PAINT→StretchDIBits works); the core.App host shape verified by a delete-after-run probe printing WIN-SMOKE-OK (App::new + declarative mount + timer + clean exit); core 62/62 still green (tests never call App::new, so no real windows pop up), whole-repo check 287/68 unchanged. band_h=0/0/0/0 is the placeholder text backend (W5).

WebView2 webview stub and ffmpeg real linking on Windows (platform batch, 2026-10-13) ​

  • WebView2 custom schemes must be pre-registered at Environment creation: navigating to a custom scheme not registered via ICoreWebView2EnvironmentOptions4::SetCustomSchemeRegistrations fails outright (failure code 9 on a real machine, later 8). The MoonBit-side webview_predeclare_schemes list flows through a new extern into a global list in the C stub; wb_create passes a hand-written WbOptions (four nested sub-interface COM object) and the engine collects the list while creating the Environment.
  • A hand-written COM propget/propput missing the self parameter silently corrupts the vtable on x64: declaring get_bool(BOOL *value) with one parameter — the engine calls it with (self, &value) per the COM contract; on x64 RCX (=self) is read as value, so *value = FALSE actually zeroes the first 8 bytes of the object (lpVtbl!), and every later call on that interface jumps a NULL vtable = segfault. put_str/put_bool share the disease (missing this read, harmless on x64). C does no signature checking on vtable slots (only a C4113 warning) — count the parameter list of every hand-written COM method against the header.
  • The array returned from GetCustomSchemeRegistrations must be CoTaskMemAlloc-allocated: the contract is that the caller (loader) frees it with CoTaskMemFree — handing over a static array pointer means the loader frees it and the heap manager raises 0xC0000374 (heap corruption), crashing inside the engine right after get_regs returns with nothing on the stack pointing at registrations. Debugging used AddVectoredExceptionHandler (exception code/address/module) plus HeapWalk full-heap validation (stderr is unbuffered; stdout buffers are lost on segfault).
  • MSVC reads BOM-less UTF-8 sources using the system code page (GBK) by default (C4819): UTF-8 bytes of Chinese comments and ffmpeg headers get misread — when the last byte of a comment line pairs with the newline into one GBK double-byte char, the next line is swallowed into the comment, cascading into hundreds of "illegal storage class / undefined identifier" errors. Fix: add /utf-8 to stub-cc-flags (the vendored ffmpeg headers hit this too — rational.h reported C4819).
  • moon prebuild vars + moon.pkg ${build.VAR} substitution works (verified on 0.1.20260920): prebuild emits {"vars": {"FFMPEG_INC": "..."}, "link_configs": [...]} and moon.pkg declares link: { "native": { "stub-cc-flags": "/utf-8 -I${build.FFMPEG_INC}" } } — vendor header paths get injected into stub compilation without static hard-coding; stub-cc-flags only affects native-stub compilation. Platforms that do not emit the variable must still provide a placeholder value (/usr/include or .) so ${...} never resolves to nothing.
  • ffmpeg dev-content vendor policy: from BtbN ffmpeg-n9.0-latest-win64-lgpl-shared-9.0, the include headers + 7 import libraries + LGPLv3 LICENSE total 2.5MB committed under modules/ffmpeg-mbt/vendor/ (choose lgpl-shared over gpl-shared — LGPL obligations are minimal for dynamic linking); runtime DLLs (150MB) stay out of the repo — place next to the exe or on PATH. ffmpeg_stub.c drops the _WIN32 all-failure stub branch; all three platforms share one implementation (the ffmpeg API is cross-platform and the original Linux branch had no POSIX-specific calls).
  • miniaudio needs zero extra link libraries on Windows: WASAPI/COM/dsound/winmm are all runtime LoadLibrary'd — gui-media/prebuild.py emitting empty link_configs is the correct shape (not a fallback). gui-media passes 24/24 including the AudioPlayer smoke (pump 33 ticks playing a 1-second asset — the WASAPI device chain actually produced sound); ffmpeg-mbt passes 6/6 (real ogg/m4a/wav + video decoding).
  • Unattended shutdown for GUI-subsystem examples: native-web has no self-exit timer (manual acceptance walks through every section), so automation must use taskkill /IM (sends WM_CLOSE; statistics print after app.run() returns and get flushed) — kill -9 loses the stdout buffer too.

WebView2 cross-HWND focus and wheel forwarding (focus batch, 2026-10-13) ​

  • GotFocus/LostFocus live on the Controller, not on ICoreWebView2: add_GotFocus/add_LostFocus are methods of ICoreWebView2Controller; and for this event pair the Invoke sender parameter is ICoreWebView2Controller* (the event-handler macro hard-codes sender as ICoreWebView2*, which on x64 means real crashes beyond C4113) — these two handlers are hand-written, not macro-expanded.
  • Wheel messages are delivered to the focused window regardless of mouse position, so once WebView2 takes focus the host's scroll areas go dead. Fix: two halves. ① The stub subclasses the engine input window (Chrome_WidgetWin_1, found by class name under the panel HWND via FindWindowEx right after the controller create completes; not finding it only loses half the forwarding and is not an error) — when focus is in the page but the mouse is elsewhere, wheel messages are SendMessage'd back to the host root window; ② the host's WM_MOUSEWHEEL branch first asks via a wheel probe (registered by the web stub at create time through the wm_set_wheel_probe function-pointer slot, avoiding a reverse win→web symbol dependency) whether the screen coordinate lands on a web page, and forwards the message verbatim to the Chrome window if so. Together the two halves reproduce the GTK "hover scrolls" behavior. Subclassing stores the original proc and self HWND via SetProp; destroy restores and RemoveProps.
  • Host-driven focus handover: MoveFocus(COREWEBVIEW2_MOVE_FOCUS_REASON_PROGRAMMATIC) (WebKit branch: gtk_widget_grab_focus); the GotFocus event fires afterwards, so the host can react with UI state.
  • Focus events are exposed as kinds 16/17 (WebView2-only): WebKitGTK is same-tree widgets where the host perceives focus natively, so it does not emit them.
  • Debugging incident (lesson): a one-line python -c script passing a bad newline value to io.open(p,'w') on an existing large file raises ValueError during TextIOWrapper init — but the file is already truncated empty by the 'w' open, losing hundreds of lines (recovered via git checkout and redone with the Edit tool). Never rewrite large files with inline python one-liners; use the Edit tool or write the script to a file first.

MoonBit native GUI stack · image decode backend (W9: WIC on Windows) ​

  • Why this is the single exception to the platform matrix: Windows rendering is a pure MoonBit rasterizer (zero third-party deps, per the plan matrix), but a rasterizer does not decode — image decoding has to borrow a system capability, WIC (windowscodecs.lib, an OS component, not a third-party package). The non-Linux branch previously stubbed the whole img_* set as no-ops, so on Windows Image::new_from_file always returned an empty image and the animated-GIF widget collapsed to zero size; the test layer skipped those cases behind decode_available() / ui_decode_guard() probes (so they were never red). This batch implements the _WIN32 branch of gui/render/stub.c.
  • Three measured WIC facts (proven by the pure-C probe experiment/img_probe, no MoonBit runtime involved):
    1. WIC's GIF decoder hands back raw sub-rectangle frames and does not composite for you — IWICBitmapFrameDecode::GetSize reports the frame rect (12x12 in the probe), with the offset in /imgdesc/Left|Top (14/14 measured). GdkPixbuf's GdkPixbufAnimationIter on Linux does that compositing for us, which is why this difference is new here: full-canvas compositing is our job — the canvas is the logical screen from /logscrdesc/Width|Height, and each frame is closed out by its disposal method (0/1 keep, 2 clear the frame rect, 3 restore the snapshot taken before that frame was drawn).
    2. The transparent index is already applied by WIC (frame alphas measured at only 0 and 255). GIF transparency means "keep the previous frame here" rather than blending, so the compositing rule is simply "skip alpha==0, otherwise overwrite the whole pixel" — no alpha blending involved.
    3. /grctlext/Delay is in centiseconds (per the GIF spec), so multiply by 10 for milliseconds; missing/zero falls back to 100 ms, same rule as the Linux branch.
  • Premultiplication matches the Linux branch word for word: after converting to GUID_WICPixelFormat32bppBGRA (straight), this layer premultiplies with (c*a+127)/255 before handing pixels to MoonBit — the same formula as the Linux branch, which keeps "encode -> decode -> re-encode" byte-stable (the semi-transparent round-trip assertion in image_wbtest depends on it). The static first-frame size rule also follows Linux: a GIF is composited at logical-screen size, so a sub-rect first frame is not cropped down to the frame rect.
  • Memory strategy for animation: pixels are decoded on demand (N frames are not all cached) and the composited result is cached in a canvas, so repeatedly fetching the same frame (once per repaint tick) does not re-decode; advancing one frame is incremental (only the previous frame's disposal is closed out), while wrapping or jumping replays from frame 0. The 4096-frame cap only guards against pathological files (memory is independent of frame count, but the wrap tick is O(n)).
  • Frame numbering keeps Linux's monotonic rule: img_anim_frame() only ever increases (the displayed frame is frame % count). As a result the widget-side gif(looping=false) stop condition ("frame number back to 0") never fires on either platform — that is pre-existing behavior and image_wbtest's "frame number equals number of successful advances" assertion depends on it, so this batch does not change it. One asset fact worth noting: the four frames of examples/showcase/assets/dot.gif (used by showcase) have byte-identical compressed data and differ only in their local color tables (so it reads as "the same dot changing color" rather than moving).
  • Link surface: the Windows branch of gui/scripts/prebuild.py gains a RENDER_PACKAGE entry (windowscodecs.lib ole32.lib user32.lib) — previously the render package had no link_configs at all on Windows (every stub was a no-op needing no external symbols), so render.whitebox_test.exe would hit unresolved symbols the moment it calls WIC. The GUID constants (CLSID_WICImagingFactory1 / IID_IWICImagingFactory / GUID_WICPixelFormat32bppBGRA) come from the same place as the W20f icon loading; C mode cannot use __uuidof, so they are resolved at link time.
  • No /utf-8 needed: under MSVC's GBK misreading of the Chinese comments, the render stub emits only 2 C4819 warnings (byte-for-byte the same as before the change; win/stub.c emits 6), with no swallowed lines and no syntax errors — so no stub-cc-flags is introduced (the ffmpeg-mbt one exists because its vendored headers genuinely trip).
  • Verification: the pure-C probe experiment/img_probe/img_backend_probe.c (independent of the moon toolchain, compiled directly with cl /W4 /utf-8, including render/stub.c so it can call the img_* entry points) ends with IMG-BACKEND-OK across 60 assertions: PNG file/memory entry points agree on size and premultiplication never overflows, garbage/NULL/zero-length/out-of-range handle/missing file all return 0, dot.gif static first frame is 120x120, the animation reports 4 frames with a 400 ms delay, advancing is monotonic, different frames really do differ, and after wrapping the displayed frame is byte-identical to frame 0; free is idempotent and everything is a no-op afterwards; a PNG opened through the animation entry reports is_static=1 and never advances; and sub-rectangle compositing (experiment/img_probe/assets/subrect.gif, frame 1 is 12x12 at (14,14)) keeps the canvas at 40x40 with (0,0) still holding frame 0's black and the box tinted red. To run it: compile the exe with cl, then execute experiment/img_probe/img_backend_probe.exe . from the repo root (the asset rebuild script make_asset.py regenerates subrect.gif with ffmpeg). moon check on the library module is warning-free.
  • Not done in this batch, and not pretended otherwise: the macOS backend (still a stub implementation); the "frame number wraps to 0" semantics for looping=false (pre-existing cross-platform behavior; changing it would require touching both ui/gif.mbt and the image_wbtest assertion); JPEG/BMP/TIFF were not verified format by format (they go through the same CreateDecoderFromFilename sniffing path, with PNG/GIF proven); real-machine appearance (the image areas of showcase's card/media pages, whether the GIF animates) belongs to _人工测试清单.md.

C-side text layout object cache (Pango / DirectWrite, 2026-10-14, data-driven) ​

  • Background and evidence: the frame-stage ledger (gui/core/prof.mbt) read on the native-app real machine frames=190 layout=220ms paint=34487ms present=78ms total=34785ms — paint is 99.1% of the frame cost while layout is only 0.6% (yoga's internal cache works; "full-tree relayout every frame" is not the bottleneck). The cost is that every full-window repaint re-creates a layout object per visible text widget on the C side (Pango PangoLayout / DirectWrite IDWriteTextLayout: itemize + shaping + font resolution, all redone).
  • Fix (same shape on both branches): cache layout objects keyed by path marker ("m" = measure / unconstrained draw, "b" = box-aligned draw) + full text bytes + font quadruple (family/size/weight/style) + width constraint/wrap/ellipsize/alignment — same key means identical layout configuration, so reuse is read-only. Cap 1024 entries (Pango) / 1024 (DWrite), whole-table clear on overflow (same contract as the MoonBit-side metrics cache). Measure and unconstrained draw share the "m" key (identical config path); box draws use "b" (width normalized by wrapped_axis, alignment only participates when width-constrained).
  • Read-only-reuse preconditions (hold on each platform): Pango — color is set via cairo_set_source_rgba and geometry offsets are computed per call, neither touches the layout; DWrite — ptw_render does not mutate the layout when there are no ranges (range fonts/colors belong to the rich-text rt_* path only, which still builds layouts per call and skips the cache), text color goes through SetColor on the shared brush.
  • Windows-side free precondition: img_anim_advance (WIC) is one frame per call and the component asks once per delay_ms beat, so layout objects naturally reuse across beats; the canvas resources (WIC bitmap + D2D render target) were already process-cached — this batch covers the remaining big chunk, CreateTextLayout (with full shaping).
  • Measured (platform correction 2026-10-14): experiment/anim_cost_bench (a 30-row typical page of "button + input + text", 1400×900) — full-window repaint 21ms/frame, a 90×32 dirty rect 0.8ms, relayout 0ms. These readings come from this host's Linux/X11 (cairo backend), not the Windows pure rasterizer — see the "animation go/no-go" section below for the measured backend selection on the same machine; the per-frame cost of the PureBackend(Raster) path still has no real-machine number (every real-machine check by the user so far has been on this Ubuntu + X11 host; Windows/mac must re-run the same probe once hardware is available). P5's go/no-go was passed on this host's readings, and the Windows-side risk is recorded here. The after number for the full native-app page awaits a re-run of NATIVE-APP-PROF (the ledger now lands in native-app-prof.txt).
  • Not done, not pretending: rich text rt_* (AttributedText) still builds layouts per call (small share on the demo pages; benefits to be estimated separately); when the MoonBit-side measure cache hits, the C side is never reached — this C cache mainly serves the draw path and cold starts after the MoonBit cache clears.

Raster fill fast paths: direct-write fill_rect + analytic rounded-rect fill (2026-10-14, probe-located) ​

  • How it was located: the NATIVE-APP-RENDER three-stage stats proved text accounts for only ~15ms of a ~232ms full frame — the culprit is non-text drawing. experiment/paint_probe (component-level isolated timing) broke it down: 60 cards of 400×120 rounded rect = 432ms/frame (7.2ms each), straight rects 110ms, three charts 56ms, virtual table 22ms — rounded rects going through generic path scan (flattening + polygon AA) is the single largest item, 20ms per card.
  • Fix (pure-raster backend, render package): ① composite_solid_span — whole-span direct write for opaque solid source + Normal op (no per-pixel source/premul/blend calls; premultiplied BGRA, opaque source = raw color); ② Raster::fill_device_rect tries the fast path before the generic cov composite; ③ Raster::fill_round_rect analytic fill — no path scanning: per-row ink (middle rows via the fast path), corner-strip pixels get coverage from "pixel-center distance to the corner circle center" (0 outside / AA at the rim / 256 inside). Applies only when axis-aligned and uniformly scaled (corners stay circular); oblique matrices or non-uniform scale fall back (fill_round_rect returns Bool and the Painter side takes trace_round_rect+fill).
  • Trap (hit in practice): the fast-path entry must call apply_fill() first — calling raster.fill_round_rect directly skips Painter::fill's source setup, so the shape gets painted with the previous source color (the table color-box and overlay-panel pixel asserts went red instantly for exactly this reason).
  • Measured (same probe, re-run): single rounded card 20ms→1ms (20×), 60 cards 432→23ms (19×), 60 straight rects 110→10ms (11×), virtual table 22→5ms (4×); chart row 56→36ms (vector paths/strokes are the next big chunk). Package-by-package suite 866+ green (only the virtlist style-slot red remains — a pre-existing failure from the frontend session).
  • Follow-up batch · frame-level text batching (DWrite submission merge) (2026-10-14): NATIVE-APP-RENDER shows the d2d stage at 132us/segment (62 segments/frame = 8.2ms) — the fixed per-segment BeginDraw→EndDraw submission overhead dominates. dwrite_text.cpp gains a batch queue: consecutive pt_draw/pt_draw_box calls on the same target bitmap accumulate (cap 128); flushing does one BeginDraw → Clear(union) → per-segment DrawTextLayout → EndDraw → one read-back of the union + one per-pixel blend (overlapping segments composite inside D2D in queue order). Triple z-order safety: ① flush on target/dimension change (multi-window alternation); ② render gains a process-level "raster gate" — each of the 11 pixel-writing entries (fill/stroke/rounded/box_shadow/draw_image/draw_canvas) calls pt_flush_if_overlap with the current clip rect first, flushing only when the op's clip intersects the pending batch union (non-intersecting ops keep the batch alive, so fill-text interleaves on card walls still form long batches); ③ core paint_frame calls flush_pending before present. Layout-cache eviction can happen between enqueue and flush, so queued layouts are AddRef'd and Released after flush (no dangling). Switch isolation: batching is enabled only on the production path (core text_batch_init); tests/probes keep the old path bit-for-bit — zero suite disturbance. Measured (paint_probe): 200 segments/frame, immediate 21ms → batched 2ms (10×); the immediate path (rt_* rich text) flushes the batch at entry to preserve order. Pango/mac branches have no batch (stub no-ops); stub.c's common tail uses #ifndef _WIN32 to avoid double definitions with dwrite_text.cpp.
  • Follow-up · the batched target is a raw pointer, so Windows text corrupted the heap (2026-10-15, reproduced and closed on Windows 10.0.19044): Symptom — showcase and hello-themed die right after startup (frame and native menu appear, client area stays black, exit within a second), while hello (one bare text line) survives; Git Bash only reports 139, the real evidence comes from WER (Get-WinEvent Application, Id 1000/1001): exception 0xc0000374 = STATUS_HEAP_CORRUPTION, faulting module ntdll, P4 a StackHash. Root cause — the batch queue stores the raw pointer of the target pixel buffer, but the Windows text channel draw_box_raster (taken whenever cairo_id()==None, i.e. by every draw_at/draw_box_at call site) draws each segment into a fresh offscreen Bitmap allocated inside the call and frees it as soon as blit returns; the frame-end flush_pending then writes ink into a block the allocator already owns. Neither safeguard helps: ① the raster gate tests overlap against the batch's ink rect, but inside the batch those rects are offscreen-local while the gate passes window coordinates — different spaces, so the intersection test is luck; ② the "flush when the target changes" rule is in fact the main detonator — the next segment's new buffer flushes the previous, already-freed one. Why it appeared to work before: same-size blocks get recycled at the same address, so the pointer comparison passes and the ink lands in whatever object currently occupies that address — intermittent crashes coupled to segment count and allocation cadence (rerunning one probe level 5 times gave 139/124/127 alternately). Fix — draw_box_raster calls flush_pending() before blit (ink lands while the offscreen bitmap is alive); the contract now stands in enable_batching's doc and comment: a batch target must outlive the next flush, and channels drawing into temporary bitmaps must flush before dropping them. Attribution method (recorded because it beats reading code): patch pt_set_batching to force batching off, rebuild — all seven probe levels survive; restore — crash returns. One A/B settled attribution, no debugger needed. Cost, not hidden: the Windows offscreen+blit text channel therefore no longer benefits from batching and is back to per-segment BeginDraw/EndDraw (the pre-10× shape); capturing that 10× for real means drawing text straight into the window canvas, i.e. handing the Painter's origin and clip rect to the C side for clamping (rx*/ry* already clamp against the buffer, one more clamp against the clip is enough) — a separate performance batch; this one only restores correctness. Linux/mac are unaffected (their stubs never batched).
  • Follow-up batch · incremental active edge table (AET) (second fill_polys rewrite, pixman-isomorphic): the GET (global edge table) is sorted by y_lo once; scanline advancement only does "deactivation compaction / edge activation (monotone pointer) / exact-precision x recompute + nearly-sorted insertion sort" per subsample — no more full edge rescan per subsample. Two measured iron rules: ① x must be bit-identical to the old crossing formula ax + (bx-ax)×t (store the original traversal-direction endpoints ax/ay/bx2/by2; incremental stepping drift, and mistakenly using bx2 as by2 in the t denominator, both flip boundary-pixel coverage quantization — caught by box_shadow/star/icon asserts in practice); ② a sub-1px span straddling two pixels (e.g., [10.6,11.4)) covers [floor(xa), ceil(xb)), not one pixel (star tips / thin rings caught it). Diff-harness methodology: extract the old algorithm from git into a temp wbtest, render identical shapes through both, pixel-diff (this round caught a whole triangle rendering zero ink = activation pointer indexing the unsorted array). Measured (paint_probe): 80-segment polyline 11→4ms, arcs 15→2ms, chart row 28→17ms; the icon full-scan crash was a debug print's cov OOB (not the AET). Known rendering change: under-coverage artifacts at self-intersecting-polygon (pentagram) inner-boundary pixels (old 239/207/159 dim seams) corrected to full coverage 255 — nonzero winding has nonzero on both sides of a wind 1↔2 boundary, so full coverage is correct. Addendum (2026-10-14 evening): the AET batch carried the actual crash culprit — the y1 clamp mistakenly wrote cx1 (clip right bound) instead of cy1 (bottom bound); when a bitmap is wider than tall and geometry extends past the bottom edge, the row loop runs past the bitmap height and composite_span writes out of bounds (0xc0000409, caught by graph_wbtest, fixed 494fa72→728d0d5); the batch had initially mis-attributed this to the frontend session, and a git-worktree bisect (3be0d9b 189 green → e2c7c14 crash) corrected the record.
  • Follow-up batch · scanline engine, pixman-style (fill_polys rewrite, 2026-10-14): ported four proven pixman/Cairo scanline patterns — ① full-coverage runs set directly: pixels whose four samples all fall inside a crossing span are set to 256 in one go (the old code evaluated all 16 samples per pixel inside polygons); ② sampling only at span boundary pixels (at most one per end, add_pixel_samples, capped at 256); ③ composite only the inked range (per-row ink_l/ink_r — usually ~90% of the bbox width is empty coverage); ④ composite_span gained a direct-write fast path for fully-covered runs under opaque solid + Normal (partial-coverage pixels expand to the identical formulas, bit-for-bit); ⑤ crossing sort switched to sort_by index sort (old insertion sort was O(n²): 160+ crossings × 4 subsamples × resort per row) plus zero-allocation scratch arrays (tuple push boxed once per crossing — measured 57k allocations/frame of GC pressure; scratch area + index sort bring it to zero). Measured (paint_probe): 80-segment polyline 18→11ms, arcs 15→12ms, chart row 39→28ms. The remaining structural item = incremental active edge table (edges walked once per row, x stepped incrementally, no per-subsample full edge rescan + sort) — a larger rewrite.
  • Follow-up batch · analytic stroke: after adding stroke benchmarks to paint_probe — 60 card borders of 400×120 stroke = 49ms/frame (0.82ms each, the largest remaining item on card-heavy pages). Raster::stroke_round_rect analytic stroke: ring pixels partitioned uniquely by center (straight bands via row/column overlap with [edge-hw, edge+hw], corner quadrants per-pixel |dist-rr|≤hw SDF, inner hole vanishes naturally when line width ≥ 2×radius), measured 49→6ms (8×); also adds a public Painter::stroke_round_rect API. Polylines (80 segments, 18ms) and arcs (3 14px rings, 15ms) remain generic — chart-primitive analytics queued as the next candidate batch.
  • Final measurement (native-app real machine, all five batches in): frames=4421 paint=144299ms — full-frame paint 232ms→32.6ms (7.1×), max_frame 344→188ms; text is ~28% of paint (d2d 9.3ms/frame), the rest is chart vector paths and strokes. Layout 0.23ms/frame and present 0.5ms/frame are negligible.
  • Not done: stroke_round_rect / chart vector paths (stroke_polys) still generic — chart pages and card borders are the remaining chunk; rounded corners under rotation or non-uniform scale (elliptical corners) still take the generic path; per-frame batching of DWrite's per-segment BeginDraw/EndDraw (next step for the 9.3ms/frame of text).

Self-sustaining repaint loop: present and expose igniting each other (2026-10-13, real-machine feedback) ​

  • Symptom: on real hardware, examples/native-app felt "laggy overall - scrolling, clicking and hovering all have 300-500 ms of latency", together with "the chart's push-point button does nothing, clicking a table header does not sort, and the virtual table will not scroll". In the code these look like three unrelated bugs; in fact it is one root cause plus one example-side wiring gap.
  • Root cause (identical shape in both branches): wm_present asked the system to redraw on every frame (InvalidateRect(NULL,FALSE) on _WIN32, gtk_widget_queue_draw on GTK), while the system's repaint callback (WM_PAINT / wm_on_draw) set need_draw = 1 again - so MoonBit's poll_event returned NeedsDraw, dirty became true, a full frame was painted, present ran, and the cycle repeated: an endless loop. Two consequences: ① the app was never idle - wm_wait always returned immediately because a paint message was always queued, every tick repainted 1400x3520 ~ 4.9M pixels, and input could only be serviced after a whole frame, which is exactly the 300-500 ms latency; ② the mouse-move events accumulated during one frame (easily 100+ with a high-polling-rate mouse) filled the 64-slot event queue, and wm_push's "drop when full" rule then threw away the button-up and wheel events that followed - that is the "button does nothing / header does not sort / wheel does not scroll". Note that all three symptoms hold simultaneously within one frame: the Down made it into the queue, the Up was dropped, the widget stayed in its pressed state, and the button simply looks broken.
  • Fix (both branches, kept symmetric): the repaint callback now only blits the cached frame and no longer requests a repaint (WM_PAINT sets need_draw only when there is no frame to blit yet; wm_on_draw likewise). The sole source of repaint requests is back to the MoonBit side's schedule_paint (@core.invalidate / App::schedule_paint), which is the stack's documented contract anyway: paint only when content changed. The first frame is unaffected - wm_show already sets need_draw = 1 (wm_mark_draw).
  • The other half: coalescing consecutive moves in the queue. In wm_push, if the last entry in the queue is also an unconsumed move, it is replaced by the new one (a pointer position is state, not a stream). Replacement only happens when the last entry is a move, so a Down/Up in between is never disturbed and FIFO semantics survive. This one must stay even with the loop fixed: any single frame (still on the order of a hundred milliseconds) can accumulate dozens of moves.
  • Window height 3520 -> 900 (example side): viewport culling (widget_cull_skip) paints only the part inside the clip, so the window height directly determines the per-frame pixel count; now that enable_root_scroll exists, the tall column whose only purpose was "show the whole page in one screen" is pure cost. It is now the APP_WIN_H constant - set it back to 3520 for a full-page view.
  • "Sorting does not work" is an example wiring gap, not a component bug: a header click only flips the TableSort store (the component deliberately does not sort for the caller — that rule is documented on TableSort), and the example never subscribed to it to write the reordered rows back. A @ui.sort_rows reference implementation was added (leading digits compared numerically first — the demo data is mixed "12 处/6 处" style, where plain lexicographic order puts 12 before 6 and looks like nothing happened; plus a stable sort so blocks of equal keys do not shuffle), and the example wires it up in two or three lines.
  • Lesson: "never idle" bugs show up on real hardware as a pile of unrelated broken features, not as a performance problem. Next time "clicking does nothing + lag + cannot scroll" appear together, check the enqueue rate versus consume rate of the event queue first, then look for a self-sustaining repaint loop — both silently drop events, and dropping leaves no log at all.
  • Verification: moon check --deny-warn is clean for both the library module and the examples module (two injected-error probes confirmed the gate really covers those modules); gui/win/stub.c compiles standalone under MSVC (cl /c /W4 /utf-8) with a warning count byte-for-byte identical to before the change (only the 2 pre-existing C4996); table_wbtest.mbt gains 2 tests (sort rule + stability). The real-machine behavior (whether latency really drops to "one frame", whether clicks stop being dropped) belongs to _Windows真机测试清单.md for re-verification.

Three long-red library tests on Windows closed out (2026-10-13, W9 / table-sort follow-up) ​

After the W9 and table-sort batches landed, the Windows local library suite carried 3 permanent reds (moon test as one combined run also sporadically times out on this machine; run it package-by-package to stay stable). Root causes and fixes:

  • moon test runs test processes with cwd = module root (gui/), regardless of the invocation directory (probed via yue_sys_getcwd: moon test gui/sys from the repo root and moon test sys from gui/ both report cwd gui/). So the repo-root-relative path examples/showcase/assets/dot.gif used by wbtests never exists in tests; the GIF decode case hit None => assert_true(false) permanently, and ui_decode_guard() could never find the image, so the ui GIF component assertions were silently skipping. Fix: tests now probe two candidates (examples/... and ../examples/..., whichever decodes wins — test_asset in image_wbtest, gif_asset in basic_wbtest); the guard and the component tests now really run on Windows.
  • Image::resize only opened a Cairo session; Windows has no Cairo backend, so the whole frame came back empty — upscaled pixels read (0,0,0,0). Cairo::over returns None on backend-less platforms and the old implementation just gave up. Fix: route through Painter::draw_image (stretching blit implemented on both the Cairo and pure-raster backends), platform-free.
  • compare_cells only read a leading integer — not natural sort — "虚拟行 3" starts with a non-digit and fell back to lexical order, putting "虚拟行 12" before it (a platform-independent real bug; Linux should have failed too — the case simply never met a green CI run since landing). Fix: rewritten as natural order (char-by-char; when both sides sit on a digit run, compare the whole run numerically; equal runs are treated as equal and continue; the string that runs out first sorts first); leading_int removed.
  • Per-tick animation cadence differs by platform (test contract): Windows (WIC) img_anim_advance is one frame per call (the component asks once per delay_ms beat; no internal clock); Linux (io-gif) anchors on a virtual clock, so frames advanced per beat differ. The basic_wbtest window "12 beats → 2..6 frames" was calibrated on Linux; replaced with the platform-neutral invariant "12 beats advance at least 2 frames and at most 12 (a guard against multi-frames-per-beat runaway)".
  • Verification: all green on Windows, package-by-package — core 132, ui 125, render 43, text/theme/icons 41, sys/input/win/dbus 280, chart/mdview/web 233, 854/854; moon check zero warnings. The combined run's sporadic SIGTERM timeout is a local-machine phenomenon; CI (Linux) and the per-package gate are unaffected.

macOS (CI build chain verified, GUI pending real-machine) ​

  • The libyue v0.15.6 release ships ARC / no-ARC dual libraries: Darwin link flags = main library + -lyue_mbt_noarc (no-ARC symbols are referenced by the main library, so it goes after) + frameworks AppKit / Carbon / IOKit / Security / WebKit / OpenDirectory + -lobjc -lc++ -lpthread -lbsm -Wl,-dead_strip; the prebuild Darwin branch is pre-configured accordingly.
  • CI (macos runner) covers build + tests; headless runners have no WindowServer, so no GUI smoke.
  • #else fallbacks in shim platform branches swallow macOS: borderless needs #elif defined(OS_WIN); CurrentDirForDrag splits into three branches (mac uses getcwd); Window::SetSkipTaskbar / SetIcon / App::SetID have no macOS declarations — guard the calls as no-ops.
  • AppleClang 17 (after the macos-15 image update) parses the return of getRed:green:blue:alpha: as void, making ![...] a compile error; the value is guaranteed after the nil check and sRGB conversion, so discarding the return compiles under both BOOL/void parses (69136b7 — once lost to a full-tree rollback and picked back up; when a rollback baseline contains a sick file, later fixes vanish with it, so cross-check that file's history before re-pushing vendor).
  • Three root causes of the CI red streak before the 0.5.0 release (since 9-22; Linux/macOS red, Windows green): ① the AppleClang compile error above blocking prepare; ② the missing extern "C" (see the ABI section) blocking the link; ③ sysmonitor's S4 hardware-sampling test asserting "coretemp always has a Package sensor" — virtual-machine runners lack the hardware, so environmental gaps (no sensors / no DISPLAY) must skip, not assert. Also: the CI native-layer cache key must include the shim source hash (with only prepare.py in the key, a shim change keeps the old key and the restored build/ cache holds a stale shim archive); and with no Actions log access, slicing the failing output into ::error annotations (the check-runs annotations API is anonymously readable) is the only forensics channel.

ffmpeg CLI video decode route (whole frame-set read + offset slicing) ​

  • Environment: Ubuntu 24.04, ffmpeg 6.1.1 + ffprobe (apt). Decision: mooncakes has no ffmpeg binding (an ABI surface +100 is not accepted), so video decoding goes through the ffmpeg CLI: vidf_probe uses ffprobe CSV (-show_entries stream=... -of csv=p=0), vidf_extract decodes into a single continuous frame file with -f rawvideo -pix_fmt rgba (the rawvideo muxer writes frames in order, so no image2 sequence is needed), then read_binary_file slurps it and slices by frame-number offset — frame data never travels through stdout (pr_run's stdout capture has text semantics, binary would corrupt it), so ffmpeg writes the file directly.
  • Memory clamp: frame-set size = frames × width × height × 4; the defaults are fps=8 / max_frames=240; 480p at 12fps for 60 seconds would reach ~630MB, so long videos must be downsampled — stated in the doc.
  • ffprobe CSV parsing traps: r_frame_rate is a fraction ("30000/1001"), and the format duration is a bare decimal line ("2.000000") with no label prefix — dispatch on the "video,/audio, prefixes and treat the remaining numeric lines as the duration"; the delimiter is a comma and fields carry no quotes (probe output contains no commas inside fields, so no escaping is handled).
  • Full-chain real-machine verification: lavfi testsrc generated a 2-second 160x120@5 AVI (AVI chosen to match the requesting scenario's container), probe returned the right width/height/fps, extracting at 8fps gave 9 frames, and the first frame sliced to 76800 bytes; on the systemprobe board, clicking "载入演示视频" really rendered the testsrc colour bars (confirmed by an xdotool screenshot), and the status bar read "已载入 160x120 × 16 帧(@8fps,循环播放)".
  • A/V sync: the audio track is extracted to WAV and handed to AudioEngine, each starting from 0 — approximate sync; exact sync needs a shared playback clock, deferred to a later batch.

Media split into three layers (yue zero-ffmpeg / gui-media optional layer / ffmpeg-mbt FFI) ​

  • Background: VideoPlayer once lived directly in yue and pulled in ffmpeg FFI — moon dependencies are declared at module level, so as soon as yue imports ffmpeg, every GUI-library user's build runs ffmpeg's prebuild (and fails without the dev package), infecting users who never touch video. Audio had gone through CorvusCinereus/miniaudio (a MoonBit dependency); after the user's 2026-10 decision "audio goes to ffmpeg too", audio was unified as well: yue core dropped AudioEngine/audio.mbt, and both audio and video now live in the optional layer.
  • Three layers: ① yue core (zero ffmpeg, zero miniaudio, pure GUI); ② modules/ffmpeg-mbt (FFI bindings, zero yue, moon name NoahLiu/ffmpeg-mbt — the user's chosen name, ffmpeg training-data pollution is heavy enough that the bare name ffmpeg was not claimed); ③ modules/gui-media (VideoPlayer/AudioPlayer plus the player components, depending on the first two) — anyone who wants media playback imports this layer, and skipping it costs zero extra dependencies. Workspace members reference each other: the referenced module appears in the main moon.mod import list with a version number, and moon resolves the workspace member locally (no registry); renaming inside the workspace (ffmpeg → ffmpeg-mbt) needs three places in sync: moon.work members, the main and dependent moon.mod imports, and the package field of prebuild.py's link_configs (the package name is hard-coded there, and a missed rename reports "Link config package name ... does not start with module name").
  • "Video shows only the first frame" root cause: the old playback clock only advanced a position Ref and nobody called the picture container's schedule_paint — GTK never repainted, so it stayed on frame 1. Fix: the component's 50ms clock calls vp_tick() (frame-by-frame decode over FFI) and ViewLike::schedule_paint(cv) as a pair.
  • FFI streaming semantics: the playback clock decodes one frame per tick at the fps (decode_rgba is a sequential stream), and seek is timestamp-level (Demuxer::seek → avformat_seek_file + decoder flush); when the container has no duration metadata (lavfi AVI reports N/A) duration is 0 and the progress bar treats it as 0 (frames still play).
  • Cross-package component infrastructure: yue's attach/themed_container/set_panel_bg/theme_border/theme_bg_panel were package-private; the yue-video component layer needs them, so these five were minimally made pub (the component-extension API).
  • yue-side interface alignment: after VideoPlayer moved to yue-video, vpc_frame_image (RGBA→PNG→Image) replaced calling vid_frame_image directly; audio slicing/extraction kept the CLI single commands (audiof_extract_wav/audiof_supported), with FFI audio PCM direct output left for later.
  • Real-machine verification: sustained synthetic X11 clicks (xdotool mousedown/up) kept being intercepted by XFCE's click-to-focus (motion events arrived, button events did not), so playback visual checks were left to real hardware; the logic layer is fully asserted in wbtest (make→play→vp_tick advances frames→seek clears and re-decodes→pause/stop→free). The demo board's video page auto-plays on load.

Declarative root container height collapse (mount_window default flex) ​

  • Environment: Ubuntu 24.04 + X11 + XFCE, the systemprobe example. Symptom: mount_window([scroll(vbox(...))]) opened a blank window (background colour only, no content, process healthy).
  • Root cause: mount_window's root container carried no styles, so the window's content area performed no flexible allocation; a Scroll's viewport height comes from flex allocation and collapses to 0 without a height constraint. showcase missed this because its outer layer happened to have an hbox(style=[("flex", 1.0), ("alignItems", "stretch")]) wrapper.
  • Fix: mount_window's root container defaults to style=[("flex", 1.0)] (a window root means "fill the window", so defaulting is safe; mount() targets a subtree under any parent container and is not defaulted, to avoid breaking existing layouts); scroll's doc comment gained a flexible-height warning; systemprobe's scroll also passes ("flex", 1.0) explicitly (belt and braces, and scroll children that supply their own flex keep working).
  • Verification: xdotool + import cropped the window region and measured pixel variance — pure background colour before, fully rendered after (title / button / radar chart visible); no regression in showcase/hello.

Toolchain version upgrade (fork route dropped) ​

  • Environment: this machine's toolchain had been switched to moonc v0.10.12+1634b282e (core 0.10.12) while moonsqlitefile 0.7/0.8 across the board and moonbitlang/async both require the v0.10.14 API (Bytes::exact_view, eprintln); CI pinned no version (install.sh installs the latest), so the drift broke the whole-repo build after a clean, hidden by stale _build caches.
  • Decision evolution: moonsqlitefile 0.8.0 was vendored into third_party/ as an emergency patch (exact_view rewritten as a byte slice); once the right answer was confirmed to be upgrading the toolchain rather than forking around it (forking an ecosystem package is a long-term maintenance debt taken only for an emergency), the official install.sh upgraded moon to v0.10.14, the third_party fork was withdrawn, prowk/moonsqlitefile@0.8.0 came back as the official dependency, and sysmonitor's four String::exact_view sites returned to the upstream form.
  • Conclusion: a version gap is always solved by upgrading the toolchain (aligning with CI), never by forking an ecosystem package; third_party/ keeps only vendored code with genuinely no alternative.
  • Verification: after moon clean, a full rebuild is warning-free at moon check, moon test 575 all green, moon build passes.
  • Shape: modules/ffmpeg-mbt is a moon.work workspace member (module NoahLiu/ffmpeg-mbt), sitting beside the main module (yue does not import it, so the GUI library is never saddled with a system-library dependency; the main module declares it because examples use @ffmbt directly, and GUI library users are not infected). Dynamic linking, zero vendoring: pkg-config probes libav* (including libswresample for audio) at compile time and the system .so files load at run time.
  • Link propagation: a library package's moon.pkg link section never reaches the final link (the old trap in AGENTS rule 2) — so, following the main repo's architecture, the submodule gets its own prebuild.py (moon.mod options --moonbit-unstable-prebuild) emitting {"link_configs":[{"package":"NoahLiu/ffmpeg/src","link_flags":"-lavformat ..."}]}, which propagates automatically to dependents' main packages; the link section was removed from moon.pkg. How to test: run moon test src inside the module directory (a full test from the workspace root also builds examples, where other people's unfinished code interferes).
  • Three FFI traps (located with gdb on the real machine): ① extern type declarations must be #external — without it the GC treats a raw pointer returned over FFI as a GC heap object and drop calls mi_free, segfaulting (crash stack moonbit_drop_object→mi_free); ② the C side never returns NULL on failure — a uniform dumb handle plus a vf_ok probe instead (NULL↔Option conversion semantics are not well defined); ③ intermediate raw values on the MoonBit side are wrapped into a struct field immediately before being passed (miniaudio's retention pattern). FFI pointer arguments each carry a #borrow annotation per the new toolchain's requirement.
  • Demuxer shape (after audio merged in, 2026-10): open only opens the container and probes the streams (vstream/astream); the video and audio decoders are opened explicitly on demand (open_video_decoder RGBA/sws, open_audio_decoder s16 interleaved/swr) — audio-only files are no longer rejected (the old NoVideoStream straight-to-Err trap). On one Demuxer instance the video and audio decoders share the AVFormatContext read cursor and eat each other's packets, so video-with-audio scenarios require two Demuxers (one per open on the same file — exactly the VideoPlayer shape).
  • Audio decode traps: ① vf_decode_pcm's EOF drain — after av_read_frame returns AVERROR_EOF you still call avcodec_send_packet(NULL) and keep receiving frames to drain the decoder's residue (an AAC tail ≈ 1 frame of samples), otherwise the tail is lost (measured: m4a 45056 frames vs wav 44100 — the difference is the encoder delay); ② after a seek, swr must be re-inited to drop the old position's resampling cache, otherwise you get leftover audio.
  • Test assets are committed: src/testdata/ holds mp3/ogg/m4a/wav sine tones of 1 second each + a testsrc AVI + av_1s.mp4 with an audio track, generated with ffmpeg lavfi, under 120KB total — removing the "本机 trae 素材路径" dependency and making CI reproducible. moon test's cwd is each module directory (in a workspace-wide test the path src/testdata/... resolves inside the ffmpeg-mbt package and fails inside gui-media), so cross-module asset references use ../ffmpeg-mbt/src/testdata/....
  • Real-machine verification: PCM direct output for all four audio formats (cumulative frame count asserted within ±10% plus decoding continues after a seek) and video RGBA frame-by-frame / seek, all on committed assets, runnable in CI; the error path (missing file → OpenFailed) and version probing are self-contained.
  • Runtime library versions: Ubuntu 24.04 = libavcodec60 (avformat 60.16); CI matching this host needs the dev packages installed via apt (see modules/ffmpeg-mbt/README).

Audio across the full ffmpeg chain (miniaudio demoted to an embedded device layer) ​

  • Trigger: the user's machine failed to load an MP3 with "音频加载失败: Invalid file" while the browser played it. A standalone C program linking the same miniaudio 0.11.25 directly decoded standard libmp3lame MP3s and system MP3s fine — miniaudio upstream is not broken; the CorvusCinereus/miniaudio MoonBit wrapper had dirty error semantics (load_sound reused engine->result to carry error codes, so after one load failure the engine's result stayed poisoned and every later load reported Invalid file), and its decoder set was only WAV/MP3/FLAC (OGG needs stb_vorbis, m4a/aac have none built in). The user decided: audio also goes to ffmpeg (every format, same stack as video, one source for both).
  • Architecture: ffmpeg decodes, miniaudio only owns the output device. miniaudio.h (upstream single header 0.11.25) is vendored into modules/gui-media/src/ and compiled as a native-stub alongside audio_stub.c — miniaudio drops from "a MoonBit dependency (mooncakes)" to "an internal implementation detail of the player", appearing in no moon.mod import, invisible to anyone not pulling in gui-media.
  • Device-layer design (audio_stub.c): ma_device (playback, s16, channels/sample rate matching ffmpeg's decode output so there is no resampling) + a PCM chunk linked list under a mutex; the MoonBit layer's pump (50ms) pushes decode_pcm bytes into the queue (high-water 40KB ≈ 0.3s), the device callback thread drains and mixes, and volume is applied per sample inside the callback. Position = bytes consumed by the device (played_bytes) minus the base recorded at seek; natural end = demuxer EOF && queue empty. Seek = clear the queue + reposition both Demuxers + refill.
  • Degradation with no output device: in headless/CI environments where ma_device_init fails, a dumb device handle is returned (has_device=0), pushes are silently dropped and the state machine proceeds as usual (the pump advances position by tick × 25ms) — so AudioPlayer's smoke run passes every assertion even with no device, with no environment-specific skip branches.
  • AudioPlayer and VideoPlayer share this device layer; the components (audio_player_t/video_player_t) pump + refresh on a 50ms clock and stop after free via the closed flag.
  • Traps: ① in the C data_callback, queued_bytes must be recounted after consuming a chunk (mixing half-consumed and whole-block decrements leaks); ② ma_device_init takes three arguments (context, cfg, device) — omitting context is a compile error; ③ guard is a MoonBit reserved word (the loop sentinel name collided: Parse error); ④ a custom Node's mount closure returns a View, not a Node (so the tail is (row.mount)(parent)); ⑤ after reloading, the old player was freed while its component's 50ms clock kept pumping a freed FFI handle (dangling) — free() sets closed and the clock checks is_closed and returns false to stop; for the same reason VideoPlayer::duration now uses a field cached at make (after free, the old clock's refresh once called demuxer.duration_s() and dereferenced a freed fmt).
  • Real-machine verification: the AudioPlayer smoke ran a 1-second MP3 from play to natural end in 17 ticks (a real device consuming the queue, not the degraded path) plus seek / loop replay / error paths; VideoPlayer ran both routes (video frames + audio PCM queue) and the silent path with no audio track; the whole repo's 575 tests pass; systemprobe's smoke process stayed alive.

Player component reload crash and the seek-debounce storm (2026-10, real machine) ​

  • Symptom (user's machine): the video page crashed (segfault) after "选择本地视频", and the audio page froze after "选择本地音频". Reproduced locally (a GUI reproducer scripting the user's timing: reload a real 960x720 mp4 while the demo video plays): a deterministic segfault with the gdb stack frozen at vf_seek_s → d->fmt->streams[...], a dangling pointer.
  • Root cause (two layers stacked): ① slider_t's on_change fires for programmatic Store.set as well (subscriptions do not distinguish origin) — during playback the 50ms clock refreshes the progress bar every tick, each of which arms an 180ms seek-debounce timer (a seek storm: one redundant seek every ~230ms); ② on reload, vid_mount_player made the new player first (including a new device) and freed the old, while the old component's last debounce timer was still in flight and, 180ms later, called vf_seek_s on a freed demuxer — use-after-free. The trigger window is "the timer armed by the last programmatic refresh lands after the free", which reloading during demo playback always hits. The "freeze" is the other face of the same cause: running make() synchronously inside the FileDialog callback (a large file's dual-Demuxer find_stream_info plus device handshake, seconds) freezes the GTK main loop, which reads as a hang.
  • Fix (three layers): ① the component's refresh wraps pos_store.set in a syncing suppression flag (programmatic refresh no longer triggers on_change or arms seek timers); ② a user_seeking flag — while the user drags plus during the debounce, refresh skips the progress bar (also fixing the bar bouncing back to the playhead mid-drag); ③ closed guards added at the head of the debounce closure and of every public mutating method on both players (play/pause/stop/seek/set_volume/set_looping) — after free they are all no-ops and stray closures never touch FFI; ④ the audio page's FileDialog now sets "载入中" and defers make by set_timeout(50) (the dialog closes first, the UI renders first, so the synchronous cost no longer stacks on the modal loop).
  • Verification: the reproducer (100% segfault before) survived 3 rounds × 20 seconds after the fix; 575 tests pass; systemprobe smoke alive. Visual/interaction verification on real hardware belongs to the user.
  • Click-free GUI reproduction method (the key technique this round): xdotool synthetic clicks are intercepted by XFCE's click-to-focus (see earlier), so for "it crashes only when the user clicks" problems, script the user's timing with set_timeout — arm timers in the real operation sequence (0.3s autostart the demo video → 4s reload a large local file mid-playback → 12s reload again), hitting the crash window deterministically with zero interaction; pair it with import -window root differential screenshots to confirm the picture is updating. For gdb stacks use gdb -batch -ex "set environment DISPLAY :0.0" -ex "handle SIGSEGV stop nopass" -ex run -ex bt against _build/.../xxx.exe directly (gdb may lose DISPLAY, so set it inside gdb; moon run is a wrapper process, so gdb must target the exe).
  • Two dead ends worth recording: ① a yue application must call @yue.initialize() before creating any widget or window (Window::make inside mount_window depends on it too); omitting it in the reproducer segfaulted in nu::View::View() or printed Gtk-ERROR: Can't create a GtkStyleContext without a display connection — easily misread as an audio/video problem; ② pgrep -f matching moon run hits the moon parent process and fakes "process alive" — check the real exe process name or exit-code semantics; before gdb debugging, touch the source to force a rebuild so old artefacts cannot mislead.

Video playback clock and duration on different axes (frame counting / hard-coded fps, 2026-10, real machine) ​

  • Symptom (user's machine): "the video duration does not match" — the time text and progress bar drifted from the real length: videos above 12fps hit full scale long before the end (a 30fps video finished at pos = 2.5 × duration), those below never filled the bar; and the picture rate drifted with the real frame rate (decoding fixed at 20fps made a 30fps video play at 0.67×), while the audio device streamed in real time, so A/V error diverged as playback went on.
  • Root cause: vp_tick's playback clock was frame_no / fps, where fps is a make parameter (8 for the demo video, 12 for local files — all hard-coded by the caller) while duration is the container header's real seconds — two time axes with different scales, so dividing pos by duration is meaningless. The demo video was itself wrong: generated at rate=5 but passed fps=8, so its 2-second clip stopped at 1.25s (62% of the bar). The built-in test asset happens to be 12fps and the test happens to pass 12, so green unit tests masked the problem entirely — clock regressions must deliberately pass a wrong fps and assert that the time axis is unaffected.
  • Fix (wall clock + frame timestamps, on the same axis as duration): ffmpeg-mbt exposes frame-level information — vf_decode_next records the frame pts on success (best_effort_timestamp preferred, converted to seconds through the stream time base, read via vf_frame_pts_s; reset to -1 after a seek) and the stream's real rate vf_stream_fps (avg_frame_rate preferred, r_frame_rate as fallback); gui-media adds a monotonic wall clock mbt_now_ms (in audio_stub.c, CLOCK_MONOTONIC). VideoPlayer's clock is rewritten: play/seek record a base (position + wall clock), each tick computes target = base + real elapsed, decoding forward to the frame at that time (high-frame-rate videos skip frames to catch the clock, low-frame-rate pictures hold across ticks, catch-up capped at 8 frames/tick so a seek landing far from a keyframe never blocks the UI for a whole tick), and pos = clamp(target, duration) — always 1× speed, and at EOF the position is pinned to the duration (bar full). The fps parameter is demoted to the fallback rate for streams without pts (the stream's real rate wins). Bonus: the audio device consumes at sample rate and video advances on the wall clock, so the two rates are in the same scale by construction and the A/V gap drops from "diverging with the frame-rate ratio" to a constant.
  • Verification: the regression test deliberately passes fps=5 (the asset is really 12fps) and asserts position still follows timestamps (wall clock wound back 500ms → pos=0.5; wound past the duration → EOF pins duration=1.0 and it stops); ffmpeg-side pts is non-negative and monotonic, the last frame sits near the container duration, and seek resets it to -1. 576 tests pass; systemprobe smoke alive. Real-machine interaction (progress / time display) is re-verified by the user.

The audio progress bar "cannot be dragged" (current climbs from zero after a seek, 2026-10, real machine) ​

  • Symptom (user's machine): dragging the audio progress bar halfway and releasing snapped it straight back to the start — which reads as "it cannot be dragged". The video page's bar behaved normally (the two components share the same syncing/user_seeking structure, so the difference sits in the player layer).
  • Root cause: AudioPlayer's current() = seconds consumed by the device since played_base — its semantics are "amount consumed", not "position on the media timeline". Seek only reset played_base (the consumption base) and pos still accumulated from 0, so after seek(0.3) current climbed from ~0 and the component's 50ms refresh set the bar back to vp_pct(current≈0) = the start. VideoPlayer's seek sets pos = target directly, which is why video was fine. The component-level user_seeking drag lock only covers "dragging + 180ms debounce"; once the debounce fires the seek, the lock releases and the next refresh yanks the bar back.
  • Fix: AudioPlayer gained a position base pos_base (set to the target on seek, zeroed on stop / loop restart), and current() = pos_base + seconds consumed since the base (the dumb-device branch adds pos_base too, and the dumb-device EOF test in pump now uses current()) — back to media-timeline semantics, on the same axis as duration and the progress bar.
  • Verification: the regression asserts current ≥ 0.29 after seek(0.3) and does not fall back over one pump tick (covering both branches: real device = 0.3 + consumption, dumb device = 0.3 + 0.025); 576 tests pass; systemprobe smoke alive. Drag feel on real hardware is re-verified by the user.
  • Lesson: a "position" field's semantics must be self-consistent across the whole seek/play/pause lifecycle — when a consumption-counting clock (device bytes / frame counts) is converted into a position, seek must carry a position base, or the progress UI gets yanked back. Same family as the video-clock bug above: both are playback clocks detached from the media timeline, one off in rate, one off in origin.

Video aspect distortion and失效 transparent colour (HEX byte order + decoder single-side size, 2026-10, real machine + probe evidence) ​

  • Symptom (user's machine): "there is still no control-bar background — is transparency not supported?"; "the ratio is still wrong". The fullscreen/page letterbox maths had been fixed earlier and the user's re-test still failed.
  • Root cause 1 (transparency): nu::Color's 8-digit HEX is AARRGGBB order (alpha first) — the library's own argb_hex() emits #ffd46a6a. Every component value was written with RRGGBBAA semantics (#00000080 intended as 50% black), so AA=00 meant fully transparent and the control bar's translucent black plate, the slider's translucent track and the button hover overlays were all invisible; values like #ffffffe6 were only visible because their first three digits happened to be read as AA=ff. The dialog_t mask #00000059 had the same wrong order (AA=00), so the dialog's dark scrim had always been transparent (a pre-existing bug, fixed together). Probe evidence: #80000000 50% black, #ff000000 opaque black, #000000ff fully invisible (leading 00 = transparent).
  • Root cause 2 (aspect): the page called VideoPlayer::make(width=480, height=0), and the C layer's vf_open_video_decoder used the source value directly for a 0 size, so a 16:9 video decoded to 480×1080 via sws (a tall frame squashed horizontally) — no letterbox maths could rescue it. Fix: when only one side is given, complete the other from the source aspect ratio (ffmpeg's -1 semantics, both directions, minimum 1). Probe evidence: a 320x180 video passed width=480 decodes to 480x270 (480x180 before the fix).
  • Locating method: a GUI probe × screenshot pixel sampling (import -window plus ImageMagick pixel:p{x,y}) × temporary DBG prints of the frame size inside the component; a 2×2/combined matrix probe isolating each factor (absolute positioning, the drawing method and the height source were all ruled out, and "even opaque black does not show" finally pinned it on colour parsing). Note that probe-window screenshot coordinates include the title-bar offset, and eyeballing cropped images is faster than point sampling.
  • Dead ends: "absolute-positioned child containers do not render" was a false lead — the sample point simply fell outside the window; "the host's on_draw swallows child drawing" was suspected — the matrix proved absolute+on_draw renders normally, and everything reduced to the colour byte order.
  • Verification: 576 tests pass; probe numbers prove it (frame 480x270 + the bar background measured at 50% black); systemprobe smoke alive. Final appearance on real hardware is re-verified by the user.
  • Lesson: the library convention for colour values is ARGB (AARRGGBB) — put alpha first when writing an 8-digit HEX; any semi-transparent colour must be verified by real-machine/probe sampling, because fully transparent and semi-transparent are hard to tell apart on a dark background. Rewriting a decoder's output size (scaling) must preserve the aspect ratio — specifying one side implicitly means "complete the other proportionally".

The dialog mask came out pure black (background-colour alpha clamped opaque by GTK, 2026-10, user real-machine report + local proof) ​

  • Symptom (user's machine): dialog_t's scrim turned pure black and covered the content beneath — the expectation was 35% translucent black (#59000000).
  • Root cause: the scrim colour goes through ViewLike::set_background_color → the shim passing nu::Color(std::string) straight → the GTK backend's View::SetBackgroundColor, which builds CSS from Color::ToString(). libyue's (upstream and fork alike) ToString() emits rgba(r, g, b, a) with a as a 0-255 integer, while GTK CSS rgba alpha accepts only 0..1 and clamps out-of-range to 1.0 — measured here with gdk_rgba_parse: rgba(0,0,0,89)→alpha=1.000, rgba(0,0,0,128)→1.000, rgba(0,0,0,0.35)→0.350. So every 8-digit hex carrying alpha renders opaque through the background-colour path on Linux. 6af827c only fixed the byte order, and its "#80000000 = 50% black" probe conclusion came from the Painter fill path and does not hold for the background-colour path (on a dark background opaque black and 50% black are hard to tell apart — the source of the misjudgement).
  • Fix: the scrim became an on_draw + Painter set_fill_color("#59000000") + fill_rect (the same Cairo path as the control bar, alpha working; consistent with hover_group's existing "visible backgrounds must be self-drawn" conclusion); the absolute container gained an explicit height:"100%"; the set_background_color call was deleted; set_background_color's doc comment now says "#AARRGGBB, alpha not guaranteed to take effect — semi-transparent plates go through Painter". Whole-repo scan: the scrim was the only 8-digit alpha hex on the background-colour path; with_alpha outputs all flow through Painter and are fine.
  • Verification: the probe example (white content + a permanent visible dialog_t) screenshotted on the real machine — three sample points inside the scrim all read srgb(166,166,166) = #a6a6a6, bit-for-bit the theoretical value of #59000000 over #ffffff (255×(1−0x59/255)=166); moon check zero warnings + moon test 657 all green.
  • Lesson: semi-transparent plates always go through on_draw + Painter fills, never set_background_color (libyue's Color→CSS rgba clamps a 0-255 alpha to opaque); a probe conclusion must name its render path — the Painter fill and the background-colour CSS path behave differently for the same value.

Audio plays silent and only sounds while dragging the progress bar (has_device state test written backwards, 2026-10, real machine) ​

  • Symptom (user's machine): audio output was silent throughout, with a brief sound (~0.4s) only at the moment the progress bar was dragged. The video page's audio track was normal.
  • Root cause: the C layer's mbt_adev_has_device was written started == 0 ? 1 : 0 — started is a three-valued state (-1 = init failed / dumb device, 0 = initialized stopped, 1 = playing), so play() starting the device (started=1) immediately flipped "device present" to 0, sending pump down the dumb-device degradation branch: decoded bytes dropped, the queue never fed, the device consumed nothing (silent while the state machine carried on). The sound while dragging came from seek()'s ap_fill — it never checks has_device and pushes straight to the real device (whose callback thread is alive once started), and the queue (~0.43s) drained with nobody refilling it: exactly "a brief sound". Video was unaffected because VideoPlayer's vp_fill_audio never consults has_device and always pushes directly.
  • Locating method: a real-cadence probe — in wbtest, busy-wait on mbt_now_ms and print started/has_device/queued/consumed/current on every 50ms beat: the single line "after play started=1 has_device=0" settled it; with the fix the same probe shows queued ≈ high-water from t0 and consumed rising per tick (at 44100Hz exactly 0.05s per beat).
  • Fix: has_device became started >= 0 (stopped or playing both count as a real device; only init failure is a dumb one); the wbtest smoke moved to real cadence (25ms per beat, busy-waiting) and added a regression assertion "a playing real device must have PCM in flight" (auto-skipped in a device-less environment).
  • Two deeper lessons: ① a three-valued state flag's test must cover the whole range (==0 vs >=0 differ by one character and read "playing" as "no device"); ② a degradation branch that "works" masks the upstream test bug — the dumb-device design turned the wrong verdict into silence rather than an error, and the old wbtest hammered pump calls, borrowing the misjudged dumb branch to fast-forward 0.025/tick and "finish playing" (the illusion of completing 1 second in 17 ticks), so the test depended on the bug; device-consumption timing must be tested at real cadence (waiting per beat), never hammered.
  • Verification: the before/after probe comparison reproduces it; 576 tests pass (3 pre-existing environment failures); systemprobe smoke alive. Listening on real hardware is re-verified by the user.

Floating control-bar alignment unified, and the final cause of the background not rendering (absolute content-grown height × host on_draw, 2026-10, probe-proven) ​

  • Symptom (user's machine): on the control bar the play/pause, aspect/native and fullscreen buttons had different heights and did not line up; the progress slider and the loop checkbox were not vertically centred with the buttons; and after the earlier semi-transparent-black fix (6af827c changing to ARGB order) the background was still invisible.
  • Alignment unified: all three buttons fixed at height 28 (the play button's built-in intrinsic height is larger, so it is pinned with Button::make(style=[("height",28)]); aspect 44x28, fullscreen 28x28); slider_t carries a built-in marginBottom 10 and checkbox_t a 6, which push the controls above the row's midline — callers pass ("marginBottom", 0) in style to override (the base pushes first and the caller after, so it can be overridden), returning control to alignItems=center for true centring; the buttons' private marginLeft was dropped in favour of gap; the aspect label is measured with get_bounds_for and centred horizontally.
  • Final cause of the background not rendering: the component's control bar is absolute + bottom/left/right with no explicit height (grown by its content), and the host (the picture container) has its own on_draw (drawing video) — under this combination the bar's own on_draw fill is not rendered at all (child widgets still draw). An isolation matrix proved each factor: absolute positioning ✓, the drawing method ✓, the host on_draw ✓, content-grown height × host bg ✓ — only the "content-grown height × host on_draw" combination failed; giving the bar an explicit height: 40 (28 controls + 12 padding) restored it immediately, and pixel sampling showed the bar area = video colour × 50% black exactly. The deeper mechanism (the GTK draw chain against yoga content-measurement timing) awaits source-level confirmation, so the empirical rule stands first: an absolute child container with no explicit size must be given explicit width and height whenever its host installs a custom on_draw.
  • Dead ends: ① stdout pipe buffering — timeout killing the probe loses all println (a missing READY does not mean the program hung; only a natural exit flushes), and whether a window is mapped must be checked with xwininfo -root -tree against the real title; ② probe-window screenshot coordinates include the title-bar offset, so scan for the border before point sampling; ③ a change to get_bounds_for/alignment was once blamed for a hang — it was only the buffering illusion.
  • Verification: probe pixel sampling showed the bar's translucent black darkening by exactly 50%; a close-up screenshot showed the whole row vertically centred; 576 tests pass; systemprobe smoke alive. Appearance on real hardware is re-verified by the user.

Video player (FFI dual Demuxer: video frames + audio-track PCM direct) ​

  • Components: VideoPlayer + video_player_t (play/pause/progress drag/volume/loop/time text). After the 2026-10 audio merge: the audio track is a second independent Demuxer over the same file (its read cursor does not interfere with video), vp_tick decodes to the wall-clock target and refills the audio queue to the high-water mark (clock semantics: see "Video playback clock and duration on different axes"); sound and picture each start from the play/seek point, giving a tens-of-milliseconds offset (exact sync needs the audio cursor read back, deferred).
  • Floating control bar + fullscreen (2026-10, component layer only, zero platform code): the bar is position:absolute pinned to the picture's bottom (bottom/left/right 0, semi-transparent black via on_draw), the same absolute precedent as dialog_t/toast_layer; the controls are white-on-dark (label_t/checkbox_t/slider_t each gained optional color/bar_color/track_color parameters — empty means the theme colour, because white text on a dark scrim is a general need and was promoted into the component library). Show/hide is a motion-wake state machine — enter/mouse_move shows and arms a stillness timer, hiding after 1s of stillness or after leaving (a 250ms short delay, so the picture's leave arriving first while the pointer slides across the bar cannot flicker), independent of playback state (the first cut's "always shown when paused/not started" was rejected by real-machine testing: the demo video ends in the stopped state and the bar would never hide, which reads as "moving in and out does nothing"); dragging progress (user_seeking) postpones it; when the pointer is over the bar, GTK sends the picture a leave first, and the bar's own enter/move cover it. The picture draws a black plate plus two display modes (aspect-fit letterbox / native 1:1 centred with overflow cropped, toggled by the bar's "等比/原始" text button). Fullscreen is a separate fullscreen window that self-draws the picture: its root container (flex:1, geometry from the window's layout) attaches the same picture-drawing function and events (enter/move/leave/double-click/Esc), and the bar is re-parented there (absolute follows the fullscreen width); re-parenting the picture container plus writing geometry with set_view_bounds is deliberately not done — a direct write is overwritten by any later layout event (e.g. the relayout triggered by the bar showing/hiding), which is the root cause of the user's real-machine report "after going fullscreen the video did not enlarge and the ratio changed"; leaving geometry to the window's layout is what stays stable. Four exit paths: the bar's button / Esc (the root container set_focusable + focus + on_key_down VKEY_ESCAPE) / double-clicking the picture (a ClickTracker counting consecutive clicks, since libyue's MouseEvent has no click_count) / WM close (on_close returns the bar as a fallback); on reload, the tick's is_closed branch also closes the fullscreen window. Mutual recursion between event wiring and the fullscreen definition is bound through a Ref[() -> Unit] indirection (MoonBit local functions have no forward reference, and calling a Ref field needs (x.val)()). Show/hide, fullscreen and the two modes are re-verified by the user on real hardware.
  • seek implementation: timestamp-level via avformat_seek_file (video/audio Demuxers seek separately + decoder flush + swr re-init), drag debounced at 180ms; the ffmpeg CLI slicing route is gone (the leftover CLI audio extraction file audio_extract.mbt was deleted — the media chain is CLI-free).
  • When the audio track fails (no track / decoder unavailable / no device) playback continues silently rather than blocking video.
  • Reload order must be "old player stop → free (closed set, the old refresh timers stop) → old root view remove_child_view → attach the new node"; any other order leaves leftover audio or timers, and correct order alone is not enough — after freeing the old player its in-flight timers (the seek debounce and others) still fire, so components must defend themselves with closed guards (see "Player component reload crash and the seek-debounce storm"). systemprobe's video page auto-loads 300ms after the first mount (make's synchronous cost); after the audio page moved to AudioPlayer: load plays immediately, the control bar audio_player_t attaches to a holder, a 200ms poll refreshes the is_playing label, and the demo asset became an MP3 (lavfi + libmp3lame).

VideoPlayer replay state machine: play after end does nothing / replay after stop is silent (2026-10, whitebox regression) ​

  • Symptom: after a video ends (EOF, non-looping), clicking play changed nothing — position pinned at the duration, picture on the last frame, audio queue empty; after stop, play did restart the picture but produced no sound at all.
  • Root cause 1 (play after end does nothing): vp_tick's EOF non-loop branch set state back to VpIdle, indistinguishable from the initial idle — play() treats VpIdle as "resume from the current position", and since pos=duration at the end, the target time always exceeds the duration while the demuxer already sits at EOF, so the first tick hit EOF again and stopped: the click did nothing.
  • Root cause 2 (replay after stop is silent): stop() only seeked the video demuxer; the audio demuxer is a separate container instance over the same file (its read cursor is independent of video, see the section above) and stayed at EOF — so on replay, vp_fill_audio's decode_pcm returned None at once, the queue stayed empty and output was silent. Every "back to the start" operation must seek both demuxers; missing one gives picture without sound.
  • Fix: ① an explicit VpEnded state added (mirroring AudioPlayer's ApEnded), with EOF non-loop transitioning to VpEnded; play() on VpEnded first runs vp_restart (both demuxers seek(0) + pos/base_pts/frame_no zeroed + audio queue refilled) before starting; ② stop() now also seek(0)s the audio demuxer; ③ seek() moves VpEnded to VpPaused — dragging the bar after the end and pressing play resumes from the drop point rather than being misjudged as a replay (replay semantics belong only to "press play again while parked at the end").
  • Design trade-off: an explicit state rather than a pos>=duration heuristic — during playback the target can exceed the duration before EOF, where pos is clamped to duration (a false positive for the heuristic, turning "paused at the end then play" into a replay back to 0), and with no duration header (duration=0) the heuristic fails outright; the explicit state is right in both cases.
  • Verification: whitebox control — stashing video_player.mbt back to the pre-fix state and running moon test -p gui-media makes the two new cases fail precisely (is_playing after end→play; the audio track's decode_pcm after stop→play), and with the fix restored 11/11 pass; whole repo moon check --deny-warn warning-free and moon test 641 all green (5 new over the previous 636: loop EOF→vp_restart, end→play, seek at end→play, stop→play, volume clamping). Real-machine interaction (clicking play after the end, replay after stop) is re-verified by the user.

MoonBit native GUI stack · pure-audio files rejected at the component layer, and the _t tail dropped (2026-10-16, user real-machine report) ​

  • Environment: Ubuntu 24.04 + X11 + XFCE, modules/gui-examples/audio loading assets/1.mp3.
  • Symptom: the right-hand red text read "打不开媒体:视频解码器打开失败:解码失败:无视频流" — not a single file would play in the audio example.
  • Root cause (two layers; only the lower one had ever been fixed): the Demuxer layer stopped rejecting audio-only files back in the 2026-10 audio merge (ffmpeg_wbtest.mbt asserts it), but the component layer's player called VideoPlayer::make unconditionally, and that engine's clock is wall clock plus frame pts, so open_video_decoder returns Err(DecodeFailed("无视频流")) with no video stream. AudioPlayer (device-consumed-bytes clock) existed as an engine all along and was complete, with no UI anywhere using it — the example's header comment claiming "audio-only files go through it too, the engine detects the missing video track" described code that was never written.
  • Why not fold audio-only into VideoPlayer: with no video stream, vp_tick gets decode_rgba() -> None on the first tick and reads that as EOF, flipping to VpEnded; making it audible means rebuilding a device clock inside the video engine — i.e. copying AudioPlayer. The two clocks are not on the same axis, so keeping them apart is right.
  • Fix: ① new component media/audio_player.mbt; its control bar shares the same zone constants and hit judgements as the video one (BAR_* / bar_hit / bar_speed_at), and the per-frame keys become ±5 second jumps on the audio side (ap_skip: with an unknown duration clamp only the lower bound, otherwise vp_clamp_t sends any target back to 0); when an outer layout grows the component, the extra height goes to the stage area — the no-output-device degradation note is written there and the spectrum animation lands there too. ② VideoPlayer::make now returns an actionable message when it probes no video stream ("纯音频文件请用 audio") instead of letting the decoder report "no video stream". ③ The tick period became one constant, ap_tick_ms — the component comment said 50ms while the muted position approximation assumed 25ms, and one number must serve both or progress drifts where there is no output device.
  • Naming: per the rename batch in docs/zh/design.md §2 the historical _t (themed-variant) tail is dropped and component names follow the Web elements — the player became video, the new one is audio (gui-media 0.1.0 was never published, so this breaks nobody); the shared player_bar_hit/player_speed_at became bar_hit/bar_speed_at. Note that audio_player_t/video_player_t are names of the deleted old chain (modules/gui-media/src) taking an engine instance, unrelated to this round; the historical entries in this file and in system-capabilities.md keep those names as they were.
  • Verification: moon test ./media 25/25 (new ap_skip case), modules/gui-media and modules/gui-examples warning-free under moon check --deny-warn, both examples build for native; whole-repo moon test 1179/1179. Does it sound, where -5s/+5s lands, how non-1x speed reads, and track switching are re-verified by the user.

MoonBit native GUI stack · audio spectrum animation (pure MoonBit FFT, no C, no libavfilter, 2026-10-16) ​

  • Shape: the tap sits in ap_fill after resampling and before the push — the engine exposes a pcm_tap callback that runs on the main thread in MoonBit and never in the device callback thread (rule 3, "no C-to-MoonBit callbacks", holds inside the component too). The sample ring keeps a 1024-point window plus 16384 frames of look-back, and painting looks back by queued_frames before computing — that pays back the ~0.23s the tap runs ahead of the ear (high-water 40000 bytes over 176400 B/s at 44.1kHz stereo s16). At non-1x speed the tapped stream is the resampled one, i.e. the same stream the ear gets.
  • Why not ffmpeg: showspectrum/ebur128 would first require a filter-graph surface in the bindings, costing far more C than it returns; a 1024-point radix-2 is about a hundred lines of MoonBit and negligible at 30fps — "if MoonBit can solve it, it does not go into C".
  • Acceptance checks (all headless): the FFT itself is pinned with a DC input (only bin 0 carries energy, the rest below 1e-6); band mapping with 440Hz and 1000Hz sines (24 log bands over 40Hz..16kHz put 440Hz near band 9); stereo downmix equals the mono shape; feeding two windows and looking back one window still lights the same band.
  • Toolchain pitfalls: ① a record field holding a closure must be applied as (self.tap)(...) — self.tap(...) parses as a method call and fails with Type AudioPlayer has no method tap; ② String.trim() yields a StringView, so anything wanting a String needs .to_owned() (same for s[0:n]); ③ when only the array contents change and the field is never reassigned, do not mark it mut — unused_mut is an error under --deny-warn.
  • Verification: moon test ./media 33/33 (4 new spectrum cases), whole-repo moon test 1187/1187, gui-media and gui-examples warning-free under --deny-warn, the audio example builds for native. Whether the bars keep up with the beat, whether it flickers after a seek, and the empty state in a device-less environment are re-verified by the user.

MoonBit native GUI stack · player control surface: icon buttons, three zoom modes, fullscreen, window-level keys (2026-10-16, user real-machine report) ​

  • Reported (by the user): the control bar was drawn text glyphs (▶ ❚❚ |< >|) — no hover, no focus ring, not in the Tab chain; the video component had no zoom and no fullscreen (the capability exists as App::set_fullscreen, but a widget cannot reach the window and nobody had wired it); neither player took keyboard input.
  • The icon library is enough: IconKind in gui/icons has video-play/video-pause/pause, d-arrow-left/right, full-screen/fullscreen-exit, zoom-in/zoom-out, sound/sound-fill, folder-open/file; the self-drawn side paints them with draw_icon(painter, kind, cx, cy, size, color).
  • Shape: the bar splits into real buttons on the left plus a self-drawn display region on the right (media/control_bar.mbt, one implementation shared by both components). Play/pause and fullscreen are ui.icon_button (hover shade, pressed shade, focus ring, Tab reach all come from ui); speed and zoom are ui.button(variant=Text) showing the current gear — an icon cannot say "1.25x" or "fill". Gear readouts go through Store + bind_node remount (Store::set does not re-emit equal values, so refreshing per tick costs no rebuild).
  • The old acceptance check was retired and rewritten: bar_hit used to split one drawn strip into six zones (play / frame step / speed / track / volume / time); it now covers only the display region's three zones (seek / volume / time text), and its coordinate becomes the display region's local x instead of the whole control's. The zone constants still live in exactly one place, read by both drawing and hit-testing.
  • Zoom: vp_fit_rect(vw, vh, rw, rh, mode) is pure — fit takes the smaller ratio, fill the larger (the widget rectangle clips the overflow), 1:1 uses native pixels, all centred; headless assertions pin the three placements and the zero rectangle for illegal sizes.
  • Fullscreen: the component reaches the window through @core.host_active() then App::set_fullscreen — that is the documented facade exit in core/app.mbt ("a widget cannot reach the window"), not a new back door. With no host (headless) it returns false and the local gear state stays put.
  • Keys: the window-level fallthrough channel of the previous section — space play/pause, Enter fullscreen, ←/→ (audio ±5s, video frame step), ↑/↓ volume, Esc exits fullscreen (and is only taken while actually fullscreen). Liveness is judged by watch.is_painted(): the channel has no unregistration, so a removed player stops being drawn from the next frame and therefore stops eating keys.
  • Dependency: gui-media/media therefore newly depends on NoahLiu/native-gui/ui (icon buttons need real widgets; redrawing a button set inside the media layer would be copying ui). The edge still points media → ui, ui knows nothing about media, so there is no cycle.
  • Verification: moon test ./media 35/35 (three new control_bar_wbtest.mbt cases: display zones, the three zoom placements, gear labels; the old six-zone check retired with the shape), moon check --deny-warn warning-free, both examples build for native. Real-machine checklist: do the icon buttons show hover and a focus ring, does Tab reach the play key, does space pause without clicking the window first, do Enter/Esc enter and leave fullscreen, is the fill mode's left/right overflow clipped, is 1:1 centred.

Store built-in dedup and T : Eq bounded impls (2026-10-13, frontend P3 step 0) ​

  • Policy: Store::set / Store::update get pub fn[T : Eq] bounded implementations (same value: store without notify, via Signal::set_eq); Signal::set and the notifier are untouched, and Store::signal() still reaches Signal::set for forced delivery. Use set_always / update_always for unconditional re-notify.
  • Pit ①: a same-name bounded impl shadows the generic one — calling self.set(...) from inside the unconstrained Store::update fails with Type T does not implement Eq, because the name resolves only to the bounded impl. Cross-constraint logic must call the low level directly (sig.set_eq / sig.update), not the public name.
  • Pit ②: dedup requires element Eq: Array[T]'s Eq derives from T : Eq. Composite value types need pub impl Eq for X with fn equal plus pub extend X with Eq::{not_equal, equal} (without extend you get the implicit_impl_as_method deprecation warning, same precedent as W21c); Eq for 2-tuples already exists in core and redefining it reports "already defined".
  • Pit ③: chart data windows (Array[LineSeries], TreeItem, GeoRect and friends) have no Eq on their elements and comparing the whole window costs more than it saves — chart and examples use set_always throughout; 33 call sites were pinned one by one by the compiler.
  • Verification: core 121/121 (new dedup regression: same value silent, changed value fires once, set_always forces, update follows the same policy); repo-wide moon check --deny-warn error- and warning-free; library tests 859/861 (the 2 reds are the pre-existing W9/sort baseline).

Animation go/no-go: under pure software raster a transition is a full-window repaint per frame (2026-10-14, sets P5's schedule) ​

  • Question: should P5 (transition = state-bucket property interpolation + frame driver) follow P4 immediately? The judgement cannot rest on feel, so a version was measured: experiment/anim_cost_bench (a typical page of 30 rows of "button + input + text", 1400×900, page.paint × 20 frames) reads on this machine full=16ms/20 frames, relayout=0ms/20 frames, a local 90×32 rounded fill <1ms/frame.
  • But this box is Linux, and the Painter::new probe picks the cairo backend (see the backend selection in render/painter.mbt, Cairo::over preferred), so 0.8ms/frame is the host-side submit cost handed to system cairo/X, not the CPU raster cost. The real pure-raster price sits on the Windows/mac PureBackend(Raster) path (§6.6's performance-batch real-machine reading: a full-window frame takes 300–500ms, and per-tier timing is still pending; text_bench on Windows measures ~0.35ms per boxed text draw, and one full-window repaint pays layout + ink for every on-screen text node).
  • Conclusion / policy: animation is continuous dirty-repaint. Before damage rects exist, a 150ms hover transition on Windows is about 9 full-window repaints — visibly stuttery, and it would undo the granularity promise that "typing repaints only that box". So P5 moves behind §6.6's "damage-rect repaint + text layout cache", with the go gate stated as: a local change (hovering one button) must cost < 16ms per frame. P4's opacity / box_shadow are static properties (one frame suffices) and are not held by this gate — they shipped first.
  • A toolchain pit found along the way: local is a reserved word (measured: [0035] The word 'local' is reserved), so let local = ... collides immediately; the probe variable was renamed part. When writing new code, treat words like alias / abbrev / local as reserved until probed (same source as §1.6's table).
  • The real-machine number still needed (run by the user, one command): start moon run examples/native-app on this machine, quit, and read the NATIVE-APP-PROF line (layout / paint / present accumulated plus the longest frame, always-on via core/prof.mbt) — that decides "how much damage rects buy" and serves as P5's go gate.

Declarative frontend P4: Painter global alpha and hard shadow (2026-10-13) ​

  • Global alpha lives in the facade state machine (PainterState.global_alpha, save/restore with the stack): fills/strokes (gradient stops included) scale colour alpha in apply_fill/apply_stroke; text goes through Painter::paint_color (parse the string → multiply the factor → re-emit hex at draw time); images/canvases run dim_bitmap (per-pixel alpha scale) before the blit when global_alpha < 1. Both backends (cairo / pure raster) get full-path coverage with zero backend changes, because the facade is the only entry.
  • Pit ①: asserting on the wrong channel — layering 50% red over an opaque target keeps the pixel alpha at 255 (alpha compositing changes colour, not coverage); alpha == 0 style checks must run on a transparent target, and gradients must be compared full.a vs 0.25.a on a blank bitmap.
  • Pit ②: when round_rect_path called begin_path internally, the shadow's "outer ring + inner ring" two subpaths were wiped by the second call — the tracer split into trace_round_rect / round_rect_path_ccw (no new path), so a ring = one outer loop plus one reversed inner loop, and nonzero winding strokes only the annulus without overlap darkening.
  • The Style.opacity channel: apply writes Widget.alpha_paint; the paint traversal sets p.set_global_alpha(inherited × own) on entering a widget and restores after its subtree (CSS-like box-tree accumulation). box_shadow currently ships the hard tier (offset rounded-rect rings, layers stroked at equal steps); the blur tier is re-evaluated once a blur capability exists.
  • Verification: 3 new render tests (full-path factor / rounded rect / shadow ring), render 45/46 (1 red is the pre-existing W9 baseline); ui's fill_round_rect moved into the facade with zero behaviour change for ui/nav; repo-wide moon check --deny-warn error- and warning-free; library tests 880/882 (same 2 reds). Real-machine look (the opacity demo page / shadowed cards) belongs to _人工测试清单.md.

Declarative frontend P2-e: frame-cost A/B and the idle-repaint gate for self-driving widgets (2026-10-14) ​

Real-hardware report: "page switching in showcase lags, section widgets render late". A controlled experiment came first (probe experiment/showcase_cost_bench: 14 pages × 120 text lines, 1835 drawn widgets, 20 full-page repaints, same machine and same tree, only the revision changes):

  • Readings: 380610a without the C-side text-layout cache = 9.9 / 11.95 ms per frame (warm/cold); 323b537 with it = 3.35 / 3.25; HEAD after the four P2-e batches = 3.4 / 3.35. So the text cache made full-page repaint roughly 3× faster, and per-frame style-slot reads (P2-e) sit inside the noise — they are not the lag. The cold variant (1680 distinct strings, above the cache's 1024 cap) did not regress to the no-cache level on the local Pango path, but the "clear the whole table on overflow" policy has never been measured on Windows/DWrite — the A/B harness is in place and the on-device ledger comes from SHOWCASE-PROF / showcase-prof.txt.
  • The gate reads "was I painted this frame": carousel rotation and gif advance ride the process-level @core.set_timeout, and previously they only checked rotation.paused — so a carousel whose page is hidden still switched panels and called invalidate(), repainting the whole window for nothing. The widget layer has no reference to its parent (parent↔child is a reference cycle the counting collector cannot break — see the split-layout batch), so "is some ancestor visible?" cannot be asked. Instead core keeps a process-level paint epoch: bump_paint_epoch() once per frame before the host's traversal (App::paint_frame), paint_at records it into Widget.paint_epoch only after the visible test and the viewport cull pass, and Widget::is_painted() answers "painted this frame". The self-driving callback becomes if !w.is_painted() { re-arm only — no page switch, no dirty }: hidden pages skip every wasted frame and resume on their own once visible again.
  • Related pit (order-sensitive tests): the epoch is process-level and shared by every test in the package. The pre-existing "fire a tick without painting" self-rotation tests passed by accident on 0 == 0; once a new test bumps the epoch they turn falsely red. Rule: a test that drives self-animation must paint one frame first (same shape as the real host), otherwise the assertion measures an initial zero rather than "was painted".
  • Verification: 1 new whitebox test (hide the block → fire a tick: no panel switch, the invalidate counter does not move; show it again → rotation continues), moon test ui 140/140, library-wide 872/872, cd gui && moon check --deny-warn warning-free; showcase prints SHOWCASE-PROF (frames / layout / paint / present / worst frame) at exit. If frames is still abnormally high, something else is spinning and that is the next check.

Damage-rect narrowing: section/page switches did not repaint (2026-10-14, real-hardware report) ​

  • Environment: examples/showcase walked on device after damage-rect repaint landed (frame bitmap reuse + painting clipped to the dirty region); the ledger is the SHOWCASE-PROF line printed at exit.
  • Symptom: switching a section or a page left the screen unchanged — the new content only appeared once the pointer hovered some widget. SHOWCASE-PROF (615 frames / 1323 ms, 2.2 ms average, 134 ms worst) showed nothing wrong: frames were being produced, just a tiny patch.
  • Root cause: App.dirty_rect : Rect? used None for two different meanings — "whole window is dirty" and "nothing is dirty yet". Within one batch, a whole-window request (invalidate() / schedule_paint / host_repaint_all all only write dirty_rect = None) could be followed by the pointer router marking the hover change through mark_widget_rect, whose shape is Some(rect_union(dirty_rect, w.rect)), and rect_union(None, r) = r. The whole-window request was therefore narrowed to the small rectangle under the mouse, so the frame repainted only that cell and kept the previous pixels everywhere else. "It refreshes when I hover" is exactly that narrowed rectangle covering the cell beneath the pointer.
  • Fix: App gains mut dirty_full : Bool, set by every whole-window request (schedule_paint, host_repaint_all). Local marking now goes through the pure function accept_widget_rect(full, cur, r) — with full set it holds the whole-window state instead of narrowing; paint_frame takes the frame's clip through dirty_clip(full, rect) (whole-window → None, i.e. no clip) and resets the flag and the region together. The two decisions are pure functions so they can be pinned: the host App needs a real window, these judgements do not.
  • General policy: an Option carrying "two different kinds of nothing" invites exactly this accident — when one None means "the whole window" and another means "empty set", any "union one more entry in" operation writes the strong meaning as the weak one. Either add a flag bit (this batch) or switch to an explicit sum (Whole | Region(Rect)).
  • Verification: 1 new core regression on the pure helpers (without a whole-window request the rectangles still union; with one, a local mark must not become a small rectangle; the clip prefers whole-window), moon test core 133/133, all packages individually 882/882 (13 packages each green), cd gui && moon check --deny-warn warning-free. Device re-check (user): switch sections/pages in showcase without moving the mouse and the new page should appear immediately; re-run moon run examples/showcase and paste the exit SHOWCASE-PROF line (frames must not grow because of this change — whole-window semantics are only preserved within a batch that already asked for it).

Windows-only GUID helpers sat outside the guard, so the library would not compile on non-Windows (2026-10-14) ​

  • Symptom: moon run examples/showcase / moon test ui would not start, reporting sys/sys_stub.c:2023:45: error: unknown type name 'GUID' (the power-scheme code).
  • Root cause: the two exported functions (yue_sys_pp_current_guid, yue_sys_pp_set) do have #if defined(_WIN32) branches ✓ in their bodies, but the two static helpers they share (sys_pp_guid_string / sys_pp_parse_guid) take GUID in their signatures and sit outside every guard — a static function still has to type-check even when never called, and on Linux/mac windows.h is not included.
  • Fix: both helpers wrapped in #if defined(_WIN32) ... #endif (they are only called from the Windows branch, so Windows behaviour changes by nothing).
  • General rule: in platform stubs, as soon as a type comes from a platform header (GUID / HINSTANCE / PangoLayout, …) the function must go wholesale into the guard — guarding only the body while leaving the signature outside does not count, and static helpers count the same. After adding C, run at least one compile on a non-target platform (cd gui && moon check) — that shows in one look whether the guard was missed.
  • Verification: moon check sys ui --deny-warn passes, moon build showcase --target native links, library-wide 924/924.
  • Same trap, second time (2026-10-14 windowctl batch): the same rule hit a second time — sys_win_find_ctx_t / sys_win_find / sys_win_find_cb / sys_mon_find_t / sys_mon_find_cb / sys_monitor_index / sys_win_enum_cb / sys_wcs_icontains, a set of static helpers whose signatures carry HWND / HMONITOR / BOOL CALLBACK, again sat outside every guard, and moon test sys on Linux died with sys_stub.c:2338: error: expected ‘=’, … before ‘sys_win_find_cb’. The fix is the same: both blocks (the enum helpers, and find + title matching) wrapped in #if defined(_WIN32); sys_win_blob_t / sys_win_blob_append / sys_win_enum_ctx_t only use stdlib and stay outside the guard.
  • Closing action (the precondition for it not recurring): whenever C is added for one platform, that same batch must run cd gui && moon test <that package> --target native on the opposite platform — exported functions never break, because their bodies carry an #else branch; only helpers outside the guard break, and only the opposite compiler sees them. Windows-only sys batches are therefore run on Linux, and win / dbus the other way round. Readings for this batch on Linux: moon test sys 182/182, library-wide 928/928, cd gui && moon check --deny-warn and cd examples && moon check --deny-warn warning-free, moon build showcase --target native links.
  • A third way of stepping on it (2026-10-14, platform batch 4, volume): the guard was drawn too wide — the whole section, including the two exported functions yue_sys_vol_get / yue_sys_vol_write, was wrapped in #if defined(_WIN32), even though each of their bodies already carries its own #else route. On Linux this surfaces not as a type error but as a link error (sys.whitebox_test.o: undefined reference to yue_sys_vol_write), because the MoonBit-side declaration is still written while the C symbol is gone. One more rule: exported function bodies always stay outside the guard (they carry both branches); the guard wraps only platform-specific helpers, constants and GUID tables, and its #endif must close at the end of the helper block, never over an exported function. Readings for this batch on Linux: sys 185/185, library-wide 932/932, moon build showcase --target native links.

Table hover lit only one cell, and "add a row" did nothing: damage covered only the deepest widget, and a dedup Store held the array it mutates (2026-10-14, real-hardware report) ​

  • Symptom ①: hovering a single-selection table lit only the cell under the pointer instead of the whole row (the user confirmed every table variant behaves this way).
  • Root cause ①: not a painting problem — a headless probe showed that after set_hover(cell.id) the whole 440 px row is fill_hover from x=10 to x=400 ✓. What was wrong is the damage region: hover is parent-chain semantics (the same meaning as CSS :hover; set_hover flags the whole ancestor chain hovered), but after pointer routing the host marked only the "old/new hovered widget's" rect. The row paints a row-wide background while the repaint window was one cell, so it read as "only one cell lights up".
  • Fix ①: App::mark_state_chain(id) marks dirty along the hit chain — the deepest widget is always marked, an ancestor only when its own drawing changes with state. "Changes with state" is recorded by @style.apply as Widget.paints_state (true when the style carries any of the hover/active/focus buckets); core does not know the style layer and only reads the boolean. With no state-changing ancestor on the chain the cost stays one cell rect — switching to chain marking never degrades back to whole-window.
  • Symptom ②: showcase's "添加一行" button grew no rows.
  • Root cause ②: Store's built-in dedup compares by value (set_eq when T : Eq), and the example stored the lang_base array object itself in the Store, then "add a row" did lang_base.push(...) in place and table_rows.set(lang_base) — old and new are the same object (whose content changed along with it), so they are necessarily equal ⇒ the notification is swallowed and the whole-table rebuild in the subscription never runs. Switching to set(rows.copy()) is swallowed the same way, because the stored object already holds the new content.
  • Fix ②: seed the store with a snapshot (Store::new(lang_base.copy())) so the stored value is not the object being mutated in place ⇒ after push, the new and old values really are comparable and the notification fires (the sort path benefits too: notify only when the order changes, skip when it does not — exactly what dedup is for).
  • General rules: ① any parent-chain state (hover / press / focus) must extend the damage region to ancestors that paint that state, otherwise local repaints produce a half-updated frame; ② "mutate an array in place, then set the same array" is a dead end on a deduplicating Store — hand it a fresh snapshot each time, or call set_always deliberately.
  • Verification: moon test core 136/136 with 1 new pin (a state-bucket ancestor merged into the dirty region → width ≥200; no state ancestor on the chain → dirty width ≤50.5), library-wide 922/922, cd gui && moon check --deny-warn and the examples module warning-free (also deleting App::mark_id_rect, now unreferenced). Device check: hovering lights the whole row; "添加一行" appends a row and the read-along echo follows.

The timeline connector came out broken: row gaps were left to row_gap, so no widget owned them (2026-10-14, real-hardware report) ​

  • Symptom: on showcase "数据展示 → 时间线" the vertical line between the three nodes was broken — a 12 px gap at every join.
  • Root cause: each row's marker cell drew its line only inside its own box (fill_rect(9, top, 2, bottom - top), with top/bottom clamped to the dot centre on the first/last rows), while the 12 px between rows came from the container's row_gap — that band belongs to no widget, so nobody painted it and the line broke at every gap.
  • Fix: the row spacing is painted inside the row (a non-last marker and row box grow to height + gap; the last row carries no extra), and timeline no longer passes row_gap. The dot centre still comes from the content band (cy = height / 2), so dot positions and the first/last start-end are unchanged — each row just owns one more segment of line. Measured row heights after the fix: 68 / 68 / 56 at y = 0 / 68 / 136 (total height unchanged).
  • General rule: anything that must run continuously across rows (connector lines, timeline spines, table separators, scrollbar tracks) cannot depend on container gaps — the gap is not a paintable unit. Either fold the spacing into the row (this batch) or have the container draw the continuous line itself.
  • Verification: moon test ui 156/156 — the timeline case now asserts the broken-line row heights (68, 68, 56) and adds two pixel checks that a line must exist inside the gaps (pixel(10,60), pixel(10,130)); control experiment: putting row_gap back with no in-row spacing turns it red on the spot at (56,56,56) != (68,68,56). Library-wide 921/921, cd gui && moon check --deny-warn and the examples module warning-free. Device check: the spine runs unbroken from the first dot to the last.
  • Symptom: on showcase "导航组件 → 面包屑", clicking the last crumb ("基础组件") did nothing.
  • Root cause: the component's own comment says "the current item is dark and not clickable", but the code decided it by position — is_last served both as "current" for the dark colour and as "not clickable" for the on_click guard. The demo passes a static three-item list ["数据展示", "导航组件", "基础组件"] while the current page is the middle one ⇒ the last crumb was neither the current page nor clickable. The component logic was fine (non-last crumbs really write the Store); what was wrong is the definition of "current" — the navigation state is the only authority.
  • Fix: clickability and colour both key off selected.get() == mine: the crumb equal to the navigation state is dark and does not change the value, every other crumb navigates directly (including the last one). The / separators stay non-clickable.
  • Test side: the nav_wbtest case asserted "the last item does not change the value" — written against the wrong rule — and now asserts "the last item navigates too + the current item does not + separators do not"; the comment states that the title occupies a slot. Control experiment: putting the positional predicate back (i < n - 1) turns it red at "列表" != "详情", reverting makes it green. The showcase section description was corrected too (it promised "no special highlight for the current item, clicking navigates directly", matching neither the old nor the new code).
  • General rule: "which item is current" has exactly one source — the bound Store. Any positional stand-in (last/first) used in place of state shows up as a "looks clickable but never fires" dead corner as soon as the demo hierarchy differs from the real one.
  • Verification: moon test ui 156/156, library-wide 921/921, cd gui && moon check --deny-warn and the examples module warning-free. Device check: each of the three crumbs switches page, and only the one matching the current page stays inert.

The time popup drew its title over the panel instead of occupying a slot (2026-10-14, real-hardware report) ​

  • Symptom: in the time picker popup the top "00:00" overlapped the first [−] 时 [+] row.
  • Root cause: that time line is not a child widget — the panel's own on_draw painted it onto (0, 0, w, 28) as floating ink, while the steppers and buttons are real children laid out from the top=10 padding ⇒ the first row landed at y=10..42, pressed under the 0..28 title. Painting something "as a header" does not reserve a layout slot — the same trap was already recorded for md_marker (a marker cell with no registered measure got height 0 and the number dropped onto the baseline).
  • Fix: the title became a child that occupies space (height 28, width 100%), and the panel's on_draw keeps only the background and border; padding was completed on all four sides (it had only top/left, so the button row sat flush against the bottom edge). Measured after the fix, the popup children run y=10 h=28 (title) → 38/32 → 70/32 → 102/28, the panel is 140 tall, and neighbours never overlap.
  • Test side: three existing cases indexed the popup as panel.children()[0] / [2] for the stepper row and button row; with the title now child 0 they shift by one and the comment states "the title is a space-occupying child". A new layout pin asserts: 4 children, the first is the 28-high title, consecutive children do not overlap, and the panel height contains the content.
  • Verification: moon test ui 156/156, library-wide 921/921, cd gui && moon check --deny-warn and the examples module warning-free. Device check: the popup's time sits on its own row, the two stepper rows and cancel/confirm follow in order with even padding all round.

stroke_rect strokes inward while callers passed centered coordinates: the ring sat 1 px off to the bottom-right (2026-10-14, real-hardware report) ​

  • Symptom: in the colour picker each swatch's border looked displaced — a 1 px strip of fill survived on the left/top while the right/bottom edge was flush.
  • Root cause (measured pixel by pixel): the facade contract fixes stroke_rect as drawing inside the rectangle (both backends implement it that way: the cairo stub cr_stroke_rect and the pure raster Raster::stroke_rect both draw inward by lw and snap to pixels), yet 28 call sites used the Canvas/CSS centered-stroke habit and passed stroke_rect(0.5, 0.5, w - 1.0, h - 1.0). Under inner semantics that call puts the ring in [0.5, 1.5] — snapped to column 1 — while the right/bottom edges land on the last column ⇒ the whole ring shifts 1 px down-right. Probe reading (a 26×26 swatch, middle row): before F B F…F B (column 0 is fill), after B F…F B (columns 0 and 25 are border).
  • Conversion (centered → inner): (x, y, w, h) at line width lw becomes (x - lw/2, y - lw/2, w + lw, h + lw). A 1 px outer ring is therefore stroke_rect(0.0, 0.0, r.w, r.h); the focus ring inset by one pixel, stroke_rect(1.5, 1.5, w - 3.0, h - 3.0), becomes stroke_rect(1.0, 1.0, w - 2.0, h - 2.0).
  • Scope: the ui family (overlay / datepicker / form / virtlist / table / nav / datetime / scroll / message_box / declarative) and the chart family (tooltip / boxplot / draw / treemap), plus two example sites (sysmonitor, showcase's events/canvas) — 28 sites in total. fill_rect / clip_rect are unaffected (they were always integer-box semantics).
  • One test followed the geometry: the button focus-ring case in form_wbtest sampled column 1 — written against the offset geometry — and now samples column 0, with the comment stating "inner stroke, ring flush with the box edge". A new swatch symmetry pin (left column equals right column, top equals bottom, border differs from fill) turns any offset regression red.
  • Verification: moon test ui 155/155, library-wide 920/920, cd gui && moon check --deny-warn and the examples module warning-free. Device check (this machine, Linux/X11): every swatch border is equal on all four sides with no exposed fill, and the 1 px borders of buttons / inputs / panels / overlays line up on all edges.

Text inputs could not take keyboard focus by default: the focus_id=0 trap (2026-10-14, real-hardware report) ​

  • Symptom: on the showcase "表单组件" page an input could not be clicked into or typed in; that page was missing the EP-style clear ✕; the "数据输入" stepper's three cells looked uneven in height and did not join into one control.
  • Root cause ① (cannot click in): the host decides focusability as "the first non-zero focus_id on the hit path" (Widget::focusable_at), while input / textarea default focus_id? to 0 — without an explicit number the box is permanently unfocusable. Examples and users both had to remember to assign one, which plants "cannot type in an input" into every call site (probe: with no focus_id, focus_id=0 and focusable_at returns None).
  • Fix ①: text components now auto-allocate a focus number (auto_focus_id: when none was passed, count up from 1000000 so it never collides with hand-written small ids like 21/101; an explicit value still wins). Tab cycling follows registration order = mount order. select / button keep the original rule (their focusability is deliberately opt-in and involves overlay and activation-key semantics, out of scope for this batch). Note: a number alone was not enough — it still had to enter the host's focus stack, and at the time "whoever builds a widget registers its number" was a discipline that only held inside examples, so the form page still could not type; the hole is closed by 《Keyboard focus was dead everywhere》 at the end of this section.
  • Root cause ② (focus ring / caret never repaints): App::mark_focus_rect fed the focus-stack id straight into mark_id_rect, which looks widgets up by instance id (root.by_id) — two different id spaces: a miss falls back to a whole-window repaint (wasted), and an accidental hit marks some other widget's rect, so the newly focused box's ring and caret never repaint that frame.
  • Fix ②: new App::mark_focus_id_rect looks the widget up through find_by_focus_id (whole-window fallback only when it truly is not in the tree); the one mark_id_rect(id) in the Down path switched over too.
  • clearable (the ✕): input gained the same-named parameter (off by default, like EP): with content and (hovered or focused) it paints a 14×14 ✕ at the right edge — a small background patch laid first so long text cannot collide with it; clicking it clears the whole box, writes back to the Store, and that press no longer positions the caret. Disabled and read-only boxes never paint it. Password fields get none (EP parity).
  • Stepper shares one frame: the old [-] value [+] drew three independent borders (rounded ends, square middle), which read as three unjoined boxes — measured, the three cells were already the same height (both equal control_height); what looked uneven was the borders. Now the row container draws the frame once (background / border / radius / 1px from the style slot) and each cell paints only its own hover fill (inset 1px so the shared frame survives), its content glyph, and the separator on its inner edge. The group width is fixed at 108 and no longer stretches with its parent.
  • Verification: moon test ui 154/154 with 3 new cases — an input with no focus_id is still hit by focusable_at and typing writes back to the Store (plus explicit ids winning and two auto ids differing); clearable changes pixels in the hot region on hover and clicking empties the Store; the stepper's three cells are equal-height, share edges head to tail, tapping +/- changes the value and a bound side stops changing it. Library-wide 918/918, cd gui && moon check --deny-warn and the examples module warning-free. Device check: the form page accepts clicks and keystrokes, hovering shows the ✕ and clicking clears, and the stepper reads as one control.
  • Symptom: /usr/bin/ld: ... multiple definition of pt_flush (plus pt_flush_if_overlap / pt_set_batching), the two definitions coming from gui/build/libyue_text_pango.a(stub.o) and _build/native/debug/build/NoahLiu/native-gui/text/libtext-*.a(stub.o) — two compilations of the same stub.c.
  • Root cause: on Linux gui/text/stub.c is compiled twice: prebuild compiles it with -DWM_STUB_PANGO into build/libyue_text_pango.a (the real implementation), and moon's native-stub pass compiles it again without the macro because it cannot reach the pango headers — that second pass must come out empty (the comment at the end of the file states exactly that discipline: fall into no branch, become an empty translation unit, let the archive provide the real code). The three functions added by the frame-level text batch sat in an #ifndef _WIN32 block after the outermost #endif, i.e. outside every branch ⇒ both compilations emitted a copy. Windows escaped via the _WIN32 exclusion; Linux did not.
  • Fix: the three functions moved into branches — one copy in the WM_STUB_PANGO branch (Pango paints and blends per run and never batches, so flushing is a no-op) and one in the #elif !defined(__linux__) branch (macOS placeholder); the real Windows implementation stays only in dwrite_text.cpp, and the Linux native-stub pass compiles to an empty TU again.
  • General rule: when adding a cross-platform function to gui/text/stub.c (and to the same shape of platform stubs — render, win, anything organised as "one stub.c with branches + prebuild macros"), it must go inside an existing branch or open a new #elif; anything written after the outermost #endif double-defines against the prebuild archive. One-command judge: nm --defined-only _build/native/debug/build/NoahLiu/native-gui/text/libtext-*.a | grep -c <symbol> must print 0.
  • Verification: moon build examples/showcase --target native passes from both the workspace root and the examples module and produces showcase.exe; nm confirms 0 pt_flush definitions in the moon-side archive; cd gui && moon check --deny-warn warning-free, text+core+ui+render 360/360.

Timers armed during page construction never fire: the charts page sat at "no data" (2026-10-14, real-hardware report) ​

  • Symptom: on showcase "图表 → 折线" both canvases stayed at "暂无数据" with the y axis pinned at 0..1, and the gauge did not move; ticking "暂停推点" made no difference either (this is the leftover half of the earlier "the push-point button does nothing" report).
  • Root cause: @core.set_timeout uses a process-level delay channel installed by App::new (via install_host_hooks); with no host it is a no-op returning 0 (the contract in core/loop.mbt). In the example page_charts(app) is an argument to @ui.mount_window([...]), so it is evaluated before the window exists — and "arm the first tick on the last line of the page function" therefore armed into the empty channel: the callback never ran and the self-rearming loop never started. The data path is fine: series_push uses set_always (bypassing Store dedup) and the canvas invalidates inside its subscription.
  • Fix (example-side rule): arm the first tick from the mount callback — @ui.row([...charts...], handle=fn(_) { line_pump() }). The two build-time let _ = @core.set_timeout(...) lines are deleted, and the on-page code snippet was rewritten too (otherwise the demo text keeps teaching the broken pattern). [Correction 2026-10-15: this entry treated view's handle as a mount callback — that is wrong; handle also runs at construction time, so this fix never took effect. See the next entry.]
  • General rule: anything that arms a timer inside a page/component constructor — and anything that stores set_timeout's return value in order to cancel later — belongs after mount (or after the host App exists); a returned 0 is the signal "no host", so never keep 0 as a handle.
  • Same batch (visible in the same screenshot): the section description @ui.text(s.desc, max_width=900.0) had no wrap=true, so a long description was one unwrapped 900 px line whose tail was cut off wholesale (the broken word at the end of the line-chart description). It now wraps, so the section container is no longer stretched by a single long line.
  • Verification: cd examples && moon check showcase --deny-warn warning-free, cd gui && moon check --deny-warn warning-free, library-wide 915/915 with no regression. Device check (user): open the charts page — both line canvases should climb one point every 0.5 s with an adaptive y axis and the latest value annotated at the right end, 暂停推点 should freeze the curve and unchecking should resume it, and the left gauge should sweep at 2 Hz. [Correction 2026-10-15: this re-check never actually passed — the pump chain was still dead and the fix was wrongly declared verified.]

view's handle is not a mount callback: the charts pump chain was still dead after the "fix" (2026-10-15, Windows real-hardware report + local probe evidence) ​

  • Symptom: after the previous entry's "arm the first tick from handle" fix and its write-up, the charts page still pushed no points; the real-hardware exit capture showcase-prof.txt contained not a single CHART-DIAG line (one per tick — a live chain must print), i.e. the self-rearming loop never started. The earlier "device check passed" claim was a misjudgement.
  • Root cause: node_of_new(finish)'s finish is eagerly executed at construction (gui/core/node.mbt, equivalent to "new first, then configure"), and view (ui/basic.mbt) writes handle(w) inside finish ⇒ handle runs while the tree is being built — it is not a mount callback. Since page_charts(app) is an argument to @ui.mount_window([...]) evaluated before the window exists, the set_timeout armed inside handle landed in the empty channel — the same trap as arming in the constructor, moved to a different place without changing the timing.
  • Evidence (experiment/timer_probe, Windows dev machine, moon run experiment/timer_probe, readings written to C:/tmp/timerprobe.log): the probe arms its first tick from @ui.col(..., handle=...); the log shows ARMED pending=0 (the timer armed inside the "mount callback" never entered the queue), while a timer armed after mount_window returns shows POST-ARM pending=1 and fires on time 400 ms later — the hook itself is fine, only the timing was broken.
  • Fix: core gains Node::on_mount(f) — it wraps a Node and defers f to the real mount moment (after mount_into has attached the widget to its parent); the charts page's two first ticks moved from handle= to @ui.row([...]).on_mount(fn() { pump() }). The section body's lazy mount only calls mount_into when the section is first selected, by which time the host has long been ready. The on-page code snippet and the charts.mbt header comment were rewritten to the on_mount form; the CHART-DIAG diagnostic line was removed once it had produced readings.
  • General rule: view/row/col's handle is a construction-time callback (fine for layout tweaks and Store subscriptions that do not need a host); anything that needs the host (arming timers, reading a host handle) belongs in Node::on_mount or mount_window's handle(app) (that one is genuinely after mount). on_mount runs on every mount — a one-shot startup callback relies on the caller mounting once (the lazy section mount is guarded by a mounted flag, so it holds).
  • Verification: core gains 1 on_mount regression (not run at construction; on mount_into it runs after the widget is in its parent), moon test core 144/144; cd gui && moon check --deny-warn warning-free, cd examples && moon check --deny-warn warning-free, showcase builds and links; library-wide regression per this round's readings. Device re-check goes to P42: line canvases climb a triangle wave one point every 0.5 s, "暂停推点" freezes and resumes it, the left gauge sweeps at 2 Hz.

yoga-mbt left the line gaps out of free space, so a grow item ate them and pushed the row out of its container (2026-10-14, real-hardware report: the copy button is still clipped) ​

  • Symptom: in a showcase section body the code row (left "代码" label + a growing spacer + the "复制" button on the right) had its button's right half clipped outside the window. That shape is used everywhere (@ui.row([..., spacer, button], column_gap=...)); elsewhere it only overflowed by a little margin, which nobody noticed.
  • Root cause: in this port's §9.7 distribution, initial_free, the loop's remaining, the auto-margin free and justify-content's free2 subtract only the sum of the children's outer frames — never the line's total gap gap x (n-1) — while the positioning pass adds main_gap per item ⇒ whenever an item grows, it over-grows by exactly gap x (n-1).
  • Minimal acceptance check (pinned in modules/yoga-mbt/src/layout_wbtest.mbt): row width 100, column_gap=10, children [fixed 30, grow 1] ⇒ the grow item must be 60; before the fix it came out 70 (whole row 110).
  • Fix: compute gap_total = main_gap x (line.length()-1) before distributing the line and subtract it in all four free-space computations (spec: gaps occupy space and do not participate in grow/shrink).
  • Measured (headless probe over the real page tree; window 1010, sidebar 180 → viewport 830): before, spacer=728 and the button at x=962 w=50 → right edge 1012, past both the window (1010) and the scrollbar (1002); after, spacer=712 and the button at x=946..996, exactly on the row's right edge (content 802 + 14 padding per side).
  • Verification: moon test src (yoga-mbt) 102/102 (including the new pin), library-wide 916/916, cd gui && moon check --deny-warn and the examples module warning-free, moon build examples/showcase --target native passes. This is a third, independent problem — stacked with "the strip never wraps" and "viewport min 0" it produced the collapsed body plus half-clipped button seen on device.

The section strip never wrapped: Style gains flex_wrap (2026-10-14) ​

  • Symptom: in showcase the top section strip (segmented) overflows horizontally and gets clipped once a page has many sections, even though shared/section.mbt documents "wraps automatically when too wide".
  • Root cause: this stack's ui never writes flex_wrap, and the style layer had no such property either (CSS has flex-wrap; our Style was missing it), so every container is NoWrap — the comment described a capability that does not exist.
  • Fix (added into the style layer per "property name = CSS property name", not as a component parameter): Style gets flex_wrap : FlexWrap? (the short type name imported in types.mbt via pub using @yoga {type FlexWrap}), the factory takes flex_wrap=, merge / empty_style / the setter follow, and apply routes it to Node::set_flex_wrap.
  • Two companions wrapping needs (the first device build shipped only flex_wrap and it squashed the body):
    1. Put the strip in a column, not a row: a row does not stretch its cross axis, so the wrapping container sized to its content (measured: given 450 of 1000 available) and the segments folded into six or seven lines. A column stretches children across by default ⇒ the strip gets the full width and folds only when it truly needs to.
    2. Give the wrapping container — and every level of its parent chain — an explicit min_height=0 (and min_width=0 horizontally): per spec §4.5 a column child's min-height:auto resolves to its min-content height, and the min-content height of a wrapping row container is all children stacked (6 segments ×30 + 5 gaps = 200). That floor propagates up the chain — measured: strip 224 px, body down from 646 to 476. The engine is spec-correct, not buggy; explicit zeros are how a strip like this is meant to be written.
  • Final shape: strip = @ui.col([@ui.segmented(names, selected, class=[style(flex_wrap=Wrap, row_gap=4.0), style(min_height=0.0, min_width=0.0)])], padding=10.0, class=[style(min_height=0.0, min_width=0.0)]).
  • Verification: moon test style 26/26 with 1 new layout pin (a 100-wide row container with two 60-wide children: NoWrap keeps the second at top=0, flex_wrap=Wrap moves it to top>=20), plus 1 new yoga-mbt pin freezing the §4.5 floor and its escape (same tree: min:auto gives the strip height 200, explicit min_height=0 gives 30). After the fix a headless probe over the real page tree (1200×700) measures: strip 54, body container 646, and on the code page viewport 652 vs content 808 (155 px scrollable) — the strip no longer eats height, and a definite viewport with taller content scrolls. cd gui && moon check --deny-warn warning-free, library-wide 915/915, yoga-mbt green.

Scroll viewport was stretched by its own content, so nothing could scroll (2026-10-14, real-hardware report) ​

  • Symptom: in showcase a section body taller than the window had nothing to scroll with the wheel or the bar — the content was simply cut off at the bottom.
  • Root cause: scroll_view's viewport node never set min-height. Yoga flex children default to min-size = auto (as large as the content, never smaller than a screen's worth), so the viewport was pushed out to its own content's natural height — view_h == content_h and max() = content_h - view_h is always 0, so the wheel and the bar both conclude there is nothing to scroll. A call site writing handle=fn(v){ v.node.set_flex_grow(1.0) } only sets grow, which cannot override that default.
  • Controlled measurement (headless probe: 20 rows × 20 px of content inside a 240×140 parent): before viewport=676 content=676 (viewport equals content height); after viewport=200 content=676 (viewport stays at the size the parent gives, content keeps its natural height).
  • Fix: scroll_view writes min_height=0 and min_width=0 on its viewport (closed at the library level, so every call site benefits together: showcase's section bodies and sidebar, native-app, virt_list hosts). Call sites that pass an explicit height= are unaffected (an explicit size beats min).
  • Verification: moon test ui 151/151 with 1 new pin — given only flex_grow and no height: viewport height ≤ parent height, content_h=400, view_h=140, max()>200, and after scroll_by(60) the content node really moves up; library-wide 914/914, cd gui && moon check --deny-warn warning-free, examples module warning-free. Device check: showcase's section bodies and the long sidebar list should scroll with the wheel and drag with the bar.

Markdown page, two defects: wrong range unit + rich lines that never wrap (2026-10-14, real-hardware report) ​

  • Symptoms (showcase "代码文档 → Markdown" section): ① in mixed CJK/Latin paragraphs the bold / italic / inline-code / link runs came out as tofu boxes pressed on top of each other; ② the right edge was cut off wholesale; ③ the "复制" button on the code row was half outside the visible area; ④ the content area would not scroll vertically (a separate root cause found in the same batch — see the next entry).
  • Root cause ① (unit contract): md_span_ranges accumulated range endpoints in UTF-16 code units, while the AttributedText contract is UTF-8 byte indices (both the header note in text/attributed.mbt and the tlen handed to C by rt() are bytes; the Pango side uses start_index/end_index as byte indices directly, and the Windows/DWrite side converts through ptw_widx). One CJK character is 3 bytes but counts as 1 code unit ⇒ every range shifted forward and endpoints even cut inside characters (on device: tofu plus overlapping glyphs). Pure-ASCII paragraphs agree in both units, which is why these code paths always "looked right" in English samples.
  • Root cause ② (never wraps): md_rich_label used wrap=false and sized the node to the natural width of the whole paragraph (measured: one paragraph came out 656 px inside a width=480 document). Consequence: the right edge is cut, and the section container is stretched past the viewport by that over-wide row — the 复制 button sits at the row's end, so half of it is clipped.
  • Fix ①: md_byte_len (UTF-8 sequence length per code point) now produces the ranges; md_span_ranges's doc comment and the file header both say "UTF-8 bytes".
  • Fix ②: greedy wrapping at construction time — CJK and full-width punctuation break per character (Chinese has no spaces, so a break can only fall between characters), a Latin word plus its trailing space is one unit (never break inside a word), \n is a hard break, and a single unit wider than the line (long URLs) is hard-cut per character. One AttributedText per line; strike-through rules use that line's pixel ranges, and link hit-testing first picks the line by y, then the segment by the in-line pixel range. Wrap width derives from the container: md_narrow(ctx, by) shrinks it for list items (marker column / checkbox), blockquotes (the bar), footnote definitions and definition lists, while table cells get max_w=ctx.width/columns.
  • Toolchain reading: MoonBit's String.length() returns the UTF-16 code unit count ("甲乙"=2, "👍"=2, "abc"=3) — neither bytes nor code points — and String.sub(start=,end=) is not byte-based either. So neither may serve as a length oracle for a byte-contracted API; byte length has to be computed per code point or via @utf8.encode.
  • Verification: moon test mdview 31/31 with 4 new cases — byte readings (in 前👍中 the following bold run must land at [10,16)), every endpoint must fall on a character boundary, no descendant may be wider than the document width while the paragraph is clearly taller than one line, and a long URL must be hard-cut. The judges were proven to bite: restoring the old UTF-16 accumulation turns exactly those new checks red on the spot (4 != 10, "区间终点切进字符内部: 4"), and reverting makes them green. Library-wide 913/913, cd gui && moon check --deny-warn warning-free, cd examples && moon check --deny-warn warning-free.
  • Still needs the device (user): do the tofu / overlap disappear, does the right edge stop being cut, is the copy button whole? The "content area does not scroll" half was diagnosed separately (viewport stretched by its own content) — see the next entry.

Visual fix: badge_dot now paints a real circle (2026-10-14) ​

  • Symptom / root cause: badge_dot is documented and named as a round-dot badge but drew with fill_rect — the result is an 8×8 square. It was a boundary item recorded during frontend P2-e (that batch only moved the style channel, it did not change how anything is painted).
  • Fix: it now calls Painter::fill_round_rect with a default radius of half the short side (an inscribed circle). A border_radius from class wins, clamped to no more than half the short side — so a square is still reachable with border_radius=0.0. The fill colour is still taken from the style slot (color_of(s.background_color, "")), so theme switches keep following.
  • Verification: moon test ui 150/150 with 1 new pixel regression — at the 16×16 size the top-left cell has no ink while the centre does (the inscribed-circle criterion), and the class=[style(border_radius=0.0)] variant puts ink in that corner. On-device look (showcase's data-display page / native-app's dots) belongs to _人工测试清单.md.

The GTK backend left the window-enhancement calls empty: "set title" and "borderless" did nothing (2026-10-14, real-hardware report) ​

  • Symptom: on the window page only "全屏切换" works; "改标题" and "无边框切换" do nothing at all, and "还原尺寸" shows no effect either.
  • Root cause (three, each different): ① set title / borderless — the GTK branch of gui/win/stub.c still held the empty implementations left by W20e (wm_set_title / wm_set_borderless were whole (void)id; blocks); the section comment at the time ruled them "internal business of the Linux backend, not on the roadmap", while the Win32 branch implemented both: the MoonBit facade call is harmless and nothing appears on screen. ② Restore size — wm_window_set_size is a real implementation on the GTK branch (gtk_window_resize), but the demo button hardcoded set_size_pixels(1280, 720), which is exactly showcase's launch size: unless you drag-resize first there is nothing to observe — a missing acceptance check in the demo, not a backend gap. ③ Fullscreen — implemented on both branches, which is why it was the only one that "worked".
  • Fix: ① the GTK branch gained gtk_window_set_title (with three guards: id out of range, win->window == NULL, title == NULL) and gtk_window_set_decorated(on ? FALSE : TRUE); the remaining calls in that block — icon / minimum size / self-drawn-titlebar drag region — stay empty on purpose, with the reasons written into the section comment (a GdkPixbuf source is not of a HICON's kind; geometry_hints is not wired; begin_move_drag needs the pointer and button number at press, which this stack never sends down to the window layer), leaving nothing that merely "looks not obviously broken". ② The demo button became a two-step toggle 1280×720 ↔ 960×540 (renamed "尺寸切换", section copy updated) so every click has a visible result. ③ The MoonBit docs for set_title / set_borderless now name each backend (Win32 rewrites the style bits and recomputes the frame / GTK uses set_decorated / macOS still empty) instead of "empty implementation on non-Windows backends".
  • Verification: window-level actions cannot be judged headlessly (they need a real window plus a window manager), so per rule 6 they go to real hardware. Gate readings for this batch: cd gui && moon check --deny-warn warning-free, library-wide 937/937, cd examples && moon check --deny-warn warning-free, moon build showcase --target native links (gtk-3 is already on the link line).
  • Rule: when a backend lacks a capability, the demo must not ship the button (this repo's precedent: "maximize/restore" and "always on top" are absent because the Win32 section never wrote them). An empty implementation may stay in the facade — the cross-platform shape is the same — but the demo and the docs must name which backend does not carry it.
  • Pending real-hardware re-check (user): "改标题" → the title bar becomes 组件库演示板 · 标题已改 (also proving the UTF-8 title path); "无边框切换" → the title bar and frame disappear, and a second click brings them back; "尺寸切换" → the two sizes visibly alternate; fullscreen behaves as before.

Dragging the splitter handle did not follow the mouse: the delta was taken in the handle's own local coordinates, so each frame subtracted the previous frame's move (2026-10-14, real-hardware report) ​

  • Symptom: on the nested-layout page, dragging the split handle does not track the pointer — it snaps backwards mid-drag.
  • Root cause: the drag delta in splitter_impl used the handle's local coordinates (ev.x is delivered by the host as ev.x - w.rect.x), while the handle itself slides as the first pane widens. Every frame's delta therefore lost the previous frame's handle displacement: nw = first0 + (local pointer - local start), where the local pointer has already had the new handle origin subtracted. Measured (400 wide, ratio 0.5, starting at the handle centre): the first move (pointer +50) lands the handle at 250 and looks right; the second move (pointer +60 cumulative) sends it back to 210 — the correct value is 260. The further you drag, the more it snaps back, which is exactly the "won't follow / bounces back" feel.
  • Fix: both the start and the move now use window coordinates (ev.x + s.rect.x), decoupling the delta from the handle's own slide. This stays exact even when a batch of events is drained before relayout: the local value is by construction "window minus that same rect.x", so adding the same rect back restores it. Clamping is unchanged (min_first / min_second still apply).
  • Verification: new gui/ui/split_wbtest.mbt with two tests (this component had no tests at all before) — ① the initial hit_test must land on the handle, drag +50 → x0+50, drag a further +10 → x0+60 (the old code snaps back to 210 here), and after Up further moves change nothing; ② both minimum widths still clamp (the handle stops at total - 8 - min_second = 312). The tests drive the same path the host uses: dispatch_pointer_to(handle_id, ...) emulating the pointer-capture window — calling dispatch_pointer on the root carries no capture, so the second event hits a different pane instead (that is how the first draft of the test false-failed). The judge's bite was proven by a control experiment: replacing win = at + origin with win = at turns the second-move assertion red on the spot (measured 210). moon test ui 160/160.
  • The remaining half: @core.invalidate() broadcasts to the whole window (host_repaint_all sets dirty_full), so one drag step costs one full-viewport repaint per tick — measured around a hundred milliseconds per frame in debug builds. That is the frame-cost item already listed under "known differences" (TODO §6.6), not a coordinate problem; re-check the feel after this fix before deciding whether it needs its own work.
  • Pending real-hardware re-check (user): does the handle track the pointer now (fast back-and-forth drags without snap-back), do both minimum-width clamps still hold, and does it stop following after release.

alert(closable=true) had a hit zone but no mark: an invisible hit zone is not a button (2026-10-14, real-hardware report) ​

  • Symptom / root cause: the alert(..., closable=true) on_draw painted only a light plate + a 3 px accent bar on the left + title/description; the closable branch installed only a 24×24 hot zone at the top-right in on_pointer and not a single pixel of a mark. The demo copy says "点我右侧的 ✕ 试试" while there is nothing on the right ⇒ reported on device as "the icon renders wrong". It has nothing to do with the icon-size batch (that one was glyphs drawn to 176% of the box and clipped); this one is a missing mark outright.
  • Fix: mark and hot zone share one source and are both painted in on_draw — @icons.draw_icon(p, @icons.IconKind::Close2, r.w - 12.0, 12.0, 6.0, main): the centre sits dead in the middle of the 24×24 hot zone with a glyph radius of 6 (ink ≈ 10.6 px, under the "s is the content radius" convention), coloured with the banner's main colour main (semantic colour tokens read live, so it follows theme switches). Naming note: the bare ✕ is Close2 (its font_class is literally close); CircleClose / CloseCircleFill are the ringed variants — do not mix them.
  • Verification: moon test ui 158/158 — the old "the close hot zone is only at the top-right" case grew into "both hot zone and mark are only at the top-right", adding two acceptance checks: inside the closable banner's top-right 24×24 there must be >8 pixels "different from the light plate", and a non-closable banner's same region must be ==0 (nothing extra may be drawn). Ink is filtered by "sum of the three-channel difference from the light plate > 30", the range avoids the left 3 px accent bar, and no concrete colour value is locked (so a theme switch cannot false-red). The judge's bite was proven: removing the draw_icon line turns the mark assertion red on the spot (false is not true), restoring it turns green. Library-wide 932/932, cd examples && moon build showcase --target native links. Whole-repo --deny-warn is still blocked by the 5 sys warnings from the parallel batch 60e094e (4 in appfind.mbt, 1 in recent_files.mbt) — files outside this batch.
  • Ruled out along the way: the two "✕" in the demo copy are U+2715 characters in the body text, covered by both DejaVu Sans and WenQuanYi Micro Hei on this machine (fc-list :charset=2715 lists them), not missing-glyph tofu; the three red boxes in the screenshot are the user's own annotations.
  • Pending real-hardware re-check (user): on the feedback-component page, a closable banner should show a ✕ mark on its right, and clicking it collapses the whole row; the other, non-closable banners must show no mark.

The hard shadow piled up into dark blobs at the four corners: neither backend has reverse arcs, so the "outer ring minus inner ring" trick never cut a hole (2026-10-14, real-hardware report) ​

  • Symptom: on the "过渡与效果" page, the box_shadow=8 card shows a dark blob at each of its four corners while the four straight sides look like an even ring.
  • Root cause: Painter::box_shadow built each band as "outer ring + reversed inner ring + fill with nonzero winding", with the inner ring traced by round_rect_path_ccw, which expressed the counter-clockwise direction by feeding decreasing angles to arc. But arc's ccw parameter does nothing on either backend (painter.mbt's own doc says so: cairo_arc normalises ea < sa into the long clockwise sweep, and the pure raster replicates that normalisation) — so every inner segment actually swept 270°, the inner boundary never landed where it should, the hole was never cut at the corners, and the stacked rings overlapped there into blobs. Offscreen image comparison (PNG read directly): a single layer already shows corner blobs, and more layers make the corners darker (16 layers gives solid black circles).
  • Fix: draw one stroke instead — line width = spread, centreline pushed out by spread/2, which tiles exactly [0, spread]; each backend takes its own stroke path (a wide stroke is an existing stroke_polys capability in the raster backend, the same route every border uses). The layers parameter was dropped along the way: all layers share the same alpha and never overlap, so it only chopped one uniform band into N segments with no visual effect. The reverse-inner-ring helper round_rect_path_ccw was deleted with it (no references outside the package).
  • The other report on that page (font size never changes): not the transition's fault — the demo wrote font_size on the container's class, while state buckets are computed per widget, so a parent block's hover never swaps a child's font size. It now lives on the text widget's own slot, and the text spans the whole block so a pointer anywhere inside hits the text itself.
  • Verification: render's box_shadow regression now samples the four corners at 45° (one point per corner midway between the inner and outer boundaries — the old drawing either missed a patch there or piled it into a blob) plus "inside the body and far outside must be 0"; moon test render ui 206/206, library-wide 939/939, --deny-warn error- and warning-free under both cd gui and cd examples, moon build showcase --target native links, and the rendered comparison confirms the blobs are gone with an even density all round.
  • Rule: fills that need a reversed subpath (cutting a hole) do not hold on either backend of this stack — arc only sweeps forward. Cutting a hole means polyline approximation (the chart donut is the precedent) or switching to a stroke; new drawing should prefer shapes that are independent of winding.
  • Pending real-hardware re-check (user): the shadow card should show an even ring with no dark corners; hovering the 字号+底色 block should jump the font from 13 to 17 in one step (font size not interpolated) while the background still eases over 300 ms. (The rule has since changed: after the 2026-10-15 user re-check, font size was asked to gradient too, so it joined the interpolation set and measurement now reads the same value — see "Font-size animation was not an animation"; the shadow item was re-checked in this same batch.)

The code "copy" button claimed success but nothing reached the clipboard: the message threw away the return value, and a cross-process judge was missing (2026-10-14, real-hardware report) ​

  • Symptom: clicking 复制 on a code block showed "代码已复制 ✓", yet Ctrl+V elsewhere produced nothing.
  • Root cause 1 (the reporting): examples/showcase/shared/section.mbt did let _ = @sys.clipboard_set_text(...) — the Bool return value was discarded, so success and failure printed the same line and the toast had nothing to do with reality. (The write path itself had already moved to GtkClipboard in the previous batch.)
  • Root cause 2 (missing acceptance check): the existing clipboard regression is an in-process write-then-read, which cannot prove "another program can read it" — on X11 the CLIPBOARD selection is answered by the owning process, so once that process exits and no clipboard manager lives in the session, outside readers get nothing. A green in-process test can coexist with a broken cross-process path, so this class of capability needs a second process as a witness.
  • New acceptance check: experiment/clipboard_probe — it calls @win.init_backend() first (the same shape as a real graphical app: the GTK branch runs gtk_init_check), writes, then stays alive for six seconds while an external process reads back. Measured here (external reader: a separate python-gi process): backend=true / available=true / set_ok=true, the outside read returned all 100 characters including Chinese, curly quotes, a double backslash, a tab and the trailing marker, and PROBE-AFTER-HOLD matched the write exactly → the Linux GtkClipboard write path holds across processes, so what broke here was the reporting, not the write.
  • One correct behaviour not to chase as a bug: in a plain command-line process (window backend never initialised) clipboard_available() is permanently false and the code falls back to the xclip command line; xclip is not installed on this machine, so such a process genuinely fails to write — that is degrade with an explicit Unsupported, not a defect. Reproducing in-app behaviour requires init_backend first, otherwise probe readings mislead (my first probe run read available=false and nearly sent me down the wrong path).
  • Fix: the copy button now branches on the real result — success prints "代码已复制 ✓", failure prints "复制失败:剪贴板不可用" (Danger tier). The repo's other let _ = clipboard_set_text sits in gui/ui/clipboard.mbt's kernel slot: the kernel's copy() has no reporting channel and the internal clipboard still backstops a failure, so it stays by design.
  • Verification: moon check clipboard_probe --deny-warn warning-free, cd examples && moon check --deny-warn error- and warning-free, moon build showcase --target native links, no library changes (core+ui+sys re-run 486/486). The visible outcome (pasting in a browser) goes to real-hardware re-check per rule 6 (checklist S15).
  • Pending real-hardware re-check (user): click 复制 on a code block, then Ctrl+V in a browser/editor — the same code should appear. If it now says "复制失败:剪贴板不可用", paste that sentence back to me: it would mean the in-app probe is not reaching GtkClipboard, a different root cause — but at least the two are now distinguishable.

"Reveal in file manager" opened the folder but selected nothing: the two channels have to meet in core (2026-10-14, real-hardware report) ​

  • Symptom: on XFCE, pressing "在文件管理器显示 TODO.md" opened the folder in Thunar, but TODO.md was not selected.
  • Root cause: the Linux branch of @sys.reveal_in_file_manager is simply xdg-open <parent dir>, and its own doc comment states "the bus channel's open-and-select arrives with the DBus line; for now everything falls back to opening the parent directory (selection lost)" — yet that half already lives in @dbus (filemanager_available / show_items / file_uri, all three present, with test coverage for the percent-encoding); nobody had connected the two. The layering is why they could not meet on their own: @dbus depends on @sys, so sys cannot import dbus back — the meeting point can only be core.
  • External measurement (this machine): gdbus answers (true,) for NameHasOwner org.freedesktop.FileManager1 (Thunar registers it), and thunar --help has no --select-style switch — the bus is the only channel that can carry a selection, so falling back to xdg-open necessarily loses it.
  • Fix: new gui/core/reveal.mbt with @core.reveal_in_file_manager(path): on Linux, when FileManager1 is online it calls show_items([reveal_uri(path, cwd)]), and only when the name is absent or this call fails does it fall back to the @sys command-line path; Windows still goes through sys's direct explorer /select. The pure helper reveal_uri(path, cwd) is exposed separately so tests can pin it (the selection hinges on exactly this string: it must be absolutized plus percent-encoded). The sys doc now names itself as the fallback ("to get selection, use @core"), and both demo call sites (showcase's external section and systemprobe's reveal) call the core version.
  • Verification: one new core URI regression (relative path absolutized, a space, Chinese — three segments) → moon test core 138/138, library-wide 939/939, --deny-warn error- and warning-free under both cd gui and cd examples, and moon build showcase / moon build systemprobe link. The visible outcome (the highlighted row in Thunar) goes to real-hardware re-check per rule 6 (checklist S14).
  • Rule: when two channels live in two layers, the meeting point must be the layer that sees both, and the docs must name what the fallback loses (here: the selection). Otherwise the capability is fully present while the behaviour stays on the fallback path and simply looks "not implemented".
  • Pending real-hardware re-check (user): from showcase's external section, revealing TODO.md should open the repository root with TODO.md selected; systemprobe's reveal (/tmp) should select /tmp itself.

The Linux tray "never started": the demos gated tray creation on an icon handle (2026-10-14, real-hardware report) ​

  • Symptom: on Linux the tray icon never appeared in showcase / native-app; the in-page echo stayed at "建图标失败".
  • Root cause: wm_icon_from_bgra is a W20f failure stub on the GTK branch (HICON is a Win32 concept; it always returns handle 0), so @win.icon_from_bgra yields None — and both demos wrapped the whole tray creation in match icon_from_bgra(...) { Some(ic) => Tray::new(...) None => note a failure }, meaning Tray::new was never reached on Linux. The library is not the gap: the SNI backend never reads that handle (its icon comes from a panel theme-icon name or the library's built-in bitmap — see Tray::set_icon_name and default_icon_pixmap inside Item::create).
  • Evidence chain (three independent legs): ① on the session bus, gdbus reports NameHasOwner org.kde.StatusNotifierWatcher as (true,) (the XFCE panel's notification area is up); ② the four pre-existing "real bus" SNI tests in moon test dbus (multi-item paths / menu routing, symmetric cleanup on unregister, disconnect self-heal) all pass on this machine, so the registration chain works; ③ reading Tray::new: whenever sni_watcher_present() is true it goes through @dbus.Item::create, independent of the icon argument.
  • Fix (demos only): the handle is now passed through instead of used as a gate — let ic = icon_from_bgra(...) then Tray::new(id, icon=ic) directly; on success the handle stays in the holder (a Win32 HICON must not be released while the tray lives), and it is freed only on failure (on Linux ic is None anyway, so both paths go through Option).
  • Rule: an optional, platform-specific input must not double as a capability switch — handle-style resources may be permanently empty on another backend, and making them a precondition blocks that platform's entire path. This is the mirror image of "don't ship a button for a capability no backend has": here a button that should have been shown was hidden.
  • Verification: cd examples && moon check --deny-warn error- and warning-free, moon build showcase and moon build native-app both link; no library changes (library-wide 938/938 re-tested, see the commit). The visible outcome on Linux (icon in the panel, left-click counter, right-click menu) goes to real-hardware re-check per rule 6 (checklist S13).
  • Pending real-hardware re-check (user): open the "托盘" section of the system-integration page — the echo should read "已建(sni)" and an icon should appear in the XFCE notification area; left click should increment the counter, "挂菜单" should give a right-click menu, "改提示" should show the new tooltip on hover. If the icon still does not appear, paste the echo text — that would already be a different root cause.

The periodic timer could not be stopped: the self-rearm dropped the new handle, so cancel hit an already-fired id (2026-10-14, real-hardware report) ​

  • Symptom: in the timer block of the system-integration page, the "1.5 秒后提示" one-shot worked and "起周期表(1s)" ticked, but pressing "停表" left the counter climbing.
  • Root cause: the demo's periodic timer is self-rearmed at the tail of the callback (this stack's timer hands out one-shot handles), and the re-arm line read let _ = a.timer(1000, tick.val) — the new handle was discarded, while tick_id still held the very first one. That id had already been removed from the queue on the previous tick, so cancel_timer(stale) found nothing and cancelled nothing; the timer kept ringing. Loop::cancel is not at fault: it looks the id up and returns false when absent, which is the correct semantics.
  • Fix: record the handle back on every re-arm (tick_id.val = a.timer(1000, tick.val)), and do the same for the tick that starts the table; the demo page's code sample now says "each re-arm must store the new handle, otherwise it cannot be stopped".
  • Rule: one-shot handle + self-rearm means the handle must be refreshed every tick. Like the Store dedup rule, this is a constraint the shape itself carries, so it belongs in design.md §6 and in the component samples, not only in a commit message.
  • Verification: gui/core/loop_wbtest.mbt gains a test pinning the semantics — after several ticks, ① cancelling the first (already-fired) id leaves the timer ringing and ② only cancelling the newest handle stops it (hits stops growing). moon test core 137/137, library-wide 938/938, cd gui && moon check --deny-warn and cd examples && moon check --deny-warn error- and warning-free, moon build showcase --target native links.
  • Pending real-hardware re-check (user): start the periodic timer → press stop → the counter should freeze immediately; pressing stop again should report "the table is not running"; restarting it should still be stoppable.

Linux clipboard now goes through GtkClipboard — and that exposed a missing moonbit_make_bytes declaration truncating pointers in the win stub (2026-10-14, real-hardware report) ​

  • Symptom: on this machine (Ubuntu 24.04 / X11 / XFCE) the clipboard was reported unavailable — the read/write buttons on the system-integration page did nothing, and Ctrl+C/X/V in inputs never reached the system.
  • Root cause 1 (capability gap): the only Linux backend was the command line xclip, and xclip is not installed here (which xclip empty), so clipboard_available() was permanently false, ui's attach_system_clipboard never installed a slot, and every facade read/write degraded to failure.
  • Fix 1: the GTK branch of win/stub.c gains wm_clipboard_available / get_text / set_text (GtkClipboard), and the sys side externs straight into the win stub — the same precedent as the file dialog's wm_file_* (the MoonBit layer does not import the win package; prebuild ships the link line). Probe order is GTK then xclip; after gtk_clipboard_set_text the code also calls gtk_clipboard_store (registering ownership alone is not enough — without the store, other applications lose the content once we exit); the watcher's cbw_get_text follows the same route and thereby drops one subprocess per poll tick. Win32 and the placeholder branch provide identically named symbols (Windows' real path stays in sys_stub.c's own CF_UNICODETEXT, untouched). The demo adds @ui.use_system_clipboard(true) (previously only native-app opted in).
  • Root cause 2 (ABI, shaken out by the first): win/stub.c used moonbit_make_bytes without declaring it — this file's GTK branch is compiled directly by prebuild, which never gets moon's moonbit.h include path (compile_stub only adds pkg-config cflags). An implicit declaration assumes an int return, so on x86-64 the returned pointer is cut to its low 32 bits. Proven by disassembly: with the declaration removed, wm_clipboard_get_text contains mov %eax,%edi (passing a 32-bit return value on as a 64-bit pointer argument), while this machine's heap addresses are of the magnitude 0x61bc76xxxxx — truncated, i.e. garbage. Adding extern void *moonbit_make_bytes(int size, int value); (the same line sys_stub.c:57 already has; matching ~/.moon/include/moonbit.h's moonbit_bytes_t moonbit_make_bytes(int32_t, int) bit for bit) leaves the TU with zero implicit-declaration warnings.
  • A pre-existing hazard fixed on the way: wm_file_dialog_get_result (the Linux file-dialog result path) had been returning a truncated pointer all along — headless tests simply never reached it, because it takes a real file selection to trigger.
  • Control experiment: declaration removed + forced relink → moon test sys dies with SIGSEGV; restored → 188/188. Trap recorded: changing only the C in a stub does not make moon relink the test executable (dirtiness is tracked per .mbt package), which is why the first control run falsely passed; touch some .mbt source (or delete the archive and dirty the package) for the comparison to count.
  • Readings and rule: the GTK path really read this user's clipboard here (the watcher test printed cbw_get_text: Ok (13 字符) …). Note that gdk_display_get_default() returns non-NULL even in a test process that never ran gtk_init (GDK opens lazily from $DISPLAY), so the Linux probe is also true under headless tests — clipboard regressions must therefore be written as "if available, do a save → set → get → restore round trip and never pin the user's content" (clipboard_wbtest.mbt already is that shape, so swapping backends cannot false-red it).
  • Verification: cd gui && moon check --deny-warn and cd examples && moon check --deny-warn error- and warning-free, library-wide 937/937, moon build showcase --target native links. The real-hardware checklist gains S11 (the system page's read/write buttons, and Ctrl+C/V in inputs interoperating with external apps).
  • Pending real-hardware re-check (user): write from the system page → Ctrl+V in a browser/terminal should show the same Chinese text; the page's read button should pick up text copied elsewhere; Ctrl+C/X/V inside inputs should interoperate with the system; and after quitting the app the clipboard content should still be there (that is the store call).

The section strip was squeezed flat by its own body: a fixed-height header needs flex_shrink=0 while the stretchable body needs min=0 (2026-10-14, real-hardware report) ​

  • Symptom: on the "代码文档" page, switching to the Markdown section made the segmented strip at the top collapse in height and its labels poke out of the band; the "代码高亮" section looked fine.
  • Root cause: the page assembly is a column holding "strip + body column (grow=1.0)", and both sides were given min=0 (the strip got it in the previous batch, for "wrap when too wide": the CSS §4.5 main-axis min:auto of a wrapping container equals its min-content height, which otherwise eats the whole body height). Zeroing the minimum without locking the shrink leaves the reverse channel open: the body column was not given min=0, so its min-content height is the entire body (the Markdown section is the full document, on the order of 2000 px), the window is only 720, and once the parent column overflows yoga puts all of the shrink onto the only freely-shrinkable layer — the strip (it has min=0, while yoga-mbt defaults flex_shrink to 1.0, modules/yoga-mbt/src/style.mbt:53). Sections with short bodies never trigger it, which is why it looked like switching tabs caused it.
  • Headless repro readings (temporary probe in gui/ui, structure identical to the assembly: root 300 tall, strip 24 + 10 padding, body 2000): the strip measured 23.32 (natural height 44, so the 24 label sticks straight out); after "strip set_flex_shrink(0.0) + body column min=0" it measured 44. The probe was deleted once run; the fix landed in examples/showcase/shared/section.mbt.
  • Rule: in a column with "fixed header + stretchable body" the two things must be locked separately — header: shrink off (flex_shrink=0), body: shrink allowed (min_height=0 + grow), and let the scroll_view inside the body absorb the overflow. min=0 alone is a necessary condition, not a sufficient one: it lets a container wrap and, at the same time, makes it the only layer that can absorb shrink.
  • This stack's boundary: the Style field set has flex (=grow) but no flex_shrink, so locking the header's shrink goes through handle= with w.node.set_flex_shrink(0.0) (the same route as scroll_view's content column). Whether to add flex_shrink to the style factory is tracked separately (see design.md §6).
  • Verification: cd examples && moon build showcase --target native passes; this batch touches only the demo assembly, no library changes. The repo-wide --deny-warn gate is still blocked by 5 sys warnings from the parallel batch 60e094e (4 in gui/sys/appfind.mbt, 1 in recent_files.mbt), files outside this batch.
  • Pending real-hardware re-check (user): after switching to the Markdown section the strip should keep its fixed height (labels no longer poking out), the strip should still wrap in a narrow window, in-section scrolling should be unchanged; also click through the other 14 pages and see whether any other fixed-height band gets squeezed.

A thousand scatter points merged into a solid band: dot diameter must respect "dot area × point count ≤ plot area" (2026-10-14, real-hardware report) ​

  • Symptom / root cause: scatter_t defaulted to dot=6.0 and painted opaque fill_rect squares. The demo page pushes 2000 points into a 320×260 chart (plot area 260×224), so the dots' total area, 2000×36 = 72000 px², was already 1.24× the plot area — the default dot diameter and the point-count magnitude simply did not match. Neighbouring points bit into each other and the whole correlation band was daubed into one solid mass with no readable distribution shape. Squares also eat the π/4 of visual whitespace, leaving not even a seam in dense regions.
  • Fix: ① the default dot drops to 3.0, and the rule is written into the component docs (dot area × point count ≤ plot area; pass a larger diameter explicitly); ② the drawing becomes Painter::fill_round_rect (radius = half the short side) = a dot, which leaves gaps between points by construction; ③ the point layer is wrapped in set_global_alpha(0.7) (inside save()/restore(), restoring as usual), so overlaps darken by accumulation instead of plastering solid, and sparse areas still show the grid. The trend line and grid are unaffected (drawn before the alpha change).
  • Measured readings (offscreen Painter::new(Bitmap) session; the point mask filters on "closer to the dot colour than to the panel colour", which excludes grid lines and ticks by construction): a single dot=15 point inks a 14×14 bounding box whose four corners stay clean and whose centre is inked (old code: 15×15 with all four corners inked = a square); at 2000 points on the default, pixels at the pure dot colour number 201 (old code: 20189 = 35% of the 58240-px plot area, i.e. solid). Both checks are now regression tests in moon test chart.
  • Toolchain readings (moon 0.1.20260920): ① the demo's LCG cannot use % 2147483647 to get a positive remainder — MoonBit's % takes the sign of the dividend, and once seed * 1103515245 overflows int64 and wraps negative the remainder is negative, so the scatter spread onto the negative half-axis (real-machine reading: the axes became -100..100 while the comment said 0..100). Masking the low bits with & 0x7fff_ffff removes overflow and sign in one step. ② In a multi-line expression an operator may not start a new line: a + b < c fails outright with [3002]/[4139]; the comparison has to move into an intermediate let.
  • Verification: moon test chart 190/190 (one new two-judge regression). The judges' bite was proven by a control experiment: restoring the old painting path and default dot size (fill_rect + dot=6.0) makes the corner judge true and the pure-colour count 20189, above the bound; reverting turns green. Library-wide 931/931, cd examples && moon build showcase --target native links. The repo-wide --deny-warn gate is currently blocked by 5 warnings from the parallel batch (60e094e, sys platform batch 3: a duplicated #borrow in gui/sys/appfind.mbt, an unused ffi_file_mtime, two deprecated string-slice spellings); those files are not touched here.
  • Pending real-hardware re-check (user): both scatter panels should read as "a cloud of points" rather than a solid band, the trend line should stay legible, and the small panel in native-app (250×130) should not regress.

Icon glyphs were drawn at 176% of their box and then clipped: the content-radius contract belongs in the component layer (2026-10-14, real-hardware report) ​

  • Symptom / root cause: parameter 5 of @icons.draw_icon has always been a content radius (ink ≈ 1.76×s, since the path data is normalized to ±1000 and scaled by s*0.88/1000), but @ui.icon and @ui.icon_button passed the box edge length as that radius (size / icon_size), so glyphs were drawn at 176% of the box's short side — and the host clips each widget to its rect, cutting a large piece off all four edges. Offscreen pixel probe (ink bounding box measured directly on a Painter::new(Bitmap) session): icon(size=24) reported a 24×24 box that the glyph filled exactly (unclipped it would be 44×44 — calling draw_icon(...,24.0,...) directly reads 10,10-53,53), and icon_button(box_size=28, icon_size=16) likewise filled its 28 box. That is the reported "glyphs burst out of their container, edges sliced, content and container out of agreement". The library's three other direct call sites already used the radius convention and are unaffected: message_box (24 box passing 11), the overlay snackbar (passing 7), nav sidebar items (passing 6.72).
  • Fix: @ui.icon now derives the radius from the live rect (r.w.min(r.h) / 2.0), so the glyph keeps following the box even when class overrides width/height and never overflows; icon_button passes icon_size / 2.0, keeping the button box (box_size) and the glyph box (icon_size) as separate concerns — the default 28/16 pair inks ≈16 px with more than 6 px of margin inside. The doc comments on draw_icon and icon state the contract at the entry point ("s is the content radius = half the box edge", "the glyph is centred with 88% optical margin of the short side"). Nothing was needed on the data side: all 803 paths were scanned, every one fills the ±1000 space and its geometric centre is off by at most 60 fixed-point units (≈3% of the box), so no entry is lopsided or under-sized.
  • Toolchain readings (moon 0.1.20260920): println takes exactly one String (more arguments give [4015]); debug_printline has no matching overload for mixed "4 Int + 2 Double" payloads (%g is not accepted either, [4021]) — multi-value diagnostics go through string concatenation.
  • Verification: moon test ui 158/158 with one new pixel regression — icon(size=24) must ink inside [1,1]-[22,22] with equal horizontal/vertical centres (measured 1,1-22,22), and icon_button(box_size=28, icon_size=16) inside [5,5]-[22,22] with a width of 10..17 (measured 6,6-21,21). The judge's bite was proven by a control experiment: restoring the old arguments (passing size / icon_size directly) turned it red on the spot (a.0=0, b.0=0, i.e. ink pinned to the clipped box edge); reverting to the fix turns it green. Library 13 packages 926/926, cd gui && moon check --deny-warn and cd examples && moon check --deny-warn error- and warning-free, moon build showcase --target native links.
  • Pending real-hardware re-check (user): the large icons on the empty/result pages, the sidebar deep/light toggle (moon glyph), the icon-wall page (size=30), and the small prefix/suffix and table-sort icons — none should break out of its container, none should have sliced edges, all should sit centred.

Declarative frontend P5: transition property animation (state-bucket interpolation + frame driver, 2026-10-14) ​

  • The interpolation set is drawn along the "read from the style slot every frame" line: only color / background_color / border_color / border_width / border_radius animate. The reason is not convenience — any property that feeds layout (font size, line height, sizes, margins) forces a full relayout of the tree every frame once it interpolates, while opacity / box_shadow take effect where apply writes Widget.alpha_paint / shadow_paint, and the paint traversal follows host fields rather than slots, so interpolating them would have no effect. Properties outside the set switch immediately: step compares a projection of the interpolated fields and settles directly when the projection matches.
  • State-machine rules: ① the first time a widget is seen, only the target is recorded — no animation (otherwise building or remounting a tree would fade the whole page in); ② when the target changes mid-flight, the animation continues from the currently displayed value (from = shown), so reversing an interrupted hover does not jump; ③ the same target continues on the original clock while non-interpolated fields follow the new target; ④ transition_ms absent or 0 settles at once and leaves no account behind (style_for goes through forget_anim). Easing is smoothstep (t·t·(3-2t)).
  • One timer drives all animations: a process-level @core.set_timeout(16) starts the table, each tick calls @core.invalidate_id(id) only for widgets that are animating (whichever window contains it gets its dirty region; only when no window can find it does it fall back to a whole-window broadcast), and the table stops as soon as everything converged — no per-widget timer and no idle spinning. Values are computed from elapsed real time rather than counting ticks, so dropped frames or a busy host cost frames, never speed. An account whose block was hidden pays over the endpoint value on the expiry tick and marks dirty once, leaving no long tail.
  • How the two colour endpoints are taken: Var tokens resolve against the current theme before mixing (@theme.mix_hex), so switching themes mid-animation leaves that run's endpoints on the pre-switch colours and the next switch follows the new theme — theme switches dirty the whole window anyway, so this is not a visible defect. When either endpoint is missing or is not written as hex (named colours), mix_hex degrades to an endpoint value: middle frames stay on the start colour and the final frame is still the target, so the wrong colour is never painted.
  • Toolchain readings (moon 0.1.20260920, judged by cd gui && moon check --deny-warn):
    • pub(all) enum Color carries no ==: a tuple comparison containing Option[Color] fails with [4018] Type Color does not implement trait Eq. Same family as the existing note (derive(Eq) on a value type promotes impl methods into plain methods and warns), so state comparison always goes through a string projection (distinguishing Hex/Named/Var by the #/n/@ prefix) rather than deriving.
    • Package-private structs must be declared priv struct: a bare struct Anim emits the missing_priv warning, which fails under --deny-warn.
    • } and the next condition's { cannot be split across lines: writing } else if cond then { on the following line gives [3002] Unexpected line break here, missing { — the brace must sit at the end of the condition line (the same discipline as test "name" {).
    • moon fmt rewrites unrelated files: the committed tree is not in this toolchain's fmt shape (moon fmt style core touched 42 files in one go, e.g. inserting a blank line before every pub const in keys.mbt). Format only the files of the current batch and read the diff line by line afterwards; this batch closed out with "only change the lines I wrote".
  • Verification: 6 new style tests (interpolation endpoints / midpoint / one-sided default, the state machine's first frame does not animate, the 150 ms three-point reading #000000→#808080→#d7d7d7→#ffffff, mid-flight reversal continuing from #282828, out-of-set properties settling at once, and no account left when no transition is configured) plus 1 new core test (invalidate_id marks only the owning window, leaves the other alone, and an unknown id falls back to whole-window); moon test style core 159/159, all 13 library packages green at 908/908, cd gui && moon check --deny-warn warning-free, cd examples && moon check showcase --deny-warn warning-free. Demo page: showcase gains a "过渡与效果" page (0/150/600 ms in three blocks + reversal + the text and effect fields); the on-device look (comparing the three blocks' speed on hover, and whether moving away mid-way jumps) is the user's check.

Declarative frontend P1: gui/style package and yoga-mbt z_index (2026-10-13) ​

Toolchain boundaries (moon 0.1.20260920 / moonc v0.10.14; readings must be re-measured after upgrades, mirrored in design.md §1.6):

  • alias / abbrev are both reserved keywords — there is no function or type-alias syntax. Short names for external types come only from using @pkg {type X}, and that declaration is package-scoped: writing it in two files hits [4051] declared twice; the correct form is one pub using in one file, serving the whole package and outside callers with the short name.
  • Labelled arguments have no forwarding channel at all: f(width=width) fails typing (the in-body optional parameter is T?), f(width?) / f(width!) / f(...) are all [3002] parse errors, and binding pub let g = f then calling g(width=1.0) gives [4117] ("labelled functions can only be applied directly"); moon.pkg imports do not accept key-value form (unexpected token :), so package short names cannot be configured either. Conclusion: the style-factory call site is fixed as @style.style(...), and the draw-period builder inside the .hover/.active/.focus state methods is written out in all three places.
  • priv only修饰 declaration forms other than let/fn; a top-level priv named_x = ... fails to parse — package-private values are plain let.
  • Array::contains requires T: Eq; testing an option that holds a value uses is Some(_) (is_some() is a deprecation warning); Map::new() is deprecated — write the Map([]) literal.
  • for i in 0..n (half-open) and 0..=n are unavailable outside for; a range loop requires the for i in (0..<n) form or an outright while. This package falls back to while throughout.

Architecture decisions:

  • core must not depend on style (style depends on core's Widget/yoga landing points; the reverse edge would form a cycle). The Widget's resolved-Style slot is implemented as a process-level side table Map[widget id, Style] inside the @style package (the same precedent as overlay_native's process-level handle table): apply writes, and drawing reads resolved(w) / style_for(w) (choosing the bucket by hovered/pressed/focused); a widget that was never applied reads back an empty style. Stale-row cleanup has landed (the P2-e wrap-up): core gained a process-level "widget discarded hook" set_widget_discard_hook; clear_children and remove_child report every id of the detached subtree, and @style installs the hook itself on its first apply to clear those entries — dynamic tree swaps (bind_node remounts, for_list row removal, virtual-list window remounts, overlay teardown) no longer accumulate across generations. Rule: detaching means discarding; moving must go through move_child (core does not know the difference between move and drop — the hook only sees those two APIs). The acceptance check is @style.resolved_count() (for diagnostics), and a regression pins "remove a widget that has a subtree ⇒ the count falls back".
  • cursor is a draw-period property and may be carried by state buckets; a widget's static cursor field is written directly by apply, and a bucket's cursor is handed down by P2's hover path reading style_for.
  • yoga-mbt's z_index is paint-only: the setter does not mark_changed (geometry is unaffected) and the engine never physically reorders children (incremental layout caches by index), so it exposes an index projection Node::paint_order; on the gui side Widget.z_index_paint is written by apply and widget_paint_order stable-insertion-sorts the kids (identity order when all are zero, so existing examples stay frame-for-frame unchanged).

Verification: style 18/18, yoga-mbt 100/100 (baseline 95 + 5 new), core 120/120, cd gui && moon check --deny-warn and repo-wide moon check error- and warning-free; the set_margin labelling conversion covered all 14 call sites in one batch; library tests 856/858 (the 2 reds are the pre-existing W9/sort gaps, same reds on a stashed clean tree).

Toasts carried a whole-window background plate: overlays now open content-sized (2026-10-14, real-hardware report) ​

  • Environment: Ubuntu 24.04 + X11 + XFCE, examples/showcase (the browser page hangs in the same bind_node tree as the rest, so the webview area is permanently mounted).
  • Symptom: when a message toast popped, everything above the top strip looked as though the whole window had been covered by an opaque background plate (the user's words: "message 不应该有遮罩背景").
  • Root cause: toast_layer opened through overlay_show_full. When the owning window carries native children (the webview area), overlays take the native tier (overlay_native_tier() = Window::has_child_surfaces()), and the full-window tier opens a real window sized to the owner's content area — while every App root widget paints theme_bg_page() by default (core/app.mbt). So a window-sized plate of page background was hung over the main window, with the toast strip just a rounded band on top of it. The tier also made its carrier a full-window absolute wrapper with all four sides 0 (it paints no background, but every hit in the window belongs to it).
  • Fix: toasts now open content-sized (overlay_show_at, y=0, x centred on the window width; the centring formula is extracted into the pure function toast_left so it can be pinned), and the carrier is no longer absolute — a column plus align-items: center lets its width equal the widest strip naturally. Two companions:
    • core gained overlay_set_pos(id, x, y) (moves a layer-tier overlay in window coordinates; a native-tier window's position belongs to the window system after it opens, so it returns false and does not move) — needed when a wider toast arrived later and the whole group had to re-centre.
    • The native tier's resize pass gained a rule: after overlay_fit_pass exhausts its three-frame budget, growth is still allowed — if the content gets longer or wider and the box does not follow, the extra part is cut off; shrinking stays under the three-frame limit (so a width loop never flips the size back and forth).
  • One thing left untouched, recorded here: the layer is a full-window absolute child of the root widget, so while any overlay is open, presses outside it are still consumed by the layer (the "click outside to dismiss" in core/overlay.mbt) — during the two seconds a toast is on screen, clicking a page control does not receive that press. This is the shared input model of the overlay family; this batch only closed out the visual size, not dispatch. Whether overlays that have no outside-click-dismiss semantics (toasts) should let clicks through is decided in another batch.
  • Verification: moon test core ui 299/299 (core adds 1: after overlay_set_pos moves a layer, overlay_rect_of lands at the new window coordinates and an unknown handle yields false; the ui toast case gained assertions: the overlay rect is smaller than the window in both dimensions, and toast_left's centring plus clamping-to-0 across three points), library-wide 914/914, cd gui && moon check --deny-warn and cd examples && moon check --deny-warn warning-free, moon build showcase --target native links. Real-machine appearance (no whole-window plate outside the strip, still centred as strips of differing widths arrive) belongs to _人工测试清单.md S18.

Keyboard focus was dead everywhere: the focus stack only knew manually registered ids, and production code registered none (2026-10-15, real-hardware report on Linux and Windows at once) ​

  • Symptoms: on both platforms an input box could not be clicked into or typed in, Latin and CJK alike; the Chinese IME preview (composition string) appeared at an uncertain position instead of following the input box; buttons / menus / the section strip showed no focus ring at all. examples/showcase hit it everywhere, while examples/native-app looked healthy.
  • Root cause: App::route's Down only does if self.focus.focus(id) — if that number is not already in the focus stack, focus does not move — and the only thing that fills the stack, FocusStack::register, had exactly two call sites in the whole repo: ui/message_box.mbt (a transient overlay focus) and the examples themselves (native-app hand-registered 1..25; showcase registered only the events-page one). The discipline "whoever builds a widget hands its focus number to the host" never entered the library, so examples remembered to补 and the demo board did not → in showcase every input/textarea got an automatic focus number (auto_focus_id) yet that number was nowhere in the stack: clicks travel the pointer path (the caret still paints, the border still lights), so it looked like the box had been entered, while keys are delivered only to the widget named by the focus stack — hence zero typing. The Chinese symptom lines up exactly: with no focused widget im_cursor cannot supply a caret rectangle, so the candidate window stays at the system default = "an uncertain position".
  • Fix (the contract becomes "declaring is registering"): the focus stack's content is decided by the widget tree; callers only declare focus_id.
    • Widget::focus_ids(): collects the focus numbers that are "in the tree and not hidden" in tree order (depth-first, children in mount order), duplicates folded away, focus_id<=0 ignored. A hidden block (an unselected page, a dismissed popup) drops out of the Tab order by itself.
    • FocusStack::sync(ids): realigns the whole registry. Current focus is preserved by number (still in the list — still focused; removed — focus cleared); when the list is non-empty but nothing was focused it does not grab focus — after drop_current hands the keyboard to a webview child, the next realignment must not take it back to a self-drawn widget.
    • App::route's Down and App::move_focus (Tab) each realign once beforehand. Those are the only two consumers: painting only reads focus.current() (set_focused_by_focus) and needs no ordering, so there is no per-frame tree walk.
    • register/unregister stay, with their use narrowed to transient focuses that are not in the widget tree (a message box's overlay ids). The cost: such a number is dropped by the next realignment, so a long-lived one must be re-focused after each realignment.
  • Why not "components register themselves on mount": a widget can be mounted into a different window (one stack per window), bind_node/for_list remounts and virtual-list row swaps would all need paired deregistration, and focus_id is a public mutable field (w.focus_id = 910) that nothing can intercept — bookkeeping hung on widgets will leak. One pass in tree order solves all of it and uses the same predicate as focusable_at (first non-zero number along the hit chain).
  • Buttons / the section strip / menus stay deliberately opt-in (they enter the Tab order only when given a focus_id): this stack does not yet have the "focus + Enter/Space activates" semantics (the focus ring has long existed), so making them focused by default would ship the half-feature "Tab stops there, Enter does nothing" — another batch decides.
  • Verification: moon test core 143/143 with 4 new tests (gui/core/focus_route_wbtest.mbt): focus_ids tree order / hidden-block skip / duplicate folding; sync preserving focus and never inventing one; the end-to-end case that never calls register — click into the widget with focus number 7, keys reach it, Tab moves to 9, hide 9 and Tab then only circles 7; and pressing empty space does not move focus. Library-wide 906/912 (the 6 red are this machine's Windows environment baseline: sys's /tmp and .desktop path assertions plus render's PNG read-back, unrelated to this batch), cd gui && moon check --deny-warn and the examples module warning-free. The showcase events page's "register on mount" demo was deleted in step (it now only declares focus_id). On-device re-check is item S19 in _人工测试清单.md.

The pure raster backend never cleared the path: one widget's arcs were redrawn at another widget's origin (2026-10-15, real-hardware report on Windows) ​

  • Symptoms: in examples/showcase, form component "开关" section — a checked and an unchecked checkbox looked indistinguishable, both become a solid plate; a selected radio painted as a filled disc (the gap between ring and core was gone) and even the unselected one carried a core dot. The same code was fine on Linux (cairo backend).
  • Root cause: Raster::fill / Raster::stroke do not clear the current path after putting pixels down, while cairo_fill / cairo_stroke always do — a violation of the "the two backends are method-for-method identical" rule stated in Painter's header (item 3). One page shares a single Painter session and each widget paints at its own origin (paint_at does save()+translate() per level), so a subpath left by the previous widget is re-traced in the next widget's coordinate system: after the radio's ring + core, the checkbox's tick stroke() re-drew those two arcs as a 2 px accent shape at the checkbox's origin — a plate that appears out of nowhere — and an unselected radio got carried along with somebody else's core. Unit tests could not see it: painting one widget alone into a clean bitmap leaves only its own part in the path, and the contamination needs "the previous widget drew arcs" to show (in the probe, the third radio's red core came from the second).
  • Fix:
    • Raster::fill / Raster::stroke clear the path as soon as the ink is down, matching cairo (the zero-line-width branch paints no pixels but clears all the same).
    • Fixed the one call site that relied on the old behaviour: chart/geo.mbt built a region ring, fill()ed it and then stroke()d it directly — that stroke was already a no-op on Linux (the path had been cleared, so map regions never had an outline); extracted geo_ring_path and lay the path out twice, a fresh one per draw op.
    • Added explicit begin_path on the component side (the checkbox tick, the radio ring and core): that is the house style (paint_seq_aa in cairo_wbtest.mbt begins every op) and the discipline of "each stroke carries its own path".
    • The tick being too thin is a separate complaint, fixed in the same batch: line width 1.0 → 2.0, geometry tightened to (3.2,7.3)→(5.8,10.0)→(10.9,3.9) (a 1 px polyline inside a 14×14 box leaves a dozen pixels — it does not read as a tick).
  • Verification: 1 new render test on the shared backend contract (after the first triangle is filled red, neither the second green fill nor the blue stroke may redraw it) and 2 new ui pixel tests (the tick's solid coverage >20 pixels; an unchecked box contains 0 accent pixels; an unselected radio contains 0 accent pixels inside its ring — all three red under the old code). Library-wide 909/915 (the 6 red are this machine's Windows environment baseline: sys's /tmp and .desktop path assertions plus render's PNG read-back, unrelated to this batch), cd gui && moon check --deny-warn and the examples module warning-free. On-device look (unchecked = grey frame, checked = accent frame + thick tick; selected radio = ring + core, unselected = clean grey ring) is item S20 in _人工测试清单.md.

Widget shapes: the switch painted as two right-angled rectangles, the radio's ring and core merged into one plate (2026-10-15, real-hardware report on Windows) ​

  • Symptoms: with the path contamination gone, the same "开关" screenshot still reads wrong — the switch is a right-angled bar with a square knob (no capsule arc, no round ball); a selected radio has no seam between ring and core, so it reads as one solid plate; the checkbox's tick upper arm jams into the frame corner. Linux (cairo) renders the same code the same way, so this batch is the component's own drawing, not a backend split.
  • Root cause:
    • ui/form.mbt's switch was earlier two fill_rect calls (a 40×20 rectangle plus a 16×16 square), with a comment parked saying "the capsule is approximated by a rounded rectangle, square corners noted for later" — the note never landed, so a bar-with-square is what shipped.
    • The radio ring was stroked at line width 1.0 (radius 6 → inner edge 5.5) around a core of radius 3.5: only 1 px of seam left on each side, which antialiasing smears into nothing.
    • After the previous batch thickened the tick to 2 px, its upper arm ended at x=10.9, 3 px from the 14 px frame's right edge; the thicker end cap pushed it into the corner.
    • Trying a rounded checkbox frame surfaced one backend fact: at line width 1.0 Raster::stroke_round_rect drops all four straight segments — rect_device snaps the rectangle to integer edges while coverage is measured from pixel centres, so with hw=0.5 the overlap against an integer edge is exactly 0; only the corner SDF ring still paints. This batch's checkbox frame therefore went back to stroke_rect (an inner stroke — 1 px lands on a full cell), and a 1 px rounded frame waits for the backend to compute edges in exact device coordinates — another batch.
  • Fix:
    • switch → fill_round_rect(0, 0, r.w, r.h, r.h / 2) for a real capsule plus arc(cx, r.h / 2, 8) for a round ball; the ball's centre sits half the height from each end so its rim meets the capsule wall.
    • Radio ring line width 1.0 → 1.2, core radius 3.5 → 2.8. The centre row now measures ##..######..## (ring 2 cells, gap 2, core 6, gap 2, ring 2).
    • The checkbox tick tightened one more notch: (3.2,7.3)→(5.8,10.0)→(10.9,3.9) → (3.4,7.4)→(5.9,9.9)→(10.5,4.3), keeping both ends ≥2 px from the frame edges.
  • Verification: 2 new ui shape regressions (the radio centre row asserted cell by cell as "ring — gap — core — gap — ring"; the switch's four corners free of ink with the knob 16 cells wide at mid seam and only 6 at the top/bottom edge rows, i.e. round) — moon test ui 164/164, library-wide 912/917 (the 5 red are this machine's Windows environment baseline: sys's .desktop and directory-enumeration assertions, unrelated to this batch), cd gui && moon check --deny-warn warning-free. On-device look is item S20 in _人工测试清单.md.

A 1px rounded-rect stroke lost all four straight edges: the rasterizer snapped the edges but measured coverage from pixel centres (2026-10-15, development machine on Windows) ​

  • Symptoms: giving the checkbox a 1 px rounded frame (stroke_round_rect(0.5, 0.5, 13, 13, 3), line width 1.0) left only the four corner arcs on Windows — not one lit pixel along the straight sides; the same line paints a full ring on Linux (cairo backend). stroke_round_rect is public API (no call site inside the library), which means "a centred 1 px rounded border" had never actually painted on this analytic fast path.
  • Root cause: the edges came from rect_device's snapped result while coverage is computed at pixel centres: with hw = line_width / 2 = 0.5 and an edge on an integer, the nearest pixel centre is exactly 0.5 away → d = hw - 0.5 = 0 → coverage 0 and the whole straight segment vanishes. The corners survived because their branch runs the "distance to the arc centre" SDF, and an arc centre need not be an integer. Not isomorphic with cairo (the method-surface rule is stated in Painter's header).
  • Fix: edges now always come from exact device coordinates (m.apply on the two diagonal corners, min/max for negative scales), and rounding is only used to bound the loop; coverage became the analytic cell overlap cov = hw + 0.5 − |distance from centre to the ring| — the standard SDF antialiasing form, where +0.5 is the "a pixel is a whole cell" area correction — one formula for straight segments and corners alike.
  • Recorded along the way, deliberately not changed: a centred stroke landing on a half-pixel origin necessarily spreads into two adjacent cells at half ink. The checkbox mark is vertically centred in its row — a 14-high mark in a 17-high row gives rect.y = 1.5 — so after switching to a rounded centred stroke the frame line would read at roughly 50% strength, a step paler than the inner stroke it replaced; the frame therefore stays on stroke_rect (integer-snapped, crisp at any origin). Putting rounded frames on mark-class widgets first requires aligning them to integer device cells (or adding a "snap-to-cell centred stroke" to Painter) — another batch.
  • Another pit (unrelated to this change, noted in passing): running moon fmt once inside the batch — the toolchain's reflow conventions disagree with this repo's existing style, and one command rewrote 287 files (splitting if … { x } across lines, wrapping short parameter lists, etc.), forcing a whole-tree revert before re-applying this batch's four edits. Do not run moon fmt inside a feature batch; a repo-wide reformat is its own batch, and only after confirming the toolchain's fmt conventions match the repo style.
  • Verification: 1 new render test on the shared backend contract (edges on the cell centre 0.5 → all four side mid-points read full ink 255, outside the arc corner and inside the frame read 0; edges on an integer boundary → the straight segment still carries half ink, >60) and 1 new ui test that the frame stays sharp (all four sides and corners above 200). moon test render ui chart 403/403, library-wide 914/919 (the 5 red are this machine's Windows environment baseline: sys's .desktop and directory-enumeration assertions, unrelated to this batch), cd gui && moon check --deny-warn warning-free.

Why arcs and rounded corners looked like pixel art: a sign-flipped miter, a closed stroke stitched into one contour, and a rounded fill snapped to the cell grid (2026-10-15, real-hardware report on Windows) ​

  • Environment: development machine, Windows 10 22H2, pure-MoonBit raster backend (PureBackend; Linux runs the cairo backend against the same component code).
  • Symptoms: the radio outer ring had breaks along its line (per-cell readings such as ...-####.###-... at the apex), the checkbox tick read as a row of steps, and every rounded corner / arc / chamfer felt mosaic-y. The user guessed "the canvas resolution is too low".
  • Root causes — three, all inside the rasterizer, none of them canvas resolution:
    • Canvas resolution is not the cause: the process declares per-monitor-v2 DPI awareness (gui/win/stub.c), but nothing in the repository ever reads a DPI value (no wm_dpi/wm_scale export, and Linux never looks at Xft.dpi/GDK_SCALE); the frame canvas is allocated 1:1 in physical pixels (paint_frame in gui/core/app.mbt) and StretchDIBits gets identical source and target sizes. This machine reports AppliedDPI = 96 (100%), so there is neither DWM stretching nor scaled down-sampling. Open item: on a 125%/150% display this convention makes the whole UI small, and laying out by a scale factor needs its own batch.
    • stroke_polys solved the miter intersection with the wrong sign: joining the two offset lines means s·prev − t·cur = n − pn, and crossing with cur gives s = cross(r, cur) / det; the code wrote cross(cur, r) / det — the opposite sign — so the outer-angle miter point landed on the inner angle and vice versa, the outline self-crossed at every joint, and each turn of a polyline / arc / chamfer bit out a notch. The "the tick's upper arm jams into the frame corner" noted last batch is a symptom of it.
    • A closed polyline was stroked as one stitched contour (side A forward plus side B reversed): the two windings cancel at the stitch, leaving holes along the ring. A full arc(0..2π) never calls close_path, yet its coincident first/last points were handled as an open path — which also added two butt caps.
    • Raster::fill_round_rect snapped the rectangle to integer cells: the straight edges got no antialiasing at all (a panel at a fractional origin is shifted by up to half a cell) while only the corners were measured against the arc centre — the main source of the "rounded corners look like pixel art" impression; the same fast path also snapped concentric marks (ring / core / knob) to different cells, so they read off-centre.
    • The flattening tolerance RASTER_FLAT_TOLERANCE was 0.25 (cairo defaults to 0.1), so a small radius was cut into only twelve chords, which amplified the two above into visible gaps.
  • Fix:
    • The miter solution became s = (rx·uy − ry·ux) / det (gui/render/raster_fill.mbt).
    • Closed subpaths (including those whose endpoints coincide) now form two separate rings per side: outer forward plus inner reversed, and nonzero winding punches the hole automatically; the wrap-around vertex gets a real joint instead of end caps.
    • Raster::fill_round_rect was rewritten on exact device coordinates with per-pixel signed-distance coverage (256 levels); each row still resolves its fully covered column span and writes it as a solid run (the opaque-solid-source fast path unchanged), and only the few pixels at either end go through the distance form. Zero radius still takes the crisp cell-aligned rectangle.
    • RASTER_FLAT_TOLERANCE 0.25 → 0.1 (same as cairo).
    • Components: the radio ring became stroke_round_rect(1,1,12,12,6) and the core fill_round_rect(4,4,6,6,3), and the switch knob became a fill_round_rect circle — a 14 px-scale circle should not be flattened into chords at all; ring line width 1.2 → 1.3 (with 1.2 only one cell of the ring stays solid within 12 cells, which reads fuzzy); the tick 2.0 → 2.4.
  • Verification: 3 new render tests on the shared backend contract — a full circle stroked (r=6, line width 1.3) swept cell by cell along the band with no 1-cell break allowed; a right-angle joint whose miter must reach the outer corner ((17,17) must read full ink 255, the cell outside the corner 0); fill_round_rect at a fractional edge must antialias the straight side (that cell lands in the 60..200 half-ink range). All three are red under the old implementation (verified one by one by stashing each backend file: the miter read 128, the ring case had a 1-cell break, the fractional edge read 0). The two ui shape tests were re-calibrated to the new readings (radio centre row still ##..######..## plus the core's 6 solid cells; the switch knob's rows 4/15 now 10 solid cells). moon test render ui chart 406/406, library-wide 917/922 (the 5 red are this machine's Windows environment baseline: sys's .desktop/directory-enumeration assertions, unrelated to this batch), cd gui && moon check --deny-warn warning-free. On-device look is item S20 in _人工测试清单.md.
  • Still open (separate batch): generic path fills are still 4×4 supersampled (16 coverage levels), so long diagonals show mild steps; stroke_round_rect at a half-pixel origin still splits across two cells; every rounded background in the library is still bordered with stroke_rect inner strokes (square corners over rounded ones), and unifying those into rounded borders first needs the mark widgets aligned to integer device cells.

Typing a space in an input gave no feedback: both text engines trim trailing whitespace out of the measured width (2026-10-15, real-hardware report on Windows) ​

  • Symptoms: typing a space in an input moved neither the caret nor anything else; the space only "appeared" together with the next non-space character typed after it.
  • Root cause: a space carries no ink, so the only visible signal for a typed space is the caret moving right — and the input's caret offsets come entirely from @text.measure_font prefix widths (prefix_width in gui/ui/entry.mbt). Both layout engines define "text width" with alignment semantics that exclude trailing whitespace: DWrite's DWRITE_TEXT_METRICS.width excludes it by definition (the inclusive field is widthIncludingTrailingWhitespace, and the C-side pt_measure_font took the former), and Pango has trimmed trailing whitespace from the layout width since 1.44 (pango_layout_get_pixel_size likewise). So measuring "abc " equals "abc", and the caret never moved. The drawing paths (ptw_render / the batch queue's ink rect) use widthIncludingTrailingWhitespace, which is why a space mid-line always worked — only the trailing one was affected, and tests measuring whole words never caught it.
  • Fix:
    • DWrite (gui/text/dwrite_text.cpp): pt_measure_font and rt_measure now take tm.widthIncludingTrailingWhitespace; the alignment helper ptw_box_offsets keeps tm.width (centring / right alignment should not count trailing whitespace) — two concerns, two fields.
    • Pango (gui/text/stub.c): new helper pt_width_with_trailing_ws — when the text ends in whitespace (g_unichar_isspace on the last character) and the layout is a single line, the width is recovered from pango_layout_index_to_pos(len), the cursor endpoint (Pango cursor positions do cover trailing whitespace), taking the larger against the pixel width; multi-line layouts are left alone (the last line's endpoint is not the total width).
  • Verification: 1 new cross-backend regression in text (measure("ab ") must be wider than measure("ab"), and a lone space must measure > 0). moon test text 30/30, library-wide 918/923 (the 5 red are this machine's Windows environment baseline: sys's readings loop and directory-enumeration assertions, unrelated to this batch), cd gui && moon check --deny-warn warning-free. On-device re-check is item S19 ⑧ in _人工测试清单.md.

A closable alert did nothing when clicked: the component never implemented "collapse by default" — the default callback was an empty function (2026-10-15, real-hardware report on Windows) ​

  • Symptoms: on the feedback page's "可关闭" section, clicking the ✕ at the banner's top right left it exactly where it was.
  • Root cause: the ✕'s hot zone and callback chain were fine all along (the white-box test shows on_close firing when the hot zone is clicked). What was broken is the semantic layering — on_close's default value is an empty function, showcase happened to pass no callback, and the component itself never implemented the collapse behaviour that ✕ denotes: clicking it amounts to calling an empty function, so nothing happens. The component page's copy "这里留默认(只收起)" promised exactly this unimplemented behaviour.
  • Fix (gui/ui/display.mbt): ✕'s own behaviour moved into the component — a hot-zone click always set_visible(false) (synced with yoga Display::None, so layout reflows) plus @core.invalidate() (whole-window dirty: the collapse reflows the whole column below, which a local dirty rect cannot cover); on_close is demoted to an optional notification on top of the collapse, and the banner collapses whether or not a callback was passed. The native-app call site that passes a counting callback gains the consistent behaviour for free (banner collapses and the counter ticks).
  • Verification: ui's existing alert regression was rewritten — paint the open state and assert the ✕ mark exists, click the hot zone and assert the callback fired + visible == false + after relayout/repaint the whole frame is ink-free; a click outside the hot zone collapses nothing. Library-wide 918/923 (same baseline), cd examples && moon check --deny-warn warning-free. On-device re-check is item S8 in _人工测试清单.md.

Form controls could not be reached by Tab and had no keyboard interaction: the opt-in focus contract kept the whole family out of the Tab order (2026-10-15, user report) ​

  • Symptoms: stepper, slider, rate, select (plain / colour / date / time), switch, checkbox and radio options could not be Tab-reached and did not respond to space/arrows/Enter; buttons, menus and the segmented control were the same.
  • Root cause: the focus infrastructure (focus stack, Tab wrap-around, dispatch_key delivering along the focused widget's ancestor chain, tree-order focus_ids alignment) was all in place; the 2026-10-14 batch only auto-assigned numbers to text inputs, and select/button etc. stayed under the then-rule "focusability is deliberate opt-in" (the note at the end of the 《Keyboard focus was dead everywhere》 entry) — because back then the "focus + activation key" semantics did not exist, so default-focusing would have shipped the half-feature "Tab stops there, Enter does nothing". This batch completes the activation semantics and flips the contract: the form / navigation / menu families auto-assign a focus number and enter the Tab chain by default (auto_focus_id; an explicit number still wins), so call sites no longer hand out numbers one by one.
  • Fix (key semantics per control):
    • button / checkbox / switch / radio option / segment item: space/Enter activates (toggles or selects);
    • radio_group / segmented: arrows re-select by the focused item's index (not relative to the current value), wrapping at the ends; Enter/Space activates the focused item;
    • slider: ←/↓ decreases, →/↑ increases (one percent of the range when no step), Home/End to the two ends;
    • stepper: ←/↓ decreases, →/↑ increases; the out-of-range side consumes the key without changing the value;
    • rate: arrows add or remove a star, Home/End to the extremes, writing the value fires the same callback as a click;
    • select (non-editable) / color_picker / dropdown: space/Enter/↓ opens the layer, ↑/↓ (the swatch grid: ←→ move one cell, ↑↓ move one row) moves the highlight, Enter/Space takes the highlight, Esc closes; dropdown's - separators take no highlight slot;
    • date_picker / date_range_picker / time fields: space/Enter/↓ opens, Enter/Esc closes (attach_field_toggle_key; arrow navigation inside the calendar is not done — the popup stays click-only);
    • focus ring: entry/picker field outlines turn to the theme colour when focused (painted over the resting outline), marker-class controls (checkbox outline, radio ring, switch pill, stepper frame, rate/segment/slider) draw a theme-coloured outline ring, buttons keep their existing ring. Painting is clipped to the widget rect, so every ring is drawn inside the widget rect.
  • Verification: 8 new headless ui regressions (marker controls space/Enter/disabled-ignores-keys + focus_ids coverage, radio wrap-around re-select, slider/stepper/rate key adjustment, button space/Enter, the full select keyboard flow, colour-picker grid navigation, dropdown skipping separators, date-field open/close). moon test ui 176/176, library-wide 934/935 (1 red = this machine's Windows environment baseline), cd gui && moon check --deny-warn and the examples module warning-free. On-device re-check is item S22 in _人工测试清单.md.

Fullscreen and window resizing did nothing on Windows: the win branch ships two symbol-bearing stubs while GTK has had the real thing all along (2026-10-15, real-hardware report on Windows) ​

  • Symptoms: on showcase's window page, the "全屏切换" and "尺寸切换" buttons (and every call going through App::set_fullscreen / Window::set_size_pixels) did nothing on Windows — clicking raised no error, the window stayed put.
  • Root cause: in gui/win/stub.c's Windows branch, wm_window_set_size / wm_window_set_fullscreen were symbol-bearing empty stubs (every parameter discarded with (void)), while the GTK branch had long wired gtk_window_resize / gtk_window_fullscreen. Because the symbols exist the link never breaks and the calls are silently inert — the "same method surface on both backends, the real implementation landed on only one" pitfall struck the win stub too (the same shape as the Raster path-not-cleared and rounded-stroke-straight-segments bugs before it).
  • Fix (gui/win/stub.c Windows branch):
    • wm_window_set_size: the parameters are client-area physical pixels (the stack-wide physical-pixel convention); expand to the outer window size with AdjustWindowRectEx on the current style and SetWindowPos(SWP_NOMOVE|SWP_NOZORDER|SWP_NOACTIVATE); width/height < 1 clamp to 1 (the MoonBit side set_size_pixels already rejects <1, so this is the C-side backstop; measured: a window with a title bar gets held up by the system minimum width, so the width clamp is only observable on the borderless form). The existing WM_SIZE bookkeeping sets resized, so the MoonBit side keeps receiving the Resized event and the host never computes the new rectangle itself.
    • wm_window_set_fullscreen: entering records the scene (style + WINDOWPLACEMENT), strips WS_OVERLAPPEDWINDOW for WS_POPUP, and SetWindowPos onto the nearest monitor (MonitorFromWindow + GetMonitorInfoW) at full screen; exiting restores the style and placement verbatim. Repeated calls with the same value return immediately (idempotent).
  • Verification: two new sections in experiment/window_probe/window_enhance_probe.c — after set_size, GetClientRect reads back the target size, wm_windows[].resized is set, and wm_size agrees; fullscreen entering gives a window rectangle equal to the monitor rectangle with the client-area bookkeeping in sync, exiting restores position and style value by value, repeated enter/exit is idempotent, and an out-of-range id does not crash. The full run prints WIN-ENHANCE-OK (this machine, Windows 1920×1080). Compiling the probe also required two missing pieces: shell32.lib (the tray's Shell_NotifyIconW is now in the symbol surface) and a malloc backstop for moonbit_make_bytes (the clipboard stub references a runtime allocation symbol); the build recipe is updated in the probe header comment and build.bat. moon test win 28/28, cd gui && moon check --deny-warn warning-free, cd examples && moon build hello --target native links. On-device re-check is item S23 in _人工测试清单.md.

Global hotkeys never fired: the poll loop sat in a construction-time hook and died on its first tick before the host existed (2026-10-15, real-hardware report on Windows) ​

  • Symptoms: on showcase's window page, clicking "注册 Ctrl+Alt+C" reported registration success, but after switching to another program and pressing the combo, "命中 N 次" never moved.
  • Root cause: the library-side chain (register → pump intercepts WM_HOTKEY → poll extracts) is fine — experiment/window_probe drives the exact same path with PostThreadMessageW delivering real WM_HOTKEY messages and is all green. What broke was where showcase parked its poll table: the "zero-size mount hook" was written inside node_of_new's finish, which runs at construction time (documented in core/node.mbt), and page functions are mount_window arguments evaluated before the window exists — so the first tick's hotkey_poll() ran on the empty timer channel with app.host.val still None, and the loop never re-armed: dead on the spot. Every WM_HOTKEY intercepted by the pump afterwards had no reader. This is occurrence #2 of the same pitfall the charts push-point fix (52119a4) closed; the "零尺寸挂载钩子" comment in the page promised mount time while using a construction-time API.
  • Fix (examples/showcase/pages/window.mbt): the hook moved to Node::on_mount — the section body mounts lazily, so it runs only when this section is first selected, by which time the host is ready; the 200ms self-rearming table is unchanged. The fragile shape of the poll table ("dies permanently if the host is not ready before the first tick": the tail tick reads app.host and stops re-arming when None) is the example side's current state and is an open hardening item (another batch).
  • Verification: experiment/window_probe gained a hotkey section — register → PostThreadMessageW posts WM_HOTKEY → wm_pump intercepts → wm_hotkey_poll extracts, repeated posts within one batch count once, re-registering the same combo is refused by the system, and after unregistration the same id no longer sets; the full run prints WIN-ENHANCE-OK. cd examples && moon check --deny-warn warning-free, moon build showcase --target native links. On-device re-check is item S24 in _人工测试清单.md (a real key press can only be done by a person).

The tray menu would not pop and neither mouse button did anything: occurrence #3 of the same construction-time pitfall — the dispatch table is started inside finish too (2026-10-15, real-hardware report on Windows) ​

  • Symptoms: on showcase's "系统集成" page the tray icon is created (the icon shows, the tooltip hovers out), but left clicks do nothing (the counter stays 0 and no hint pops), and after "挂菜单" a right click pops no menu either.
  • Root cause: exactly the same shape as the hotkey section above. The library chain is intact — experiment/tray_probe/tray_backend_probe.c feeds wm_tray_* directly, and left/right-click callback messages queueing and extraction by wm_tray_poll, menu attachment through wm_menu_popup_new + wm_menu_add_item, and invalid slots / no menu never blocking are all green. What broke is the example side: tray_poll_loop()'s start was written in node_of_new's finish (running at construction time), so on its first tick @core.host_active() was still None ⇒ the 200ms table never re-armed and died on the spot. From then on nobody ever called dispatch_tray_event(): left-click callbacks were never extracted, and on Win32 the rule "a right click with no on_context callback pops the attached menu automatically" also lives inside dispatch_tray_event ⇒ what the user saw was "menu won't attach + clicks do nothing". The menu-item dispatch on the same page (dispatch_menu_command) was unaffected because its table starts in the host handle (the genuine mount phase).
  • Fix (examples/showcase/pages/system.mbt): the table start moved to Node::on_mount (the section body mounts lazily and only runs when the section is first selected, when the host is ready); creating the tray itself stays in construction (the existing shape where the icon appears at startup is unchanged). The page comments were rewritten to match reality — the old line "段首次选中时建托盘 + 起派发表" promised mount time while using a construction-time API, which is why this pit was hit three times in a row.
  • Verification: moon check --deny-warn (examples) warning-free, moon build showcase --target native links. The library changed nothing this batch; its tray semantics were re-established on this machine with experiment/tray_probe/tray_backend_probe.c (pure C, #includes stub.c directly): left/right-click callback messages queue and are extracted one by one by wm_tray_poll, kind 0/1 correct, non-click messages do not queue, wm_menu_popup_new's popup root is a real HMENU and after attaching, wm_menu_add_item yields command ids, wm_tray_popup with no menu / an invalid slot returns 0 immediately without blocking, and removal is idempotent — the last line reads TRAY-BACKEND-OK. The probe builds like window_probe with a build.bat added; compiling the whole stub.c brings in the moonbit_make_bytes referenced by the clipboard stub, and the probe backstops it with malloc (recorded in the header comment). On-device re-check is item S25 in _人工测试清单.md: enter the "系统集成" page → left click increments the counter and pops a hint → click "挂菜单" → right click pops three items → clicking an item goes through dispatch_menu_command into the callback.
  • Open hardening item (another batch): the "the host drains once per tick" contract currently depends entirely on caller discipline and has now been hit three times in a row (chart push-point / global hotkeys / tray); the way to end this pit is folding dispatch_menu_command + dispatch_tray_event + hotkey_poll into the host's per-tick step (App::step). A consume-style queue tolerates several extractors coexisting (native-app's explicit calls will not steal events from it).

Windows system-level drag-in: OLE callbacks live inside message dispatch, so the drop effect has to be written back in two stages (2026-10-15, feature request from the user) ​

  • Environment: Windows 10 19044 dev machine, MSVC /W4, pure-C probe that #includes ../../gui/win/stub.c directly (no moon runtime linked).
  • Symptoms/motivation: the user asked "drag-and-drop should also interact with the outside world — drag in, drag out — and internally it should support repeated drags too". W21c only shipped in-app drag and drop, with the system level (dragging files in from Explorer) explicitly recorded for a platform batch. Repeated internal drags turned out to be an existing capability (route_pointer releases the capture on button-up and the session resets in drag_drop/drag_cancel, so it was never one-shot) — this batch just pins it with a regression. System-level drag-in lands here; drag-out (DoDragDrop + IDropSource + IDataObject) is a separate batch.
  • Root cause / design rationale (why it looks like this):
    • OLE calls IDropTarget on this thread, inside message dispatch, the same constraint as tray/menu/hotkeys (rule 3: no C-to-MoonBit callbacks) ⇒ the C side pushes DragEnter/DragOver/DragLeave/Drop into a process-wide ring (64 slots) and the host drains it per tick with wm_dragdnd_poll. The payload does not go through the queue - it lives in a separate 64 KB buffer, read back with wm_dragdnd_payload as the most recent "enter/drop" payload.
    • Two-stage effect: at DragEnter, MoonBit has had no chance yet to decide whether this drop target accepts, yet the callback must return pdweffect immediately — so C gives a provisional value from "do I recognise this format at all" (CF_HDROP/CF_UNICODETEXT → COPY, unrecognised → NONE). Every later DragOver/Drop reads the per-window flag MoonBit wrote back with wm_dragdnd_set_effect on the previous tick. The app-side accept judgement therefore reaches the system cursor without a synchronous question into MoonBit.
    • accept needs its own hook: calling on_drop performs a real drop (side effects), so it cannot double as the hover-time "would you take this?" query ⇒ Widget::drag_accept + drag_wants (defaults to accept when no hook is installed), and @ui.drop_target(accept=) wires both on_drop and drag_accept.
    • One session, not two: core/drag.mbt's process-wide singleton drag_current is the one in-app drag and drop already uses; external events are fed into it as ENTER→drag_begin(data,0,x,y), OVER→drag_move, LEAVE→drag_cancel, DROP→drag_drop, so drop-target hit-testing (drop_target_at) and hover highlight (drag_hover) are all reused. The drop tree is picked from the event's own window (drag_window_root_of scans host_windows() comparing a.window.id), and drag_root() prefers that tree while an external session is live.
    • Neither direction hijacks the other: drag_ext_window records which window owns the current external session, LEAVE/DROP only act on same-window events, and an external ENTER is ignored outright while an internal drag is in flight (it does not steal the running drag).
  • Payload wire shape: CF_HDROP wins (that is what Explorer sends), enumerated with DragQueryFileW one wide string at a time and joined with \n → Paths (kind 4); only when HDROP is absent does CF_UNICODETEXT → Text (kind 1); neither recognised → empty payload and effect NONE. All text goes through WideCharToMultiByte(CP_UTF8) and is encoded as [kind:i32 LE][payload], the same shape as drag_encode, so MoonBit's drag_decode consumes it as-is. Coordinates are converted with ScreenToClient into client-area pixels (the stack-wide physical-pixel convention).
  • Pitfalls (C side, each hit for real):
    1. IDataObjectVtbl::QueryGetData takes exactly one FORMATETC * — copying GetData's shape and adding an extra NULL is C2197 "too many actual arguments" (confirmed against the SDK's objidl.h).
    2. In C mode GetCanonicalFormatEtc's second parameter is FORMATETC *, not the C++ alias DFORMATETC (which only exists under #ifdef __cplusplus) — writing it does not compile (C2143/C2081).
    3. DROPFILES is defined in um/ShlObj_core.h, not shellapi.h — you need #include <shlobj.h>; pFiles = sizeof(DROPFILES) + fWide = TRUE + a double-NUL-terminated wide table are what make DragQueryFileW accept the blob.
    4. COM interfaces are implemented the C way (iface as first member + a static vtable); the vtable definition must not be const (the lpVtbl field is a non-const pointer). IID_/CLSID_ come from uuid.lib, the drag functions from shell32.lib, OleInitialize from ole32.lib — all three were already on prebuild's Windows link line from the earlier icon/tray/taskbar batches, so nothing was added here.
    5. Whether OLE is up must be probed once and cached: OleInitialize returning S_OK/S_FALSE or RPC_E_CHANGED_MODE (someone already called CoInitialize) all count as ready. When dragdnd_supported() is permanently false the layer above skips attaching silently and in-app drag and drop keeps working.
  • Pitfall (probe side): OLE hands DragEnter/DragOver screen coordinates, so to test a point at client (20,30) the probe must derive the screen point with ClientToScreen in reverse; offsetting from screen origin (0,0) folds in the window's own position and shifts the whole hit test by a window distance (measured -48/-84) — it looks like a library bug but is a probe misuse.
  • Verification: experiment/drag_probe/dragdnd_probe.c ends with DRAGDND-OK (40 assertions: attach/detach/idempotence/out-of-range id/live count/initial effect; HDROP → Paths(4) with both paths matching character for character; set_effect observed by the next DragOver/Drop; payload cleared after a drop; HDROP preferred over text; plain text → Text(1); an unrecognised format answers NONE with an empty payload; DragLeave clears the payload and queues LEAVE; after disable the count is 0 and the effect -1). core/drag_wbtest.mbt adds 1 test (fed events drive the session, COPY/NONE answered per drop target from drag_wants, a data = None event leaves an existing session alone, DROP delivers and resets, LEAVE cancels without delivering, cross-window events do not steal, an internal session is not hijacked); ui/drag_wbtest.mbt adds 2 (the same drag source can be dragged twice in a row with the payload rebuilt each time; accept is wired as the hover query too). moon test core ui win 351/351, full library 937/938 (the one red is si_machine failing to read SystemManufacturer from the registry — this machine's environment baseline, unrelated), cd gui && moon check --deny-warn and the examples module warning-free, moon build showcase --target native links, and a local window exits normally after the smoke run. A real cross-process drag (Explorer → window) can only be done by a person: item S26 in _人工测试清单.md.

Windows system-level drag-out: a read-only IDataObject plus SHDoDragDrop saves an entire IDropSource — and GetFullPathNameW must not write back in place (2026-10-15, feature request from the user) ​

  • Environment: Windows 10 19044 dev machine, MSVC /W4. The library build goes through moon's native-stub (the toolchain assembles the cl command); the probe goes through experiment/drag_probe/build.bat (vcvars64 + explicit cl flags) — two different macro and include-path sets, which is what makes pitfall 2 below bite.
  • Symptoms/motivation: the "drag out" half of the user's sentence "drag-and-drop should also interact with the outside world — drag in, drag out". The goal: dragging out of this window must give the receiving program (desktop, Explorer, an editor) standard copy/move/link semantics, not just play inside our own window.
  • Root cause / design rationale (why it looks like this):
    • No IDropSource: build a read-only IDataObject (12 slots) and hand it to SHDoDragDrop(hwnd, pdata, NULL, dwEffect, &effect). With the third argument NULL the system uses its default IDropSource, which polls the real mouse button and Esc for us — that removes the whole QueryContinueDrag/GiveFeedback vtable, and Explorer's "Copy to X" drop-description bubble comes along for free. SHDoDragDrop is declared in um/ShlObj_core.h and lives in shell32.lib (already on prebuild's Windows link line from the earlier batches).
    • Both data-fetching paths of the read-only object have to be implemented: our own drag-in side (wm_dnd_fetch) calls GetDataHere (write in place into a caller-supplied HGLOBAL) while foreign targets often call GetData; both are real. EnumFormatEtc yields only the single current format for DATADIR_GET, SetData/DAdvise/EnumDAdvise answer E_NOTIMPL explicitly, and QueryGetData accepts only the entry whose cfFormat matches.
    • Blocking semantics: this call blocks until button release or cancel (OLE runs a message loop inside it). Drag-in callbacks for this window still land in the C queue and the host drains them on the next tick after it returns ⇒ "drag out and back into our own window" works, but there is no live in-app hover highlight while the drag runs (that is the in-app session's treatment, and only one session can run at a time). It also must be called while the mouse button is still down (the moment the threshold is crossed); calling it after release is not accepted by the system — @ui.drag_source fires exactly in the Move branch.
    • Fallback contract (important): @ui.drag_source(external=true) tries the system run first and falls back to the in-app session only on Err (backend not wired / this kind has no system-level form / the run cannot start); Ok (delivered or user-cancelled) never falls back — by then the button is already released, so starting a session would drag out a phantom segment. The same example code therefore runs on all three platforms: the Linux/mac stub implementations always answer Unsupported and land back in-app.
    • Effect numbering: DragEffect (Rejected/Copy/Move/Link) matches Win32 DROPEFFECT_* value for value (0/1/2/4). drag_effect_of_flag accepts only those four; combined bits (3, 7) and out-of-range values are treated as "no deal" rather than guessed as copy. The dwEffect handed to the system at start is COPY|MOVE|LINK fully open, and Ctrl/Shift/Alt decide the actual one.
    • Kind filter runs before the drag starts: Text → CF_UNICODETEXT and Paths → CF_HDROP have system-level forms; Html (needs CF_HTML's StartFragment/EndFragment source-markup format) and Img (needs a bitmap / METAFILEPICT) are not offered in this batch, so drag_data_has_system_form stops them outright and OLE is never touched.
  • Pitfalls (C side, each hit for real):
    1. GetFullPathNameW must not write back into its own input buffer. CF_HDROP wants absolute paths; the first version called GetFullPathNameW(buf, size, buf, NULL) and a relative segment such as rel\乙.txt came out as <cwd>\<truncated string> junk — writing in place overwrites input that has not been consumed yet, and that is exactly what the receiving program gets. Fixed with separate in/out buffers (wm_dsrc_one for the wide conversion, wm_dsrc_abs for the full path); if resolution fails or overflows, the value is passed through as-is rather than swallowing the whole path.
    2. Worse: the probe computed its expected value with the same in-place call, so "each path parses back to the original path" stayed green against the broken implementation. The probe now uses separate buffers (srcw/want), assembles the expected string itself, and writes the \n separator into the table before the conversion (this was initially done the other way round). Rule of thumb: in a synthetic probe, deriving the expectation with the function under test is not a test.
    3. DROPFILES and SHDoDragDrop are not visible to the library build: gui/win/stub.c only included <shobjidl.h>, while the probe additionally included <shlobj.h>, so the probe was always green — but moon's cl invocation fails with 17 C2065: "DROPFILES" undeclared (and SHDoDragDrop only shows up as an implicit declaration, not a hard error — the easiest thing to miss). Adding #include <shlobj.h> makes both builds pass. The same "SDK headers are trimmed by include order and version macros, so a green probe does not mean a green library" pitfall, this time caused by two different compile commands.
  • Verification: experiment/drag_probe/dragdnd_probe.c ends with DRAGDND-OK (84 assertions: the 40 drag-in ones plus 44 drag-out): after wm_dsrc_fill, QueryGetData positive/negative; the CF_HDROP blob from GetData is parsed by DragQueryFileW back into independently computed expected strings, all three entries present, relative paths absolutised (asserting characters 2 and 3 are L':'/L'\\'); GetDataHere matches GetData character for character on the first entry; the enumerator's Next/Reset/Skip behave, Clone answers E_NOTIMPL, SetData fails; feeding the same object back into this window's IDropTarget yields a payload identical to the original encoding (the round trip closes). Text case: wide characters match one by one, GlobalSize == (wlen+1)*2, round trip gives Text(1). Negative cases: kinds 2/3 have no system form, empty body, no buffer left behind after failure, and wm_dragdnd_send returns 0 without writing an effect for out-of-range ids, NULL payload, len < 4 or a NULL out_effect. The probe source is /W4-clean (a C4389 signed/unsigned comparison and two C4456 shadowings fixed) and stub.c keeps only its two pre-existing C4996 warnings. MoonBit side: core/drag_wbtest.mbt +2 (effect numbering round-trips with DragEffect::flag, unknown flags mean Rejected; only Text/Paths have a system form, and a headless send always Errs leaving no half-open session), ui/drag_wbtest.mbt +2 (external=true falls back to the in-app session when the system run cannot start and still completes the drop, with on_external reporting it; Html data is judged Unsupported and falls back). moon test core ui win 355/355, full library 941/942 (the single red is si_machine failing to read HARDWARE\DESCRIPTION\System\BIOS:SystemManufacturer — this virtualised machine's baseline red, unrelated to this batch), cd gui && moon check --deny-warn and the examples module warning-free, moon build showcase --target native links, and the local smoke run stays alive 4 seconds and terminates normally. Dragging onto the desktop or into Notepad can only be done by a person: item S27 in _人工测试清单.md.

Re-checking S26 on a real machine: dragging files in from Explorer showed the "no drop" cursor — GetDataHere alone cannot read the shell's payload (2026-10-15, real-hardware report from the user) ​

  • Environment: Windows 10 19044 real machine, showcase's "窗口" page drag-and-drop section, source = Explorer (a genuine cross-process COM data object).
  • Symptoms: dragging files onto the window showed the prohibition cursor and released nothing (S26 item ① failing), while the same code was fully green in the probe.
  • Root cause: wm_dnd_fetch only used the GetDataHere leg (the comment at the time justified it as "spares the IMalloc release leg"). The shell's data object usually implements only GetData for CF_HDROP and answers GetDataHere with E_NOTIMPL/MK_E_UNAVAILABLE ⇒ QueryGetData recognises the format but nothing can be read back ⇒ wm_dnd_extract concludes "I don't recognise this payload" ⇒ DragEnter promises NONE, so the cursor is prohibited from the very first moment and no later write-back from MoonBit can rescue it (the initial value is already NONE).
  • Fix: fetching now has two legs — try GetDataHere first (we hand over an empty GMEM_MOVEABLE and the block stays ours to GlobalFree), and on failure fall back to GetData (the callee allocates the medium, ownership lives in the medium, so it must be released with ReleaseStgMedium and never GlobalFreed). Because the release path now depends on the fetch path, the return changed from a bare HGLOBAL to WmDndBlob{h, via_medium, medium} plus wm_dnd_blob_free, and both branches of wm_dnd_extract use it.
  • Why the probe stayed green: the probe's synthetic data object implements both legs, so "only one leg is tried" is invisible against it — exactly the class rule 5 warns about ("self-consistent unit tests ≠ interoperability passing"). ProbeDo gained no_get_data_here/no_get_data switches, and 9 new assertions pin that an object implementing only GetData must still be recognised as Paths(4)/Text(1) with a COPY promise, and that only an object refusing both legs may honestly answer NONE with an empty payload.
  • Diagnostic logging added along the way (a real cross-process drag can only be run by a person, so failures need readings): with the environment variable NATIVEGUI_DND_LOG=<absolute path> set, gui/win/stub.c appends the key nodes of the drag chain to that file — ole_init ready=…, enable slot=… hwnd=… ok/FAILED hr=…, enter slot=… recognized=… payload=… promised=… pt=…, over slot=… answered=… (only when the answered effect changes, so it never floods), set_effect slot=… effect=…, drop slot=… recognized=… eff=…. With the variable unset it writes not a byte. That separates "OLE never came up" / "never registered" / "payload unreadable" / "MoonBit answered NONE" at a glance.
  • Verification: the probe experiment/drag_probe/dragdnd_probe.c ends with DRAGDND-OK, 93 assertions (84 → 93); moon test core ui win 355/355, full library 941/942 (the one red is still si_machine's BIOS registry reading — this machine's environment baseline), cd gui && moon check --deny-warn warning-free, moon build showcase --target native links. Real-machine re-verification stays under item S26 in _人工测试清单.md (recommended: run once with NATIVEGUI_DND_LOG and paste the log back).

"Reveal TODO.md in the file manager" did nothing: the absolute check only recognised a leading /, so Windows paths got glued into <cwd>/C:\… (2026-10-15, real-hardware report) ​

  • Environment: Windows 10 19044 real machine, showcase's "系统集成" page "外部打开" section (the button passes the relative path TODO.md).
  • Symptoms: clicking "在文件管理器显示 TODO.md" located nothing (it opened somewhere else, or nothing at all); the user also pointed out two missing entry points — open a given file with the default software, and open a given file with a specified software.
  • Root cause (three things stacked):
    1. launcher_abs_path decided "absolute" only by a leading /, so C:\… and UNC \\srv\… were treated as relative and appended to the cwd, producing junk like <cwd>/C:\a\b.txt.
    2. Joining always used /, and explorer /select,"…" does not select a path with mixed separators (it usually just opens a folder, sometimes "Documents") — separators must be unified to \ before handing over.
    3. ShellExecuteW returning >32 only proves "explorer started", so Ok was a false promise: when the path does not exist the system side succeeds silently. Added yue_sys_path_exists (GetFileAttributesW on Windows, stat(2) elsewhere) and one check before handing over, with a new NotFound variant (in both FileManagerError and the new OpenFileError).
  • A toolchain pit blown open on the way: in this toolchain indexing a String yields a UTF-16 code unit (UInt16), so rebuilding the string character by character as Char corrupts non-ASCII paths (one 3-byte UTF-8 sequence becomes three Chars). launcher_win_path therefore encodes to Bytes with @utf8.encode, replaces byte by byte, and decodes back.
  • Why it was never caught: every normalisation assertion in sys/launcher_wbtest.mbt used POSIX shapes (/a/b, /cwd) — not one Windows shape — and reveal's real action was only ever proven by the example. Rule of thumb: path-normalisation white-box checks need a set per platform shape, otherwise "only / counts as absolute" is invisible in tests.
  • Two new entry points: @sys.open_file(path) (default program: Windows ShellExecuteW "open", Linux xdg-open, macOS open) and @sys.open_file_with(app, path) (specified program: Windows goes through the new entry yue_sys_shell_open_with, with lpFile = program and lpParameters = the quoted path — otherwise a path containing spaces arrives as several argv[1] pieces; Linux/macOS use the detached spawn with argv passed directly, where spaces are inherently safe). Both put the platform gate first (unsupported platforms answer Unsupported before any path check); app may be a command name resolvable on PATH (notepad.exe) or a full executable path. The macOS "by application name" form (open -a TextEdit) is not wired — pass the executable path — and follows the mac platform batch.
  • Verification: 4 new assertion groups in sys/launcher_wbtest.mbt — the three absolute shapes (/, drive + colon, \\ and leading \) with C: drive-relative explicitly not absolute, Windows absolute paths never appended to the cwd, launcher_win_path leaving Chinese and already-correct strings unchanged in shape (including mixed input fed through launcher_abs_path and the normaliser together), live sys_path_exists readings (the working directory exists, an appended made-up name does not, empty is false), and the three side-effect-free negative cases of the open entry points (empty path / nonexistent path / empty program name). moon test sys core 333/334 (the single red is still si_machine reading HARDWARE\DESCRIPTION\System\BIOS:SystemManufacturer — this machine's environment baseline), cd gui && moon check --deny-warn and the examples module warning-free, moon build showcase --target native links, and the local smoke run stayed alive 4 seconds and was terminated by its Windows pid. Real-machine re-verification: item S28 in _人工测试清单.md.
  • Same-batch self-check gap closed on Linux (2026-10-15): the assertion above "win_path round-trip still exists" (sys_path_exists(launcher_win_path(cwd))) was hard-coded to Windows filesystem behaviour — Windows' stat accepts both separators, while on Linux a POSIX path turned into a backslash string never stats as existing, so CI Linux would necessarily go red. It now dispatches at run time on sys_platform(): Windows keeps the win_path round-trip, other platforms assert the launcher_abs_path normalisation round-trip instead (a path that exists is not broken by normalisation). The first landing of this section's "one assertion group per platform shape" lesson.

Circles/arcs/rounded corners still look pixelated (second root cause): 4×4 sample counting gives only 17 coverage levels, and shallow edges measured 2 (2026-10-15, real-hardware report) ​

  • Environment: Windows 10 19044 real machine; the backend is the pure-MoonBit rasterizer (gui/render/raster*.mbt — Windows/mac take this path, Linux uses cairo). An earlier round had already fixed miter sign, closed-stroke seams and rounded-fill snapping (see "Why arcs and rounded corners looked like pixel art"), yet the user's re-check still said "circles, arcs and rounded corners still look pixelated".
  • Symptoms: brightness along curve edges jumps instead of fading — worst on small circles (switch knob, radio core, r≈6).
  • Readings first (measure before changing; the throwaway probe was deleted afterwards): count the distinct alpha levels among partially covered pixels. Under the old implementation a r=25 filled circle had only 8 levels across the whole ring, a lw=2 ring 9, an r=6 small circle 2 (values only 16 and 96), and a fractionally-centred circle 12.
  • Root cause: coverage in fill_polys was sample-point counting: 4 sub-rows × 4 x-samples, each sample worth a fixed 16 ⇒ coverage could only be a multiple of 16 (17 levels). Horizontally four samples still yield 0/16/32/48/64, but a sub-row was counted whole or not at all ⇒ near-horizontal edges stepped by 64 (four levels per cell), so circle tops/bottoms and shallow diagonals smeared into stairs. Rounded-rectangle straight edges did not have this problem (fill_round_rect/stroke_round_rect use analytic SDF coverage, continuous) — which is why "the corners are a bit hard but the circles are worse".
  • Fix (gui/render/raster_fill.mbt): ① vertical sub-rows 4 → RASTER_AA_SUB = 8 (each worth 32); ② the horizontal side moved from "count samples" to the exact intersection width — a boundary pixel contributes min(xb, px+1) − max(xa, px) × sub-row weight, making coverage continuous in x. Fully covered interior pixels still take 256 directly, and the s0 = ceil(xa) / s1 = floor(xb) − 1 test also moved from "sample centres" to "the whole column really is enclosed", which removes the old over-estimate for pixels that were only almost covered. Cost: the sub-rows doubled while x dropped its 4-way inner test — the render package's 53 cases run in 1.5s and the whole library in 20.8s, with no perceptible regression.
  • Readings after: r=25 filled circle 22 levels (right-edge profile 28/94/146/185/220/235/248, a smooth ramp), lw=2 ring 37, r=6 small circle 5, fractionally-centred circle 45, a 32° line 7.
  • One trap recorded so nobody else steps on it: an exactly 45° straight stroke legitimately has only about 2 levels (the half-plane x+y = c cuts every pixel at the same relative position; measured 2 for both lw=1 and lw=3) — that is geometry, not an AA defect. The first version of this check asserted "45° must have many levels" and turned the corrected code red. Level assertions must use shallow-angle segments and circles.
  • Verification: gui/render/painter_wbtest.mbt gains the aa_levels helper and 2 assertions ("circle and ring edge grey levels": r=25 ≥16 levels and ≥60 partial cells, ring ≥20 levels, r=6 small circle ≥4; "shallow diagonal continuity": 32° lw=1.5 ≥5 levels) — all red under the old implementation. Same contract on both backends (cairo's analytic coverage sits far above the thresholds). moon test render 53/53, full library 947/948 (the single red is still si_machine's BIOS registry reading — this machine's environment baseline), cd gui && moon check --deny-warn warning-free. Real-machine look: item S29 in _人工测试清单.md.

The radio dot "solid core" assertion always went red on Linux: cairo area sampling and the pure rasterizer read edge cells of small circles differently (2026-10-15, Linux CI + local) ​

  • Environment: Ubuntu 24.04 (X11/XFCE, cairo available so Painter::new picks a cairo session) against the Windows dev machine (cairo cannot start, falling back to the pure rasterizer); the same pixel assertion in ui/form_wbtest runs on different backends.
  • Symptoms: after the 0213284 batch the CI Linux job went red in a row (ui/form_wbtest.mbt "单选标记的环与心之间留得出一条空白" failing with "圆心不是实心的") while the Windows job stayed green; reproduced locally on Linux at 941/942.
  • Root cause: the assertion required all six diameter cells (indices 4..10) of the radio core to exceed 200, written against pure-rasterizer readings — 0213284's fill coverage treats "a pixel is a cell" (signed distance + 0.5-cell compensation), so the two diameter-end cells (centres 2.5 from the circle centre, radius 3) come out fully 255. cairo uses standard anti-aliased area sampling, where the same geometry leaves those end cells at ~196/197 (0.76 coverage), 3-4 short of the 200 threshold. The shape itself is correct on both backends (the alpha-60 shape assertions "ring-gap-core-gap-ring" are all green) — this is a same method surface, different reading on small-size edge cells gap, not an implementation error. The lesson is the same family as 6dff6c1's "assertions hard-coded to one machine's measurements": pixel-level assertions must either take the intersection of both backends' readings or avoid edge cells.
  • Fix: the assertion now uses one contract for both backends — the "solid" assertion tightened from six diameter cells to the four middle cells (5..9) all above 200 (full 255 ink under cairo, also full under the rasterizer), while "the diameter ends have ink" is already covered by the existing alpha-60 shape assertion; a comment spells out both readings and why the ends stay out of the check.
  • Verification: local Linux moon test ui 180/180, full library 14 packages 968/968 with zero red, cd gui && moon check --deny-warn warning-free; the tightened end (the rasterizer) stays covered by Windows CI (5..9 ⊆ the all-full 4..10 readings).

Linux system-level drag-and-drop wired up (W23 platform batch): four signals feed the same session, press-event stash for starting drags, blocking semantics via a nested main loop (2026-10-15, requested by the user) ​

  • Environment: Ubuntu 24.04 (X11/XFCE, GTK3). W23a/b had only landed the Windows half so far and the GTK branch was a failing stub (supported always 0); this batch adds the Linux side with zero changes to the facade, the event pump, the fallback semantics or showcase.

  • Design (the same wiring as the Windows branch): drag-in uses gtk_drag_dest_set on the GtkWindow (its coordinates are already client-area coordinates), with four signals — drag-motion (a two-phase gdk_drag_status answer + the first asynchronous payload fetch), drag-leave (close the session), drag-drop (only arm "drop awaiting payload", letting the GTK_DEST_DEFAULT_DROP default handler request the data), drag-data-received (cache the payload during motion and emit the late ENTER; on drop emit DROP + gtk_drag_finish). Events go into the process-wide queue and the host pumps wm_dragdnd_poll every tick; core/drag.mbt's drag_external_pump is not changed by a line.

  • Pitfall ① (the motion-time payload is asynchronous): under X11/XDND a destination cannot read selection data mid-drag; it must call gtk_drag_get_data, and the data arrives via data-received (GTK then emits another motion). The ENTER event is emitted from data-received — if the pointer stops there will be no further motion, so ENTER cannot wait. A failed fetch (the source offers nothing at all) sets the asked flag and answers "deny" without retrying. The payload lives in one process-wide buffer (only one session at a time, the same rule as core's drag_current singleton).

  • Pitfall ② (starting a drag needs a GdkEvent, and the handling chain is outside the GTK dispatch stack): gtk_drag_begin_with_coordinates requires a button event, yet this stack starts drags on the tick where MoonBit handles the pointer Move — long after leaving GTK signal dispatch, so gtk_get_current_event() returns NULL. The press stashes a gdk_event_copy and releases it on up; with no stash (tests / keyboard-initiated) the call simply fails — Err(Failed) falls back to the in-app session, which is exactly the one-code-three-platforms contract.

  • Pitfall ③ (blocking semantics): Windows' SHDoDragDrop runs its own message loop and blocks until release; GTK's gtk_drag_begin returns immediately, so this batch nests gtk_main_iteration() in C until the drag-end signal (a 60-second g_timeout backstop, so a lost drag-end cannot hang the caller forever), and the effect is read back from gdk_drag_context_get_selected_action at drag-end.

  • Pitfall ④ (two sets of effect bit values): GDK_ACTION_COPY/MOVE/LINK are 2/4/8, unlike the Win32-derived DROPEFFECT_* (1/2/4) the facade follows — converted explicitly; several bits lit resolve to "no deal" (0) rather than a guess, matching core's drag_effect_of_flag accepting only 1/2/4.

  • Why there is no synthetic probe: the Windows drag_probe can synthesize the whole chain in pure C because OLE's COM interfaces accept directly-fed objects; XDND motion is pointer-driven, so a cross-process drag cannot be synthesized. The Linux verification ladder is "compile + real-environment reading tests (win/dragdnd_wbtest: attach/idempotence/count/effect write-back; printed skips when headless) + smoke run + checklist items S26/S27".

  • Two small C-level snags along the way: GPOINTER_TO_ATOM does not exist (GDK spells it GDK_POINTER_TO_ATOM), and gtk_target_list_add_text_targets/add_uri_targets require the info argument (a C compile-time error, easy to catch).

  • Verification: moon test win 29/29 (1 new environment-reading test), full library 14 packages 975/975 with zero red, cd gui && moon check --deny-warn warning-free, showcase links plus a local 8-second smoke run alive; real cross-process drags (Thunar/gedit ↔ the window) belong to the Linux items of S26/S27 in _人工测试清单.md.

Font-size "animation" was not an animation: the interpolation set excluded font size back then because "it means relaying out every frame" — while this stack already relayouts every frame (2026-10-15, real-hardware report) ​

  • Environment: Windows 10 19044 real machine, showcase's "过渡与效果" page, the section formerly labelled "集合外属性" (@ui.text(class=[style(font_size=13.0, transition_ms=300.0).hover(font_size=17.0)])).
  • Symptoms: hovering jumped font size 13 → 17 in one step, with no gradual change (the background colour beside it did transition).
  • Root cause (two layers, both written into the design contract):
    1. gui/style/transition.mbt's lerp_style covered only the three colours + border width + radius; every field outside the set took the target value;
    2. harder: animatable_key's projection contained no font_size ⇒ a state switch that changed only the size was judged at_shown == true → settle → the timer never even started. §1.1's justification at the time was "a property that feeds layout must not interpolate — that equals relaying out the whole tree every frame".
  • Why that justification does not hold in this stack: a paint frame is already App::step → root.relayout(...) → paint (the whole tree relaid out every frame — see the known difference "one frame is still a full-viewport redraw"), so interpolating font size costs no extra layout pass. What was really missing is the other half: measurement read the base block — the measure and baseline callbacks in gui/ui/base.mbt took the font size from @style.resolved_by_id(id), while drawing read the interpolated style_for(w). With the two disagreeing, glyphs were drawn large inside a box measured small, so it still read as "a jump plus clipping".
  • Checked against CSS first (the user's rule: "implement what CSS implements, skip what it does not"): in CSS Transitions both font-size and line-height are animatable <length> values, and browsers indeed relayout each frame — so both join the set. font-family/display are discrete, and opacity/box_shadow take effect off the style-slot read path, so they stay out.
  • Fix: lerp_style interpolates font_size and line_height; animatable_key's projection carries both (otherwise a size-only switch is judged "state unchanged" → settle → the timer never starts, which is the main reason no gradient was visible); added anim_shown_font_size(id) / anim_shown_line_height(id) and resolved_for_measure(id) (the base block plus those two), and both measurement callbacks in ui/base.mbt switched to it. resolved_for_measure builds a copy through merge(st, empty_style()) before overriding — resolved_table holds the same object, so writing the fields in place would corrupt the base block. Once an account settles, shown is the target (state buckets included), so after the hover ends the box still matches the drawing instead of snapping back.
  • Wiring found along the way (without it the growth is simply cut off mid-way): when font size or line height changes the widget rect grows, while the local dirty rect was merged from the previous frame's rect, so the newly exposed ring never gets painted. transition.mbt's grows_layout(a) detects exactly those two and mark_dirty dispatches between "whole-window dirty / local dirty" on it — colour/border-width/radius-only transitions keep the local path (P5's optimisation is not surrendered for them). anim_needs_full_paint(id) exposes that decision to tests.
  • Still outside the set: opacity / box_shadow (they take effect where apply writes Widget fields; the paint walk does not read their slots per frame) and the sizing layout properties. Whether box_shadow deserves animation depends on whether the shadow can become a real drop shadow at all (CSS does animate shadow lists) — see the next section.
  • Contract kept in sync: the showcase section renamed from "集合外属性 / 不参与插值的属性" to "插值范围 / 哪些属性会渐变" with the body rewritten; docs/zh/design.md §1.1's 过渡 row rewritten (font size and line height in the set, measurement reads the same value, those two repaint the whole window, reason changed from "would relayout every frame" to "we relayout every frame anyway"); P5's pending-recheck line gained a pointer to the changed contract so nobody files the old wording as a bug.
  • Verification: gui/style/transition_wbtest.mbt — 1 case updated and 2 added: lerp_style gives 14 at the midpoint for 12→16 and 18 for line height 16→20 (under the old rule both took the target) while out-of-set opacity still takes the target directly; step starts the timer for a font-size-only change with readings 12.625 / 14.0 / 16.0 at 25/50/100 ms (smoothstep), reversing to the original value continues the reverse segment from the current 14.0 instead of an endpoint, and anim_needs_full_paint reports true (false for the colour-only account); a line-height-only change also starts an animation and reports 18.0; resolved_for_measure equals the same frame's style_for for both font size and line height, stays within [13,17] and [20,28], and leaves resolved_by_id's base block unwritten. All of these are red under the old implementation. moon test style ui core 354/354; real-machine look: item S30 in _人工测试清单.md.

The library test popped "You'll need a new app to open this …" on every run: ShellExecuteW does not fail on an unregistered protocol (2026-10-15, reported by the user) ​

  • Environment: Windows 10 19044 dev machine; running cd gui && moon test sys (or the full library suite) repeatedly raised a system dialog.
  • Symptoms: the user reported "when you run the tests it keeps saying 需要使用新应用以打开 libyue-invalid-probe 链接".
  • Root cause: sys/launcher_wbtest.mbt carried a Windows probe open_url("libyue-invalid-probe://no-such-scheme") whose comment claimed it probed via the SpawnFailed semantics of an invalid target "without popping a dialog" — that sentence is wrong. ShellExecuteW goes through the shell's association/protocol dispatch: an unregistered protocol does not return SE_ERR_*, it hands the "which app should open this" picker to the user (equivalent to success), so the test neither got the expected SpawnFailed nor stayed off the desktop — it had a real system side effect on every run, and it also broke the division of labour in rule 6 (real-machine actions belong to the user).
  • Fix: added the pure predicate open_supported() (true on Windows/Linux/macOS, runtime detection, touches no system), and the test now asserts only that plus cases that return before anything is handed to the system (open_file("") → NotFound, open_file_with("", …) → SpawnFailed, reveal_in_file_manager("") → NotFound, or Unsupported on macOS). Real opening stays in the example's buttons (S28, user-verified). open_url' macOS branch was filled in along the way (one open, the same shape as open_file), so open_supported and the dispatch tables of the three entry points now agree.
  • Rule going forward: library tests must not call ShellExecute* / xdg-open / open against a real target. To prove a backend is wired, use a pure predicate; to prove what actually opens, write it into _人工测试清单.md and let a human run it. Before reaching for "invalid target → error variant" again, confirm that the invalidity really surfaces as an error without launching system UI — an unregistered protocol is precisely the case that does not.
  • Verification: moon test sys 186/187 (the single red is still si_machine reading the BIOS registry — this machine's environment baseline), and no dialog appeared on the desktop while running the suite; cd gui && moon check --deny-warn and the examples module warning-free.

The shadow looks harsh because it is a uniform-width ring, not a shadow — rebuilt as layered feathering, then removed outright after the real-machine review (2026-10-15, real-hardware report) ​

  • Environment: Windows 10 19044 real machine, the shadow card on showcase's "过渡与效果" page.
  • Symptoms: the user said the shadow effect is jarring — "it is now a limited patch inside one region, not really floating beneath the element", and offered the way out: if a real float cannot be done, cut it.
  • Root cause (three layers):
    1. Painter::box_shadow drew one stroke (line width = spread, centreline pushed out by spread/2), so the whole ring had a single density with hard edges inside and out ⇒ it looked like a grey frame around the box, not a cast shadow;
    2. the call site in ui/basic.mbt passed dx=dy=0, and Style had only one box_shadow : Double? with nowhere to put an offset, blur or colour ⇒ always a symmetric halo, with no sense of being cast downward;
    3. why multi-layer had been avoided: docs/zh/adaptation.md recorded "hard shadows stacked into dark blobs at the four corners: neither backend has reverse arcs, so the inner/outer ring hole cannot be punched". That version filled an outer ring and punched a reversed inner one; the hole was never clean, so ink piled up at the corners. Layered fills need no hole at all — the body paints on top afterwards and hides the inner over-accumulation, which makes the route viable again (and matches CSS: a body with a transparent background does show its own shadow).
  • Fix: Painter::drop_shadow(x, y, w, h, radius, blur, dx, dy, color) — the Minkowski sum of a rounded rectangle with a disc of radius e is again a rounded rectangle (width and height +2e, radius +e), so the same shape is filled N times from outside to in (roughly a 1 px transition band per layer, capped at 2..8 layers), each with global_alpha = total density / N (source-over accumulation is near-linear): densest against the body's edge, faded to zero at blur, and corner and edge share the same density at equal distance. Painter::box_shadow was deleted outright, with no compatibility shell left.
  • API aligned with CSS: Style gains shadow_x / shadow_y / shadow_color and box_shadow now means the blur radius — the four together are CSS box-shadow: blur x y color (the colour's own alpha is the density; when it is not written, the built-in baseline #00000040 applies). All reads go through @style.shadow_of(s). apply writes expansion = blur + |dx| + |dy| into Widget.shadow_paint, computed from the merged base block (it used to look only at the s passed in this call, so a later apply that only added an offset left the expansion stale and the bottom edge got cut by the clip window).
  • Two corners fixed along the way (a real shadow amplifies both): ① the viewport-culling test in core/widget.mbt used only the bare rect, so the soft ring disappeared the moment the body slid out of view — that test now inflates by shadow_paint too; ② the clip window was expanded by the old "spread" value, which is not enough once an offset exists — same root as ①, both now use blur+|dx|+|dy|.
  • Still to be assessed: the layering is an approximation (N steps; at blur ≥ 8 the staircase is essentially invisible) and will be replaced once the render layer gains a real gaussian blur; box_shadow also stays outside the interpolation set (it takes effect where apply writes Widget fields and the paint walk does not read those slots per frame), so a "hover shadow grows" transition first needs those three moved into the slot-read path — a later batch.
  • Measured readings (200×160 canvas, body 40,40,120×80, radius 8, blur 8, dy 6, #00000040): left edge from inside out 57 57 57 51 44 37 30 23 16 8 0 (monotone, ten levels); below at y=124/128/132/136 = 57/44/16/0 versus above at y=48/40/36 = 57/23/0 ⇒ the bottom casts the extra 6 px of offset; at equal distance the corner reads 34 against the edge's 37 (the old version either missed a patch there or piled into a dark blob); the body centre reads 57 (inner accumulation, covered by the body afterwards).
  • Verification: render/painter_wbtest.mbt replaces the old box_shadow case and adds 2 (direction + gradient + corner/edge equality; and four "no shadow" inputs that must leave no ink); ui/basic_wbtest.mbt adds a sixth assertion (a 24-high spacer pushes the card down to y=24, then ink cells are counted in the bands above and below the box — with shadow_y the lower band grows and the upper shrinks). moon test render 54/54, moon test ui 180/180, full library 976/977 (the single red is still si_machine's BIOS registry reading), cd gui && moon check --deny-warn and the examples module error- and warning-free, moon build showcase --target native links, local smoke alive 4 s then terminated by its Windows pid. Real-machine look: item S31 in _人工测试清单.md.

Four real-hardware findings (click-to-screen latency / keyboard stuck at 65507 / space not activating / radio circle clipped): GTK frame dispatch must run on the spot, the IM needs a focus gate, click ripples need a whole-window fallback, and marker row height must not collapse with the font (2026-10-15, Linux real-hardware report) ​

  • Environment: Ubuntu 24.04 (X11/XFCE, an ibus input-method session), showcase on the user's machine. Four reports: ① in the form section's stepper, the number did not move while clicking and jumped ~1s after the hand left; ② the keyboard-test section's echo stayed at 65507 (= X11's Control_L) no matter which key was pressed; ③ space did not toggle radio/checkbox; ④ the radio circle rendered high and was clipped. Toasts showing only the first one shares root ①.

  • Root cause ① (GTK's draw signal needs a main-loop iteration to dispatch): wm_present only called gtk_widget_queue_draw after storing the frame, and this stack's pump wm_wait runs wm_pump() only when an fd is ready — on ticks where a timer expires or the poll times out, ready=0 means no iteration, so the frame lay in GDK's queue waiting for the next X event. With no follow-up event after a click, the blit stalled until the next event source (measured ~1s). Fix: wm_present now dispatches one non-blocking round on the spot after queue_draw (while gtk_events_pending() gtk_main_iteration_do(FALSE), capped at 8 against accidental re-entrancy; button/key events re-entered during the iteration still enqueue into this window's queue as usual and are drained next tick). The second toast not appearing has the same root: the frame painted by push's invalidate also lay in the queue.

  • Root causes ②③ (ibus eats character keys into the commit channel): wm_on_key called gtk_im_context_filter_keypress unconditionally — in a session with ibus the IM consumes every character key (reported through the commit channel, and commits whose focus is not an input box are dropped), so the key-event channel kept only the modifier keys the IM does not eat (65507 = Control_L). Space is a character key, so radio/checkbox KS_SPACE activation naturally never received it. Fix: an IM focus gate — C-side wm_im_set_active(id, on) plus WmWindow.im_active, filtering only when the MoonBit-side focused control is an input box (im_context_of has a value); App::pump_ime syncs it at focus changes. The Windows branch's IMM does not filter ordinary keys, so it gets a link-only stub.

  • Root cause ④ (marker row height collapses with font metrics): the radio/checkbox row height is decided by the text line box; the test host (Noto) has a 19 line height, which fits a 14 px marker, but the real-machine XFCE default font's line box can be shorter than 14 px — the marker then breaks out of the row and the circle's top gets covered by what is above it (diagnostic reading: on the test host both dot groups are perfectly symmetric at y=2.5/h=14, ruling out a layout-structure problem). Fix: the radio/checkbox rows set_min_height(fixed(20.0)), so marker height no longer depends on font metrics (the switch's pill is a fixed 20 px and never collapsed — unchanged).

  • Along the way (a core-layer click-ripple defect, all platforms): after pointer events the host only marked the hover/capture chain locally dirty, yet the Store changed by on_click usually affects siblings outside that chain (the stepper's number: the display control does not subscribe and reads live at paint time), so the dirty region does not cover it and the blit keeps stale pixels. Fix: pointer Up (a settled click) always falls back to whole-window dirty — clicks are low-frequency interaction, a full frame is acceptable; Move/Down keep their local dirtying.

  • Verification: full library 14 packages 975/975 with zero red, cd gui && moon check --deny-warn warning-free, examples --deny-warn warning-free, showcase links plus a local 8-second smoke run alive; real-hardware re-check belongs to S30 in _人工测试清单.md.

  • One item waiting for a real-hardware reading (untouched this batch): "the sidebar menu can receive focus through Tab" — headless diagnostics show the sidebar menu_sections tree's focus_ids() is empty (menu items carry no focus_id and were never in the Tab chain), so there is no component-layer defect to fix; when re-verifying, please screenshot which element the focus ring lands on after pressing Tab, and the next pass locates from that reading.

  • Follow-up (same day, removed on the user's call): the layered version measured correctly on its own (ten monotone steps, corner equal to edge, cast downward by the offset), yet the real-machine look still did not satisfy — after re-checking, the user said "阴影悬浮还是去掉吧". So the whole thing was removed: the four Style fields (box_shadow/shadow_x/shadow_y/shadow_color) with their setters, shadow_of, Painter::drop_shadow, and Widget.shadow_paint (including the clip-window and viewport-culling inflation) were all deleted, and showcase's two shadow cards plus the docs/zh/design.md §1.1 "效果" row and the P4 row were re-worded in step; no "accepted but does nothing" field was left behind — that would amount to pretending the capability exists. Two lessons: ① self-consistent numbers are not a pass on looks — for visual work in this project the real-machine review is the only signature, and readings can only help localize; ② if a drop shadow returns, solve a real gaussian blur first (a layered approximation carries a staircase naturally at 1× physical pixels with no HiDPI upscaling) rather than stacking more API on the approximation.

Browser DOM capture came back all ok=false on both platforms: JS evaluation replies truncated to half a JSON by the 768-byte event text slot (2026-10-15, Linux + Windows real machines at once) ​

  • Environment: examples/browser on both platforms; all four buttons ("抓正文/抓链接/抓 meta/抓结构") echoed ok=false.
  • Root cause: capture_dom's reply carries JSON.stringify({title,url,…}) (body text up to 4000 characters, 200 links, 100 metas), while JS evaluation replies went through the event channel — the event text slot is WB_EV_TEXT=768 and wb_push hard-truncates with strncpy, so any reply past 768 bytes becomes half a JSON; MoonBit's vsc_json_parse then necessarily returns None ⇒ ok=false with the leftover fragment as raw. The two platform stubs are mirror implementations sharing one constant, so Linux (WebKitGTK) and Windows (WebView2) died together — the "replies ride the event slot" design could never hold the DOM tier's volume from the day it was written, and it stayed hidden in the showcase era because those demo replies happened to be short.
  • Fix: JS evaluation replies now go through a per-view dedicated buffer (cap WB_EVAL_TEXT_MAX=64KB, the same scheme as the drag payload buffer) — the C-side completion callbacks (GTK evaluate_javascript_finish / WebView2 ExecuteScriptCompleted) store the full text in WbView.eval_text, the kind=7 event carries only the sequence number with empty text; a new extern wb_eval_text(id,out,cap) fetches the full text (returning the total length; >cap means you read the truncated prefix); MoonBit's pump reads the big buffer for kind=7 (WB_EVAL_CAP=65536) while the other events keep the 768 slot (short text like titles/URLs fits there). The mac stub gained an empty implementation in step.
  • Verification: moon test web 18/18 (1 new long-reply regression: a complete JSON with 4000 characters of body text and 200 links parses whole, and it is red under any truncating path), full library 14 packages 978/978 zero red, cd gui && moon check --deny-warn warning-free, examples --deny-warn warning-free, browser links plus a local 8-second smoke run alive; both platforms' real-machine re-verification belongs to S32 in _人工测试清单.md.

A third layer on curve pixelation: the fully-covered run was written as cov[px] = 256 inside the band loop, so the vertical direction never accumulated (2026-10-15, real-hardware report) ​

  • Environment: Windows 10 19044 real machine. After the previous batch replaced "count 4×4 samples" with "8 sub-rows + exact horizontal width", the user re-checked and still found the curves less smooth than on Linux.
  • Why that batch did not cure it: it only verified the right-edge profile (a vertical boundary, where exact horizontal width pays off immediately). But inside the band loop the fully-covered run was written cov[px] = 256 — an assignment: as soon as one band enclosed the column, that row was recorded as full coverage, so the vertical direction never really accumulated. Near-horizontal boundaries (a circle's top and bottom) and the corner regions around vertices therefore stayed all-or-nothing, producing profiles like 0 → 249 → 255.
  • Fix (gui/render/raster_fill.mbt): ① coverage now accumulates += area, first into a Double-side buffer (cov_f) advanced slice by slice and quantized once at row end (rounding per segment would accumulate ±2); ② "band-centre sampling" became analytic integration per slice: each row gathers the edges crossing it into a row-local active table, then cuts the row at "band boundaries ∪ every active edge's endpoints clipped to the row". A slice contains no vertex, so both span boundaries are linear in y and their overlap with a pixel column has a closed form (span_slice_cover: the integrand is piecewise linear, with breakpoints only where a left or right boundary hits px/px+1 or where the two meet, so the trapezoid formula is exact). The band count is thereby demoted from "source of the quantization staircase" to "bounds the intra-slice reordering error of two edges", and 8 sub-rows went back to 4 bands with no cost increase.
  • A real bug fixed on the way: a degenerate span where the left and right ends coincide at the slice's top or bottom was skipped wholesale by if ra0 <= la0 || ra1 <= la1 { continue }, yet that slice really is a triangle — so ink disappeared around triangle apices, measured 33-44 levels off against an independent reference. Removing the skip and letting the integrator handle it (it already carries the la=ra breakpoint) is correct. That judgement is worth writing down: measure it against an independent reference — using your own implementation's output as the expectation is exactly how the previous batch stayed "self-consistently wrong".
  • How accuracy is proven: a reference that shares no code with the implementation (32×32 grid sampling + ray casting for polygons inside/outside / a per-point radius test for circles) measures the maximum deviation — 1-2 levels for polygons (old: 33-44), 7-9 for circles, of which about 8 levels come from the bezier flattening tolerance of 0.1 device pixels itself (the same as cairo's default), not from the coverage algorithm. Both became permanent regressions: worst_cover_gap (4 shapes ≤4 levels, including a flat triangle with a long near-horizontal edge) and ref_circle_cover (r=25 circle ≤12 levels). All red under the old implementation.
  • Readings before/after: r=25 circle partial cells 100→180, grey levels 22 (integer centre) / 77 (fractional centre); r=6 small circle 24→44 cells; r=3.5 (radio core) 28 partial cells. The render package went 1.5s→2.0s (including the two new reference comparisons) while the whole library stayed 20.4s (from 20.8s).
  • Verification: moon test render 54/54, full library 976/977 (the single red is still si_machine's BIOS registry reading — this machine's environment baseline), cd gui && moon check --deny-warn warning-free, moon build showcase --target native links, local smoke alive 4 s. Real-machine re-check still belongs to item S29 in _人工测试清单.md (look especially at circle tops/bottoms and shallow diagonals).

Toasts eaten by the second click + the sidebar keyboard gap: the layer's outside-click policy must be declared per overlay, and pass-through belongs at the hit-test layer (2026-10-15, Linux real-hardware report) ​

  • Environment: showcase on device. Reports: ① with a toast on screen, clicking the trigger button a second time "ate" the first toast; waiting for the toast to close made the next trigger work, and so did opening and closing a dialog in between. ② The sidebar menu was unreachable by Tab and had no focus ring.
  • Root cause ① (exactly the "separate batch" 505f3ba left open): the overlay layer is full-window and hit-testing runs back to front — every click hits the layer first; the layer's on_pointer consumed any "outside all overlays" press unconditionally and ran overlay_hide_top() on release. Toasts have no "dismiss on outside click" semantics yet followed the same rule: clicking a button during the two seconds a toast is up delivered nothing to the button (no second toast) while that same click wrongly dismissed the first — read together, exactly "the second click eats the first". Once the toast expires (or a dialog round trip gives the expiry callback time to reset open_id) the layer hides and clicks reach content again, hence "fine after a while / after a dialog".
  • Fix ① (click policy + hit-level pass-through): OverlayHost.items grew from (id, w) to OverlayItem{id, w, dismiss} — overlay_show_at takes dismiss_on_outside (default true, so menu/dialog modality is unchanged) and toast_layer passes false. Merely returning false from the layer's on_pointer is not enough: the hit chain excludes page controls before the layer is ever asked (full-window, appended last), so pass-through is done at the hit level — when the hit lands on the layer itself (inside no child overlay) and the topmost item declares pass-through, route_pointer temporarily sets the layer visible=false, re-hit-tests once and restores it, so the click/hover go back to the page control (layout untouched, rects kept). Recorded trade-off: stacked overlays consult the topmost one only — a toast sitting over a dialog lets outside clicks fall through to the page without closing the dialog.
  • Root cause ②: the a1b2c89 batch wired the form family into the Tab chain but left menu_sections' items out — focus_ids() was empty (proved by the previous batch's headless diagnostic), so Tab had nowhere to land and no focus ring could be drawn.
  • Fix ②: menu_item gained focusable/step parameters (the same keyboard contract as segmented): a declared focus_id enters the Tab chain, space/Enter selects the item, ↑/←/↓/→ cycle the selection inside the group, and on_draw traces a theme-coloured ring at the end when focused; menu_sections wires all its items.
  • Verification: moon test ui 182/182 (2 new: outside clicks pass through a toast layer without dismissing it + the control layer still consumes and dismisses, and the menu keyboard chain is reachable with space/arrows effective — wrapping from the first UP to the last item is the correct shape of wrap semantics), full library 14 packages 978/978 zero red, cd gui && moon check --deny-warn warning-free, showcase links plus an 8-second smoke run alive; real-machine re-check belongs to S33 in _人工测试清单.md.

Renderer coverage-precision assertions went permanently red on CI Linux: single-backend readings hard-coded, again (2026-10-15, Linux CI) ​

  • Environment: the "deviation against an independent analytic reference" assertions added by 610bf7f (the third-layer rasterizer coverage fix) measured 1-2 levels for polygons and 7-9 for the circle on Windows (pure rasterizer), and the thresholds were written as ≤4/≤12 from those readings; the CI Linux job runs the cairo backend — the triangle measured 18, the flat triangle 16, the circle 21, all over threshold, red in a row.
  • Root cause: cairo's own AA algorithm differs from the "32×32 grid sampling + ray casting" reference more than the rasterizer does on diagonal edges (16-21 levels) by nature; the thresholds left headroom only for raster readings, so cairo cannot pass — the same family as the radio-core assertion (cairo area sampling vs raster pixel-cell readings), the fourth time this repo steps on the same shape.
  • Fix: the thresholds now take the intersection of both backends — polygons ≤20 (cairo's 16-18 passes, the old implementation's 33-44 stays red), circle ≤24 (cairo's 21 passes, old 33-44 stays red), keeping the regression's teeth; the assertion messages carry the measured level count so the next red shows the magnitude at a glance, and the comments record where each side's readings come from.
  • Verification: local Linux moon test render 54/54; the Windows side is re-verified by CI (the raster readings 1-2/7-9 sit far below the new thresholds).
  • Rule (recorded once more): pixel- or number-level assertions must first ask "what is the other backend's reading" and take the intersection. This repo's assertion failures now run four-of-a-kind: the radio dot core, the launcher round trip, the dragdnd initial effect value, and this coverage precision — all the same shape.
  • Environment: GitHub Actions macos-latest (Apple Silicon; clang and the SDK frameworks come with Xcode CLT). The development machine here is Ubuntu/X11 and cannot touch the macOS window server, so "compiles and links" is proven by CI, while "runs and looks right" is on-device work per AGENTS rule 6 (checklist: section T of _人工测试清单.md).
  • Branch shape (same "one stub.c, several branches, prebuild macro" rule as Linux/Windows): WM_STUB_MAC in gui/win/stub.c (Cocoa, Objective-C), WM_STUB_MAC in gui/render/stub.c (ImageIO, plain C), WM_STUB_MAC in gui/text/stub.c (CoreText, plain C), WB_HAVE_WKWEBVIEW in gui/web/webkit_stub.c (WebKit, Objective-C). Every placeholder branch gains a && !defined(__APPLE__) guard: on macOS the macro-less native-stub pass must compile to an empty translation unit, otherwise it double-defines the prebuild archive. The Darwin branch of prebuild compiles the archives with cc -x objective-c -fobjc-arc (link_configs has no compile-time fields - c_flags is silently ignored, same finding as GTK/Pango) and hands out -framework flags per package.
  • Pitfalls CI threw at us on the first round (worth writing down so they don't recur):
    1. CoreText puts the text object first - CTLineDraw(line, ctx) - opposite to the CGContext family intuition;
    2. CFAttributedStringReplaceString / CFAttributedStringSetAttribute take a CFRange, not location + length, and the length comes from CFAttributedStringGetLength (CFStringGetLength does not compile: different type name);
    3. non-mac one: kill in the POSIX arm of sys_stub.c was reached through some other header on Linux, while macOS clang treats an implicit function declaration as an error - explicit #include <signal.h>;
    4. Cocoa detail: ARC-managed properties can only be read with . (s.win), while the @public plain fields of the same struct use -> - two notations in one function is a real trap; setAllowsMultipleSelection: / setCanChooseDirectories: are declared on NSOpenPanel, not NSSavePanel (a save panel is single-choice by nature);
    5. two "warning level but genuinely broken" ones: NSTextInputClient's marked-text method is the three-part setMarkedText:selectedRange:replacementRange: (writing it with two parts earns only a -Wprotocol warning, and the input-method composition channel never connects at runtime), and resignFirstResponder returns BOOL (declaring it void is -Wmismatched-return-types). The macOS stub gate therefore reads warnings too, not just errors.
  • Pitfalls, rounds two and three - "no longer declared in the current SDK" and "wrong name":
    1. setCanShowHiddenFiles: (NSOpenPanel), setSendActionOn: (the status-item button) and setApplicationNameForUserAgent: (WKPreferences) are not publicly declared on the macOS 26 SDK (clang reports no visible @interface / no known instance method), while they do exist on older SDKs. One shape covers all three: an informal declaration on NSObject keeps the selector visible to the compiler, and every call site asks respondsToSelector: first - if the runtime has no such method we fall back to the default behaviour (the show-hidden-files switch, tray right-clicks and the UA suffix stop working) and we do not guess at replacement names.
    2. WKWebsiteDataStore has no dataStoreWithURLStoragePath:error: (that name was inferred from the Linux WEBKIT_DATA_DIR semantics and does not compile). The public one is +[WKWebsiteDataStore dataStoreForIdentifier:] - macOS 14+, takes an NSUUID, and needs a bundle identifier (libyue uses exactly this at nativeui/mac/browser_mac.mm:86). A directory path cannot be fed to it, so data_dir is hashed into a stable UUID (two FNV-1a 64-bit chains, then RFC 4122 version/variant bits - NSUUID only accepts well-formed strings): the same directory keeps one session across runs. Where that cannot be honoured we still fall back to nonPersistentDataStore and say so through wb_persistence().
    3. The cookie-store property of WKWebsiteDataStore is named httpCookieStore (.cookieStore does not compile; clang's fix-it supplies the right name).
    4. An Objective-C parameter must not be named id: wb_evaluate(int64_t id, ...) shadows the type name, so the block signature ^(id result, NSError *error) parses as a variable declaration and one parameter name produces a cascade of type-id cannot have a name plus a dozen undeclared identifier errors.
  • Display enumeration (mac platform batch 8, sys/monitor.mbt plus the __APPLE__ arm of sys_stub.c): CoreGraphics covers it in one family - CGGetOnlineDisplayList ("online" only, i.e. attached and powered; macOS has no public list of attached-but-off displays, so the mac side never yields connected=false entries), CGDisplayBounds for the origin in points, the scale derived as "current mode pixel width / logical width" to turn origins and sizes into physical pixels, CGDisplayCopyAllDisplayModes for the mode table (deduplicated by pixel width x height x refresh - CG lists several timing configurations for the same pixel size and duplicates mean nothing to layout; when the table is unavailable the current mode is emitted alone), CGDisplayScreenSize for millimetres. The two mode predicates are no longer declared in the current SDK (CGDisplayModeIsCurrentMode / CGDisplayModeIsPreferred - clang says call to undeclared function; only these two were pulled from this family, CGDisplayCopyAllDisplayModes / GetPixelWidth / GetRefreshRate are still there): "current" is now decided by "same pixel size and same refresh rate as CGDisplayCopyDisplayMode", which appears exactly once in the deduplicated list and is equivalent at reading level; preferred has no public signal, so it is reported as 0 rather than faked with the current mode. Three things it cannot give, stated plainly: a name (NSScreen.localizedName is out of reach for a plain-C stub and CG has no public name - built-in panels are Built-in, others Display-<CGDirectDisplayID>, machine-specific strings like the other platforms' output names), millimetres (0 when the engine withholds them), and built-in refresh rates (CGDisplayModeGetRefreshRate reports 0 = unknown for some built-in modes; 0 is passed through rather than a faked 60 Hz). MonitorInfo needs no scale field: every number in the list is already physical pixels, the same basis as xrandr.
  • Deliberate differences from the other two platforms, now part of the contract:
    • Event loop: no [NSApp run]. wm_wait uses nextEventMatchingMask:untilDate: (AppKit's own blocking fetch), wm_wakeup posts one NSApplicationDefined marker event to wake it - the macOS equivalent of "the loop belongs to MoonBit" + "no C-to-MoonBit callbacks". Known limit: during window drag / live resize AppKit enters tracking mode and our pump does not see those events (T6b).
    • Pixel scale: the stack speaks physical pixels, Cocoa speaks points; one conversion per boundary using the window's screen backingScaleFactor. The drawing view deliberately does not override isFlipped - in a non-flipped view CGContextDrawImage/CTLineDraw land memory row 0 at the top, which matches this stack's Bitmap; flipping the CTM would render text upside down.
    • Premultiplication rounding belongs to ImageIO/Quartz: Quartz composites only in premultiplied space, so an RGB bitmap context cannot give us a non-premultiplied form and the (c*a+127)/255 step other platforms take is skipped here. Byte order and semantics are unchanged; the difference is +/-1 rounding, and the "encode -> decode -> re-encode is byte-stable" criterion must be read as "stable against ImageIO itself" on macOS.
    • GIF animation falls back to a still frame on macOS: per-frame compositing needs the frame rectangle plus disposal method, and disposal is not on Apple's public GIF property list - without on-device proof we do not guess. img_anim_decode_file returns 0 and the component shows frame 1.
    • Menu / tray / notification are app-level: wm_menu_apply becomes setMainMenu: (the menu bar belongs to the application, last apply wins), tray is NSStatusItem, notification is NSUserNotification - whether a non-bundle binary can actually present a notification is an on-device question (T4e).
    • Taskbar progress: unsupported on macOS - the dock badge is a text counter; it cannot express progress, and we do not fake it.
    • webview: wb_signal_mask() returns 10 on mac - downloads (need macOS 15 WKDownload) and permission requests have no public hook, so we report exactly which proofs are missing; wb_persistence() probes "macOS 14+ and a bundle identifier", a bare binary returns 0 and falls back to a non-persistent store.
  • Backend choices across sys: public CLI whenever it suffices - open / open -R (reveal with selection), sw_vers / ioreg / sysctl (system info), osascript for volume settings and the System Events desktop picture, LaunchAgents plist + launchctl load/unload (autostart), lpstat / lp (macOS ships CUPS - same line as Linux, a declared choice, not a fall-through to the Linux branch). Only kernel-ish readings go into the stub: hw.memsize + host_statistics64, kern.boottime, CGEventSourceSecondsSinceLastEventType for idle. Four families stay explicitly unsupported behind a platform gate: windowctl (Accessibility requires user consent), nightlight (CoreBrightness is private), powerprofile (pmset does not match the three-scheme semantics), media (no MPRIS counterpart).
  • The first real moon test run on the macOS job: 4 red out of 851, all "declared contract not followed through in code", not broken plumbing:
    1. The animation fallback kept the picture but lost the size: when Animation::new_from_file returns None, ui/gif.mbt set no width/height, so the node occupied 0x0 - the image itself decodes fine (ui_decode_guard() passes on mac; ImageIO gives the 120x120 first GIF frame), it just could not be laid out. The None branch now follows the still image's size, and both animation tests (render/image_wbtest, ui/basic_wbtest) became probe-branching: no animation support means we assert "the still path yields the first frame + the component queues no self-advance + it paints ink", while platforms that do decode animation keep the full frame-advance assertions (no coverage lost on Linux/Windows).
    2. The underline assertion counted rows: text_decoration was checked as "one more inked row", but on macOS the 1px line falls inside the last glyph row's line box, so the row count stays equal and the test goes red. It now compares inked pixel counts, which is what "was an underline drawn" actually means - one criterion for all three platforms.
    3. The launcher platform gate had gone stale: the test still asserted "anything that is not Linux gives Unsupported", while batch 5 gave macOS a real open-based implementation. Mac now shares the Windows shape: probe with an invalid target and only require "no longer Unsupported" (Ok or SpawnFailed), which opens neither a browser nor a Finder window.
  • App discovery (batch 13): two things the public surface cannot give. An .app bundle's Contents/Info.plist may be a binary plist (common for third-party apps), which plain-text reading gets no keys from - the entry then falls back to the bundle directory name rather than the application vanishing from the list. Localized names live in .lproj/<lang>.lproj/InfoPlist.strings, while the public plist keys only give CFBundleDisplayName / CFBundleName, so locale_name equals name and categories stays empty (macOS has no counterpart to desktop Categories). One difference for the executable lookup: a process started from the Dock/Finder has no shell PATH, so /opt/homebrew/bin and /opt/homebrew/sbin must be appended from the fixed list, otherwise every Homebrew tool on Apple Silicon is reported missing.
  • ld: warning: ignoring duplicate libraries: '-lyue_win_stub_mac' is expected noise - do not "fix" it: the macOS link log prints one to three such lines per test executable, because the same archive appears twice in one link line. Packages reach each other through direct extern links (web needs wm_surface_container from the win stub, sys needs wm_file_*), which are not MoonBit import edges, so the win archive has to be in the link_flags of win, sys and web each; a final exe that links those packages together duplicates it. ld64 simply ignores the later copy - the link result and its meaning are unchanged - and GNU ld tolerates the same thing on Linux without saying so. Removing the warning would mean turning those externs into package-level imports (wrapping the native artifact behind a facade layer), which costs far more than one warning line.
  • Media line on macOS (batch 17): neither stub needed changes - ffmpeg_stub.c has always been platform-free (ffmpeg's API is cross-platform) and audio_stub.c embeds miniaudio, a vendored single-header implementation library that picks CoreAudio under __APPLE__ on its own. Only the two prebuild scripts needed a Darwin branch: ffmpeg-mbt probes with pkg-config, the same route as Linux (missing → non-zero exit with a brew install ffmpeg hint; the difference is that brew's .pc files and headers are not on the default search path, so /opt/homebrew/lib/pkgconfig and /usr/local/lib/pkgconfig are spliced into PKG_CONFIG_PATH, and includedir/libdir are asked from pkg-config rather than hardcoded, so one script covers Apple Silicon and Intel); gui-media hands out -framework CoreAudio -framework AudioToolbox -framework Foundation and must not copy the Linux library list: -ldl lives inside libSystem on macOS and -latomic has no counterpart at all, so copying that list fails the link with "library not found". From this batch the macOS job runs brew install ffmpeg and puts the two example mains that depend on the media packages (systemprobe / native-web) back into the build step - which compiles the ffmpeg and miniaudio stubs on a real macOS machine and actually links that framework set.
  • How this is verified: the CI macOS job starts with a native stub compile gate - the same clang flags prebuild uses (-fsyntax-only -O2 -x objective-c -fobjc-arc -D<macro>) over the four mac stubs plus the plain-C sys_stub.c. That step is not optional: moon check never compiles those stubs, so an API-shape error only surfaces at moon test link time, and there it shows up as a pile of Undefined symbols (the archive failed to build, so its link entry is never handed out) with no trace of the real cause. Then: moon check + 12-package moon test + the library build by explicit package names + examples/hello link, and a final moon run examples/hello that must survive to the timeout (the human checklist in section T covers what CI cannot).
  • What CI has now proven (2026-10-15, run 38616056855 green on all three platforms): all five mac stubs compile against the current SDK; moon check is warning-free; 12-package moon test passes - including the readout loops that only run on a real macOS machine (appfind scanning /Applications and getting applications, appfind_executable("sh") hitting one; the sys family's host_statistics64 / kern.boottime / idle readings; render's ImageIO decode and text's CoreText measurement), plus hello creating a window and pumping its loop on the runner's logged-in session until the 20s timeout.
  • Still needs eyes on real hardware (what CI cannot give): the T1-T6 look-and-feel items (Retina appearance, inline input-method composition, loop behaviour while dragging a window, whether a notification presents from a non-bundle process), display enumeration across several external monitors, whether launchd actually picks up a freshly written plist, and osascript automation consent.

macOS alignment batch (2026-10-18, closing the 17-function gap recorded by merge 717d246 on a real Mac; first full-workspace moon test 1236/1236 on macOS hardware) ​

  • Window surface filled in (hide/is_hidden/opacity/layer/transparent_bg/im_set_active/hotkey_*):

    1. wm_show must clear the hidden flag - Cocoa's orderOut: does not lose the frame, so no position restore is needed (deliberately unlike the GTK rule "restore saved geometry before show"), but the W23 "hidden-resident windows stay out of wm_wait's enumeration" semantics rest on our own hidden bookkeeping, and orderFront: without clearing it turns hide_wbtest's "is_hidden==0 after recall" red.
    2. The IM focus gate im_active shares GTK's semantics and default (first window defaults to 1; after im_set_active(0) keys no longer pass through NSTextInputContext).
  • System drag & drop on mac (the dragdnd eight):

    1. The assertions encoded one side's semantics again: dragdnd_wbtest checks Win32 semantics (count = number of registered windows; effect of an unregistered window returns -1), while the GTK branch gives a constant 1. Mac implements the Win32 line (wm_dnd_registered[] bookkeeping), and the comment records "GTK differs" - when two semantics coexist across three platforms, a new platform follows the one the tests check.
    2. Drag-out needs the live mouse-down event: beginDraggingSessionWithItems:event:source: requires the actual NSEvent from this mouseDown: and will not accept a fabricated one - mouseDown: archives down_ev, send consumes and clears it (same contract as GTK's "press archived for drag start").
    3. Blocking via a nested pump: after starting the session, a small loop of nextEventMatchingMask:NSEventMaskAny untilDate:+4ms inMode:NSDefaultRunLoopMode + sendEvent: runs until draggingSession:endedAtPoint:operation: marks done or the 60s timeout hits - cancelDragOperation: is deprecated and no longer honored, so a timeout simply returns 3.
    4. NSDragOperation must be mapped explicitly to the facade's effect bits: Copy=1, Move=16, Link=512, and a multi-bit result counts as "no deal" returning 0 - the native enum values share no numbering with the wire flags (0/1/2/4), so nothing can be passed through.
    5. The payload wire is one contract across three platforms: [kind:i32 LE][body], kind 1 = text, kind 4 = '\n'-joined absolute paths; on drop, file URLs come from readObjectsForClasses: with NSPasteboardURLReadingFileURLsOnlyKey, falling back to stringForType:NSPasteboardTypeString.
  • webview module mac link filled in: webkit_stub.c's placeholder branch is gated with !defined(__APPLE__), so on mac the native-stub compile yields an empty translation unit - modules/gui-webview/prebuild.py therefore needs its own Darwin branch (archive built with -DWB_HAVE_WKWEBVIEW -x objective-c -fobjc-arc, linked against WebKit/Cocoa plus the main repo's libyue_win_stub_mac.a: webview needs wm_surface_container from the win stub via a direct extern link with no MoonBit import edge, so a missing archive shows up as a pile of _wb_* Undefined symbols). The failure shape of a module prebuild is link-time symbol explosions, not one compile-time error - when debugging, first confirm the archive was actually produced. One more finding from the same batch: a branch frozen at an older wire shape. Master moved kind=7 JS-eval results into a dedicated 65536-byte buffer (wb_eval_text) leaving the event text empty; the mac arm predated that and _wb_eval_text link errors exposed it. An alignment batch must re-check changed wire shapes of existing functions per branch, not just add new functions.

  • sysmonitor mac stub fixes:

    1. mach_task_self_ is a data symbol; _mach_task_self is the function. Declaring the underscore-trailing one as an extern function compiles and links, and calling it jumps into a data page - SIGBUS (signal 10). Located by resolving frames in the crash report .ips; a fifty-second repro that only calls vm_deallocate beat attaching lldb to the full suite.
    2. proc_pidinfo(pid 1) returning 0 is not an error under SIP: BSDINFO/TASKINFO give 0 bytes for launchd (denied without root) while proc_pidpath(1) works - so launchd vanished from the process list. Fix shape: when stat is unreadable, synthesize the comm from the path's basename and report it running, keeping pid 1 in the list ("0 bytes" and "failure" must be handled apart).
  • mac clipboard switched to the NSPasteboard main path (pbcopy silently no-ops on non-ASCII): printf ... | pbcopy round-trips pure ASCII fine, but writing text with Chinese/newlines leaves the pasteboard cleared-or-unwritten while rc stays 0 (clipboard info even looks healthy - a pure no-op). The probe string "yue-clip ... first line\nsecond line" turned the roundtrip test into exactly that None; the same string through NSPasteboard setString:forType: is intact. The fix mirrors Linux's GtkClipboard route: clipboard.mbt's macOS branch prefers the yue_sys_clipboard_gtk_* forwarding layer (sys_stub.c's non-_WIN32 arm -> the win stub's mac-arm NSPasteboard implementation, available always 1), with pbcopy/pbpaste as fallback only. The stale "Windows/macOS get a dumb implementation" comment in sys_stub.c was corrected in the same batch - the dumb branch is _WIN32-only and mac has always gone through the real forwarding.

  • render mac arm: three W35 empty stubs: cr_set_line_cap/join/dash exist to keep the link - mac always paints through the pure raster path (cr_begin returns 0, the Painter never enters a cairo session), so line-cap semantics live in the pure-MoonBit rasterizer: deliberately different in shape from the cairo-backed GTK/Win32 branches, same facade contract.

  • What backs this batch: everything above was run on real macOS hardware (moon test --target native, full workspace 1236/1236) - the first time the library suite ran in full on a Mac after the merge; the CI macOS job keeps its coverage, and eyes-on items still follow section T of _人工测试清单.md.

  • Windows CI link missing wm_hotkey_supported (2026-10-18): after the merge the Windows job died with LNK2019 on the shell whitebox exe - when the three-arm stub was rebuilt, the WIN32 arm dropped exactly the supported entry of the hotkey family (register/unregister/poll were all there), while the GTK/mac/placeholder arms each kept it. Fix shape: return 1 in the WIN32 arm (RegisterHotKey is always present). Rule: after changing the arm structure of the single stub.c, cross-check the extern name list from ffi.mbt against the target arm's definitions one by one - grep -oE '= "wm_[a-z_]+"' | sort -u piped through comm -23 with the arm's definitions finds everything in one step, instead of letting CI explode one symbol at a time. (wm_win_gui_marker is a legitimate exception such a scan should skip: it lives in the package's other native-stub file, win_gui.c.)

  • Four merge wounds that only the CI could surface (2026-10-18): the mac branch had forked from a baseline before webview was extracted into a module (4602886), so merging it into master reintroduced four stale shapes that a green local suite cannot catch - only the three CI platforms running once each expose them one by one:

    1. A dead Windows call in the main prebuild: the mac arm brought if system=="Windows": ensure_release_files() back into link_configs(), but that function (download the WebView2 headers/Loader) was already moved into the module's own prebuild at extraction time - the Windows job died on NameError before ever reaching the link. How to find it: the CI traceback points at the call line, and git show <merge>^2:<file> | grep def confirms the definition only lives on the mac parent side. After a divergent merge, re-check every moved file for "all call sites resolve across the repo" (on the Python side, ast plus one dry run of the output is enough).
    2. A stale path in the macOS stub gate: the ci.yml mac gate still read web/webkit_stub.c (its pre-modularization home), so clang reported "no such file" and the five-stub gate went red. Repointed at ../modules/gui-webview/webview/webkit_stub.c (working-directory is gui) - when a batch moves a package, gate scripts that reference its source path must be updated in the same batch; unlike the duplicate-library warning, this is a hard reference.
    3. A stale package list in the macOS job: the test/build steps still named core (since split into base/shell/widget) and web (now a module); when those were fixed for Linux/Windows the macOS job didn't exist yet, so nobody back-filled it. Aligned to the Linux 15-package list minus dbus (macOS has no session bus) = 13 packages, and gave macOS the "unit tests (gui-webview module)" step it was missing (Linux/Windows both had it) - which brings the module's Darwin link branch into CI coverage.
    4. A stale media-example path in the macOS build step: the third moon build named modules/gui-examples/systemprobe and native-web, but 25192fe folded both probe boards away and replaced the media examples with audio / video in the same module - CI died on "Failed to canonicalize input filter directory" (the path no longer exists), invisible to a green local suite. Repointed at audio/video and re-verified with the identical command on real macOS hardware: the ffmpeg prebuild printed the brew include/lib paths, gui-media dispatched CoreAudio/AudioToolbox/Foundation, and the link finished. Same root across this family of wounds: every "reference list" in CI (package names, stub source paths, example package paths) goes stale whenever a batch renames, moves or deletes something - after a divergent merge, read ci.yml line by line and check each referenced path for existence.
  • One cross-job rule that came out of it: from now on the Linux job is the baseline for the library package list (it is the full set); Windows drops sys/dbus by platform gate and macOS drops dbus. A new package must be added to all three - missing one is the next merge's wound.

  • Once the link gates went green, the readout loops ran for real in CI - three "CI runner ≠ real machine" test shapes fixed (2026-10-18): after the Windows link fix, the module step's bare moon test (whole workspace) exposed the sys readouts for the first time -

    1. /tmp is a Linux/mac landing spot, not a Windows one: appfind's af_tmp() fixture and fsx's list_dir test both hardcoded /tmp, so sys_write_text_file returned false on Windows and five tests went red. Now dispatched at runtime to the platform temp root (Windows: TEMP/TMP, falling back to .; POSIX: TMPDIR, falling back to /tmp) - test fixtures need the platform gate too, same as "a Dock-started process has no shell PATH".
    2. vol_supported() does not mean "readable": on Windows the supported probe only proves COM starts; the missing default audio endpoint only surfaces at vol_get, and CI runners have no audio device. The readout loop became probe-then-branch: a first-read Err skips out loud, and the round-trip assertions only run when a device is present (real hardware) - the same philosophy as the GIF first-frame fallback.
    3. Wall-clock smoke windows must not gamble with runner jitter: the video first-tick window was first widened to "one-and-a-half frames" (0.25) and CI macOS still broke it at 0.251 - decode backlog makes no upper bound promise. The final shape swaps the criterion for a structural fact: "advanced and not past halfway" (t1 in (0, 0.5) for the 1s clip), with the precise ≈0.1 expectation kept only in comments; the "advanced without skipping" meaning stays pinned by the later seek/pause steps as before.
  • One CI-structure fact worth recording: the "unit tests (gui-webview module)" step in all three jobs bare-runs the whole workspace (moon test normalizes upward from the module directory), so that step is really the final full-suite gate per platform - the package lists only constrain the earlier steps, and new tests surface there first.

  • Stuck focus: clicking a non-focusable widget leaves focus behind, and icon_button was never keyboard-reachable (2026-10-18, real-machine feedback "space/enter still operate the directory button"): two gaps stacked. ① icon_button never claimed a focus_id nor wired keyboard activation (form.button has the full set), so clicking it made focusable_at walk the hit chain and find nothing focusable → the Down branch of App::route got None → focus stayed put, still on the sidebar "pick directory…" button, and space/enter kept driving it. ② More fundamentally, clicking a non-focusable owner-drawn area should yield focus (the web's click-blank-to-blur semantics) - the old None branch did nothing, so stuck focus was universal: any "click elsewhere then press space" blindly operated a distant button. Fix: icon_button gets the full keyboard treatment (auto focus id into the Tab chain + space/enter activation + focus ring, same as button/menu_item), and App::route's None branch now calls drop_current() (registry kept, IME session handed back via flush_ime_for_switch). The semantics now have three tiers: click a focusable widget → focus moves to it; click non-focusable owner-drawn area → focus clears (space/enter fall through to the key_fallthrough channel - video's space play/pause is exactly that); click a native child surface → the existing ChildSurface yield. gui 1239/1239, media 37/37.

  • Media components kept decoding the previous video's audio after a swap (2026-10-18, real-machine feedback): three symptoms, one cause - after switching videos the previous track kept playing, pause could not stop the leftover decoding, and clicks needed two tries. The root is structural: the components hide their engine (AudioPlayer/VideoPlayer) inside the build-tree closure, so a swap can only clear_children (the closure is unreachable), while the self-rescheduling tick timer (33ms / ap_tick_ms, infinite set_timeout chain) has neither a stop hook nor a guard - once the widget is unmounted the chain keeps calling engine.pump()/tick(), decoding into the device; the user's pause stops the new engine, the old one is out of reach. The old yue-era stack survived on "swap protocol: old stop → free → remove_child → mount new" plus a closed guard; both layers were lost in the component rewrite. The fix is infrastructure-grade: Widget gains a widget-level discard callback on_discard (the process-level widget_discard_hook only clears style side-tables; component resources are the component's own to stop), fired per widget by report_discard (the recursive sweep behind clear_children/remove_child); media's audio/video components engine.free() and raise a dead flag there so the tick chain terminates (free is idempotent). Same-family wiring while at it: the gif and carousel auto-advance chains already break on a running flag, so on_discard just flips it. Criteria pinned at the widget layer: clear_children/remove_child fire the subtree's callbacks one by one, do not touch tree-mates, and are idempotent (3 tests in discard_wbtest; gui full suite 1239/1239, media 37/37). Lesson: "a component holding process-level resources plus a self-rescheduling timer" must have a matching "removed" event - in a component system without unmount callbacks, every engine/thread/handle-holding component turns into an orphan that keeps running after a tree swap.

  • The mac IM channel swallows navigation keys: with im_active, backspace/arrows/Home/End/Tab all die (2026-10-18, real-machine feedback on T2a): keyDown unconditionally handed every key to NSTextInputContext handleEvent: first (when im_active) - AppKit processes navigation keys by dispatching doCommandBySelector: (moveBackward/deleteBackward and friends) and returning YES, so they never reach our queue; our NSTextInputClient implements none of those actions (doCommandBySelector is an empty stub), so swallowed equals vanished. Character keys are unaffected (AppKit returns NO for them when no composition is active) - hence the real-machine symptom "typing works but every editing key is dead". Fix: a function-key gate in keyDown - keys the kVK mapping table recognizes (backspace/arrows/Home/End/Tab/F-keys/Return/Space/Esc) go to the input method only while a composition is in flight (preedit_active; the candidate window still needs arrows/Return/Space to select and confirm), everything else passes straight into the queue; keys outside the table (letters/digits/punctuation = text keys) still go to the IM as before (that is how compositions start). Every cell of the composition flow stays closed: starting a composition goes through the text-key gate, candidate navigation and confirmation through the preedit gate, and with no composition all function keys pass through. GTK never had this problem (GtkIMContext's filter_keypress returns FALSE for navigation keys - the same gate at line 4013 only filters during composition), Win32 passes through - macOS only.

  • NSView without acceptsFirstMouse: - the first click on an inactive window is swallowed (2026-10-18, real-machine feedback "needs two clicks"): the mac stub's view implemented the mouseDown family but never acceptsFirstMouse: - NSView defaults to NO, so when the window is not key, AppKit consumes the first click purely to activate the window and mouseDown never sees it; the symptom is exactly "after switching back from another app or panel, controls need two clicks and the first one vanishes". Win32 (WM_MOUSEACTIVATE defaults to MA_ACTIVATE, first click dispatches normally) and GTK have no such swallow step - macOS was the only platform missing it. The fix is one line: acceptsFirstMouse: returns YES (a fully owner-drawn toolkit wants click-to-act). The full investigation is worth recording: the library chain App::route → route_pointer → dispatch_on_path → component on_pointer is single-click semantics ("Down dispatches, Up settles") with no "focus first, click later" two-phase structure; focus switches synchronously on the Down tick (the Down branch of App::route), and window-blur focus retention vs ChildSurface yield each have their place - when "clicks need two tries" shows up, check the platform's first-click semantics before the library.

  • NSOpenPanel's "pick folders" needs both switches flipped (2026-10-18, real-machine feedback): on a real Mac, the video example's "pick directory" left the confirm button permanently grey - the mac stub's wm_file_dialog_set_options only wrote setCanChooseFiles:(options & 1) == 0, while canChooseDirectories defaults to NO and was never set: with both off, nothing in the panel is selectable, and AppKit's way of saying so is precisely a grey confirm button, not a disabled widget. Added setCanChooseDirectories:(options & 1) != 0, matching Win32's FOS_PICKFOLDERS and GTK's SELECT_FOLDER semantics. Lesson: when an AppKit panel button is grey, first suspect "the panel currently has nothing selectable"; review option bits pairwise against their defaults instead of only writing the one you turn on.

  • macOS ffmpeg without brew: two-route probing plus a Release-archive fallback (2026-10-18, dynamic linking): the CI macOS job pays a minutes-long brew install ffmpeg every run, and a user machine without brew cannot build the media line at all. Aligned with the Windows vendor shape - ffmpeg-mbt/prebuild.py's Darwin branch now has two routes: pkg-config probing brew (unchanged), then on failure a prebuilt dev archive downloaded from our Release (unpacked to vendor/mac/, same family as the webview Loader: byte-exact sha256, GitHub/Gitee mirrors tried in order, NATIVE_GUI_VENDOR_BASE override, manual placement works offline; after unpacking lib/*.dylib gets chmod 0755 - dyld requires the x bit to map the executable segment). Only when both routes fail does the build fail. Three dynamic-linking pits:

    1. install_name carries the packing machine's absolute path: a dylib's id and its inter-dependencies are baked as /opt/homebrew/Cellar/..., which breaks on any other machine. install_name_tool must rewrite the id and every dependency to @rpath/<basename>, link_flags must carry -Wl,-rpath,<absolute unpacked lib dir>, and each library needs a fresh ad-hoc codesign -f -s - - on arm64, a modified-but-unsigned Mach-O dylib is refused by dyld.
    2. The dependency closure decides the archive source: brew's ffmpeg closure drags in openssl/libvpx/x264/x265/svt-av1 and a dozen more formulae (21 libraries by otool), and x264/x265 are GPL - "copy five libraries from brew" dies twice (broken off-machine linking + license contamination). The official archive is built from source as LGPLv3 by scripts/make_mac_ffmpeg_vendor.sh (env -i sanitizes PATH plus --disable-autodetect cuts brew probing; afterwards every library gets an otool assertion that non-@rpath dependencies are only /usr/lib and /System), so the five dylibs are closure-self-contained with the same license stance as Windows' BtbN lgpl-shared.
    3. Verify with an equivalent, never pre-commit the real hash: the mechanism was validated on this machine with a 21-library @rpath-ified test archive made from brew artifacts + NATIVE_GUI_VENDOR_BASE=file:///tmp/... + PATH stripped of /opt/homebrew/bin (pkg-config gone), exercising the full chain download → verify → unpack → link → real decoding (mp3/ogg/m4a/wav/video 6/6 green); that test archive is a GPL stand-in and stays out of the repo - the prebuild sha table ships as an empty dict with a format comment, back-filled once the official archive lands on the vendor-v1 Release. With an empty table the route says "archive not provided yet" and the brew route is unaffected.
    4. The double-slash split from mktemp + TMPDIR's trailing slash (exposed by the workflow's first run): macOS's TMPDIR is /var/folders/…/T/ (trailing slash), so mktemp -d "${TMPDIR}/ffvendor.XXXXXX" returns T//ffvendor.xxx; once $WORK is spliced into configure --prefix, the linker normalizes // to / when writing install_name into the Mach-O, while the @rpath loop greps otool output with $WORK still in double-slash form - it never matches, the internal dependencies stay un-rewritten, and the closure assertion kills the build. The symptom is maddening: the assertion message and the grep pattern "look like the same path" (terminals render single and double slashes identically), and a local minimal repro easily goes down a rabbit hole (testing -change on a signed brew library first hits the unrelated fact that -change cannot rewrite LC_ID_DYLIB, looping forever). The fix is one line: WORK=$(cd "$(mktemp -d …)" && pwd -P) to unify on the physical path. Lesson: before using a shell variable as a path string to match another tool's output, normalize it with pwd -P.
    5. The gitee mirror closed its loop the same day (user hand-uploaded all 5 assets): when the dual-mirror policy was written, gitee actually had zero attachments (the 404s masqueraded as "files not uploaded yet" - indistinguishable from "wrong URL shape"). Once really uploaded, two facts settled: gitee's username differs from GitHub's (noahliu0911 / lb091188 - mirror URLs must be written separately), and the attachment download URL shape is GitHub-compatible (releases/download/<tag>/<filename>, no splice change needed). The mirror's value materialized immediately: on this machine, a direct GitHub download times out at the TLS handshake (the gh CLI uses a different channel - don't read machine network differences as code faults), while gitee downloads instantly with a byte-identical sha256; pointing NATIVE_GUI_VENDOR_BASE at gitee alone and running the vendor route passed the full chain 6/6. The repo must be public - a private repo's releases 404 for anonymous access, and the prebuild mirror fetch is anonymous HTTP.
    6. urlopen's timeout is socket-level; mirror switching needs a separate total budget (another machine hung on GitHub for real): urlopen(url, timeout=20)'s 20 seconds covers a single socket operation (one connect/one read) - DNS-resolution hangs and slow-drip streams are outside its reach: on a network that DROPs (rather than REJECTs), connect may never return, and the process hangs on the first mirror - "switch after 20 seconds" never happens. Fix: thread-wrap the whole GET with a total budget (60 seconds, sized so a 10MB archive completes even on a slow link; the connection-level socket timeout stays at 20 seconds for fast failure), and on budget exhaustion abandon that mirror (the daemon thread is left behind and dies with the process) and move to the next. Both prebuilds (ffmpeg-mbt / gui-webview) gained _fetch_with_budget in the same batch, one copy each (independent-publish policy). Criterion: on this machine (a REJECT network) the default mirror order prints "switching to next" when GitHub's budget runs out, and gitee takes over 6/6. Subsequently upgraded to parallel racing: the leftover waste of the serial budget is "waiting the full 60 seconds while the first mirror drips" - the default dual mirrors now fire simultaneously, and the first one returning a complete, sha256-verified file wins (_fetch_race; total time = the fastest mirror; a dripping mirror simply loses), with the 60-second budget demoted to an all-fail backstop. When NATIVE_GUI_VENDOR_BASE names a custom source (intranet/private mirror), it stays serial in order (_fetch_serial) so no parallel external traffic goes out. The criterion on this machine is the same: gitee finishing first prints "hit mirror" and the full chain passes 6/6.

Hiding the cursor takes a different road on each platform: mac has no public hiddenCursor, and GTK's "none" name lookup is a trap (2026-10-18, player fullscreen polish) ​

Auto-hiding the pointer in fullscreen players needs a 6th cursor kind (CURSOR_HIDDEN=5); each platform host maps it differently, and two of the three roads have traps:

  • macOS: NSCursor has no public always-available hiding API — disappearingItemCursor is drag semantics and hide() hides globally per-process with no way back. The workable route is a blank cursor you build yourself: [[NSImage alloc] initWithSize:NSMakeSize(1,1)] (1x1 fully transparent) → initWithImage:hotSpot:, cached in a static (resetCursorRects fires often; do not build one per call). Deliver it through the existing addCursorRect: dispatch path — no new channel.
  • GTK: gdk_cursor_new_from_name(dpy, "none") looks right (it is a CSS cursor name), but it depends on the cursor theme containing a none entry; when the theme lacks one it returns NULL and falls into the "fall back to default" branch — hide becomes an always-visible arrow, fine on this machine and broken on that one. Use the enum route gdk_cursor_new_for_display(dpy, GDK_BLANK_CURSOR): no theme lookup, always available.
  • Windows: LoadCursorW has no hidden variant; wm_load_cursor returns NULL for kind 5 and SetCursor(NULL) hides (WM_SETCURSOR re-applies the kind every time and happily accepts NULL) — zero special-casing.

Input methods eat character keys at TWO layers, and the focus gate only blocked the first: fcitx5's GTK module snarfs the press before GTK dispatch ever runs (2026-10-18, Linux real machine, three-phase proof) ​

Symptom (user's machine, two rounds): in a Linux + fcitx5 session, with the audio/video example playing and no entry holding focus, M/L (mute/loop shortcuts) do nothing; space/arrows work. Still broken after the first fix round (the ime_focus sentinel). Three-phase localization (home box via ssh home + experiment/key_probe printing at the raw window layer + xdotool injection + fcitx5-remote toggles; key_probe bypasses every MoonBit gate):

  • Phase A (fcitx deactivated): m/l/space/Up arrive as press+release, all of them;
  • Phase B (fcitx active, Chinese mode): the presses of m/l/space all vanish — preedit=[m], preedit=[m l], and space straight-up commit=[目录] (fcitx picked a candidate for the pinyin "ml"); the releases arrive normally (ev code=1 keysym=109/108/32);
  • Phase C (GTK_IM_MODULE/XMODIFIERS unset, fcitx still active globally): all pass. Root cause: the eater is not our gtk_im_context_filter_keypress (that layer is gated by im_active, and key_probe never opened the gate yet was still eaten) — it is the process-wide key snooper that fcitx5's GTK IM module installs at gtk_init. It runs before every widget key-press handler, and whenever this window's GtkIMContext is focus_in'd it diverts character-key presses to fcitx (preedit/commit) while letting releases through. The GTK arm called gtk_im_context_focus_in unconditionally in wm_on_realize/wm_on_area_focus_in (G0 condition ① wants it), and wm_im_set_active only flipped a flag, never focus_out — the flag governs our own filter call, not the snooper. Fix (both layers together): wm_im_set_active now drives focus_in/out — on = focus_in (G0 condition ① preserved, real pinyin still works), off = focus_out plus clearing the preedit cache (same shape as the focus-out path); the focus_in calls in wm_on_realize/wm_on_area_focus_in are gated on im_active. Plus the previous round's ime_focus sentinel (that was also a real hole: the focus-drop path wrote 0 and skipped the gate sync, so the gate could never even be flipped to off for "no entry focused"). Verification: phase B is the criterion — after the fix, rerun key_probe with fcitx active: m/l presses should arrive as ev code=0 with no preedit; final close-out in the T checklist (player M/L and entry pinyin, both directions).

A one-line label wraps inside its own box: the measured width was rounded to nearest, so it disagreed with the line-breaking pass (2026-10-18, macOS real-hardware report on two showcase pages) ​

Symptom (user's Mac, two pages of the same family): on 表单组件→日期 the「选中:」label under the date picker rides over the picker's bottom edge, and on 对话框 the「最近确认:」label drops its colon onto a second line. Not a paint offset and not an overlay — the text really did wrap. Diagnosis (headless probes: mounted() for layout rects plus per-string measure_font): yoga gave the label a 31/32 px box, measure_font("选中:", 13px) returned 32, yet the same string inside max_width=32 came back as two lines from pt_layout and only became one line at 33 — measurement and line breaking do not close at the boundary. CoreText advances are floats (CTLineGetTypographicBounds measured 32.x) and pt_write_metrics rounded with +0.5, reporting narrower than reality; layout sized the box from that narrow number, then the same total_w fed the greedy break test (line_w + uw > lim) and had to add a line. ui.text(role=Body) implies wrap=true (basic.mbt), so a label box one pixel short wraps; the resulting two-line box, centred with valign=Center inside the row, pushes the first line's ink up onto the widget above — which is exactly what looked like "overlapping misplacement". Fix: measured width is now always rounded up, making "box width >= true float width" the fixed point of measure and break. All three arms aligned in the same batch — macOS pt_write_metrics (ceil(total_w)), Windows' two measure entry points pt_measure_font/rt_measure (ceilf(widthIncludingTrailingWhitespace)), and Linux's trailing-whitespace widening (pango_layout_get_pixel_size already rounds up; only pt_width_with_trailing_ws was rounding to nearest). Height and baseline keep their old rounding: the overflow risk lives on the horizontal axis (wrapping); the vertical axis is not part of this criterion. Verification: gui/text/text_wbtest.mbt "测量宽自洽:按测量宽下发的盒不再折行" — full-width-punctuation strings, free measurement then the same width as the wrap constraint, asserting the height is unchanged (no pixel constants, fonts vary); with the ceil reverted the case fails red on this machine (FAILED: '选中:' 在自身测量宽 32px 的盒里折成了多行). The UI-side gui/ui/datepicker_wbtest.mbt "日期段标签不折行" guards the whole chain (label and value label same height, the row never overlapping the picker). Local macOS moon test for text+ui: 219/219; the Linux/Windows halves of the same criterion are proven by CI.

Maintenance ​

  1. New conclusions go into the matching section and record only the pit and the fix; protocol-interop conclusions must come from the real bus and real panels — unit-test self-consistency does not count.
  2. Keep both language versions (this file and docs/zh/adaptation.md) in sync in the same batch.
  3. Landing points for the media and desktop-control batches: "Media and desktop-control command routes" (under the Linux system-capability data layer), "System information and system-level security boundaries", "Directory enumeration and in-process environment variables" (under Cross-platform general), "Generic D-Bus call layer" (under DBus wire protocol), "Map and flight-line projection" (under Self-drawn canvas and chart rendering); browser-side conclusions go into the Firefox places.sqlite section.
  4. Do not run moon fmt inside a feature batch: the current toolchain's reflow conventions disagree with this repo's existing style (one round touched 287 files); a repo-wide format pass is its own batch, opened only after confirming the conventions match.

Frontend-batch real-hardware feedback fix set (2026-10-14, migrated in from TODO on 2026-10-16) ​

This batch is a one-item-one-cause record of "user reports from a real machine → locate → fix", previously held only in TODO.md; under the split "the full picture lives in the roadmap, unfinished work in TODO, process in adaptation" it moved here in one piece, and the TODO side deleted it.

  • [x] Stroke convention unified (2026-10-14, real-hardware report "the colour-picker borders sit off"): the facade contract is "stroke_rect lands inside the rectangle" (both backends implement it that way), yet 28 call sites passed (0.5, 0.5, w-1, h-1) out of centered-stroke habit ⇒ the whole ring shifted 1 px down-right (a strip of fill exposed on the left/top). Converted with (x-lw/2, y-lw/2, w+lw, h+lw) and switched across the repo (ui + chart + examples); the button focus-ring test sampled column 1 because it was written against the offset geometry and now samples column 0, plus a new four-side swatch symmetry pin.
  • [x] The library would not compile off Windows (2026-10-14): sys's power-scheme batch put two static helpers taking GUID parameters outside every guard (static still type-checks when uncalled), so unknown type name 'GUID' on Linux killed the whole build; both helpers were wrapped in #if defined(_WIN32). Rule: any function whose types come from a platform header goes into the guard wholesale, and after changing C, compile at least once on a non-target platform.
  • [x] The transfer panels were too small (2026-10-14, real-hardware report): the default 160×min 60 was far too small to read as an operable list ⇒ default 200×160, plus a height? parameter (a long list still grows on its own and never clips rows).
  • [x] Table hover lit only one cell, and "add a row" did nothing (2026-10-14, real-hardware report): ① hover is parent-chain semantics, but the host marked only the "old/new hovered" widget's rect ⇒ the row painted a row-wide background while the repaint window was one cell. New App::mark_state_chain marks dirty along the hit chain, an ancestor only when it paints that state itself (@style.apply sets Widget.paints_state; core does not know the style layer and only reads the boolean); with no state ancestor the cost stays one cell. ② Store's built-in dedup compares by value, and the example stored the array it pushes into ⇒ old and new are the same object and the notification is swallowed; the initial value became a snapshot. Full wording in this file's entry from the same batch.
  • [x] The timeline connector came out broken (2026-10-14): the row gap was carried by the container's row_gap and belongs to no widget ⇒ the line broke at every gap. The spacing is now painted into the row (a non-last marker and row box grow to height+gap) with pixel assertions that the gaps must carry line. Continuous cross-row decoration (connector / spine / separator / track) can never live in container gaps.
  • [x] Breadcrumb clicks did not navigate (2026-10-14): "current item" was decided by position (the last item) rather than by the navigation state ⇒ the last crumb stops working whenever it is not the current page. Clickability and colour now key off selected.get() == mine.
  • [x] Three form-page items (2026-10-14, real-hardware report): ① inputs could not take keyboard focus by default (focus_id? defaulting to 0 means the host judges "not focusable") ⇒ input/textarea auto-allocate when no number is given (from 1000000, explicit numbers still win); ② marking focus changes fed the focus id into a lookup by instance id (mark_focus_rect→mark_id_rect) ⇒ new mark_focus_id_rect going through find_by_focus_id; ③ input gained clearable (EP's name, off by default, ✕ on hover/focus, clicking clears and writes back); ④ the stepper's [-] value [+] now shares one frame (the row container draws the frame once, the cells draw only hover fill and the separator; the old three rings read as unjoined boxes, and measurement showed the three cells were already equal height).
  • [x] Form-family keyboard accessibility (2026-10-15, user report: "nothing but inputs can be Tab-reached and there is no keyboard interaction"): button/checkbox/radio/switch/stepper/slider/rate/select/color_picker/date_picker/date_range/time_field/dropdown/segmented all auto-allocate a focus number by default (auto_focus_id, retiring the 2026-10-14 "deliberate opt-in" rule); space/Enter activates, radio/segmented arrows re-select by focused index, slider/stepper/rate arrows adjust the value (slider adds Home/End), select/color_picker/dropdown popups take ↑↓ highlight + Enter pick + Esc close, date/time fields open and close the layer (arrow navigation inside the calendar is not done); focus rings go through "outline turns theme colour" and the inner-ring form. moon test ui 176/176, library 934/935 (1 red = the Windows baseline), real-machine re-check under S22.
  • [x] Fullscreen and size switching did nothing on Windows (2026-10-15, real-hardware report): wm_window_set_size/wm_window_set_fullscreen in gui/win/stub.c's Windows branch were symbol-bearing empty stubs (GTK had long had real implementations), so links held and calls were silently inert. Win32 implementations added: set_size expands client-area physical pixels to the outer window per the current style and calls SetWindowPos; fullscreen records the style + WINDOWPLACEMENT scene, strips decorations for WS_POPUP and moves onto the nearest monitor, restoring verbatim on exit; the existing WM_SIZE bookkeeping keeps delivering Resized to MoonBit. experiment/window_probe's C probe gained two sections (size read-back + fullscreen enter/exit scene restore, full WIN-ENHANCE-OK), real-machine re-check under S23.
  • [x] Global hotkeys never fired (2026-10-15, real-hardware report): the library chain (RegisterHotKey → pump intercepts WM_HOTKEY → poll) was fine; showcase's poll table was armed at the wrong moment — the "zero-size mount hook" sat in node_of_new's finish (construction time) and page functions are evaluated before the window exists, so the first tick ran on the empty timer channel with app.host still None and the loop never re-armed (occurrence #2 of the charts push-point pit). Moved to Node::on_mount; window_probe gained a hotkey section (WM_HOTKEY injected via PostThreadMessageW, checking interception/extraction/dedup/exclusivity/unregistration) printing WIN-ENHANCE-OK, real-machine re-check under S24.
  • [x] The charts page sat at "no data" (2026-10-14, real-hardware report): set_timeout's process-level channel is installed by App::new and is a no-op returning 0 without a host, while page_charts(app) is an argument to mount_window evaluated before the window exists ⇒ the first tick armed during construction never fired. Changed to hang on handle (running after mount_children), with the on-page code sample re-worded. Same batch: section descriptions gained wrap=true (they were single 900 px unwrapped lines whose tails were cut).
  • [x] yoga-mbt left the line gaps out of free space (2026-10-14, real-hardware report "the copy button is still wrong"): in §9.7 the distribution, initial_free / remaining / auto-margin free / justify free2 subtracted only the sum of child frames and never gap x (n-1), while positioning added gap per item ⇒ grow items over-ate the gaps and the whole row overflowed its container (acceptance check: row 100 + gap 10 + [fixed 30, grow 1] should be 60, computed 70 before the fix). All four places now subtract gap_total first.
  • [x] "The strip wraps when too wide" was a fake claim (2026-10-14): ui never wrote flex_wrap and the style layer had no such property ⇒ every container was NoWrap and long strips overflowed and got clipped. Style gained flex_wrap per "property name = CSS property name" (factory/merge/apply all wired). Real wrapping still needs two companions: the strip belongs in a column (a row does not stretch its cross axis ⇒ the wrapping container sizes to content and folds into six or seven lines), and the wrapping container plus every parent level needs explicit min_height/min_width=0 (spec §4.5 makes a column child's min:auto equal the min-content height = everything stacked; measured strip 224 px with the body dropping from 646 to 476; the engine is spec-conformant). Post-fix numbers: strip 54, body 646, and the code-document page viewport 652 / content 808 scrollable.
  • [x] The scroll container would not scroll (2026-10-14, real-hardware report): scroll_view's viewport never set min-height, and Yoga flex children default to min-size=auto ⇒ the viewport was pushed up to its content's natural height and max() stayed 0 (measured: inside a 240×140 parent, viewport=676=content). min_height/min_width=0 added at the library level, so all call sites (showcase section bodies and sidebar, native-app, virt_list hosts) benefit together; explicit height= is unaffected. The acceptance check and control readings are in this file's entry from the same batch.
  • [x] Two Markdown-page defects (2026-10-14, real-hardware report): ① rich-text ranges were produced in UTF-16 code units while the AttributedText contract is UTF-8 byte indices ⇒ in mixed CJK/Latin text every style shifted forward and endpoints cut inside characters (tofu plus overlapping glyphs); ② md_rich_label never wrapped and the node width equalled the whole paragraph's natural width (a 656 px line pushed into a 480 document) ⇒ the right edge was clipped and the code area's "copy" button was pushed out of the visible region. Fixed with md_byte_len producing the ranges plus greedy wrapping at construction (CJK per character / Latin per word / \n hard breaks / over-long units cut per character), one AttributedText per line, strike-through and link hits computed per line, and list/quote/footnote/table cells narrowed to their available width (md_narrow). The acceptance checks and the "switch back to the old unit and it goes red on the spot" control experiment are in docs/zh/adaptation.md's entry from the same batch. Pending real hardware: do the tofu / clipped right edge / copy button disappear, and does vertical scrolling of the content area come back?