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
linksection (moon would generate a main-less exe and the build fails); all link flags come fromscripts/prebuild.pyvia the--moonbit-unstable-prebuildhook, which emits link_configs auto-propagated to every main package depending on the library module. Never hand-writecc-link-flagsin 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 fmtmigrates in one shot);moon doconly 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-8for MSVC. - Recent moon deprecates implicit trait-method promotion: call sites use the explicit static form
ViewLike::method(obj);impl Trait for Xdeclaration sites need explicitpub extend X with Trait::{...}(generate the method list frommoon check --no-renderoutput, 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;@masterreferences 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 upgradeaborted 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 lacksStringView::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 incrementalmoon check/moon testgreens were false. Nightly version numbers are delisted the same day (the CLI server 403s any named historical version), so pinning viaMOONBIT_INSTALL_VERSIONis not viable — the only repair is rerunninginstall/unix.shwith no version to install the latest full bundle (binaries and core replaced in one pass), matching CI. Verify by checking thatmoon version --alland~/.moon/lib/core/moon.modagree, 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.0was 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 ofNoahLiu/yoga-mbt@0.1.0the package manifest is stillmoon.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.mooncakesdownload 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 (itsmoon.pkgis 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'sbuild_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 publishfrom each module root (optionally with--dry-runfirst); before pushing, bake a localmoon packagepreview archive (<module>/_build/publish/<Owner>-<name>-<version>.zip) and unpack it to confirm zero old.jsonmanifests and thatmoon.mod/moon.pkgare 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.workmatches 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-levelmoon.modbut still ships the oldsrc/moon.pkg.json(published before the in-repo migration; the registry copy is immutable), andNoahLiu/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 publishpacks just that module from its own root (dependencies are not vendored), andmoon.workonly 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 probeexamples/probe-markdownverifies the parse / render_html / serialize calls (moon run examples/probe-markdown --target nativeprints 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), withmoon checkzero warnings andmoon test576 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 experimentalmoonbitlang/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 addtransitivity measured: a consumer'smoon add mizchi/markdown@0.8.3writes 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, withFailed to resolve registry dependency ... module was not found in the registry. Layer one: a freshly installed runner's registry index has noprowk/moonsqlitefileormizchi/markdown, and moon prints the password itself —you may need to run moon update; both jobs now open with amoon updatestep. Layer two only shows once the index is refreshed: since 2026-10-12 the toolchain only acceptsmoon.mod/moon.pkg, while the published third-party packages still shipmoon.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 (runningmoon fmtin every.mooncakes/**/moon.{mod,pkg}.jsondirectory) — 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 membermodules/yoga-mbt/src/moon.pkg.jsonwas migrated tomoon.pkgin the same batch (warn_listunchanged 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()returnsNfFailed(ServiceUnknown)whilenotify_supported()says there is no backend. The oldcore/notify_caps()forwarded the dbus error directly, which made the two probes disagree. Nownotify_caps()decides "is there a backend" throughnotify_backend()first, givesErr(Unsupported)when there is none, and only asksGetCapabilitiesonce the D-Bus backend is confirmed. One-line contract: whennotify_supported()is false,notify_caps()must giveUnsupported. Thedbus/notify_wbtestcapability case prints a skip for "bus present, service absent", the same treatment asNfUnsupported, instead of calling it a code failure. - Declarative mount (the assertion encoded a dev-machine accident, fixed):
mount_window_render(render, ...)ismount_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-listcount,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-corejoined the apt list so at least one family exists, and every assertion frozen to the dev machine's metrics became font-independent: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);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);ui/textarea's up/down keys used linesabandcd, so the remembered pixel x mapped differently per font (on DejaVu the second line is narrower and up lands mid-line); both lines are nowab- the test checks the rule, not one font's width relation;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;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: inui/base.mbt,draw_underlinecomputedy = top + baseline + 1.5and then hitif 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 whenr.h <= 0, otherwise clamp withmin(r.h-1, max(0, y))against the bottom edge), and the assertion now prints plain/under ink, both row counts andmeasure_font's w/h/baseline.
/tmpis not a universal writable path:render/image_wbtest's "write PNG, read it back" went red on Windows -/tmp/...resolves toC:\tmp(absent) there, sowrite_to_filereturns false. The test now writes to a relative path (themoon testprocess has the module rootgui/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 -QuietPatternparameter (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-devand four other-devpackages - not because the main path uses them, but becausemoon test/moon checkinside the workspace also runsmodules/ffmpeg-mbt's own prebuild, which exits non-zero when pkg-config cannot findlibav*(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 sysreproduces 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()returnsNfFailed(ServiceUnknown)whilenotify_supported()says there is no backend. The oldcore/notify_caps()forwarded the dbus error directly, which made the two probes disagree. Nownotify_caps()decides "is there a backend" throughnotify_backend()first and only asksGetCapabilitiesonce the D-Bus backend is confirmed. One-line contract: whennotify_supported()is false,notify_caps()must giveUnsupported. - Declarative mount (the assertion encoded a dev-machine accident, fixed):
mount_window_render(render, ...)ismount_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: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);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);ui/textarea's up/down keys used linesabandcd, so the remembered pixel x mapped differently per font (on DejaVu the second line is narrower and up lands mid-line); both lines are nowab- the test checks the rule, not one font's width relation;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.
/tmpis not a universal writable path:render/image_wbtest's "write PNG, read it back" went red on Windows -/tmp/...resolves toC:\tmp(absent) there, sowrite_to_filereturns false. The test now writes to a relative path (themoon testprocess has the module rootgui/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 testfor 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 -QuietPatternparameter (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:
rendergains 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), andraster.mbt(theRastersession, method-for-method mirroringCairo); thePainterfacade becomes a two-backend enum with runtime probing (Cairo::overfailing to open a session ⇒ pure path), no compile directives (AGENTS rule 11). The stubs merge intorender/stub.c: the real cairo implementation hangs off theWM_STUB_CAIROmacro 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 viaPainter::cairo_id(): with cairo it paints into the session context; the pure backend returns None (W5's DirectWrite shape paints straight into the Bitmap), andPainter::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 composites+(d·inv+127)/255matches 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); uppercaseletis illegal — useconst; scientific notation1e-9fails to parse, spell it0.000000001(repeated an old pitfall; suggest grepping1e-); trailing commas on match arms are illegal, including after tuple arms(a,b,c). Test-side: tests that usedCairo::overdirectly became "backend probe + structural assertions" shared cross-platform; decode-dependent tests gained adecode_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/subprochad 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:7dies under MSVC withfatal error C1083: cannot open include file 'poll.h', which blocked the wholesyschain (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 stubsproc_spawn/proc_wait/proc_getpid, zero third-party deps) intosys/sys_stub.cas C, symbolsyue_sys_proc_*, semantics aligned bit-for-bit: POSIX branch shared by Linux/macOS (fork→setsid/setpgidon macOS →dup2temp files →execvpeon Linux /execvp+*_NSGetEnvironon macOS,_exit(127)on exec failure,_exit(126)onchdirfailure); Windows usesCreateProcessW+CREATE_NO_WINDOW,STARTF_USESTDHANDLESwith 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 — Windowswaitreopens it viaOpenProcess(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). __uuidofdoes not exist in MSVC's C mode: inCoCreateInstance(&CLSID_FileOpenDialog, ...), writing__uuidof(FileOpenDialog)fails witherror C4233: nonstandard extension used: keyword only supported in C++(the coclass declarations in the SDK headers are C++-only). In C, take theextern const GUIDconstants from the headers (CLSID_FileOpenDialog/IID_IFileDialog/IID_IShellItem), which come from uuid.lib — hencescripts/prebuild.py's Windows branch now issuesole32.lib shell32.lib uuid.libforsys(MSVC takes them as positional arguments;-ltriggers D9002).- UCRT has no
_getwcwd: the name looks like the wide getcwd but is declared nowhere in the SDK — MSVC only emitswarning C4013: undefined, default external return int, truncating the returnedwchar_t*to int and then dereferencing it: compiles, crashes. Use the Win32GetCurrentDirectoryWinstead (two passes: size 0 to query, then fetch). - Environment variables must go wide, and Windows has no "exists but empty" state:
getenv/_putenv_sare 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 bysys/envx_wbtest.mbt. Switched toGetEnvironmentVariableW/_wputenv_s(query size with 0 first). Related semantic difference: on Windows_wputenv_s(name, L"")is the delete operation, soenvx_get(name)reads back an empty-value write asNone, notSome(""); 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. ShellExecuteWsucceeds when the return value is > 32: it returns the legacy HINSTANCE-style status (≤32 areSE_ERR_*), so>32 ? 0 : -1is the correct judgement. Itsopenverb carries the system associations (URL → default browser, document → associated app), which is exactly the rolexdg-openplays 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 hadfsw_write_fileasreturn 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 withWriteFailedon 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,
/tmpmeans "tmp under the current drive root", so if that directory does not exist the write fails — temp paths must come from%TEMP%/$TMPDIRper platform (separator too); ② cmd emits\r\n, so assertions onechooutput 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 (POSIXsh -c, Windowscmd /c). Unix-convention-specific assertions (exit 127 for a missing command, self-inflicted SIGTERM, forcedLC_ALL=C,/etc/hostname) run on the POSIX side only; Windows gets the equivalent (CreateProcessWfailure surfaces asSpawnFailed) or prints a skip. - Without a text layer, self-drawn widgets collapse to size 0: on the pure-rasterizer platform
@text.measurereturnsNoneandattach_text_measure's fallback is(0.0, 0.0), so width-from-text buttons/labels/links neither measure nor get hit — 20 assertions inuiare 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 explicitUnsupported" stance. - Verification (Windows 10 19045 + MSVC 14.44 + SDK 10.0.26100, moon 0.1.20260920):
moon test -p sys163/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-packagemoon check --deny-warnzero warnings;moon check ./examples/native-app --deny-warnpasses (previously blocked at the dependency stage); whole-repo red dots stay at 287 errors/68 warnings.textremains 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_newin the GTK branch ofwin/stub.cto reuse slots by scanningarea == NULL, while the_WIN32branch of the same function still didwm_next_id++— repeatedly opening/closing secondary windows exhausts the 32 slots andopen_windowsilently returnsNone. Fixed identically to the GTK branch, with the marker changed tohwnd == NULL(set on a successful create, cleared by the memset inwm_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'sRegisterClassExWreturns 0 when the class name is already registered (ERROR_CLASS_ALREADY_EXISTS) — so the first call succeeds and every later one reports "backend unavailable", makingApp::open_windowalways returnNoneon 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, andERROR_CLASS_ALREADY_EXISTScounts as success. The GTK branch usesgtk_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'sWM_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", printingsub_wid/primary/active_is_sub/countandbefore/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 keepsrunning=truewith the count back down. ③ A finalMULTIWIN-DONEline and exit code 0. Run withmoon run experiment/multiwin_probe; headless environments printMULTIWIN SKIP. Visual confirmation (occlusion/redraw between two windows, preedit inside the secondary window) stays with the user per AGENTS rule 6; theexamples/native-appstart-up smoke on Windows printsNATIVE-APP-MW wins=1 ...and exits 0 (clicking its buttons needs injection, i.e. W7). - Side fix of the same class: the
_WIN32branch callssnprintfwithout including<stdio.h>, and MSVC only warnsC4013: 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 whybh_supported/bm_supportedhad 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_HOMEelse$HOME/.config; Windows =%LOCALAPPDATA%, where Chromium-family profiles sit one level deeper at<vendor>/<product>/User Data(thatUser Datalayer 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 forHistory/Bookmarks, and the SQLite/JSON parsing are one shared path across the three platforms, so only the definition tablebh_defs()and root resolution branch by platform. - Evidence (real Windows machine with Chrome/Edge installed):
bh: profiles=2 items=5andbh: downloads=3are readings from the actual local browser databases — on Windows this path used to be gated off and always skipped — and thebm_bookmarksend-to-end parse likewise flipped from "skip" to real execution;moon test -p sys163/163,moon check ./gui/sys --deny-warnand./examples/native-sys --deny-warnwarning-free. One stale test assertion (bm_supported() == (platform=="linux")) moved to the three-platform rule. - Rule of thumb:
fsx_list_dir/sys_read_text_filego 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.hdoes not compile in C — use a C++ TU:DWRITE_MAKE_OPENTYPE_TAG/DWRITE_MAKE_FONT_FEATURE_TAGexpandstatic_castinto 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: anextern "C"round trip worked), so the platform split became:stub.cPango branch (prebuild with-DWM_STUB_PANGO) +stub.cplaceholder 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.cppcompiles 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_heightbuilds a"M"layout and readsDWRITE_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 withstrcmp(same convention as Pango'sg_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_RANGEwants 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 withMultiByteToWideChar. - Drawing = D2D render target + 32bpp DIB + premultiplied over: the same
IDWriteTextLayoutis first drawn into a DIB (Clearto transparent, antialiasing pinned to grayscale — ClearType is subpixel rendering and would fringe on a transparent background), then composited into the MoonBit buffer without = 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) beforeSetDrawingEffect, so colours are snapshotted only on the draw pass; box alignment follows the Pangopt_render_boxrules: 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_atwithcairo_id()==Nonerender into their own offscreen bitmap via the buffer variants (pt_draw_box/rt_draw), then land it withRaster::blit— translate/clip/blend are all computed by blit. - Along the way, a real
Raster::blitbug got fixed: it fed the device-space rect fromrect_deviceintomtand then composed the CTM again — the CTM was counted twice, so with atranslatein 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: buildmtin user space (e/ffrom the user-spacedx/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_fontinuigoes 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-appstart-up smoke prints all readouts and exits 0; per-package--deny-warnwarning-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_caretis a byte offset too, and reading a commit requirescommit_doneto clear it; inWM_IME_COMPOSITION,GCS_RESULTSTRfills commit (into the text state) whileGCS_COMPSTRfills preedit (self-drawing only, never into the text state). - Three platform differences (all measured or interface facts): ①
WM_IME_SETCONTEXTmust stripISC_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 aWideCharToMultiByteprefix, otherwise the caret lands wrong for every multi-byte composition; ③ legacy IMM32 has no surrounding-text API (that is TSF), sowm_im_set_surroundingis 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_cursorstores the rect and re-feeds it onWM_IME_STARTCOMPOSITION(some IMEs only read the position then);ImmSetCompositionWindow(CFS_POINT)andImmSetCandidateWindow(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_resetgoes throughImmNotifyIME(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_probeprobe (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_probeand theexamples/native-appsmoke 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(newclipboard.mbt+ the_WIN32branch ofsys_stub.c): Windows usesCF_UNICODETEXT(OpenClipboard/GetClipboardData/GlobalLockto read;GlobalAlloc(GMEM_MOVEABLE)+SetClipboardDatato write — after a successful set the ownership belongs to the system, so neverGlobalFree, the classic Win32 clipboard trap); Linux/mac go through commands (xclip -selection clipboard/pbcopy·pbpaste). Chinese content must take the wide path (win_utf8_wideplus 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_runperforms no shell parsing, so the explicit form issh -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 ofLNK2019: __imp_SetClipboardDataerrors, 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)ininputgives the kernel an external clipboard hook —copy/cutalso publish,Ctrl+Vfetches live (falling back to the built-inclipwhen unavailable), and the kernel itself still depends on no platform layer (headless-testable). Off by default: the app opts in withui'suse_system_clipboard(true)(one line each inexamples/native-appandexamples/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 machinemoon test -p sysis 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_waitnever dispatches. The Win32 branch waited withMsgWaitForMultipleObjectsExand never calledwm_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_presentonly 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 insidepaint_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 inwm_close. - Cause 3: key-code convention mismatch. The Win32 stub mapped control characters the GDK way (
0x01000000|u) while the kernel'sKS_*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_keytakes 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::routedispatches 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 != 0was 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_pathwas written to increment before indexing — after walking the last path itemiequals the length, andpath[i]is out of bounds; on real hardware this surfaced as "right-clicking any widget panics withPanicError". Linux never saw it because in those cases the right-click was consumed at a deeper level andhandledleft the loop early. Fix: ancestor-priority now indexes first and increments at the end of the loop body; regression case added inwidget_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=1lands, and no Panic appears in the log. - Cause 7 (Chinese IME could not compose): the message pump lacked
TranslateMessage. The Win32wm_pumpdid onlyPeekMessage+DispatchMessage— but legacy IME (IMM32) hooks its composition processing onto theTranslateMessagepath: without it you get bare keys only (letter key-downs are consumed by the IME, leaving just key-ups) and never see a singleWM_IME_STARTCOMPOSITION/WM_IME_COMPOSITION(measured: onlyWM_IME_SETCONTEXTarrived), so Pinyin can never compose. After adding it the probe immediately reportspreedit=[ni'h'ao …]andcommit=[你还奥]. Check: runexperiment/key_probeand 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_markerset only a width, has no children and registers no measure — so its height is 0; withhbox's defaultalign_items=Centera zero-height cell gets placed at the row's midpoint (measuredrect=(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" inmarkdown_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
BindDCper call, 1.8ms all-in per segment); ② process-level caches for canvas and brushes, grow-only; ③ aRaster::blitfast 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
CreateBitmapFromMemoryand 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 andLock(WICBitmapLockRead)to read back after drawing. ② Clearing the canvas with a fully transparent pen viaFillRectangleis a no-op under source coverage semantics; you mustClear— 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 PowerShellAppActivate+SendKeys) for the keyboard path;experiment/text_benchas 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 insideApp::step/paint_framelocated 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.batis blocked by a reg.exe policy, so compiling a pure-C probe by hand means settingINCLUDE/LIByourself, with Windows-style paths (/c/...gets treated as a source file):bashThe pure-C probeMSVC='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.libexperiment/menu_probe/menu_backend_probe.c#includes../../gui/win/stub.cdirectly —wm_menu_bar_newreturns a handle-table index (1..31), not a realHMENU, so reading the menu structure back requires the staticwm_menus[]table in the same TU (the first version passed the index as anHMENUtoGetMenuItemCountand got-1everywhere). - A pure-C probe that includes the stub must define
-DUNICODE/-D_UNICODEexplicitly: without itIDC_ARROWetc. areMAKEINTRESOURCEAforms (LPSTR) and the...Wparameters emit C4133 (stub.c:388-396,646); with it there are no new warnings (the C4996 forwcsncpy/wcsncatinsidestub.cis pre-existing).moon's native-stub already defines UNICODE (recorded in the W2 section), so the production chain never sees those two C4133s. AppendMenuWcaps 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 didcmd = next++beforeAppendMenuW, 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_COMMANDcarries only the id, so a collision dispatches the click to the wrong item). Fixed tocmd = next, withnext = cmd + 1only afterAppendMenuWsucceeds (wrapping toBASEat the pool end). ②WM_MENU_CMD_MAX=2048must 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).\tinside the label is the accelerator hint:AppendMenuWright-aligns everything after\t(verified by reading it back withGetMenuItemInfoW, which also checks the Chinese UTF-8→UTF-16 round trip and the\tCtrl+Alt+Qhint 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":
MessageBoxWshows and modally blocks in one call until the user clicks a button, returning the button number — no callback, no window slot; theuTypefour-field bit combination (icon + buttons) is computed by a MoonBit-side pure function and passed in (the probe measuredicon=64 btn=0 raw=327744on 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'sWM_HOTKEYlands in the registering thread's queue and never reaches the WndProc — same scheme as menus, with the stub'swm_hotkey_pollpulled from the MoonBit pump. Accelerator-string parsing ("CmdOrCtrl+Shift+M"→ modifier bits + virtual key code) is a three-platform-shared pure function (CmdOrCtrlmaps to Ctrl on Win, Cmd on mac), 10/10. - Evidence: the pure-C probe
experiment/menu_probe/menu_backend_probe.cprintsMENU-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.mbt10/10,win/hotkey_wbtest.mbt10/10 andwin/message_box_wbtest.mbt8/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/.bmpgo throughLoadImageW(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→CreateFormatConvertertoGUID_WICPixelFormat32bppBGRA→CopyPixels); already-decoded pixels go through a 32bpp top-down DIB +CreateIconIndirect.wm_icon_load_filedispatches 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) —CreateIconIndirectblends with the color bitmap's alpha, so premultiplication is what avoids halos; the two layouts line up naturally, so pixels are a byte-for-bytememcpyinto the DIB. Verification caveat:GetObject'sbmBitson a DIB section is not guaranteed readable (measured: an unusable pointer, memcmp always fails) — to read pixels back useGetDIBitswith a negativebiHeight(top-down request, matching the input row order). CLSID_WICImagingFactoryis a macro that points at Win8-only Factory2:wincodec.hdoes#define CLSID_WICImagingFactory CLSID_WICImagingFactory2when_WIN32_WINNT >= 0x0602; for older systems takeCLSID_WICImagingFactory1explicitly (identical vtable prefix,IID_IWICImagingFactorystill v1).- All the GUID constants are exported from
windowscodecs.lib(verified withdumpbin /linkermember:CLSID_WICImagingFactory1,IID_IWICImagingFactory,GUID_WICPixelFormat32bppBGRAare all there), so win's Windows link line only addswindowscodecs.lib ole32.lib— nouuid.libneeded (__uuidofremains 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 callswm_icon_from_png, defined after it, and compiling stub.c standalone gives MSVCC4013: 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, feedswm_icon_*directly, reads back withGetIconInfo/GetDIBits) ends withICON-BACKEND-OK: BGRA→HICON size/bit-depth/pixel round trip,.icofrom 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 byexperiment/icon_probe/make_png.pyusing only stdlib zlib (no Pillow).moon test ./gui/winis 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'sptMinTrackSizewants 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 withAdjustWindowRectEx(&r, st, FALSE, ex)before fillingptMinTrackSize. Probe measurement: a minimum client width of 400 underWS_OVERLAPPEDWINDOWyields a 416-wide frame (8 px each side), and theAdjustWindowRectExconvention agrees withWM_GETMINMAXINFO.- A zero axis must be explicitly zeroed:
set_min_sizeallows one axis to be unconstrained (0= no limit on that axis). But0must not be fed intoAdjustWindowRectEx's rect — that computes a fake minimum of "a zero client area still occupies 2× frame width". The implementation writesmmi->ptMinTrackSize.x = w->min_w > 0 ? (r.right - r.left) : 0;and checks both axes; thewm_set_min_sizeentry also clamps negatives to0first. When only one axis is set, the other must fall back to 0 or the window gets stuck on that axis. WM_NCHITTESTmustbreak, notreturn 0: on a hit in the self-drawn title-bar drag region itreturn HTCAPTION(handing window dragging to the system), but outside the region it mustbreakback to default handling — the default return values ofWM_NCHITTESTcarry meaning (client areaHTCLIENT, resize cornersHTBOTTOMRIGHT, …), andreturn 0swallows them: measured symptom, the frame can no longer be dragged and client-area mouse behaviour goes wrong. The hit test convertslParam's screen coordinates to client coordinates withScreenToClientbefore comparing rects (lParampacks 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)mapswhich0/1/2 toICON_BIG/ICON_SMALL/ both and callsSendMessageW(hwnd, WM_SETICON, …)directly. The probe reads back withWM_GETICON(ICON_BIG)and getting the sameHICONhandle proves the write took effect. The icon handle table belongs to the caller (W20f's slot table); the window only uses theHICON, 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. Passingicon_slot=0clears the icon. - Borderless = restyle, then recompute the frame from the client size +
SWP_FRAMECHANGED: ON dropsWS_CAPTION|WS_THICKFRAME|WS_MINIMIZEBOX|WS_MAXIMIZEBOX|WS_SYSMENU|WS_BORDER|WS_DLGFRAMEand switches toWS_POPUP(keepingWS_MINIMIZEBOXso minimizing still works); OFF restoresWS_OVERLAPPEDWINDOW. Two key steps: ① capture the current client rect withGetClientRectfirst, recompute the frame withAdjustWindowRectExunder the new style, thenSetWindowPos(..., SWP_NOMOVE|SWP_NOZORDER|SWP_FRAMECHANGED)— withoutSWP_FRAMECHANGEDthe non-client area is not recomputed and the window size jumps; ② make it idempotent viaw->borderless, returning early on a same-value call (otherwise every call runsSetWindowPosfor nothing). Probe verification: after borderless there is noWS_CAPTIONinGWL_STYLE, and the client width is still 640 (unchanged). - Runtime retitle goes through
SetWindowTextW:wm_set_titleconverts UTF-8 to UTF-16 then callsSetWindowTextW; the probe reads back withGetWindowTextWto 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 readsSendMessageW(WM_GETMINMAXINFO/WM_NCHITTEST)directly andGetWindowTextW/WM_GETICON/GWL_STYLEback) ends withWIN-ENHANCE-OK(all 40 assertions pass):SetWindowTextWtakes effect (Chinese),ptMinTrackSize.x == 416(client 400 + frame), multi-monitor negative coordinates hit / open-interval boundary, in-region returnsHTCAPTIONand out-of-region returnsHTCLIENT,WM_GETICON(ICON_BIG)equals the icon passed in, noWS_CAPTIONafter borderless with the client width unchanged, and an out-of-range id neither crashes nor writes the buffer. Example wiring:examples/native-appgained a window-enhancement section (retitle / self-drawn icon / borderless toggle / drag-band toggle) and an exit status lineNATIVE-APP-WIN title='…' min=… drag=… bl=… icon=…, andmoon buildproducesnative-app.exe.moon test ./gui/win24/24,moon check ./gui/win ./examples/native-app --deny-warnwarning-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_NotifyIconWposts callback messages to the window inNOTIFYICONDATAW.hWnd, withwParam=uIDandlParam= the mouse message. This side therefore creates its own hidden message window (yue_wm_trayclass +HWND_MESSAGE, lazily and idempotently), and the slot number is theuID(1-based, slot 0 unused;WM_MAX_TRAYS=8). WIN32_LEAN_AND_MEANstrips outshellapi.h:Shell_NotifyIconW/NOTIFYICONDATAW/NIM_*/NIF_*all live there, so it must be included explicitly (same treatment asimm.h). The link line gainsshell32.lib(it is not inuser32/gdi32) — missing it meansLNK2019 __imp_Shell_NotifyIconW.- The callback message is
WM_APP+2, and again "C never calls back into MoonBit":WM_APP+1is already taken bywm_wakeup's waker window (thatcase WM_APP + 1:instub.c), so the tray takesWM_APP+2. Hits (WM_LBUTTONUP/WM_LBUTTONDBLCLK→ left,WM_RBUTTONUP→ right) go into a process-level ring queue that the host drains each tick withwm_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 fromwm_menu_bar_newonly inCreatePopupMenuvsCreateMenu), core'smenu_translate_intofills it as usual, and popping usesTrackPopupMenuEx(menu, TPM_RIGHTBUTTON|TPM_RETURNCMD|TPM_NONOTIFY, x, y, tray_hwnd, NULL)—TPM_RETURNCMDhands the selected item back as the return value, and pushing it intowm_menu_pushrejoinsdispatch_menu_command()'s callback table without relying on the tray window'sWM_COMMANDrouting. 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). szTipis a fixed 128-wide-character array, so long text must be truncated: the tooltip write useswcsncpy_s(nid.szTip, 128, wtip, _TRUNCATE)— a barewcsncpydraws C4996 (the same family as the two W20a menu-label sites). The_svariant 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 andNameHasOwner(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'sGtkStatusIcon(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 gainssysanddbusdependencies (the former fordesktop_environment()readingXDG_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-namedTrayErrorlives in the retiringyue/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 aRef[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, feedswm_tray_*directly) ends withTRAY-BACKEND-OK, all 28 assertions passing:wm_tray_supported()==1, a realShell_NotifyIconW(NIM_ADD)succeeds, tooltip Chinese UTF-8→UTF-16 round trip plus a 299-char truncation under 127 without overrunning,wm_menu_popup_newreturning a realHMENUattached to the tray, postingWM_TRAY_CBto 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 (TrackPopupMenuExblocks, 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/coreis 99/99 (6 new pure-function tests:basename_no_extacross path shapes/empty/trailing dot/multiple dots, non-empty desktop name, the threeTrayErrorshows; 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-appgains a tray section (five buttons: create / retip / attach menu / popup / remove, plus a state counter) and an exit status lineNATIVE-APP-TRAY supported=… backend=… count=… clicks=… note=…, withdispatch_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=1confirms the Win32 tray backend is available). Whole-repomoon checkred 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_ADDcreates the tray item, thenNIM_MODIFYwithNIF_INFOplusszInfoTitle/szInfopops 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 newWmTray.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_wndprocgained awm_trays[slot].kind == 0guard when handlingWM_TRAY_CB, so akind == 1slot drops clicks outright — a balloon's click semantics are undefined (Windows handles toast clicks itself, not viauCallbackMessage), and enqueueing anyway would dispatch to a slot that is not a tray icon. The probe postsWM_LBUTTONUP/WM_RBUTTONUPto a notification slot and confirms the queue stays empty. - "Dismissing a balloon" has no standalone API; follow the official convention of sending
NIF_INFOagain with an emptyszInfo:wm_notify_closefirst sends that empty-bodyNIM_MODIFY(the system retracts the balloon), thenNIM_DELETEremoves the tray item — both steps are needed, andNIM_DELETEalone 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.mbtheader and here): ①uTimeoutis ignored by the system since Vista, so a balloon's dwell time is set by the system's accessibility settings andtimeout_msexists only for signature parity with the Linux side — it has no effect here; ② a Win32 balloon has no "progress hint", whereas Linux's freedesktopvaluehint 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 toNIIF_*: 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 readdwInfoFlagsback, 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).szInfoTitleis a fixed 64-wide-character array andszInfoa 256-wide-character one: writes always usewcsncpy_s(..., _TRUNCATE)(a barewcsncpydraws 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=8table, but their counts are fully decoupled:wm_tray_count()counts onlykind == 0andwm_notify_count()onlykind == 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 unifiedwm_tray_alloc(kind, icon_slot, tip), withwm_tray_newdelegating atkind=0. - Facade layering:
win/notify.mbtis the thin Windows-side wrapper (notify_supported/notify_send/notify_update/notify_close/notify_count/notify_titleplus theNOTIFY_INFO/WARNING/ERRORconstants);core/notify.mbtis 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 isnotify_send(app_name, summary, body, icon?, timeout_ms?, replaces_id?, progress?, kind?, icon_handle?), whereiconis a Linux theme icon name andicon_handlea Win32 W20f icon handle (0 = use the system status icon). Whenreplaces_id > 0, the Win32 side triesnotify_updatefirst (re-sending on the same slot replaces it). - Evidence: the pure-C probe
experiment/notify_probe/notify_backend_probe.c(#includes stub.c, feedswm_notify_*directly) ends withNOTIFY-BACKEND-OK, all 37 assertions passing:wm_notify_supported()==1, a realShell_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/coreis 108/108 (9 new:notify_clamp_kindclamped to 0..2,notify_clamp_pctclamped to 0..100,notify_body_with_progressleaving negative progress alone / appending to the body / clamping the percentage, the twoNotifyErrorshows, backend name matching the enum, andnotify_capsin 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-appgains a notification section (five buttons: info / warning / error / 60% progress / close, plus a state counter) and an exit status lineNATIVE-APP-NOTIFY supported=… backend=… sends=… id=… count=… note=…(thecount=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-balloonconfirms the backend selection). Whole-repomoon checkred 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 throughMessageBoxW— 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:
buttonsis a list of(label, response code)and the component passes the code straight back. The native tier'sOk(1)/Cancel(2)/…/No(7)isMessageBoxW'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 intoNATIVE-APP-MSGBOX(native) andNATIVE-APP-MSGBOX2(self-drawn). - ✕ / Esc / clicking the mask = dismiss without a code (via
on_dismiss):MessageBoxW's ✕ maps toIDCANCEL, 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_maskdefaults 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 viamsgbox_alloc_focus_id()and callsfocus.register+focus.focuson@core.host_active(), with the panel itself as the focus widget (it sits deeper than the scrim, so keys reach it first); on teardown itunregisters 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) thisregisterstill 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):MsgBoxKindwas first written with aNonevariant, and immediately everyStore::new(None)elsewhere in the same package started inferring asMsgBoxKind::None, producing 6 type errors indatetime_wbtest. The cause: in positions with no expected type such asStore::new(None), a same-named constructor gets picked up. Renaming it toDefaultcleared it. Lesson: before adding an enum to a package, check whether the variant name already has an expected-type-free use in that package (Noneabove all, then names that clash withSemanticTypesuch asWarning). - Relation to
dialog_t: both share the same overlay machinery incore/overlay.mbt(full-window scrim / centring / close-on-outside-click / relayout on open), differing in semantic surface —dialog_tis a content dialog (arbitrary content tree + confirm/cancel exits) whilemessage_box_tis a message box (kind-driven icon + informative sub-line + any number of buttons each carrying a code). Content dialogs still go throughdialog_t; this batch did not merge the two. - Evidence:
ui/message_box_wbtest.mbtadds 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 + bothclose_on_maskstates + an empty informative leaving no sub-line + empty-label buttons skipped), using a headless widget tree with a manually installed overlay host (as inoverlay_wbtest) — no real window needed.moon test ./gui/uiis 118/118 (was 110).examples/native-appgains a self-drawn message-box section (three buttons: info / warning / error, each driving its own box) and an exit status lineNATIVE-APP-MSGBOX2 shots=… last=… ovl=…(theovl=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-warnis 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_*/ Win32IDropTarget) requiring three separate handlers for enter/update/leave; in-app drag and drop in this stack touches no platform interface — the session model lives incore/drag.mbt(process-level) and the two declarative hooks inui/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};Imgholds 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), anddrag_encode/drag_decodepack[kind:i32 LE][payload](Pathsjoined 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 byWidget::drop_target_at, scanning back-to-front for the deepest visible widget carryingon_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_pointerrecords the captured id as the deepest widget on the hit path (same meaning as a Webpointerdowntarget); the drag source wrapper'son_pointerreceives 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. Whenpayload()returnsNonethe drag does not materialise, again falling back to an ordinary press.derive(Eq)on a value type trips theimplicit_impl_as_methodwarning (this batch's compile-time trap): the correct form is a hand-writtenpub impl Eq for DataKind with fn equal(self, other) -> Boolpluspub extend DataKind with Eq::{not_equal, equal}. Note the implementation function isfn equal(writingop_equalfirst makes the compiler reportno method op_equal in trait Eq).DragDatadoes not go throughEq(it holdsBytes/Array) nor implementDebug, so tests observe it via strings/ids.- Another implementation trap (recorded):
Byteshas noop_setandraw[4:...]yields aBytesViewthat cannot be sliced directly — assemble bytes withFixedArray::make+Bytes::from_array;split("\n")yields anIter[StringView]and needs.map(fn(s){ s.to_owned() })to become anArray[String]. - Evidence:
core/drag_wbtest.mbthas 6 tests (id round trip,DragData::kind/as_text, encode/decode round trip and bad input,drop_target_atdeepest hit, sessionbegin→move→drop, no-target/reject/cancel, and a constant no-target result when there is no root);ui/drag_wbtest.mbthas 4 tests (press → cross threshold → drag → move onto target → release delivers, below-threshold press behaving as an ordinary click / release over blank not delivering,acceptrejecting so the callback does not run, andpayloadreturningNoneproducing no session), all driven through the host's@core.route_pointer(including press capture) — no real window needed.moon test ./gui/coreis 115/115 andmoon test ./gui/uiis 122/122.examples/native-appgains a drag-and-drop section (two sources "text payload / paths payload" plus two targets "accept all / paths only") and an exit status lineNATIVE-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-warnis 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>.desktopand writes it to disk (pure string assembly plus the existing text-file read/write entries infsx.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 ofsys/sys_stub.c. macOS is explicitlyUnsupportedthis batch (it needs a LaunchAgents plist; deferred to real hardware). - The value's shape is a platform convention, not an arbitrary string: a Linux
.desktopExec 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
enableis an overwrite (the.desktopon Linux, the same-named Run value on Windows), anddisablealso succeeds when the entry was never there (Linux relies onyue_sys_remove_file's probe-then-delete; Windows treats bothRegDeleteValueW'sERROR_FILE_NOT_FOUNDand "the key itself is missing" as success). - app_id validation is a shared precondition of both landing points: the app_id doubles as the
.desktopfilename 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).sysdoes not depend ondbusin reverse, so the rule is implemented locally insys/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-app2is valid whilecom.2appandcom.-appare not. A test first treated "contains-" as invalid (assertingcom.ap-pto be false, though it is in fact valid); the run corrected it. - Link surface: the
Reg*family lives in advapi32.lib, whilesys's Windows link line previously carried onlyole32/shell32/uuid/user32(those serve the file dialog's COM). This batch addsadvapi32.libto 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 ownmoonbit_make_bytes— a pure-C probe does not link the moon runtime, yetyue_sys_*returns moon byte strings; a self-consistent layout suffices (the length sits in the 4 bytes before the data pointer, matching the realmoonbit_objectheader), with the real ABI interaction covered by the whitebox tests. ② Thestub.cinclude order defeats_CRT_SECURE_NO_WARNINGS— the probe includes<windows.h>first (which already pulls instring.h), and UCRT decides at that instant whether to warn about deprecated names, so the stub's twowcscpy/wcscatsites 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 includesys_stub.cfirst, 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_pathmatching an independentGetModuleFileNameWcall character for character (including the.exesuffix and backslashes), ok=0 when unwritten, a write followed by the probe's ownRegQueryValueExWread confirming it landed underSoftware\Microsoft\Windows\CurrentVersion\Runwith typeREG_SZand a byte-identical value, overwrite / Chinese-path round trip / empty value (length 0 yet present) / a 1207-character long value,NULLarguments returning -1 from all three entry points, an independent read after removal showingERROR_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 valuecom.moonbit.native-gui.autostart_cprobeboth on entry and on exit, cleaning up regardless of outcome (checked afterwards withreg query; no residue).sys/autostart_wbtest.mbthas 8 tests: 6 pure (directory resolution in three states / Exec escaping /.desktopcontent and parsing in both states / Run value shape and its inverse / app_id validation / the errorShow) + 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.desktopbody 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 whenautostart_supported()is false.moon test ./gui/sysruns 174, 173 pass, 1 red — that one is the pre-existingfsx_wbtest.mbt:109(thesys_make_dirscase was already red on Windows:pr_available()is true there, yet the case used the POSIX/tmppluspr_run("rm"/"mkdir"); re-checked withgit stashon a pristine tree, same single red, unrelated to this batch). Since fixed —sys_make_dirsis now the C thin FFIyue_sys_make_dirsand the case itself is platform-portable;sysis 178/178.examples/native-sysgains "enable autostart / disable autostart" buttons and a status line, plus the exit readoutNATIVE-SYS-AUTOSTART supported=1 enabled=0 id=… exe=… on_hits=0 note=…(is_enabledreads the disk/registry live, so it still works after the window closes, unlike window readouts); real-hardware smoke:exit 0(no button clicked, soenabled=0and no registry residue).moon check ./gui/sys ./examples/native-sys --deny-warnis warning-free; the C side under MSVC/W4keeps 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 orschtaskswith a delay, Linux could addX-GNOME-Autostart-Delay— not written here), capability probing beyondis_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 functionsidle_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 usesGetLastInputInfo+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 isDWORD 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 throughdlopen("libX11.so.6"/".so")+dlopen("libXss.so.1"/".so")anddlsymforXOpenDisplay/XDefaultRootWindow/XScreenSaverAllocInfo/XScreenSaverQueryInfo/XFree/XCloseDisplay. TheXScreenSaverInfostruct is declared locally in the stub (rather than includingX11/extensions/scrnsaver.h, which would drag in X headers); symbols are fetched through avoid*hop (memcpy) to dodge-Wcast-function-type. The result is cached in a staticg_idle_x11(atriedflag guards against repeated probing). - A Wayland session gets "unsupported" rather than a wrong number: with no X server (
DISPLAYempty)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 withResult: 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 needsDISPLAYnon-empty and both libraries dlopened; macOS false). Insideidle_seconds(),ok=1givesOk; otherwise it consultsidle_supported()— true meansErr(Failed("空闲查询失败"))(idle query failed, e.g. an X connection dropping mid-flight), false meansErr(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.cends withIDLE-BACKEND-OK, all 13 assertions passing: supportability always 1, the reading non-negative and within int32 (no truncation), matching an independently recomputedGetTickCount() - 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 theokout-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 bundledmoonbit_make_bytes). Build recipe in the probe header (adduser32.lib—GetLastInputInfolives in user32; and because#include sys_stub.cpulls in the registry functions, linkadvapi32.libalong with it).sys/idle_wbtest.mbthas 4 tests: 3 pure (millisecond clamping / milliseconds-to-seconds / errorShow) + 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/sysruns 178, 177 pass, 1 red — that one is the pre-existingfsx_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-sysgains a "read idle" button and status line, and the exit readoutNATIVE-SYS-IDLE supported=1 seconds=2486.141 hits=0 note=…; real-hardware smoke:exit 0, andNATIVE-SYS-GEOincludesidle=1786,368,110x32(the button laid out).moon check ./examples/native-sysis 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 asregister_global_shortcutplus theparse_acceleratorpure function), leaving Linux/macOS callers with no way to write isomorphic code. So W22 = addcore/hotkey.mbt, shaped like the other two facades: probehotkey_supported()+ structured errorHotkeyError{Unsupported, Failed(String)}+ pump hookhotkey_poll(). - Current backends and the degradation contract: Windows is a real implementation (
RegisterHotKey+ a thread queue, already verified in W20b); Linux's X11XGrabKeyand macOS's Carbon/AppKit hotkeys are not wired, and always returnErr(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", whereCmdOrCtrlmaps to Ctrl/Cmd per platform — parsing delegates to win's pure function rather than reimplementing it in core);hotkey_poll()returnsHotkey{id, spec}on a hit — Win32'spollonly 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 emptyspec, meaning it was not registered through this facade). - Platform probing goes through the runtime
@sys.sys_platform()(the C stubffi_sys_platformreturns 1/2/3 → "windows"/"macos"/"linux"), consistent with AGENTS rule 11 "runtime probe + match, no#cfg"; theis_maccriterion comes from the same source (it decides theCmdOrCtrlmapping). - Two small toolchain pitfalls (both already hit in the code): ①
Arrayhas nofind— the lookup table's linear scan is a hand-written loop (for h in arr { if … return Some(h) }); appending usesarr.push(x), neverarr + [x](the parser treats+as infix and reportsunexpected token infix 1, an error message far from the root cause). ②inspect(value, content="…")'scontentis 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.mbthas 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 throughErr(Failed)rather than crashing or silently succeeding, other platforms only print skip);moon test ./gui/core118/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-warnwarning-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.cthe GTK branch'swm_hotkey_*trio moves from placeholder stubs to a real implementation, andwm_hotkey_supportedis 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, bothvkandkeysymare passed and each platform stub picks its own (Windows takes vk, X11 takes keysym); the MoonBit-sideregister_global_shortcutnow takes the wholeHotkey. - A self-opened X connection instead of hooking the GTK pump: one process-level
XOpenDisplayconnection;XGrabKeylands on its root window, grabbed KeyPress events enter only this connection's queue, andwm_hotkey_polldrains them non-blockingly withXPending— neither thegdk_window_add_filternor the periodicXCheckIfEventoriginally envisioned in the TODO is needed; the pump never enters the GTK main loop. - Variant grabs are a requirement, not defensive coding: registration calls
XGrabKeyfor 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 withstate=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,
XGrabKeyreports no error in place — only a temporaryXSetErrorHandlertrap plusXSyncreveals it; failure of any variant rolls the whole thing back withXUngrabKeyand reports registration failure (the facade givesErr(Failed), never silence). Bare keys (no modifiers) are refused, matching Windows'RegisterHotKeyconvention. - 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 (bareXGrabKey+ XTEST injection) did not fire either; switching toCtrl+Alt+Hhit on the first try —Ctrl+Alt+Fnis 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_DISPLAYnon-emptywm_hotkey_supportedis 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 yieldsSome(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 checkwarning-free, library tests 982/982. Along the way:HotkeyErrorgainedShow/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
ITaskbarList3has 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 fromnotify_title, which can read back becauseNIF_INFO'sszInfoTitleis readable). - The COM instance is a process-level singleton; the target window is distinguished by hwnd: the
ITaskbarList3interface itself is window-agnostic (thehwndargument ofSetProgressValueis the target), so the stub keeps one static singleton (wm_taskbarplus awm_taskbar_triedguard against repeated probing) and takes the window handle from the window slot (an out-of-rangeidor a NULLhwndreturns 0, the same contract as every otherwm_*entry). Initialization details:CoInitializeEx(COINIT_APARTMENTTHREADED)falls back toCoInitializeonRPC_E_CHANGED_MODE;S_FALSE(this thread already initialized) counts as success; a failedHrInitmustReleasebefore reporting unavailable; there is noCoUninitializeat 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 constantsCLSID_TaskbarList/IID_ITaskbarList3must be resolved at link time — ole32.lib only exports the basic COM GUIDs; these two live in uuid.lib (the same pitfall as W20f'sCLSID_WICImagingFactoryfamily living in windowscodecs.lib: which library a GUID constant lands in has to be confirmed one by one). Hencegui/scripts/prebuild.py's Windows branch addsuuid.libto 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 usesInt64forvalue/totaland the C side passes them through asULONGLONG; whentotal <= 0theSetProgressValuesemantics are meaningless, and the documented convention is "use theIndeterminatestate for indeterminate progress". - Evidence: the pure-C probe
experiment/taskbar_probe/taskbar_backend_probe.cends withTASKBAR-BACKEND-OKand all 20 assertions passing:wm_initsucceeds,supported == 1and the singleton is non-NULL (these two together verify both the uuid.lib GUID symbol resolution and theCoCreateInstance+HrInitsteps), a real window is created (wm_window_new+wm_show, closed withwm_closeat the end), all five state ids and thevalue/totalwrite paths succeed, a4GB-1 / 8GBlarge value succeeds, the guard paths (out-of-rangeid=0/id=9999, an unbuilt window whose slot exists but whosehwndis NULL, illegalstate=5/state=-1) all return 0 without crashing or writing, the teardown clears back toNoProgressbefore closing, and the same id returns 0 after the window closes (hwndalready NULL).gui/win/taskbar_wbtest.mbthas 4 tests: ids aligned withTBPF_*, id round-trip, unknown ids falling toNoProgress(matching the C-side default branch), and "assert only when connected" — when the backend is available, a nonexistent window handle must returnfalserather than silently succeeding (other platforms only print skip).moon test ./gui/win28/28 (the original 24 plus 4 new). - Example wiring:
examples/native-appgains a "taskbar progress (§6.3)" section — seven writer buttons (10% / 50% / 100% / error / paused / indeterminate / clear) plus alive_counterthat 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 isNATIVE-APP-TASKBAR supported=… last=….moon check ./examples/native-app --deny-warnis 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_人工测试清单.mdheader). - 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 withsupportedalways 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/wcsncatin thewm_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'sscheme_root()only honored theTEMPenv var and fell back to a relative pathlibyue-browser-scheme/when it was missing. Linux desktops have noTEMP(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 leftoverlibyue-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 (
TEMPis essentially always present) but on Linux it makes the worst place to write the default.Fix (
scheme_rootinexamples/browser/main.mbt): per-platform split — Windows readsTEMP, thenTMP, thenC:/Windows/Temp(never a relative path); Linux/macOS readXDG_RUNTIME_DIR(per-user runtime dir, cleaned at logout), falling back to/tmp. The directory was renamednative-gui-browser-schemealong 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 statusclean.Rule: when an example must write intermediate files to disk, the landing point must be one of the system temp areas (
XDG_RUNTIME_DIR→/tmpon Linux;TEMP→TMPon 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.mdline "retire the old chain'smodules/gui-media" was read as "ffmpeg is going away". Checking the tree confirmed ffmpeg is not going anywhere (the new-stackyue/mediastill importsNoahLiu/ffmpeg-mbt/src— it is the decode backend), but it surfaced a real problem:yue/medialived inside the main module, and for its import of ffmpeg-mbt to resolve the rootmoon.modhad to declareNoahLiu/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.pkgrequires the module dependency to be declared inmoon.modfirst (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 (amoon.pkgimport is the compile/link boundary) was never lost: not importingyue/mediameans-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.worklistsmodules/ffmpeg-mbt), so building any in-repo package runs ffmpeg-mbt's prebuild — measured:moon build ./gui/coreandmoon build ./modules/yoga-mbt/srcboth print theffmpeg(prebuild):line, and neither has anything to do with media. That is not caused by the rootmoon.mod; workspace membership alone triggers it (it runs whether or notmoon.workchanges;.mooncakesholds noNoahLiu/, so everyNoahLiu/*resolves from a local workspace member). This batch's payoff is therefore confined to external consumers: they sit outside this workspace and resolve onlymoon.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 moduleNoahLiu/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'sscripts/prebuild.pyinto the new module's ownmodules/gui-media/prebuild.py, issued by package nameNoahLiu/gui-media/media(graceful empty link_configs off Linux, same stance as ffmpeg-mbt / the main prebuild); ③modules/gui-media/moon.modgainsNoahLiu/yoga-mbt@0.1.0(the media package takes layout nodes via@yoga) plus the prebuild option; ④the rootmoon.moddropsNoahLiu/ffmpeg-mbt@0.1.0.The example has to move with it (the
b01d1ddlesson):examples/native-webwas the only package in the main module importingyue/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 intomodules/gui-examples. So the whole example moves tomodules/gui-examples/native-web(the module documented as "the standalone example module that needs media"); itsmoon.pkgchangesNoahLiu/native-gui/yue/mediatoNoahLiu/gui-media/media(the alias still derives as@media, so zero source changes), andmodules/gui-examples/moon.modgainsNoahLiu/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.mdnow point the webview + media landing point atmodules/gui-examples/native-webinstead ofexamples/native-web(the historical evidence lines keep their original wording — that is what happened then).Evidence: ①
moon treefor the root module no longer listsNoahLiu/ffmpeg-mbt(it was explicitly listed before; after the change the first level is just the three yoga-mbt / subproc / markdown branches); ②repo-widemoon checkred 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 thenmoon check); ④the new module's prebuild run directly —echo '{}' | python modules/gui-media/prebuild.py→{"link_configs": []}(non-Linux degradation); ⑤grepshows no package in the main module referencingyue/mediaand noMEDIA_PACKAGEresidue in the main prebuild. One Windows-side gain (it was already broken): on Windows the root module could not build at all — thenative-web→yue/mediaC stub needslibavformat/avformat.handaudio_stub.cusesCLOCK_MONOTONIC; measured:moon build ./examples/native-webfails outright. After the move the root module contains no package that cannot build on Windows.Not done this batch, not pretending otherwise:
miniaudio.his still two copies (modules/gui-media/src/miniaudio.handmodules/gui-media/media/miniaudio.h, same md58d5bf2a0b46efa835bde4d0eac5a0cd8) — folding them awaits retiring the old chain'smodules/gui-media/src, blocked on migratingsystemprobe'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 needorg.freedesktop.ScreenSaverorext-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.cis 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.creportserror C2065: "CLOCK_MONOTONIC": undeclared identifier, pluswarning C4013: "clock_gettime" undefined.CLOCK_MONOTONICis a POSIX-only constant that MSVC's<time.h>does not provide (andclock_gettimeitself only lands in some UCRT versions). - Fix (one stub.c, multiple branches, platform macros — precedent
win/stub.c; AGENTS rule 11):mbt_now_msnow branches three ways —_WIN32usesQueryPerformanceCounter/QueryPerformanceFrequency(QPC is Windows' monotonic high-resolution counter; notGetTickCount, whose granularity is ~15.6 ms and which is nudged by system-time changes);__APPLE__usesmach_absolute_time+mach_timebase_info(notclock_gettime:CLOCK_MONOTONICneeds macOS 10.12+, and mach is both more conservative and equally monotonic); everything else POSIX keepsclock_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) / freqsoticks * 1000cannot 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_tto bridget * numer / denom, guarding the intermediate product when numer/denom is not 1:1 (as on Apple Silicon). - Both copies changed together:
media/audio_stub.candsrc/audio_stub.cwere byte-identical (md52b807752…). At one point the change left one extra comment line in thesrc/copy — which made them no longer byte-identical; that line was reverted on the spot and the two are identical again (both md5856a4c98e3879e77aa5108f7bb4dcfd8). 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 lineAUDIO-BACKEND-OK, 48 assertions pass, 0 failures — ①clock: consecutive / five-in-a-row reads are non-decreasing and positive, andSleep(60)advances it by 61 ms (within [40,400]); ②device-open arguments: accepted only whenchannels ∈ [1,8]andrate > 0;0/9/-1/rate=0/negative rate all return NULL; ③queue and volume semantics (independent of whether a device exists):pushappends,queued_bytesaccumulates 400→600, an invalid push leaves the queue untouched,clearzeroes it and is idempotent on the second call,played_bytesis 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_bytesreally 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 readsQueryPerformanceFrequencydirectly 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>/includeformoonbit.h; addole32.lib uuid.libfor 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 onema_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.ccompiles and runs on Windows (the probe is the proof), butmoon test ./modules/gui-media/mediastill cannot run — the test executable linksffmpeg_stub.cas well, and that needslibavformat/avformat.h(theffmpeg-mbtline 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.libto it, nor macOS frameworks. ①Windows: takingaudio_stub.c(which#includes miniaudio) and linking with no extra libraries at all succeeds (measured:/Fe:probe_nolibs.exelinks with zero libraries and still passes all 48 assertions at runtime), because miniaudio defaults to runtimeLoadLibrary(MA_NO_RUNTIME_LINKINGundefined) and emits no link-time symbols — the earlier probes carryingole32.lib uuid.libproved 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_LINKINGdemands-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/showcasemoves off the old chain (the@yueroot package +@declarative+@components) onto the new stack (theNodelayer incore+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 before91204caat 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 ofstyle=[("marginTop", 8.0)]lines all go away and are expressed by the parent'srow_gap. ② Visibility and lazy mounting moved a layer:View::set_visible→Widget::set_visible(internally the safedisplay:noneyoga path) andNode::mount_into(View)→Node::mount_into(Widget);Node.mount's closure also returns aWidgetinstead of aView. Semantics unchanged. ③ "Fill the remaining height" cannot be written on the control: the old chain wrotestyle=[("flexgrow", 1.0)]; the new scroll container (scroll_v) takes nogrowparameter, so the viewport widget is grown inside thehandlecallback (node.set_flex_grow(1.0)—handlereceives the viewport itself). ④ The nav page is ours to write:nav_pagewas 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 inshared/section.mbt—set_visiblefor 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@componentsand the example's own shared layer was@ui, so there was no clash; on the new stack the component library isui, and two packages both deriving to@uiis an outright ambiguity. Fix:examples/showcase/ui/→examples/showcase/shared/(deriving to@shared), with the@ui.App/@ui.page/@ui.sectioncall sites updated along the way. Do not paper over it with an explicit alias for the component library inmoon.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 ispopover_t(trigger, content, width?, height?, handle?), so two positionalDoubles 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.VERSIONis 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 localSHOWCASE_VERSIONconstant instead, noting that the authoritative value staysmoon.mod+yue/version.mbt(whose build-time consistency check isscripts/prebuild.py's job). - Two component-layer consolidations (write the new shape; do not hunt for the old function):
alert_closeablefolded intoalert(closable=true, on_close=), andresulttakes 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_layeron 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 thetoast_nodethat the old@components.toast_layer()destructured rather than mounting it. - Verification:
moon check ./examples/showcase ./examples/showcase/shared ./examples/showcase/pages --deny-warnis warning-free; repo-widemoon checkred dots hold at 287/68 (the 14 deleted old pages were already counted there); the real-hardware smoke exits0withSHOWCASE-READY title=组件库演示板 pages=2 follow=1andSHOWCASE-DONE page=基础组件, the window title is right, it responds, andCloseMainWindowexits cleanly. Thelibpng warning: iCCP: cHRM chunk does not match sRGBline on exit is pre-existing (examples/native-appprints 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-appalready 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_colorinside 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.mbtnow 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'sfor ch in striterates code points whilestr.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 functionmd_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.codecarries a trailing\n(line splitting must drop the trailing empty line);parse_code_block_infoonly understands thelang: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 produceBlankLinesblocks, 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 consumeListItem.checked(☑/☐), Blockquote sub-blocks recurse wholly into the accent bar, ordered lists honorstart. Verification:moon checkzero warnings +moon test595 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_labelswitches 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 checkzero warnings +moon test600 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::subscribereturns a handle andSignal::remove(id)removes it; on Store,subscribe_id(f)takes a handle andremove(handle)removes it (Store::subscribekeeps 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_changereturns a handle,off_theme_change(handle)removes it. ③Deep or bulk subscriptions use the subscription bagsub_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_timeoutreturns an id thatclear_timeout(id)cancels immediately (self-rescheduling carousel chains use this);set_timeris 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 arotation? : CarouselHandlehandle pluscarousel_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 libyueParseHexColorsource-level convention, the opposite of CSS#RRGGBBAA;yue/color.mbt'sparse_hexagrees). 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 fillcolor + "26"— accent#009688becomes#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 throughwith_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_paintersupports all geometry drawing (fill / stroke / arc / clip) withoutinitialize(), 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 afterinitializewhen DISPLAY is present; both tiers record intocharts_wbtest.mbt's output. stroke()under the painter's default stroke color is a no-op (draws nothing): a benchmark that forgetsset_stroke_colorshows 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_toitself ~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::Arcis a plaincairo_arc, and cairo normalizesea < sainto 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. ChangingPainterGtk::Arctocairo_arc_negativein 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 towrap=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 gainedyue_mbt_painter_draw_text_exexposing TextAttributes' wrap/ellipsis (pure ABI translation), MoonBit's draw_text gained optional parameters, and table cells now draw uniformly withwrap=false + ellipsis=truesingle-line truncation (done by the platform layout, so no per-cell measurement);fmt_axisswitches 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 - offsetalready 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_rectand pathstrokerender 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 singleon_drawon 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 getflex:1too. 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_layouton 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 --windowsends 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
flexshorthand (flex:1) resolves in this yoga vintage to grow=1 with a derived basis of 0 (resolveFlexBasisPtr); ② hiding a page goes through display:none, whereYGZeroOutLayoutRecursivelywipes the layout wholesale with= {}, knockingcomputedFlexBasisback to undefined; ③ after re-showing, the flex basis computation (Yoga.cppYGNodeComputeFlexBasisForChild) rewrites the derived basis-0 whenever the resolved value is defined andcomputedFlexBasisis 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 azeroedByDisplayNoneflag 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 -Wextracombo does not include shadow — patch code all-green locally, all-red on CI; check shadow first), and the fork patch lands inpatches/(the prebuilt workflow runsgit -C third_party/yoga applyafter 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 withmoon 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 stackOnFocusOut → 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
YGConfigSetUseWebDefaultsaligns all at once (the fork hook is one line at nu'sState::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.0shorthand) 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 19flex 1.0shorthands 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; bare0..nis illegal —0..<n; a1e15scientific-notation literal fails to parse — spell1000000000000.0;foldrequires the labelfold(init=0.0, ...);guardis a reserved word; generics arefn[T] f(...), notfn f[T]; top-level constants useconst NAMEor lowercaselet; apub priv structtype 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 forcespub 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 namemoon.pkg.jsonwith{"warn_list": "-6"}(unused_constructor is a false positive for a library whose constructors exist for consumers; this disables it explicitly); test assertions areassert_eq(a, b)without the!suffix and usable only in a context with an error type (inside test blocks). Verification:moon checkzero warnings +moon test -p NoahLiu/yoga-mbt/src19/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 checkzero warnings + repo-widemoon test687/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 withset_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_crosswas just "line offset + align offset" whilefree_chad already used margin-inclusive outer sizes, so amargin_top: 30item stuck to the line start (measured0 != 30) andalign-items: flex-end+margin_top: 20swallowed the margin whole (measured130 != 150); the main axis was fine (pos_mainaccumulatedm.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_sumsums 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 checkzero warnings +moon test -p NoahLiu/yoga-mbt/src36/36 + repo-widemoon test697/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 usedcontent_main_sum(children's size_main + margin only) andown_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 hardcodedcontent_main_sumto0.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 getpad_border_main / pad_border_crossadded; thenlines == 1branch stays equivalent to the old value (an auto-main container never joins flex resolution, sosize_main≡hyp_mainand switching to "max per line" changes nothing for single lines). Verification:moon checkzero warnings +moon test -p NoahLiu/yoga-mbt/src40/40 + repo-widemoon test701/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-containerb.height == 35assertion 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:autotakes 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; writingmin:auto = contentyields 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 intoinner_main/inner_cross), so wrap limits, flex resolution, line cross sizes and stretch all run on it; skipping that step gave the wrong result "awidth:320 + aspect-ratio:2container 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 checkzero warnings +moon test -p NoahLiu/yoga-mbt/src47/47 + repo-widemoon test708/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_intoonly 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) andrev_cross = rtl && the main axis is a column(flip only when the cross axis is the inline axis);axis_margingains across_reverseparameter 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, withpos_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 usedinner_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 acolumn-reversecontainer 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 checkzero warnings +moon test -p NoahLiu/yoga-mbt/src60/60 + repo-widemoon test721/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 checkzero warnings +moon test -p NoahLiu/yoga-mbt/src63/63 + repo-widemoon test724/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 enumexposes only the type and match-readability outside the package; constructing variants is read-only outside:@lib.Greenor@lib.Color::Redin an external package reportsE4036 Cannot create values of the read-only type;pub(readonly) enumsyntax is unsupported and a per-variantpubreports "No individual visibility for enum constructor". The correct form ispub(all) enum(basis: the official E4036/E0006 explanations inmoon check --explainplus 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 ranmoon check, a guaranteed stumble before YG4 integration. Fix: all nine becomepub(all) enum;Value's existingfixed/percent/auto/undefinedhelpers stay (both spellings work for consumers). The same batch established the performance baseline, toolmodules/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 withalign-items: baseline+ grow + gap + padding, leaves with measure, text with baseline callbacks; setroundsto 0 for the "process + tree build" baseline): measured here on Ubuntu 24.04 x86_64 — 500 rounds ofcalculate_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 throughlayout_intotwice, 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 checkzero warnings +moon test -p NoahLiu/yoga-mbt/src63/63 + repo-widemoon test724/724, and with the bench package participating inmoon checkas an external consumer, "enums constructible across packages" now has a standing regression.yoga-mbt measurement cache (YG3b):
measure_contentruns 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 onNode(measure_cache : Array[((avail_w, avail_h), (w, h, baseline))]; keys and values use only tuples and base types — a field ofpub struct Nodemust 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 singlecalculate_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 checkzero warnings +moon test -p NoahLiu/yoga-mbt/src65/65 + repo-widemoon test726/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_layoutappends a post-pass that landsleft/top/width/heighton 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 gives33 / 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'slayout_leftis 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 became33 + 16.67 = 49.67, absolute end66.33, rounding to 50/66 → width 16 (should be 17; the16 != 17assertion exploded it). After the fix the parameters split intoraw_*(reconstructing the children's true absolute edges) andsnap_*(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 (pixelGridSnapErrorand 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 checkzero warnings +moon test -p NoahLiu/yoga-mbt/src69/69 + repo-widemoon test730/730 (4 new: whole-pixel split33/34/33, half-pixel grid33.5/33/33.5, no grid keeps100.0/3.0, nested items compensated by absolute coordinatesmid.left 33 / width 34andinner 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 … contributionappears 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 offacebook/yoga'syoga/algorithm+yoga/style+yoga/enums): ①Unithas onlyMaxContent / FitContent / Stretch— no min-content keyword, and theSizingModecomment itself admits "Missing min-content"; ② min-content exists only as a §4.5 internal quantity —computeMinContentMainSizecalls a leaf's measure function with "requested axisAtMost 0+ the other axisUndefined" (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; ③ underMaxContentsizing mode the measure function's available space is rewritten toYGUndefined. This engine takes "Yoga's probing conventions + the spec's composition rules": leaf probing keepsSome(0.0)/None(compatible with the existingset_measuresignature, 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.5min:auto's content size suggestion was approximated by "the measured value at the current available space" → now true min-content; ② §9.2flex-basis: autowith 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 valuesmin_content() / max_content() / fit_content()can enter width / height / min-* / max-* / flex-basis, and abspos items consume them too (one funnel ataxis_size). Two stumbles: ① intrinsic probing must not refresh the node's baseline — the first version's leaf probe went throughmeasure_content, and the unconstrained probe call overwrote the real layout'sNode.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_contentbuilt 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 bypassingmeasure_contentfor 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'smoon runnumbers, 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 checkzero warnings +moon test -p NoahLiu/yoga-mbt/src79/79 + repo-widemoon test740/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, andfacebook/yoga'syoga/algorithm/CalculateLayout.cpp(computeMinContentMainSize/measureNodeWithMeasureFunc) withyoga/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 inexperiment/ime_probe, two modes in one binary: mode B = a pureGtkIMContextchannel (text state kept once, in MoonBit); mode C = a visibleGtkEntry+ CSS theming. Four build-chain pitfalls: ① moon'slink_configscarries link-time fields only — addingc_flagsto 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'sbuild_ime_probe_stub()compiles the stub withpkg-config --cflagsintobuild/libime_probe_stub.a, passed only throughlink_flags. ② the archive must follow ld naming aslibime_probe_stub.a, or-lime_probe_stubwill not find it. ③ moon does not relink when a native static library changes: after editing the stub,moon runstill launches the old exe (the SIGSEGV scene is identical) — the same "delete the artifacts after changing the shim" pit, reproduced here on the-lstatic-library path; deletebuild/libime_probe_stub.aand the package's exe before rerunning. ④ CI runsmoon build --target nativewith full-package linking on all three platforms, so a Linux-only probe breaksip_*symbols on Windows/macOS; fix: addime_probe_stub_portable.c, giving every entry an empty implementation under#if !defined(__linux__)(ip_initreturns 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_modulecalledG_OBJECT_TYPE_NAMEonpr.im == NULLin 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 — nogtk_mainneeded, 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); aftermin-height: 0pxin 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 rectangle83,22 1x17pushed 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 theexperiment/ime_probe/README.mdchecklist under both fcitx5 and ibusGTK_IM_MODULEsettings; conclusions land back in this section. Verification:moon checkzero warnings + repo-widemoon test740/740 +moon build --target native68 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 aRef[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 typeView, 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 includeyue_mbt.hmust mark its function definitionsextern "C"explicitly: without it they export C++-mangled (__Z25yue_mbt_system_accent_macv) whileyue_mbt.cppreferences 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_protocolused to encode[1,0,0,0]— the C side read the first 4 little-endian bytes asok=1(success) and kept reading mime_len/content_len past the end of the 4-byte buffer (native heap over-read), then builtstd::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, viafinish_error— see OnProtocolRequest in the yue fork'snativeui/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 functionencode_protocol_payloadfor 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 avoid8+mime_len+4overflow), 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'sMoonbit_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 checkwith zero warnings +moon test636 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/showcaserelinks 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) implementsapply_to(Self, View, String)for the builtinsDouble/String; the mixed-typed arrayArray[(String, &StyVal)]takes pixel and string values in one parameter, dispatching internally to the two C channelsffi_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&Traitreferences 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_viewdoes not exist in moon 0.1.20260904's core (no method), while on 0.1.20260920 it exists andsubin turn is flagged deprecated — such APIs exist mutually exclusively as nightly core evolves; follow the current nightly's canonical names, and on version switches takemoon checkoutput 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/annotationsis 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 pathsyue_mbt.libandyue_prebuilt.libfuse 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 (runningmoon buildfrom 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_flagsissued 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'sweb/lib/WebView2LoaderStatic.libfor the web package (the only path-bearing entry; the other 4 pure system libraries unaffected) was dropped whole ⇒shlwapi.liband the static Loader vanished together, and linking reported a string of LNK2019 forCreateCoreWebView2EnvironmentWithOptions/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-flagsfollows the same rule (-I"${build.FFMPEG_INC}", otherwise the apostrophe path splits into two arguments on the cl side). Acceptance check: runecho '{}' | python gui/scripts/prebuild.pyand confirm the web entry is a complete quoted path; grep the built exe forWebView2to 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 preferslib/<platform>/vendored and falls back tobuild/, and its freshness test_sources_newerrelies 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 — andensure_native_libreturns 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_newerconservatively 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'sjustifyContent=centercenters 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 usesopendir/readdir(skipping.and..), Windows usesFindFirstFileW. FindFirstFileA is not usable — the A version is ANSI-codepage, so non-ASCII paths (Chinese user directories) come out garbled; paths convert withbase::SysUTF8ToWide, the pattern appendsL"\\*", and entries convert back viaSysWideToUTF8. 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. Theokout-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 callssetenv/unsetenvdirectly; Windows uses_putenv_s, which always overwrites — theoverwrite=0branch must first probe withstd::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 POSIXunsetenv; 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.fieldaccesses,::methodsuffixes 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 trailingbind()(which calls bind_label) moves into the declarative package with it; ⑥ system — the cross-platformopen_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 stashtest 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 —methodis a reserved keyword (Warning 0035 says so). Fix: always writepub fn T::name(self : T, ...).Pitfall:
match expr with { }reportsunexpected token with(match ... with {appears 0 times in the existing repo code). Fix: writematch expr { }; notewithstays legal in record update{ s with tx: 1.0 }— the two constructs differ.Pitfall:
Bytesis read-only (noop_set), and this version has noByteArraytype (The type/trait ByteArray is not found). Fix: mutable byte buffers useFixedArray[Byte](already used byyue/png_rgba.mbt:12-19), and in-place writes need nomutbinding. The pixel bitmap field is thereforeFixedArray[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 requiringT &mut, forcingmut pat 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 innerPainterState'smutfields while the outerstatefield is never rebound (same shape asyue/signals.mbt:247'sself.node.dirty = false), keepingp.fill_rect(..)usable on an immutable handle.Pitfall: structs are reference-semantics, so
save()pushingself.stateonto the stack meansrestore()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 (seeyue/view.mbt:513-521).Pitfall: a test-only dependency across
moon.workmembers declared only inmoon.pkg.json'swbtest.importreportsPackage ... not found, and the entry'saliasfield is silently ignored (the default alias is the last path segment, hence@srcin bench). Fix / trade-off: to letrender's tests use yoga-mbt, the module dependency would have to be declared in the rootmoon.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'sColorconvention is#AARRGGBB(stated in the parse_hex comment ofyue/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 fmton this version rewrites ~80 files unrelated to this batch (the repo is not fmt-clean) and migratesmoon.pkg.jsonintomoon.pkg— where the new DSL does not knowwarn_list(seemodules/yoga-mbt/HANDOFF.md§6.11), soyoga-mbt/src'swarn_list: -6gets silently moved out. Fix: runmoon fmtonly on this batch's new files, then checkgit statusand revert anything else withgit checkout -- <explicit paths>.Verification:
moon checkzero warnings + repo-widemoon test748/748 (8 new pixel-level assertions inrender: 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_allthe 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 ofgtk_main_iteration_do(FALSE)(do not use the deprecatedgdk_flush). - Pitfall ② (busy poll saturating CPU, dt=0): calling
g_main_context_wakeupbeforepollmakes 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 externalwm_wakeup()(whose effect is exactly to make the current poll return early). - Pitfall ③ (hardened GLib signatures): this machine's
g_main_context_query/checktake an extran_fdsargument 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.hinstead of copying documentation examples. - Pitfall ④ (time base for relative delays):
CLOCK_MONOTONICcounts from boot — a large number (21405569ms measured here). IfLoop'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 buildreports "no work to do" and runs the old exe (same as "delete the artifacts after changing the shim"). Fix: deletebuild/libyue_win_stub.aand the example's_buildoutput 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/core9/9;examples/native-windowlocal 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
renderBitmap's internal layout now matchesCAIRO_FORMAT_ARGB32exactly (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-writtenBitmap::paintnow does source-over in premultiplied space (same arithmetic as Cairo) andBitmap::pixelunpremultiplies, so the public semantics (independent RGBA components) and all G1 assertions stay unchanged. - Simpler present:
wm_presentdrops the RGBA→BGRx swizzle and the staging copy, building an ARGB32 surface over the caller's buffer directly (held by#borrowduring the session, so the buffer cannot move). stroke_recthas one meaning on both sides: Cairo'sstrokeis 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
#00000080over 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/render14/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-windowsmoke 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 writesout[0..2]through anint32_t *out) segfaults — MoonBit hands C the array object, so writingout[0]corrupts its length header and everything touching that array afterwards dies. The repo's established form is oneRef[Int]per out value (ok : Ref[Int],yue/ffi.mbt:452); switching to threeRef[Int]params made it 7/7. Rule: to return several values from C, use severalRef[Int]— never borrow anArray[Int]pointer. - Pitfall: constructing or reading struct fields across packages requires
pub(all) struct(theRectFshape inyue/geometry.mbt:20); plainpub structreportsCannot create values of the read-only type. Same story aspub(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);baselineis the first baseline measured from the top of the layout box, matching yoga-mbt'sset_baselinedefinition so wiring needs no conversion; ellipsize/wrap semantics follow the teacher'sattributed_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_widthclamps 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/text7/7;moon check gui/{core,win,render,text}warning-free;examples/native-windowsmoke 52 frames, exit code 0 (mixed CJK/Latin text drawn in the window); repo-wide red unchanged at 290 errors.
MoonBit native GUI stack G3b addendum · line-height definition settled and "the test executable does not relink"
- Definition settled:
line_heighttakes the font metrics' ascent+descent (pango_context_get_metrics), andline_ascenttakes 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) = 17matches it); this layer'sline_height(14) = 22(ascent+descent). Both numbers are right, the label was wrong; G5 should size text boxes from the 22 path. - Pitfall:
PangoFontMetricsis not a GObject — it must be released withpango_font_metrics_unref; misusingg_object_unrefSIGSEGVs 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 testexecutables are not relinked either — previously recorded only formoon buildexes, 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/text7/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_rangealigns start backwards, end forwards — better to include one extra code point than return half a character. The kernel never touches the system clipboard:cut/copyreturn text andpaste_textaccepts text, so the whole logic is testable without a display.Tab/Shift+Tabreturn "not consumed" for the upper focus stack; printable keys are landed bykeysym_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 needfn Editor::helper(self : Editor, ..); ② top-levelpub name : Int = vfails to parse, usepub const NAME : Int = v(uppercase); ③moveis a reserved keyword (Warning 0035) — the method is namedmove_caret; ④Int.to_char()returnsChar?, and interpolating it warns "Option does not have a meaningful string representation" — match out the Char first; ⑤ variants ofpub enum X derive(Eq, Show) { A B }cannot be constructed elsewhere; payload-less enums needpub(all) enum, and this batch went further and replaced the two-case enum (Consumed/NotConsumed) with a plainBoolto shed one syntactic layer; ⑥} else match ... {is illegal — a match used as a branch body must be wrapped in{ }. - Pitfall (out of bounds):
floor_boundaryallows k == buffer length (the end is a legal boundary), so the loop must short-circuit onk < nbefore indexing, otherwiseArray::atpanics (SIGABRT observed). - Verification:
moon test -p NoahLiu/native-gui/input10/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'skeysym_to_textyields 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 everyCtrl+Left/Shift+Tabcombination misfires. - Prerequisites for receiving keys:
gtk_widget_set_can_focus(area, TRUE)+gtk_widget_add_events(area, GDK_KEY_PRESS_MASK | ... | GDK_SCROLL_MASK), plusgtk_widget_grab_focusaftershow_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_eventreturns the code, thenwm_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.mbtowns 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'sFocusManager(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_afterwas promoted from private to public so upper layers advance the same way. - Verification: five packages
moon checkwarning-free; tests core 14/14, input 10/10, render 14/14, text 7/7, yoga-mbt 79/79;examples/native-windowlocal 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;
GtkIMMulticontextfalls back to the built-inGtkIMContextSimple(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 calledgtk_im_context_focus_in, so it stayed inactive andfilter_keypressconsumed 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 callfocus_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_outsynced with focus; ② key handling installed on the focused widget (window-level wiring gets both the ordering and the consumption wrong — exactly the difference fromGtkEntrybehaviour); ③gtk_im_context_set_surroundingevery 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
GtkEntrypreviews 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] changedin 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_eventuses return value 4 to mean "an input event is pending", but the MoonBit-sideWindow::poll_eventmapping only covered 1/2/3, so 4 fell into the_ => NoEventarm — the main loop therefore believed "no events" and ended the drain immediately, andtake_inputwas 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::advancestarves 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 withxdotoolfor objective verification (this path works on this host and can replace eyes). - Verification:
moon check winwarning-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 onw->area(the drawing area), the same shape as GtkEntry. - (2)
GDK_MOD2_MASKwas being treated as Meta — fatal and very easy to misjudge.wm_mods()set MOD_META wheneverstate & (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'shandle_keysees alt/meta nonzero, classifies it as a chord, and inserts nothing, silently dropping every printable key. Fix: Meta comes only fromGDK_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" — printcode/keysym/mods/focus/editor byte-countat the dispatch point. This time a singlemods=8field 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::advancestarving 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 fyieldsNATIVE-WINDOW-OK ... a='helloabc' b='第二个框def'— the first three keys into the first box, Tab moves focus, the last three into the second box, withmods=0. Five packagesmoon checkwarning-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_windowbeforeshow— the drawing area is not realized yet,gtk_widget_get_windowreturns NULL, the multicontext attaches to no IM module, andfilter_keypressnever consumes. It must run in therealizesignal (same as the probe), paired withfocus-in/out-eventforfocus_in/focus_out. - Pitfall 2 (core dump:
free(): double free detected in tcache 2): the UTF-8 string delivered by thecommitsignal is owned by GTK and freed after the handler returns; callingg_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_resetalongside the per-tick surrounding feed breaks the composition — reset means "discard the current composition". Feed onlygtk_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-changedsignal signature is(GtkIMContext*, gpointer); the string must come fromgtk_im_context_get_preedit_string(ctx, &str, &attrs, &caret)(connecting as(ctx, text, data)hands you a bogus pointer);caretis a byte offset inside the composition, the same unit as this stack's UTF-8 byte caret. - Toolchain addenda:
FixedArray[Byte]→Stringgoes through@utf8.decode_lossy(FixedArray::unsafe_reinterpret_as_bytes(buf))(neither@utf8.decode_utf8norbuf.to_bytes()exists in this version);Double → Intisx.floor().to_int()(noInt.floor_of_double, no+.operator, and a line-continuation operator must sit at the end of the previous line). - Objective verification (
xdotoolinjection,GTK_IM_MODULE=fcitx): typingnihao+ space yields the exit linea='hello你哈哦'— no pinyin-letter residue, andseen=6counts only the unconsumed keys, so filtering and "consume-and-eat" both behave as intended; the process exits cleanly. Five packagesmoon checkwarning-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
ImmAssociateContextand macOS.
MoonBit native GUI stack · Painter contract completion, first batch (facade + blend modes + gradients)
- Facade decision:
Painteris 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 requiresPainter::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 addingend()did headroom return to the safe zone. - Blend modes: the 25
BlendModevalues 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 equals0.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 withcairo_user_to_device, round withfloor(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_rectanglepluscairo_clipmust not be wrapped incairo_save/cairo_restore. Clipping belongs to the saved state, sorestorereverts the clip just set. Restore manually withcairo_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::newdoes not sync them, strokes that never callset_line_width, such as the inner band ofstroke_rect, come out one step thicker. The "stroke an inner ring" case located it. - Pitfall — a function that does not exist:
cairo_pattern_get_statusdoes not exist; the correct name iscairo_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/NoahLiuwith 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/radialplusadd_stop. Offsets clamp to [0,1], invalid hex is ignored, a gradient with no stops is ignored as a whole, andset_fill_coloroverrides 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/render25/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 packagesmoon checkzero warnings, core 15/15, input 10/10, text 7/7, yoga-mbt 80/80.examples/native-windowsmoke: 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;
GdkPixbufLoadersniffs the format from content. Pixels convert to this stack'sBitmaplayout: ARGB32 premultiplied, little-endian B,G,R,A. Premultiplication rounding matchesBitmap::paint,(c*a+127)/255, so encode → decode → re-encode is byte-stable; translucent colors such as#12345680round-trip exactly. The decoder takes only the first static frame. The old chain'sImagewas aGdkPixbufAnimation, so GIFs animated; animation is a known gap and is not pretended to be supported. - Sizing follows the teacher:
get_width/get_heightreturn the logical size, pixels divided by scale;get_scale_factorreturns the recorded ratio. This matches libyueimage_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 addPADextension so edges do not open into transparent seams. - Pitfall —
ar rcsappends renamed members: after splitting the source intocairo_stub.cplusimage_stub.c, the stalelibrender_cairo_stub.oleft in the old archive entered the library together with the new object, producingmultiple definitionat link time. prebuild'scompile_stubnow deletes the old archive beforear 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.mbtintorender/png.mbt. The public entry is renamedpng_encode_rgba; internalpngr_*names stay. Six existing cases moved with it. The root package's private helperutf8_bytesdoes not exist in the new package, so it becomes@utf8.encodeplusmoonbitlang/core/encoding/utf8inmoon.pkg. After the move, errors dropped from 290 to 287 — one fewer file and one fewer test in the root package. - File writing:
Canvas/Imagewrite_to_filecurrently supports only "png". Writing goes through the C-sidefsw_write_filefallback, a temporary channel before the system layer lands; paths are taken as UTF-8 bytes. - Verification:
moon test -p NoahLiu/native-gui/render40/40 — 9 new: PNG round-trip opaque/translucent, empty-image semantics and no pixels landing on an empty image, logical size following scale,resizeupscaling keeping a solid color,draw_canvas2x across four quadrants,draw_canvas_from_rectsub-rectangle without bleeding, blit respecting multiply blend, PNG written then read back pixel by pixel; six PNG encoder cases arrived with the move. Five packagesmoon checkzero warnings, core 15/15, input 10/10, text 7/7, yoga-mbt 80/80.examples/native-windowsmoke: 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.mbtmoved wholesale into the newiconspackage: the 803-variantIconKind, thepath_num/fill_icon_pathinterpreter,draw_icon, theicon_path_datablock, and thekind_index/all_icons/icon_nametables. Only two kinds of change: the type reference@yue.Painter → @render.Painterand the header note. The data block matches the original line for line —diffreports only the 7 header lines and 2 type-reference lines. The declarative wrappersicon/icon_button_tdepend 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
fillfor 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_iconlands 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
seencounter 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,seenis non-zero while the text does not change. That is expected, not stray keystrokes. - Verification:
moon test -p NoahLiu/native-gui/icons4/4;moon check iconszero warnings;examples/native-windowsmoke: 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'sPangoWeight— plusTextAlign/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
textat the package boundary:draw_in_box/draw_font, not aPainter::draw_textmethod. 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'sp.draw_text(..)to@text.draw_in_box(p.bitmap, ..), a fixed-point rewrite under G7. - Pitfall, semantic level, caught by probe:
wrap=falsepreviously had no effect at all. Pango wraps with the defaultWRAP_WORDas soon aspango_layout_set_widthis 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_heighttouched only the MoonBit extern and the portable stub; the real implementation, in the Pango branch oftext/stub.c, still took 4 arguments. The extra argument landed in theout_hpointer 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 theimplicit_impl_as_methoddeprecation warning, becauseequal/not_equalare implicitly promoted to ordinary methods. Writingderive(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/text14/14 — 7 new:wrap=falsesingle-line natural width against thewrap=trueheight, ellipsis width clamped, bold not narrower than regular, in-box horizontal/vertical alignment landing points and centering,AttributedTextattribute surface and bounding box,new_with/formatround-trip.moon check textzero warnings.examples/native-windowsmoke: 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 — explicitflex-basis: 0plusmin-height: 0under 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 viadocument.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_basisinstead ofc.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 newmain_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. Themin: autocontent 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 thedisplay:nonecorpus 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:
Themewith a 20-field palette, thedefault_theme/dark_themepresets,theme_from_accentusing 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;*_lightmixes 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 — plustheme_current/on_theme_change/off_theme_change/theme_apply, 20 getters,control_height(32) andentry_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_allas 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/endbatch 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#AARRGGBBis 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),tis the share taken from the former, and the endpoints t=0/1 plus invalid input must return the input string unchanged without ahex6round-trip.hex6outputs lowercase hex, which would rewrite#2D68C4into#2d68c4and 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/theme7/7;moon check theme --deny-warnzero 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 inwin, the drawing contract inrender— 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'simportgained"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/topare offsets relative to the parent's border box.relayout/relayout_atfold 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'son_drawwrites local coordinates and never clips itself. - Propagation rule: from the hit widget upward along the ancestor chain, the first handler returning
trueconsumes the event — one rule for pointer, wheel and key. The host stores no parent pointers; bubbling uses the hit path,hit_pathor a path found by focus id, so re-mounting a subtree cannot leave a dangling parent reference. - Pitfall ① — a widget cannot support
==:Widgethas closure fields, soEqcannot be derived andOption[Widget] == Noneis a compile error reported on theimpl Eq for Optionconstraint. Approach: each widget carries an auto-incrementid; 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 theirlayout_*values from the previous round. Ifcollect_absdoes 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 ③ —
localis a reserved word:let local = ...triggersWarning [0035] reserved_keyword; this toolchain reserveslocalfor 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_drawpaints a block and itson_pointercounts presses only.xdotool mousemove --window <id> X 250plusclick 1, once each at window x 200/250/300/360, gives the exit lineNATIVE-WINDOW-OK band_h=1/1/1/1— exactly one press per cell, no wrong cell and no double count — withframes=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
Apphost, translatingwin's raw events into this layer'sPointerEvent/KeyEventand owning the present loop, lands with the declarative layer G7. - Verification:
moon test -p NoahLiu/native-gui/core22/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-warnall 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
sysper plan §2. The first asset is the zero-dependency JSON parser, moved wholesale from master'syue/vscjson.mbt. Public namesVscJson/vsc_json_parse/vsc_json_get/vsc_json_str/vsc_json_int64/vsc_hex_val/vsc_codepoint_atare 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_bookmarkscases. 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 literal9007199254740993.0itself lands on 2^53 in binary, so it passes; asserting "out of range rejected" needs magnitudes such as1e20. - Verification:
moon test -p NoahLiu/native-gui/sys7/7;moon check sys --deny-warnzero 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:
AttributedTextstores data only — text, format, base font/color, andArray[TextRange]. Each measure or draw builds a session in the C-sidert_*slot table,RT_MAX 16, and callsrt_freeimmediately 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 MoonBitBytespointer 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_textkeeps the range table per the old semantics. The public surface addsrange_count(), which the component layer uses to decide whether to take the rich-text path. - Three measured Pango-side facts: ①
pango_layout_insert_attrneeds 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 toPangoAttrListpluspango_layout_set_attributes, available since 1.44. ② Foreground color and foreground alpha are two separate attributes:pango_attr_foreground_alpha_newtakes a singleguint16, not RGBA four arguments, so a translucent range needsforeground_newandforeground_alpha_newinserted together. ③pango_attr_font_desc_newcopies the description itself, so the local one is released withpango_font_description_freeright 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/text20/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 afterset_text.moon check text --deny-warnzero warnings. The example's title recolours the leading 7 bytesMoonBit; 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, becauseinputalready depends oncoreand 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'scodecoming from the C-sideWmEv:0/1key press/release,2/3button press/release,4pointer motion,5wheel. Two data slots reach MoonBit named after C'sdx/dy: the button number viabutton(dx), the wheel's vertical delta viaaxis(dy, positive = scroll up); on wheel eventsdxis horizontal. Translation is isolated inraw_to_eventso 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 afocus_id; onDownthe 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 widgetgtk_im_context_resetis required; otherwise the composition accumulated in the previous field commits into the next one. Injectinga b Tab cmeasuredt2='第二个框岸边', where 岸 and 边 were clearly typed in the first field. AddedWindow::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 givest1='hello岸边' c1=11 t2='第二个框曾', each field keeping its own text. - Four toolchain notes: ①
loopis a reserved word, so App's scheduling field issched;localwas recorded in the previous batch. ②PointerKindhas closure fields and cannot deriveEq, so decisions usematch. ③FocusStack::focus(id)is a mutating setter; highlighting in the draw path must use the new read-onlyis_focused(id). The example originally calledfocus(id)insideon_draw, so every repaint gave focus to whichever widget drew it last — the exit line readfocus=3and 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 8Widget has no fielderrors 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 keywithout--window; with--windowit 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 intomousemove --windowfollowed by a separateclick 1, this round measuredclicks=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 withenv -u GTK_IM_MODULE -u XMODIFIERS, otherwise local fcitx5 double-pinyin turnsa binto 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 readingWidget::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/core28/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_cursorreachability and unreachable after hiding; a widget with noim_contextdeclaration not joining the input-method round;focusable_atreturning 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 exampleexamples/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=1with 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 typingzgivest1='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_gridall begin withmark_changed(self), which bumps a global clock and records it in the node'srev. A missed mark raises no error; it silently returns stale results, the hardest class of bug to find. ② Subtree version — at the entry ofcalculate_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" meanssubtree_rev == laid_rev, with no parent pointer: a parent pointer pluschildrenis 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_intorecords 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, neverlayout_width/height— the latter is rewritten by theround_to_gridpost-pass, so reusing it makes a gridded tree drift a little lower every round.round_to_gridstill 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 whencache_rev != subtree_rev. A change deep in a leaf invalidates its ancestors' intrinsic size caches, handled through subtree version rather than backtracking cleanup. Acache_slots = 16cap 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:steadywith repeated arguments 3652 → 4;one_leafchanging one leaf per round 3656 → 78;resizechanging 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 usemoonbitlang/core/env's millisecond clock, kept inside the bench package so the engine itself stays dependency-free. - Meaning for the host:
App::paint_framecallsroot.relayout(w, h)every frame, which is thesteadypath at 4µs per thousand nodes on a clean tree, so "relayout every frame" becomes viable in a GUI. Once G7 wires textset_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
snapcollided with the roundingsnapalready inalgorithm.mbt, reported astoplevel identifier declared twicerather than an ordinary duplicate-name note. ② The third clause of a C-style three-part loop accepts onlyi = i + 3;i += 3is a syntax error. ③ An empty arraylet a = []reportsunresolved_type_variableunder the zero-warning gate; write it out aslet a : Array[(Double, Double, Double, Double)] = []. ④ AUInt64duration passed through.to_double()into a function takingUInt64is blocked by the type gate, so the unit conversion goes inside the callee. One more rule from the same batch: the reparent case first assertedleft == 50from "new parent's left margin 50", while the actual value is 150, since inlinebox_ais 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-mbt91/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-warnandmoon check bench --deny-warnzero 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 aGStringseparated by\n, withg_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_availablewalks the cache line by line withg_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/monospacealways 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 CSSfont-familylist and returns""when none is available; an empty family name means "follow the system default" and is used for both measure and draw. ③Font::resolvefalls 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 intoFontMetrics;line_height_font/line_ascent_fontbecome 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 fromget_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
Stringordering is shortlex — length first, then code units — so<andcompareare not lexicographic:"Agency FB" < "Aharoni"is false and"b" < "aa"is true. UseString::lexical_comparefor lexicographic order andcompare_ignore_ascii_caseto ignore case. The family-table assertion hit exactly this: C had sorted lexicographically, yetlist[i-1] < list[i]failed across the board. ②Int::to_char()returnsChar?, sounsafe_to_charis required; interpolating the optional silently yields"Some('x')", with one deprecated warning at compile time and nothing else. ③ This toolchain'sBufferhas no usablepush/push_char; writing a character iswrite_char_utf8, and short strings are simpler with+=. ④Array[Byte]has nofreeze/to_fixed_arraypath toFixedArray[Byte]; when C needs it asBytes, start fromFixedArray::make(n, b'\x00')and write by index. ⑤@utf8's package path ismoonbitlang/core/encoding/utf8;moonbitlang/core/utf8reportsCannot find import. ⑥ MoonBit has no single-quoted strings —''is a lexer error. - Verification:
moon test -p NoahLiu/native-gui/text28/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_familytaking the first available and falling back to empty when none;Font::resolveandresolve_fontspreserving size, weight and style;font_metricssame-source as the two existing conventions with 28px ascent greater than 13px;line_height_multi's aggregation order-independent and idempotent; inAttributedText::line_heightcolor ranges not participating while size ranges do; line height covering drawn height.moon check text --deny-warnzero 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-appgains a font row — the same text in two boxes, with box heights taken from the base and the mixedline_height()respectively, the right box visibly taller: smoke exit code 0, status linefam=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.
MoonBit native GUI stack · Declarative foundation (Node/mount/Store/signals/relink on top of Widget)
- Only one thing changes: master's
Node { mount : (View) -> View }becomesNode { mount : (Widget) -> Widget }. The semantics — a constructor only builds the tree, andmountinstantiates 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_ofwrapping an existing widget as a node;node_of_newfor create plus a configuration callback, where the callback returns the widget so chaining further subtrees stays easy;mount_children; andApp::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_childputs 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 gainedNode::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 gainedWidget::insert_child/remove_child/index_of/move_child, keeping the widget table and node table in sync and identifying removal byWidget.id, since widgets with closure fields cannot compare with==. ② The old chain's two Windows workarounds are unnecessary: master'sbind_nodeusedpost_taskto defer one beat plus a whole-windowrefresh_layoutafter 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/endcollecting the current generation during mount, so dead subscriptions do not accumulate with rebuild count. A side benefit is that nestedbind_nodeis also correct — an innersig.subscriberegistered inside its ownmountlands in the outer bag and leaves when the outer generation does. - Repaint loop:
set_repaint_hook/invalidate()is a process-level single slot;App::newinstalls its ownschedule_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
Storemoved line for line from master'syue/signals.mbt/yue/store.mbtintocore/signal.mbt/store.mbt; both were already free of platform dependencies and the public namesSignal/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.mbtfiles in the same package share top-level helper names; a duplicate is not reported asdeclared twicebut makes the earlier definition unused — redefiningsizedinnode_wbtest.mbtimmediately madewidget_wbtest.mbt's copy reportUnused function 'sized'. Grep the package's existing helpers before writing tests instead of making a second one. ② Ifnode_of_new's configuration callback is declared to returnUnit, using a widget-returning helper such assized(w, ..)in the body is a direct type mismatch; returningWidgetboth drops thelet _ =and lets the callback build further subtrees. - Verification:
moon test -p NoahLiu/native-gui/core53/53 — 28 existing, 19 ported signal/Store, 6 declarative:mount_childrenorder and coordinates;bind_nodereplacing 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_nodepre-building two trees and toggling visibility only, with the hidden tree's rectangle zeroed;invalidatea 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-warnall zero warnings; repo-wide still 287 errors / 68 warnings. Real-window evidence:examples/native-appgains a declarative row where a click doesStore.updateand 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 — withclicks=1in the same round, the button hit still working after three relinks,t1='hellox' c1=6for 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:
Widgetgainedhovered/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 inon_draw. The old chain'shover_grouppolled, 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 synthesizedLeave. 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), andApponly stores and writes back the two states. Rules: theDownhit target is marked pressed and becomes the capture owner, after which everyMove/Upis delivered to the capture owner along its path to the root, with local coordinates folded as usual;Upclears 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 receivesUpagain. 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'ssetPointerCapture. - Callbacks must not capture the widget itself — the reference-cycle discipline:
on_draw's first parameter becameWidgetandim_cursornow takes aWidget, so a widget reads its ownrect/hovered/pressedfrom 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 withbind_nodeone 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:nonesibling 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" androute_pointerreturned 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 withxdotool mousemove --window <id> X2 Y2and release withxdotool 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/core59/59 — 6 new: hover following withLeavesynthesized; 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_idanddispatch_pointer_todelivering by identity with coordinates playing no part in the decision; wheel following the same bubbling rule. Eight packages--deny-warnall zero warnings; yoga-mbt 92/92; repo-wide still 287 errors / 68 warnings. Real-window evidence:examples/native-appgains a "capture band" whose draw state readsw.pressed/w.hovereddirectly — 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'sclicks=1and the declarative row'sdecl=2 kids=7/7unchanged 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@yueroot 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 gatemoon check <package> --deny-warnpermanently unexecutable, since the old files' errors would be reported together. The new component layer therefore starts a separateuipackage, 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 bothset_measure, taking size frommeasure_font, andset_baseline, taking it fromline_ascent_font, on the node. Text therefore takes part in a realalign-items: baselinelayout for the first time, and alabel's width and height are the true sizes Pango reports. Two assertions corrected by measurement: ① a column container's defaultalign-items: stretchpulls the label to the full row width — to observe the measured width the parent must useFlexStartor 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 axisSome(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 becameWidget, under the same reference-cycle discipline ason_draw. A button needsself.rectto 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: ①
typeinbutton(type="text")is a MoonBit reserved word and a syntax error as a parameter name; it becomeskind : ButtonKindwithPrimary/Border/Text. ② The disabled color scheme temporarily uses afill_hoverbase withtext_secondarytext; a real disabled palette joinsThemewith 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 oneinvalidate(). Per-widget subscription is deliberately not done —invalidateis global anyway, and per-widget subscriptions would leave one dead subscription per relink while buying no precision. Colors are read fresh insideon_drawrather 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'sbind_labelposition — 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/ui7/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-warnall 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-appgains a bottom row built entirely from@uiconstructors — label, vertical separator, icon, an加一button and a禁用button — and the window grows to 420×372. Injectingxdotool mousemove --window 135 278 click 1twice givescomp=2; clicking the disabled button next leaves the count unchanged withdecl=0, so disabled really does not consume events;kids=8/8keeps 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 fromBitmap.dataand drew glyphs onto it, a path that does not pass through the Painter's CTM and clip. The host's per-layertranslate + clip_recttherefore 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 gainsvoid *cr_session_context(int64_t id)returning that session'scairo_t*, and the text stub gainspt_draw_box_crandrt_draw_cr, which on the caller's context docairo_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 arePainter::cairo_id(),@text.draw_box_at / draw_at / draw_text_atandAttributedText::draw_at. The olderdraw_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::paintdidp.translate(self.rect.x, self.rect.y)layer by layer whilerectis 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 topaint_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: theNATIVE-APP-RECTSgeometry line, giving each root child'sx,y,wxh, from which injection coordinates are taken instead of guessing at a screenshot; andimport -window <id> x.pngwhenever the picture itself must be inspected. - Linking across static libraries: the text stub references symbols from the render stub, so
moon test -p .../gui/textfailed at link time withundefined reference to cr_session_context, invisible at compile time. Per AGENTS rule 1, link flags change only inscripts/prebuild.py: text'slink_flagsgained-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
disabledfixed into the closure at tree build, once a value reached the boundary the-/+buttons' disabled state stayed frozen at the moment of mount. Changed toEnabled { mut off : Bool }, read fresh in drawing and events; composite widgets — the checkbox row, the switch, both sides of the stepper — share one cell, andset_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'spressed, because the host'spressedis 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_totake 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-baseddispatch_pointercannot deliver a drag that started outside the widget; out-of-bounds motion must go throughroot.dispatch_pointer_to(w.id, ev), the equivalent of the host's capture-period delivery. - Seven toolchain writings: a
matchwith several subjects must be written as a tuple,match (a, b) { (Some(x), y) => ... };Double::clamptakes labelledmin=/max=;Int::to_char()returnsChar?and needsunsafe_to_char, otherwise it silently interpolates"Some('x')";String.splityieldsIter[StringView]andStringView::to_stringis deprecated in favour ofto_owned;Array[Byte]has nofreeze/to_fixed_array, so when C needs it asBytesstart fromFixedArray::make; a missing closing]inOption[(A, B) -> R]produces an unrelatedunexpected token infix; continuation lines cannot start with+or&&, since MoonBit breaks statements at line ends, andtypeis a reserved word that cannot be a parameter name. - Verification:
moon test -p .../gui/ui15/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=0after 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.text29/29; nine packages--deny-warnall 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 fromNATIVE-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=1for 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 viasubscribe_idplus 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 byApp::pump_ime; the component only suppliesim_context, committed text plus byte caret, andim_cursor, the candidate window's placement rectangle. - Focus highlight is now synced by the host: added
Widget.focusedandWidget::set_focused_by_focus(focus_id), withApp::paint_framesyncing once before each frame's drawing. Same reasoning ashovered/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
Downevents 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_bytecounts everything>= 0x80as 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组件框abselected the entire组件框aband the replacement leftQ. The ASCII side behaves normally — space-delimited, so in组件框ab ca double-click selects onlyc. 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
Moveextends the selection withselect_range(anchor, byte_at_x(x)), continuous even when the pointer leaves the field — the second consumer of the host's capture period.Upends the drag, and laterMoveevents 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/ui25/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 withfocusedsynced by the host. Nine packages--deny-warnall zero warnings; repo-wide back to 287 errors / 68 warnings, having briefly reached 69 from one unused variable in a test. Real-window injection, withexamples/native-appgaining an@ui.entryrow atfocus_id=4: click into the field → typeab c→ double-click at the end → typeQ→Home+Shift+End+Delete+Ctrl+Z→ status linet3='组件框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'scaret_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 enumSemanticType— Primary/Success/Warning/Danger/Info — withtype_color/type_light_colorkeeps the same names, and colors are read fromtheme'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
statisticcalledset_widthwith the measured width insideon_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_closefires 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/ui33/33 — 8 new display-family cases: five semantic colors pairwise distinct and all valid hex; label and badge sized by content showing99+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-warnall zero warnings; repo-wide still 287 errors / 68 warnings. Real-window evidence:examples/native-appgains a display-family block with a geometry line giving coordinates — clicking the alert outside the hot zone givesclosed=0, clicking the top-right twice givesclosed=2, with the component button'scomp=1andkids=11/11in 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.
MoonBit native GUI stack · Display family close-out (timeline + carousel, auto-advance on the host's delay channel)
- New channel: process-level delay —
set_timer_hooks/set_timeout/clear_timeoutincore/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 likeset_repaint_hook:App::newforwards both functions to its ownLoop,timer/cancel, and components only call@core.set_timeout(ms, f). With no host,set_timeoutreturns 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 asswap_node: not destroying a subtree leaves no dangling callback and needs no remount. ② The arrow's hover background reads the host-maintainedWidget.hovereddirectly, dropping master'son_mouse_enter/on_mouse_leave— interaction state is set centrally by the host and components only read it. ③ Hover-pause relies on theMove/Leavethis widget receives from the parent chain, since events bubble from the deepest hit widget upward and the firsttrueconsumes. 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 whycarousel_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_stopboth setsrunning=falseand callsclear_timeoutto cancel the pending beat. The test measured the boundary semantics: after stop, the beat already queued still runs once, but therunningguard makes it neither switch pages nor schedule again — so cancelling the handle cannot be the only safeguard and therunningflag 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 hasPrimaryin bothButtonKindandSemanticType— 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 ownrect— this example therefore added theNATIVE-APP-CAR/NATIVE-APP-AUTOgeometry lines, giving the arrows, the last indicator dot and the auto-advance panel directly. - Verification:
moon test -p NoahLiu/native-gui/ui37/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/core60/60 — 1 new: with no channel installedset_timeoutreturns 0 and does nothing, and once installedset_timeout/clear_timeoutforward as a pair while handle 0 forwards no cancellation. Nine packages--deny-warnall zero warnings,yoga-mbt92/92, repo-wide still 287 errors / 68 warnings, no increase. Real-window evidence:examples/native-appgains a "display family close-out" block with the window grown to 560×900 — injecting right arrow, then last indicator dot, then left arrow givescar=2, a unique index that proves both arrows and the indicator each worked; the baseline auto-advance readsauto=6; parking the pointer on the auto-advancing carousel without moving givesauto=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 inwinplus an extern inffi.mbtplus the public API inwindow.mbt, forwarded bycoreaslocal_ymd(). A calendar needs calendar time, while the stack's existingnow_ms()isCLOCK_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 usestime()pluslocaltime_rthrough the system time zone, packed asyear*10000 + month*100 + day. Failure and the portable stub return 0, whichcoreturns into(0,0,0), and only then does the component layer'stoday_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. todayis 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_pathtakes 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_gridwrites only each cell'sRef[DateYMD]/Ref[Bool]and then callsinvalidate(). - Selection read fresh from the Store, no cached copy:
state_ofreadsvalue.get()on every paint, falling back to the widget's ownselonly when there is no Store, so an externalv.set(...)replaces the solid square immediately with no "subscribe then refresh manually" step. Master'srefresh()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
Downconsumes only whenin_month, so a cross-month cell does not even take the press and the event keeps bubbling;Upadditionally 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::filterneedsfn(x) { x.1 }. Getting it wrong produces a cascade of unrelatedcannot infer/unbound/Parse errormessages. ② On this toolchain,derive(Eq, Show)on a value type hasShowdeprecated, pointing atDebug, whilederive(Eq)andderive(Debug)both raise theimplicit_impl_as_methoddeprecation 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/ui44/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-warnall zero warnings,yoga-mbt92/92, repo-wide still 287 errors / 68 warnings, no increase. Real-window evidence:examples/native-apprearranged 6b/6c into three columns with the window changed to 900×700 and the geometry line gainingNATIVE-APP-CAL— injecting right arrow, last indicator dot, left arrow, then "cell 14 → right arrow to November → the same cell again" gives the status linecar=2 auto=6 date='2026-11-15',kids=12/12, exit code 0. Screenshot review: three columns side by side, the calendar header2026 年 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_itembecomes "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, sincefollow_storeexists only to request a repaint. - Selection always through the Store, read at draw time:
segmented/breadcrumb/paginationall "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 mutablepagesthere 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 plusinvalidate()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 ownRef[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 assertionl1 != l3fails asfalse. 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/ui49/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-warnall zero warnings,yoga-mbt92/92, repo-wide still 287 errors / 68 warnings, no increase. Real-window evidence:examples/native-applaid this batch into the fourth column with the window at 1240×840 and the geometry line gainingNATIVE-APP-NAV, giving the four injection rectangles directly — injecting "segmented second item → breadcrumb second item → page 3 → second block's header row" givesseg='分段乙' crumb='列表' page=3 step=1 fold='11', wherefold='11'proves the second block opened without dragging the first along;kids=12/12in the same round, the earliercar/datereadings 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_rectcomposes a path insideuifrom four line segments plus four 90° arcs, with arc angles always increasing —Painter::arc'sccwparameter 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 atchildren()[2i]and the container atchildren()[2i+1]. A leaf row does not even consumeDownwhenhas_kidsis 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 byhead.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_testexplicitly in window coordinates rather than adding an offset to the widget's own rect. - Verification:
moon test -p NoahLiu/native-gui/ui53/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-warnall zero warnings,yoga-mbt92/92, repo-wide still 287 errors / 68 warnings, no increase. Real-window evidence:examples/native-appadding a fifth column with the window at 1620×840 and the geometry line gainingNATIVE-APP-NAV2with 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, givescar=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/discardbefore each rebuild so dead subscriptions did not accumulate with moves. In this stack a row reads only its ownRef[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.setdoes not check whether the value actually changed: an empty move, with an empty selection, still callsfrom.set(remain)plusto.set(merged), both Stores notify and both columns rebuild again. That isStore's existing semantics — no deduplication, seecore/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 aset.- 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, leavingxfer=3/1unchanged; moving the tree click last gavexfer=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/ui54/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-warnall zero warnings,examples/native-appzero warnings,yoga-mbt92/92, repo-wide still 287 errors / 68 warnings, no increase. Real-window evidence:examples/native-appadding the transfer to the fifth column with the window at 1620×900 andNATIVE-APP-NAV2gaining thexrow/xbtninjection 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: relativeplustop: -offsetmoves 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 fromcollect_absevery frame, so the component layer needs no second coordinate conversion. To get here, yoga-mbt gained relative-position offsets:top/lefttake priority, the other end of the same axis is ignored when both are given,autodoes not offset, percentages resolve against the parent's content box; absolutely positioned items do not take this offset, since theirtopis an inset measured from the padding box. All three are pinned inrelative_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_drawgets 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 measuredscr=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.hand the content child'srect.h, soScrollState.view_h/content_his refreshed each frame by the viewport'son_draw, and both the bar ratio and out-of-bounds clamping consume them. The cost is thatmax()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 mustpaintonce 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>, reportsInline wasm syntax error: unknown operator ?STRINGplusThe 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/ui58/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-mbt96/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-warnall zero warnings; repo-wide still 287 errors / 68 warnings, no increase. Real-window evidence:examples/native-appgaining a sixth column "scroll container" — a 190×120 viewport holding 20 rows of 22, geometry line gainingNATIVE-APP-SCR— wheel down three notches givingscr=90/320with the geometry line'srow0=1182,437, that is the content's first row moved up 90 = 527-90; dragging the bar from top to middle givingscr=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:nonewidget 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_menulisten 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'soverlay_alivebranch is a fallback for programmatic delivery or a removed layer.tooltip_tuses 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_hoverwas therefore changed to parent-chain semantics, with a parent counting as hovered while the pointer is on a child, matching CSS:hover, andon_hover_changewas 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_twas a GTK native tooltip viaffi_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_timeoutand doing nothing without a host. - Verification:
moon test -p NoahLiu/native-gui/ui62/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/core61/61 — 1 new: parent-chain hover semantics pluson_hover_changefiring on flips, with repeated setting not repeating the callback. Nine packages--deny-warnall zero warnings; repo-wide still 287 errors / 68 warnings, no increase. Real-window evidence:examples/native-appgaining a "first overlay batch" column with the geometry line gainingNATIVE-APP-OV— injecting "click dropdown → click item 2 → click popover → click outside → hover on the tooltip target" givespick=2 ov=1, whereov=1means 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
Noderesident in the page tree, toggled byset_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 usesoverlay_show_full, a wrapper with all insets 0, plus explicitwidth/height: 100%: relying on an inneralign-items: centerto 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 explicitmountform,{ 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 == 3walks 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 consumeDownregardless 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/ui67/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 perclose_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-warnall zero warnings; repo-wide still 287 errors / 68 warnings, no increase. Real-window evidence:examples/native-appadding a "second overlay batch" column to the second row withNATIVE-APP-OV2— injecting "click dialog → click confirm → click toast → right-click → click menu item" givesdlg=1 ctx=1 toast=1, withov=0at 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_atlayer, 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.mbtdirectly:build_calendar, all 42 cells built once with month changes rewriting data, andrange_state_of, solid endpoints with a light run between, are shared by all four pickers, sodate_range_pickeris 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 zonewidth-40..width-20; if open, dismiss; otherwise open the layer. Clearing does not fireon_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_numberfields 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 useentryand the upcomingtextarea_t. - Verification:
moon test -p NoahLiu/native-gui/ui71/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-warnall zero warnings; repo-wide still 287 errors / 68 warnings, no increase. Real-window evidence:examples/native-appadding a "date family pickers" column to the second row,NATIVE-APP-DP— injecting "click the date field → click the 1 October cell" givesdpk='2026-10-01'; "click the range field → 3 October → 7 October" givesdrg='2026-10-03~2026-10-07'; "click the time field → click + → click confirm" givestrg='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 withcolumn = -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 inon_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/ui75/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-warnall zero warnings; repo-wide still 287 errors / 68 warnings, no increase. Real-window evidence:examples/native-appgaining a table block,NATIVE-APP-TB— injecting "click the header twice, ascending then descending, then click row 2" givestsort=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, reportsThe type constructor Store expects 1 argument(s), but is here given NplusExpected 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 offirst × row heightwhenfirst > 0and 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, rowialways lands ati × row heightbelow the inner origin, that is exactly oneoffsetlower than correct, and the host clips to the widget's rectangle,clip_rectinwidget.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'sposition: relativeplustop: -offset, the same mechanism asscroll_view/sticky_list. The old form only looked fine whenoffsetwas 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
rectcomparisons. - The scrollbar extracted from
scroll_vfor reuse,scroll_bar(state): track and thumb ratio, track-click jumping, press-drag and stop-on-release are written once and composed by bothscroll_vandvirt_list. The extraction hit a "script-style extraction" pitfall: slicing by line numbers duplicated the setup code, since the range fromlet bartoappendalready contained it, and--deny-warnimmediately reportedunused 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 readingkids, so changing child widgets disturbs the iterator. Viewport height is therefore only copied into state inon_draw, and window rebuilds happen in event callbacks,ScrollState.apply, and in data subscriptions. Before the first frame has a real viewport height, the declaredheightis 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_hfrom 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 withtable_t— the header and rows were extracted fromtable_tintotable_header/table_row_widget, both using them, after whichtable_titself 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/ui78/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 astable_t. Nine packages--deny-warnall zero warnings; repo-wide still 287 errors / 68 warnings, no increase. Real-window evidence:examples/native-appgaining a virtual table block,NATIVE-APP-VT— injecting "wheel down three notches then click the list's middle" givesvt=1/1andNATIVE-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 ui133/133, including the new pin;moon test core ui render text theme icons sys input win dbus chart mdview web865/865;cd gui && moon check --deny-warnzero warnings. Items needing real-hardware confirmation, awaiting user evidence: the process pages ofmoon run examples/native-appandmoon 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_teach hold aRef[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 arev : Store[Int]that makesvirt_listremount 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
<= offsetintoStickyList.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 asscroll_v, and scrolling plus the scrollbar reuse the shared piece. - Verification:
moon test -p NoahLiu/native-gui/ui80/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-warnall zero warnings; repo-wide still 287 errors / 68 warnings, no increase. Real-window evidence:examples/native-appgaining a sticky list block and table dragging — pressing the header's 140px boundary and dragging right 80px giveshead0=24,521,200x40, total width 240 minus the minimum column width 40, bit-for-bit with the hand calculation; wheel down five notches givessticky='组二'withNATIVE-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, wroteset_boundsdirectly and double-wrotewidth/flexbasisstyles — 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 becomesfixed(px), owned by the user from then on; the two ends havemin_first/min_secondfloors clamped during dragging. TextRoleandmeasure_text_height: a role folds size plus color into one — Title/Section/Body/Secondary/Accent — androle_colorreads 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/ui85/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 atmax_widthwith 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-warnall zero warnings; repo-wide still 287 errors / 68 warnings, no increase. Real-window evidence:examples/native-appgaining a "split layout and text family" block withNATIVE-APP-SPL— pressing the handle and dragging right 100px givesfirst=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.
MoonBit native GUI stack · Form family, first batch of five (button_t/link/form_item/form/rate_t)
- 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 atoxreads the background and misreports "the focus ring was never drawn" — this batch's first case failed falsely exactly this way. Sample atox+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_pointerperforms hit testing first,rect.contains, so an event with x beyond the widget's width is never delivered to it and theclampinside 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 usedispatch_pointer_to(id, ev), which is how the host's pointer capture works. on_changefires only on interactive input: master attached the callback tovalue.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 useslabel_dyn(err.signal()): the Store's signal feeds the label directly, and text changes go throughrelabelfor an in-place re-measure with no subtree rebuild. - Button width convention differs from master: master measured text width manually via
get_bounds_forat mount and added+32to set the width, then centred horizontally itself during drawing. Text measurement here already feeds the layout engine,attach_text_measureplus 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/ui90/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-warnall zero warnings; repo-wide still 287 errors / 68 warnings, no increase. Real-window evidence:examples/native-appgaining a "remaining form family" block withNATIVE-APP-FORM— injecting "click solid button → click link → click danger button, writing the error text → click the 4th star" givesbtn=2 lnk=1 rate=4 ferr=1, withitem=24,1475,245x39bit-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_atincreases monotonically and is never reused, and dismissal on outside click is done by the layer, so the component's ownopen_idnever resets. Testingopen_id.val != 0therefore 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. Useoverlay_alive(open_id.val)for the open decision, which internally asks whetheroverlay_rect_of(id)still exists. The!= 0guard inclose_popstays harmless, since hiding an invalid id is a no-op.popover_t/dropdown_menuwere 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 byrect.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". Usew.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+8capped at 300, so many options never push the popup outside the window. Scroll geometry is still copied intoScrollStateat the first frame's draw, see the scroll-container entry, so headless cases mustrelayoutonce before clicking. container'spaddingapplies to left/top only: it followsWidget::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 callson_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/ui93/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-warnall 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 gainingNATIVE-APP-FORM sel= col=and status linesel= col= ovmax=— injecting "click dropdown field → click item 2 → click color field → click cell 2 → click dropdown ✕" givessel='选项乙/', selected then cleared,col='#2E9E5B', andovmax=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_idis 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 carryingfocus_idonly means focusable, andAppdoes not change focus on press if that id is not in the focus stack, so the example's new fields 21..24 had to callapp.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: declaringfocus_idputs it in the stack, viaWidget::focus_ids→FocusStack::sync, aligned once before Down and once before Tab, andregisteris 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 toxdotool windowactivate --sync WIDplusxdotool windowfocus WID, thenkey/typewithout--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, whileReturnpassed. 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 withfcitx5-remote -nand not using-t, which toggles. The multi-line field's keyboard semantics are therefore covered mainly by white-box cases withdispatch_keydelivered 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_xis 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) == 0x80reads out of bounds whenat == text length, and the symptom was the test driver reporting onlySIGABRT (core dumped)with no assertion visible in the stack. Added theat < nguard. - Do not use
j = hias 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/ui98/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-warnall 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 gainingta=and status lineta= tacur=, with the example content changed to 8 lines — injecting "click row 3 inside the field, window y=1644" givestacur=12, that row's start byte, bit-for-bit with the hand calculation; injectingReturngivesta='行 1/行 2//行 3/…'withtacur=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_groupneeds 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 readsWidget.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'sset_textonly marked dirty without relayout, so a longer label was truncated and had to be followed byupdate_layout. Here a text change goes throughrelabel, 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_windowprovides nocenter/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 makesApp::run()return, with cleanup after the caller'srun(). 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/progressare already declarative constructors inuireturning@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_scrollis simplyscroll_vhere, since the scrollbar has always been an absolutely positioned child of the viewport;gifgoes with the icon/animation batch;cursor_groupneeds 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 unboundplusParse error, unexpected token id. ②Option::is_none()is deprecated on this toolchain, pointing atx is None; do not write it into new code. ③ A match guard is notv when condbut MoonBit's| cond; for a simple two-way branch, falling back toif/elseis less trouble. - Verification:
moon test -p NoahLiu/native-gui/ui103/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-warnall zero warnings; repo-wide still 287 errors / 68 warnings, no increase. Real-window evidence:examples/native-appgaining a "declarative L1" block, geometry line gainingNATIVE-APP-DCand status linedcl= lblw=, with the window grown 1760→1900 because the page lengthened again — injecting "click the加一button inside the card" givesdcl=1 lblw=36/87, so one Store change moves both label text and width, withlabel=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" onMove, and only calls FFI,Window::set_cursor, when the shape differs from the one already sent. Taking "deepest non-zero" rather than "deepest" lets acursor_groupwrapping 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_grouphad 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_kindrecords the current shape, so repeated requests for the same shape do not create anotherGdkCursor, sincegdk_cursor_new_from_nameallocates each time and hover jitter would burn it. When the name resolves to nothing, a theme missing the glyph, it falls back todefault. - Two toolchain writings: ①
pub(all) enum PointerKinddoes not carryEqautomatically, sopev.kind == Movedoes not compile, reported asType PointerKind does not implement trait Eq; writematch pev.kind { Move => true _ => false }. ②pub(all) struct Rect's fields are likewise public, so tests readw.rect.xdirectly with no extra getter. - Verification:
moon test -p NoahLiu/native-gui/core62/62 — 1 newcursor_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/ui104/104 — 1 new: hand on button/themed button/hover group, text shape onentry/textarea, horizontal/vertical onhsplit/vsplithandles, andcursor_groupproviding an overridable shape while children stay default. Nine packages--deny-warnall zero warnings; repo-wide still 287 errors / 68 warnings, no increase. Real-window evidence:examples/native-appsampling the shape the host has sent once per beat, status line gainingcur=手/文本/横向/纵向— injecting "stop in order on the solid button → the multi-line field → the split handle → empty space" givescur=8/8/8/0, the first three shapes each sampled across 8 beats, with the vertical one at 0 because the example has novsplit; 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.cursoris a one-shotIntwritten at tree build, while disabled state in this stack is a cell,Enabled, the settled convention instate.mbt, since a boolean fixed at build time makes every laterset_disabledunable to move the frozen field.button()/icon_button_t()wroteCURSOR_HANDunconditionally, 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:Widgetgainedcursor_fn : Option[(Widget) -> Int], which overrides the staticcursorwhen present, andcursor_atnow evaluates per node through the new public methodWidget::cursor_request(). Returning 0 still means "this widget requests nothing", so the query continues up the ancestors andcursor_group's group semantics are unaffected.ui/state.mbtgained the private helperenabled_cursor(cell, on), returningCURSOR_DEFAULTwhen disabled andonwhen enabled. Changed:ui/basic.mbt'sbutton/icon_button_t,ui/form.mbt'scheckbox/switch_t/slider/button_t; the stepper's two sides go throughbutton()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:151asserts directly on a button's staticcursorand went red on the spot,0 != 1, found by the user runningmoon test core ui. Grepping only the implementation side would miss it, since the assertion reads.cursorand 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 addingcursor_request()was exactly to give assertions a semantically correct entry. - Verification:
moon test core ui243/243 — new: 1 core case, withcursor_fnthe computed result wins, overriding the staticcursor, 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 existingdeclarative_wbtestassertion 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, sinceentry'son_changereopens the overlay; Enter taking the highlighted item usesentry'son_enterhook; ↑/↓/Esc come from keys bubbling along the ancestor chain:entrydoes not consume those three function keys — the kernel'shandle_keyreacheskeysym_to_text, gets None,handled=false, and the host keeps asking upward — so the enclosingcombo.on_keyreceives 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'son_keyis that channel. - Open on release, same pattern as popover/dropdown:
entryconsumes the press, since it must place the caret, and the same click's release bubbles to the wrapping layer, wherecomboopens the layer onUp. 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 changehiand callinvalidate()without remounting the popup content; only a changed filter result reopens it,close()plusoverlay_show_at. - Overlay ids increase monotonically, and miscounting in a test crashes: every
overlay_show_atissues 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 givesoverlay_root_of(nonexistent id)a None, the test helper falls back to an empty widget, and the nextchildren()[0]goes out of bounds — the symptom is the test driver reporting onlySIGABRT (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/ui105/105 — 1 new: click the field opening the layer with the full candidate list →dispatch_commitof甲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-warnall zero warnings; repo-wide still 287 errors / 68 warnings, no increase. Real-window evidence:examples/native-app's form block addingcomboto the dropdown row, geometry line gainingcmb=, status linecmb='…', focus id 25 registered — injecting "click the editable combo field → click item 3" givescmb='组件丙', exit code 0. A second round of four clicks, "open → item 2 → click the field again → click the old row-3 position", ends atcmb='组件乙': 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)plusscale(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 anddraw_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 runartifacts unlinked too, previously recorded for the test exe only:build/librender_cairo_stub.awas 13:50 whilenative-app.exewas 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 thatmoon run's executable lives underbuild/, notexecbin/. Acceptance check: compare the.aand.exemtimes; "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 attand 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 theirdelay_mscadence. 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 givinggif=30frames, about 2.5 frames/second, matching the asset's per-frame durations. - Both
loopandstaticare MoonBit reserved words: the parameter nameloop~fails to parse, changed tolooping~, and the struct fieldstatictriggers areserved_keywordwarning, changed tois_static. - Sampling point for the icon button's hover background: a
fill_round_rectwith 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/render42/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), plusdraw_image_from_rect's sampling domain not overflowing.moon test -p .../gui/ui107/107 — 3 new: the animation component self-advancing by frame, pause breaking the chain,playresuming, 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.core62/62,yoga-mbt96/96, nine packages--deny-warnall zero warnings; repo-wide still 287 errors / 68 warnings, no increase. Real-window evidence:examples/native-appgaining an "icons and animation" block, geometry lineNATIVE-APP-MEDIA, status linegif=/ib=— injecting "click the icon button" givesib=1andgif=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:
themetakes only two reading functions as parameters,theme_from_system(sys_color_scheme, sys_accent), while the real probes are inwinand the polling plustheme_applyincore/app.mbt, asApp::follow_system_theme(interval_ms)returning a stop function. The theme package therefore stays platform-free, socoredepending on it forms no cycle, and the follow policy is testable headless with fake readers covering four states. Same reasoning aslocal_ymd: platform probes are forwarded through core, so examples and the integration layer need not importwin. - What is read is the desktop environment's gsettings, not a native widget stylesheet:
org.gnome.desktop.interface'scolor-schemefirst, counting only when the value contains dark or light, falling back to thegtk-themename, normalised and searched for dark/light; the accent readsorg.gnome.desktop.wm.preferences button-settingsif it holds a literal#rrggbb, oraccent-color, where GNOME returns names such as blue/teal and this layer maps them to hex.g_settings_schema_has_keymust come before any read: this machine's schema has noaccent-colorkey at all, and callingg_settings_get_stringdirectly 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-schemaslists 38org.gnome.desktop.*— withcolor-scheme='default', neither dark nor light, so it falls togtk-theme='Orchis-Teal-Light-Compact', which contains light after normalisation, reported as "light";accent-colorhas no such key so the accent is an empty string.theme_from_systemtherefore takes "light plus built-in accent", matching the base palette, and the real-window status line evidencessys='light/',fing=#2D68C4/#F5F6F7. Acceptance check: the follow loop callstheme_applyonly 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 inon_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=… — orhandle=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_bagbatch unsubscribe was long since implemented incore/signal.mbtand is covered repeatedly by the component batches, whole-generation retirement in rebuild-style lists. - Verification:
moon test -p NoahLiu/native-gui/theme8/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 matchestheme_from_accent, and aftertheme_apply_systemboth getters and the fingerprint follow the new snapshot.ui107/107,core62/62,render42/42,yoga-mbt96/96, nine packages--deny-warnall zero warnings; repo-wide still 287 errors / 68 warnings, no increase. Real-window evidence:examples/native-appstartingfollow_system_theme(interval_ms=1000)and stopping it at 3 seconds to verify the stop function's ownership — status line gainingsys='light/'andfing=#2D68C4/#F5F6F7,frames=118and 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 withgsettings set org.gnome.desktop.interface color-scheme prefer-darktemporarily; 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/chartsin place: the old directory references the@yueroot 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-warnzero warnings", which becomes impossible once mixed, for the same reason asyue/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.Nodecomponents. - 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 asdraw_donut, giving -1 inside the hole and outside the circle. Bar hit still uses master'sbar_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_handlewere madepubfor the chart package. They were package-private insideui, so the chart package would have had to copy subscription logic, andwatch_theme's "subscribe once per process only" is exactly the part that must not be duplicated. - Four toolchain writings measured in this batch: ①
pub constnames must be uppercase —pub const pireports "Expected upper case identifier for name of const, found lower case. Did you mean let?"; usepub letfor a lowercase name. ②for i = 1..<n { }is unsupported: a range is legal only infor x in a..<b, so the classic for isfor i = 1; i < n; i = i + 1. ③StringBuilder's write methods arewrite_string/write_char; there is noadd/append. ④ TheOrderingtype 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/chart11/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-warnall zero warnings; repo-wide still 287 errors / 68 warnings, no increase. Real-window evidence:examples/native-appgaining a charts block — line plus bar on one row, donut plus gauge plus scatter plus a push-point button on another, geometry lineNATIVE-APP-CH, status lineln=/chp=— injecting "click the push button three times" givesln=27/23 chp=3: window 24→27 points, last value 7→23, bit-for-bit with the hand-calculatedpushes*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
@traybusin master. In the new stack the two places became process-level injection slots:brightness_set_logind_probe(()->Bool)andbrightness_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 asci_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, andbluetooth, org.bluez, both use@traybus's general D-Bus property reads;diskuses@traybus.gdbus_call_systemin 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/sys41/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-warnzero 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
GtkFileChooserDialogwrapper lives inwin/gtk_stub.cunder thewm_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.mbtis 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, andscripts/prebuild.pyissues 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_uriraises SIGSEGV directly while its internal file system model is uninitialised. The stub adds awm_dlg_acceptedflag and permits queries only aftergtk_dialog_runreturns 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_newcallsgtk_init_checkfirst 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 testreported 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 comparingbuild/*.aagainst the test exe's mtime; delete both_build/native/debug/test/.../gui/sysand 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_urireturns a percent-encoded string and Chinese directories break (2026-10-16 user feedback on real hardware):wm_file_dialog_get_resultused to takegtk_file_chooser_get_uriand strip thefile://prefix by hand, so choosing the 下载 directory returned/home/lkyh/%E4%B8%8B%E8%BD%BD, and the example handing it tofsx_list_dirgotFsError::OpenFailed. Paths with spaces,#or%behave the same, and the save dialog's initial directory takes the same fall. Changed togtk_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. Thewm_dlg_acceptedguard from the previous item stays, since querying without running still segfaults. Verification:moon test ./sys187/187, with mtime checked per the "stub changed without relinking" entry —gui/build/libyue_win_stub.anewer thangui/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/sys30/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-warnzero 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.csplits by platform with#ifat the top, the Windows branch being a stub with the FindFirstFileW wide-character routing deferred to M2, andmoonbit_make_bytesis declaredexternand links the runtime symbol directly, as in ime_probe_stub. sys_getenvreads 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 landedsys_read/write_text_file,sys_remove_file, idempotent, andsys_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 threesys_*;@subproc, chensuiyi/subproc, is already a main-module dependency, andpr_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 replacingffi_getenv_bytes's outputokRef assertion pattern with theOption-returningsys_getenv, the leftoverinspect(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/sys29/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 anda=brejected. 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_runwith a real subprocess round trip —echo, non-zero exit with stderr captured, timeout kill, the/bin/shscript path.moon check sys --deny-warnzero 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/markdowndepends on the@yueroot package, the error backlog; the new stack follows the "one folder one package" convention atmdview, withmizchi/markdown@0.8.3already 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_linkcallback: master's rich-text row click called@yue.open_urldirectly. In the new stack open_url belongs to the system-capability line, the desktop integration family still to be ported, somarkdown_viewgained anon_link? : (String) -> Unitparameter threaded throughMDCtxinto 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); writingctx.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_drawcompares@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/vboxhere has only four-sided padding and no padding_bottom parameter, so block spacing is handed uniformly to the outermostvbox'srow_gap, with individual blocks' bottom space vianode.set_margin_bottom(@yoga.fixed(...)). - Verification:
moon test -p NoahLiu/native-gui/mdview26/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 strippingfile://, and the table alignment mapping rewritten asispattern 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-warnzero 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_wordare same-source and unmodified from master'scomponents_text.mbt, since the toolchain writings were already the new convention.code_viewchanged 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=0in this batch while a 6-character Chinese string gavegreen=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/ui110/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-warnzero 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::parsereads 8 digits as RGBA, alpha last.eff_alpha_colorfeeding 8-digit input straight to@theme.parse_hextreated the alpha byte as red —#ff009688parsing 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_timerhad no cancel id and stopping relied on the callback returning false. This stack's@core.set_timeout/clear_timeoutreturn real handles, soEffAnimholdsrunning + timerandeff_stopcancels the pending callback immediately — the same continuation pattern as carousel auto-advance, where the callback schedules the next beat. - GeoJSON parsing goes through
@sys, withvsc_json_parsealready ported there, andRectFbecame a localGeoRect, shaped like treemap'sTmRectwith each defined on its own.argb_hexsat in the old chain's root package among the errors, so the chart package implements it locally withInt::unsafe_to_charassembling hex, noting thatChar::from_intis deprecated. - Three test lessons, all hit in this batch: ①
ink_ofon 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 ordiff_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/chart189/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-warnzero 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_textwith conditional alignment, and the component layer's mount blocks. pictorialbar's 4 places omitted thewrap=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_atform asdraw_donut_core. - Same-field structs in one package make a literal ambiguous as "Multiple possible record types":
SunItem/TreeItem/TmItemall have the shape{name, value, children}, so a literal cannot disambiguate — go throughmakepluschildren.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)andGraphEdge::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'svalue? : Double?is a double Option, so a number must be wrapped asSome(v). - graph's layout cache moved onto the disposal bag: the old chain cleared the cache inside
subscribeplusschedule_paint; herefollow_storehandles repaint only, and cache invalidation has its ownsubscribe_idregistered withsub_bag_register, the same pattern as gauge_t. - Verification:
moon test -p NoahLiu/native-gui/chart146/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-warnzero 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_textwith a conditional align, and the component layer's mount blocks.draw_text'salign=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
RectFbecame a localTmRect: the old chain reused the root package'sRectFwith x/y/width/height, while this stack's coreRectuses w/h field names and means a layout rectangle. The chart package therefore carries its own same-shapeTmRect, 15 replacements, taking on no cross-semantics dependency.from_hslis available in@theme.palette. tree_chart_t'sorientation : Stringbecamevertical : Bool: the old chain's string parameter, "horizontal"/"vertical", was a historical API shape; component parameters in this stack are always typed, aligning withbar_chart_t'shorizontal?.- TreeItem/TmItem must be constructed across packages through
make: apub(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 uniformlyTreeItem::make(name, value=…)pluschildren.push(…), since children is an array field with mutable contents. - Verification:
moon test -p NoahLiu/native-gui/chart98/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-warnzero 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 -1346clamped 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.mbtbecameboxplot.mbt/funnel.mbt/heatmap.mbt/radar.mbt— pure-function layer verbatim, drawing layer switched to@render.Painter/@text.draw_box_atwith colors read live from@theme, component layer switched to thenode_of_newform. The four wbtest files came across correspondingly, each with a component mounting producing pixels assertion:ink_of > N, plusdiff_of > 50after 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'sAttributedText"no data" was uniformly replaced with the fixed-box@text.draw_box_atform, same asdraw.mbt. - All dependencies already in the new stack:
mix_hex/lighten_hex/parse_hex/rgb_to_hslare in@theme.palette, andbar_slot/draw_hgrid/series_color/fmt_axis/truncate_toare reused from this package.mut_view().sort_by(fn(a,b){ -1/0/1 })works on this toolchain, an Int-returning comparator, unrelated to theOrderingunavailability pitfall. - Verification:
moon test -p NoahLiu/native-gui/chart64/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-warnzero warnings; repo-wide still 287 errors / 68 warnings. Real-window evidence:examples/native-appgaining a "specialised charts" block, window grown to 1400×2300, geometry lineNATIVE-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 -windowcaptures 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.mbtwere ported same-source intotooltip.mbt— theCiTipoverlay state with out-of-bounds-safe placement,CiPlotdrawing area, nearest-pointci_nearest_idxand sectorci_sector_athit, theci_tooltipinteractive canvas component;interactive.mbt— the fullCiZoomwindow math,CiVisualMapcolor bands,ci_mark_line/ci_mark_area,ci_saveoffscreen export, legend hit and visibility bit helpers, theci_legendcomponent; andcharts_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_atread live. master fixed (name, color string) into a Store, so a theme change left the whole palette stale and required rebuilding items viaon_theme_change.ci_legendhere takescolor_at : (Int) -> Stringand reads aton_draw— series colors, donut colors and bar positive/negative colors all come from the theme live — so a theme switch through the process-levelwatch_themerepaint yields new colors automatically, and theon_theme_changerebuild 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], unlikeon_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 byon_draw, which does have the widget parameter, and read byon_scrollas 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_tooltipgained anon_leavecallback: hiding the overlay clears onlyCiTip, but the caller's own hover state — the polyline's guide line and landing point, the sector's outward expansion — is drawing state beyondhit, 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 passon_leave=fn(){ hover.val = -1 }.- Drag capture is no longer managed by the component: master's
set_capture/release_captureare structurally unnecessary here, since the host'sroute_pointermakes the Down hit target the capture owner automatically, and every laterMove/Upgoes to it, so a release is never lost when the pointer leaves. draw_donutsplit to exposedraw_donut_core, sectors plus centre summary with colors through acolor_atparameter: the interactive version's legend isci_legend, clickable and visibility-toggleable, so it cannot reuse the originaldraw_donut's embedded legend, anddraw_donutitself became a thin shell, core plus embedded legend.ci_sector_at, general sector hit with a thickness parameter, coexists with the simplifieddonut_hitwritten 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.mbtbeing a pure implementation with no platform shim: master'sci_savealways 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/tmpand assertsOk, andci_save_chart's offscreenPaintermust callend()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/5produces button4/5 events at the X level, received byxevand 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'swm_on_scroll. The wheel-zoom component chain is covered by the white-boxdispatch_scrolldirect call, injectingdy:1→ narrower window → pixels change, and real-hardware wheel feel and appearance are left to the user. Programmatic wheel injection would need uinput;/dev/uinputexists while ydotool and evemu are not installed. - Triple coordinate alignment for real-hardware screenshots, a pitfall on 10px targets: ①
xdotool mousemove --windowgives client-relative coordinates, the same system as GTK event coordinates, so injection needs no conversion. ②import -windowon 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 viagetmouselocation, before concluding "not drawn". Likewise, injecting into a window taller than the screen requiresxdotool windowmoveto 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/chart37/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,CiPlotfractional 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_chartsize 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 bydispatch_scrolldirect 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-warnall zero warnings,yoga-mbt96/96, repo-wide still 287 errors / 68 warnings, no increase. Real-window evidence:examples/native-appgaining an "interactive charts" block, window grown to 1400×2080, geometry lineNATIVE-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 throughWM_CHAR/Unicode, macOS throughcharactersIgnoringModifiers, 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_modsinwin/stub.c, which must fold on the C side and cannot call back into MoonBit, and it is bit-for-bit identical withgtk_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'skVK_Delete(51) is Backspace andkVK_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/core78/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 WinMK_*and macbuttonNumberinto 1 left, 2 middle, 3 right; the routing table's six rules; the trigger conditions for reset and surrounding.moon check core --deny-warnzero 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 tablewm_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 throughoverlay_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 toApp, and the host holdingAppwould be a reference cycle. The popup table is a process-levelMap[handle, App], andoverlay_hide/overlay_clearplus 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_requestis the window's minimum content size, set from the initial size at creation, so callinggtk_window_resizewithout changing it cannot shrink below the initial value and a measured overlay would keep a dead margin; ②set_resizable(FALSE)makes programmaticgtk_window_resizeignored entirely — a borderless window cannot be dragged by the user anyway, so setting TRUE has no side effect. Both are handled together insidewm_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 switchoverlay_set_dismiss_on_blur(on)stays and can be enabled once the check exists. - A new event constructor hits a "frozen" baseline page: adding
LostFocusto@win.Eventtook repo errors from 287 to 288, becauseexamples/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/core81/81 — 3 new: with no child surface going the layer tier whileoverlay_show_at/hide/countbehave 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/winand four examples--deny-warnzero 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)plusset_type_hint(GDK_WINDOW_TYPE_HINT_POPUP_MENU)plusset_transient_for(owner)plusset_skip_taskbar_hint/skip_pager_hint(TRUE)plusset_keep_above(TRUE), withset_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_positionalways 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, measuredowner_origin=2,34with 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_popupgoes through batch A's registry, so it shares the loop and the pump naturally,close_window()detaches only itself without affecting the host, andApp::shutdowntears both down. Example evidence: after picking a menu item,xdotool search --name '^popup$'returns 0 while the main window remains, withNATIVE-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/core71/71 — 1 new placement regression covering seven boundary cases.moon check win/coreand three examples--deny-warnzero 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'saudio_player.mbt, 416 lines, andvideo_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 newyue/mediapackage 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/srcwrote../ffmpeg-mbt/src/testdata/. The new package must writemodules/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, whilerender'sBitmapis internally CairoARGB32in 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 inrgba_to_bitmap, with the wbtest asserting10 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 withFixedArray::make(n, b'\x00')written by index plusBytes::from_array. AlsoString::splityields aStringViewiterator, needingto_owned()for aString;replacetakes labelled parameters,old=/new=; the constructor form isStringBuilder(), sinceBuffer::newdoes not exist; and an unused parameter such asymust be written_y. - Layering between UI and engine:
videolives inyue/media, importing@core/@render/@text/@themeinside the package, and not inui— otherwise every button-only application would link ffmpeg and miniaudio. The engine filesaudio.mbt/video.mbt/subtitle.mbtstay 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(), formerlyvp_tick, opened as the publictickin this batch withframes_decoded()added, is driven by a self-continuing@core.set_timeout(33, ...), not advanced inside drawing. Each tick calls the observation hookon_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 whenframes_decoded()changes, since rebuilding an Image every frame would repeatedly create cairo surfaces. - Seek debouncing made into a partition pure function:
player_bar_hitsplits 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'smakefails it draws an explanation panel and hands the reason out throughon_error, probing plus degrade with an explicitUnsupported, and the example'serr=''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_volumebut no control was drawn; frame stepping. - Verification:
moon test -p NoahLiu/native-gui/yue/media18/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-warnzero 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 playingav_1s.mp4plus 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 and0: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_focusran once when the window showed, after which the WebKit child took focus on click, and aGtkDrawingAreadoes 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] inand 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 callsrequest_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-linetextareadoes 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_resetinterrupts 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
printlnis 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", andstdbuf -oLwas 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] inappears 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阿宝.core82/82,ui110/110,input10/10,web15/15 all green, per-package--deny-warnzero 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 ofApp::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 — askingWidget::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
Apprequired 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 andApp::newwere affected. - Verification:
moon test ./core149/149, with the newkey_fallthrough_wbtest.mbtadding 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-warnzero 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_presentpointedw->pendingstraight at the MoonBit-sideBitmapbuffer, 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 — inApp::paint_frameit 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 —
WmWindowgainedbacking/backing_cap,wm_presentgoes throughwm_store_frame, reallocating on demand plus one memcpy, withpendingpointing at its own buffer, andwm_closereleases 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 andyue/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-webrelinked, withbuild/libyue_win_stub.arebuilt. 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_urifailing theWEBKIT_IS_WEB_VIEW(webView)assertion, immediately followed bygtk_widget_get_parent/gtk_widget_destroyfailingGTK_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. SoWebview'shandlestill held the old slot number andpumpingstayed true: the scheduled pump and the example's status polling kept callingwb_get_title/wb_is_loadingwith that handle, while the stub'swb_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. Finallywb_destroy, thecloseat exit, used it once more forgtk_widget_get_parent/destroy, producing those twoGTK_IS_WIDGETlines. - 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 andwm_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. Everywb_getpasses this gate, so all engine queries and actions become no-ops automatically after the view dies, with no per-function check to add;wb_destroyuses the same gate — with no container it onlyg_freeplusmemsetto return resources and never touches the pointer. MoonBit gained the publicWebview::is_alive(), and the pump's beat, finding the view gone, setspumping=false,handle=0Land dropseval_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 != 0Lon 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'swm_windows[]is memset cell by cell after teardown, and every slot access was counted — anything touchingw->window/w->areaalready null-checks and returns, the rest read only counters such asev_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 ownclose; a different shape, not in scope. - Verification:
moon test -p NoahLiu/native-gui/web17/17 — 1 new: with a non-zero handle but no view,is_alive()is false, and afterretire_if_deadcloses the gap,handle=0/pumping=false/eval_waiterscleared.weband the example--deny-warnzero 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 readsreg=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-mbtlocated only the video and audio streams, twoav_find_best_streamcalls invf_open, and subtitle packets were treated as "not a stream I want" in the decode loop and dropped withav_packet_unref. This batch adds a third:VfFmt.sstreamplus a separateVfSDec,vf_sub_open/vf_sub_next/vf_sub_pts_s/vf_sub_end_s/vf_sub_close, and on the MoonBit sidesubtitle_cues(path)reading the whole track at once intoArray[SubCue]. - A separate container read is required: if the subtitle decoder shared the
AVFormatContextwith video and audio, both would advance the sameav_read_framecursor, so after reading subtitles the video would start from the middle or hit EOF immediately.subtitle_cuestherefore 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_timealways 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.6wrong against0.2..0.6 / 0.6..1.0as the asset intends. Fix: take whichever is shorter between the packet duration and end_display_time, with the packet duration read beforeav_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->textand 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 numericReadOrder, 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 toyue/media'sstrip_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'ssubtitleis 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 existingon_tick/on_speed, and the example writes both values into the exit line,NATIVE-WEB-MEDIA ... subs=2 emb=1andNATIVE-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 throughon_errorinstead of silently returning an empty table. - Verification:
moon test -p NoahLiu/ffmpeg-mbt/src6/6 — 2 new:subtitle_ass_textstripping the prefix plus three "should not strip" negatives; the real containerav_1s_subs.mp4read 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/media24/24 — 2 new: embedded track converted toCuewith transport and cleaning conventions aligned; end-to-end hit by position.yue/media/web/core/ui/ffmpeg-mbtandexamples/native-web--deny-warnzero warnings; repo-wide still 287 errors / 68 warnings. The test asset is a 13KB three-stream clip kept in the repository —av_1s.mp4stream-copied plus two mov_text tracks, made withffmpeg -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 readingsubs=0without 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_streamchoice; 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/speedbefore 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 >= targetonly 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_speedreads the current position first, then sets the speed, assigns that position tobase_pts/pos_baseand the current wall time towall0/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 inAudioPlayer::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.25can 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:
videogainedon_speed(v), shaped likeon_tick/on_error, and the example writes the step into the exit lineNATIVE-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-mbtto 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/media22/22 — 3 new: the speed ladder with wrap-around and negative indices plus short labels;media_clamp_speedand the threevp_target_ptsformulas;media_resample_s16sample counts at 2x and 0.5x, interpolation midpoints, stereo channels not crossed, and the three pass-through cases. The real-decode case gained aget_speedclamping round trip, and the control bar partition test moved to six segments.moon check yue/media/./examples/native-web--deny-warnzero 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 theon_speedhook 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.5xwith full progress, the volume bar and0: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:
seekclears the current frame toNonewaiting for the next tick, and while pausedtick()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 asvp_settle, and stepping = pause first → move the position by1/fps→seek→settle. All acceptance checks live in the headless case: after steppingframes_decoded()increases,current()advances,current_frame()is non-empty, andis_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: beloww < 400the 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,VideoPlayergainedget_volume;AudioPlayerhad 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-mbtto expose the subtitle stream first; real audio output re-verification. - Verification:
moon test -p NoahLiu/native-gui/yue/media19/19 — 1 new frame-stepping case:n=0not moving; after stepping, frame count, position, having a frame and still paused all holding; both ends clamping;get_volumereturning 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-warnzero 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, witherr=''meaning no degradation. After clicking fullscreen,xdotool getwindowgeometrygivesPosition: 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, plus0: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 elseapplication/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_dirswas composed from the existing subprocess channel,pr_run("mkdir", ["-p", path]), withdefaultapps_wbtestalready doing the same in this repository. The cost is that it holds only wherepr_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 nomkdir, sofsx_wbtest.mbt:109had been red on Windows since the day it shipped. It is now a thin FFI,yue_sys_make_dirs,CreateDirectoryWlevel by level on Windows andmkdir(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: nopr_available()requirement, and no fork plus two temp files per call. CreateDirectoryWdoes 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 returnsERROR_ACCESS_DENIED(5)rather thanERROR_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:GetFileAttributesWvalid means this level exists and is skipped, with 183 tolerated only for a creation race. POSIX has no such trap —mkdiron an existing directory isEEXIST.- 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-schemedid not exist at all and bothsys_write_text_filecalls failed. The fix issys_make_dirs, but the acceptance check must be split first: the example's exit line gained afiles=count — registration succeeded / load issued / files actually present — because without it one is guessing which link broke. - Two fine-grained navigation reasons: ①
WEBKIT_LOAD_REDIRECTEDemits kind 15, since callers must distinguish "address changed" from "reloading". ② The load-failed payload is structured asuri|domain|code|message, withdomaingiven as a name throughg_quark_to_string, and MoonBit'snav_error_parsesplitting it intoNavError. Error text may contain|, so the first three segments are taken positionally and the remainder all belongs to message; too few segments givesNonerather than guessing fields. - One seam defect only the real window could catch:
WebEventhasLoadFailed, but the kind table inWebview::dispatchnever listed 14, so every failure arrived asUnknown(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_parsesplitting four segments. - Pages installed with
load_htmldo not enter forward/back history (measured on the real window; do not read this as back being broken): the bridge page was installed withload_html, then a click on a nonexistent domain — the example'sa.example, since.exampleis an RFC 2606 reserved domain and DNS must fail — readcan_back=0 can_fwd=0withgo_back()a no-op. But the root cause is not "a failed navigation cleared history" — testing the four steps separately makes it clear:A failed navigation still leaves a back-stack state; that reading was 0 only because the bridge page's history had nothing besides theafter-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 backload_htmlentry. Cross-platform consistent: WebView2's documentation forNavigateToStringstates plainly "the back or forward history will not be modified", the same shape as the Linux measurement; Apple makes no promise for macOS'sloadHTMLString: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后退 → urlor后退:无处可退(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 andload_url, notload_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 andunregister_protocolstill replacing with an error handler. The list is sealed at view creation bywebview_seal_schemes(); an empty list fills in the default nameapp, so zero configuration works. Linux's dynamic capability stays in the stub, not exposed. Verification:web16/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 windowNATIVE-WEB-SCHEME reg=1 loads=1 files=2, the example callingwebview_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_protocoljudged onhandle != 0L, so anything before mount returned false — the caller had to schedule its own wait.load_htmlalready hadpending_html, so one matter had two rules.pending_protoswas added:(scheme, root)queued before mount and applied bymount_atright after the view is built;unregister_protocolfirst 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 theload_htmlfallback 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_ofreturned baretext/htmlfor.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 returnscharset=utf-8for 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:web16/16, andwebplusexamples/native-web--deny-warnzero warnings; the real-window screenshot shows the protocol page's Chinese correct, and the event area's后退 → yuedemo://local/bridge.htmlproves the protocol page is in history and back really works — control: the same page installed withload_htmlhad back as a no-op — with the exit linenavs=5 … dom_ok=1 text_bytes=68 … last='标题 本地桥接页'. - The protocol channel supplies MIME only, so text types must carry
charset=utf-8themselves (mojibake caught in the real window): the same bridge page HTML was fine throughload_htmland became whole-page桥接页throughyuedemo://. Root cause: the two channels differ in encoding basis —webkit_web_view_load_htmltakes UTF-8 text as its input, so the engine decodes as UTF-8, while a protocol reply passes only a content type, and baretext/htmlwithout 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_ofreturns...; charset=utf-8for 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_protocolreturned false while unmounted. The bridge window in the example took exactly this path: right afterwin.mount, registration returned false, it fell back toload_html, and the page's back button was permanently a no-op. Now it shares the shape withpending_html: unmounted means recorded inpending_protos, applied one by one bymount_atafter the view is built, andunregister_protocolcan 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/web16/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/sys164/164 — 1 newsys_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-warnzero 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='本地协议页'plusNATIVE-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, sostyle.cssalso came overyuedemo://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 byfinish_errorverbatim — on screen the single linescheme 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 theload_htmlversion 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_eventyields the kind,wb_event_num/wb_event_textread 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_atis translating and clipping layer by layer). So mounting starts a self-rescheduling@core.set_timeout(30, ...)pump, andclosestops it. load_htmlmust 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 callingload_htmlright afterwin.mount(...)found handle 0 and the page area stayed atabout:blank. The acceptance check:navs=1with only about:blank in the events and every capture empty. Same fix asload_url: recordpending_htmland apply it inmount_atright 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 repliedSyntaxError: Unexpected token '}'. Marking thatok=falsein the parser is correct (no silent success), but the generator needs a lock: a single wrapperdom_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-policyenum has noWEBKIT_POLICY_DECISION_TYPE_PERMISSION_REQUEST(only NAVIGATION_ACTION / NEW_WINDOW_ACTION / RESPONSE), so permissions must come from the"permission-request"signal; andwebkit_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_lookupbefore 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_finishreturns aGList, not aGSList, whose elements areSoupCookie; ② from 2.40webkit_web_view_evaluate_javascript_finishhands back aJSCValue*directly (only the oldrun_javascript_finishgave aWebKitJavascriptResult*), andjsc_value_to_stringon 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_dirinwb_creategoes throughwebkit_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 alwaysPersistenton 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 withimport -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), withcurrent_urlas an alias ofget_url.remove_binding/remove_all_user_scriptsalways 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.openis 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/web11/11 (9 new headless: capture-script shape and brace balance; structured parsing of all four JSON variants; parse failure keepsok=falseplus the raw text; every call safe while unmounted withcapture_dom/add_raw_binding/result-carrying eval returning false/-1; kind→WebEventmapping item by item; JS results paired by sequence and delivered exactly once; invoke on both paths (on_invokeplus theon_eventside channel); persistence probe consistent with engine availability).moon check weband./examples/native-webclean 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 aload_htmllocal 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→pageexecute_javascriptreally 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
GtkFixedholding "drawing area + positionable children", not aGtkOverlay: an overlay needspass_throughto 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).GtkFixedis 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_requeston the drawing area from the fixed'ssize-allocate, gated by "only when the size actually changed" -set_size_requesttriggers 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}, andwm_close(window)clears every surface belonging to that window. Otherwise the window is torn down first and the capability layer (the webkit stub) still callsgtk_fixed_putwith a danglingGtkWidget*- segfault. Surface slots are recycled the same way as the window slots in batch A (scan forcontainer == NULL). - Pointers travel C-to-C, MoonBit only carries integers: win exports
wm_surface_container(int64) -> GtkWidget*andweb/webkit_stub.cdeclares itextern(same executable, static link). That keepsweb's MoonBit layer from importingwin'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.pyprobeswebkit2gtk-4.1; found -> compile with-DWB_HAVE_WEBKITand hand-lwebkit2gtk-4.1toweb; not found -> compile the macro-less stub implementation (wb_available()returns 0). Either way thewb_*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_webbefore-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. ChildKindis a register, not an open set: onlyWebview = 1today. 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
webheadless 2/2 (rounding and dedup key; probe/constructor agreement with zero handles before mounting;closesafe when never mounted);moon check win/web/./examples/native-webclean 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 givesNATIVE-WEB-STAT supported=1 loading=0 title='Example Domain' url='https://example.com/'withNATIVE-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" yieldsloads=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 - theLoop(one process-wide time base, built once byhost_loop()) and the platform pump (@win.wait/wakewere already process-level). The registry only answers "which windows exist, which is current, does closing one quit the host";App::stepnow drains events per window, pumps the IME per window and paints the dirty ones, always iterating ahost_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::shutdowndestroys every window still registered (secondaries may be open); handles already recycled to -1 makewm_closeidempotent. - 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()) plusoverlay_ids()/overlay_top_id(). The symptom was not a compile error but a test crash: a dozen call sites inuihardcodedoverlay_root_of(1), and once ids accumulated the lookup returnedNoneandchildren()[0]aborted withArray::atout of bounds (SIGABRT). Rule: handles are allocated at run time - use the return value ortop_id(), never a literal. raiseis a MoonBit keyword:pub fn OverlayHost::raise(...)does not report "reserved word"; the whole function header fails to parse and the errors cascade intoThe value identifier self is unboundplusunexpected token if, you may expect pub|fn|..., which looks like a broken body. Renamed toraise_layer; the tell is that the first error points at the header line.- The GTK stub must recycle window slots:
wm_window_newused to onlywm_next_id++, so opening and closing secondary windows would exhaust the 32 slots (andApp::open_windowwould silently return None). It now takes the first slot witharea == NULL-areais always set at creation andwm_closememsets 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 (doesxdotool 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/core70/70 (8 new headless registry tests:host_loopreturning the same Loop - proved withpending()sincestarted()cannot prove identity; primary/secondary bookkeeping and active fallback; the three closing verdicts;close_windowidempotence and a secondary not stopping the primary;invalidatebroadcasting; overlays landing on the current window with independent stacks and handles per window;mountraising only its own layer;shutdowndestroying all windows and resetting the registry).ui110/110,chart189/189,mdview26/26,render42/42,text29/29,input10/10,theme8/8,sys163/163,dbus64/64,yoga-mbt96/96 all green;moon check ./examples/native-appand./examples/native-sysclean 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=101and the geometry lineNATIVE-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", withxdotool 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 getsPrimaryOwner(a repeated claim yields the idempotentAlreadyOwner); a second instance that getsAlreadyExists/InQueueinstead calls the first instance'sorg.moonbitlibyue.Instance.Wake('as' argv)and exits. The first instance answers the Wake before firing its callback (is_wake_callis checked first inbus.mbt's inbound branch - the second instance'scall_synchas 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 "已有实例,已唤起并退出" withexit=0in the second, and "收到第二实例的唤起" in the first. - The brightness write path is wired one way only:
dbusdepends onsys(environment, subprocesses, sysfs reads) and not vice versa, sobridge.mbtinjects the logind capability into sys's two slots at startup (brightness_set_logind_probefor the probe,brightness_set_actionfor the(subsystem, name, value) -> error stringaction) and sys never imports D-Bus. Measured here: after injection@sys.brightness_supported()flips from the fake default to true (logind_available=true), whilebrightness_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/dbus64/64 (2 new: the brightness gate matching logind's availability after injection, and a repeated same-process name claim yieldingAlreadyOwner);moon check ./examples/native-sys --deny-warnclean; real-window evidence as above (pool=:1.483, second instanceexit=0). Left for the user on a real machine:brightness_set_percentactually changing the screen needs a machine with a backlight (this host is a desktop with no/sys/class/backlightdevice).
MoonBit native GUI stack · SNI tray and DBusMenu (dbus: sni + icon + instance + filemanager)
- Migration surface: the old chain's
traybusfilessni.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) andfilemanager.mbt(98, FileManager1ShowItems) ported line by line intodbus. The tray hooks previously stripped frombus.mbtcame back in the same batch: theConn.items/menusregistries, re-registration of every item after aNameOwnerChanged(arg0=watcher)watcher restart, carrying tray items along during reconnect migration, and the three-way inbound dispatch (single-instanceWakereplies first and fires after -> a path hittingitems/menusgoes to SNI'shandle_call-> theCallReqfallback last). This is the "foundation strips semantics, capability modules put them back" way of closing out. - Tests came along,
inspectbecameassert:sni_wbtest(212) /instance_wbtest(93) /filemanager_wbtest(24), with 40-odd oldinspect(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'sRegisteredStatusNotifierItemswent from 9 to 10 entries (the extra one being this process's item), and it is unregistered symmetrically before exit; thesni_wbtestlive 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, orNameHasOwnerreturning 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 acall_syncpump; after several rounds a single xfce4-panel query wedges and subsequent registrations time out. The acceptance check is "a re-run is clean, anddbus-sendstill listsRegisteredStatusNotifierItems"; 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/dbus62/62 (12 new: sni 6 / instance 4 / file manager 2), three consecutive runs with zero skips;moon check ./examples/native-sys --deny-warnclean,moon buildpasses; real-window evidencepool=:1.479, five-button geometry linetray=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
traybusfilesupower(197 lines) /logind(105) /netmon(191) /screensaver(94) /inhibit(146), plusyue/system/media.mbt(707 lines, MPRIS), ported line by line intodbus(package prefixes swapped only; media'spr_run/pr_availablenow point at@sys). Their tests came along in the same batch, and every oldinspect(x, content=…)becameassert_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_statushooked@yue.set_timerdirectly (its callback returned a Bool to decide whether to keep ticking); the new stack gives it aschedule : (Int, () -> Unit) -> Unitparameter (the GUI layer passes@core.set_timeout, headless tests pass a fake channel that collects the callbacks) and re-arms internally viaarm()- the same layering reason asclipboard_watchand 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_capsusesGetCapabilities(no side effects; the capability list is not translated into an enum, the raw entries stay available), whilenotify_send/notify_closeuseNotify/CloseNotification. Progress is not a method of its own - per the freedesktop spec it means re-sendingNotifywithreplaces_idset to the same notification and a standardvaluehint (INT32, 0..100; this layer clamps above 100 and treats negatives as "no progress"), which desktop daemons render as a progress bar;notify_progresshas exactly that shape, and the argument order plus hint shape are pinned by the pure functionnf_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-sysgained 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_delayedwrapped aroundapp.timer, plusdbus_pump()every 200 ms). A full run ends withpool=: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/dbus50/50 (27 new: UPower 4 / logind 2 / network 3 / screensaver 2 / MPRIS 12 / notifications 4),moon check ./examples/native-sys --deny-warnclean. 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) anddbus.mbt(419 lines, theGDBusValuegeneric call layer) are ported line by line (onlysys_f64_to_bits/from_bitsgain new FFI names), whilebus.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/menusfields, the watcher special case, the inboundWakebranch) - those come back with their own capability modules. - The biggest layering difference: no glib fd watch. The old chain used
g_unix_fd_addto hang the bus fd on the GTK main loop and libyue'sPostDelayedTaskfor reconnect backoff; this stack bans glib and does not depend on the GUI layer either, so there are two injection points instead: the host callsdbus_pump()periodically (defaulttimeout_ms=0, a non-blocking poll, fine on every frame/tick), and reconnect backoff goes throughdbus_set_post_delayed(fn(ms, f){...}). With neither injected, synchronous calls still work -call_syncandgdbus_call_*carry their ownpump_untilpump (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(toleratingEINPROGRESS) /read/write/ single-fdpoll/close/getuid/ Double bit patterns (memcpyreinterpretation guarded by astatic_asserton 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_CREDENTIALSat connect time and the server readsSO_PEERCRED; the client only sendsAUTH EXTERNAL <hex(uid)>(the old shim has nosendmsgeither - 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/Msgin order to reply; the new package exposesCallReq{bus, sender, path, iface, action, args, reply, reply_error}, wherereplyderives its signature from the argument values (gdbus_args_wire), and signal subscribers receive decodedGDBusValuearrays 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=givesUnexpected '=' in struct expression; ② a function-typed struct field is not a method -req.reply_error(...)fails withType 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/dbus23/23 - session handshake succeeds (local unique name:1.392),ListNamesreally returns and containsorg.freedesktop.DBus, an unknown-method call really yields the D-Bus error name and text, signalAddMatchreally registers,dbus_pumpidles without blocking or declaring death, and the system busListNamesreturns 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.Notificationsandset_progress), UPower/logind/NetworkManager/screensaver/MPRIS monitors, single instance, and the real logindSetBrightnessinjection forbrightness.
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) andNATIVE-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 onlyapp.mount([...])withoutapp.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-appstarts withroot.set_column(); a new page must do the same, and a panel row that really is horizontal needs an explicitdirection=@yoga.Row.- An injection count lower than the click count is not a lost event: one re-probe re-reads whole databases (Chrome's
Historyis 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 giveprobes=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 andsysnever 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 showsClipboardError::Unsupported; that is not a page defect. - Verification:
moon check ./examples/native-sys --deny-warnclean,moon build ./examples/native-syspasses;sys163/163,core62/62,ui110/110,chart189/189,mdview26/26,render42/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/tmpin 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
ftellalone (a real bug fixed this batch; it affected every read that went through this stub): pseudo-files under/proc/meminfo,/proc/uptimeand/sys/class/dmi/id/*report 0 bytes forlseek(END)- their content only appears when actually read. The old shape (fseek(END)→ftell→ allocate that size →fread) hit exactly this: pseudo-files returnedOk("")(not an error), so callers saw a parse failure and blamed the data format.yue_sys_read_text_filenow uses ftell only as a hint and loopsfreadto 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:
windowctlparseswmctrl -l -p(list),-i -a(activate by id) and-c(close by title);clipboard_watchpollsxclip -selection clipboard -oread-only - a missing xclip surfaces as exit code 127 and is normalised toUnsupported(matching "tool absent" rather than "command failed");sysinfoonly reads/etc/os-release,/proc/meminfo,/proc/uptimeand/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_watchhooked libyue'sset_timerdirectly (no cancel id; it stops via a parking flag).sysdoes not import the GUI core layer, so scheduling became a parameterschedule : (Int, () -> Unit) -> Unit; the GUI layer passesfn(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 (thetimedatectlhalf is bus-free). - Verification:
moon test -p NoahLiu/native-gui/sys163/163 (36 new: sysinfo 14 including two live/procassertions; windowctl 14 with a realwmctrl -l -plist of 8 windows; clipboard_watch 8 covering fake-channel tick dispatch, stop preventing the next arm, and the missing-xclip normalisation).sysclean 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/moonsqlitefileis already inmoon.mod'simport, yet using@moonsqlitefile.*fromsysfailed withPackage "moonsqlitefile" not found in the loaded packages- a module-level import does not substitute for a package-level one, sosys/moon.pkgneeds"prowk/moonsqlitefile" @moonsqlitefile. The old-chainyue/moon.pkghas 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 assys_read_text_file(yue_sys_read_text_filealready slurps the file intoBytes; the difference is only whether MoonBit decodes it). The text version cannot feed SQLite: decoding withdecode_lossyand 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
Historyuses microseconds since 1601-01-01 UTC (constant11644473600000,<=0means unknown → 0, sub-microsecond truncated); bookmarkdate_addeduses 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 MBHistoryis really read, two profiles are hit, returned timestamps land in 2026, the ordering invariant (time descending, ties by url ascending) holds, and thedownloadstable reads; VS Code'sstate.vscdbplus recents run end to end; this host has no Firefox (~/.mozilla/firefox/*/places.sqliteabsent) so it takes the legitimate skip branch. Same discipline as the previous batch: a detected database that fails to read mustfail;Err(_) => ()is only allowed when the host genuinely has no such data. - Verification:
moon test -p NoahLiu/native-gui/sys127/127 (41 new: browser history 12, Firefox 10, bookmarks 9, VS Code 10, each combining pure-function assertions with a live smoke).sysclean 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 offbookmarkplusbookmark: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%XXgroups as UTF-8, while100%/%E/%zzstay 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 theLsuffix (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 aprintlnplus an explicit failing assertion.- A smoke case's error branch must not skip silently: the old
rf_recent/rf_by_appshape wasErr(_) => ()("no data on this host, so skip"), which also hides the real regression of "the file exists but cannot be read". It is nowErr(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_declarationwalks to-->); when a bookmark carries twomime:mime-typenodes the first wins; an XBEL truncated mid-write only collects the already-closed entries. - Verification:
moon test -p NoahLiu/native-gui/sys86/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_takebounds,rf_data_dir's three states, and two live smoke reads).sysclean 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_blobwas 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 onlyPATH/XDG_RUNTIME_DIR/HOMEplusLC_ALL=C);xrandr --queryexits 1 withoutDISPLAY, andxfconf-query/gsettingscannot start withoutDBUS_SESSION_BUS_ADDRESS. After passing throughDISPLAY/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 throughpr_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-imageis 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 themonitorNone-1fallback 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 pointsXDG_CONFIG_HOMEat/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/ParseFailedcollide betweenWallpaperError/MonitorError/VolumeError/FileManagerError/AppFindError; writing them bare in a test gives 4124 (The constructor ... is ambiguous), so tests must useWallpaperError::ParseFailed(...). Inside the implementation filesmatch self { Unsupported => ... }infers the type and needs no change. ② A payload-free enum does not getShoweither:println("\{de}")does not compile (Type DesktopEnv does not implement trait Show), so diagnostics go through the dedicateddesktop_name(). - Verification:
moon test -p NoahLiu/native-gui/sys73/73 (14 new: wallpaper 9 - the three-pass selection over this host's real xfconf property list, themonitorNone-1fallback, gsettings/KDE output parsing and argument construction, JS single-quote escaping, text helpers, argument guards andShowtexts, 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 temporaryXDG_CONFIG_HOME, and the live xdg-mime probe).sysclean under--deny-warn; whole-repo backlog still 287 errors / 68 warnings, unchanged.wp_setis 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 assys/launcher.mbt+sys/appfind.mbt+sys/desktop.mbt. The desktop detection is carried as its own pure functions insidesysrather 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 seconddetect_desktop. - Detached spawn gets its own stub: master used the shim's
g_spawn_async, whilesys/sys_stub.conly uses standard headers, so this isfork → setsid → fork → execvpplus a CLOEXEC pipe reporting back whether exec succeeded. Not going through [pr_run] and waiting is deliberate:xdg-openusually stays alive after handing the window off, so waiting would pin the calling thread; the double fork also guarantees no zombie (this functionwaitpids 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 whenexecvpfailed → the intermediate child read EOF (all write ends closed) and called it a success → "command does not exist" came back asOk. 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 towrite("E"). Okfromopen_url/reveal_in_file_managermeans 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
revealis an acknowledged gap: master first asks the FileManager1 bus forShowItemsand 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).ShowItemscomes with the DBus line. - appfind's ordering keeps master's
mut_view().sort_byshape: in this toolchain a comparator that returns the-1/0/1literals works fine - the earlier "Orderingis undefined" note only applies when the type nameOrdering(orOrdering::Less) is written out explicitly, not to a comparator returning Int. Ordering semantics unchanged: comparenameby codepoint, thenid;appfind_str_cmpcompares 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 ofsys_stub.cis still a stub implementation, andopen_url/reveal/appfind_*returnUnsupportedoff 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
500u64does not parse (unexpected token id (lowercase start)) - convert the monotonic-clock difference with.to_int()before comparing; ② a payload-freepub(all) enumdoes not getEqeither (the earlier note was aboutPointerKindwith a closure field; this extends it to plain enums) -desktop_detect() == beforewill not compile, so compare withmatchor viadesktop_name(). - Verification:
moon test -p NoahLiu/native-gui/sys59/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 liveappfind_installedscan of this host, andappfind_executablehitting/usr/bin/shplus argument guards; open-external 5 -parent_dir/relative-path normalisation, the real cwd, detached spawn's success/failure/non-blocking cases (sleep 3returns in under 500 ms), and the two error enums'Showplus the platform gate).core62/62,ui110/110,render42/42,mdview26/26,theme8/8; twelve packages clean under--deny-warn; whole-repo backlog still 287 errors / 68 warnings, unchanged. Left for real-machine re-verification (observable onceexamples/native-syslands): whetheropen_urlreally raises the default browser, whetherreveal_in_file_manageropens the parent directory window, and the app count reported byappfind_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::AddNotificationnever 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_DISPLAYguard 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 sessionXAUTHORITY=/run/user/1000/.mutter-Xwaylandauth.*andWAYLAND_DISPLAY=wayland-0; both needDBUS_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; useset_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
/MenuBarunconditionally, and the menus registry (menu object path → Item) collided on that key — creating a second tray item didc.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, andConn::next_item_pathnow 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_reconnectused 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 intoConn::adopt_saved(bus), called by connect_session/connect_system whenever any new connection is established (on-demand rebuild and self-healing share the path), andtry_reconnectfirst 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), andItem::unregisteralso 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 viasys_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_nameclears 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::propertycallsdownscale_pixmap(0, 0, …)to build the extra 16×16 entry — the old clamp guardif y*h/nh > h-1 { h-1 }is true when h=0 (0 > -1), clamping sy to -1, andpx[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_pixmapearly-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 mainpixmap_valueentry 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 test659 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
freadand a doubling buffer (16MB cap); opening a directory succeeds butfreadfails with EISDIR — detect viaferror. Implemented in the app's own native stubexamples/sysmonitor/stub/sysmon.c; the MoonBit side goes throughread_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 haveProcessor/Hardware— three-level fallback; core count = number ofprocessorlines (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;
MemAvailableonly exists on kernels ≥ 3.14 — fall back toMemFree; used = total − available (includes reclaimable cache). - The
moon runwrapper 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_v2collides 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 popennvidia-smibatch 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 / nouveaugpu_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/nameto 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_pidsrequests 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 registryCentralProcessor\0ProcessorNameString/~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 whosepid_*_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.
- CPU: /proc/stat from GetSystemTimes (global; kernel includes idle and must be subtracted) plus ntdll
- 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
platformexplicitly 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 hasPDH_RAW_COUNTER.FirstValueas a LONGLONG (no.largeValuesuffix; the anonymous-union shape belongs toPDH_FMT_COUNTERVALUE's FmtValue) — SYS1's minimal stub header had been written to match the code, so-fsyntax-onlypassed and the CI Windows runner would necessarily fail. SYS2 corrected it toRawValue.FirstValueand 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-onlywarnings does not mean it will really compile. - SYS2 verification: no Windows host here, so the Windows branch passes
gcc -fsyntax-only -Wall -Wextraat zero warnings against a minimal Win32 stub header defined to the real SDK shape (not committed); after touching the stub,moon build examples/sysmonitoris warning-free (sysmon.o really recompiles);moon checkzero warnings +moon test608 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).
- 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
- 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 -jsonsubprocess 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 withvm_deallocate(mach_task_self_())(one block per tick at 1Hz, and leaks accumulate). Model frommachdep.cpu.brand_string(Apple Silicon implements it too) → fallbackhw.model; clock fromhw.cpufrequency(Hz→MHz, absent on some machines = 0); core count fromhw.ncpu. - Memory:
hw.memsizefor total;host_statistics64(HOST_VM_INFO64)for free / inactive, available = free + inactive (Activity Monitor's rule); swap via sysctlvm.swapusage(thexsw_usagelayout 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_fsgains the BSD pseudo filesystems (devfs / fdesc / nullfs / volfs / union / synth; autofs is shared) andis_system_mountgains 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\040per kernel convention and the MoonBit side restores them withunescape_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/diskstatsno longer fails the whole tick (the old early return blanked the entire disk page),DiskInfo.read_bps/write_bpschange from Double toDouble?, 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_datapointing atstruct 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 andPROC_PIDTASKINFOfor thread count and nanosecond timings (÷1e7 → 10ms tick, withyue_sysmon_clk_tckhard-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 -jsonsubprocess — 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_profilerto 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 andparse_system_profiler_gpuparses it in MoonBit (splitting_itemsby brace depth while ignoring braces inside strings, model fromsppci_modelfalling back to_name, VRAM fromspdisplays_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/hwmonnaturally 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_listmacOS branch only calls whenon_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_INFOis 2 not 1,MNT_WAITis 1 not 2,MAXCOMLENis 16, the 64-bitstatfshasf_mntonnamebeforef_mntfromname, andif_data64's counters areifi_ibytes/ifi_obytes(there is noifi_oobytes) — writing these wrong from memory never fails to compile (your declaration becomes the truth), and-fsyntax-onlyonly 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_getreturns a UInt16 (a UTF-16 code unit), not a Char: while scanning JSON / escaped strings character by character,c == '\\'does not typecheck — either compare viato_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
.ofiles with future mtimes as current, sotouch-ing sources does not trigger a rebuild — whenmoon buildreports "up to date" after a stub change, delete the corresponding.ounder_buildfirst. - SYS3 verification: no macOS machine in this environment. On the Linux host
moon checkis warning-free andmoon test614 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 passesgcc -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/sysmonitoris 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.
- Platform split: the stub has three platform structures (
- 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*_inputand amdgpu'sgpu_busy_percentare 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) > sysfsgpu_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, parsedrm-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'sDRIVER=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 fromgpu_list()toGpuMonitor::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-idsince v5.19 (amdgpu / i915 "Convert to common fdinfo format v5", 2022-05-26; absent in v5.18, present in v5.19);drm-pdevsince v6.5 (thedev_is_pcibranch ofdrm_show_fdinfoindrm_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/1permission 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 byhas_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 annssuffix, side keys carry units (drm-memory-vram: 1024 KiB), and amdgpu prints an extra space after the colon indrm-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 hasif (!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 checkis warning-free andmoon test623 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.ounder_build,moon build examples/sysmonitoris 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 againstintel_gpu_topin the same window ② AMD dGPU sysfsgpu_busy_percentversus 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 againstsudo intel_gpu_top⑦ the scan's effect on the 1Hz duty cycle.
- Version boundaries (checked against the torvalds/linux source version by version):
- 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 testdebug 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 byiter2'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 checkis warning-free andmoon test628 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.ounder_build,moon build examples/sysmonitoris 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 withlspci -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]/statis signed (-20..19), but the parser usedparse_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) useparse_u64, signed fields (nice/ppid/threads) use the existingparse_i64, with field indices and reading rules unchanged. ②ProcMonitor::tick's previous sample usedself.prev.get(pid).unwrap_or(0): for a pid that appeared between samples (a new process, or a child spawned bymoon testitself) 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 measured400 != 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 functionproc_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");ticknow passesself.prev.get(pid). With a previous sample present it dispatches to the existingproc_cpu_pctand behavior is unchanged.- Pitfall: both
parse_u64andparse_i64collapse failures intounwrap_or(0), soNoneand "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 genericnum(). - 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-warnwarning-free andmoon test644 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.ounder_build,moon build examples/sysmonitoris warning-free; white-box control — stashing the pre-fix code and runningmoon test -p sysmonitormakes the negative-nice case fail precisely (0 != -20) and restoring the oldproc_tick_cpu_pctsemantics 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.
- Pitfall: both
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@printsVolume: 0.42 [MUTED], and the sink display name comes fromwpctl 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 pinLC_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 inUser/History/<hash>/entries.jsonis 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_withtakes 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_executabledecides "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 defaultfor 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 hintsvaluefield (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/systemprobeand 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:
| Capability | Primary route | Fallback route | Probe (read-only, no side effects) |
|---|---|---|---|
| Night colour temperature nightlight | redshift -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 monitor | xrandr --query, fully parsed (refresh rates as integer centihertz) | none (Unavailable → Unsupported) | same single read-only --query probe |
| Wallpaper | XFCE 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 windowctl | wmctrl -l list / -i -a <id> / -ic <id> | none (non-Linux or missing wmctrl → Unsupported) | a missing command surfaces as 127 → Unsupported |
| Clipboard watch clipboard_watch | xclip -selection clipboard -o (read-only polling) | none | same; a read failure (not installed / no text in the selection) silently skips that tick |
| Power profile powerprofile | powerprofilesctl list / get / set | none | missing command → PpUnsupported; list output keeps the backend order (a recommended profile listed first stays first) |
| Printer printer | lpstat -p / -d / -o <printer> + lp [-d printer] -n copies file | none | lpstat missing (CUPS not installed) → PrtUnsupported |
| Disk volumes disk | udisks2 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.wavloads, 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 offersopen_wal_database, which needs the -wal file handed to the same Database. - Tables and columns:
moz_placesis the only table needed, takingurl(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) andhidden(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.inisupplies thePath=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
Errwith 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;AddChildis 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
UpdateChildBoundsmisses GTK's first size-allocate (it happens before map): layout must run unconditionally. Slider::SetValuesets the ignore flag even for identical values, swallowing the user's first callback: set it only when the value actually changes.ProgressBar::SetValuemeans 0..100 on both Linux and Windows; the yue layer is unified on 0..1, so the conversion branch must cover both platforms.View::GetBoundsInScreenstacks coordinates wrongly under Scroll / nested containers (patchpatch_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_positionincludes 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=16explicitly 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_urirejects 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_enteris 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::SetTextswallowson_text_change: the GTK side guards with anis-editingobject-data flag that filters thechangedsignal 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 invokeson_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_beginsynchronously 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 withoutlibnvidia-egl-gbm, GLVND only has the X11 backend (10_nvidia.jsonhas 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) setsWEBKIT_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: baremoon run examples/showcaseopens its window, stays alive, zero aborts. Root fix at the system level: installlibnvidia-egl-gbm(NVIDIA's GBM EGL backend), after whichWEBKIT_DISABLE_DMABUF_RENDERER=0restores hardware rendering. Engineering note: after changing the shim, moon does not necessarily relink (the exe isn't in its dependency graph) — runmoon cleanor delete the exe to force a relink and confirm withnm 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.ashows 46 undefinedwebkit_*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_RENDERERacross the repo returns nothing (it lived in the deleted shim'sapp_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.sopresent, RTX 3070), and a C probe that creates no window — with the variable unset — survivedwebkit_web_view_new()plusload_uri("about:blank")plus about two seconds of main-loop iterations (PROBE ALIVE, exit 0), so thesetenvwas 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 installinglibnvidia-egl-gbm. - The overlay-scrollbar default needs a process-environment guard: the
GTK_OVERLAY_SCROLLINGenvironment variable outranks gsettings inside GtkSettings (distro scripts / users exporting=0to force classic scrollbars is a common practice), and GTK re-reads the process environment every time a ScrolledWindow is created — writing thegtk-overlay-scrollingGtkSettings property during theme init alone cannot beat the env-var path. app_init (Linux) callssetenv("GTK_OVERLAY_SCROLLING","1",1)before gtk_init (overwrite=1: the library default andset_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 gainsyue_mbt_scroll_refresh_content_size(on Linux it writesgtk_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), plusScroll::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_textgrowing content after mount (systemprobe's system-capability report, dozens of text lines) andmount_intoattaching 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 callingrefresh_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::SetTextonly 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; addingset_visible(false)→set_visible(true)before refresh rendered all 16 lines. The toggle works because the shim'syue_mbt_view_set_visibleperforms a root-levelLayout()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 gainsreport_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 checkzero warnings +moon test657 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, whosepango_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_sizewrites ①'s wrong value (92) into the viewport, the adjustment upper never moves andget_max_scroll_position_ystays 0. Mechanism corroboration: in the same probe,set_content_size(600,1000)gavemax=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 gainsmeasure_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_setbecomes 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'syue_mbt_scroll_get_max_position_x/ynow readsupper − page_sizestraight fromgtk_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 Windowsscroll()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_accentsteps through three): ① GNOME 47+'s GSettingsorg.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_stringaborts instead of returning empty, so probe withg_settings_schema_has_keyfirst (abort reproduced with a probe on this machine); ②@define-color theme_selected_bg_colorin 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/themesundergtk-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,PlatformInstallMouseMoveEventsnever 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: thecursor_groupcomponent shares hover_group's global poll tick — while the pointer is inside the group bounds, the shim'syue_mbt_view_set_cursor_deeprecursively 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/flexbasismeasured) never lands — a rootupdate_layoutcall, 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 addsyue_mbt_view_set_bounds(a straight pass-through toView::SetBounds) plus a genericset_view_boundson 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
selectablechannel 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), sogtk_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>::getand 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: shimyue_mbt_quitwalks 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'sLabel::SetTextonly doesYGNodeMarkDirty+queue_draw(a pure repaint, no layout); ② even when someone callsLayout()(a full yoga recompute whose new deep-node values are computed), delivery of the bounds depends on each container'sUpdateChildBounds, and the root only SetBounds its direct children; ③ GTK short-circuits asize_allocatefor 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'sContainer::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'syue_mbt_view_layoutwalked to the root and calledroot->Layout()itself, which bypassed exactly that self-heal. Fix (shim): extractlayout_root_down— afterLayout()on the root, forceUpdateChildBounds()on every Container along the root → start-point path (idempotent delivery; the visibility guard on non-Linux platforms stays as is), andyue_mbt_view_refreshswitches 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;IsRootYGNodedecides by "no YG parent"), and only itsUpdateChildBoundstriggers 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 threeset_textsites inbind_label's subscription callback, stat_card's number (num) and form_item's validation text (err) now callupdate_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.ais 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 onlyWebView2.h(capital W); prepare.py adds a lowercase alias after extraction. On the same volumeshutil.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_APMRESUMEmacro does not exist (resume only ever sends the unconditionalPBT_APMRESUMEAUTOMATIC, plusPBT_APMRESUMESUSPENDafterwards 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 thePBT_*constants into winuser.h and there is no standalonepbt.hat all — explicitly including it fails with C1083;WTSRegisterSessionNotification/NOTIFY_FOR_THIS_SESSIONare declared inwtsapi32.hwhile theWTS_SESSION_LOCKmessage 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.hwith__linux__; MSVC needs_USE_MATH_DEFINESforM_PI;base::FilePathisstd::wstringunder UNICODE builds — go throughFromUTF8Unsafe / AsUTF8Unsafe; Windows has no Popover and noSetOverlayScrollbar/Clipboard::Selection/Tray::SetTitleetc., all degraded to no-ops in the shim;operator new/deleteredirect tomalloc/free(moon builds its runtime with MOONBIT_ALLOCATOR=SYSTEM); a widget's HWND comes fromdynamic_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 isWS_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.
Link flags (moon → cl / link)
cc-link-flagsis spliced verbatim into the cl command line; GNU-style-L/-ltriggers 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, socmake --buildmust 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.ccarries#pragma comment(linker, "/SUBSYSTEM:WINDOWS")plus/ENTRY:mainCRTStartupunder#if defined(_WIN32) && defined(_MSC_VER), and sits ingui/win/moon.pkg'snative-stublist next tostub.c. The pragma lands in the object's.drectvesection; moon compiles native-stub C sources straight into the exe build (the cl command line listswin_gui.c), but the linker only honours.drectveof archive members it actually extracts — so the file also exportswm_win_gui_marker, referenced bywin/window.mbt'sinit_backend()asignore(wm_win_gui_marker()). That reference is the extraction anchor, andcore/app.mbt's app path callsinit_backend(), so consumers get GUI(2) with zero configuration./ENTRY:mainCRTStartupis not optional: switching the subsystem makesWinMainCRTStartupthe default entry point while moon emits a console-stylemain(), so without it the link fails on an unresolvedWinMain. Appending the flags instead cannot work — userlink_flagsare spliced before/link, so cl discards/SUBSYSTEM-class link options outright (D9002).release-bin.ymltherefore 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 --stripproduces three files whose Subsystem reads back as 2 with no post-processing; test exes are largely unaffected because only those whose code reachesinit_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 andfsx_wbtest.mbt:109cases). 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 buildreports "no work to do": delete the produced exe under_buildto force relinking. - prepare.py mode-switch pitfall (prebuilt↔source):
cmake -Donly 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.libends 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.liband 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, andmoon testlinking the traybus test exe failed with 12 LNK2019yue_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
.libtwice is harmless; listingmanifest.restwice 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/linkdo 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 —
TaskDialogIndirectis 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 classicMessageBoxW(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 inpszMainInstructionand only the informative text inpszContent, whileSetTextwrites only the former, so the fallback must concatenate both fields into MessageBoxW's single text line. Distribution-style builds setYUE_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 fullmoon 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_windowonce passed theWindowstruct straight into an extern expecting aViewhandle (the C side received a MoonBit heap address instead of the registry id), soCastTo<nu::Window>failed silently — the message box ended up parentless and the synchronous path deadlocked. Detection: the MoonBit-generated C prototype showsstruct ...Window*for the parameter instead ofvoid*. Rule: any extern declaration taking a handle must useView(the external type) and let MoonBit call sites unwrap the concrete struct via.view(); the asyncshow_for_windowand the syncrun_for_windowmust follow the same convention. Also note: moon's incremental check is unreliable — after changing the shim/library,moon buildmay report "up to date" and not relink, so delete the produced exe under_buildto 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 anIsContainer()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'syue_mbt_view_layout(update_layout) now walks the parent chain to the root container and callsLayout()there, and tabs_t'ssel.subscribeadds oneupdate_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), soDrawChildskips the whole block onchild_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::SetOverlayScrollbaris 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-levelattach_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 declarativescroll()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:
DwmGetColorizationColorreturns 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 registryHKCU\Software\Microsoft\Windows\DWM\AccentColorfirst (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).
AttributedTextranged 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 toGetSysColor(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
NormalizeModifiersgained 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 scrollingGetBoundsInScreenyields garbage offsets, so anchor coordinates come from the native child HWND'sGetWindowRect; close is destructive on Windows — a reusable popup switches toSetVisible(false); outside-click dismissal installs aWH_MOUSE_LLhook that PostTasks a close when the press lands outside the popup rectangle; the popup background ignores themes, soPopover::set_background_colorwas added end to end. libyue's SetVisible / IsVisible are unreliable on a non-activating topmost window — use Win32SetWindowPos+SW_SHOWNOACTIVATEdirectly. - 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_vcenternarrows 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
FindChildFromPointiterates 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 shimyue_mbt_view_on_wheelgained a Windows branch converting WM_MOUSEWHEEL deltas. - Text measuring and drawing disagree:
GetBoundsForuses GenericDefault (with overhang) whileDrawStringuses GenericTypographic, so manualx=(width−measured)/2centering always sits left; positioning must usealign=Center/Endand leave it to the platform — measuring is only for computing container widths. - Two splitter traps: an explicit
SetCaptureon Windows triggersWM_CAPTURECHANGED, which tears down the implicit capture (skip if capture is already held); theflexbasis:"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::newcrashes with 0xc0000005 (minimal repro involves no drawing at all). Root cause: Canvas / DoubleBuffer / Painter construction depends onnu::State— GDI+ (GdiplusHolder), the default font, and NativeTheme are all established with State, which initialize() creates; a bareGdiplusStartupfallback inside canvas_new still crashed (State has more null dereferences), and the correct fix is running the sameyue_mbt_app_init()as initialize wheng_stateis 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 throughyue_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#elsefallback 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::Showrequires 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-threadCoInitializeEx(MTA)network query),Showhangs forever — observed as "app freezes the moment you open/save a file". Fix: shimyue_mbt_app_initcallsState::InitializeCOM()right after creating State (ScopedCOMInitializer STA + OleInitialize, idempotent); afterwardsCoInitializeEx(MTA)on the UI thread only yieldsRPC_E_CHANGED_MODEand 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_applyrebuilds 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: shimyue_mbt_repaint_allgains a Windows branch —EnumWindowsfiltered 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::Invalidatereturns 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_viewsetsminHeight=row_heighton 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::SetBlendModemaps only Copy toCompositingModeSourceCopy, everything else falls toSourceOver— GDI+Graphicshas 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 ofImage::write_to_filein 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_changewhen the first page is added (internal initial selection, not a user switch): registering the callback before adding pages makes it fire spuriously during mount (declarativetab()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 testfrom 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.pyexits non-zero when pkg-config is missing, and every check/test across the fivemoon.workmembers runs it first; the mainscripts/prebuild.pyalready 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.cusedgint32as 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,gint32undefined). Changed toint32_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 POSIXpoll.hand cannot compile on Windows:sys'sprocrun.mbtbuilds 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 testinside 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 check287 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 onTextMetrics::unwrapnull (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—_WIN32real Win32 implementation,WM_STUB_GTKreal GTK implementation (prebuild compiles it on Linux with-DWM_STUB_GTK+ pkg-config header paths intolibyue_win_stub.a), and placeholders for the rest; the oldgtk_stub.c/gtk_stub_portable.cpair 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.hdirectly 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_ARROWis theMAKEINTRESOURCEAform (LPSTR), and passing it toLoadCursorW'sLPCWSTRparameter trips C4133; force#define UNICODE/_UNICODEat the top of the stub file to go all-W. - Key mapping aligned with GDK: the character face is taken in
WM_KEYDOWNviaToUnicode(ASCII/Latin-1 verbatim, otherwise0x01000000|unicode, matchinggdk_unicode_to_keyval, so both ofkeysym_to_text's Latin-1/UCS paths accept it); for Ctrl combos theVK_CONTROLbit must be removed from theGetKeyboardStatesnapshot 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 (ToUnicodereturning negative) push no event; composition happens naturally on the next key via layout state. Alt+F4 is handed back toDefWindowProcto produceWM_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 32bppBI_RGBtop-down DIB (negative biHeight) —StretchDIBitsblits directly; present only records the pointer +InvalidateRect, the actual blit happens inWM_PAINT(same shape as the GTK stub's "blit only in the draw callback", avoiding the dead-window window).WM_ERASEBKGNDreturns 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 anHWND_MESSAGEmessage-only window +PostMessageW(WM_APP+1); the pump drains withPeekMessageW+DispatchMessageWand deliberately does no TranslateMessage (WM_CHAR is not consumed; the character face is taken in WM_KEYDOWN). - Slot and creation ordering:
WM_SIZEarrives duringCreateWindowW, so the slot id must land inWM_CREATEviaCREATESTRUCT.lpCreateParams(settingGWLP_USERDATAonly after the call drops the first WM_SIZE); client-area semantics viaAdjustWindowRect(frame stretched so the client area is exactly width×height). DPI viaSetProcessDpiAwarenessContextper-monitor v2 (through GetProcAddress so old systems missing the export don't fail to load), whole stack in physical pixels,WM_DPICHANGEDrepositions by the suggested rect with WM_SIZE doing the bookkeeping. - Verification (2026-10-11, this machine):
moon build examples/native-windowlinks the full chain; smokeNATIVE-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 printingWIN-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::SetCustomSchemeRegistrationsfails outright (failure code 9 on a real machine, later 8). The MoonBit-sidewebview_predeclare_schemeslist flows through a new extern into a global list in the C stub;wb_createpasses a hand-writtenWbOptions(four nested sub-interface COM object) and the engine collects the list while creating the Environment. - A hand-written COM propget/propput missing the
selfparameter silently corrupts the vtable on x64: declaringget_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 = FALSEactually 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
GetCustomSchemeRegistrationsmust beCoTaskMemAlloc-allocated: the contract is that the caller (loader) frees it withCoTaskMemFree— handing over a static array pointer means the loader frees it and the heap manager raises0xC0000374(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-8tostub-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 declareslink: { "native": { "stub-cc-flags": "/utf-8 -I${build.FFMPEG_INC}" } }— vendor header paths get injected into stub compilation without static hard-coding;stub-cc-flagsonly affects native-stub compilation. Platforms that do not emit the variable must still provide a placeholder value (/usr/includeor.) 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 undermodules/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.cdrops the_WIN32all-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.pyemitting 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-webhas no self-exit timer (manual acceptance walks through every section), so automation must usetaskkill /IM(sends WM_CLOSE; statistics print after app.run() returns and get flushed) —kill -9loses 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_LostFocusare methods ofICoreWebView2Controller; and for this event pair theInvokesender parameter isICoreWebView2Controller*(the event-handler macro hard-codes sender asICoreWebView2*, 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'sWM_MOUSEWHEELbranch first asks via a wheel probe (registered by the web stub at create time through thewm_set_wheel_probefunction-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 -cscript passing a badnewlinevalue toio.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 wholeimg_*set as no-ops, so on WindowsImage::new_from_filealways returned an empty image and the animated-GIF widget collapsed to zero size; the test layer skipped those cases behinddecode_available()/ui_decode_guard()probes (so they were never red). This batch implements the_WIN32branch ofgui/render/stub.c. - Three measured WIC facts (proven by the pure-C probe
experiment/img_probe, no MoonBit runtime involved):- WIC's GIF decoder hands back raw sub-rectangle frames and does not composite for you —
IWICBitmapFrameDecode::GetSizereports the frame rect (12x12 in the probe), with the offset in/imgdesc/Left|Top(14/14 measured). GdkPixbuf'sGdkPixbufAnimationIteron 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). - 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.
/grctlext/Delayis 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.
- WIC's GIF decoder hands back raw sub-rectangle frames and does not composite for you —
- Premultiplication matches the Linux branch word for word: after converting to
GUID_WICPixelFormat32bppBGRA(straight), this layer premultiplies with(c*a+127)/255before 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 inimage_wbtestdepends 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 isframe % count). As a result the widget-sidegif(looping=false)stop condition ("frame number back to 0") never fires on either platform — that is pre-existing behavior andimage_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 ofexamples/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.pygains 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), sorender.whitebox_test.exewould 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-8needed: 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.cemits 6), with no swallowed lines and no syntax errors — so nostub-cc-flagsis 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 withcl /W4 /utf-8, includingrender/stub.cso it can call theimg_*entry points) ends withIMG-BACKEND-OKacross 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.gifstatic 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 reportsis_static=1and 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 withcl, then executeexperiment/img_probe/img_backend_probe.exe .from the repo root (the asset rebuild scriptmake_asset.pyregenerates subrect.gif with ffmpeg).moon checkon 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 bothui/gif.mbtand theimage_wbtestassertion); JPEG/BMP/TIFF were not verified format by format (they go through the sameCreateDecoderFromFilenamesniffing 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 machineframes=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 (PangoPangoLayout/ DirectWriteIDWriteTextLayout: 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_rgbaand geometry offsets are computed per call, neither touches the layout; DWrite —ptw_renderdoes 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 throughSetColoron the shared brush. - Windows-side free precondition:
img_anim_advance(WIC) is one frame per call and the component asks once perdelay_msbeat, 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 thePureBackend(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 ofNATIVE-APP-PROF(the ledger now lands innative-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_recttries the fast path before the generic cov composite; ③Raster::fill_round_rectanalytic 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_rectreturns Bool and the Painter side takestrace_round_rect+fill). - Trap (hit in practice): the fast-path entry must call
apply_fill()first — callingraster.fill_round_rectdirectly 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.cppgains 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) callspt_flush_if_overlapwith 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); ③ corepaint_framecallsflush_pendingbefore 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 (coretext_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 _WIN32to 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-WinEventApplication, 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 channeldraw_box_raster(taken whenevercairo_id()==None, i.e. by everydraw_at/draw_box_atcall site) draws each segment into a fresh offscreenBitmapallocated inside the call and frees it as soon asblitreturns; the frame-endflush_pendingthen 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_rastercallsflush_pending()beforeblit(ink lands while the offscreen bitmap is alive); the contract now stands inenable_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): patchpt_set_batchingto 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 wrotecx1(clip right bound) instead ofcy1(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_spangained 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 tosort_byindex 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_rectanalytic stroke: ring pixels partitioned uniquely by center (straight bands via row/column overlap with[edge-hw, edge+hw], corner quadrants per-pixel|dist-rr|≤hwSDF, inner hole vanishes naturally when line width ≥ 2×radius), measured 49→6ms (8×); also adds a publicPainter::stroke_round_rectAPI. 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-appfelt "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_presentasked the system to redraw on every frame (InvalidateRect(NULL,FALSE)on_WIN32,gtk_widget_queue_drawon GTK), while the system's repaint callback (WM_PAINT/wm_on_draw) setneed_draw = 1again - so MoonBit'spoll_eventreturnedNeedsDraw,dirtybecame true, a full frame was painted,presentran, and the cycle repeated: an endless loop. Two consequences: ① the app was never idle -wm_waitalways 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, andwm_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: theDownmade it into the queue, theUpwas 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_PAINTsetsneed_drawonly when there is no frame to blit yet;wm_on_drawlikewise). The sole source of repaint requests is back to the MoonBit side'sschedule_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_showalready setsneed_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 thatenable_root_scrollexists, the tall column whose only purpose was "show the whole page in one screen" is pure cost. It is now theAPP_WIN_Hconstant - 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
TableSortstore (the component deliberately does not sort for the caller — that rule is documented onTableSort), and the example never subscribed to it to write the reordered rows back. A@ui.sort_rowsreference 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-warnis clean for both the library module and theexamplesmodule (two injected-error probes confirmed the gate really covers those modules);gui/win/stub.ccompiles 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.mbtgains 2 tests (sort rule + stability). The real-machine behavior (whether latency really drops to "one frame", whether clicks stop being dropped) belongs to_Windows真机测试清单.mdfor 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 testruns test processes with cwd = module root (gui/), regardless of the invocation directory (probed viayue_sys_getcwd:moon test gui/sysfrom the repo root andmoon test sysfromgui/both report cwdgui/). So the repo-root-relative pathexamples/showcase/assets/dot.gifused by wbtests never exists in tests; the GIF decode case hitNone => assert_true(false)permanently, andui_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_assetinimage_wbtest,gif_assetinbasic_wbtest); the guard and the component tests now really run on Windows.Image::resizeonly opened a Cairo session; Windows has no Cairo backend, so the whole frame came back empty — upscaled pixels read(0,0,0,0).Cairo::overreturnsNoneon backend-less platforms and the old implementation just gave up. Fix: route throughPainter::draw_image(stretching blit implemented on both the Cairo and pure-raster backends), platform-free.compare_cellsonly 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_intremoved.- Per-tick animation cadence differs by platform (test contract): Windows (WIC)
img_anim_advanceis one frame per call (the component asks once perdelay_msbeat; no internal clock); Linux (io-gif) anchors on a virtual clock, so frames advanced per beat differ. Thebasic_wbtestwindow "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 checkzero 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.
#elsefallbacks in shim platform branches swallow macOS: borderless needs#elif defined(OS_WIN); CurrentDirForDrag splits into three branches (mac usesgetcwd);Window::SetSkipTaskbar/SetIcon/App::SetIDhave 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
::errorannotations (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_probeuses ffprobe CSV (-show_entries stream=... -of csv=p=0),vidf_extractdecodes 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), thenread_binary_fileslurps 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
testsrcgenerated 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 thepackagefield 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_buildcaches. - 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 fourString::exact_viewsites 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 test575 all green,moon buildpasses.
ffmpeg dynamic-link FFI (workspace submodule modules/ffmpeg-mbt)
- Shape:
modules/ffmpeg-mbtis a moon.work workspace member (moduleNoahLiu/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
linksection never reaches the final link (the old trap in AGENTS rule 2) — so, following the main repo's architecture, the submodule gets its ownprebuild.py(moon.mod options --moonbit-unstable-prebuild) emitting{"link_configs":[{"package":"NoahLiu/ffmpeg/src","link_flags":"-lavformat ..."}]}, which propagates automatically to dependents' main packages; thelinksection was removed from moon.pkg. How to test: runmoon test srcinside 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#borrowannotation 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 pathsrc/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/miniaudioMoonBit 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; ③
guardis 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 rootdifferential screenshots to confirm the picture is updating. For gdb stacks usegdb -batch -ex "set environment DISPLAY :0.0" -ex "handle SIGSEGV stop nopass" -ex run -ex btagainst_build/.../xxx.exedirectly (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 innu::View::View()or printedGtk-ERROR: Can't create a GtkStyleContext without a display connection— easily misread as an audio/video problem; ②pgrep -fmatchingmoon runhits the moon parent process and fakes "process alive" — check the real exe process name or exit-code semantics; before gdb debugging,touchthe 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 wasframe_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 atrate=5but passedfps=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_nextrecords the frame pts on success (best_effort_timestamppreferred, converted to seconds through the stream time base, read viavf_frame_pts_s; reset to -1 after a seek) and the stream's real ratevf_stream_fps(avg_frame_rate preferred, r_frame_rate as fallback); gui-media adds a monotonic wall clockmbt_now_ms(in audio_stub.c, CLOCK_MONOTONIC). VideoPlayer's clock is rewritten: play/seek record a base (position + wall clock), each tick computestarget = 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), andpos = 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), andcurrent() = 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 (#00000080intended 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#ffffffe6were only visible because their first three digits happened to be read as AA=ff. The dialog_t mask#00000059had the same wrong order (AA=00), so the dialog's dark scrim had always been transparent (a pre-existing bug, fixed together). Probe evidence:#8000000050% black,#ff000000opaque black,#000000fffully invisible (leading 00 = transparent). - Root cause 2 (aspect): the page called
VideoPlayer::make(width=480, height=0), and the C layer'svf_open_video_decoderused 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-1semantics, both directions, minimum 1). Probe evidence: a 320x180 video passed width=480 decodes to480x270(480x180 before the fix). - Locating method: a GUI probe × screenshot pixel sampling (
import -windowplus ImageMagickpixel: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 passingnu::Color(std::string)straight → the GTK backend'sView::SetBackgroundColor, which builds CSS fromColor::ToString(). libyue's (upstream and fork alike)ToString()emitsrgba(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 withgdk_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.6af827conly 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 explicitheight:"100%"; theset_background_colorcall 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_alphaoutputs 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#59000000over#ffffff(255×(1−0x59/255)=166);moon checkzero warnings +moon test657 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_devicewas writtenstarted == 0 ? 1 : 0—startedis a three-valued state (-1 = init failed / dumb device, 0 = initialized stopped, 1 = playing), soplay()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 fromseek()'sap_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'svp_fill_audionever consults has_device and always pushes directly. - Locating method: a real-cadence probe — in wbtest, busy-wait on
mbt_now_msand printstarted/has_device/queued/consumed/currenton 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_devicebecamestarted >= 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 (
==0vs>=0differ 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 withget_bounds_forand 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 explicitheight: 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 —
timeoutkilling 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 withxwininfo -root -treeagainst 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:absolutepinned 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 aRef[() -> 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 toVpIdle, 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'sdecode_pcmreturned 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
VpEndedstate added (mirroring AudioPlayer'sApEnded), with EOF non-loop transitioning to VpEnded;play()on VpEnded first runsvp_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>=durationheuristic — 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-mediamakes 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 repomoon check --deny-warnwarning-free andmoon test641 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/audioloadingassets/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.mbtasserts it), but the component layer's player calledVideoPlayer::makeunconditionally, and that engine's clock is wall clock plus frame pts, soopen_video_decoderreturnsErr(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_tickgetsdecode_rgba() -> Noneon the first tick and reads that as EOF, flipping toVpEnded; making it audible means rebuilding a device clock inside the video engine — i.e. copyingAudioPlayer. 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, otherwisevp_clamp_tsends 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::makenow 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 becamevideo, the new one isaudio(gui-media 0.1.0 was never published, so this breaks nobody); the sharedplayer_bar_hit/player_speed_atbecamebar_hit/bar_speed_at. Note thataudio_player_t/video_player_tare 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 insystem-capabilities.mdkeep those names as they were. - Verification:
moon test ./media25/25 (newap_skipcase),modules/gui-mediaandmodules/gui-exampleswarning-free undermoon check --deny-warn, both examples build for native; whole-repomoon test1179/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_fillafter resampling and before the push — the engine exposes apcm_tapcallback 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 byqueued_framesbefore 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/ebur128would 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 withType AudioPlayer has no method tap; ②String.trim()yields aStringView, so anything wanting aStringneeds.to_owned()(same fors[0:n]); ③ when only the array contents change and the field is never reassigned, do not mark itmut—unused_mutis an error under--deny-warn. - Verification:
moon test ./media33/33 (4 new spectrum cases), whole-repomoon test1187/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 asApp::set_fullscreen, but a widget cannot reach the window and nobody had wired it); neither player took keyboard input. - The icon library is enough:
IconKindingui/iconshasvideo-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 withdraw_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 areui.icon_button(hover shade, pressed shade, focus ring, Tab reach all come from ui); speed and zoom areui.button(variant=Text)showing the current gear — an icon cannot say "1.25x" or "fill". Gear readouts go throughStore+bind_noderemount (Store::setdoes not re-emit equal values, so refreshing per tick costs no rebuild). - The old acceptance check was retired and rewritten:
bar_hitused 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()thenApp::set_fullscreen— that is the documented facade exit incore/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,Escexits fullscreen (and is only taken while actually fullscreen). Liveness is judged bywatch.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/mediatherefore newly depends onNoahLiu/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 ./media35/35 (three newcontrol_bar_wbtest.mbtcases: display zones, the three zoom placements, gear labels; the old six-zone check retired with the shape),moon check --deny-warnwarning-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::updategetpub fn[T : Eq]bounded implementations (same value: store without notify, viaSignal::set_eq);Signal::setand the notifier are untouched, andStore::signal()still reachesSignal::setfor forced delivery. Useset_always/update_alwaysfor unconditional re-notify. - Pit ①: a same-name bounded impl shadows the generic one — calling
self.set(...)from inside the unconstrainedStore::updatefails withType 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 fromT : Eq. Composite value types needpub impl Eq for X with fn equalpluspub extend X with Eq::{not_equal, equal}(without extend you get theimplicit_impl_as_methoddeprecation 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,GeoRectand friends) have no Eq on their elements and comparing the whole window costs more than it saves — chart and examples useset_alwaysthroughout; 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_alwaysforces,updatefollows the same policy); repo-widemoon check --deny-warnerror- 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 machinefull=16ms/20 frames,relayout=0ms/20 frames, a local 90×32 rounded fill<1ms/frame. - But this box is Linux, and the
Painter::newprobe picks the cairo backend (see the backend selection inrender/painter.mbt,Cairo::overpreferred), 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/macPureBackend(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_benchon 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_shadoware static properties (one frame suffices) and are not held by this gate — they shipped first. - A toolchain pit found along the way:
localis a reserved word (measured:[0035] The word 'local' is reserved), solet local = ...collides immediately; the probe variable was renamedpart. When writing new code, treat words likealias/abbrev/localas 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-appon this machine, quit, and read theNATIVE-APP-PROFline (layout / paint / present accumulated plus the longest frame, always-on viacore/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 inapply_fill/apply_stroke; text goes throughPainter::paint_color(parse the string → multiply the factor → re-emit hex at draw time); images/canvases rundim_bitmap(per-pixel alpha scale) before the blit whenglobal_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 == 0style checks must run on a transparent target, and gradients must be comparedfull.avs0.25.aon a blank bitmap. - Pit ②: when
round_rect_pathcalledbegin_pathinternally, the shadow's "outer ring + inner ring" two subpaths were wiped by the second call — the tracer split intotrace_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.opacitychannel:applywritesWidget.alpha_paint; the paint traversal setsp.set_global_alpha(inherited × own)on entering a widget and restores after its subtree (CSS-like box-tree accumulation).box_shadowcurrently ships the hard tier (offset rounded-rect rings,layersstroked 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_rectmoved into the facade with zero behaviour change for ui/nav; repo-widemoon check --deny-warnerror- 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:
380610awithout the C-side text-layout cache = 9.9 / 11.95 ms per frame (warm/cold);323b537with it = 3.35 / 3.25;HEADafter 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 fromSHOWCASE-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 checkedrotation.paused— so a carousel whose page is hidden still switched panels and calledinvalidate(), 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_atrecords it intoWidget.paint_epochonly after thevisibletest and the viewport cull pass, andWidget::is_painted()answers "painted this frame". The self-driving callback becomesif !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
invalidatecounter does not move; show it again → rotation continues),moon test ui140/140, library-wide 872/872,cd gui && moon check --deny-warnwarning-free; showcase printsSHOWCASE-PROF(frames / layout / paint / present / worst frame) at exit. Ifframesis 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/showcasewalked on device after damage-rect repaint landed (frame bitmap reuse + painting clipped to the dirty region); the ledger is theSHOWCASE-PROFline 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?usedNonefor two different meanings — "whole window is dirty" and "nothing is dirty yet". Within one batch, a whole-window request (invalidate()/schedule_paint/host_repaint_allall only writedirty_rect = None) could be followed by the pointer router marking the hover change throughmark_widget_rect, whose shape isSome(rect_union(dirty_rect, w.rect)), andrect_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:
Appgainsmut dirty_full : Bool, set by every whole-window request (schedule_paint,host_repaint_all). Local marking now goes through the pure functionaccept_widget_rect(full, cur, r)— withfullset it holds the whole-window state instead of narrowing;paint_frametakes the frame's clip throughdirty_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 hostAppneeds a real window, these judgements do not. - General policy: an
Optioncarrying "two different kinds of nothing" invites exactly this accident — when oneNonemeans "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 core133/133, all packages individually 882/882 (13 packages each green),cd gui && moon check --deny-warnwarning-free. Device re-check (user): switch sections/pages in showcase without moving the mouse and the new page should appear immediately; re-runmoon run examples/showcaseand paste the exitSHOWCASE-PROFline (framesmust 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 uiwould not start, reportingsys/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 twostatichelpers they share (sys_pp_guid_string/sys_pp_parse_guid) takeGUIDin their signatures and sit outside every guard — astaticfunction still has to type-check even when never called, and on Linux/macwindows.his 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
statichelpers 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-warnpasses,moon build showcase --target nativelinks, 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 ofstatichelpers whose signatures carryHWND/HMONITOR/BOOL CALLBACK, again sat outside every guard, andmoon test syson Linux died withsys_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_tonly 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 nativeon the opposite platform — exported functions never break, because their bodies carry an#elsebranch; only helpers outside the guard break, and only the opposite compiler sees them. Windows-onlysysbatches are therefore run on Linux, andwin/dbusthe other way round. Readings for this batch on Linux:moon test sys182/182, library-wide 928/928,cd gui && moon check --deny-warnandcd examples && moon check --deny-warnwarning-free,moon build showcase --target nativelinks. - 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#elseroute. 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#endifmust 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 nativelinks.
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 isfill_hoverfrom x=10 to x=400 ✓. What was wrong is the damage region: hover is parent-chain semantics (the same meaning as CSS:hover;set_hoverflags the whole ancestor chainhovered), 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.applyasWidget.paints_state(true when the style carries any of thehover/active/focusbuckets); 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_eqwhenT : Eq), and the example stored thelang_basearray object itself in the Store, then "add a row" didlang_base.push(...)in place andtable_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 toset(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_alwaysdeliberately. - Verification:
moon test core136/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-warnand the examples module warning-free (also deletingApp::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'srow_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), andtimelineno longer passesrow_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 ui156/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: puttingrow_gapback 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-warnand the examples module warning-free. Device check: the spine runs unbroken from the first dot to the last.
Breadcrumb clicks did not navigate: "current item" was decided by position, not by the bound store (2026-10-14, real-hardware report)
- 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_lastserved both as "current" for the dark colour and as "not clickable" for theon_clickguard. 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 ui156/156, library-wide 921/921,cd gui && moon check --deny-warnand 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_drawpainted it onto(0, 0, w, 28)as floating ink, while the steppers and buttons are real children laid out from thetop=10padding ⇒ 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 formd_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_drawkeeps only the background and border; padding was completed on all four sides (it had onlytop/left, so the button row sat flush against the bottom edge). Measured after the fix, the popup children runy=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 ui156/156, library-wide 921/921,cd gui && moon check --deny-warnand 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_rectas drawing inside the rectangle (both backends implement it that way: the cairo stubcr_stroke_rectand the pure rasterRaster::stroke_rectboth draw inward bylwand snap to pixels), yet 28 call sites used the Canvas/CSS centered-stroke habit and passedstroke_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): beforeF B F…F B(column 0 is fill), afterB F…F B(columns 0 and 25 are border). - Conversion (centered → inner):
(x, y, w, h)at line widthlwbecomes(x - lw/2, y - lw/2, w + lw, h + lw). A 1 px outer ring is thereforestroke_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), becomesstroke_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_rectare 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 ui155/155, library-wide 920/920,cd gui && moon check --deny-warnand 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_idon the hit path" (Widget::focusable_at), whileinput/textareadefaultfocus_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=0andfocusable_atreturnsNone). - 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/buttonkeep 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_rectfed the focus-stack id straight intomark_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_rectlooks the widget up throughfind_by_focus_id(whole-window fallback only when it truly is not in the tree); the onemark_id_rect(id)in the Down path switched over too. clearable(the ✕):inputgained 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 equalcontrol_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 ui154/154 with 3 new cases — an input with no focus_id is still hit byfocusable_atand typing writes back to the Store (plus explicit ids winning and two auto ids differing);clearablechanges 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-warnand 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.
The frame-batching stubs got defined twice: Linux link failure (2026-10-14, reported by moon run examples/showcase)
- Symptom:
/usr/bin/ld: ... multiple definition of pt_flush(pluspt_flush_if_overlap/pt_set_batching), the two definitions coming fromgui/build/libyue_text_pango.a(stub.o)and_build/native/debug/build/NoahLiu/native-gui/text/libtext-*.a(stub.o)— two compilations of the samestub.c. - Root cause: on Linux
gui/text/stub.cis compiled twice: prebuild compiles it with-DWM_STUB_PANGOintobuild/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 _WIN32block after the outermost#endif, i.e. outside every branch ⇒ both compilations emitted a copy. Windows escaped via the_WIN32exclusion; Linux did not. - Fix: the three functions moved into branches — one copy in the
WM_STUB_PANGObranch (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 indwrite_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#endifdouble-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 nativepasses from both the workspace root and theexamplesmodule and producesshowcase.exe;nmconfirms 0pt_flushdefinitions in the moon-side archive;cd gui && moon check --deny-warnwarning-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_timeoutuses a process-level delay channel installed byApp::new(viainstall_host_hooks); with no host it is a no-op returning 0 (the contract incore/loop.mbt). In the examplepage_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_pushusesset_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-timelet _ = @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 treatedview'shandleas a mount callback — that is wrong;handlealso 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 nowrap=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-warnwarning-free,cd gui && moon check --deny-warnwarning-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 captureshowcase-prof.txtcontained not a singleCHART-DIAGline (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)'sfinishis eagerly executed at construction (gui/core/node.mbt, equivalent to "new first, then configure"), andview(ui/basic.mbt) writeshandle(w)insidefinish⇒handleruns while the tree is being built — it is not a mount callback. Sincepage_charts(app)is an argument to@ui.mount_window([...])evaluated before the window exists, theset_timeoutarmed insidehandlelanded 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 toC:/tmp/timerprobe.log): the probe arms its first tick from@ui.col(..., handle=...); the log showsARMED pending=0(the timer armed inside the "mount callback" never entered the queue), while a timer armed aftermount_windowreturns showsPOST-ARM pending=1and fires on time 400 ms later — the hook itself is fine, only the timing was broken. - Fix:
coregainsNode::on_mount(f)— it wraps a Node and defersfto the real mount moment (aftermount_intohas attached the widget to its parent); the charts page's two first ticks moved fromhandle=to@ui.row([...]).on_mount(fn() { pump() }). The section body's lazy mount only callsmount_intowhen 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'shandleis 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 inNode::on_mountormount_window'shandle(app)(that one is genuinely after mount).on_mountruns on every mount — a one-shot startup callback relies on the caller mounting once (the lazy section mount is guarded by amountedflag, so it holds). - Verification: core gains 1 on_mount regression (not run at construction; on
mount_intoit runs after the widget is in its parent),moon test core144/144;cd gui && moon check --deny-warnwarning-free,cd examples && moon check --deny-warnwarning-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'sremaining, the auto-marginfreeand justify-content'sfree2subtract only the sum of the children's outer frames — never the line's total gapgap x (n-1)— while the positioning pass addsmain_gapper item ⇒ whenever an item grows, it over-grows by exactlygap 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 atx=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-warnand the examples module warning-free,moon build examples/showcase --target nativepasses. 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.mbtdocuments "wraps automatically when too wide". - Root cause: this stack's
uinever writesflex_wrap, and the style layer had no such property either (CSS hasflex-wrap; ourStylewas missing it), so every container isNoWrap— 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):
Stylegetsflex_wrap : FlexWrap?(the short type name imported intypes.mbtviapub using @yoga {type FlexWrap}), the factory takesflex_wrap=,merge/empty_style/ the setter follow, andapplyroutes it toNode::set_flex_wrap. - Two companions wrapping needs (the first device build shipped only
flex_wrapand it squashed the body):- 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.
- Give the wrapping container — and every level of its parent chain — an explicit
min_height=0(andmin_width=0horizontally): per spec §4.5 a column child'smin-height:autoresolves 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 style26/26 with 1 new layout pin (a 100-wide row container with two 60-wide children:NoWrapkeeps the second attop=0,flex_wrap=Wrapmoves it totop>=20), plus 1 new yoga-mbt pin freezing the §4.5 floor and its escape (same tree:min:autogives the strip height 200, explicitmin_height=0gives 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-warnwarning-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 setmin-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_handmax() = content_h - view_his always 0, so the wheel and the bar both conclude there is nothing to scroll. A call site writinghandle=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); afterviewport=200 content=676(viewport stays at the size the parent gives, content keeps its natural height). - Fix:
scroll_viewwritesmin_height=0andmin_width=0on its viewport (closed at the library level, so every call site benefits together: showcase's section bodies and sidebar, native-app,virt_listhosts). Call sites that pass an explicitheight=are unaffected (an explicit size beats min). - Verification:
moon test ui151/151 with 1 new pin — given onlyflex_growand no height: viewport height ≤ parent height,content_h=400,view_h=140,max()>200, and afterscroll_by(60)the content node really moves up; library-wide 914/914,cd gui && moon check --deny-warnwarning-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_rangesaccumulated range endpoints in UTF-16 code units, while theAttributedTextcontract is UTF-8 byte indices (both the header note intext/attributed.mbtand thetlenhanded to C byrt()are bytes; the Pango side usesstart_index/end_indexas byte indices directly, and the Windows/DWrite side converts throughptw_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_labelusedwrap=falseand sized the node to the natural width of the whole paragraph (measured: one paragraph came out 656 px inside awidth=480document). 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),
\nis a hard break, and a single unit wider than the line (long URLs) is hard-cut per character. OneAttributedTextper line; strike-through rules use that line's pixel ranges, and link hit-testing first picks the line byy, 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 getmax_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 — andString.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 mdview31/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-warnwarning-free,cd examples && moon check --deny-warnwarning-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_dotis documented and named as a round-dot badge but drew withfill_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_rectwith a default radius of half the short side (an inscribed circle). Aborder_radiusfromclasswins, clamped to no more than half the short side — so a square is still reachable withborder_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 ui150/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 theclass=[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.cstill held the empty implementations left by W20e (wm_set_title/wm_set_borderlesswere 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_sizeis a real implementation on the GTK branch (gtk_window_resize), but the demo button hardcodedset_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) andgtk_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_hintsis not wired;begin_move_dragneeds 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 forset_title/set_borderlessnow name each backend (Win32 rewrites the style bits and recomputes the frame / GTK usesset_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-warnwarning-free, library-wide 937/937,cd examples && moon check --deny-warnwarning-free,moon build showcase --target nativelinks (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
splithandle does not track the pointer — it snaps backwards mid-drag. - Root cause: the drag delta in
splitter_implused the handle's local coordinates (ev.xis delivered by the host asev.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_secondstill apply). - Verification: new
gui/ui/split_wbtest.mbtwith two tests (this component had no tests at all before) — ① the initialhit_testmust 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 attotal - 8 - min_second= 312). The tests drive the same path the host uses:dispatch_pointer_to(handle_id, ...)emulating the pointer-capture window — callingdispatch_pointeron 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: replacingwin = at + originwithwin = atturns the second-move assertion red on the spot (measured 210).moon test ui160/160. - The remaining half:
@core.invalidate()broadcasts to the whole window (host_repaint_allsetsdirty_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_drawpainted only a light plate + a 3 px accent bar on the left + title/description; theclosablebranch installed only a 24×24 hot zone at the top-right inon_pointerand 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 colourmain(semantic colour tokens read live, so it follows theme switches). Naming note: the bare ✕ isClose2(its font_class is literallyclose);CircleClose/CloseCircleFillare the ringed variants — do not mix them. - Verification:
moon test ui158/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 thedraw_iconline turns the mark assertion red on the spot (falseis not true), restoring it turns green. Library-wide 932/932,cd examples && moon build showcase --target nativelinks. Whole-repo--deny-warnis still blocked by the 5 sys warnings from the parallel batch 60e094e (4 inappfind.mbt, 1 inrecent_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=2715lists 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=8card shows a dark blob at each of its four corners while the four straight sides look like an even ring. - Root cause:
Painter::box_shadowbuilt each band as "outer ring + reversed inner ring + fill with nonzero winding", with the inner ring traced byround_rect_path_ccw, which expressed the counter-clockwise direction by feeding decreasing angles toarc. Butarc'sccwparameter does nothing on either backend (painter.mbt's own doc says so:cairo_arcnormalisesea < sainto 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 byspread/2, which tiles exactly[0, spread]; each backend takes its own stroke path (a wide stroke is an existingstroke_polyscapability in the raster backend, the same route every border uses). Thelayersparameter 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 helperround_rect_path_ccwwas 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_sizeon the container'sclass, 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_shadowregression 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 ui206/206, library-wide 939/939,--deny-warnerror- and warning-free under bothcd guiandcd examples,moon build showcase --target nativelinks, 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 —
arconly 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.mbtdidlet _ = @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 runsgtk_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, andPROBE-AFTER-HOLDmatched 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 explicitUnsupported, not a defect. Reproducing in-app behaviour requiresinit_backendfirst, otherwise probe readings mislead (my first probe run readavailable=falseand 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_textsits ingui/ui/clipboard.mbt's kernel slot: the kernel'scopy()has no reporting channel and the internal clipboard still backstops a failure, so it stays by design. - Verification:
moon check clipboard_probe --deny-warnwarning-free,cd examples && moon check --deny-warnerror- and warning-free,moon build showcase --target nativelinks, 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.mdwas not selected. - Root cause: the Linux branch of
@sys.reveal_in_file_manageris simplyxdg-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:@dbusdepends on@sys, so sys cannot import dbus back — the meeting point can only be core. - External measurement (this machine):
gdbusanswers(true,)forNameHasOwner org.freedesktop.FileManager1(Thunar registers it), andthunar --helphas no--select-style switch — the bus is the only channel that can carry a selection, so falling back toxdg-opennecessarily loses it. - Fix: new
gui/core/reveal.mbtwith@core.reveal_in_file_manager(path): on Linux, when FileManager1 is online it callsshow_items([reveal_uri(path, cwd)]), and only when the name is absent or this call fails does it fall back to the@syscommand-line path; Windows still goes through sys's directexplorer /select. The pure helperreveal_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 core138/138, library-wide 939/939,--deny-warnerror- and warning-free under bothcd guiandcd examples, andmoon build showcase/moon build systemprobelink. 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/tmpitself.
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_bgrais a W20f failure stub on the GTK branch (HICON is a Win32 concept; it always returns handle 0), so@win.icon_from_bgrayieldsNone— and both demos wrapped the whole tray creation inmatch icon_from_bgra(...) { Some(ic) => Tray::new(...) None => note a failure }, meaningTray::newwas 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 — seeTray::set_icon_nameanddefault_icon_pixmapinsideItem::create). - Evidence chain (three independent legs): ① on the session bus,
gdbusreportsNameHasOwner org.kde.StatusNotifierWatcheras(true,)(the XFCE panel's notification area is up); ② the four pre-existing "real bus" SNI tests inmoon test dbus(multi-item paths / menu routing, symmetric cleanup on unregister, disconnect self-heal) all pass on this machine, so the registration chain works; ③ readingTray::new: wheneversni_watcher_present()is true it goes through@dbus.Item::create, independent of theiconargument. - Fix (demos only): the handle is now passed through instead of used as a gate —
let ic = icon_from_bgra(...)thenTray::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 LinuxicisNoneanyway, 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-warnerror- and warning-free,moon build showcaseandmoon build native-appboth 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
timerhands out one-shot handles), and the re-arm line readlet _ = a.timer(1000, tick.val)— the new handle was discarded, whiletick_idstill held the very first one. That id had already been removed from the queue on the previous tick, socancel_timer(stale)found nothing and cancelled nothing; the timer kept ringing.Loop::cancelis 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.mbtgains 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 (hitsstops growing).moon test core137/137, library-wide 938/938,cd gui && moon check --deny-warnandcd examples && moon check --deny-warnerror- and warning-free,moon build showcase --target nativelinks. - 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 xclipempty), soclipboard_available()was permanently false,ui'sattach_system_clipboardnever installed a slot, and every facade read/write degraded to failure. - Fix 1: the GTK branch of
win/stub.cgainswm_clipboard_available / get_text / set_text(GtkClipboard), and the sys side externs straight into the win stub — the same precedent as the file dialog'swm_file_*(the MoonBit layer does not import the win package; prebuild ships the link line). Probe order is GTK then xclip; aftergtk_clipboard_set_textthe code also callsgtk_clipboard_store(registering ownership alone is not enough — without the store, other applications lose the content once we exit); the watcher'scbw_get_textfollows the same route and thereby drops one subprocess per poll tick. Win32 and the placeholder branch provide identically named symbols (Windows' real path stays insys_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.cusedmoonbit_make_byteswithout declaring it — this file's GTK branch is compiled directly by prebuild, which never gets moon'smoonbit.hinclude path (compile_stubonly adds pkg-config cflags). An implicit declaration assumes anintreturn, so on x86-64 the returned pointer is cut to its low 32 bits. Proven by disassembly: with the declaration removed,wm_clipboard_get_textcontainsmov %eax,%edi(passing a 32-bit return value on as a 64-bit pointer argument), while this machine's heap addresses are of the magnitude0x61bc76xxxxx— truncated, i.e. garbage. Addingextern void *moonbit_make_bytes(int size, int value);(the same linesys_stub.c:57already has; matching~/.moon/include/moonbit.h'smoonbit_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 sysdies 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;touchsome .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 thatgdk_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.mbtalready is that shape, so swapping backends cannot false-red it). - Verification:
cd gui && moon check --deny-warnandcd examples && moon check --deny-warnerror- and warning-free, library-wide 937/937,moon build showcase --target nativelinks. 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
storecall).
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 givenmin=0(the strip got it in the previous batch, for "wrap when too wide": the CSS §4.5 main-axismin:autoof 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 givenmin=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 defaultsflex_shrinkto 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 "stripset_flex_shrink(0.0)+ body columnmin=0" it measured 44. The probe was deleted once run; the fix landed inexamples/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 thescroll_viewinside the body absorb the overflow.min=0alone 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
Stylefield set hasflex(=grow) but noflex_shrink, so locking the header's shrink goes throughhandle=withw.node.set_flex_shrink(0.0)(the same route asscroll_view's content column). Whether to addflex_shrinkto the style factory is tracked separately (see design.md §6). - Verification:
cd examples && moon build showcase --target nativepasses; this batch touches only the demo assembly, no library changes. The repo-wide--deny-warngate is still blocked by 5 sys warnings from the parallel batch 60e094e (4 ingui/sys/appfind.mbt, 1 inrecent_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_tdefaulted todot=6.0and painted opaquefill_rectsquares. 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
dotdrops 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 becomesPainter::fill_round_rect(radius = half the short side) = a dot, which leaves gaps between points by construction; ③ the point layer is wrapped inset_global_alpha(0.7)(insidesave()/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 singledot=15point 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 inmoon test chart. - Toolchain readings (moon 0.1.20260920): ① the demo's LCG cannot use
% 2147483647to get a positive remainder — MoonBit's%takes the sign of the dividend, and onceseed * 1103515245overflows 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_ffffremoves overflow and sign in one step. ② In a multi-line expression an operator may not start a new line:a + b < cfails outright with[3002]/[4139]; the comparison has to move into an intermediatelet. - Verification:
moon test chart190/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 nativelinks. The repo-wide--deny-warngate is currently blocked by 5 warnings from the parallel batch (60e094e, sys platform batch 3: a duplicated#borrowingui/sys/appfind.mbt, an unusedffi_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_iconhas always been a content radius (ink ≈ 1.76×s, since the path data is normalized to ±1000 and scaled bys*0.88/1000), but@ui.iconand@ui.icon_buttonpassed 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 aPainter::new(Bitmap)session):icon(size=24)reported a 24×24 box that the glyph filled exactly (unclipped it would be 44×44 — callingdraw_icon(...,24.0,...)directly reads10,10-53,53), andicon_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), theoverlaysnackbar (passing 7),navsidebar items (passing 6.72). - Fix:
@ui.iconnow derives the radius from the live rect (r.w.min(r.h) / 2.0), so the glyph keeps following the box even whenclassoverrides width/height and never overflows;icon_buttonpassesicon_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 ondraw_iconandiconstate 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):
printlntakes exactly oneString(more arguments give[4015]);debug_printlinehas no matching overload for mixed "4 Int + 2 Double" payloads (%gis not accepted either,[4021]) — multi-value diagnostics go through string concatenation. - Verification:
moon test ui158/158 with one new pixel regression —icon(size=24)must ink inside[1,1]-[22,22]with equal horizontal/vertical centres (measured1,1-22,22), andicon_button(box_size=28, icon_size=16)inside[5,5]-[22,22]with a width of 10..17 (measured6,6-21,21). The judge's bite was proven by a control experiment: restoring the old arguments (passingsize/icon_sizedirectly) 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-warnandcd examples && moon check --deny-warnerror- and warning-free,moon build showcase --target nativelinks. - 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_radiusanimate. 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, whileopacity/box_shadowtake effect whereapplywritesWidget.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:stepcompares 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_msabsent or 0 settles at once and leaves no account behind (style_forgoes throughforget_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:
Vartokens 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_hexdegrades 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 Colorcarries no==: a tuple comparison containingOption[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 barestruct Animemits themissing_privwarning, which fails under--deny-warn. }and the next condition's{cannot be split across lines: writing} else if condthen{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 astest "name" {).moon fmtrewrites unrelated files: the committed tree is not in this toolchain's fmt shape (moon fmt style coretouched 42 files in one go, e.g. inserting a blank line before everypub constinkeys.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_idmarks only the owning window, leaves the other alone, and an unknown id falls back to whole-window);moon test style core159/159, all 13 library packages green at 908/908,cd gui && moon check --deny-warnwarning-free,cd examples && moon check showcase --deny-warnwarning-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/abbrevare both reserved keywords — there is no function or type-alias syntax. Short names for external types come only fromusing @pkg {type X}, and that declaration is package-scoped: writing it in two files hits[4051] declared twice; the correct form is onepub usingin 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 isT?),f(width?)/f(width!)/f(...)are all[3002]parse errors, and bindingpub let g = fthen callingg(width=1.0)gives[4117]("labelled functions can only be applied directly");moon.pkgimports 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/.focusstate methods is written out in all three places. privonly修饰 declaration forms other thanlet/fn; a top-levelpriv named_x = ...fails to parse — package-private values are plainlet.Array::containsrequiresT: Eq; testing an option that holds a value usesis Some(_)(is_some()is a deprecation warning);Map::new()is deprecated — write theMap([])literal.for i in 0..n(half-open) and0..=nare unavailable outsidefor; a range loop requires thefor i in (0..<n)form or an outrightwhile. This package falls back towhilethroughout.
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@stylepackage (the same precedent as overlay_native's process-level handle table):applywrites, and drawing readsresolved(w)/style_for(w)(choosing the bucket byhovered/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_childrenandremove_childreport every id of the detached subtree, and@styleinstalls the hook itself on its firstapplyto clear those entries — dynamic tree swaps (bind_noderemounts,for_listrow removal, virtual-list window remounts, overlay teardown) no longer accumulate across generations. Rule: detaching means discarding; moving must go throughmove_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
cursorfield is written directly byapply, and a bucket's cursor is handed down by P2's hover path readingstyle_for. - yoga-mbt's
z_indexis paint-only: the setter does notmark_changed(geometry is unaffected) and the engine never physically reorders children (incremental layout caches by index), so it exposes an index projectionNode::paint_order; on the gui sideWidget.z_index_paintis written byapplyandwidget_paint_orderstable-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 samebind_nodetree 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_layeropened throughoverlay_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 everyApproot widget paintstheme_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-windowabsolutewrapper 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,xcentred on the window width; the centring formula is extracted into the pure functiontoast_leftso it can be pinned), and the carrier is no longer absolute — a column plusalign-items: centerlets 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_passexhausts 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).
- core gained
- 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 ui299/299 (core adds 1: afteroverlay_set_posmoves a layer,overlay_rect_oflands 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, andtoast_left's centring plus clamping-to-0 across three points), library-wide 914/914,cd gui && moon check --deny-warnandcd examples && moon check --deny-warnwarning-free,moon build showcase --target nativelinks. Real-machine appearance (no whole-window plate outside the strip, still centred as strips of differing widths arrive) belongs to_人工测试清单.mdS18.
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/showcasehit it everywhere, whileexamples/native-applooked healthy. - Root cause:
App::route's Down only doesif 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-apphand-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 everyinput/textareagot 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 widgetim_cursorcannot 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<=0ignored. 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 — afterdrop_currenthands the keyboard to a webview child, the next realignment must not take it back to a self-drawn widget.App::route's Down andApp::move_focus(Tab) each realign once beforehand. Those are the only two consumers: painting only readsfocus.current()(set_focused_by_focus) and needs no ordering, so there is no per-frame tree walk.register/unregisterstay, 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_listremounts and virtual-list row swaps would all need paired deregistration, andfocus_idis 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 asfocusable_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 core143/143 with 4 new tests (gui/core/focus_route_wbtest.mbt):focus_idstree order / hidden-block skip / duplicate folding;syncpreserving 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/tmpand.desktoppath assertions plus render's PNG read-back, unrelated to this batch),cd gui && moon check --deny-warnand the examples module warning-free. The showcase events page's "register on mount" demo was deleted in step (it now only declaresfocus_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::strokedo not clear the current path after putting pixels down, whilecairo_fill/cairo_strokealways do — a violation of the "the two backends are method-for-method identical" rule stated inPainter's header (item 3). One page shares a single Painter session and each widget paints at its own origin (paint_atdoessave()+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 tickstroke()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::strokeclear 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.mbtbuilt a region ring,fill()ed it and thenstroke()d it directly — that stroke was already a no-op on Linux (the path had been cleared, so map regions never had an outline); extractedgeo_ring_pathand lay the path out twice, a fresh one per draw op. - Added explicit
begin_pathon the component side (the checkbox tick, the radio ring and core): that is the house style (paint_seq_aaincairo_wbtest.mbtbegins 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
/tmpand.desktoppath assertions plus render's PNG read-back, unrelated to this batch),cd gui && moon check --deny-warnand 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'sswitchwas earlier twofill_rectcalls (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_rectdrops all four straight segments —rect_devicesnaps the rectangle to integer edges while coverage is measured from pixel centres, so withhw=0.5the overlap against an integer edge is exactly 0; only the corner SDF ring still paints. This batch's checkbox frame therefore went back tostroke_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 plusarc(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 ui164/164, library-wide 912/917 (the 5 red are this machine's Windows environment baseline: sys's.desktopand directory-enumeration assertions, unrelated to this batch),cd gui && moon check --deny-warnwarning-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_rectis 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: withhw = line_width / 2 = 0.5and 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 inPainter's header). - Fix: edges now always come from exact device coordinates (
m.applyon the two diagonal corners, min/max for negative scales), and rounding is only used to bound the loop; coverage became the analytic cell overlapcov = hw + 0.5 − |distance from centre to the ring|— the standard SDF antialiasing form, where+0.5is 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 onstroke_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" toPainter) — another batch. - Another pit (unrelated to this change, noted in passing): running
moon fmtonce inside the batch — the toolchain's reflow conventions disagree with this repo's existing style, and one command rewrote 287 files (splittingif … { x }across lines, wrapping short parameter lists, etc.), forcing a whole-tree revert before re-applying this batch's four edits. Do not runmoon fmtinside 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 chart403/403, library-wide 914/919 (the 5 red are this machine's Windows environment baseline: sys's.desktopand directory-enumeration assertions, unrelated to this batch),cd gui && moon check --deny-warnwarning-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 (nowm_dpi/wm_scaleexport, and Linux never looks atXft.dpi/GDK_SCALE); the frame canvas is allocated 1:1 in physical pixels (paint_frameingui/core/app.mbt) andStretchDIBitsgets identical source and target sizes. This machine reportsAppliedDPI = 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_polyssolved the miter intersection with the wrong sign: joining the two offset lines meanss·prev − t·cur = n − pn, and crossing withcurgivess = cross(r, cur) / det; the code wrotecross(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 callsclose_path, yet its coincident first/last points were handled as an open path — which also added two butt caps. Raster::fill_round_rectsnapped 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_TOLERANCEwas 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.
- Canvas resolution is not the cause: the process declares per-monitor-v2 DPI awareness (
- 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_rectwas 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_TOLERANCE0.25 → 0.1 (same as cairo).- Components: the radio ring became
stroke_round_rect(1,1,12,12,6)and the corefill_round_rect(4,4,6,6,3), and theswitchknob became afill_round_rectcircle — 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.
- The miter solution became
- 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_rectat 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 chart406/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-warnwarning-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_rectat a half-pixel origin still splits across two cells; every rounded background in the library is still bordered withstroke_rectinner 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_fontprefix widths (prefix_widthingui/ui/entry.mbt). Both layout engines define "text width" with alignment semantics that exclude trailing whitespace: DWrite'sDWRITE_TEXT_METRICS.widthexcludes it by definition (the inclusive field iswidthIncludingTrailingWhitespace, and the C-sidept_measure_fonttook the former), and Pango has trimmed trailing whitespace from the layout width since 1.44 (pango_layout_get_pixel_sizelikewise). So measuring"abc "equals"abc", and the caret never moved. The drawing paths (ptw_render/ the batch queue's ink rect) usewidthIncludingTrailingWhitespace, 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_fontandrt_measurenow taketm.widthIncludingTrailingWhitespace; the alignment helperptw_box_offsetskeepstm.width(centring / right alignment should not count trailing whitespace) — two concerns, two fields. - Pango (
gui/text/stub.c): new helperpt_width_with_trailing_ws— when the text ends in whitespace (g_unichar_isspaceon the last character) and the layout is a single line, the width is recovered frompango_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).
- DWrite (
- Verification: 1 new cross-backend regression in
text(measure("ab ")must be wider thanmeasure("ab"), and a lone space must measure > 0).moon test text30/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-warnwarning-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_closefiring 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 alwaysset_visible(false)(synced with yogaDisplay::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_closeis demoted to an optional notification on top of the collapse, and the banner collapses whether or not a callback was passed. Thenative-appcall 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-warnwarning-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_keydelivering along the focused widget's ancestor chain, tree-orderfocus_idsalignment) was all in place; the 2026-10-14 batch only auto-assigned numbers to text inputs, andselect/buttonetc. 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 nostep), 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_idscoverage, 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 ui176/176, library-wide 934/935 (1 red = this machine's Windows environment baseline),cd gui && moon check --deny-warnand 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_fullscreenwere symbol-bearing empty stubs (every parameter discarded with(void)), while the GTK branch had long wiredgtk_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.cWindows branch):wm_window_set_size: the parameters are client-area physical pixels (the stack-wide physical-pixel convention); expand to the outer window size withAdjustWindowRectExon the current style andSetWindowPos(SWP_NOMOVE|SWP_NOZORDER|SWP_NOACTIVATE); width/height < 1 clamp to 1 (the MoonBit sideset_size_pixelsalready 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 setsresized, so the MoonBit side keeps receiving theResizedevent and the host never computes the new rectangle itself.wm_window_set_fullscreen: entering records the scene (style +WINDOWPLACEMENT), stripsWS_OVERLAPPEDWINDOWforWS_POPUP, andSetWindowPosonto 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— afterset_size,GetClientRectreads back the target size,wm_windows[].resizedis set, andwm_sizeagrees; 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 printsWIN-ENHANCE-OK(this machine, Windows 1920×1080). Compiling the probe also required two missing pieces:shell32.lib(the tray'sShell_NotifyIconWis now in the symbol surface) and a malloc backstop formoonbit_make_bytes(the clipboard stub references a runtime allocation symbol); the build recipe is updated in the probe header comment andbuild.bat.moon test win28/28,cd gui && moon check --deny-warnwarning-free,cd examples && moon build hello --target nativelinks. 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_probedrives the exact same path withPostThreadMessageWdelivering real WM_HOTKEY messages and is all green. What broke was where showcase parked its poll table: the "zero-size mount hook" was written insidenode_of_new's finish, which runs at construction time (documented incore/node.mbt), and page functions aremount_windowarguments evaluated before the window exists — so the first tick'shotkey_poll()ran on the empty timer channel withapp.host.valstill 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 toNode::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 readsapp.hostand stops re-arming when None) is the example side's current state and is an open hardening item (another batch). - Verification:
experiment/window_probegained a hotkey section — register →PostThreadMessageWposts WM_HOTKEY →wm_pumpintercepts →wm_hotkey_pollextracts, 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 printsWIN-ENHANCE-OK.cd examples && moon check --deny-warnwarning-free,moon build showcase --target nativelinks. 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.cfeedswm_tray_*directly, and left/right-click callback messages queueing and extraction bywm_tray_poll, menu attachment throughwm_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 innode_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 calleddispatch_tray_event(): left-click callbacks were never extracted, and on Win32 the rule "a right click with noon_contextcallback pops the attached menu automatically" also lives insidedispatch_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 hosthandle(the genuine mount phase). - Fix (
examples/showcase/pages/system.mbt): the table start moved toNode::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 nativelinks. The library changed nothing this batch; its tray semantics were re-established on this machine withexperiment/tray_probe/tray_backend_probe.c(pure C,#includes stub.c directly): left/right-click callback messages queue and are extracted one by one bywm_tray_poll,kind0/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_itemyields command ids,wm_tray_popupwith no menu / an invalid slot returns 0 immediately without blocking, and removal is idempotent — the last line readsTRAY-BACKEND-OK. The probe builds like window_probe with abuild.batadded; compiling the whole stub.c brings in themoonbit_make_bytesreferenced 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 throughdispatch_menu_commandinto 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_pollinto 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.cdirectly (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_pointerreleases the capture on button-up and the session resets indrag_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
IDropTargeton this thread, inside message dispatch, the same constraint as tray/menu/hotkeys (rule 3: no C-to-MoonBit callbacks) ⇒ the C side pushesDragEnter/DragOver/DragLeave/Dropinto a process-wide ring (64 slots) and the host drains it per tick withwm_dragdnd_poll. The payload does not go through the queue - it lives in a separate 64 KB buffer, read back withwm_dragdnd_payloadas 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 returnpdweffectimmediately — so C gives a provisional value from "do I recognise this format at all" (CF_HDROP/CF_UNICODETEXT → COPY, unrecognised → NONE). Every laterDragOver/Dropreads the per-window flag MoonBit wrote back withwm_dragdnd_set_effecton the previous tick. The app-sideacceptjudgement therefore reaches the system cursor without a synchronous question into MoonBit. acceptneeds its own hook: callingon_dropperforms 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 bothon_dropanddrag_accept.- One session, not two:
core/drag.mbt's process-wide singletondrag_currentis 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_ofscanshost_windows()comparinga.window.id), anddrag_root()prefers that tree while an external session is live. - Neither direction hijacks the other:
drag_ext_windowrecords 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).
- OLE calls
- Payload wire shape: CF_HDROP wins (that is what Explorer sends), enumerated with
DragQueryFileWone 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 throughWideCharToMultiByte(CP_UTF8)and is encoded as[kind:i32 LE][payload], the same shape asdrag_encode, so MoonBit'sdrag_decodeconsumes it as-is. Coordinates are converted withScreenToClientinto client-area pixels (the stack-wide physical-pixel convention). - Pitfalls (C side, each hit for real):
IDataObjectVtbl::QueryGetDatatakes exactly oneFORMATETC *— copyingGetData's shape and adding an extraNULLis C2197 "too many actual arguments" (confirmed against the SDK'sobjidl.h).- In C mode
GetCanonicalFormatEtc's second parameter isFORMATETC *, not the C++ aliasDFORMATETC(which only exists under#ifdef __cplusplus) — writing it does not compile (C2143/C2081). DROPFILESis defined inum/ShlObj_core.h, notshellapi.h— you need#include <shlobj.h>;pFiles = sizeof(DROPFILES)+fWide = TRUE+ a double-NUL-terminated wide table are what makeDragQueryFileWaccept the blob.- COM interfaces are implemented the C way (iface as first member + a static vtable); the vtable definition must not be
const(thelpVtblfield is a non-const pointer). IID_/CLSID_ come fromuuid.lib, the drag functions fromshell32.lib,OleInitializefromole32.lib— all three were already on prebuild's Windows link line from the earlier icon/tray/taskbar batches, so nothing was added here. - Whether OLE is up must be probed once and cached:
OleInitializereturningS_OK/S_FALSEorRPC_E_CHANGED_MODE(someone already calledCoInitialize) all count as ready. Whendragdnd_supported()is permanently false the layer above skips attaching silently and in-app drag and drop keeps working.
- Pitfall (probe side): OLE hands
DragEnter/DragOverscreen coordinates, so to test a point at client (20,30) the probe must derive the screen point withClientToScreenin 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.cends withDRAGDND-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_effectobserved by the nextDragOver/Drop; payload cleared after a drop; HDROP preferred over text; plain text → Text(1); an unrecognised format answers NONE with an empty payload;DragLeaveclears the payload and queues LEAVE; afterdisablethe count is 0 and the effect -1).core/drag_wbtest.mbtadds 1 test (fed events drive the session, COPY/NONE answered per drop target fromdrag_wants, adata = Noneevent 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.mbtadds 2 (the same drag source can be dragged twice in a row with the payload rebuilt each time;acceptis wired as the hover query too).moon test core ui win351/351, full library 937/938 (the one red issi_machinefailing to readSystemManufacturerfrom the registry — this machine's environment baseline, unrelated),cd gui && moon check --deny-warnand the examples module warning-free,moon build showcase --target nativelinks, 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 theclcommand); the probe goes throughexperiment/drag_probe/build.bat(vcvars64 + explicitclflags) — 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-onlyIDataObject(12 slots) and hand it toSHDoDragDrop(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 wholeQueryContinueDrag/GiveFeedbackvtable, and Explorer's "Copy to X" drop-description bubble comes along for free.SHDoDragDropis declared inum/ShlObj_core.hand lives inshell32.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) callsGetDataHere(write in place into a caller-supplied HGLOBAL) while foreign targets often callGetData; both are real.EnumFormatEtcyields only the single current format forDATADIR_GET,SetData/DAdvise/EnumDAdviseanswerE_NOTIMPLexplicitly, andQueryGetDataaccepts only the entry whosecfFormatmatches. - 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_sourcefires exactly in theMovebranch. - Fallback contract (important):
@ui.drag_source(external=true)tries the system run first and falls back to the in-app session only onErr(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 answerUnsupportedand land back in-app. - Effect numbering:
DragEffect(Rejected/Copy/Move/Link) matches Win32DROPEFFECT_*value for value (0/1/2/4).drag_effect_of_flagaccepts only those four; combined bits (3, 7) and out-of-range values are treated as "no deal" rather than guessed as copy. ThedwEffecthanded 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_UNICODETEXTandPaths→CF_HDROPhave system-level forms;Html(needs CF_HTML'sStartFragment/EndFragmentsource-markup format) andImg(needs a bitmap /METAFILEPICT) are not offered in this batch, sodrag_data_has_system_formstops them outright and OLE is never touched.
- No
- Pitfalls (C side, each hit for real):
GetFullPathNameWmust not write back into its own input buffer. CF_HDROP wants absolute paths; the first version calledGetFullPathNameW(buf, size, buf, NULL)and a relative segment such asrel\乙.txtcame 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_onefor the wide conversion,wm_dsrc_absfor the full path); if resolution fails or overflows, the value is passed through as-is rather than swallowing the whole path.- 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\nseparator 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. DROPFILESandSHDoDragDropare not visible to the library build:gui/win/stub.conly included<shobjidl.h>, while the probe additionally included<shlobj.h>, so the probe was always green — but moon'sclinvocation fails with 17C2065: "DROPFILES" undeclared(andSHDoDragDroponly 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.cends withDRAGDND-OK(84 assertions: the 40 drag-in ones plus 44 drag-out): afterwm_dsrc_fill,QueryGetDatapositive/negative; the CF_HDROP blob fromGetDatais parsed byDragQueryFileWback into independently computed expected strings, all three entries present, relative paths absolutised (asserting characters 2 and 3 areL':'/L'\\');GetDataHerematchesGetDatacharacter for character on the first entry; the enumerator'sNext/Reset/Skipbehave,CloneanswersE_NOTIMPL,SetDatafails; feeding the same object back into this window'sIDropTargetyields 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 givesText(1). Negative cases: kinds 2/3 have no system form, empty body, no buffer left behind after failure, andwm_dragdnd_sendreturns 0 without writing an effect for out-of-range ids, NULL payload,len < 4or a NULLout_effect. The probe source is/W4-clean (a C4389 signed/unsigned comparison and two C4456 shadowings fixed) andstub.ckeeps only its two pre-existing C4996 warnings. MoonBit side:core/drag_wbtest.mbt+2 (effect numbering round-trips withDragEffect::flag, unknown flags mean Rejected; only Text/Paths have a system form, and a headless send alwaysErrs leaving no half-open session),ui/drag_wbtest.mbt+2 (external=truefalls back to the in-app session when the system run cannot start and still completes the drop, withon_externalreporting it;Htmldata is judgedUnsupportedand falls back).moon test core ui win355/355, full library 941/942 (the single red issi_machinefailing to readHARDWARE\DESCRIPTION\System\BIOS:SystemManufacturer— this virtualised machine's baseline red, unrelated to this batch),cd gui && moon check --deny-warnand the examples module warning-free,moon build showcase --target nativelinks, 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_fetchonly used theGetDataHereleg (the comment at the time justified it as "spares the IMalloc release leg"). The shell's data object usually implements onlyGetDatafor CF_HDROP and answersGetDataHerewithE_NOTIMPL/MK_E_UNAVAILABLE⇒QueryGetDatarecognises the format but nothing can be read back ⇒wm_dnd_extractconcludes "I don't recognise this payload" ⇒DragEnterpromises 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
GetDataHerefirst (we hand over an emptyGMEM_MOVEABLEand the block stays ours toGlobalFree), and on failure fall back toGetData(the callee allocates the medium, ownership lives in the medium, so it must be released withReleaseStgMediumand neverGlobalFreed). Because the release path now depends on the fetch path, the return changed from a bareHGLOBALtoWmDndBlob{h, via_medium, medium}pluswm_dnd_blob_free, and both branches ofwm_dnd_extractuse 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").
ProbeDogainedno_get_data_here/no_get_dataswitches, and 9 new assertions pin that an object implementing onlyGetDatamust 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.cappends 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.cends withDRAGDND-OK, 93 assertions (84 → 93);moon test core ui win355/355, full library 941/942 (the one red is stillsi_machine's BIOS registry reading — this machine's environment baseline),cd gui && moon check --deny-warnwarning-free,moon build showcase --target nativelinks. Real-machine re-verification stays under item S26 in_人工测试清单.md(recommended: run once withNATIVEGUI_DND_LOGand 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):
launcher_abs_pathdecided "absolute" only by a leading/, soC:\…and UNC\\srv\…were treated as relative and appended to the cwd, producing junk like<cwd>/C:\a\b.txt.- Joining always used
/, andexplorer /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. ShellExecuteWreturning >32 only proves "explorer started", soOkwas a false promise: when the path does not exist the system side succeeds silently. Addedyue_sys_path_exists(GetFileAttributesWon Windows,stat(2)elsewhere) and one check before handing over, with a newNotFoundvariant (in bothFileManagerErrorand the newOpenFileError).
- A toolchain pit blown open on the way: in this toolchain indexing a
Stringyields a UTF-16 code unit (UInt16), so rebuilding the string character by character asCharcorrupts non-ASCII paths (one 3-byte UTF-8 sequence becomes three Chars).launcher_win_paththerefore encodes toByteswith@utf8.encode, replaces byte by byte, and decodes back. - Why it was never caught: every normalisation assertion in
sys/launcher_wbtest.mbtused POSIX shapes (/a/b,/cwd) — not one Windows shape — andreveal'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: WindowsShellExecuteW "open", Linuxxdg-open, macOSopen) and@sys.open_file_with(app, path)(specified program: Windows goes through the new entryyue_sys_shell_open_with, withlpFile= program andlpParameters= the quoted path — otherwise a path containing spaces arrives as severalargv[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 answerUnsupportedbefore any path check);appmay 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\) withC:drive-relative explicitly not absolute, Windows absolute paths never appended to the cwd,launcher_win_pathleaving Chinese and already-correct strings unchanged in shape (including mixed input fed throughlauncher_abs_pathand the normaliser together), livesys_path_existsreadings (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 core333/334 (the single red is stillsi_machinereadingHARDWARE\DESCRIPTION\System\BIOS:SystemManufacturer— this machine's environment baseline),cd gui && moon check --deny-warnand the examples module warning-free,moon build showcase --target nativelinks, 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 onsys_platform(): Windows keeps the win_path round-trip, other platforms assert thelauncher_abs_pathnormalisation 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_polyswas 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_rectuse 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 contributesmin(xb, px+1) − max(xa, px)× sub-row weight, making coverage continuous in x. Fully covered interior pixels still take 256 directly, and thes0 = ceil(xa)/s1 = floor(xb) − 1test 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 = ccuts 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.mbtgains theaa_levelshelper 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 render53/53, full library 947/948 (the single red is stillsi_machine's BIOS registry reading — this machine's environment baseline),cd gui && moon check --deny-warnwarning-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::newpicks a cairo session) against the Windows dev machine (cairo cannot start, falling back to the pure rasterizer); the same pixel assertion inui/form_wbtestruns 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 ui180/180, full library 14 packages 968/968 with zero red,cd gui && moon check --deny-warnwarning-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 (
supportedalways 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_seton the GtkWindow (its coordinates are already client-area coordinates), with four signals —drag-motion(a two-phasegdk_drag_statusanswer + the first asynchronous payload fetch),drag-leave(close the session),drag-drop(only arm "drop awaiting payload", letting theGTK_DEST_DEFAULT_DROPdefault 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 pumpswm_dragdnd_pollevery tick;core/drag.mbt'sdrag_external_pumpis 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 viadata-received(GTK then emits another motion). The ENTER event is emitted fromdata-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 theaskedflag and answers "deny" without retrying. The payload lives in one process-wide buffer (only one session at a time, the same rule as core'sdrag_currentsingleton).Pitfall ② (starting a drag needs a GdkEvent, and the handling chain is outside the GTK dispatch stack):
gtk_drag_begin_with_coordinatesrequires a button event, yet this stack starts drags on the tick where MoonBit handles the pointer Move — long after leaving GTK signal dispatch, sogtk_get_current_event()returns NULL. The press stashes agdk_event_copyand 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'
SHDoDragDropruns its own message loop and blocks until release; GTK'sgtk_drag_beginreturns immediately, so this batch nestsgtk_main_iteration()in C until thedrag-endsignal (a 60-secondg_timeoutbackstop, so a lostdrag-endcannot hang the caller forever), and the effect is read back fromgdk_drag_context_get_selected_actionatdrag-end.Pitfall ④ (two sets of effect bit values):
GDK_ACTION_COPY/MOVE/LINKare 2/4/8, unlike the Win32-derivedDROPEFFECT_*(1/2/4) the facade follows — converted explicitly; several bits lit resolve to "no deal" (0) rather than a guess, matching core'sdrag_effect_of_flagaccepting only 1/2/4.Why there is no synthetic probe: the Windows
drag_probecan 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_ATOMdoes not exist (GDK spells itGDK_POINTER_TO_ATOM), andgtk_target_list_add_text_targets/add_uri_targetsrequire theinfoargument (a C compile-time error, easy to catch).Verification:
moon test win29/29 (1 new environment-reading test), full library 14 packages 975/975 with zero red,cd gui && moon check --deny-warnwarning-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):
gui/style/transition.mbt'slerp_stylecovered only the three colours + border width + radius; every field outside the set took the target value;- harder:
animatable_key's projection contained nofont_size⇒ a state switch that changed only the size was judgedat_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 ingui/ui/base.mbttook the font size from@style.resolved_by_id(id), while drawing read the interpolatedstyle_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-sizeandline-heightare animatable<length>values, and browsers indeed relayout each frame — so both join the set.font-family/displayare discrete, andopacity/box_shadowtake effect off the style-slot read path, so they stay out. - Fix:
lerp_styleinterpolatesfont_sizeandline_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); addedanim_shown_font_size(id)/anim_shown_line_height(id)andresolved_for_measure(id)(the base block plus those two), and both measurement callbacks inui/base.mbtswitched to it.resolved_for_measurebuilds a copy throughmerge(st, empty_style())before overriding —resolved_tableholds the same object, so writing the fields in place would corrupt the base block. Once an account settles,shownis 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'sgrows_layout(a)detects exactly those two andmark_dirtydispatches 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 whereapplywritesWidgetfields; the paint walk does not read their slots per frame) and the sizing layout properties. Whetherbox_shadowdeserves 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_stylegives 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-setopacitystill takes the target directly;stepstarts 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, andanim_needs_full_paintreports true (false for the colour-only account); a line-height-only change also starts an animation and reports 18.0;resolved_for_measureequals the same frame'sstyle_forfor both font size and line height, stays within [13,17] and [20,28], and leavesresolved_by_id's base block unwritten. All of these are red under the old implementation.moon test style ui core354/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.mbtcarried a Windows probeopen_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.ShellExecuteWgoes through the shell's association/protocol dispatch: an unregistered protocol does not returnSE_ERR_*, it hands the "which app should open this" picker to the user (equivalent to success), so the test neither got the expectedSpawnFailednor 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, orUnsupportedon macOS). Real opening stays in the example's buttons (S28, user-verified).open_url' macOS branch was filled in along the way (oneopen, the same shape asopen_file), soopen_supportedand the dispatch tables of the three entry points now agree. - Rule going forward: library tests must not call
ShellExecute*/xdg-open/openagainst a real target. To prove a backend is wired, use a pure predicate; to prove what actually opens, write it into_人工测试清单.mdand 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 sys186/187 (the single red is stillsi_machinereading the BIOS registry — this machine's environment baseline), and no dialog appeared on the desktop while running the suite;cd gui && moon check --deny-warnand 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):
Painter::box_shadowdrew 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;- the call site in
ui/basic.mbtpasseddx=dy=0, andStylehad only onebox_shadow : Double?with nowhere to put an offset, blur or colour ⇒ always a symmetric halo, with no sense of being cast downward; - why multi-layer had been avoided:
docs/zh/adaptation.mdrecorded "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 withglobal_alpha = total density / N(source-over accumulation is near-linear): densest against the body's edge, faded to zero atblur, and corner and edge share the same density at equal distance.Painter::box_shadowwas deleted outright, with no compatibility shell left. - API aligned with CSS:
Stylegainsshadow_x/shadow_y/shadow_colorandbox_shadownow means the blur radius — the four together are CSSbox-shadow: blur x y color(the colour's own alpha is the density; when it is not written, the built-in baseline#00000040applies). All reads go through@style.shadow_of(s).applywrites expansion = blur + |dx| + |dy| intoWidget.shadow_paint, computed from the merged base block (it used to look only at thespassed 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.mbtused only the bare rect, so the soft ring disappeared the moment the body slid out of view — that test now inflates byshadow_painttoo; ② 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_shadowalso stays outside the interpolation set (it takes effect whereapplywritesWidgetfields 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 out57 57 57 51 44 37 30 23 16 8 0(monotone, ten levels); below at y=124/128/132/136 =57/44/16/0versus above at y=48/40/36 =57/23/0⇒ the bottom casts the extra 6 px of offset; at equal distance the corner reads34against the edge's37(the old version either missed a patch there or piled into a dark blob); the body centre reads57(inner accumulation, covered by the body afterwards). - Verification:
render/painter_wbtest.mbtreplaces the oldbox_shadowcase and adds 2 (direction + gradient + corner/edge equality; and four "no shadow" inputs that must leave no ink);ui/basic_wbtest.mbtadds 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 — withshadow_ythe lower band grows and the upper shrinks).moon test render54/54,moon test ui180/180, full library 976/977 (the single red is stillsi_machine's BIOS registry reading),cd gui && moon check --deny-warnand the examples module error- and warning-free,moon build showcase --target nativelinks, 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_presentonly calledgtk_widget_queue_drawafter storing the frame, and this stack's pumpwm_waitrunswm_pump()only when an fd is ready — on ticks where a timer expires or the poll times out,ready=0means 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_presentnow 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_keycalledgtk_im_context_filter_keypressunconditionally — 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/checkboxKS_SPACEactivation naturally never received it. Fix: an IM focus gate — C-sidewm_im_set_active(id, on)plusWmWindow.im_active, filtering only when the MoonBit-side focused control is an input box (im_context_ofhas a value);App::pump_imesyncs 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_clickusually 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-warnwarning-free, examples--deny-warnwarning-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_sectionstree'sfocus_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
Stylefields (box_shadow/shadow_x/shadow_y/shadow_color) with their setters,shadow_of,Painter::drop_shadow, andWidget.shadow_paint(including the clip-window and viewport-culling inflation) were all deleted, and showcase's two shadow cards plus thedocs/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 carriesJSON.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 isWB_EV_TEXT=768andwb_pushhard-truncates withstrncpy, so any reply past 768 bytes becomes half a JSON; MoonBit'svsc_json_parsethen necessarily returns None ⇒ok=falsewith 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 (GTKevaluate_javascript_finish/ WebView2ExecuteScriptCompleted) store the full text inWbView.eval_text, the kind=7 event carries only the sequence number with empty text; a new externwb_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 web18/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-warnwarning-free, examples--deny-warnwarning-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 like0 → 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 hitspx/px+1or 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 thela=rabreakpoint) 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) andref_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 render54/54, full library 976/977 (the single red is stillsi_machine's BIOS registry reading — this machine's environment baseline),cd gui && moon check --deny-warnwarning-free,moon build showcase --target nativelinks, 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_pointerconsumed any "outside all overlays" press unconditionally and ranoverlay_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 resetopen_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.itemsgrew from(id, w)toOverlayItem{id, w, dismiss}—overlay_show_attakesdismiss_on_outside(default true, so menu/dialog modality is unchanged) andtoast_layerpasses false. Merely returning false from the layer'son_pointeris 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_pointertemporarily sets the layervisible=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_itemgainedfocusable/stepparameters (the same keyboard contract as segmented): a declaredfocus_identers the Tab chain, space/Enter selects the item, ↑/←/↓/→ cycle the selection inside the group, andon_drawtraces a theme-coloured ring at the end when focused;menu_sectionswires all its items. - Verification:
moon test ui182/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-warnwarning-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 render54/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.
macOS backend, first batch (mac platform batches 1-8, 2026-10-15; compile/link proven by the CI macOS job, on-device items still pending)
- 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_MACingui/win/stub.c(Cocoa, Objective-C),WM_STUB_MACingui/render/stub.c(ImageIO, plain C),WM_STUB_MACingui/text/stub.c(CoreText, plain C),WB_HAVE_WKWEBVIEWingui/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 withcc -x objective-c -fobjc-arc(link_configshas no compile-time fields -c_flagsis silently ignored, same finding as GTK/Pango) and hands out-frameworkflags per package. - Pitfalls CI threw at us on the first round (worth writing down so they don't recur):
- CoreText puts the text object first -
CTLineDraw(line, ctx)- opposite to theCGContextfamily intuition; CFAttributedStringReplaceString/CFAttributedStringSetAttributetake aCFRange, not location + length, and the length comes fromCFAttributedStringGetLength(CFStringGetLengthdoes not compile: different type name);- non-mac one:
killin the POSIX arm ofsys_stub.cwas reached through some other header on Linux, while macOS clang treats an implicit function declaration as an error - explicit#include <signal.h>; - Cocoa detail: ARC-managed properties can only be read with
.(s.win), while the@publicplain fields of the same struct use->- two notations in one function is a real trap;setAllowsMultipleSelection:/setCanChooseDirectories:are declared onNSOpenPanel, notNSSavePanel(a save panel is single-choice by nature); - two "warning level but genuinely broken" ones:
NSTextInputClient's marked-text method is the three-partsetMarkedText:selectedRange:replacementRange:(writing it with two parts earns only a-Wprotocolwarning, and the input-method composition channel never connects at runtime), andresignFirstResponderreturnsBOOL(declaring itvoidis-Wmismatched-return-types). The macOS stub gate therefore reads warnings too, not just errors.
- CoreText puts the text object first -
- Pitfalls, rounds two and three - "no longer declared in the current SDK" and "wrong name":
setCanShowHiddenFiles:(NSOpenPanel),setSendActionOn:(the status-item button) andsetApplicationNameForUserAgent:(WKPreferences) are not publicly declared on the macOS 26 SDK (clang reportsno visible @interface/no known instance method), while they do exist on older SDKs. One shape covers all three: an informal declaration onNSObjectkeeps the selector visible to the compiler, and every call site asksrespondsToSelector: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.WKWebsiteDataStorehas nodataStoreWithURLStoragePath:error:(that name was inferred from the LinuxWEBKIT_DATA_DIRsemantics and does not compile). The public one is+[WKWebsiteDataStore dataStoreForIdentifier:]- macOS 14+, takes anNSUUID, and needs a bundle identifier (libyue uses exactly this atnativeui/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 -NSUUIDonly accepts well-formed strings): the same directory keeps one session across runs. Where that cannot be honoured we still fall back tononPersistentDataStoreand say so throughwb_persistence().- The cookie-store property of
WKWebsiteDataStoreis namedhttpCookieStore(.cookieStoredoes not compile; clang's fix-it supplies the right name). - 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 oftype-id cannot have a nameplus a dozenundeclared identifiererrors.
- Display enumeration (mac platform batch 8,
sys/monitor.mbtplus the__APPLE__arm ofsys_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 yieldsconnected=falseentries),CGDisplayBoundsfor the origin in points, the scale derived as "current mode pixel width / logical width" to turn origins and sizes into physical pixels,CGDisplayCopyAllDisplayModesfor 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),CGDisplayScreenSizefor millimetres. The two mode predicates are no longer declared in the current SDK (CGDisplayModeIsCurrentMode/CGDisplayModeIsPreferred- clang sayscall to undeclared function; only these two were pulled from this family,CGDisplayCopyAllDisplayModes/GetPixelWidth/GetRefreshRateare still there): "current" is now decided by "same pixel size and same refresh rate asCGDisplayCopyDisplayMode", which appears exactly once in the deduplicated list and is equivalent at reading level;preferredhas 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.localizedNameis out of reach for a plain-C stub and CG has no public name - built-in panels areBuilt-in, othersDisplay-<CGDirectDisplayID>, machine-specific strings like the other platforms' output names), millimetres (0 when the engine withholds them), and built-in refresh rates (CGDisplayModeGetRefreshRatereports 0 = unknown for some built-in modes; 0 is passed through rather than a faked 60 Hz).MonitorInfoneeds 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_waitusesnextEventMatchingMask:untilDate:(AppKit's own blocking fetch),wm_wakeupposts oneNSApplicationDefinedmarker 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 overrideisFlipped- in a non-flipped viewCGContextDrawImage/CTLineDrawland 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)/255step 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_filereturns 0 and the component shows frame 1. - Menu / tray / notification are app-level:
wm_menu_applybecomessetMainMenu:(the menu bar belongs to the application, last apply wins), tray isNSStatusItem, notification isNSUserNotification- 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 15WKDownload) 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.
- Event loop: no
- Backend choices across
sys: public CLI whenever it suffices -open/open -R(reveal with selection),sw_vers/ioreg/sysctl(system info),osascriptforvolume settingsand 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,CGEventSourceSecondsSinceLastEventTypefor idle. Four families stay explicitly unsupported behind a platform gate: windowctl (Accessibility requires user consent), nightlight (CoreBrightness is private), powerprofile (pmsetdoes not match the three-scheme semantics), media (no MPRIS counterpart). - The first real
moon testrun on the macOS job: 4 red out of 851, all "declared contract not followed through in code", not broken plumbing:- The animation fallback kept the picture but lost the size: when
Animation::new_from_filereturns None,ui/gif.mbtset 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). - The underline assertion counted rows:
text_decorationwas 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. - The launcher platform gate had gone stale: the test still asserted "anything that is not Linux gives
Unsupported", while batch 5 gave macOS a realopen-based implementation. Mac now shares the Windows shape: probe with an invalid target and only require "no longerUnsupported" (OkorSpawnFailed), which opens neither a browser nor a Finder window.
- The animation fallback kept the picture but lost the size: when
- App discovery (batch 13): two things the public surface cannot give. An
.appbundle'sContents/Info.plistmay 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 giveCFBundleDisplayName/CFBundleName, solocale_nameequalsnameandcategoriesstays 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/binand/opt/homebrew/sbinmust 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 needswm_surface_containerfrom the win stub, sys needswm_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.chas always been platform-free (ffmpeg's API is cross-platform) andaudio_stub.cembeds 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-mbtprobes with pkg-config, the same route as Linux (missing → non-zero exit with abrew install ffmpeghint; the difference is that brew's.pcfiles and headers are not on the default search path, so/opt/homebrew/lib/pkgconfigand/usr/local/lib/pkgconfigare spliced intoPKG_CONFIG_PATH, and includedir/libdir are asked from pkg-config rather than hardcoded, so one script covers Apple Silicon and Intel);gui-mediahands out-framework CoreAudio -framework AudioToolbox -framework Foundationand must not copy the Linux library list:-ldllives inside libSystem on macOS and-latomichas no counterpart at all, so copying that list fails the link with "library not found". From this batch the macOS job runsbrew install ffmpegand 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-Csys_stub.c. That step is not optional:moon checknever compiles those stubs, so an API-shape error only surfaces atmoon testlink time, and there it shows up as a pile ofUndefined 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-packagemoon test+ the library build by explicit package names + examples/hello link, and a finalmoon run examples/hellothat 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 checkis warning-free; 12-packagemoon testpasses - including the readout loops that only run on a real macOS machine (appfindscanning/Applicationsand getting applications,appfind_executable("sh")hitting one; thesysfamily'shost_statistics64/kern.boottime/ idle readings;render's ImageIO decode andtext's CoreText measurement), plushellocreating 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_*):
wm_showmust clear thehiddenflag - Cocoa'sorderOut: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 ofwm_wait's enumeration" semantics rest on our ownhiddenbookkeeping, andorderFront:without clearing it turnshide_wbtest's "is_hidden==0 after recall" red.- The IM focus gate
im_activeshares GTK's semantics and default (first window defaults to 1; afterim_set_active(0)keys no longer pass throughNSTextInputContext).
System drag & drop on mac (the dragdnd eight):
- The assertions encoded one side's semantics again:
dragdnd_wbtestchecks Win32 semantics (count = number of registered windows;effectof 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. - Drag-out needs the live mouse-down event:
beginDraggingSessionWithItems:event:source:requires the actualNSEventfrom thismouseDown:and will not accept a fabricated one -mouseDown:archivesdown_ev,sendconsumes and clears it (same contract as GTK's "press archived for drag start"). - Blocking via a nested pump: after starting the session, a small loop of
nextEventMatchingMask:NSEventMaskAny untilDate:+4ms inMode:NSDefaultRunLoopMode+sendEvent:runs untildraggingSession:endedAtPoint:operation:marks done or the 60s timeout hits -cancelDragOperation:is deprecated and no longer honored, so a timeout simply returns 3. - 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.
- 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 fromreadObjectsForClasses:withNSPasteboardURLReadingFileURLsOnlyKey, falling back tostringForType:NSPasteboardTypeString.
- The assertions encoded one side's semantics again:
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.pytherefore needs its own Darwin branch (archive built with-DWB_HAVE_WKWEBVIEW -x objective-c -fobjc-arc, linked against WebKit/Cocoa plus the main repo'slibyue_win_stub_mac.a: webview needswm_surface_containerfrom 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_textlink 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:
mach_task_self_is a data symbol;_mach_task_selfis the function. Declaring the underscore-trailing one as anextern functioncompiles 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 callsvm_deallocatebeat attaching lldb to the full suite.proc_pidinfo(pid 1)returning 0 is not an error under SIP: BSDINFO/TASKINFO give 0 bytes for launchd (denied without root) whileproc_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 ... | pbcopyround-trips pure ASCII fine, but writing text with Chinese/newlines leaves the pasteboard cleared-or-unwritten while rc stays 0 (clipboard infoeven 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 throughNSPasteboard setString:forType:is intact. The fix mirrors Linux's GtkClipboard route:clipboard.mbt's macOS branch prefers theyue_sys_clipboard_gtk_*forwarding layer (sys_stub.c's non-_WIN32arm -> the win stub's mac-arm NSPasteboard implementation,availablealways 1), with pbcopy/pbpaste as fallback only. The stale "Windows/macOS get a dumb implementation" comment insys_stub.cwas 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/dashexist to keep the link - mac always paints through the pure raster path (cr_beginreturns 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 1in the WIN32 arm (RegisterHotKey is always present). Rule: after changing the arm structure of the single stub.c, cross-check the extern name list fromffi.mbtagainst the target arm's definitions one by one -grep -oE '= "wm_[a-z_]+"' | sort -upiped throughcomm -23with the arm's definitions finds everything in one step, instead of letting CI explode one symbol at a time. (wm_win_gui_markeris 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:
- A dead Windows call in the main prebuild: the mac arm brought
if system=="Windows": ensure_release_files()back intolink_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 onNameErrorbefore ever reaching the link. How to find it: the CI traceback points at the call line, andgit show <merge>^2:<file> | grep defconfirms 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,astplus one dry run of the output is enough). - 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. - A stale package list in the macOS job: the test/build steps still named
core(since split into base/shell/widget) andweb(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. - A stale media-example path in the macOS build step: the third
moon buildnamedmodules/gui-examples/systemprobeandnative-web, but 25192fe folded both probe boards away and replaced the media examples withaudio/videoin 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.
- A dead Windows call in the main prebuild: the mac arm brought
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 -/tmpis a Linux/mac landing spot, not a Windows one: appfind'saf_tmp()fixture and fsx'slist_dirtest both hardcoded/tmp, sosys_write_text_filereturned 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".vol_supported()does not mean "readable": on Windows the supported probe only proves COM starts; the missing default audio endpoint only surfaces atvol_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.- 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 testnormalizes 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_buttonnever claimed afocus_idnor wired keyboard activation (form.buttonhas the full set), so clicking it madefocusable_atwalk the hit chain and find nothing focusable → the Down branch ofApp::routegot 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), andApp::route's None branch now callsdrop_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, infiniteset_timeoutchain) has neither a stop hook nor a guard - once the widget is unmounted the chain keeps callingengine.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:Widgetgains a widget-level discard callbackon_discard(the process-levelwidget_discard_hookonly clears style side-tables; component resources are the component's own to stop), fired per widget byreport_discard(the recursive sweep behindclear_children/remove_child); media's audio/video componentsengine.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, soon_discardjust 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):
keyDownunconditionally handed every key toNSTextInputContext handleEvent:first (when im_active) - AppKit processes navigation keys by dispatchingdoCommandBySelector:(moveBackward/deleteBackward and friends) and returning YES, so they never reach our queue; ourNSTextInputClientimplements none of those actions (doCommandBySelectoris 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 neveracceptsFirstMouse:- NSView defaults toNO, so when the window is not key, AppKit consumes the first click purely to activate the window andmouseDownnever 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:returnsYES(a fully owner-drawn toolkit wants click-to-act). The full investigation is worth recording: the library chainApp::route → route_pointer → dispatch_on_path → component on_pointeris 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 ofApp::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_optionsonly wrotesetCanChooseFiles:(options & 1) == 0, whilecanChooseDirectoriesdefaults 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. AddedsetCanChooseDirectories:(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 ffmpegevery 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 tovendor/mac/, same family as the webview Loader: byte-exact sha256, GitHub/Gitee mirrors tried in order,NATIVE_GUI_VENDOR_BASEoverride, manual placement works offline; after unpackinglib/*.dylibgets 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:- 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_toolmust rewrite the id and every dependency to@rpath/<basename>,link_flagsmust carry-Wl,-rpath,<absolute unpacked lib dir>, and each library needs a fresh ad-hoccodesign -f -s -- on arm64, a modified-but-unsigned Mach-O dylib is refused by dyld. - 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 byscripts/make_mac_ffmpeg_vendor.sh(env -isanitizes PATH plus--disable-autodetectcuts brew probing; afterwards every library gets anotoolassertion that non-@rpath dependencies are only/usr/liband/System), so the five dylibs are closure-self-contained with the same license stance as Windows' BtbN lgpl-shared. - 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 thevendor-v1Release. With an empty table the route says "archive not provided yet" and the brew route is unaffected. - The double-slash split from mktemp + TMPDIR's trailing slash (exposed by the workflow's first run): macOS's
TMPDIRis/var/folders/…/T/(trailing slash), somktemp -d "${TMPDIR}/ffvendor.XXXXXX"returnsT//ffvendor.xxx; once$WORKis spliced intoconfigure --prefix, the linker normalizes//to/when writing install_name into the Mach-O, while the @rpath loop greps otool output with$WORKstill 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-changeon a signed brew library first hits the unrelated fact that-changecannot 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 withpwd -P. - 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; pointingNATIVE_GUI_VENDOR_BASEat 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. - 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_budgetin 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. WhenNATIVE_GUI_VENDOR_BASEnames 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.
- install_name carries the packing machine's absolute path: a dylib's id and its inter-dependencies are baked as
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:
NSCursorhas no public always-available hiding API —disappearingItemCursoris drag semantics andhide()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 (resetCursorRectsfires often; do not build one per call). Deliver it through the existingaddCursorRect: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 routegdk_cursor_new_for_display(dpy, GDK_BLANK_CURSOR): no theme lookup, always available. - Windows:
LoadCursorWhas no hidden variant;wm_load_cursorreturns NULL for kind 5 andSetCursor(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-upcommit=[目录](fcitx picked a candidate for the pinyin "ml"); the releases arrive normally (ev code=1 keysym=109/108/32); - Phase C (
GTK_IM_MODULE/XMODIFIERSunset, fcitx still active globally): all pass. Root cause: the eater is not ourgtk_im_context_filter_keypress(that layer is gated byim_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 atgtk_init. It runs before every widget key-press handler, and whenever this window'sGtkIMContextis focus_in'd it diverts character-key presses to fcitx (preedit/commit) while letting releases through. The GTK arm calledgtk_im_context_focus_inunconditionally inwm_on_realize/wm_on_area_focus_in(G0 condition ① wants it), andwm_im_set_activeonly flipped a flag, never focus_out — the flag governs our own filter call, not the snooper. Fix (both layers together):wm_im_set_activenow 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 inwm_on_realize/wm_on_area_focus_inare gated onim_active. Plus the previous round'sime_focussentinel (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 asev code=0with 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
- 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.
- Keep both language versions (this file and docs/zh/adaptation.md) in sync in the same batch.
- 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.sqlitesection. - Do not run
moon fmtinside 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_rectlands 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
statichelpers takingGUIDparameters outside every guard (staticstill type-checks when uncalled), sounknown 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_chainmarks dirty along the hit chain, an ancestor only when it paints that state itself (@style.applysetsWidget.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_gapand 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 toheight+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/textareaauto-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) ⇒ newmark_focus_id_rectgoing throughfind_by_focus_id; ③inputgainedclearable(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 ui176/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_fullscreeningui/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 callsSetWindowPos; fullscreen records the style +WINDOWPLACEMENTscene, strips decorations forWS_POPUPand moves onto the nearest monitor, restoring verbatim on exit; the existingWM_SIZEbookkeeping keeps deliveringResizedto MoonBit.experiment/window_probe's C probe gained two sections (size read-back + fullscreen enter/exit scene restore, fullWIN-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 withapp.hoststill None and the loop never re-armed (occurrence #2 of the charts push-point pit). Moved toNode::on_mount; window_probe gained a hotkey section (WM_HOTKEY injected via PostThreadMessageW, checking interception/extraction/dedup/exclusivity/unregistration) printingWIN-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 byApp::newand is a no-op returning 0 without a host, whilepage_charts(app)is an argument tomount_windowevaluated before the window exists ⇒ the first tick armed during construction never fired. Changed to hang onhandle(running aftermount_children), with the on-page code sample re-worded. Same batch: section descriptions gainedwrap=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-marginfree/ justifyfree2subtracted only the sum of child frames and nevergap 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 subtractgap_totalfirst. - [x] "The strip wraps when too wide" was a fake claim (2026-10-14):
uinever wroteflex_wrapand the style layer had no such property ⇒ every container was NoWrap and long strips overflowed and got clipped.Stylegainedflex_wrapper "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 explicitmin_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 setmin-height, and Yoga flex children default to min-size=auto ⇒ the viewport was pushed up to its content's natural height andmax()stayed 0 (measured: inside a 240×140 parent, viewport=676=content).min_height/min_width=0added at the library level, so all call sites (showcase section bodies and sidebar, native-app, virt_list hosts) benefit together; explicitheight=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
AttributedTextcontract 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_labelnever 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 withmd_byte_lenproducing the ranges plus greedy wrapping at construction (CJK per character / Latin per word /\nhard 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 indocs/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?