Compare commits

...
Sign in to create a new pull request.

840 commits

Author SHA1 Message Date
andodeki
a3d8f521a9 fix(game/render): port EntityLocal particle anchors from libs/render
makepad-game-sim added EmitterAnchor::EntityLocal but game/render's
ParticleSystem still matched only Entity/Point (E0004). Port the
libs/render implementation: step() now resolves (pos, yaw) so a local
offset turns with its body; add clear(). Arcade caller passes yaw.
2026-09-26 11:17:02 +00:00
andodeki
1561856949 fix(game): pass filmed body to 5-arg camera_boom_limit
libs/sim migrated camera_boom_limit to take `ignore: u64` (the filmed
body) but the game/blocks controller, game/render scene and the
gamemaker example still called the 4-arg form, so every crate depending
on makepad-game-blocks/render failed to compile (nigig TRAFFIC-P0-01).
Pass the controller's `subject` / `world.cam_third`, mirroring the
already-migrated libs/render/src/scene.rs.
2026-09-26 11:15:34 +00:00
b580de105b fix(fork): refresh widgets from upstream work + re-apply sibling reexports 2026-09-26 03:20:34 +03:00
e3a1e72e53 fix(fork): refresh ai-hub from upstream work - HttpServerHandle API 2026-09-26 03:14:42 +03:00
82c6ef3ab0 fix(fork): refresh draw from upstream work - font API skew 2026-09-26 03:11:40 +03:00
562dfde5e3 fix(fork): add Touch protocol names to makepad-test studio_msg_name 2026-09-26 03:07:45 +03:00
ba69fc28bf fix(fork): refresh libs/regex from upstream work - ascii_word_boundary 2026-09-26 03:02:48 +03:00
b211a372d9 fix(fork): base platform+test on upstream work 5f99533 + re-apply nigig BeforeStartup, NIGIG_TEST_MODE, touch protocol 2026-09-25 23:19:08 +03:00
4e8c0b5182 fix(fork): refresh libs/regex from upstream dev - add ascii_word_boundary 2026-09-25 23:02:43 +03:00
8eaa075f1f fix(fork): refresh platform from upstream dev fda40f5 + re-apply nigig BeforeStartup, NIGIG_TEST_MODE, touch/long-press/paste/IME protocol 2026-09-25 22:55:32 +03:00
91b6267f84 fix(fork): restore upstream metal.rs - fix merge delimiter artifact 2026-09-25 21:11:01 +03:00
ce45048baf fix(fork): restore upstream blocking_http streaming API + add RemotePinch alongside nigig touch/joystick protocol 2026-09-25 20:47:20 +03:00
afccda5445 fix(fork): restore nigig game-sim/math + workspace members, take upstream finance/route manifests - preserve game crates alongside dev/work split into scene/soft-body 2026-09-25 20:12:03 +03:00
99ca24dbbc Merge upstream origin/work 5f99533 into nigig fork alongside dev 2026-09-25 17:32:26 +03:00
a0f46b7d83 Merge upstream origin/dev fda40f56 into nigig fork - preserve nigig hub/web_server, take upstream dev updates 2026-09-25 17:25:35 +03:00
02d92e5cb9 Merge remote fork gitdab/nigig-makepad-test-android preserving local nigig android commits 2026-09-25 16:35:10 +03:00
cfb2a4b0ba Merge remote fork gitdab/nigig-makepad-test-android (66cc4f1) preserving local 10 nigig android commits 2026-09-25 16:24:43 +03:00
Admin
5f99533505 Merge branch 'wm-fixes' into work 2026-09-25 11:20:28 +02:00
Admin
343de346b7 calendar: the Calendars sheet is an opaque panel, and the wide layout does not float one over its sidebar -- paper (sheets, pages, panels) was the theme's inset tint, 15% black in the desktop dark theme, so every sheet showed the content under it; it is now that tint over the window background, the colour the root already showed. A Calendars sheet or page carried from the phone or landscape layout into the wide one reopened as a 320 pt panel at the phone button's position, its Close floating over the sidebar's own calendar rows; the wide layout closes it, since the sidebar lists the calendars
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 11:14:14 +02:00
Admin
f82a3c2126 draw: glass of a view that stopped drawing leaves the screen -- a glass surface records into the window overlay and stays there while its parent list does not redraw (a cached view); a texture-cached view that went invisible never redraws, so its glass stayed where it last was. The calendar switching from its phone layout to the wide one kept the phone toolbar's glass capsule over the sidebar's mini month. The overlay now also drops a glass whose parent list renders into a pass that is no longer attached (Cx::pass_attachment_is_stale, walked up the texture-pass chain)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 11:14:14 +02:00
Admin
abcb4c3445 platform (windows): a texture pass drawn before its window has a size is skipped, not a crash -- a hosted child can draw before the host's first WindowGeomChange, at dpi 0, so a texture-cached view's pass had a NaN size: the viewport check (< 1) let NaN through, CreateTexture2D failed and the unwrap of the missing render target killed the child (calendar in wm on .100: 'Websocket closed', 3 of 4 launches). setup_pass_render_targets now says whether there is a target to draw into (refuses NaN and a render target the device could not create) and draw_pass_to_texture skips the pass
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 11:14:14 +02:00
Admin
b9882bff16 platform + wm: a hosted child redraws at its tile's new size -- two ways a geometry change was lost, measured on Windows (.100) with wm hosting clock and calendar: after a split or a resize the child kept drawing at its old size and the tile showed that frame stretched or squeezed into the new rect (child logs: the WindowGeomChange never arrived, painting stayed at 2268x1428 against a 560-wide tile)
- The child's Tick drains the network queue for HTTP and script sockets (dispatch_network_runtime_events), and that queue also carries the host socket its hosted loop reads. A host batch landing during a Tick went through dispatch_studio_msg, which drops the loop's own messages: WindowGeomChange, Swapchain, Tick. The drain now parks host-socket responses in Cx::studio_backlog once a loop owns the socket (its first read), and the loop's next read takes them first, in order. Before: 1 in 2-3 split runs lost the geometry; after: 6 of 6 runs delivered every geometry sent. Applies to the macOS and Linux X11 hosted loops too; Android hosted never drained the network in its Tick
- wm's run view cleared its bootstrap (the geometry resend) on any present, so a frame already in flight when the tile changed size cancelled the geometry before it was sent; a present settles it only after the first bootstrap beat has sent the messages

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 11:14:14 +02:00
Admin
d51069baea Merge branch 'win-deck' into work 2026-09-25 10:53:36 +02:00
Admin
3c56f0cf08 Merge branch 'd3d-first-draw' into work 2026-09-25 10:53:36 +02:00
Admin
2b05e260c1 Merge branch 'mcp-reverse' into work 2026-09-25 10:53:35 +02:00
Admin
641ef10a1f civil-time: one local wall clock for every app, and it knows the zone on Windows -- the Clock app said "UTC" on Windows because its localtime_r copy was Unix-only; clock, calendar, task, mail and wm each carried their own copy (mail's macOS-only, wm's with a separate GetLocalTime path). makepad_civil_time::local() now breaks an epoch second down by the OS's own zone rules (localtime_r on Unix, FileTimeToSystemTime + SystemTimeToTzSpecificLocalTime on Windows, so DST applies at that instant) with the UTC offset, and all five apps use it
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 10:22:16 +02:00
Admin
c023dbd28b builder: an app edited after its build shows as needing a compile -- besides a newer release, the Builder reads Cargo's dep-info for the built app (target/release/<binary>.d) and shows 'compile' when any source it lists is newer than the app or gone, so changes made by a coding agent or the person are visible in the menu after an update; the check is cached per app (rechecked when the app is rebuilt or after 5 s)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 10:19:39 +02:00
Admin
48f7eabac1 wm: opening an app rebuilds it when its binary is out of date; warm instances never build -- the rule is that pre-warmed ("hot") clients only start when nothing has to compile, not that nothing compiles: opening an app (a click, F10 for the pane) builds it when its binary is missing OR older than any source cargo's dep-info (<bin>.d) lists, on every platform, then runs it in the same tile. The warm pool, the pane at startup and previews run only built, up-to-date binaries; a stale one is not warmed (remembered by its build time, so the check runs once per build) until an open rebuilds it. So no build of wm's own ever holds cargo's lock ahead of the app the person opened. A build logs to wm-client-<id>-build.log, which the app's own log no longer overwrites. On .100 with the Builder's environment: startup ran no cargo and left the stale terminal cold; Photos opened 10 s after start built and drew in 9.9 s with no lock wait; the stale terminal rebuilt on open (4.1 s) and was warmed again after
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 02:08:06 +02:00
Admin
d4f8533e42 platform (windows): a popup opened for the first time draws its rows -- AI provider dropdown, Styles menu and every other first open showed only the menu's background
Measured on .100 with apps/aichat and a draw-call log: the popup's rows use two
shaders created on its first draw (menu item background and text). D3D11
compiles them on the pool and skips their draw calls until they are installed;
the background's shader already existed, so the panel drew empty. The finished
compiles were only picked up inside a redraw (hlsl_compile_shaders ran under
need_redrawing), and a window at rest has none: the finished task raised the
internal signal, the loop woke and went back to sleep, and the rows stayed
missing until the next input. A startup shader that finished after the last
startup redraw waited 17 s for the click that opened the menu. Warm shader cache:
the same empty panel. Draw order is fine: the rows follow the background in the
popup's overlay list, above it in depth. Metal compiles inline and was correct.

- hlsl_adopt_shaders installs finished compiles; the Signal handler runs it
  when the internal signal is raised (one wake per finished task, no polling),
  and every paint tick runs it before it draws (and the stdin host's tick).
- Once the startup set is installed, new shaders compile on the UI thread
  before the frame renders, as Metal does, with a 1 s budget per frame and the
  rest on the pool. Startup keeps compiling on the pool. Measured first open
  with a cold cache: 26 ms + 19 ms, the menu complete in its first frame.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 02:03:03 +02:00
Admin
10cc3eb7dc wm: the clock and calendar know the date on Windows -- local_now() had only a POSIX (localtime_r) version and returned None on Windows, so the date view fell back to January 1 2026 and the bar's clock had no local time; Windows reads it from GetLocalTime
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 02:02:00 +02:00
Admin
6c05f3d43d ai: Connect hands the .mcpb to Claude Desktop itself -- Windows associates no program with .mcpb, so opening it with the OS opener did nothing there; Claude Desktop takes the bundle's path as an argument, running or not, and shows its install dialog
- Windows: the Store package's App Execution Alias (%LOCALAPPDATA%\Microsoft\WindowsApps\claude-desktop.exe), else the running Claude Desktop's own exe (never Claude Code's claude.exe), else the installer's AnthropicClaude\claude.exe or Programs\Claude\Claude.exe; started with no console window.
- macOS: open -a Claude <bundle>, when Claude.app is in /Applications or ~/Applications. Linux: xdg-open as before.
- No Claude Desktop found: the panel says "Claude Desktop isn't installed".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 02:01:34 +02:00
Admin
8f4e447367 builder: CUDA crates build in an installation whose path has a space -- nvcc cannot create its intermediate files under a TMP with a space (Downloads\makepad-builder (2)) or in the verbatim \\?\ form canonicalize gives on Windows ("Could not open output file '\\?\...\tmp/tmpxft_...'"), so with CUDA installed every crate with CUDA kernels failed (aichat and files, built by wm, on .100). On Windows TEMP/TMP/TMPDIR are the tmp folder's plain short (8.3) path; measured on .100: the verbatim and spaced TMP fail, the short one compiles
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 01:54:08 +02:00
Admin
8a7758947d wm: the whole deck out of a Builder's source, compiled only when the person opens an app -- the Builder starts wm in the source it downloaded (with its compiler environment), but wm took a checkout to need local/, which a download has not: it fell back to sibling binaries, listed only the apps already built next to it (calculator, calendar, mixer, route) and logged "binary not found: aichat / terminal / browser / files / task". A checkout is now the workspace with apps/wm in it. And wm compiles nothing behind the person's back: every child is its built binary (the checkout's release build in CARGO_TARGET_DIR, the Builder's shared target, or the installed sibling), started directly instead of through cargo run; the warm pool, the AI pane at startup and previews start only apps that are built (seven cargo runs at startup queued on one target lock while the clicked app said "Waiting to compile" behind them). The menu lists every deck app, "compiles when opened" under those not built; opening one runs a single hidden cargo build --release -p <pkg> --bin <bin> whose progress bar is read into the tile ("compiling 103/107 crates · makepad-widgets…"), and the app starts in that tile when it is built, or the tile says the build failed and names its log
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 01:54:08 +02:00
Admin
482b2b1f56 wm: children open no console windows on Windows -- wm is a windowed program started detached by the Builder, so it has no console; every console program it started (the pre-ac1917d70 Builder linked every app as a console program: makepad-mixer.exe, wm.exe, calculator.exe are PE subsystem 3 in the user's install; and cargo whenever wm ran out of a checkout) got a console window of its own, which showed over the full-screen desk as "a terminal inside wm" and, with cargo, one per warm client. Every child wm starts (clients, curl for themes) and every sibling wm_api starts gets CREATE_NO_WINDOW; hosted children talk over pipes and the hub, never a console
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 01:53:58 +02:00
Admin
e8dae096f5 builder: menus wrap around -- Up on the first row goes to the last and Down on the last to the first, in the Rust TUI and the shell previews' lists
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 01:53:40 +02:00
Admin
e831c4520f Merge branch 'mcp-reverse' into work 2026-09-25 01:39:16 +02:00
Admin
7fdb4dd742 builder: the compile bar's total is the app's own crates -- the estimate takes the packages from cargo tree -p <app> (resolved for that package alone; cargo metadata resolved every workspace member's features and counted about twice as many, so an app whose other crates were already built showed 3 / 70), and after a successful build the exact count is kept beside the build output (<binary>.crates) for the next one; fresh crates still leave the total
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 01:35:44 +02:00
Admin
01911e2374 builder: an incremental compile's bar follows what really compiles -- crates Cargo reports as fresh (already up to date) leave the total instead of adding to the count, so the bar no longer jumps to half at once and crawls through the rest
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 01:20:01 +02:00
Admin
9305341f5b Merge branch 'ci-disk-space' into work 2026-09-25 01:17:56 +02:00
Admin
ff61d1e8ef ci: a full disk empties the build output before the run, not every row after it -- the mini's wall went red on workspace ("check host": failed to write full.rmeta: No space left on device), apps/browser ("comes up": grab write: No space left on device) and apps/calculator on ac1917d70: the volume had 104 MB left, 207 GB of it the checkout's target (16 target triples, 59 GB host debug). Cargo never removes what it built for a feature set or dependency graph that is gone: each triple held 8 to 10 builds of makepad-widgets alone since 09-21, and tonight's makepad-widgets features added new ones for every crate above it on every triple. The sync step now counts the build output (stopping once it reaches the free space; 434k files on the mini) and, when it is as large as what the volume has left, removes it and the run builds from cold: the worst a run adds is one more copy of what is there.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 01:16:33 +02:00
Admin
b6a99cb381 wm: starts on Windows -- the desk no longer overflows the 1 MB main stack
wm.exe died at startup with "thread 'main' has overflowed its stack"
(0xc00000fd) on Windows, where the main thread gets 1 MB against 8 MB on
macOS and Linux. The startup chain is shallow (~85 frames) but five of its
frames were huge: ShellIcons (41 DrawSvg, ~110 KB) sat by value in every
ShellDraw, PhoneSurface carries one ShellDraw and WmDesk carries a
PhoneSurface plus its own ShellDraw (286 KB). Each constructor layer
(WmDesk factory, WmDesk::script_new, PhoneSurface::script_new and
script_new_with_default, ShellDraw) held its whole value in its frame:
about 970 KB in five frames. Reproduced on macOS by linking wm with a
1 MB main stack (same overflow).

script gets a transparent Box<T> (ScriptNew/ScriptApply/ScriptHook forward
to T: same type id, proto, default and apply), and ShellDraw boxes its
icons. The same chain now takes ~305 KB (WmDesk 286 -> 69 KB, PhoneSurface
140 -> 30 KB, ShellDraw 117 -> 7 KB); the 1 MB-stack build starts and runs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 01:13:29 +02:00
Kevin Boos
fda40f5650
PortalList: stop following the end when scrolling to an earlier item (#1261)
`smooth_scroll_to()` never cleared `tail_range`, so on a list that was following
its end (e.g., a chat timeline restored at the bottom), each draw pulled the list
back down while the scroll animation moved it up, and the target was never reached.

Now scrolling to any item but the last one stops following the end and drops any
pending tail adjustment. Scrolling to the last item still resumes tailing once it lands.
2026-09-25 00:56:26 +02:00
Kevin Boos
03705413e7
Constrain Splash external I/O to the host service bridge (#1243)
* Constrain Splash external I/O to the host service bridge

* Validate untrusted Splash source with the host I/O restriction

* Give strict Splash validation a disposable storage jail

* Close the remaining ways out of the Splash host I/O restriction

- Keep CachedWidget singletons per heap, and don't register CachedWidget
  or WindowMenu in restricted isolates.
- Don't emit Html/Markdown link URLs as actions from a restricted isolate,
  and ignore its menu bar updates.
- Suppress clipboard copy/cut hits for any restricted isolate, not only
  within Splash.
- Give script calls made while an isolate is installed the same budget.
- On web, let package resource fetches skip the guest I/O guard.
- Name the validation jail without the wall clock, and skip it on wasm.
- Make the host I/O tests fail when their guards are removed.
2026-09-25 00:56:09 +02:00
Admin
0f39445982 ai: Claude Desktop drives any app with the F10 panel -- pick "Claude Desktop" in the panel's provider menu and the app serves its tools to it over MCP
- The loopback MCP server moves from Director into libs/ai/services (mcp::server); Director re-exports it, its lane tokens and tests unchanged. The dispatcher now names the server and its instructions; a TokenStore can live in memory only (ephemeral).
- mcp::host: while Claude Desktop is the provider the panel serves its registry's tools (service__tool names) on an ephemeral 127.0.0.1 port with a fresh bearer token, and writes ~/.makepad/mcp/<exe-stem>.json {pid, port, token, title} (0600 in a 0700 dir), removed when the provider changes or the panel goes. Calls queue to the UI thread and run through EngineCore::call_external: a card in the transcript, destructive calls held for the person's confirm (the pane opens for it), the result sent back when the card lands.
- mcp::mcpb: "Connect to Claude Desktop" writes <exe-stem>.mcpb (a stored zip, manifest_version 0.3, binary server = this executable with --mcp) and opens it so Claude Desktop shows its install dialog.
- platform mcp_relay: `<app> --mcp`, checked first in app_main before any Cx, window, GPU or audio, relays newline JSON-RPC on stdio to the running app's endpoint. It answers initialize/ping itself and tools/list from the app's last list while the app is down, starts the app (detached, MAKEPAD_AI_PROVIDER=claude-desktop, engine up with the pane closed) on the first call that needs it, waits up to 20 s for its file, and exits when stdin closes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 00:43:05 +02:00
Admin
ac1917d70c windows: desktop apps open no console window, and still speak through pipes -- the Builder links apps as windowed programs (/SUBSYSTEM:WINDOWS, mainCRTStartup), so starting one from Explorer or the Builder shows no cmd window; app_main joins the parent terminal's console only when the process has no stdout (started from a terminal), so a pipe (an MCP client starting --mcp) and console builds keep their stdin/stdout untouched
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 00:26:00 +02:00
Admin
952cdc8b12 builder: the Windows exe is built for size -- cross-windows.py compiles the Builder with fat LTO, one codegen unit and opt-level z (29.8 -> 21.8 MB measured before the widget trim)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 00:14:18 +02:00
Admin
971c90f47a ai hub: a local chat with no model says so once, plainly -- after 'no model' the worker handed the line that just failed back to itself and retried it forever ('listening for the fleet' in a loop); it now waits for the person's next line. The message reads 'No local AI model is installed yet. Put a model file (.gguf) in <weights>, or pick another AI above.' and the fleet routes that were tried go to the log
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 00:14:18 +02:00
Admin
fcdb8694d5 platform (macos): the display link is kept until it is invalidated -- the CADisplayLink from NSView displayLinkWithTarget: was stored without a reference of our own; closing the last window releases the view's link before the deferred WindowClosed retires the window, so invalidate hit a freed link and macOS 15.7 aborted on its unfair lock (EXC_BREAKPOINT in retire_cocoa_window) on every app exit. The link is retained when created and released after each invalidate.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 00:14:18 +02:00
Admin
9cd063cb3e Merge branch 'widget-features' into work 2026-09-25 00:13:30 +02:00
Admin
2e176aaea5 widgets, builder: the big widget families are features, all on by default, and the Builder takes none of them -- makepad-widgets gains tweaker (with reflect and the theme store; needs dock and data), charts (chart, chart shapes, waveform; needs color), color, data (data grid, tree view, file tree), dates (calendar, date and time pickers), chat, dock, rich_text (text flow, rich text, HTML, Markdown, LaTeX maths, log list; makepad-html, pulldown-cmark and latex-math become optional), dropzone, vector, glass and nav_menus (floating action, radial, pill-nav, line and hamburger menus) and command_palette. Each gates its modules, re-exports and registration; the core stays unconditional. Without the tweaker a Window's Tweaker is an empty hidden view, as the voice wave is without voice, and the window, widget tree and fab panel skip their tweaker and dock hooks. wm_theme and wm_api take no families; the terminal app keeps them all through a default all_widgets feature so shared target dirs build makepad-widgets once. The Builder (tools/makepad_builder) takes widgets and terminal without defaults: macOS release 30.04 MB -> 24.41 MB (stripped 24.39 -> 19.70 MB, __text 15.96 -> 12.62 MB), window and TUI unchanged (identical grab), no script errors in its log. Checked: cargo check --workspace, widgets with no features and with each feature alone, widget lib tests with and without defaults (1849 / 1100 pass), Builder on macOS, Windows and Linux targets.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 00:10:52 +02:00
Admin
e1778776e9 audio_route example: print the samples that reached the processor each second -- a silent application gives no callbacks at all on macOS (0 samples, not silent buffers), which is what consumers idle on
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 00:04:41 +02:00
Admin
6b22295eae builder: CUDA whenever the machine has NVIDIA -- on Windows with an NVIDIA driver, Install build tools includes the NVIDIA CUDA Toolkit as one more step (its agreement on the same consent screen), an installation that has the tools but not CUDA offers just that step at start, and every build and launch uses CUDA exactly when the driver and the private toolkit are there (cuda::build_with); the CUDA row's on/off switch and the installed-cuda record are gone
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 23:17:24 +02:00
Admin
79ea9dc876 platform (windows): half-float RGBA textures have four channels -- TexturePixel::RGBAf16 was created as DXGI R16_FLOAT, one red channel, so on D3D11 the relief buffer lost its green, blue and height (a green display lit its neighbours red) and the gauss chain's blur levels lost everything but red; it is R16G16B16A16_FLOAT, as on Metal and Vulkan
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 23:15:27 +02:00
Admin
1530e2818b builder: the menu opens on the first of YOUR APPS -- the start-up setup's work page left Build tools as the row to return to, so after a first install the cursor sat on Build tools; that is forgotten before the menu opens, while work started from the menu still returns to its row
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 23:11:29 +02:00
Admin
e6e18588c3 builder: wait out Windows security on fresh Rust -- moving the unpacked toolchain into place failed at once when a scanner still held files from the rustc check (Windows refuses to rename a folder with open files), which showed 'still scanning' to a new user; the rename now retries with the same bounded waits as the rustc check, both about 30 seconds in all, with a 'waiting for Windows security' status
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 23:08:00 +02:00
Admin
d02335e27b builder: no menu flash after log-in -- the log-in screen checked the email through refresh_licenses, which switched to the main menu for its busy line, so the menu showed between typing the email and the setup screens; that check now keeps the log-in page and shows its busy line there
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 22:55:29 +02:00
Admin
706f8f295d builder: the menu waits for a choice -- after log-in, graphics, agreements and build tools the Builder stops at the menu with the first app selected instead of downloading, compiling and opening Scope by itself
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 19:56:17 +02:00
Admin
addc9e9c7e builder: a HEAD request is a HEAD -- fetch_method_progress sent every method but POST as GET, so asking the three Rust archives' sizes downloaded all of them (about 180 MB, no progress shown) before the real download started; HEAD now uses the HEAD method and an unknown method is an error. builder.log lines carry the seconds since start
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 19:27:33 +02:00
Admin
563cf38a9e widgets: relief surfaces can swing their light toward a point -- set_relief_light_swing turns every surface's own light direction toward a given position (keeping its brightness), so a moving light re-shades bevels and shadows across a whole relief window; unswung windows pay one comparison per draw. glass and hello_world render pixel-identical
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 19:21:33 +02:00
Admin
dc1b8d193f builder: no Scope command -- the menu row that put a scope shim on the user PATH (registry on Windows, ~/.local/bin and a shell profile line on Unix) and its repair on every start are gone; the Builder never edits the user PATH or shell profiles
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 19:18:11 +02:00
Admin
9f4c781444 builder: apps get the icon their package declares -- after the workspace's resources/icon.icns or icon_1024.png, the icon comes from icon = "…" under [package.metadata.makepad.desktop] in the package's Cargo.toml (as cargo makepad reads it), so Makepad Amp's exe and macOS bundle carry apps/music/resources/music-icon.png instead of the default
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 19:18:11 +02:00
Admin
f89ef3d4e2 builder: the compile line is the bar and n / total crates -- no crate names flickering past; the Builder's own rebuild (with Scope) counts its crates too, with the same --no-default-features it builds with, and an app's features are passed to the count
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 19:11:55 +02:00
Admin
c490c709cf builder: Compile shows a real bar -- the crates the build produces are counted beforehand from cargo metadata for this platform (packages reachable through normal and build dependencies, their build scripts, the app's binary) and the bar reads n / total crates; a component's package record ends with its bar, so Compile no longer showed the source download's 2 of 2 as a fixed 50%
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 19:10:42 +02:00
Admin
6b6fbf003b platform, widgets, ai: a restyle recompiles nothing it already has, shows only complete frames, and a window can own its caption -- generated shader helpers were named after their module's heap position, which moves on every restyle, so the Metal source text changed and every style switch recompiled ~20 pipelines, even back to a loaded style; helpers are now numbered by first call order within the shader, so a warm switch compiles 0 and misses 0 draws (was 47 missing per frame for 0.5 s). A style switch holds the old frame until three frames in a row are complete (no draw skipped, no pipeline pending), then crossfades; cx.pipelines_pending() and per-reason skipped-draw counts back it. A window can hide its stock caption and name its own drag region (set_drag_region), enforced on every event and draw so no re-apply brings the stock bar back; app keys in that region answer the drag query as client. The audio analyzer keeps a stereo history (latest_stereo). The AI engine runs a CLI model's tool calls as they stream in and shows the text between them
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 18:47:36 +02:00
Admin
5986d4f8ee audio_route: one API on macOS, Windows and Linux, monitoring by default and processing only when asked, plus shaders that discard a value compile to WGSL
audio_route: the Core Audio process tap gains Windows (WASAPI process loopback; the source is ducked through its ISimpleAudioVolume) and Linux (the PulseAudio/PipeWire monitor of the source's sink; ducked through its stream volume) backends behind the same Route API. A route only monitors by default (copy for meters and visualisers; the app's own output is untouched). Route::set_processing turns processing on: the source is ducked to 2^-13 and boosted back by exactly 2^13 in float, so the processed signal is bit-exact. RouteConfig.state_dir keeps the duck state, so restore_after_crash puts a ducked volume back after a crash.

script/wgsl: an if/else whose branches end in a value nothing uses wrote that value as a bare statement (_phi_353;). Metal, GLSL and HLSL accept that, WGSL does not, so DrawMenuRow failed on Vulkan and menu rows lost their drawing (the theme chips on Linux). ShaderBackend::write_discarded_expr writes such values as `_ = expr;` for WGSL and keeps void calls as statements, in both places that emit leftovers. MAKEPAD_TRACE=shader.wgsl lifts the two-error log suppression.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 18:45:23 +02:00
Admin
6b199fddc7 builder: a first build resolves online, the Rust row and bars move when the work does, and the window fits the TUI -- cargo ran with --offline whenever a Cargo.lock existed, so a new private CARGO_HOME could not resolve the Android/OpenHarmony crates in the graph (hilog-sys) and Scope failed to build; Rust becomes the current step with its first package and its manifest downloads with a bar; unpacking source reports the pack import and file checkout with their totals instead of holding the bar at half; the window opens at 664 x 650, the TUI's 80 columns and menu height
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 18:40:46 +02:00
Admin
6d9830e778 builder: long work runs on its own page -- installing build tools, CUDA, an app (download source, compile, open) and Update show a checklist of the steps that actually run, the current one carrying its bar (one forward-only bar over all source packs; compile counts crates), then return to where they started with the result; a failed step shows a red ✗ and its reason with ⏎ back; the Disk action reads clear build data
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 17:57:50 +02:00
Admin
624c8e3bc3 builder previews: long work gets a page of its own (steps as a checklist, the current one carrying its bar, footer working · ctrl+c stops), Account shows ✓ email, and the Disk action reads clear build data
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 17:54:42 +02:00
Admin
27a2bd09fa platform (windows): every window keeps animating with several windows open -- the per-window frame beat held back a window that was keeping a spare DXGI credit (it never presented again, which is why things only moved during a window drag), stepped animations only on the first-registered window (a move re-registers it last), and always served the first ready window; the wait now serves the least recently served window first, the animation clock steps once per refresh on whichever beat arrives first (wall-clock fallback), only windows actually waiting for a beat are held back and a held pass keeps its previous paint state, and a finished move/resize drains the extra credits it created (Present had been blocking the UI thread a frame deep). An instance upload with zero instances no longer asks D3D11 for a zero-byte buffer. Amp with its pop-out: 32/32 presents per second on the 32 Hz remote display with one or two windows and after moving either.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 17:52:11 +02:00
Admin
b3159c7d35 builder: a refused email says "Email not recognised" and keeps what was typed for fixing
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 17:48:30 +02:00
Admin
a65be5428d builder: a refused email opens the editor again with what was typed and says why, and the app section is YOUR APPS -- when the server answers not enabled / unknown, the log-in screen and Account keep the text with a yellow reason line (Return checks again, Esc keeps the previous email); offline or server errors do not; YOUR LICENSES is renamed YOUR APPS in the TUI, the previews and AGENTS.md
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 17:33:08 +02:00
Admin
06ac6e4357 cuda: builds link only the CUDA toolkit they are given, never a system install -- makepad-ai-cuda's build script takes its toolkit from MAKEPAD_CUDA_ROOT alone; CUDA_PATH, CUDA_HOME and the Program Files / /usr/local / /opt scans are gone, because an exe linked against a system toolkit then needs that toolkit's DLLs on PATH wherever it runs (music.exe on a box with CUDA 12.4 installed but not on PATH stopped with "cublasLt64_12.dll was not found"). The Builder passes its private toolkit as MAKEPAD_CUDA_ROOT when CUDA is enabled; the Arch fleet provisioning opts in with MAKEPAD_CUDA_ROOT=/opt/cuda.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 15:56:53 +02:00
Admin
c052c25da4 builder: the GPU notice is remembered -- choosing I understand writes graphics-notice-read in the install folder, so later starts on Windows and Linux skip it; the Graphics row reopens it and MAKEPAD_GPU_ACK is gone
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 15:21:26 +02:00
Admin
59d4759c6a builder: one forward-only bar per setup component, green checks on finished setup rows, a black Builder window and a smaller Windows ZIP -- Build tools, Windows SDK, Rust and CUDA each show a bar totalled from the manifests' real byte sizes (download + unpack, cache hits count as done) under a "✓ Build tools ● Windows SDK ○ Rust" line; Account, Graphics, Agreements, Build tools/Rust, Xcode tools/System packages and CUDA show a green ✓ when done; the Agreements screen can agree or disagree (recorded in agreements-accepted, withdrawing never deletes anything); Update reads "check for updates"; builds use --offline instead of --locked (the pinned commits are the lock, a stale Cargo.lock no longer fails a build); macOS bundles are named after the catalog title (Makepad Scope.app, Makepad Amp.app); the Builder window is black; the Windows ZIP leaves out the CJK and colour-emoji fonts the Builder never draws.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 15:13:46 +02:00
Admin
ccf108c778 git: worktree status matches git on real repositories and can be cancelled -- status looks at symlinks themselves (a tracked link to a folder no longer fails the whole status; untracked links are one entry), shows an untracked nested repository as one dir/ entry and skips tracked submodules, uses the real GitIgnore matcher (the old one missed a root **/target/ and reported ~147k false untracked files) with the user's global excludes file, and gains status_cancellable / compute_status_cancellable so long walks honour a budget. Output is identical to git status --porcelain=v1 on makepad (1680 records) and scope.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 14:41:51 +02:00
Admin
3993ebb2f7 sheets, score, git, home: saves cannot cut a file short or overwrite another one, and checkouts cannot write outside the repository -- Sheets and Score save through a temp file renamed into place, refuse an empty path, and ask (a second Save) before replacing a file that is not the document's own; Sheets says when formulas were saved as values. The git library rejects tree entry names "", ".", "..", ".git" and any containing / \ or NUL, and refuses to write or remove through a symlinked parent folder. An empty MAKEPAD_HOME counts as unset everywhere it is read, so caches never land in the current folder.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 14:20:07 +02:00
Admin
27ae3e2fad director: coding agents ask before acting unless you choose otherwise -- the hard-coded --dangerously-skip-permissions / --dangerously-bypass-approvals-and-sandbox flags are gone; Settings > Coding agents has, per agent (Claude, Codex, Grok), a "Let this agent act without asking (bypass permissions)" checkbox (off by default; adds the provider's own flag, Grok --always-approve) and a custom-arguments field (empty by default; split like shell words without a shell), stored in agent_arguments.ron beside settings.ron and read at every launch and resume.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 14:20:07 +02:00
Admin
cb97f351b7 files: the Files app deletes nothing and never overwrites -- Move to Trash, Delete Permanently, their shortcuts, the two-press confirm, the AI "trash" tool and the trash/delete operations are removed; a per-platform "Show in Finder / Explorer / File Manager" row (open -R, explorer /select, xdg-open on the parent, no shell) takes their place so deleting happens in the system's own file manager. Undo of copies and new folders (which deleted what they made) is gone, moves are renames only (another disk: copy instead), and moves, undo, copies and renames refuse an existing target instead of replacing it.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 14:12:27 +02:00
Admin
2389e46094 builder: the terminal scripts delete nothing and read top to bottom, and every Builder delete stays inside its own folder -- bootstrap.sh/check-tools.sh/installer.sh are rewritten as four numbered steps with plain prompts: downloads go to fixed .part names that are renamed into place, Rust components unpack straight into a staging folder (tar --strip-components) that is renamed when verified, the source is checked out in a staging folder the same way, and no rm/rmdir remains; the installer only accepts an empty folder or an earlier Builder folder, never / or the home folder, and the compile shows one counting line with the full log in build.log. In the Rust crate every remaining file delete goes through remove_inside(root, path), which refuses anything not strictly inside a validated install root; gitclone no longer removes a destination on failure, downloads and links are replaced by rename instead of delete-then-write, and clean build is cargo clean --target-dir. tui.rs is regrouped by screen with smaller functions.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 14:05:49 +02:00
Admin
90b6a05813 platform: the pipeline-skip repaint mark exists only on Apple, where Metal reads it -- it warned as never read on Android, Windows and Linux
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 13:53:23 +02:00
Admin
f0754226ec platform: Android builds without Vulkan compile again, and the font-selection test counts the hosted entry point -- android_hosted.rs used the Vulkan renderer unconditionally, so every default (OpenGL) Android check failed to compile; the Vulkan paths now build only with use_vulkan behind small helpers, and a hosted child in a GL build logs that it needs MAKEPAD=vulkan and exits. makepad_hosted_main is the fifth entry point calling new_cx_with_font_set
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 13:51:07 +02:00
Admin
41fc2e3b0a builder: the Builder TUI matches the new terminal design, Windows executables carry app icons, and every app has one -- plain full-screen menu (SETUP / YOUR LICENSES / MAKEPAD EXPERIMENTS / CODING AGENTS) with one status line and one progress bar; log in with an email kept in the install folder (downloads are no longer personalized); consent screens list each license agreement (Return opens it) with Agree to all / Cancel; a GPU notice screen on Windows and Linux; optional CUDA for Makepad Amp on NVIDIA GPUs; Apple developer tools screen on macOS; updates save local edits as changes/<app>-<date>.diff for a coding agent to merge back; "clean build" runs cargo clean. libs/win_resource writes Windows icon and version resources without rc.exe from the same icon source as macOS bundles; cargo-makepad uses it. Apps get icon.svg + icon_1024.png (tools/app_icons/render.sh). makepad-mac.sh and makepad-windows-preview.sh are the simulated visual specs.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 13:48:33 +02:00
Admin
e047d2a790 platform, widgets, audio_route, ai: window crossfades and whole-frame presents, caption controls that click, themed menus, a stereo-pair audio tap, and the AI chat on local Claude Code / Codex with images -- a window can snapshot its presented frame and crossfade it over the next one (0.25 s, Metal; other backends cut), and can hold a frame until everything in it is on the GPU so a relayout appears in one step; app controls in the caption bar answer the window's drag query as client so real clicks reach them; menus scroll within the window and keep a readable text/background contrast, drop-downs take their popup look from the host; relief surfaces gain a texture light knee, spill tint and screen colour maps. audio_route taps a player's output on the device's own stereo pair (no system downmix, device rate and channel order) and reports its format and permission without prompting. The AI chat can use a logged-in Claude Code or Codex CLI as its model (tools as tagged text blocks), attach dropped images, and pick its model; the speech lib gains opt-in GPU pacing, a shorter audio context and a token cap; audio tags expose bpm. The remote bridge gains window resize and drag-query probes
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 13:48:31 +02:00
Admin
dc1de829e1 widgets: relief surfaces can travel like mechanical keys, and dark surfaces take less neighbour light -- ReliefView gains a timed press, a travel that moves the surface and its children down into its slot, and a latch that holds it down while its function is on, all off by default; spill_dark lets a dark surface take a fraction of the light a white one would, and near-white texture-light frames are damped harder so a white flash no longer washes dark bezels pale
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 01:38:57 +02:00
Admin
149138afc7 widgets, platform: a texture can light the relief buffer, menus take colour chips, and a style reload recompiles changed shader functions -- relief_texture_light makes any texture a light source for neighbouring surfaces: the relief pass runs after the texture's producing pass in the same frame and repaints only when that producer painted, with a soft knee (and damping of near-white frames) so a flash does not flood every bevel; the buffer gained a fifth, widest blur level so spill washes the near half of a neighbouring face, surfaces take a style-wide spill tint and ceiling, and ReliefView caps can follow a held button. MenuRow::swatch(fill, dot) puts a colour chip before a row's label. A style reload re-evaluates Splash modules under the names they had, so the object- and function-address shader caches handed back the previous shader or another template's (changes landing a reload late, or on the wrong surface); a pending style reload now clears those two caches like a file-change live edit does, keeping the code-keyed cache so unchanged shaders do not recompile. examples/skeuomorph gains an animated plasma screen lighting the keys beside it. glass renders pixel-identical, hello_world differs only in its animated GIF
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 01:11:51 +02:00
Admin
1c64cfdfeb widgets, draw: skeuomorphic surfaces light each other through an optional relief buffer -- mod.sdf.Material shades a widget from its own SDF (height profile, normals, key light, inner shadow, AO, rim, gloss, cast shadow, revolve-profile knobs, a ±1 LSB screen dither and optional grain); widgets that opt in register lit shapes (rounded box or disc with a height and an emissive colour) keyed by their draw list, and the window renders them once into a quarter-resolution f16 height+emissive buffer with its own blur chain, rebuilt only when the shapes change, so a lit LED spills onto its neighbours' bevels, raised parts cast AO between widgets, and nothing renders at rest. Material draws bind the same textures, so batching is unchanged (39 draw calls with and without). A window with no lit shapes allocates and records nothing; hello_world and glass render pixel-identical to before. examples/skeuomorph shows a Bluetooth key, lit ON/OFF buttons, a knob with an LED ring and a segmented control
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 23:49:22 +02:00
Admin
2a3db09653 director: agent lanes form a tree and Grok joins the usage bar -- lanes start child lanes the person can see, Grok's weekly allowance comes from its own billing call, and lanes are hosted by the agents binary on macOS and Linux too
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 22:34:45 +02:00
Admin
b9e58a99fe mail: the local view takes a theme-derived palette and line icons, search pages share records -- Reply and Open in Mail get icons, --size WxH checks breakpoints
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 22:34:45 +02:00
Admin
79873edaeb files: the tile view's three projections are toolbar buttons -- 2D, 2.5D and Perspective open the treemap directly
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 22:34:45 +02:00
Admin
d838ea6aae diskmap: a scan lists each macOS directory in one getattrlistbulk call and classifies files without allocating or locking -- a photo-heavy home folder no longer waits on a mutex
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 22:34:45 +02:00
Admin
f937750491 ai llm: a Metal main buffer is filled in 32 MB chunks -- no host copy the size of the whole buffer under a capped allocator
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 22:34:45 +02:00
Admin
e290165556 ai: the hub's rig-fixture tests find the asset library without the asset client -- the hub never depended on it
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 22:34:45 +02:00
Admin
371b5dc458 git: timings read a portable clock -- std::time::Instant panics on wasm; the crate reads the host clocks the platform already imports
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 22:34:45 +02:00
Admin
f0e7064364 cef: captured frames and audio carry callback clocks and a navigation epoch -- Stage's browser recording measures AV sync and drops paints from the previous page
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 22:34:45 +02:00
Admin
84368eec4b flowgraph: ordered ports take many wires, sockets can override their icon, and the canvas embeds as a viewport -- what Stage's flow product builds against
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 22:34:45 +02:00
Admin
dc0ccb4281 agents: a user touching an app under test no longer stops the agent -- input invalidates the interrupted input sequence, not the authorization to keep inspecting, driving, closing and restarting the workflow's app (user instruction 2026-09-22); CEF apps keep their browser profile per executable name, so a replacement launch passes MAKEPAD_CEF_PROFILE_DIR and never shares the user's profile with a second instance
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 22:34:45 +02:00
Admin
d15996efd9 task, cargo-makepad, widgets, audio: the task manager chooses its columns, graphs every process' network and disk traffic and installs itself as a Dock app built by cargo makepad -- cargo makepad desktop bundle -p <crate> [--install[=DIR]] builds a self-contained, signed macOS .app (MAKEPAD_PACKAGE_DIR=resources in its own target dir, every dependency's resources plus only the fonts the binary's manifest names, icon from [package.metadata.makepad.desktop], per-app usage strings, signed with the Apple Development identity so privacy grants survive rebuilds) and installs it outside target/ (default ~/.makepad/apps), replacing the per-app package-macos.sh scripts
- task: a right-click column chooser (sections as flyouts, Default Columns), every figure and graph its own sortable column, dragged order and widths saved; per-process network bytes/packets from the kernel's ntstat control socket (matches nettop, no root), disk bytes, footprint and idle wake-ups every tick; history journal v4
- widgets: data_grid_columns, one column helper (chooser, reorder, resize, fit, sort cycle, layout text) that task uses and other tables can reuse; the menu engine refreshes marks inside an open flyout; the segmented control centres its labels on the line height and no longer glides after a moved row
- svg: a stroke join never connects to the previous subpath (the diagonal through outline icons)
- platform: home::app_data_dir; script: ScriptIp body ids widened to 14 bits (16384 bodies, was 4096) with an index of 26 bits, and a clear stop instead of aliasing past the limit
- audio_route (new): tap an app's audio output through the Core Audio process tap into a host processor (equalizer, gain, limiter, analyzer) and play it; audio_picture owns the one FFT; audio_decode probes tags and length from a file's head and tail; search::fold_words; zip_file reads archives with a trailing comment

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 22:34:45 +02:00
Admin
6907154d02 wm on Android: the look switcher stays put, the iOS dock shows its icons, background apps follow a look change -- the Light / Dark / iOS pill moved between skins and crowded the clock; it now sits at one spot in every look, centred above the page dots / All apps row. In the iOS skin the dock's glass bar was drawn over its icons; the glass draws first now. An idle background child applied a look change only after it next got input (the idle-wake work in b61ab763e left it without a tick); it applies it at once, and a tile-only app shows its current tile on its own ground in the new look
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 22:34:45 +02:00
Admin
50991700b2 wm on Android: Recents shows the apps over a receding home, and the home screen switches looks -- opening Recents from Home placed every card a screen width off-screen unless a lift had paused, so only headers showed; cards are placed at once now and only a paused lift slides them in. Behind the cards the home recedes to 0.92 and fades while the wallpaper dims to the launcher's 100/255 scrim, continuous with the lift and eased on release, instead of jumping to the tonal grey surface. A Light / Dark / iOS pill on the clock row (above the page row on iOS and in landscape) switches the shell look live, hosted apps keep their processes, and the choice persists as shell.look. A paused lift no longer draws the overlay's fallback status ground as a white strip
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 22:34:45 +02:00
Admin
5ac42f026d wm on Android: the shell follows the Pixel launcher's motion -- launching from an icon grows the window from an icon-sized circle over 500 ms on the emphasized curves while the icon crossfades out (25-75 ms) and home scales to 0.97; releasing an app to Home flies it into its icon or tile on Quickstep's three springs (dock-row circle fallback), gone by 85% while home reveals from 0.85 over 1 s; the lift subtracts the 11.3 dp slop, a motion pause opens Recents with a haptic click and neighbour cards sliding in over 300 ms, >= 36 dp goes Home; All Apps is a full-screen panel rising 300 dp with the launcher's fades, home at 0.97 and hidden at 40%, opening past 40% or on a > 1 dp/ms fling. Cx::haptic_feedback reaches the Activity's performHaptic. The status band keeps the app's last sampled colour while a Recents card opens. Latency: a finger move goes to a hosted child at once with its own tick and every batched MotionEvent sample reaches it (Mail scroll touch->present 22.1 -> 15.7 ms mean). Values from AOSP Launcher3/Quickstep
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 22:34:44 +02:00
Admin
ccadef613e widgets: touch lists scroll with Android's physics -- Mail's inbox on the Pixel felt indirect and its flings wrong. Touch scrolling in PortalList and ScrollBar-driven views now shares widgets/src/scroll_motion.rs on Android: release velocity is estimated like VelocityTracker (LSQ2 over 100 ms, zero after a 40 ms rest), flings follow OverScroller's spline to its end within 50-8000 dp/s, the 8 dp slop is subtracted so the content does not jump when the drag takes over, and edges stretch briefly and firmly. Mouse, trackpad and every other platform are unchanged. Mail re-binds a list row only when its row, the selection or the layout changes instead of re-running script evaluation for every visible row each frame (4.2 -> 3.05 ms per child frame)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 22:34:44 +02:00
Admin
6487c41d3a wm on Android: gestures and surfaces settle like the phone's -- the home wallpaper's motion slows over about 5 s and stops instead of animating forever; the status and navigation bands are a flat colour fading 16 pt into the app instead of the app's stretched edge rows (Weather's clouds smeared into warped bars); swiping an app up keeps its bottom edge under the finger (Quickstep's rule), a pause opens Recents and 36 dp up goes Home; on Home a swipe from the bottom strip opens Recents and a swipe anywhere above opens the drawer; opening an app from Recents only grows, no bounce. The Clock tile no longer redraws every second
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 22:34:44 +02:00
Admin
3e01d10828 android: hosted children hand frames over with GPU fences and draw only when they have work -- Mail as a WM child ran at 20-26 ms per frame (3% of frames at 120 Hz): the child CPU-waited its GPU before announcing each frame and the WM paced it one frame per WM frame on a fixed 125 Hz beat. The child now exports a SYNC_FD semaphore per frame over the socket that carries its AHardwareBuffers and the WM waits on it GPU-side; the WM returns a release semaphore so the child never renders into an image being sampled (VK_KHR_external_semaphore_fd; children announce that they fence, and anything else falls back to the CPU wait). The WM ticks a child on its display frame only when it has work (a requested frame, input, a due timer, startup, a bootstrap), and host messages handled outside a tick ask for a frame. Idle wakeups are gone: host messages no longer wake the child's UI loop, the 2 s heartbeat, the 125 Hz beat on empty views and the 20 Hz warm-pool timer are removed, the bar no longer redraws every second, and touch times are mapped to the app clock (the phone shell treated every touch as just now and never came to rest). Mail landscape fling: 20.5/29.2 ms -> 8.48/11.6 ms (p50/p95), 86% of frames at 120 Hz; the WM home at rest repaints once in 10 s instead of ~1220 times
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 22:34:44 +02:00
Admin
801a07e884 vulkan (android): a window released on suspend is not released again -- switching away from a Makepad Android app and back crashed it (SIGSEGV in ANativeWindow_release from CxVulkan::update_surface): suspend_surface had already released the window and set it to NULL, and update_surface released it again. Only a non-null window is released now
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 22:34:44 +02:00
Admin
dedc447e68 android: rotated Vulkan windows stop rebuilding their swapchain every frame, and hosted children get touch -- on the Pixel, Mail as a WM child process could not be scrolled in portrait and ran at about 40 fps in landscape. In landscape the WM's swapchain (IDENTITY pre-transform on a rotated display) reported VK_SUBOPTIMAL_KHR on every present and vulkan.rs rebuilt the whole swapchain each frame: present went from 34-35 ms to about 1 ms and repaint from 52-66 ms to 2-7 ms once that one known Android case (extent and transform unchanged since creation) no longer rebuilds; every other SUBOPTIMAL, and desktop Wayland/X11, rebuild as before. The WM forwards the finger to a child as mouse events, so a press on a row captured the mouse and the list refused to drag; an Android hosted child now dispatches it as one touch like the Activity build (Cx::dispatch_hosted_touch), cancels included, and reports its focused text field on a cancel. Mail's toolbar buttons are flat discs with hover/press states instead of glass (the child drew its window twice per frame: 12.8 -> 7.1 ms). The WM's bottom band draws a smooth app edge stretched (Weather's sky) and a busy one in its dominant colour, so text scrolling under it no longer smears into streaks
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 22:34:44 +02:00
Admin
0c83b8c73f wm on Android: app transitions and gestures feel like the phone's -- on the Pixel, opening Clock from its live tile showed a streaked frame, the swipe up to Recents moved the app about 0.28 pt per pt of finger travel, the bottom gesture was hard to find, fading cards went grey and a closed app landed on a white card then "Loading". The WM now sizes a child's shared images for full screen before it opens (MpRunView::prepare_size), the child refuses to draw into a smaller image and the WM rejects such frames; the lifted card's centre follows the finger 1:1 while it shrinks and on release travels straight to its target; the WM draws its own 32 pt bottom-gesture strip and pill above the OS gesture band; rounded captures use fill_premul (opacity was applied twice); closing into a tile keeps the tile's last face until the child confirms a new one, holding the old capture so a live tile never draws over the foreground app. On Android a child renders one frame per WM frame into three shared images, with tick counters that resync on each acknowledgement; the warm pool is off there. Calculator's portrait toolbar uses a flat button (the glass one refracted its neighbour). A debug hook setprop debug.makepad.grab burst-<tag>-<n> saves a child's next frames
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 22:34:44 +02:00
Admin
e603d6ae84 wm on Android: apps build on the phone -- the multi-process WM's APK can now carry the toolchain, source tree and prebuilt target/ (proc-pack --proc-toolchain=<tc>, written as wm-proc-ondevice.apk), like wm-dyn's super-app, but without the engine dylib: each app is a small generated wrapper crate linking the engine statically, so an on-device cargo build reuses the shipped engine rlibs and compiles only the app. With debug.makepad.wm.ondevice=1 the launcher (--build) builds the app before loading it; builds take turns on a lock, each finished library is copied to its own file in files/run/ and loaded from there, and a build or lock wait past its deadline (300 s, 600 s for the first proc-macro bootstrap) is killed with its process group and the APK's prebuilt library runs instead. Provisioning, unpacking and the proc-macro bootstrap moved out of the WM's dylib host into libs/ondevice_build, shared with wm-dyn (streaming LZ4 parts, same stamps and paths); dyn-pack's staging was factored so proc-pack reuses it. The debuggable switch is opt-in and only for the on-device APK. Proven on a Pixel 11 Pro XL: calculator's source edited in the phone's copy rebuilt in 12.7 s and showed the change; four apps built in turn in about 6 s each
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 22:34:44 +02:00
Admin
9baf6d6854 platform, wm: hosted apps ask the WM for what they cannot do themselves -- a hosted child has no OS window and, on Android, no JVM, so the clipboard menu, opening a URL, permission prompts, file pickers and HTTP were unavailable to it (Weather sat on "Loading" as a phone child process). AppToStudio::Relay / StudioToApp::Relay (appended, tags 26/27, existing ordinals pinned) carry these requests (platform/studio/src/relay.rs); the child side routes them automatically from the ordinary Cx APIs (platform/src/hosted_relay.rs), the WM executes them and answers by a host request id mapped back to the child's. HTTP for Android children is relayed through the WM's own Java HTTPS (NDK code has no TLS and the repo takes no external TLS crate), arriving as the NetworkResponses apps already handle; a relayed request fails after 60 s without an answer. The WM serves relays only to clients it holds a live connection to; open_url allows http, https, mailto, tel and geo; picked documents are copied on a worker into a per-pick cache folder (older than a day deleted); Android save/folder pickers answer cancelled until children can write through SAF. open_url, which was unimplemented on Android, now opens the URL. linux_direct gets a clipboard of its own (platform/src/direct_clipboard.rs): the WM owns it, Ctrl/Logo+C/X/V work as on X11, and children reach it through the existing copy/paste messages
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 22:34:44 +02:00
Admin
3495ce41fa clock: a cancelled release neither opens an alarm nor switches tab -- the alarm list and the phone tab bar acted on any FingerUp; with the touch-cancel contract (237470eef) a press a scroller took away arrives as a cancelled release, which now only clears the press
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 22:34:44 +02:00
Admin
a8bf0e4dcf wm on Android: every app is its own process -- the plain makepad-wm now ships as an Android APK (cargo makepad android ... proc-pack -p makepad-wm-android) whose phone apps run as real child processes instead of dylibs loaded into the WM. The interface between WM and app shrinks to what the desktop WM uses: shared GPU buffers and the StudioToApp/AppToStudio protocol. The WM allocates each app's swapchain as AHardwareBuffers and hands them over a unix socket (AHardwareBuffer_sendHandleToUnixSocket, API 26); the child, started through the libmakepad_launch.so launcher in nativeLibraryDir (W^X allows it there), imports them into a windowless Vulkan device and renders its window pass into them (android_hosted.rs). Each app library links the engine statically, so the engine-dylib identity handling of wm-dyn is not needed. Children have no JVM: assets are read from the APK file, audio device lookup no longer goes through Java, and the startup/resize extra draw of the Activity build is repeated so the first frame carries its text. On a phone the WM asks for the soft keyboard only while the child reports a focused text field. Proven on a Pixel 11 Pro XL: all twelve phone apps launch as processes (first frame 1.6-2.4 s cold), taps reach them. Not yet: HTTP in children (the Activity build borrows Java's), clipboard/permission/file-picker relays, idle-app reclaim, and on-device builds of app libraries
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 22:34:44 +02:00
Admin
f9a8f12a00 wm: the phone shell's surfaces match the app and the grid -- in the Android skin a fixed light status band sat over dark apps, corner radii were drawn at twice their size (tiles 44 instead of 28, Recents cards 52), wallpaper seams showed at the screen edges and the home, drawer and Recents spacing drifted. The status and navigation bands now take the open app's own edge colours from a small WM-owned strip read back only after the app really changed (26 KB instead of the 5.2 MB full capture, off for good where readback is unsupported, nothing at rest), with ink chosen by contrast ratio and a wash where neither ink reaches 4.5:1; iOS gets the same bands. Android geometry follows the Material spec: the date under the 48 pt clock, 16 pt tile gutters, a 120x48 All apps target, 96 pt landscape tiles with one row of seven favourites, a 56 pt drawer search with centred Clear/Cancel, fixed row pitch, and Recents with a tonal backdrop, 24 pt icon + 14 pt title header and a 24 pt card gap. iOS keeps its previous radii, gaps and typography through skin-specific metrics
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 22:34:44 +02:00
Admin
1e44574d45 files: the phone layout tidies up -- in the WM's Android skin Files ran Browse into its view switch, showed no selected view, clipped long names and let its footer cover the last row. Browse is its own 48 pt action, Icons/List/Storage is a segmented switch that shows the current view (List stays lit after List->narrow), the toolbar is 56 pt with 48 pt targets, names are 14 pt on two whole lines, and the footer reserves 24 pt. The segment row fits the narrowest phones
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 22:34:44 +02:00
Admin
3fed1ffce7 weather, notes, finance, photos, route: the phone layouts tidy up -- in the WM's Android skin Weather drew a dark box behind its city and temperature and ran its list controls over the forecast, Notes had a 56 pt empty spacer and small targets, Finance clipped negative amounts and squeezed four stats into narrow capsules, Photos showed a filesystem path in its footer and had no way back from a deep zoom, and Route's Layers button was a missing-glyph box. Weather's scrims feather instead of boxing, its controls sit on a solid 64 pt bar, the landscape hero is compact and the header fade reaches the regrouped labels. Notes gets 56 pt folder rows, a 20 pt chevron and 48 pt toolbar targets. Finance cards use 16 pt radius and spacing, amounts fit by measuring the whole formatted value, and narrow landscape panes show the stats in two columns. Photos gets a 64/56 pt search row, a Fit action that frames the visible picked or centred picture, and a readable "N pictures . library" footer. Route draws Layers as an SVG, shortens the location button to "Locate me" and aligns its three controls 16 pt from the edges
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 22:34:43 +02:00
Admin
6c6ebff2ec platform, widgets, wm: a touch that is taken away is cancelled, never released -- in the WM's phone skin the mouse now reaches the foreground app as a touch, and scrolling Mail then lifting opened the message under the finger: a list that took over a drag had no way to tell the pressed row it lost the press, and the same happened on real phones. Cancellation is now a contract. Event::FingerCancel and FingerUpEvent.cancelled carry it; was_tap() rejects it and no long press fires. claim_finger_gesture makes one owner per finger: PortalList and ScrollBar claim only with touch travel along their own axis (mouse drags scroll as before), and every loser, ancestors included, gets its terminal cancel in the same dispatch and never moves again. Pressed rows are cancelled before a list recycles them, cancels reach hidden widgets, and captures retire only after every consumer sharing the area has seen the cancel. iOS touchesCancelled and Android ACTION_CANCEL arrive as cancels and end an internal drag without a Drop. The WM relays StudioToApp::MouseCancel (appended, existing tags pinned) only to children that advertise it; inside a child it dispatches as FingerCancel. Widgets and apps that activated, committed, dropped, flung or resampled on any release now clear their state without acting (buttons, radios, menus, DataGrid, Kanban, Carousel, WheelPicker, RadialMenu, Modal, Dialog, Popover, colour controls, video hold-to-pause, maps, the Files treemap and tiles, Weather, Clock, AIChat, fab, flowgraph and more).
The WM phone shell rides on it: the simulated finger in the desktop skin, time-based release velocity (80 ms window, stale after a 120 ms rest), one critically damped spring (k 900, c 60) seeded with that velocity for paging, drawer, recents and app open/close, an 8 pt / 1.2x axis lock latched through release, a drawer that tracks the finger 1:1 and draws opaque over the home tiles, hold-to-Recents precedence, launches that zoom from the icon actually drawn with an opaque launch card, no ghost card on close, and one cancel/reset path for rotation, resize, focus loss, style switch and keyboard navigation.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 22:34:43 +02:00
Admin
8329e1fd11 calendar, reminders, calculator, sheets: the phone layouts fit their space -- in the WM's Android skin Reminders drew a blank glass "New Reminder" pill and clipped its "Today" title, Calendar drew month rules the phone layout meant to hide and set today's circle low, Calculator left an empty band under a landscape keypad, and Sheets clipped its toolbar and showed "Your text here" in the name box. Reminders gets solid 80x48 New/Done buttons, baseline-aligned title and count, 56/80/104 pt rows, readable secondary ink and per-tile ink, and a solid priority selector. Calendar honours hidden rules, centres the today mark, snaps week dividers to one physical pixel and gives landscape a 56 pt toolbar with a solid Month/Week/Day selector whose title shrinks near 700 pt. Calculator's landscape header and five 48 pt key rows fill the 332 pt height, results fit from 32 down to 24 pt and pan beyond that. Sheets' portrait toolbar keeps name, Undo and a scrolling More sheet that holds every moved command and Insert function, the name box shows the active cell, tabs scroll with "+" and rename on screen, and a rotation no longer discards an unfinished formula
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 22:34:43 +02:00
Admin
fde31df7e3 clock: the phone layout fits both orientations -- in the WM's Android skin the landscape dial ran under the tab bar and the tab bar drew grey square corners from its blur backing. Clock now has a 56/48 pt heading row with an overflow menu holding the "Digital home tile" preference, a two-column landscape (192 pt dial beside time, date and alarm card) and side-by-side Stopwatch/Timer in landscape with widths derived from the screen, and a solid 370x64 tab bar with 16 pt corners whose height the pages reserve. Tab, date and caption ink reach 4.5:1 in light and dark. The alarm editor slides again: margin: Inset{..} failed at runtime with two errors a frame, so the sheet is translated with paired top/bottom margins
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 22:34:43 +02:00
Admin
9a12bcf7ea mail: the phone layout lines up -- in the WM's Android skin the portrait inbox had its back button on the screen edge, titles, icons and cards on three different left edges and a bottom bar over the last row; landscape stacked shadowed toolbars and a second Inbox heading. Portrait now has one 64 pt app bar (48 pt back at the 16 pt margin, title at 72), a 48 pt search, 56 pt mailbox rows with a right-aligned count slot and separators, and a solid 56 pt bottom bar the list reserves. Landscape has one solid 56 pt toolbar, a 320 pt list of 84 pt two-line rows with 24 pt trailing clearance, and the reader actions appear only once a message is selected. The desktop three-pane layout keeps its structure
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 22:34:43 +02:00
Admin
deb66b4fef draw text: the CoreText outline fallback is only ever resolved for a face whose outlines live solely in hvgl, and font-family diagnostics are the font trace topic -- a space has no outline in any font, so every face on macOS reached the fallback and would have resolved a CTFont for it (PostScript-name lookup, a cascade-list scan, then the face written to a temp file and loaded through the font manager) on the UI thread to outline nothing; the gate checks for an hvgl table with no glyf, CFF or CFF2, so bitmap faces stay out too; MAKEPAD_FONT_DEBUG read the environment on every update_font_definitions and is now MAKEPAD_TRACE=font
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 22:34:43 +02:00
ychen
09ff817679 feat(text): CoreText outline fallback for hvgl-only fonts (macOS)
Apple ships system fonts whose only outline table is the proprietary hvgl
format (PingFangUI.ttc on macOS 26+ has no glyf/CFF at all). They parse
fine — cmap, metrics and shaping all work — but ttf_parser can outline
nothing, so text silently renders blank.

When ttf_parser yields no outline, Font::glyph_outline now asks CoreText
(whose in-OS decoder reads hvgl) for the glyph path and converts the CGPath
into the same GlyphOutline commands the SDF rasterizer already consumes.
Shaping, metrics and the atlas are untouched; the fallback only fires for
glyphs that render nothing today.

CTFont resolution is layered because CoreGraphics and CTFontManager both
refuse hvgl-only fonts loaded from data or URL, and CTFontCreateWithName
silently substitutes a fallback for dot-prefixed hidden names: try the
PostScript name, then scan the system UI font's cascade lists for CJK
languages (where hidden fonts like .PingFangUITextSC-Default are
reachable), then a temp-file font-manager load. Every candidate is
validated against the ttf_parser view of the face (glyph count + cmap
probes) so a substituted font can never smuggle mismatched glyph IDs into
the atlas. Variation coordinates (e.g. wght for bold) are carried onto the
CTFont via kCTFontVariationAttribute, consistent with the HVAR-adjusted
metrics rustybuzz produces.

Also: env-gated MAKEPAD_FONT_DEBUG=1 logs font family member resolution
(resource path + byte count) while families load.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 4c59a370834189b87ee6d151b9971defb860e812)
2026-09-23 22:34:43 +02:00
ychen
6db127c82e fix(text): enable ttf-parser gvar-alloc for many-tuple variable fonts
Apple system fonts carry more than 32 gvar variation tuples per glyph
(SFNS.ttf: 54). Without the gvar-alloc heap spill, VariationTuples::reserve
fails past its stack capacity and outline_glyph returns None for most
glyphs — variable-font text renders completely blank.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit f401d4d567245809f4ede9aa2d4a4b54157367fd)
2026-09-23 22:34:43 +02:00
Admin
6941c0e86b script: the Octoscript hardening keeps a Makepad host's semantics and its speed -- an uncaught error ends an evaluation only when the host sets bail_on_uncaught_error, so by default a module keeps evaluating past one bad statement and scripts can inspect returned error values; an error raised while no script ran is reported at the Rust->script boundary instead of ending, or being caught by, the next run; cross-call try unwinding stops at the nearest root frame, so an error inside a native's callback (array.retain) never pops frames an outer run_core still executes; the equality work ceiling applies to bounded evaluations only and a scalar == allocates nothing; run_core keeps its cached opcode pointer, invalidated by an epoch every bodies.borrow_mut() advances; take_allocation_error and charge_allocation are one flag read when nothing is limited, cast_to_f64 keeps its number path inlinable and checked_index is one round-trip compare
splash_bench geomean against work, best of alternating runs: +12.9% with the series as submitted (the per-instruction Option<String>::take in take_allocation_error alone was +9.8%), -1.1% with this commit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 22:34:43 +02:00
ymote
a4c31be0b5 script: keep silenced streaming evals running past uncaught errors
The ws1 equality/fuel port made an uncaught script error Bail the whole
evaluation (vendor semantics). Splash's incremental eval_with_append_source
sets silence_errors because incomplete source inevitably raises errors that
are meaningless until the rest arrives, and its live widget tree relies on
evaluation continuing past them; with the Bail, `field := TextInput{...}`
never produced its child and
splash::style_tests::embedded_splash_restyles_its_isolate_without_replacing_edits
regressed (bisected to vm-port/ws1-equality-fuel alone).

Gate the Bail on !silence_errors: streaming evals keep drain-and-continue,
every other eval (including Octoscript's captured-sink evals) terminates on
the first uncaught error. Regression test added in vm.rs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgSir4cQyaakzPju4h6smw
(cherry picked from commit a4347332b38d0d511287006dc19f0c079604f78a)
(cherry picked from commit 6ee2aecfcb7d9296b501be5ea481caef1a4d024a)
2026-09-23 22:34:43 +02:00
ymote
ba33084d9c script: port orphaned Octoscript VM hardening (execution caps, clear_type_methods, parser diagnostics)
Items no workstream branch owned, ported from the Octoscript vendor tree:

- Operand-stack and call-frame caps: ScriptVm::with_stack_value_limit,
  with_call_frame_limit, clear_execution_limit_failures; ScriptThread
  call_frame_limit / *_limit_exceeded flags, push_call_frame,
  has_execution_limit_exceeded; run_core, CALL_EXEC/RETURN/RETURN_IF_ERR
  and handle_return skip pop_to_me and raise an uncatchable
  "script operand stack limit exceeded" / "script call frame limit
  exceeded" bail. The root evaluation frame counts toward the cap.
- ScriptNative::clear_type_methods for restricted embeddings.
- ScriptParser structured diagnostics: ScriptParserDiagnostic,
  MAX_PARSER_DIAGNOSTICS, diagnostics()/diagnostics_truncated(),
  set_emit_errors(); the existing parse_errors sink is kept in lockstep.

Tests: tests/execution_limits.rs, vm.rs
return_does_not_pop_to_me_after_an_operand_stack_limit, parser.rs
diagnostics_* tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgSir4cQyaakzPju4h6smw
(cherry picked from commit bfa4087b59abf64d30c385bea53120e0adf2f64b)
(cherry picked from commit 6a349506dd58b4f150dfa12f447385def3f1f469)
2026-09-23 22:34:43 +02:00
ymote
1f481016f4 script: port Octoscript heap/string/array/object hardening onto upstream's allocation budget
Workstream 3 of the Octoscript VM patch port (heap, strings, arrays, objects).

- Array opcode reads and writes validate the index (finite, non-negative,
  integral, representable) before touching storage: ScriptValue::checked_index
  and ScriptVm::checked_array_index, applied at every array/pod index site in
  opcodes_vars.rs and opcodes_assign.rs.
- Updating an existing untracked object field keeps its insertion order
  (ScriptObjectData::map_insert), matching the tracked path.
- Numeric string conversion handles inline and heap strings alike and yields
  a traced NaN for text that is not a number (ScriptHeap::cast_to_f64).
- Heap accounting: Octoscript's retained-heap cap is expressed over upstream's
  ScriptAllocationBudget instead of a second parallel accounting system. A
  persistent budget (heap_cap) is charged by the same charge_allocation calls
  as the scoped with_heap_allocation_limit budget, its headroom re-derived
  from a retained-capacity estimate by reconcile_heap_bytes (setup, GC sweep,
  shrink_to_fit, host boundaries). Public API preserved for octoscript-core:
  set_max_heap_bytes / max_heap_bytes / accounted_heap_bytes /
  reconcile_heap_bytes / take_heap_limit_exceeded. Refusals surface through
  take_allocation_error, so run_core bails uncatchably as before.
- Per-string ceiling: set_max_string_bytes / max_string_bytes /
  take_string_limit_exceeded, enforced on exact lengths at the store choke
  points (new_string_from_str, new_string_concat, intern_or_store_string and
  its preflighted variant, check_intern_string), plus ScriptStringSink,
  ScriptStringBuffer, new_bounded_string_with and temp_bounded_string_with
  (bounded by the string ceiling and the remaining allocation budget) for
  hosts that build strings incrementally. cast_to_string is generic over the
  sink. Byte-array parse_json builds its lossy text through the bounded buffer.
- Pod creation is charged; ValueMap/ScriptArrayStorage/ScriptObjectData expose
  retained_bytes for the estimate.

Retired in favor of upstream: per-path capacity preflights in array_heap.rs /
object_heap.rs (charge_allocation already meters sparse growth), the sink
generalization of to_json/percent/regex builders (upstream preflights exact
lengths), array_mut_with in vec_prims.rs (host conversions are covered by
reconcile_heap_bytes at the host boundary).

Tests: invalid index reads/writes leave storage untouched, numeric conversion
variants, insertion order, capped sparse growth (array, object vec, object
map) refused before mutation, string ceiling at the store paths and in the
bounded buffer, byte-array to_string/parse_json limits, lossy UTF-8 parity,
scoped budget nesting inside the cap.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgSir4cQyaakzPju4h6smw
(cherry picked from commit ce4bbe5980f38fd0206f06fe27568528bd49a394)
(cherry picked from commit b21de9ff271d523f52b6154df3c7ef84048772d7)
2026-09-23 22:34:43 +02:00
ymote
2f9565e08a script: port Octoscript parser/tokenizer/control-flow VM patches (ws2)
Ports the parser, tokenizer and control-flow part of the Octoscript
makepad-script patch set (PATCHES.md, grammar v0.2) onto the September
`work` revision, by hand, area by area.

Decisions per PATCHES.md item:

1. Logical/comparison precedence, streaming, `!`: ADAPT. Upstream already
   patches a pending ShortCircuitEnd during auto-close but forgot the
   operator: ShortCircuitEnd now retains `what_op` so a tighter logical
   operator keeps a looser left jump open (`a || b && c`), the checkpoint
   restores the original TEST opcodes on continuation, comparisons (14)
   bind tighter than equality (15), and NOT always negates truth
   conversion instead of doing a bitwise NOT on f64-stored numbers.
   Upstream's `last_jump_target` bookkeeping is kept at every patch site.
2. Canonical `try protected catch fallback`: PORT. `catch` is a one-shot
   contextual separator carried in TryErrBlockOrExpr checkpoint state
   (allow_catch/canonical_catch/protected_was_block); block branches keep
   their tail value by removing the inherited pop-to-me marker before
   recomputing the jump; TRY_ERR now uses its encoded relative distance
   and the parser adds the extra TRY_OK skip only when legacy `ok`
   follows. Legacy catch-less `try a b [ok c]` still parses (the checker
   in Octoscript's own crates restricts it to the compatibility entry).
3. Cross-call unwinding: PORT. handle_errors searches all call frames
   (call_stack_has_try), pops younger script calls restoring slot_base
   and the return ip's body, then applies the try-frame cleanup/jump.
   Hard bails stay on ScriptTrapOn::Bail.
4. Loop back-edges: ADAPT onto upstream's reset_iteration_scope fast
   path: truncate_loop_iteration_bases discards iteration-local tries,
   operand values and mes before the scope reset; plain loop/while keep
   their iteration scope and free nested ones. Hard time-budget bails
   drain their diagnostic; OK_END with no try frame bails.
5. Field-assignment reverse-pair walk: PORT (stop at a 1-opcode chunk).
6. Prototype-field `:` rewrite: PORT (chain must begin with an id,
   paired insert through insert_code_with_source keeps opcode/source-map
   lockstep); unavailable rust-value index is a parse error.
7. Numeric-boundary tokenizer: PORT (`_` stays in the pending number
   instead of moving to Whitespace with stale text) plus the char-count
   token length so a multibyte identifier cannot underflow `pos`.

Tests: inline parser/tokenizer/vm regressions and tests/try_catch.rs
(legacy syntax, block/expression branches, nested and cross-function
recovery, contextual `catch`, checkpoint restoration, loop cleanup,
streaming appends, precedence and effects, tokenizer boundaries).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgSir4cQyaakzPju4h6smw
(cherry picked from commit 8231a13906cfb339b496ff78b687e43a48e4e11b)
(cherry picked from commit a1f7afb543c8a737f9c56936bfcba605097f88df)
2026-09-23 22:34:43 +02:00
ymote
4a00885f26 script: port Octoscript WS1 — worklist equality with fuel/deadline/work bail, uncaught-error bail, allow_debug_output
Port of the equality / fuel / error-bail slice of Octoscript's makepad-script
patch set onto the September `work` base.

equality (PORT, string compare ADAPTED): `deep_eq` moves from heap.rs into
the new equality.rs as an iterative worklist that visits each container pair
once (cycles and shared DAGs terminate), keeps NaN unequal to itself, and
charges one work unit per processed pair, queued edge and typed-array
element, capped by MAX_EQUALITY_WORK = 65,536 per comparison. Upstream's
69d78873e string early-out is kept instead of the patch's chunked byte
compare: every heap string is interned (string_heap.rs) and short strings
are inline, so string equality is exactly bit equality and a string pair
costs one unit. The raw host `ScriptHeap::deep_eq` is iterative and
unbounded and consumes no VM fuel. The `==`/`!=` opcodes go through
`deep_eq_bounded`: each unit charges one instruction of
`instruction_limit_remaining`, the hard deadline (now an f64 on the
platform clock) is sampled every 256 units so trivial comparisons never
touch the clock, and exhaustion drains diagnostics and raises an
uncatchable Bail, like the instruction limit.

uncaught errors (PORT): `handle_errors` without an active try frame drains
the diagnostic once and sets `ScriptTrapOn::Bail(error)`, so no later
instruction or host effect runs and `eval` returns the error value; active
`try` handlers recover exactly as before. The hard time-budget bail drains
its diagnostic before unwinding, as the instruction-limit bail already did.

allow_debug_output (PORT): new host-controlled `ScriptVmBase` flag,
default true so raw makepad debugging is unchanged. When false the `~` LOG
opcode raises a catchable not-allowed error and `ScriptVm::log` is a no-op.
Incidental VM prints are removed: run_core's `log!` traces, the undefined
opcode `eprintln!` (now a bail) and the loop "unknown state" `println!`
(now a bail). mod_std.rs needs no change: std.log already routes through
the gated `ScriptVm::log`.

Tests: platform/script/tests/equality_fuel_bail.rs covers cycles, shared
DAGs, NaN, string/number semantics, the host deep_eq on typed arrays and
beyond the ceiling, instruction fuel charging, the work ceiling being
uncatchable, the in-comparison hard-deadline check, the hard-budget drain,
uncaught-error bail with try recovery, and the debug-output flag.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgSir4cQyaakzPju4h6smw
(cherry picked from commit a44f8678ed68b20a0c413d607c07a37d55186fbe)
(cherry picked from commit 353fa369faa5672e075dd874a431dc928420bafb)
2026-09-23 22:34:43 +02:00
ymote
6c3414f087 script: re-entrant dispatch, thread index validation, Any-based handle downcasts, UTF-8 previews; regex: never_loop fix
Port of Octoscript's re-entrancy and hardening items onto the new VM host contract.

- vm.rs: run_core no longer caches a raw pointer into the active body's opcode
  vector across native calls. Each opcode is copied through a scoped
  `bodies.borrow()` that ends before dispatch, so a native handler that
  re-enters `eval` and replaces the body's parser cannot leave a dangling
  pointer. Regression: reentrant_reload_of_the_active_body_does_not_keep_an_opcode_pointer.
- thread.rs: ScriptThreads::set_current validates the index before updating
  the cached pointer (set_current_thread_id routes through it); update_ptr is
  bounds-checked via get_mut; cur/cur_ref/trap use release-mode assert!.
  Regressions: selecting_an_unknown_current_thread_panics_before_pointer_update,
  empty_threads_reject_current_access_in_release_builds.
- handle.rs: ScriptHandleGc: Any; is/downcast_ref/downcast_mut compare
  Any::type_id, removing the overridable ref_cast_type_id hook.
  Regression: handle_downcasts_use_the_concrete_any_type.
- suggest.rs: value previews truncate at character boundaries.
  Regression: preview_truncation_preserves_utf8_boundaries.
- libs/regex utf8.rs: iterator entry uses `self.range_stack.pop()?` instead of
  a never-advancing `while let`, replacing upstream's #[allow(clippy::never_loop)].

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgSir4cQyaakzPju4h6smw
(cherry picked from commit ee7729e71c1408f61ec8f9083a79bdf9117d31a7)
(cherry picked from commit cdbc33e8ac1683fcdeb6e9dcb076e26ce5d23360)
2026-09-23 22:34:43 +02:00
Kevin Boos
1f60033ed3
RadioButton: take key focus on the click, not on the press (#1259)
Pressing a radio grabbed key focus immediately, and the theme draws the focus
ring in the same color as hover, so a press dragged off the button left what
looks like a stuck hover highlight behind.

Move `set_key_focus` to the completed click, so a cancelled press leaves no
mark. Keyboard focus still arrives through `Hit::KeyFocus`.
2026-09-23 07:38:46 +02:00
Kevin Boos
d14a991085
HtmlLink: fix its hover and pressed states (#1257)
* HtmlLink: don't keep the hover color after a touch release

`Hit::FingerUp` played `hover.on` whenever the release was over the link, and
that state sets `pressed` as well as `hovered`. Touch never sends a
`FingerHoverOut` afterwards, so a tapped or long-pressed link kept the red
pressed color until its list item was recycled.

Guard that branch with `has_hovers()`, the way `TextFlowLink` and `Button`
already do.

* HtmlLink: let `hover_color` show while hovering

`hover.on` snapped `pressed` to 1.0, and `draw_walk` checks `pressed` before
`hovered`, so a hovered link always drew `pressed_color` and any `hover_color`
was dead. `TextFlowLink` carries the same animator but checks `hovered` first,
which is why it never showed there.

Clear `pressed` in that state, like `Button`, `CheckBox` and `Markdown` do.
Its `from` clause already fades `pressed` over 0.01s, which only makes sense
fading to zero.
2026-09-22 21:28:44 +02:00
Kevin Boos
2b1de986e9
RadioButton: fix its touch hover state and click-off behavior (#1258)
* RadioButton: don't keep the hover tint after a touch release

`Hit::FingerUp` played `hover.on` unconditionally, and touch never sends a
`FingerHoverOut` afterwards, so a tapped radio kept its hover tint until
something else redrew it. Releasing the mouse away from the button left it
tinted too, since the arm never checked `is_over`.

Guard that branch with `is_over` and `has_hovers()`, the way `Button` and
`TextFlowLink` already do.

* RadioButton: only select when the release is over the button

`Hit::FingerUp` selected and emitted `Clicked` without checking `is_over`, so
pressing a radio and releasing anywhere else still selected it. `Button` gates
its click on `is_over`; do the same here.
2026-09-22 21:28:29 +02:00
Admin
76627b6f19 ci: a driven app takes every key once, and the storage module builds for the browser -- the mini's wm script went red one run in three at the browser launch, and the shell menu's new log lines said why: the filter read "bbrowser" and the menu had opened twice. The remote bridge applied every wait=1 input first and, when the frame after it could not be sealed (the window was busy presenting the warm browsers), answered "requested input frame could not be submitted; retry"; the CI driver took that at its word and sent the input again, so a busy app took the Cmd+Space and the first letter twice. The bridge now keeps the waiters of an applied input and asks for the frame on the next beat, as it already did for a drawable still being acquired; the driver never asks an input route twice, whatever the answer, and still retries a grab it could not place. The workspace row was orange and apps/scope red on wasm32 since 8d7246231: the public volume_available_bytes had been put between the not(wasm32) guard and the native module it guarded, so the module compiled in the browser with nothing using it (17 warnings) and the function it exported was missing there; the guard is back on the module, and the browser has a volume_available_bytes that says the free space is not known 2026-09-22 19:04:04 +02:00
Admin
3cc56cf9b5 git: the memory ledger can take what it is asked for -- MemoryAccount::reserve takes the bytes whether or not the account has room, keeps the excess in overcommitted and leaves available at zero while it stands, so callers that can wait hold back; try_reserve stays for them. The ledger paces work, it is not a wall: a preparation in Scope that could not do without a lease stopped with a capacity error where the machine could have swapped (the user: "analysis ram cant just fail either")
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 17:40:41 +02:00
Admin
2e926a70f4 platform: the storage module says how much a volume has free -- volume_available_bytes(path) was the private figure behind the storage estimate; an application that sizes a store by its disk has to ask the disk, not carry a figure of its own (Scope prepared into a fixed 32 GB per namespace and stopped at "disk capacity" on a volume with room to spare), so it is public now
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 17:11:05 +02:00
Admin
0663a2be6c wm: the shell menu says what it did, and the CI waits for that -- the mini's wm run went red once with 'log did not contain "browser client * first frame" within 240s' and nothing in the log between the Return and the timeout: no launch, no adoption, no menu line at all, so there was no telling whether the launcher never opened, lost the typed name or picked nothing. The menu now logs each open with its number and path, every activation with its target, its close, and a Return that activated nothing with the filter and row count it had; the terminal and browser steps wait for "shell menu activate apps.<name>" right after Return, so a launcher that went wrong fails there by name instead of at the frame wait
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 14:16:57 +02:00
Admin
da8a29869b widgets: the pooled test context's resets replace the globals -- Cx::set_global keeps a global that is already there, so the reset landed in 83228f9e5 never touched the Escape claim or the drawn root once a case had installed them, and the mini still failed escape_belongs_to_an_overlay_locked_above on the next run; the resets now assign through global(). A test checks the contract on the pool itself: a claim in one checkout does not refuse the next checkout's Escape
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 14:12:48 +02:00
Admin
83228f9e54 widgets: a pooled test context forgets the last case's Escape claim -- the pooled contexts the widget tests run on since 90c9a7b01 are handed from case to case without an event loop, so their event id never moves; the first case on a thread that claimed an Escape left its claim behind, and every later case on that thread whose menu needed the key was refused it (radial_menu's the_keyboard_walks_the_rings and escape_belongs_to_an_overlay_locked_above failed about one run in four, depending on which cases shared a thread). A checkout now resets the claim, the orphaned-lock list and the drawn root the last case left; twelve release runs in a row pass
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 13:48:16 +02:00
ymote
a08ef4ebd6 widgets: let the host install script mods into every Splash isolate
A Splash isolate receives makepad's own mods and nothing else, so a widget
type defined in host code is unnameable from a mounted body. Octoscript-Makepad
hit this with OctoscriptTap: unable to name it, the generated body had to
target a Button, whose handle_event captures the finger on touch-down, so
every tappable row starved the scroll it sat in.

register_splash_isolate_mod(fn(&mut ScriptVm)) records an installer; each
isolate runs the registered installers as the last step of its allocation.
Last matters: it is after the ambient-authority strip (fs, run, res,
cx.quit) and after the jailed fs and brokered host re-registrations, so a
host mod cannot be removed by that pass and sees the isolate's final
namespace. Host code is trusted and already chooses what it installs.

The registry is a thread-local because the caller has no Cx in hand, and is
collected before running so an installer may register another. Mods are
taken at allocation, so a registration only reaches later isolates.

Test covers the contract in both directions: an isolate allocated before
registration does not resolve the probe, one allocated after does, each
allocation installs again, and the earlier isolate stays unchanged.

makepad-widgets: 207 passed, 5 failed — the same 5 that fail on the
unmodified branch (desktop_style, grid x2, widget_tree x2).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GR1KyERPDtrF9FgZ9HvWqt
(cherry picked from commit 227aa3625b8e53ab1ff3f153f63933564849640d)
(cherry picked from commit 9addb746b2497abd9fe530aebb68b3c3d99b0ccf)
2026-09-22 13:41:13 +02:00
Admin
90c9a7b01a tests: the three binaries over ten seconds in release come under it -- the user's law is the whole suite in about two minutes, in release, and the CI now runs every test binary of its selection several at a time with a cap on each, so one long binary is the run's floor. Grok measured every binary the debug-era table named (release harness time): fourteen were already under 10 s and are untouched; three were not. Widgets --lib (1822 tests) 17.4 s -> 8.0 s: no single slow test; every test built a fresh Cx and registered the widget library on it (45 ms each, in release), and libtest runs each test on its own thread, so nothing could be kept. Test modules that build widgets now run their cases through on_test_cx on four threads that stay up and keep one registered Cx each (checkout_test_cx); finger, key, pass, draw-list and window state is cleared on every checkout so a case that locks a sweep or draws a menu leaves nothing for the next. The style-reload tests keep their own contexts: a reload would change the next case. The isolate cost table measures batches of 1 and 2 (the four sizes measured 30.06, 29.92, 29.63 and 29.49 ms per isolate: flat), and the two sheet-contrast tests read one walk of every sheet from a OnceLock instead of building the library twelve times each. Director mcp_battery 10.2 s -> 0.4 s: the slowloris test waited out the production head deadline of 10 s; McpServer::start_with_head_deadline takes the budget, production start still passes HEAD_DEADLINE_MS (asserted), and the test proves the socket is held until an 80 ms budget and closed after it. Piano acoustic_reference 11.6 s -> 8.6 s: the promotion renders are 2.5 s instead of 4.0 s, which covers every sample measure and onset read (2.0 s after an onset found in the first 0.5 s). No test is ignored, deleted or loosened. The review dropped the lane's short-circuit in script_mod (a library change for a test's sake: the one test helper that re-registered on a pooled context no longer does) and its per-module pool thread-locals that the checkout never read.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 12:29:54 +02:00
Admin
f7ac37f690 ci: a card is as high as its content -- with the time beside the count there is no bottom line to keep room for, so the tile height is capped at the name and two lines under it with the padding around them, and the name takes the size the width allows; the layout test asserts what matters (the whole name fits) instead of an exact column count
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 10:26:13 +02:00
Admin
14bcadec89 cef: a hosted browser survives its host's exit -- the window manager quits, its pipes close, and the browser it hosted keeps painting for a moment; the CEF crate's next diagnostic line went through eprintln!, which panics when stderr's reader is gone ("failed printing to stderr"), and that panic aborted the browser with a crash report on every quit of the desk (the CI box showed the dialog after each window manager run). Its diagnostics now go through a writer that drops the line when nobody reads it, as the platform's log sink already did
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 10:26:13 +02:00
Kevin Boos
88639f79a2
Tooltip: position anchored tooltips in the same draw (#1256)
* Tooltip: position anchored tooltips in the same draw

`CalloutTooltip` drew itself invisibly, waited on a 5ms timer, then read the
drawn size back and re-applied margins/widths until the height stopped
changing. When the timer beat the paint it read a zero rect, gave up, and left
the tooltip opened but invisible.
* base `Tooltip` gains `show_anchored`: it draws `content`, measures it, and
  shifts its align range into place (`Tooltip::place` flips to the roomier
  side, clamps to the safe area, and keeps `gap` from the anchor and edges)
* `CalloutTooltip` now derefs `Tooltip`, measures its label each draw to pick
  the wrap width, and writes the callout edge/offset into the shader post-draw

* Tooltip: ignore zero-delta scroll events

macOS sends a zero-delta `Scroll` whenever fingers touch or rest on the
trackpad (`ScrollPhase::Touched`, and `Began` for a two-finger rest). That hid
every open tooltip, and since the pointer never left the target, nothing showed
it again. Only a scroll that actually moves counts as an interaction now.
2026-09-22 09:51:33 +02:00
Admin
611a4e2035 ci: the tests are the platform's own, in release, in two minutes -- the user's law: "we mostly only care about the platform tests", "a max of about 2 minutes of tests", "test everything the CI does in release builds; debug builds are uselessly slow for this". ci.test now builds every test binary of its selection ONCE in release and runs them several at a time (half the cores, at least two), each one timed and capped, so a hung test costs its own cap and never the hour, and the step's detail names the slowest binaries with their counts; the doc tests follow through cargo for the packages that have a library. A selection is dirs (the packages whose manifests live under those directories, by cargo metadata), packages, package or workspace: true; a run over budget_secs (120 by default) turns the block orange and says so. The root script runs platform, draw, widgets and tools/ci by default: 49 binaries, 2535 tests, 22 s on a laptop. Every other crate's tests are the deep run, opt-in with deep_tests = true in ci.toml or ci --deep (ci.deep in scripts). The wall's cards lose a line: the time sits beside the count, 1 passed · 12s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 09:50:38 +02:00
Admin
68aed71902 gif: the crate's doc examples compile -- they were the upstream crate's, naming it gif, and failed as doc tests in every workspace test run; they alias the crate under that name and are compile-only, since they open sample files the repository does not carry
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 09:36:13 +02:00
Admin
ab64a64e7e tools: makepad-screen is makepad-agents, and its binary is agents -- the crate is the agent session manager (start, attach, list, the TUI); "screen" was a nod to GNU screen, and its other job, the pty trampoline a terminal starts its shell through, does not need the old name either. The package is makepad-agents in tools/agents, the executable is agents, so it is the command on the PATH itself and the shell wrapper that existed only to give it that name is gone (the binary already resolves the workspace's session directory the wrapper used to export); the terminal's pty spawn looks for agents beside the app, Director looks for and pins the sibling agents with the makepad-agents-v1 record and the agents version line, the session environment is MAKEPAD_AGENTS_SESSION and MAKEPAD_AGENTS_STATE_DIR, the CI scripts build makepad-agents, and Director's notes say how to build and run it
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 09:36:13 +02:00
Admin
06a708dc3f ci: the header names the tip being tested -- while a run was on, the branch line still showed the tip of the last finished run beside "testing now", since the live state only learned the tip when the run ended; the record a run keeps as it goes now carries the tip under test from its first update
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 08:57:50 +02:00
Kevin Boos
71f3b84a87
Linux: fix window chrome button hovers and how maximized/fullscreen windows work (#1255)
* Wayland: restore a maximized window maximized, not fullscreen

`configure_window`'s `is_fullscreen` is the legacy maximize-or-fullscreen
flag, and on Wayland the reporting side says so out loud: `wayland_state`
builds the geom with `is_fullscreen: is_fullscreen || is_maximized`. But
`WaylandWindow::new` took that same bool and called
`toplevel.set_fullscreen(None)`. So an app that persists `is_fullscreen()`
on exit and feeds it back through `configure_window` on the next launch --
Robrix does exactly this -- turns a window the user had merely maximized
into true compositor fullscreen, and the escalation sticks: the next save
records the monitor size rather than the work area.

Wayland was alone in reading the flag that way. x11 creates with
`self.maximize()` (`_NET_WM_STATE_MAXIMIZED_HORZ/VERT`) and reports
`get_is_maximized()` back; win32 creates with `ShowWindow(SW_MAXIMIZE)`
and reports the `WS_MAXIMIZE` style bit. Both round-trip. Only macOS takes
the flag literally, and AppKit keeps a menu bar and traffic lights there.

* Create with `set_maximized()`.
* Seed `is_maximized` from the request rather than `is_fullscreen`.
  `wayland_is_fullscreen` is copied out of that field before the first
  configure arrives, so the old seeding claimed fullscreen from frame one,
  before the compositor had confirmed anything, while `window_geom` still
  said `is_fullscreen: false`. The two signals no longer disagree.
* `should_show_csd_shadow` gets the request as `maximized`; same answer as
  before, now for the right reason.

An app that wants to come up genuinely fullscreen still can:
`WindowHandle::fullscreen()` during `Event::Startup` queues
`FullscreenWindow` behind `CreateWindow` in the same FIFO drain.

Also corrects the docs this contradicted. `configure_window` claimed
`inner_size` and `position` are ignored when fullscreen and that the
window is sized to the monitor, which is true on no backend now, and
`maximize()` claimed macOS zooms when it calls `toggleFullScreen:`.

* Wayland: keep our window chrome up in fullscreen

Under client-side decorations we draw the title bar and the min/max/close
cluster ourselves, and we were hiding both the moment the compositor put
the toplevel in fullscreen. The compositor draws nothing in their place,
so the window ends up with no chrome at all -- and the max button is the
only path to `RestoreWindow`, so there is no way back out. There is no
F11, no Escape, `Window::handle_event` has no `KeyDown` arm, and the View
menu is inert outside macOS. That leaves the compositor's own keybinding,
if the desktop happens to have one bound.

Hiding chrome in fullscreen is the right call when the OS supplies its own
-- macOS has an auto-hiding menu bar and traffic lights there, which is
why that arm stays as it is. Wayland supplies nothing, so ours stays up.

* `sync_caption_bar_state` drops the `wayland_fullscreen` terms: the
  caption bar and the buttons now follow `custom_chrome` alone.
* Fill `window_chrome_buttons` in the geom in fullscreen too. It is the
  transitional hit-test rect `WindowDragQuery` falls back on before the
  widget layout is known, so leaving it empty made the first clicks after
  entering fullscreen read as a caption drag instead of a button press.
* Let the caption's own gestures through in fullscreen -- they were gated
  behind `!is_fullscreen` on a bar that could not be visible then anyway.
  Double-click now unsets fullscreen first: `set_maximized` under it does
  nothing, so the bar would have looked dead.

`is_wayland_fullscreen()` loses its only caller but stays public: it is
the only way to tell true fullscreen from maximize, which the conflated
`is_fullscreen()` cannot. Say so on `WindowGeom::is_fullscreen` too, since
reading it as real fullscreen is what started this.

* Window: drop hide_caption_on_fullscreen, a dead trap

The `WindowGeomChange` arm hid the caption bar whenever the geom flipped
to `is_fullscreen`, on `Windows | Macos`. On Windows that flag is literally
`get_is_maximized()` (`win32_window.rs`: `is_fullscreen:
self.get_is_maximized()`), and Windows draws its own chrome, so this would
have stripped the close button on a plain maximize -- the same trap just
fixed on Wayland, one `#[live]` default away from firing.

It never fired, and could not have: `hide_caption_on_fullscreen` is set
nowhere in makepad or in any app (grep finds no other mention, DSL
included), and `sync_caption_bar_state` re-decides caption visibility on
every event through `ensure_initialized()`, so it overwrites whatever this
arm set. The macOS half it duplicated lives there already.

Deleted rather than repaired: the whole caption policy belongs in
`sync_caption_bar_state`, and a second copy that keys off a flag meaning
different things per platform is what produced the bug in the first place.

* macOS: restore() no longer enters fullscreen

`restore()` and `maximize()` were the same call, `toggleFullScreen:`, so
`CxOsOp::RestoreWindow` on a window that was not fullscreen put it *into*
fullscreen. The Window widget's max button hands `restore()` whatever
`is_fullscreen()` reports, which on macOS is the real NSWindow fullscreen
state -- so this only misfires when something else pushes `RestoreWindow`
on its own, but then it does the exact opposite of its name.

Guard on `is_fullscreen`, the field the fullscreen delegates maintain.

* x11: implement FullscreenWindow and NormalizeWindow

Both fell through to the catch-all `Not implemented on this platform`, so
`WindowRef::fullscreen()` and `disable_fullscreen()` silently did nothing
on x11. `_NET_WM_STATE_FULLSCREEN` was never even interned -- the atom
table only carried the two maximize atoms.

* Intern `_NET_WM_STATE_FULLSCREEN`, and split the `_NET_WM_STATE` client
  message out of `restore_or_maximize` so the fullscreen requests can
  reuse it instead of copying the send.
* `get_is_maximized` becomes a thin caller of `has_net_wm_state`, which
  `get_is_fullscreen` shares.
* `get_window_geom` reports `maximized || fullscreen`, the same union
  Wayland reports and the meaning the flag already had. Creation still
  maps the flag to `maximize()` alone, so a persisted `true` cannot come
  back as fullscreen -- the bug this branch opened with.
* `RestoreWindow` drops both states, matching the Wayland arm: a caller
  restoring off `is_fullscreen()` means "make it small again", and the
  union does not say which of the two is set.

* Wayland: go back to the floating size when leaving maximize

An xdg_toplevel configure of 0x0 means "pick your own size", which is what
the compositor sends on the way out of maximize or fullscreen. We fell back
to `window_geom.inner_size` -- the size we were maximized at -- so the
window came out of maximize still covering the work area, with nothing to
bring it back down. Creating a window maximized made it permanent: the
floating size was never recorded anywhere.

Track the last size the window actually floated at, and use that for the
0x0 case. `is_floating` excludes tiled as well as maximized and fullscreen,
so a half-snapped window does not get recorded as the floating size.

A configure that does carry a size is still obeyed exactly as before, which
is both what the protocol requires and what a user drag-resize produces.

Seen with a window created maximized and then normalized: 3383x1408 before,
the requested 900x600 after. Note this only covers the case where the
compositor defers to us -- GNOME sends a concrete size after unfullscreening
a window that was maximized first, and we honor it.

* Linux: tell the app when the pointer leaves the window

Neither Linux backend ever sent `Event::MouseLeave`. Windows has sent one
since forever, and `Hit` handles it -- `finger.rs` returns `FingerHoverOut`
for whatever area still holds the hover -- but on Wayland and x11 nothing
produced it, so the last hovered widget kept its hover the entire time the
pointer was outside the window.

That is what makes the window chrome buttons flicker. Hover close, move the
pointer off the top of the window, and the button stays lit; the hover only
clears on the first motion after the pointer comes back, so returning to the
window makes the button flash off. It is most obvious on the chrome buttons
because they sit against the window edge, where leaving the button and
leaving the window are the same gesture.

It also broke re-entry. `FingerHoverOut` does not clear the stored hover
area -- `cycle_hover_area` does, once per event -- so with no leave event
`hover_last` still named the button, and coming back over it returned
`FingerHoverOver` instead of `FingerHoverIn`. Widgets act on HoverIn, so a
button could fail to light up at all on the second hover.

* Wayland: emit it from `wl_pointer.leave`, guarded on `pointer_window`
  being set, which it is only for the toplevel surface -- leaving a CSD
  shadow gutter has no hover to drop.
* x11: finish the `LeaveNotify` TODO that has been commented out in
  `xlib_app`. The condition it had, `detail == 4`, would not have fired
  anyway: a real pointer-out-of-window here reports detail 3
  (NotifyNonlinear). Take any detail except NotifyInferior (the pointer
  only moved into a child), and only mode NotifyNormal, so a grab or
  ungrab does not drop the hover mid-drag.
* Both event loops then `cycle_hover_area` + `switch_captures`, the same
  pair the MouseMove arm uses and the same thing the Win32 arm does.

Verified on both backends with a probe on the hover in/out arms: before,
leaving the surface logged nothing; after, HoverOut fires on leave and a
fresh HoverIn on re-entry.

* DesktopButton: cross-fade the hover background premultiplied

The chrome buttons flash dark for an instant when the pointer leaves them.
Not a hover-state bug -- the `hover` instance really does fall 1.0 -> 0.0
monotonically over the 100ms fade. The dip is in the shader.

`bg_color` is `#00000000`: transparent BLACK. Mixing it toward an opaque
`bg_color_hover` in straight-alpha space ramps rgb up from black as well as
alpha, and `sdf.fill` then multiplies rgb by that same alpha again, so what
reaches the premultiplied blend is `rgb * h^2` against coverage `h`. Over a
caption bar of luminance C the composite is quadratic in h and sags well
below both endpoints in the middle.

Measured on a #F3F3F3 bar with the `#E9E9E9` hover face this file's callers
use (widgets/src/window.rs), sweeping hover across the button's width:

    hover    0%   30%   52%   80%  100%
    before  246   207   199   212   238
    after   246   244   242   240   239

The endpoints are 8 levels apart, so the intended highlight is nearly
invisible -- and the 47-level excursion between them is the only thing the
eye catches. It is symmetric, but on the way in it reads as arrival feedback
and on the way out it is a dark flash left behind where the pointer just
was, which is why it gets reported as flicker on hover-out.

Premultiply each face before mixing and fill with `fill_premul`, so the
cross-fade is linear in `hover` and anti-aliasing blends in premultiplied
space too. `DesktopButton` is the only widget with a fully transparent base
colour feeding this pattern.
2026-09-22 08:47:38 +02:00
Admin
0770a8c8b8 tests: the last three failures of the CI box's night -- the dock clips a reported tab header on the tab bar's scroll axis only, since a tab's own height is its own and clipping it to the bar's box moved the tab's reported centre by a logical pixel and a half (the text centring test measured the "H" tab against that rect and failed everywhere); the Files cancel test walks a made-up home under the temp dir instead of the real one, whose Desktop, Documents and Downloads block on a macOS permission dialog that nobody at a CI box answers (the whole suite hung on it for an hour, and that dialog was the "release wants access to Downloads" the person at the box saw); and the tour generation's "feels instant" budget stays two seconds for the release build it describes while a debug build, where the same work takes 1.7 s on a fast laptop, gets a proportionate eight and the best of three runs, so a busy CI box does not fail it by a few percent
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 08:46:45 +02:00
Admin
c20cf99c89 ci: a judgement is never lost to the way it was phrased, and every "retry" of the bridge is retried -- the small vision model, asked to describe the desk's terminal, locked onto one phrase and repeated it to the token cap, so its answer had no verdict line and the window manager tile went red over a terminal that was fine; the decoder now stops a looping answer (the tail of the text is one short pattern over and over), and when the free answer has no verdict line the conversation is continued with "VERDICT:" already written and the model finishes that line, so the judgement is asked for outright instead of being lost. The harness retries every answer of the bridge that ends in "; retry" (a grab it could not arm as well as an input frame it could not place), where it matched one wording only and failed the browser step on the other. The workspace test suite gets three hours instead of one: it runs beside the app scripts now, and a hung test was worth a whole hour before
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 08:46:45 +02:00
Admin
863ca812a3 ci: the workspace script gives the wall back after warming -- after a push that touches a core crate the workspace script spent ten minutes and more alone (fifteen target checks, a release build of every app, the workspace check and the whole test suite) while every other tile sat grey, which read as a hang. It still goes first and alone for the part only it may do alone, warming the cache every other script reads, and then calls ci.shared(): its permit turns from exclusive into an ordinary one and the waiting scripts start beside its checks and tests. The runner sends it through the same pool as the rest and hands out nothing else until it holds the wall, instead of running it to the end before the pool even started. It also builds the pty helper while it is alone, so the terminal, director and window manager scripts get a cache answer instead of a cargo run that would queue behind the test build
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 00:07:23 +02:00
Admin
86cd0604ef libs: no warnings in the workspace check on any row -- the CI box checks every package on every target, and the library crates, which have no tile of their own, warned on the rows nobody builds by hand. On wasm and mobile, things are gated with their users: the workspace document worker (its commands, payloads, retry constants and Drop are native; the wasm API stays, behind an uninhabited worker whose constructors answer that the browser has no document worker), the hub's unused non-Unix available_bytes and its ram facts parser, the loader's ArenaPtr, system_speech's bcp47. On the tvOS rows, the only ones built on nightly, the legacy numeric constants and functions become the associated constants they have been since Rust 1.43 (micro_serde, bytemuck, rustybuzz, unicode-script, smallvec, weezl); fetch_update keeps its name under allow(deprecated) in stitch, git and the map archive, since try_update does not exist on older stable toolchains; objc-sys declares free with the signature the standard library uses; rustybuzz calls its own method through a path that a future std method of the same name cannot shadow; and the Script derive's unused helper attribute splat, which nightly now reserves as a built-in, is spelled script_splat (nothing in makepad, Scope or Stage uses it)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 00:02:28 +02:00
Admin
53d4960320 ci: a library's warnings turn the workspace block yellow, and a desktop tool is not warmed for the web -- warming blames nobody for an APP's trouble, since the app's own tile tells it, but a library has no tile: its warnings scrolled past in the log while the workspace block stayed green. The warm step now collects the warnings of every package that is not one of the apps being warmed, names the crates with their counts, and goes yellow. ci.warm and ci.check_targets take desktop_only, packages that are left out of the web, mobile and embedded rows; the root script lists director there, as its own script already asks only for the desktop targets, which takes its 139 compiled-out-worker warnings off the wall
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 23:56:25 +02:00
Admin
3e8eba0122 examples: the UI tests pass, for the reasons they failed -- the dock reports a tab header clipped to its visible tab bar, and leaves out a tab that is wholly scrolled out of view: the splash example's left panel has more tabs than room, "Toggles" is cut off at the panel's edge, and the centre of its unclipped rect lay on the neighbouring panel's first tab, so a test (or any automation) that clicks a tab by its rect opened the wrong one; the splash test then scrolls the Toggles page to the dropdown that sits below its fold, the way the media test beside it already scrolls; and the render-to-texture test captures until the child pass composite is there (at most three seconds) instead of judging the first frame after the pane has a rect, which is one frame before the composite lands -- what it pins is that every quadrant arrives in its own corner, not on which frame
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 23:55:46 +02:00
Admin
661842f875 platform: a hidden window wears no hands-off frame -- the red frame a window wears for three seconds after the remote bridge injects input is for a person watching a scripted run; in a hidden window nobody watches, and there it only landed in the grabs, present in a capture taken right after a click and absent in the next one, which made the text atlas stress test see "changed text rows" and changes what any pixel test or vision check sees from one capture to the next
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 23:48:07 +02:00
Admin
9788b827eb tests: the workspace suite, run as a whole for the first time, passes outside the example UI tests -- each failure was settled from history as a stale test or a regression. Stale, expectation updated to the change that overtook it: draw's road pack tests fed a z-bias tick outside the exact f16 range; network no longer reserves Accept (906f94a94); the log ring test waits for the asynchronous sink (2e27ce2ff) and the audio tap test no longer assumes it owns the shared registry; xatlas compares the irregular oracle within the official tool's float precision instead of bit for bit; xr's four-wheel car exposes the chassis query plus one per wheel (080794781) and the depth mesh plan follows the 0.32 m chunk (ddec6fc60); score's DSL test sets up the OS context clock it reaches; hello_world's GIF test describes the fixture that is actually embedded. Regressions, code fixed: the tweaker could not print layout enums as whole values because the derive never emitted the __enum companion it looks up; the workspace style picker never learned about the Black orange style put at the front of DesktopStyle::ALL, and per the decision that Black orange is not offered in pickers it lists ALL without it, maps indices accordingly and loads a stored "blackorange" without a selection; xr's car controller no longer scales engine force by 1/dt a second time (same force at the fixed step) and a scaled vehicle's suspension is stiff enough to carry it at its shortened rest length. The two xr tests that need a local-only reference dump (xr/dump is git-ignored) say so and return
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 23:41:03 +02:00
Admin
160668c21f widgets: children a lookup discovers reach the dump, the snapshot and the flood searches -- a view's on_render output never showed up in /d, /snap or a makepad_test selector (the counter example's label could be seen and clicked around but not found): the view marks itself dirty, a lookup is usually the first to settle that node, and lookups deliberately do not raise structure_dirty, so that a recycling list does not make every frame pay a dense rebuild; but the lookup also consumed the dirty mark, the only thing that told sync_dirty the dense arrays were behind. A lookup-driven topology change now sets dense_stale, which only the dense readers act on; structure_dirty keeps its meaning and the two tests that pin it still hold
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 23:41:03 +02:00
Admin
c091430d32 ci: a quiet header, a build that visibly moves, and scripts that leave the machine alone -- the window's top is one band: the branch and its tip, the test progress large (17 / 29) with failed and warning counts only when there are any, the run's elapsed time, Run now and Stop; the line under it is the run progress bar and the tiles start right below it, the rows of counts, "now:" and "Watching" are gone and next poll and the model's state moved to the one footer line; a running step carries what its command is doing ("compiled makepad_draw · 212 crates", told at most twice a second from cargo's artifact messages) and the running tile shows it, so a ten-minute warm build no longer looks stuck; director embeds a terminal, so its script builds the pty helper as the terminal's does; ci.launch takes app_env, and Files runs on its synthetic tree (MAKEPAD_FILES_DEMO) instead of walking the real home, which made macOS ask the person at the CI box for access to Downloads on behalf of "release"; and the workspace tests run with --no-fail-fast, so a run names every failing test target instead of the first
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 23:41:02 +02:00
Admin
7ffcdab2ca ci: a stop or a restart is not a test result -- restarting the CI app in the middle of a run painted the whole wall red and then left it red: every script still going failed with "stopped by user", a script that had not launched yet got nil from ci.launch and raised method-not-found errors that counted as real failures, and the interrupted tip was recorded as tested, so the new process had nothing to run. Now a failure recorded while the run is already stopped is a skipped step, never a failed one; ci.launch hands a stopped script the inert app; a run the process shutdown interrupts leaves the last finished run as the record and its tip untested, so the next start runs it again; and a run the user stops keeps what finished, leaves the rest untested and says "stopped before it finished" instead of passing
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 23:15:29 +02:00
Admin
11cd394812 Remove Flow, asset and VJ applications moved into Stage
Remove the retired applications, asset-specific libraries and DJ pack tool
from Makepad, together with their workspace and launcher entries. All 981
removed source paths are accounted for in the private Stage repository.

Keep public AI chat generation through the AI Hub's generic job runner.
Extract shared SHA-256 and UDP binding into core_util so the public hub and
model crates no longer depend on the relocated asset libraries. Preserve
the retained public coverage in the split wasm validation script.

Validation on the exact cleanup tree in an isolated checkout:
- Release checks: core_util, model, aichat, WM and Builder.
- Release builds: aichat, WM and Builder.
- Existing tests: core_util 5, model 30, aichat 7, Builder 2 passed.
- Core/model checks: wasm32, Linux and Windows passed.
- No warnings in the successful checks, builds or tests.

Known baseline: WM library tests do not compile because the unchanged
style-transition assertion compares seven expected weights with eight.
The unrelated working-tree correction is intentionally outside this commit.
2026-09-21 23:09:50 +02:00
Admin
5f4a1632a8 ci: the window is a dashboard -- inside the wall's law (filled grey tiles, near-black ground, a failure the only bright thing) the window gets a header that reads as one: wordmark, branch with its tip in a monospace face and its state in words, the next poll, the model's state and quiet Run now / Stop buttons, with a thin grey run-progress bar under it and one line for what runs now; tiles have rounded corners drawn in their own shader, an even gutter, a large whole name, measured ellipsis instead of counted characters, the duration pinned to the bottom edge and the running tile's progress inside its rounded shape; tiles keep a stable order while a run is on and put failures first when none is; the detail is a panel with a steps list (state mark, name, duration, the failed step expanded with its reason in a monospace block), captures with an index and the log tail, and a footer names the checkout, the host target, the model and the run directory
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 23:05:31 +02:00
Kevin Boos
ccb1041a90
cargo-makepad: keep the android SDK at a stable path (#1253)
The SDK came from `env!("CARGO_MANIFEST_DIR")`, so every copy of the binary had
its own NDK at its own path. Switching copies changes `CMAKE_C_COMPILER`, and
cmake then deletes its cache and re-configures *without* the `-D` flags, losing
`CMAKE_SYSTEM_NAME=Android`. Deps like `aws-lc-sys` then build for the host and
Darwin forces `-arch arm64` into the NDK clang.

* default to `~/.makepad/<host-dir>`, independent of which binary runs
* migrate an existing per-checkout SDK with a single `rename`
2026-09-21 23:03:57 +02:00
Kevin Boos
1d09116779
ScrollBar: add show_handle for a view that scrolls without a grabbable bar (#1254)
* ScrollBar: add `show_handle` for a view that scrolls without a grabbable bar

The handle is both the visual and the hit target, and `show_scroll_x`
gates wheel/trackpad input too, so there was no way to keep a view
scrollable while dropping the bar a user can click.

* `show_handle: false` skips drawing the handle and its hit test.
* Wheel, trackpad, finger drag and the scroll API are untouched.

* ScrollBarTabs: stop the invisible handle from eating presses on tabs

The tab-bar handle is transparent until hovered, and it runs along the
bottom of a strip whose tabs are exactly as tall as it, so it sits over
the lower edge of every tab. `TabBar` hands presses to the scroll bars
before the tabs, so while the strip overflows, a press near a tab's
bottom grabs a bar nobody can see instead of selecting the tab.

* Default `show_handle: false`, so `TabBar`/`TabBarFlat` scroll only by
  wheel, trackpad and drag.
2026-09-21 23:03:44 +02:00
Admin
41079817ba apps: what the CI box found -- the workspace test suite compiles again (the window manager's style tween test states that Windows 2000 carries the whole weight by its own index instead of a seven-entry literal for eight styles, and the landscape test expects the 28-point home strip minimum the shell has kept since the device chrome got it); route builds for the web, with map_build's faces, native, testmap and repack modules, route's bake provisioner and its local navigation and charger queries gated off wasm, where they answer that local data is unavailable on the web; and the warnings that only showed on other targets are gone by gating each item with its users: files' symlink target, photos' Lane, sheets' module import, storybook's filesystem tree helper, task's swap fields on Windows and iOS, and three test-only leftovers in widgets
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 22:44:50 +02:00
Admin
68a9e24ca4 platform: no warnings on tvOS and linux_direct, and a missing pty helper says so -- the stdin host-batch coalescer and drain, the studio websocket receive helpers and stdin_apply_native_geom are gated to the desktop backends (and the GPU simulator) that call them instead of being compiled everywhere but android under allow(dead_code); IOSurfaceCreate / IOSurfaceGetID and update_shared_texture are macOS and iOS only; i16::MIN/MAX replace the deprecated std::i16 constants; fetch_update keeps its name under allow(deprecated), since nightly renames it to try_update and older stable toolchains must keep building; the tvOS app accessor reads its static through addr_of; and screen_helper returns a NotFound error that names the missing makepad-screen binary and the cargo command that builds it, where a terminal used to report a bare "No such file or directory"
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 22:44:50 +02:00
Admin
5165eaf349 ci: the terminal gets its pty helper, director is a desktop tool, and a refused input is asked again -- on macOS the terminal starts its shell through makepad-screen, which lives next to it and which an app build alone does not produce, so the terminal and window manager scripts build it (the CI box had a terminal that could not spawn a shell and a desk whose terminal window stayed blank); director drives cargo builds and child processes and most of it is compiled out on web and mobile, so its script asks for the desktop targets only; and when an app answers that it could not place an input on a frame and says retry, the harness asks again, up to five times, instead of failing the step
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 22:44:50 +02:00
Kevin Boos
6296f4c885
Vulkan: stop printing the loader's startup narration by default (#1252)
* macOS: re-arm the display links when a window leaves the Dock

`pause_display_link` only sets `setPaused: YES` and keeps the links, so
`display_link_needs_rearm` stays false and `ensure_timer0_started` never
clears `timer0_armed`. A work beat while minimized arms the NSTimer with
that flag set, so deminiaturize early-outs before anything unpauses the
links and the restored window paces on the timer, not its own panel,
until the next idle downshift.

Clear the flag first, like the pointer-capture release does.

* Vulkan: stop printing the loader's startup narration by default

`vulkan_debug_messenger_create_info` asked for `INFO` severity on the
`GENERAL` message type, which is the channel the Vulkan loader narrates
itself on. Every run dumped around ninety lines naming each directory it
searched for layer and ICD manifests, each manifest it found, and the
layer callstack it assembled, before the app had drawn anything. None of
it comes from the validation layer -- that only loads under
`MAKEPAD_VULKAN_VALIDATION` -- so it was noise on every Linux desktop,
Android and OpenXR run, on every machine.

* Subscribe to `ERROR | WARNING`, adding `INFO | VERBOSE` only when
  `MAKEPAD_TRACE=vulkan.debug` is set. The driver skips the callback for a
  severity we did not ask for, so the loader no longer formats the lines
  either. Validation errors and warnings still print unconditionally.
* Route the informational branch of the callback through `trace!`, so it
  carries the topic that enabled it like the `gl.*` and `shader.*` ones.
* `devices` logged a line per software device it stepped over, which fires
  on any machine carrying lavapipe -- that is most Mesa systems. Move it to
  `MAKEPAD_TRACE=vulkan.device`, and instead say so once when software
  rasterizers were the *only* devices found, since every caller then
  reports no usable device, which reads as if the machine had no Vulkan at
  all rather than no accelerated one. What happens next is left to the
  caller that decides it: `linux_wayland` already logs its OpenGL ES
  fallback.
* Two Android camera-import sites used `warning!` for a plain dump of
  image size and format on the success path; they become
  `MAKEPAD_TRACE=vulkan.camera`.

A desktop Linux run now prints the one line that says what it got:

    Vulkan: NVIDIA GeForce GTX 1070, graphics/present queue 0

`MAKEPAD_TRACE=vulkan` brings all of it back; the topics are hierarchical,
so `vulkan.debug`, `vulkan.device` and `vulkan.camera` also work on their own.
2026-09-21 22:17:19 +02:00
Admin
c0ea5bc162 ci: warming fills the cache and blames nobody -- a package that fails or warns in the root script's batched warm-up is told by the script that asks for it, on its own tile, so one app that does not compile for wasm no longer turns the workspace tile red as well; the warm step notes which packages are failing, and goes red only when cargo itself could not run
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 22:15:53 +02:00
Admin
8c0a75861e ci: untested is grey and nothing else -- the wall no longer puts a bright dot of the last finished run's verdict on a tile that has not been tested yet; that history is told in words in the detail header, and the name gets the width the dot had reserved
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 22:12:39 +02:00
Admin
fbe8c2e6c4 ci: the wall shows THIS run and is fit for an OLED -- a tile is grey until its script is tested, grey-blue with a progress bar while it runs, then grey-green, grey-amber or the one bright thing on the screen, a failure red; what the last finished run said is a small dot, never the tile's colour, and a script the user stopped or a restart interrupted goes back to untested instead of red; tiles carry their short name (wm, scope, workspace) in a named font family, since an empty family only rendered where a system fallback happened to exist and the tiles came up blank on the CI box; the packing picks the columns that give the largest whole name; a progress line says how many are tested, passed, warned and failed and what runs now; the pulse and the clocks tick on an 8 Hz timer only while something runs, never per frame on a 240 Hz display; the window no longer maximizes into native fullscreen over its left-half geometry, the background is near-black, the detail sits under the grid so it fits 960 points, a watcher problem is one header line, and the install button shows only when the vision model is missing; a compiler warning is orange and never red in the root script, and a desktop-only app with no library is not applicable on web and mobile targets instead of a standing warning
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 22:10:19 +02:00
Admin
0458f0c2b7 ci: a run is one target dir and one cargo batch per target, and the wall draws -- the root script warms a run-level cache with ONE cargo check per target covering every app package (the matrix's BuildTy per row, diagnostics attributed by package_id, --keep-going then per-package fallback when one package fails) and one release build of all bins, so the 24 app scripts answer check_targets and build from the cache; the tile grid registers its draw type on DrawQuad and merges its shader into the widget, which is what makes the green / orange / red / grey tiles appear at all; the window takes the left half of the main display (system_profiler, overridable with window = [x, y, w, h]); only apps/<name>/Cargo.toml is an app, so a private app's nested deps are its libraries; scope is no longer skipped by default; a failed launch hands the script an inert app instead of nil and a failure a step already carries is said once; the log keeps the first 20 diagnostics per cargo invocation and hub-install counts a present file once
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 21:52:43 +02:00
Admin
b6d45554af stitch: the crate says what the rest of the repository says about its license -- MIT OR Apache-2.0, with license-file pointing at the one text at the repository root, which cargo package copies into the published crate; and the authors, description, homepage and repository the main crates carry
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 21:23:11 +02:00
Admin
229cdd4d2e ci: tools/ci, a Splash runtime that watches a branch and runs every ci.splash it finds -- each script checks its app on every other platform, builds and runs it here, drives it over --remote and has a small local vision model judge the grabs
A test is a ci.splash beside what it tests; the script decides every input and the model only ever judges one picture against one acceptance text. mod.ci: launch (hidden, --remote, user_seq preserved), key, type_text, click, get, snap, wait_log (a * is a gap inside one line), no_errors, grab, quit; step, sleep, check, run; cargo, check_targets (the cargo makepad check matrix, check only for platforms we are not on, a test fails if the two tables drift), test, build, machine (another box over the makepad tunnel), exclusive; judge, accept, ask. The watcher polls git ls-remote once a minute for work and any extra branches, syncs a checkout the CI owns, runs the root script first and alone, then the rest up to a parallel limit behind one shared model judge. The window is a wall of squares, one per script: green passed, orange warnings, red failures, with a detail panel for the selected one.

Scripts: the root ci.splash (workspace check with core warnings denied, the tests), apps/wm (desktop up, switch to macOS by Cmd+Space / type / Return, launch the terminal and the browser, each waited for by the WM's own first-frame line), and one per main app in the default shape. Proven here: apps/wm/ci.splash green in 280 s, fifteen target checks and seven vision verdicts.

Models come from Hugging Face through the hub: registry entries qwen3.5-4b-vision and qwen3.5-9b-vision with exact revisions, sizes and digests, and hub-install, a command line over LocalModels::start_install. vlm-probe reads PNG.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 21:23:11 +02:00
Admin
cc5a86c73f platform, wm: work builds again on iOS, tvOS and wasm, and the window manager is warning-free on Windows -- found by the first runs of the CI, which checks every app on every target in cargo makepad's matrix
iOS: VideoFileDecoder::open_audio guarded its Apple path with macOS alone, so iOS fell through to UNSUPPORTED, a constant no Apple target has. It takes the same split as every other entry point in that file.

tvOS: libs/apple_sys opened with a crate guard of macOS or iOS, so on tvOS the crate compiled to nothing and every msg_send! user lost the macro; the crate guard and its 27 inner guards of that shape now name tvOS, and MTLCopyAllDevices is macOS only, which is where it exists. The platform's Apple video playback, player and YUV modules, the 17 guards of the Metal NV12 video path and the texture-pool imports follow, and the tvOS app gains try_metal_device, the lookup the texture adopt path already calls on iOS.

wasm: the window manager's dylib host needs a process, a linker and a loader, so it is native only; the web gets a stand-in with the same surface that refuses every compile through the queue the native host answers on, and libloading is a non-wasm dependency.

Windows: three Unix-only uses in apps/wm/src/clients.rs (Cx, CancellationToken, the grace argument) are guarded to match where they are used, tests included.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 21:22:33 +02:00
Kevin Boos
0ef0e37b97
macOS: re-arm the display links when a window leaves the Dock (#1251)
`pause_display_link` only sets `setPaused: YES` and keeps the links, so
`display_link_needs_rearm` stays false and `ensure_timer0_started` never
clears `timer0_armed`. A work beat while minimized arms the NSTimer with
that flag set, so deminiaturize early-outs before anything unpauses the
links and the restored window paces on the timer, not its own panel,
until the next idle downshift.

Clear the flag first, like the pointer-capture release does.
2026-09-21 21:08:16 +02:00
Admin
3f26b0e55d cef, video, platform: what Stage's browser needs from the shared layers -- a page can be opened with options and evaluated, its console read and its profile flushed on shutdown; the video encoder can write a movie in fragments so a file is readable while it is still growing; the audio output seam fences a panicking app closure and feeds the taps either way; and MIDI messages and ports can be injected over the remote port
The CEF follow-up (libs/cef, widgets/src/browser.rs, apps/browser): BrowserOptions with software frames, evaluate_javascript answered as JSON or the exception text, console messages taken by the embedder, editable_focus, with_cef_browser on the widget, the profile flushed on Event::Shutdown, and a repr(C) mismatch in the FFI fixed.

platform/video: VideoFileEncoder::new_fragmented lays the container down in movie fragments (AVAssetWriter's movieFragmentInterval), proven by tests/fragmented_growing.rs: 90 of 120 frames readable before finish, all 120 after. Windows and Linux write one movie as before. The Apple backend's plain constructor went with it: nothing called it once the fragment-aware one existed.

platform: the audio output fence is seated in the one seam (media_api.rs), so a panic in an app's output closure costs that closure and its buffer, not the device thread, and the taps are fed the silence so a recording keeps its place; its tests adapted to this tree's tap registry. /midi routes inject a message as if a device sent it, declare ports for the app to adopt, read back what the app sent, and reset (platform/src/midi.rs, remote.rs).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 12:52:10 +02:00
Admin
6857adeebc wm: the desktop style menu lists each style once -- the black-orange row and the two dark rows are gone, since dark or light is the appearance toggle's to say, and a style pick no longer resets that toggle
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 12:42:46 +02:00
Admin
3d12bf2c51 AGENTS.md: a platform change made for one application needs the user's feedback and checks first, and app specifics never leak into the shared layers
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 09:14:23 +02:00
Admin
eafc15da62 workspace: Scope's settings carry the code map's tab width and the directories each prepared project hides from its map
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 01:49:29 +02:00
Admin
ab08812892 The regex engine accepts \b and \B, reports the earliest match so a scan can resume after it, and can decide word boundaries on ASCII so a code search never falls back to the slow NFA on non-ASCII text
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 01:39:51 +02:00
Admin
dea3de2f4b The Builder compiles itself again from the Makepad tree Scope's release pins and removes the source snapshots nothing is built from any more
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 00:17:23 +02:00
Admin
9e85467d30 widgets, platform: the last twelve warnings go -- the inspector's Row is InspectorRow so it no longer shadows the chart's at the crate root, the avatar propagates its children's draw steps, a test-only wheel helper and a stray re-export are gone, and the audio output fence is on the output seam it was written for
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 00:08:23 +02:00
Admin
8315f089e0 platform, wm, director: a hosted app's software frame arrives as a top-left texture and the window manager and the director carry no flip term at all
The child draws its frame through a texture pass, which on GL renders through an inverted projection, so its glReadPixels rows come in picture order already; the host keeps them as they are, and the two run views sample the texture as stored on every path. The old shader flip on the software path inverted it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 23:38:22 +02:00
Admin
eeb6b6c66a platform: every OpenGL texture pass stores top-left rows, custom cameras included, and culls with the winding its inverted projection gives
A custom-camera pass used to keep GL's bottom-up storage while the 2D passes were inverted, so a 3D scene rendered to a texture came out upside down on Linux and Android GL and nowhere else; it now uploads an inverted copy of its projection as the web backend does. Backface culling follows with a clockwise front face on those passes, and a target allocated taller than its pass keeps the pass at row 0 instead of the far end.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 23:31:17 +02:00
Admin
400f5fc000 widgets: the supersampled resolve and the map's shadow mask sample their textures as stored, like every other render texture
The resolve flipped V and mirrored the whole window on Metal, grab-verified the wrong way round; the mask flipped on Metal and iOS alone. Both textures are ordinary passes drawn with the 2D camera, top-left on every backend.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 23:31:17 +02:00
Admin
b043bf0de3 calendar: its own colour contrast is named explicitly, now that the theme store exports one under the same name through the widgets glob
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 23:06:15 +02:00
Admin
93fd93fde3 platform, widgets, wm: every Vulkan pass renders through a negative-height viewport again, so a Wayland or X11 window stands upright, and no capture consumer flips V any more
The 2D camera is GL-style: the top of a pass rect lands at clip y = +1 on every backend. Vulkan's clip space points down, so a pass drawn with that camera has to go through a negative-height viewport, window and capture alike; the window comes out upright and a capture's rows are stored top-left like Metal's. fbfec26ad made both viewports positive to cure an inverted phone desk, and every desktop Vulkan window stood on its head (the Scope report of 2026-09-20). The desk was inverted by its own OS-keyed consumer flips, not by the viewport, so those go: the gauss stack's per-OS flip and its callers, the phone shell's three Android flips and its y_flip shader term, the dock warp's capture_y_flip and its term. The direct display's letterbox blit keeps its positive viewport: its own vertex shader maps uv.y = 0 to clip -1.

Seen right side up by eye on the Arch RTX 5090 box: apps/wm as a Wayland client under sway, the hosted apps inside it, and the linux_direct WM on the panel; and on the Pixel 11 Pro XL the wm-dyn super-app (Vulkan, no GL fallback). Codex review in the session's notes endorses the restores and removals and leaves two flips for a later look: the SSAA resolve's 1.0 and the map shadow mask's Metal flip, both untouched here.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 23:06:15 +02:00
Admin
554adf0459 ai stems: LaneDemixer, VocalsModel and the platform Stage origin/main needs
Taken from vjroger/work. A full merge of that fork fights this work branch
in widgets; this is the stems crate, vocal mel-band, span-cache lanes,
log_ring, output fence, midi inject, effect_doc, mp3 sniff, mp4 audio
edit, and audio-only file open. Stage on origin/main cargo-checks again.
2026-09-19 21:36:09 +02:00
Kevin Boos
5e9a697940
app_main!: only ship the fonts an app declares (#1247)
Every manifest `app_main!` emitted carried three fonts no theme role uses:
`NewCMMath-Regular.otf` for `MathView`, and `Inter.ttf` / `RobotoFlex.ttf` for the opt-in
iOS and Android platform styles. That was 3.6 MB in every package, and it only existed
because a font an app forgot to declare failed silently: `FontFamily::update_font_definitions`
skipped a member whose bytes never arrived, so the text showed as boxes with no log line.

* Drop the forced extras. The manifest is now the font set's fallback chain plus whatever
  the app puts in `font_assets`, which is what makepad's own apps already did for `Inter`.
* `font_assets` takes expressions, and `INTER_FONT_ASSET` / `ROBOTO_FLEX_FONT_ASSET` join
  `MATH_VIEW_FONT_ASSET`, so an app declares a font by name instead of by path.
* A font that never loads now logs one `error!` naming the path and the fix. A missing
  member still degrades gracefully: the family keeps its remaining members.
* The apps that use those fonts declare them: the `wm` family binds both platform faces,
  `clock`, `weather` and `director` draw with Inter and can select any style, `terminal`
  and the builder use Inter for symbols, `splash` and `aichat` use `MathView`.
2026-09-19 20:35:58 +02:00
Kevin Boos
b05eae3a2c
cargo-makepad: find dependency crate dirs via cargo metadata (#1246)
`cargo tree` only prints a directory for path dependencies, so for a git
dependency we fell back to the `<crate>.path` file its build script drops
in the target dir. Any tool that prunes the target dir deletes that file,
and a warm cache means the build script never re-runs to recreate it, so
`add_resources` silently found no `resources` dir for `makepad-widgets`
and packaging failed with "font assets declared by makepad.font-assets.v1
are missing on disk". Ask cargo for each package's `manifest_path`
instead, keeping the `.path` file as a last resort.
2026-09-19 08:12:59 +02:00
Kevin Boos
7bd250fdea
Linux: only link libdrm for the direct backend (#1245)
`drm_sys` carries `#[link(name = "drm")]` but was compiled for every non-Android
Linux target, so an ordinary desktop build fails to link on a machine without
libdrm, even though nothing outside the DRM/KMS backend calls into it.

* Gate the module on `linux_direct`, the same cfg its only callers already
  carry: `drm_native_resolution` in `vulkan_linux` and the `direct` module.
2026-09-19 08:12:46 +02:00
Kevin Boos
9770ff315e
macOS: keep the paint clock beating while the window is minimized (#1244)
`NSView.displayLink` never fires for a window in the Dock, and the 0.2s
NSTimer fallback only starts from inside the timer 0 branch that the link
drives. So minimizing froze the UI thread, and Ctrl+C / SIGTERM / SIGHUP
sat in `REQUESTED` until the window came back.

* swap the paint clock on `windowDidMiniaturize:`/`windowDidDeminiaturize:`
* skip link pacing while every window is miniaturized
* termination worker restores `SIG_DFL` if it gives up, so the process
  can't end up unkillable
2026-09-19 08:12:31 +02:00
vjroger
d21ff7fd91 the accent a name is written with stops deciding whether search finds it
A record filed as "Café del Mar" could not be found by anybody typing
"cafe". The tokenizer breaks a run at every non-ASCII character, so the
title indexed as `caf` + `e` and the query asked for `cafe` — three
terms that never meet. Which spelling a name happens to carry was
deciding whether it was reachable, and nobody decided that.

The two sides are now deliberately asymmetric, and the law is one line:

  index(text) = tokenize(text) ∪ tokenize(fold(text))
  query(text) =                  tokenize(fold(text))

so the index is a superset of what any query can ask, and folding is
idempotent, which means "café" and "cafe" ask exactly ONE question —
they have to, or the cursor fingerprint and the ranking would disagree
between two spellings of one word. The fold happens to the TEXT before
tokenization, because by the time there are terms the accent has
already split the word and there is no per-term place to hang a folded
form.

The fold is this repo's own twenty-arm Latin table, not canonical
decomposition: decomposition cannot tell you that 'ß' is "ss" or 'æ' is
"ae", and this repo already answered both, tested, inside the
importer's alias slugger. That table moves to the data crate so the
alias a record is filed under and the terms it is found by fold by
construction rather than by two tables happening to agree; the
slugger's own locked assertions pass untouched, which is the proof the
move changed nothing.

Two guards for the two ways this goes wrong quietly. An ASCII text
takes a fast path that provably tokenizes identically, so every
existing field indexes bit-for-bit as before. And a term both passes
find is counted at the HIGHER of the two frequencies, never their sum —
doubling a weight fails no assertion about which rows come back, only
about the order they come back in, so it would have shipped silently.

The index holds terms, not the rule that made them, so a catalog
written before this holds the wrong ones: schema 15 rebuilds every
posting row from the annotations that produced it. It is a
recomputation from data the store already has, not a repair, and it
takes the same builder the writer uses so the two cannot drift. It
deliberately does not enforce the index-term budget — these rows were
admitted under it already, and failing a migration on a budget would
leave a catalog nobody can open. Alias postings are untouched: an alias
is `[a-z0-9_-]` by construction, so the fold is the identity on every
one and rebuilding would be a whole-table cost for a guaranteed no-op.
Both openers carry it, the server's migration ladder and the embedded
one, and the embedded gate now admits a v14 root rather than refusing
it.

Verified against a real 3.2 MB catalog: it migrated 13 -> 15 on open,
after which "chloe" finds a track titled "…(Chloé Caillet mix)" and so
does "chloé"; "chlo", the truncation the old index held, still answers,
which is the superset law showing up as behaviour; and the ASCII
searches around it return exactly what they did before. The migration
was not perceptible on that catalog, but I could not isolate its cost
honestly — it commits inside the WAL and the store opens lazily — so no
number is claimed here.

The store's integration tests cannot link in this environment
(sqlite3.lib, pre-existing and unrelated), so the four end-to-end tests
added here are unrun locally; the tokenizer law itself is covered by
unit tests that do run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit d61b9dd4fe3c682c6e597e894f17c4c3a8b4e296)
2026-09-19 01:17:35 +02:00
Admin
74b2933aec svg: a stroke that bends tighter than its half width collapses its inner edge to the corner
A path that bends tighter than the stroke's half width has no inner
offset curve: the per-point inner miter points run backwards along the
path and the strip folds over itself there, spikes on the inside of the
bend and doubly blended wedges across it. The true inner edge of such a
bend is the corner where the inner edges of the two segments around it
meet, so every point of the folded run now takes that corner as its
inner vertex and the strip fans around it. Bevel and round joins are
emitted as strip pairs whose inner side collapses to the miter point,
so the two segments' quads no longer overlap; only a segment shorter
than the stroke is wide keeps its two offset points.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 01:17:25 +02:00
Admin
b184970545 flow: the flow-ui app and the flowgraph canvas return to the workspace, with staged progress in libs/flow
apps/flow-ui is the flow editor again: the graph canvas with its camera,
cards and progress bars, the node faces and inspector, the source and
App views, the running list, the menu bar and the toolbar with the run's
total bar, the template picker behind New, the hub model lists, and the
aichat bridge (`services`) with the port, event and worker wiring.

libs/flowgraph is the reusable graph-canvas widget with a display-only
view model and orthogonal wire routing; the review that removed the
two-fillet restriction from `valid_orthogonal` stays local.

libs/flow reports a turn's progress as stages: a `Stage { stage,
permille }` event carries the named phase (admission, download, load,
prefill, serving) and that phase's own fraction when the producer knows
it, while a node's whole-operation permille is optional and reaches
1000 only with Done; the hub's Loading fraction belongs to its phase
and restarts with each one.

makepad.splash lists the app for the Studio release runner; both crates
are workspace members.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 01:17:25 +02:00
Kevin Boos
10b33c528e
libs/ai: exclude hub_ui and services from the AI workspace (#1242)
* Button: activate from the keyboard when focused

`Button` matched only `KeyFocus` and `KeyFocusLost`, so a button reached with
Tab could take focus and animate, but no key press ever activated it.

* `Space`, `Enter` and numpad enter now emit `Pressed` and then `Clicked`, and
  make the same `on_press`/`on_click` script calls a tap does.
* Key repeat is ignored, so holding the key down doesn't re-press.
* Both arms are gated on `enabled`, like the finger paths.

* libs/ai: exclude hub_ui and services from the AI workspace

`cargo install --git ... cargo-makepad` fails with "package
`libs/ai/services/Cargo.toml` is a member of the wrong workspace": the root
workspace lists `libs/ai/hub_ui` and `libs/ai/services` as members, but both
sit under `libs/ai`, which is its own workspace, and cargo gives a package to
the first workspace root above it that doesn't exclude it. `libs/ai/hub` and
`libs/ai/livepipe` were already fine since each declares its own `[workspace]`.
2026-09-19 01:04:19 +02:00
Kevin Boos
43cab688f9
Button: activate from the keyboard when focused (#1241)
`Button` matched only `KeyFocus` and `KeyFocusLost`, so a button reached with
Tab could take focus and animate, but no key press ever activated it.

* `Space`, `Enter` and numpad enter now emit `Pressed` and then `Clicked`, and
  make the same `on_press`/`on_click` script calls a tap does.
* Key repeat is ignored, so holding the key down doesn't re-press.
* Both arms are gated on `enabled`, like the finger paths.
2026-09-19 01:01:47 +02:00
Admin
f16204fbb4 cef: a page's audio can be captured instead of played -- the audio handler is bound rather than stubbed, stays absent until a browser asks for capture, and hands packets to the embedder without ever making Chromium's capture thread wait
CefAudioHandler next to the paint handlers: Browser::enable_audio_capture
names a rate and a channel count, Chromium mixes the page down to it and
mutes the page's own output while the capture runs, and poll_audio drains
Started / Packet / Stopped / Error on the thread that pumps CEF.

A browser that never enables capture hands CEF a null handler, exactly as
the stub did, so nothing that embeds a page today changes.

The capture thread takes no lock the embedder can hold: packets go out
through a bounded try_send, their buffers come back through a try_recv
and are reused, a full queue drops the packet and counts it, and a lost
Started or Stopped is made up again from the stream epoch on the drain
side.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 00:46:41 +02:00
Admin
68e1892585 platform: the Android desk swipes at 120 Hz — offscreen render passes and framebuffers cached, SVG meshes kept, font families that complete
Measured on the Pixel 11 Pro XL (PowerVR): a pane swipe presented at
~84 fps on the 120 Hz panel, a vsync dropped every three to six frames.
simpleperf showed 22% of the CPU in the driver's shader compiler and 24%
in its render-target teardown: the Vulkan backend created a VkRenderPass
and a VkFramebuffer for every offscreen pass on every frame and destroyed
them after the fence, and on this driver each render pass compiles a
load-op shader. Offscreen draw render passes now live for the device
(keyed by formats and load/store ops) and framebuffers are cached per
render pass, attachment views and storage extent, invalidated through
texture retirement so they die after the frame that used them.

The app icons were re-tessellated from SVG every frame: one DrawSvg kept
one scale and the desk draws each icon at two or three sizes. A DrawSvg
keeps up to four meshes per device scale; the geometry pool defers frees
and releases them once per frame against the geometry ids the live draw
lists still name, so a retained draw call never sees its slot reused.

A font member whose resource can never load (the WM referenced Inter and
its other faces through `self:../../widgets/...`, unmapped in a package)
kept its family incomplete, and an incomplete family is redefined every
frame: the layout cache cleared, every label laid out again, the asset
reopened. Such a member drops out of its family once, logged, keyed on
the resource registry's generation so a resource that appears later is
asked for again. The WM names its fonts through `makepad_widgets:` and
reads the clock in-process on the UI thread instead of forking `date`
twice a second.

Android gains a `frame.cpu` trace (events, next-frame, draw and repaint
milliseconds per drawn frame) and a profileable manifest so simpleperf
can sample a release build. The dyn-pack tile proof tolerates the app's
own Dirty line after an engine rebuild.

After: SurfaceFlinger presents every swipe frame at 8.3 ms, the render
thread runs at ~45% instead of 85–97%, and the frame is paced by the GPU.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 00:03:14 +02:00
Admin
a072563d8c wm, widgets: the style tween starts with all eight weights, and the new style sheet takes the last number instead of macOS's
The contribution widened `StyleTween` to eight weights but left its two initialisers at
seven, so the window manager did not compile. It also declared `BlackOrange` second in
`DesktopStyle`, while the window manager reads the tween's weights by discriminant (1 is
macOS, 3 Windows 2000, 4 NeXTSTEP): every style after Omarchy would have driven the chrome
of the one before it. The new style is declared last, `ALL` keeps the order the sheets are
shown in, and `next()` walks `ALL` by place rather than by discriminant. An unused import
in a storybook story goes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 22:56:31 +02:00
vjroger
6f345003c9 widgets, storybook: the widget catalogue app, some eighty new widgets, a theme store with mixable style sheets, and the rule that a press belongs to whatever took it
Squashed from vjroger/makepad `storybook-pr` at 086d25452 (1,332 commits on top of
6f1e44649; his last commit restored every path outside the contribution to upstream).

- apps/storybook: every component organised, documented and previewed live.
- widgets: about eighty new widgets (accordion, alert, avatar, badge, breadcrumb, calendar,
  card, carousel, chat, chip, colour, command palette, date and time pickers, dialog,
  dropzone, floating action and panel, form, hamburger, line and radial menus, kanban,
  masonry, menu, nav list, pagination, pill nav, popover, progress, property inspector,
  range slider, rich text, select, spinner, table, tabs, tag field, timeline, toast,
  toolbar, tour, transfer, tree, waveform, wheel picker and more); theme tokens and a
  theme store, themes mixed by weight with a legibility check, a twelfth style sheet in
  black and orange; the data grid gains row selection, drag and reorder, heading tips and
  alignment; the glass button is a water lens; the portal list keeps the wheel it uses,
  stands down from a press another control holds, can keep a row on screen and rule the
  gap under a short list.
- platform: sweep locks and scroll blocks nest, `is_mouse_held_outside`, per-axis
  scroll-handled flags, `next_frame_is_pending`, owner-scoped scroll unblocking, a
  hands-off marker for the remote bridge, exploded-view projection and focus.
- draw: the interior distance of square-cornered boxes, a pointer shape, and
  `turtle_ancestor_clip`.
- wm: the style tween carries an eighth weight for the new sheet.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 22:56:31 +02:00
Admin
d86cbfe23e cargo-makepad: the super-app APK is packed by android dyn-pack, in Rust
`cargo makepad android dyn-pack` stages the relocatable checkout,
cross-builds host + engine from it through the ordinary Android build,
proves every tile's on-device command against that target/, packs the
APK with the phone toolchain, the checkout and target/ as streamed LZ4
tar parts, and rehearses the phone's first tile open from the packed
APK; `dyn-rehearse` runs that last gate alone. This replaces the
Python and shell pipeline that lived outside the tree. The stage
directory is tool-owned, every cargo phase runs under one controlled
environment recorded in the target's marker, rustc runs through
cargo-makepad itself as the remapping wrapper, and the APK is renamed
into place only after signing and the rehearsal. The host package
names its engine and tiles in [package.metadata.makepad.dyn]. Only the
three /system/bin/sh templates that run on the phone stay shell.

libs/rmeta is the rustc metadata header reader apps/wm used, now shared
with cargo-makepad; libs/tar gains a streaming ustar writer with GNU
long names; the zip writer streams to any sink so a 900 MB APK never
sits in memory.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 19:02:50 +02:00
Admin
fbfec26ad7 wm: the Android super-app compiles tiles on the phone against the host engine
The APK carries rustc, the checkout and a packed target tree as LZ4
frames. First compile streams them out of the asset manager into files/
and shows progress on the desk; later launches skip unpack when the
stamp matches. App crates keep a desktop Cargo.toml — dynamic-module is
empty — and rustc `--extern force:` binds makepad_wm_engine already in
the process so widgets stay the host dylib.

libs/lz4 grows a streaming frame codec and a makepad-lz4 CLI; libs/tar
unpacks those frames without buffering the archive. Android Vulkan
records two in-flight repaints instead of waiting every pass, and the
capture Y-flip applies only on the OpenGL fallback.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 17:50:20 +02:00
Admin
6f1e446495 builder: Makepad WM and Makepad Apps are direct launches; Scope comes to the front with --focus
The root menu names the two entries Makepad WM and Makepad Apps, in that
order, below Compile and run Scope. Every entry in Makepad Apps now does
what the WM entry already did: set up any missing compiler, download the
sources, compile and open the app, then return to the list with the
selection kept. The nested per-app checklist and its Back entries are
gone, so the menu has one shape. The Builder's runbook follows.

The platform reads --focus since this morning, not MAKEPAD_FOCUS; the
macOS launch of Scope passes the argument.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 14:41:09 +02:00
Admin
384d0e031c tools: the Builder replaces makepad_loader, the web server moves to makepad/webserver, fleet scripts, docs and the workspace members
tools/makepad_builder replaces tools/makepad_loader: one build target
shared across app builds, workspace package selection, checkout
progress on the public Git API, detached built apps with a completion
state, waits for Windows security scans, manual retry after compiler
locks, dedicated-folder installer checks, catalog and runtime fixes.
tools/web_server and its scripts leave for github.com/makepad/webserver.
Arch USB clone/restore scripts, the qwen38 box scripts and the G-belt
serial test join tools/. docs/agents records the agent workflow and the
remote-control handoff protocol; AGENTS.md forbids vendored sources and
bulk imports. Cargo.toml lists apps/wm-dyn, libs/code_language,
libs/search, libs/tar, libs/loader_bundle and tools/makepad_builder,
and drops the two removed crates.

Squashed from work:
- Share Builder target across Makepad app builds
- Fix Builder workspace package selection
- Align Builder checkout progress with public Git API
- Detach built apps and show completion state
- Wait for Windows security scans
- Offer manual retry after Windows compiler locks
- docs: the agent workflow of record and the remote-control handoff protocol
- builder: dedicated-folder installer checks, catalog and runtime fixes; Windows job objects hold c_void handles
- tools: Arch USB clone/restore scripts, the qwen38 box scripts, and the G-belt serial test
- tools: the web server moves to makepad/webserver
- AGENTS.md: no vendored sources or bulk imports in the tree

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 12:17:59 +02:00
Admin
25a08dccbe apps: Apple Mail with Gmail labels, attachments and reimport; the Android super-app and its module apps; terminal exit polling; TextFlow word selection
apps/mail indexes local Apple Mail through the Envelope Index: Gmail
Inbox, Sent, Starred and Important are label rows, attachments open
from the message view, and a reimport button wipes the cache. apps/wm
hosts apps as on-demand dylibs on Android (apps/wm-dyn, with the module
apps' manifests and module.rs following). apps/terminal polls the child
with MpTerm::process_exited and resolves widget fonts through
makepad_widgets. Widgets: double-click selects a word in TextFlow,
links keep their hand cursor, and three stale tests follow the tree's
root rule and the mobile font policy.

Squashed from work (the apps and widgets parts of each):
- Index local Apple Mail with Gmail labels, attachments and reimport
- terminal: MpTerm::process_exited polls the child, and widget fonts resolve through makepad_widgets
- widgets: double-click selects a word in TextFlow; links keep their hand cursor
- wm: the Android super-app hosts apps as on-demand dylibs (apps/wm-dyn)
- widgets: three stale tests follow the tree's root rule and the mobile font policy

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 12:17:03 +02:00
Admin
2522a19e4f libs: sqlite WITHOUT ROWID reads, an in-repo tar reader, packed git imports, a Haskell lexer, and the AI crate warning cleanup
libs/sqlite_query reads WITHOUT ROWID tables through the index cursor,
refuses to write them, and the pager releases its process write slot on
drop. libs/tar is an in-repo tar reader with gzip through fast_inflate.
libs/git imports packed objects with bounded checkout writers and its
HTTP file responses carry a trailer. libs/code_language gains a Haskell lexer with literate (Bird) dialect
detection. libs/loader_bundle and libs/search are new;
libs/app_module carries the super-app module surface; libs/workspace
adds Settings.infinite_zoom and RendererChoice::gpu_env_value; libs/ai
builds without warnings across the hub, llm, metal and model crates;
windows-rs job object handles are c_void.

Squashed from work (the libs parts of each):
- Restore retained renderer support for Scope (libs/search)
- Share Builder target across Makepad app builds (libs/loader_bundle)
- Index local Apple Mail with Gmail labels, attachments and reimport (libs/sqlite_query)
- libs/ai: warning cleanup across the hub, llm, metal and model crates
- code_language: a Haskell lexer with literate (Bird) dialect detection
- git: packed imports and bounded checkout writers; HTTP file responses carry a trailer
- builder: dedicated-folder installer checks, catalog and runtime fixes; Windows job objects hold c_void handles (libs/windows)
- workspace: Settings.infinite_zoom, the experimental prepared map inside the glyph
- wm: the Android super-app hosts apps as on-demand dylibs (libs/app_module)
- libs/tar: an in-repo tar reader; the super-app unpacks its archives with it
- workspace: RendererChoice::gpu_env_value follows the platform's runtime GPU choice

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 12:15:55 +02:00
Admin
9b5575a2db platform: render-loop fixes from review, the remote gate, --focus, and owned state instead of globals
Review of the retained renderer on top of the runtime GPU choice: a
refused GL retained upload skips the draw and dyn-uniform writes are
bounds checked; Vulkan draws retained publications whose CPU-side data
is empty and records a fresh retained transfer command buffer after
each submission; Metal stamps consumption for every encoded retained
item, empty ranges included; a refused WebGL retained upload is final
for that content.
TaskPool::new_with_priority sets the heavy workers' thread priority.
Remote control: keys no longer hold the gate, if_user_seq is optional,
and status waits through a stall; `--focus` brings the app to the front
as its macOS window opens. The package map, the remote activity ledger
and the GPU choice are owned Cx state, not globals.

Squashed from work:
- platform: a refused GL retained upload skips the draw; dyn-uniform writes are bounds checked
- platform: TaskPool::new_with_priority sets the heavy workers' thread priority
- platform: Vulkan draws retained publications whose CPU-side data is empty
- platform: MAKEPAD_FOCUS activates the app when its window opens on macOS
- platform: `--focus` brings the app to the front as its macOS window opens
- platform: Vulkan records a fresh retained transfer command buffer after each submission
- platform: Metal stamps consumption for every encoded retained item, empty ranges included
- remote: keys no longer hold the gate, if_user_seq is optional, status waits through a stall
- platform: a refused WebGL retained upload is final for that content
- platform: the package map, the remote activity ledger and the GPU choice are owned state, not globals

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 12:15:08 +02:00
Kevin Boos
bc8c37357a Choose the Linux GPU backend at runtime, and make Vulkan the default (#1237)
* Choose the Linux GPU backend at runtime and pace Wayland frames adaptively

A Vulkan-capable desktop Linux build (the `vulkan` feature, or
`MAKEPAD=vulkan`) now carries OpenGL ES as well and picks between them
when its event loop starts: Vulkan on Wayland when a hardware device
answers, OpenGL ES when none does (no driver, only a software rasterizer,
or an X11 session). `MAKEPAD_GPU=auto|gl|vulkan` overrides the choice, and
`MAKEPAD=gl` still produces an OpenGL-only binary. The feature stays
opt-in: the hosted (`--stdin-loop`) and direct renderers of such a build
are Vulkan-only, and Vulkan has no video texture import yet.

Wayland frame pacing
- Pace presents by what the backend and the session can actually do,
  rather than by a fixed number (new `wayland/frame_pacer.rs`). Vulkan
  runs two presents in flight only when the compositor offers `fifo-v1`
  and the driver uses it; otherwise a second present would block inside
  `vkQueuePresentKHR` on a callback an occluded window never receives.
  OpenGL starts the next frame early only when its measured cost says the
  swap would land after the outstanding callback is due, so cheap frames
  are not committed twice per refresh. One present in flight, which is
  what this did before, left a heavy scene at half the display rate.
- Bound the pacing gate at 250 ms so an occluded window cannot freeze the
  app's clocks, and let pending screenshot requests through it.
- Treat WouldBlock on the display flush as transient.

Vulkan
- Bound the frame fence wait and the swapchain acquire on Linux instead of
  waiting forever.
- Keep the per-frame packet arena mapped, recycle completed frame
  resources on the window path, and ask for one more swapchain image on
  Linux, where the pacing can keep two presents queued.
- Skip CPU devices unless `MAKEPAD_GPU=vulkan` asks for Vulkan explicitly.

OpenGL
- Stop repainting forever at rest: poll the texture lifetime fence once
  per frame, and check for time-driven shaders only after the
  zero-instance skip, as Vulkan does. The explicit
  `Cx::frame_completion_serial` poll still always arms a fence.
- Upload draw-call uniforms only when they changed; they were uploaded
  twice per draw call per frame. A zbias shift now marks them dirty, so a
  call skipped that frame still uploads when it next draws.
- Compute the retained-instance upload plan once per buffer per frame; it
  was computed three times.
- Target remote screenshot requests at the presenting window. Every
  `--remote` grab timed out on OpenGL before this.
- Emit the `gpu.present` trace with render and swap timings.

Retained instances
- `upload_plan` settles segments that kept their slot and offset by `Arc`
  identity, scans for the first few that moved, and only then builds a
  pointer-keyed map. On a large map this took a plan from 0.3-1.5 ms to
  about 0.07 ms. Results are identical to the previous planner.
- Add `collect_backlog` so a renderer can drain retirements once a frame.

Runtime backend consistency
- `CxOs::vulkan_active()` replaces the compile-time branches that decided
  between the two renderers, so a build that fell back to OpenGL releases
  its uniform buffers, shares host swapchains and retires textures the way
  an OpenGL build does.

Wayland teardown
- Drop windows before the `Connection`, and destroy a window's EGL surface
  and `wl_egl_window` before its `wl_surface`. Every OpenGL exit on
  Wayland segfaulted inside NVIDIA's egl-wayland.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Make Vulkan the default on desktop Linux, with mipmaps and hosted fallback

Every desktop Linux binary now carries both renderers and picks at startup,
instead of only the apps that asked for Vulkan by name. Three things had to
be true first.

Gate the feature where the fallback exists. build.rs derived `use_vulkan`
from `target_os == "linux"` alone, which also matches OpenHarmony and every
other Linux triple, none of which carry `naga`, and it ignored
`MAKEPAD=linux_direct`, whose DRM/KMS renderer has no OpenGL fallback of its
own. The feature now only reaches x86_64/aarch64 gnu windowed builds;
`MAKEPAD=linux_direct+vulkan` remains the way to ask for direct Vulkan.

Give Vulkan a mip chain. `image_cache_use_mipmaps` was off for Vulkan
because the uploader only ever filled level 0, so every minified image
aliased. Images now allocate their full chain and fill levels below the
first with `vkCmdBlitImage`, the way `glGenerateMipmap` does, skipping
formats the device cannot linearly blit. On Robrix's sign-in icons this
takes Vulkan from 2153 pixels differing from the OpenGL render by more than
8, to 400.

Choose the hosted renderer at runtime too. `--stdin-loop` mode was
Vulkan-only in a Vulkan-capable build and panicked when no device answered,
while its host, on an X11 session, had already fallen back to OpenGL: with
the feature on by default that combination would have killed every child the
wm launches. The hosted path now selects the way the windowed one does, its
import follows the renderer the process actually started, and a hosted child
rejects software devices for the same reason a window does.

Video and `Texture::read_back` are still OpenGL-only; the video error now
names `MAKEPAD_GPU=gl`, and the feature comment says so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* workspace: drop the renderer-routing argument and its build-time wording

One desktop Linux binary now carries both GPU backends and picks at startup,
so an app honouring a saved renderer choice passes it to the platform as
MAKEPAD_GPU and restarts itself. Nothing produces `--renderer-routed` any
more; an argument this parser does not know was already ignored, so dropping
its arm changes nothing for anyone still passing it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Keep the shared Android and direct-display paths as they were

An audit of what this branch reaches on platforms that share these files
found five places where it changed behaviour it was never meant to touch.
All of them come from code this branch made runtime-selected or relaxed.

Mipmaps are desktop Linux only, matching `image_cache_use_mipmaps`, which
is what asks for the format. An Android or Quest Vulkan build shared the
new chain code and would have allocated levels and recorded blits that
nothing there requests and nothing measured.

The mip chain also needs more of the format than it asked for. It checked
only that the format samples linearly, while `record_mip_chain` blits
between levels, so it now requires BLIT_SRC and BLIT_DST too and keeps a
single level otherwise.

Shader compilation stays a compile-time answer off desktop Linux. Whether
a draw shader is compiled to SPIR-V became a runtime `vulkan_active()`
test, which on Quest would follow an Android Vulkan init failure instead
of the build. Only desktop Linux has that fallback.

The hosted loop compiles GLSL only when OpenGL is the renderer. Losing its
cfg left it calling `gl()` in a Vulkan hosted child, which has no EGL
context, so it panicked. Its Wayland sibling already guards this way.

The direct display build keeps its software-buffer upload. `texture_for_draw`
gained a `not(linux_direct)` that was never needed: `MAKEPAD=linux_direct`
without Vulkan has its own `upload_presentable_image_software_buffer` in
os/linux/presentable.rs, and the outer gate already excludes the direct
Vulkan build.

Also: `gpu_preference` is now gated exactly where its caller is compiled,
since `vulkan_linux.rs` builds for every `target_os = "linux"` under
`use_vulkan`, and the hosted loop's imports follow the block that uses
them, which the direct Vulkan build does not compile.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 12:14:30 +02:00
Admin
a67096d20b platform: the retained renderer for Scope, the vulkan cargo feature and Cx::gpu_backend()
Retained renderer support is back for Scope on Metal, Vulkan, OpenGL,
WebGL and the simulated GPU. A `vulkan` cargo feature picks Vulkan on
desktop Linux, Cx::gpu_backend() names the compiled GPU API, and the
direct WM builds again. Settings.renderer in libs/workspace keeps the
saved GPU API choice (Vulkan | OpenGL) behind the --renderer-routed
argument. The Android build keeps the texture alloc types imported for
OES adoption, and that import stays off the web build. The simulated
GPU builds on Linux again.

Squashed from work, without the cargo vendor snapshot the retained
renderer commit carried there:
- platform: a `vulkan` cargo feature picks Vulkan on desktop Linux; Cx::gpu_backend() names the compiled GPU API; the direct WM builds again
- workspace: Settings.renderer — the saved GPU API choice (Vulkan | OpenGL) and the --renderer-routed argument
- Restore retained renderer support for Scope
- platform: Android builds again — the texture alloc types stay imported for OES adoption
- platform: the Android texture-adoption import stays off the web build
- platform: the simulated GPU builds on Linux again

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 12:14:21 +02:00
Admin
b5a545bfe2 Share language tokenizers with the code editor on work 2026-09-18 12:12:36 +02:00
Kevin Boos
b5bd9bc6b9
Ten fixes from moving an app onto current dev: macOS drawable/resize, the UI signal broadcast, internal drag on Linux, and log noise (#1239)
* Button: add `label_align` to center a wrapped label

`Button::draw_walk` passed `Align::default()` to `draw_text.draw_walk`, so
a label that wrapped onto more rows left-aligned them under each other even
when the button itself centered its content, and no script property could
reach that argument.

* New `#[live] label_align: Align`, mirroring the one `TextInput` already has.
* Defaults to left, so every existing button draws exactly as before.
* Pair it with a `Fill`-width `label_walk` to give the rows room to move.

* macOS: don't paint into a drawable the layer has since outgrown

The prefetching `DrawableWorker` hands back a drawable that `nextDrawable`
acquired on the previous beat. A frame that straddles a resize therefore
attaches a texture of the old size to a viewport derived from the new one.

* moving a window between displays of different DPI drew the whole UI at
  the old scale, and it stuck: a dpi change dirties the pass exactly once
* dragging a border fast left the strip the old texture didn't cover
  unpainted, which macOS shows as magenta

Check the invariant where the drawable is consumed: a texture whose size
isn't the layer's `cal_size` is dropped and one is acquired on this beat,
the way the pre-worker path did every beat. The pool was just rebuilt for
the new size, so that acquire doesn't block. Traced on the `present` topic.

* Window: don't put an app icon in the caption bar by default

`AppIcon` falls back to a generic placeholder tile for any app id makepad
ships no artwork for, so every third-party app got a meaningless icon next
to its title, and an off-centre title with it.

* `AppIcon` gains `visible`, which it had no way to express before
* the caption icon defaults to hidden; apps opt in with
  `caption_icon +: {visible: true}`

* Quiet the startup and per-decode log spam

A plain run printed ~1500 lines before anything happened.

* `zune-qoi` was the only zune crate with `log` in its default features,
  and cargo unifies that onto `zune-core`, so every zune decoder logged
  per image. zune-core's macros became real logging in this tree, so what
  used to be inert now floods the log
* memory budget, task-pool priority/summary and the Metal retained-upload
  budgets move onto the `MAKEPAD_TRACE` topics this tree added
  (`memory`, `pool`, `gpu.upload`)
* drop the studio-websocket line, which only says a disabled thing is off

* Make the UI-hang sampler opt-in

It started with every `Cx`, so a shipped app carried a thread waking ~16
times a second forever, and any stall over 250ms got the UI thread
suspended once per sample while its stack was walked.

`MAKEPAD_UI_HANG_MS` is now the switch as well as the threshold. Unset, no
thread starts and the phase guards see a null registration, which is a TLS
read and a null check.

* Wake the event loop from render workers without raising the UI signal

`Event::Signal` means "a worker has something for you" and is dispatched to
the whole widget tree. The submitter, the instance allocator and the
drawable worker raised it after every commit, so an app painting at 120fps
walked its tree 120 extra times a second. Measured in robrix: 119 signals
against 111 repaints, down to ~0.3 per frame.

They only ever wanted the loop awake, so give them `wake_ui_loop()`, which
is what `set_ui_signal` already called underneath. A dirty pass is what
keeps the paint clock armed, so nothing depends on the flag to get painted.

* Compare the resident instance bytes instead of hashing them

`immediate_payload_hash` FNV'd every byte of every dirty draw call to skip
the upload when nothing changed. That suits a few big payloads, not a 2D
frame: robrix scrolls ~1090 draw calls of ~110 bytes, and the hash cost
2.3ms a frame in a debug build to skip ~12% of 110KB of uploads.

Instance buffers are StorageModeShared, so the resident copy can just be
compared. `memcmp` stays fast in an unoptimized build, and an exact
comparison can't collide into a stale frame the way a hash can.

* Install a platform stylesheet only when an app asks for one

`current()` picked "ios"/"android" straight off `OsType`, so any app built
for a phone was silently restyled: ~270 theme tokens including the fonts,
over whatever the app had already set.

Worse, it only half-landed. `apply_theme` runs from `widgets_mod` but
`apply_widgets` runs from `script_mod`, so an app that calls the
`theme_mod` + `widgets_mod` pair got the mobile palette with desktop
metrics. Every in-tree user already calls `install` or sets
`MAKEPAD_WIDGET_STYLE`, so that variable is now the only implicit route.

* Let an internal drag deliver its pointer events on Linux and wasm

Moving internal drag handling into shared code changed it in two ways that
the macOS and old X11 paths never had, and a dock tab shows both.

The pointer event was replaced by the drag event rather than followed by
it, so no widget saw the `MouseUp`. `Tab` sets `is_dragging` on FingerMove
and clears it only on FingerUp, so a tab could be reordered once and then
never dragged again. Dispatch the pointer event first and append the drag
one, with a flag so that dispatch doesn't produce the drag event again.

A release that never moved after `start_dragging` also produced no Drop and
no DragEnd at all, so `Dock` never cleared `dragging_tab` and kept painting
the ghost. `Tab::min_drag_dist` has no default, so a press, one motion and a
release reaches it. Every other backend ends the drag unconditionally.

* Split the UI signal so makepad's own queues don't broadcast Event::Signal

`set_ui_signal` was the one wake for everything, and every platform loop
answered it by running makepad's handlers AND broadcasting `Event::Signal`
to the whole tree. So termination, the network runtime, live reload and
every pool completion (label shaping, per frame) woke every widget.

* `set_internal_signal`: the loops run their handlers and don't broadcast
* `TaskPool::submit_internal`: a job whose result makepad polls at draw
* `Event::Signal` is documented, and the loops treat the app flag as a
  superset of the internal one, so nothing left on it can regress

The scheduler keeps the app signal on purpose: `service_scheduler` re-arms
the platform timer from `call_event_handler`, which only the app half runs.
Media device changes still go through `SignalToUI::set`, whose instance API
is app-facing; they are hotplug-rare, so splitting that is left alone.

* Harden the drawable re-acquire, the internal drag and the opt-in sampler

Follow-ups from reviewing the five commits above.

* the resize re-acquire only runs while the drawable pool has a free slot.
  Exhausted, `nextDrawable` blocks the UI thread on the compositor, which is
  what the worker exists to avoid; skip the beat and stay dirty instead
* the byte compare no longer skips an item the GPU has evicted, which would
  leave it invisible in a pass that then repaints forever
* the internal drag suspends its items across the pointer dispatch instead
  of holding a flag. An unwound dispatch now ends the drag rather than
  wedging it for the life of the process, and a widget that starts a new
  drag from that dispatch keeps it instead of tripping "start drag twice"
* `tests/ui_hang.rs` opts the sampler in, since it is the thing under test
2026-09-18 09:06:31 +02:00
Kevin Boos
d771eda6fb
Button: add label_align to center a wrapped label (#1238)
`Button::draw_walk` passed `Align::default()` to `draw_text.draw_walk`, so
a label that wrapped onto more rows left-aligned them under each other even
when the button itself centered its content, and no script property could
reach that argument.

* New `#[live] label_align: Align`, mirroring the one `TextInput` already has.
* Defaults to left, so every existing button draws exactly as before.
* Pair it with a `Fill`-width `label_walk` to give the rows room to move.
2026-09-18 09:03:53 +02:00
Admin
a4ea2536a4 platform: topic hunks the per-commit assembly could not place, the Sep 2–15 dev PRs (#1208–#1236) as adapted to this tree, and the zero-warning chore
code_editor/Cargo.toml                          |     1 -
 code_editor/src/lib.rs                          |     3 -
 draw/src/cx_2d.rs                               |    83 +
 draw/src/cx_draw.rs                             |    43 +
 draw/src/draw_list_2d.rs                        |    22 +
 draw/src/geometry/geometry_gen.rs               |     7 +-
 draw/src/image_cache.rs                         |    51 +-
 draw/src/lib.rs                                 |     1 -
 draw/src/shader/draw_text.rs                    |    40 +-
 draw/src/shader/mod.rs                          |     1 -
 draw/src/text/fonts.rs                          |     2 +-
 draw/src/text/mod.rs                            |     1 -
 draw/src/text/slug_atlas.rs                     |     2 +-
 draw/src/turtle.rs                              |  3469 ++++-
 draw/src/vector/triangulate.rs                  |   378 +-
 libs/error_log/src/lib.rs                       |    17 +-
 platform/network/src/backend.rs                 |     6 +
 platform/network/src/backend/apple/http.rs      |     2 +-
 platform/network/src/http_server.rs             |    15 +-
 platform/script/derive/src/derive_scriptable.rs |    19 +-
 platform/script/src/lib.rs                      |     4 +
 platform/script/src/shader_calls.rs             |    92 +-
 platform/script/src/shader_hlsl.rs              |     9 +-
 platform/script/src/shader_metal.rs             |    18 +-
 platform/script/src/shader_wgsl.rs              |    12 +-
 platform/script/tests/hook_scope.rs             |     8 +-
 platform/src/action.rs                          |    12 +-
 platform/src/app_main.rs                        |   110 +-
 platform/src/cx.rs                              |   466 +-
 platform/src/cx_api.rs                          |   368 +-
 platform/src/draw_list.rs                       |  2598 +++-
 platform/src/draw_pass.rs                       |    35 +-
 platform/src/draw_vars.rs                       |   103 +-
 platform/src/geometry.rs                        |   588 +-
 platform/src/gpu_texture.rs                     |     2 +-
 platform/src/id_pool.rs                         |    71 +-
 platform/src/lib.rs                             |    26 +-
 platform/src/log.rs                             |   111 +-
 platform/src/os/apple/apple_game_input.rs       |     6 +
 platform/src/os/apple/apple_sys.rs              |     1 +
 platform/src/os/apple/macos/macos_app.rs        |   384 +-
 platform/src/os/apple/metal.rs                  |  3347 +++-
 platform/src/os/cx_native.rs                    |     5 +-
 platform/src/os/cx_shared.rs                    |   202 +-
 platform/src/os/gpusim/event_loop.rs            |    24 +-
 platform/src/os/gpusim/mod.rs                   |     8 +
 platform/src/os/gpusim/raster.rs                |    11 +
 platform/src/os/linux/opengl.rs                 |   856 +-
 platform/src/os/linux/vulkan.rs                 |   752 +-
 platform/src/os/linux/vulkan_linux.rs           |     4 -
 platform/src/os/linux/wayland/linux_wayland.rs  |    15 +-
 platform/src/os/linux/wayland/opengl_wayland.rs |    67 +-
 platform/src/os/linux/wayland/wayland_state.rs  |    37 +-
 platform/src/os/web/web.js                      |  1409 +-
 platform/src/os/web/web.rs                      |   212 +-
 platform/src/os/web/web_gl.rs                   |   284 +-
 platform/src/os/web/web_worker.js               |    76 +-
 platform/src/os/windows/d3d11.rs                |  1323 +-
 platform/src/remote.rs                          |  1297 +-
 platform/src/retained_instances.rs              |     3 -
 platform/src/script/res.rs                      |   245 +-
 platform/src/sploded.rs                         |    59 +-
 platform/src/texture.rs                         |   416 +-
 platform/src/thread.rs                          |  2816 +++-
 platform/src/thread/ui_hang/linux.rs            |     2 +-
 platform/src/thread/ui_hang/macos.rs            |    22 +-
 platform/src/thread/ui_hang/native.rs           |     3 +
 platform/src/thread/ui_hang/windows.rs          |     2 +-
 platform/src/web_socket.rs                      |    45 +-
 platform/src/window.rs                          |   129 +-
 platform/tests/texture_lifetime.rs              |   143 +-
 platform/video/src/stream_debug.rs              |     2 +-
 platform/video/tests/stream_codec.rs            |    85 +-
 widgets/derive_widget/src/derive_widget.rs      |     4 +-
 widgets/src/animator.rs                         |   138 +-
 widgets/src/combo_box.rs                        |    10 +
 widgets/src/desktop_style.rs                    |     4 +-
 widgets/src/drop_down.rs                        |    96 +
 widgets/src/font_policy.rs                      |    25 +
 widgets/src/grid.rs                             |     1 +
 widgets/src/image.rs                            |     3 +
 widgets/src/lib.rs                              |    18 +-
 widgets/src/map/archive.rs                      |   544 +-
 widgets/src/map/geometry.rs                     |    34 +-
 widgets/src/map/label.rs                        |    48 +-
 widgets/src/map/tile.rs                         |  6890 ++++++---
 widgets/src/map/tile_draw.rs                    |     2 -
 widgets/src/map/view.rs                         | 17896 ++++++++++++++--------
 widgets/src/menu_bar.rs                         |    18 +-
 widgets/src/screen_cap.rs                       |   513 +-
 widgets/src/slider.rs                           |     6 +
 widgets/src/splash.rs                           |   280 +-
 widgets/src/tweaker.rs                          |   221 +-
 widgets/src/view.rs                             |   105 +-
 widgets/src/widget.rs                           |     5 +-
 widgets/src/widget_async.rs                     |   269 +-
 widgets/src/window.rs                           |   601 +-
 widgets/src/window_voice_input.rs               |    17 +-
 98 files changed, 38159 insertions(+), 12677 deletions(-)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 13:40:33 +02:00
Admin
12d223737a tools, arch, docs, workspace, examples, sqlite, wasm_bridge
Squash of 55 work commits (Sep 1–12):
  0fd356d  windows: the vendored bindings are generated from a checked-in filter
  79882b5  fabric: a photo or a live camera to a fitted sewing pattern
  1e93309  AGENTS.md: designs stay local; no OS screenshots; focus and hidden-window laws
  aa96dbd  cargo-makepad wasm: package the bin target's wasm and create dirs before minifying
  14d0723  cargo-makepad wasm: production packaging — strip, small profile LTO, optional binaryen, size report
  79e7526  sqlite: a page-store seam — the file backend as before, an in-memory backend, and open_memory / open_with
  60ee978  cargo-makepad: package artifacts carry a content hash so a re-upload is a new URL
  611c9eb  cargo-makepad: production packaging stays off fat LTO; script VM under LTO investigated
  ab8febc  cargo-makepad: fonts packaged from the app's font manifest
  f6bbee9  wasm bridge: shared memory asks for the 4 GiB wasm32 ceiling and steps down where the engine refuses
  fb1416d  cargo-makepad: the threaded wasm module is linked with the 4 GiB wasm32 memory ceiling
  fd5d70c  cargo-makepad: the app's own resources are packaged under its bin name, which is how self:// resolves
  f51ca07  workspace: the wasm interpreter's tests build at opt-level 1 — its own profile setting is ignored inside a workspace, and opt-level 0 overflowed the script eval stack
  942ff86  sqlite: the browser store has one owner — its locks never wait on a clock
  82d0cfa  web path: the trace helper keeps its doc, the journal nonce steps a counter where there is no clock or pid
  6f8c08f  web-server: POST /api/crash stores crash reports in a rotating log on both servers
  106b38c  wasm bridge: the imported memory honours the module's declared limits
  94b8726  dj-pack: tracks in, stems through the hub, a site store snapshot out
  d9f04fc  dj-pack: pack reads caches, never creates them; dry-run writes nothing
  e6a305c  ai-hub + dj-pack: a whole track fits a stems job; long tracks split into spans
  d1910b8  web-server: a store snapshot's extensionless routes are served with the types the exporter recorded
  c1f7b53  asset-client + dj-pack: a long description never rejects a snapshot; the packer writes one bounded line
  453197d  dj-pack: analyse produces the beat grid, overview and loop-splat caches the demo cache ships
  9b4a3ca  network: every completion raises the UI signal
  fad1c49  web server: audio and text files are served, and models/ is immutable like maps/
  569b4a7  dj-pack: every CC BY and CC BY-SA version and the public domain mark are redistributable licences
  dc9cce6  workspace: no std clock on the web in any crate the web apps link — the last start-up worker death is gone
  c7639f0  clippy: timed std waits (sleep, recv_timeout, wait_timeout, park_timeout) are disallowed — they read the std clock and panic on wasm workers
  d748753  wasm bridge: the page environment carries js_worker_wait so the module links — the pool landing added the import for workers only
  ec40fdb  vj + widgets: double-click a knob or fader to reset it to its default — the Slider handles tap_count 2 and emits its normal Slide action; the deck controls carry their neutral defaults (pitch 0, gain/EQ/stems 1, filter centre, crossfader centre)
  fe23e06  AGENTS.md: the execution policy — zero locking on the UI thread as one mechanism for native and wasm, no temporary threads (the pool), the standard operating flow (Codex codes, Fable designs and reviews, Grok tests), and the tweaker on Shift+F10
  e689aea  web_server + geodata + route: live radar, wind and weather on makepad.nl/api — one bounded poller per feed, hourly, disk-backed cache served from an Arc snapshot (a restart never re-polls early), 503 warming until the first result, health reports ok/warming/unavailable with timestamps; KNMI key from --knmi-key-file, the documented anonymous open-data key otherwise; libs/geodata fetches through the platform HTTP client instead of shelling out to curl; the client retries 503 after 30 s and disables a layer only on 404
  cd33943  web_server: radar and weather run on KNMI's documented anonymous key when no --knmi-key-file is given; without --live-cache the pollers keep an in-memory cache and say so once
  2f3e393  web_server: a directory path without its trailing slash (/score) redirects to /score/ instead of 404, query preserved
  9a31d21  flow-ui + widgets: a chosen model shows no node list, and a closed ComboBox shows the start of a long label
  22b2c78  docs: streamline agent runbook and extract reference guides
  6058284  terminal: add hostable session multiplexing via tools/screen
  8a9345b  tools: migrate Cargo.toml lookups to segment-path keys
  8749b91  counter: keep app state across Splash reloads
  3ffd485  tools: add agent launcher and AIHub node update and smoke scripts
  c33a9d3  screen: add bypass and resume menu options
  c40d234  AGENTS.md: current delegation hierarchy (Grok mechanical, Codex hard, Fable manages)
  4184455  Workspace: scope lives at apps/scope (clone of makepad/scope)
  27844c9  makepad arch usb builder
  dd967eb  Workspace: drop nine members that are not in the repository
  1961768  AGENTS.md: hierarchy 2026-09-11 — Fable builds, Codex reviews, Grok proves
  9cd9f94  AGENTS.md: rendering is verified on the real GPU backend, headless is for logic tests only
  5f53254  AGENTS.md: GPU proofs may run hidden; only the headless CPU backend is out
  e950b08  docs: app-remote — hidden GPU runs vs the simulated-GPU backend, measured grab cadence, remote hazards
  9c94a77  arch: the platform plan names the simulated-GPU backend gpusim
  3009257  micro_serde: serde_json-style JsonValue accessors, pretty printer, depth limit and strict parse
  134cd76  gitignore: alternate target directories, root scratch dirs and stray logs are never source
  60ba86e  arch: the render node refs name platform/src/os/gpusim/mod.rs
  1dc571a  tools/arch_usb: Wi-Fi, Intel GPU firmware, the AI hub service and game-hardware udev rules on the Arch image; the WM session script picks the saved compositor GPU
  cc3b05a  tools/arch_usb: a polkit rule lets the arch account start, stop and restart the WM service

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 13:40:33 +02:00
Admin
ef94cb59d6 libs: piano_model, drumkit_phys, show_control, score_view, diffusion
Squash of 5 work commits (Sep 2–9):
  066c782  piano model: the lower register is hammered, not plucked
  f2b0253  drumkit: a sample-based kit for the score preview; the physical model parked as drumkit_phys
  3dfe5aa  show-control: restore sixteen DMX scenes with local save overlays
  b49e2e5  piano-model: calibrate partial gains and decay against recordings
  d73cd40  piano_model: add an interactive piano-physics explainer page

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 13:40:32 +02:00
Admin
b4f4eb1abd libs: geodata, map_build, mbtile_reader, map_nav
Squash of 7 work commits (Sep 1–12):
  f7093bf  map_nav: the search db's positioned read builds on Windows
  3c03397  geodata: a data library keeps its own clock — the GUI platform is not a dependency
  64d2d3a  map-build: an unfinished bake resumes or restarts itself, and the maps root does not depend on the cwd
  cb572ae  map-tiles: makepad-map-repack rewrites an archive to what the renderer reads
  30fc13b  map-tiles: repack runs on all cores, resumes per shard, reports --status
  2db9d48  mkmap: a root record may decode to 512 MiB — the densest world shards list over five million tiles
  e8be900  deps: drop osmpbf and serde_json from the root workspace

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 13:40:32 +02:00
Admin
f08ef0a5e3 libs: git
Squash of 1 work commits (Sep 9–9):
  842ea96  git: topological log, worktree lifecycle and bounded line diff in libs/git

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 13:40:31 +02:00
Admin
d2130e6550 libs: model, mesh_edit, csg, scene, sim, render, xr
Squash of 25 work commits (Sep 1–12):
  237da90  render: the sprite lane hands the screen draw back the way it found it
  895b9a7  particles: an emitter can ride a body's own frame
  914471f  ai-hub: a feed session whose last socket left ends on its idle timeout; skin: parent, skinned centroid and a nodes-only rig for retargets
  3fcccf3  sim: the whole world is implicitly editable, and one seam says where the ground is
  5692fab  sim: the landform world proves itself — walker through the tunnel included
  f4af6df  sim: terrain knows who changed it — a plan layer over player history
  adbb078  render: a water volume can be physics without a picture
  c17480f  render: a non-rigid body may carry its own orientation
  acad401  sim: an agent with no route holds and retries instead of walking into the wall
  22b2bf9  sim + chat: the composed world surface takes a map floor; the chat gets plan tools
  faf8112  web path: the tessellator's lap timer, the trace span and the fusion cycle timer have no clock on the web
  d3472eb  tsdf: the clock-taking XR helpers are native-only — the browser has no depth camera and no Instant
  4946c9e  sim + render: a repaint is not a world edit — colour and glow restyles never rebake the lightmap
  4317f58  sim + render + chat: walk decks as a surface, the filmed body is no obstruction, the brief never asks
  9791279  render: support rigged models and custom materials across viewers
  3ce2792  sim: use deck geometry for collision and sensing
  c8b79ff  Add portable PBR, rig and soft-body authoring support
  108d423  Add transactional polygon modeling and editable asset documents
  38f4d3b  Refine editable modeling and firm yarn character behavior
  08a2637  sim: add entity-owned lights and vehicle headlights
  00d96a7  render: add clustered lights, local shadows and incremental GI
  cf916af  render: import glTF asset extensions and wire clustered GI
  c6d2cca  model: cut transaction memory and raise capacity limits
  41af47a  raytrace: add a CPU probe-ray BVH budget example
  c963d0a  libs: the game sim splits into makepad-scene and makepad-soft-body; render, model, fab and the asset importer retarget

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 13:40:31 +02:00
Admin
81e2c16c30 flow, flowgraph, flow-server
Squash of 55 work commits (Sep 3–9):
  2d4037d  flow: every asset-creator generation kind as a prelude prototype, and every pipeline as a flow template — inventory and gaps alongside
  90dfcb6  flow: the language — splash prelude for nodes, evaluate a flow file into a graph with typed ports and located errors, tool schema from Input/Output, canonical writer
  2a902c6  flow: the recipe prelude loads behind the core one and every pipeline template evaluates, round-trips and is canonical — a test per template
  13f9359  flow: the server host (state thread, two planes over bounded_http, definition routes, events long-poll, lock/listen/token files, watcher) and the client (fail-closed connect, session, subscriber, embed policy); apps/flow-server bin and the flow-ui shell that hosts the server in-process
  2b98a1f  flow: the run engine — instances with an inputs table, one VM per run with live Fn closures, a dataflow scheduler, five executors (chat over the hub session, gen over the fleet, fn, http under an egress policy, ask that parks a run), content-addressed values with a RAM budget and spill
  b3da0c5  flow: ports declare their types — ports: {in: {name: @type}, out: {…}} — so a second image port (mask, last_frame, reference) is as typed as the first; Inpaint, edit references, Music.lyrics, Paint.reference_image, Control.control restored; DREAM closes its loop again; the node catalog walks the prelude
  098ba77  flow: three hostile test batteries — the server routes over real sockets, the language, the engine — with the findings pinned as ignored tests naming the bug
  428d23e  flow: instances, runs and values on the wire — instance CRUD and inputs (answers a parked Ask), runs with cancel, values on the data plane with ETag/Range and PUT upload, run events fanned into the ring by topic, autostart, janitor, bounded shutdown; real seams by default with with_seams for tests
  58f0700  flow: media inputs route by (domain, port) — inpaint takes image and mask as named inputs, control's image is the primary — and every gen node's declared port types are authoritative, including derived Image and Upscale; ocr template typed; empty legacy port arrays warn
  c36f1e7  flow-ui: the AI service bridge — a flows service (authoring + what is running) and one service per definition on the aichat bus, per-turn context, tools answered off worker threads
  a2e0c43  flow: the old pipelines ship as templates — GET /v1/templates lists them with label, brief, inputs and outputs; POST /v1/flows/{name} {template} creates a definition from one
  09e4bf1  flow: what the batteries pinned — 3xx refused, a node object where a port reference is required is an error, undeclared Fn ports refuse edges, parameter errors point at the field, the omitted node is named, allowed HTTP methods listed, a 192 KiB source cap, an unterminated Flow{ is a parse error
  263d6f0  flow-ui: the canvas — node frames with faces evaluated in an isolate per instance, typed port dots, bezier wires, drag-move and drag-connect, palette, inspector, source pane, App view, Running list — integrated with the chat bridge and wired to the run routes
  7b10d61  flow: the hub's models on the wire — GET /v1/models snapshots the discovered fleet (health + model list per node, refreshed on demand, fleet hints for tests) and /v1/nodes carries the model ids per domain
  14b194b  flow: the contact-sheet acceptance and an instance/run/value battery over real sockets — Http read, Fn, two images in parallel, an Ask that parks and resumes the run, a third image, an Http POST of the result
  a40ad74  flow-ui: the empty-state icon is the node's output type — a picture card waits with the picture icon, not the text glyph
  662a121  flow: the gen executor renews the hub lease every two seconds and says bye on shutdown, so fleet boxes stop reaping our jobs; flow-ui's bridge publishes run and instance events as messages on the bus, long runs return early with a subscription, and the AI gets templates, models and create tools
  64f6d41  flow: pin the exact chat hand-off the Llm node makes (system, user turn, model) as a regression test
  2dbd1b7  flow + hub: a flow's LLM turn asks the node not to think and sends no token cap for zero, so a fleet Qwen node answers with a visible paragraph; the empty-state placeholder draws one icon
  df9f449  flow: the style picker lives on the node whose input it sets — the add_style Fn, not the image
  e2a37c9  flow-ui: a format picker with common image sizes and a swap beside width and height, the picture clipped inside the card under the selection outline, a face-declared control mounts as a labelled row on the node it sets with a hint when it binds elsewhere, the Fn face gets a strip for them
  8b05496  flow + flow-ui: width and height snap to the type's step (16 for images, the doc is the authority) in the fields, the inspector, the presets and the evaluator with a warning, so a fleet backend never sees an illegal size; text in cards and the inspector scrolls inside a bounded area that follows streaming until you scroll up
  4aec005  flow-ui: wires route around cards with fillets and a pulse that travels the cable when a value lands; Clear drops an instance's generated state and keeps its inputs, as a route, a toolbar button, a menu entry and a shortcut
  f95e34b  flow-ui: the inspector has one purpose — the selected node's header and note, the settings its card does not show, its connections as chips, its result, the raw face source folded under Advanced; a full-window image viewer with cursor-anchored zoom, pan, fit and 1:1, arrows through the instance's pictures, save and copy digest
  8ff1d1c  flow-ui: wires never pass behind a card — endpoint cards are obstacles except at their own port stub, targets below or below-left route through a clear corridor; the selection outline is a stroke on the card's own rounded body under the port discs; a press on a picture box drags the card and a still click opens the viewer
  b7d95d8  flow-ui: the image viewer opens fitted and centred — the picture syncs after the stage has drawn instead of reading its cleared area as empty; a sampling-mode field replaces the abuse of the image shader's rotation; pan clamps at the picture's edge, 1:1 means one image pixel per device pixel, the checker stays fixed to the screen
  6139a73  flow + flow-ui: a card can face right-to-left — flip: true in the file mirrors its ports; the router is directional; a card auto-flips when that cuts its cable length below 80 percent, a hand flip pins it; the flip animates
  b41fab4  flow-ui: a wire takes the simplest shape the geometry allows — the clear S-curve wins over any corridor route, tangents scale with the hop, level ports get a straight line, and no route has more bends than an obstacle forces
  4781378  flow-ui: the image viewer is modal for input — wheel, pointer and keys stop at it, and the canvas ignores a wheel under any open overlay
  2ed495f  flow-ui: the flip decision scores length, crossings, bends and loops instead of length alone, so a crossed pair unflips; ports and wire midpoints carry direction chevrons that hide below half zoom
  8c40d28  flow-ui: a close button at the top-left of the image viewer, fixed to the screen; the bar and the button fade with the viewer
  4c37048  flow-ui: wires are stable and selectable — a route depends only on its own obstacles and keeps its kind unless a new one is clearly better, with a fixed tie-break; a click within six pixels selects a cable, the inspector shows the connection, Delete removes it
  c90df1f  flow + hub: a node advertises only the models it can admit, the flow picks admitted nodes for the named model, retries on refusal, and names every node's reason when none can take it
  8f23e3b  flow-ui: the port chevron sits between the disc and its label, never inside the disc
  f5ec84e  flow-ui: the port disc's shape carries the flow direction — outputs end in a point, inputs have the matching dimple
  cf3b648  flow-ui: oval port discs with a small input notch, the icon visually centred, and wires that start at the output's tip
  c6890fd  flowgraph: the flow canvas and wire router become libs/flowgraph, a widgets-only crate any UI can host; flow-ui is its first consumer
  27f02f2  flowgraph: the canvas's style table resolves its registered type, so node and port icons draw again
  2a51724  flowgraph + flow-ui: two wire modes never mixed — bezier or routed — and routed wires use narrow gaps
  5863c43  flow-ui: an input card is one text area filling the card, with no name beside it
  30ef925  flow-ui: the image generator card shows only its settings; the picture lives on the Output card it feeds
  d20a208  flowgraph: a routed wire turns right after its port and hugs its own card
  7e397cf  flow + flow-ui: Publish end node writes to the asset library; Assets tab in the Inspector; prompt-to-library template
  c71d639  flow + flow-ui + flowgraph: a stale keep-alive is replayed on a fresh socket; a palette drop rewrites the file text so the new card mounts; the progress bar is centred in the header and inset past the corners
  a777488  flow + flow-ui + flowgraph: the loaded flow is the editable design (Input.value lives in the file); Play clones it into a numbered locked run instance with a Design button back; inspector text rows follow the graph
  767e260  flow + flow-ui: parallel runs — random seeds with a die, a fleet parallelism estimate (generation nodes bound it; the chat box never caps the image fleet), batch routes, the Run dropdown (1 / max · N), Ctrl+Enter queues a run, and the Running panel becomes a Queue of every run with per-row and per-batch cancel and Clear all
  acd2328  flow + flow-ui: a cancelled run clears its cards and bars at once and its live nodes read cancelled; layout edits (move, size, flip) work while a run is shown; the queue row is one centre line — name, strip, state · time, x
  8f51704  flow-ui: a Templates menu on the bar lists every template the server serves; choosing one creates and opens a flow from it
  778dbb8  flow: accept bodyless 204 responses
  b0b0e41  flow-ui: select runs from the whole queue row
  2b4bff9  widgets + flow-ui: menu entries fire again — the bar's Closed action no longer hides the Selected one behind it, and an open menu takes pointer and key input before the panels beneath its popup
  6f2fdfa  flowgraph: a wire between nearly level ports takes the short run instead of a trip around the card (the fillet rule rejected any interior segment under two radii); fewer bends win only among routes of comparable length; six screenshot fixtures and the routing review
  39a1b74  flow-ui: a locked run's pictures and clips still take clicks — the viewer opens from the Output card again; only the inputs stay inert
  83a00d2  flow: archive generated assets and add live source editing
  47a043e  flow: add leased, reroutable fleet job admission for gen nodes

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 13:40:31 +02:00
Admin
60615db4ed libs: ai hub/models/cuda, speech, chat_ui
Squash of 54 work commits (Sep 1–12):
  6251f7c  ai-hub: body domain — live pose packets ride the realtime session
  ea50c77  chat_ui: the feed's session gets its profile brief back
  f51b5f3  ai-body: the crate for the native SAM 3D Body port, with its weights reader
  8211ae6  ai-body: the MHR rig and the pose head's parameter decoding, oracle-exact
  9e343a8  ai-body: the DINOv3 ViT-H+/16 backbone, crop and ray conditioning; Metal gains rope-half and affine layer norm
  69d842c  ai-body: the promptable pose decoder and its refinement loop, oracle-matched on Metal
  66e5e2f  ai-hub: SAM 3D Body runs natively — `sam3dbody` on the body domain, oracle-matched end to end
  a634198  ai-hub: the body-native commit carried a peer's in-flight hub hunks; put them back where they were
  9ff44e8  ai-hub: the body-native wiring, this time only the lane's hunks
  6a1c16b  ai-body: third-party notices — what the port is implemented after, and what it is not
  d78411a  ai-body: the per-step work moves to the GPU
  b22259b  ai-body: the context stays on the GPU; only the pose token leaves the loop
  346f31f  ai-body: flash attention for the head-dim-64 blocks
  45b5b98  ai-body: the crop size is a runtime knob, and the loop reports where its time goes
  4be6d19  ai-body: the test modules import the grid constants they still use
  7598346  ai-body: tensor-core GEMMs for the backbone, and the rig's correctives only where they count
  a9ce596  ai-body: the crop warp runs across cores
  8964ba6  ai-body: an FP8 backbone mode, off by default, measured against the oracle
  a2aaa8f  ai-body: the FP8 bias rides a column-broadcast add on the device
  d53c77d  metal: a device-resident ViT stack, and the body backbone rides it
  d006d0a  metal: resident f32 linears keep their weight on the device
  525ba1c  metal: a device-resident two-way decoder layer, and the body decoder rides it
  c9e6d88  ai-body: the hands pass — hand crops, the hand decoder, the hand-mode rig and the wrist fusion
  62dff26  ai-body: the mask prompt — a person's segmentation mask conditions the body pass
  a648cf8  ai-hub: body session options — hands, detect, persons=N
  8c568df  ai-hub: drop the SAM 3D Body reference worker backend
  7ff875a  ai-hub: keep a peer's in-flight beats/notes/local work out of the body commits
  31e5faa  ai-hub: local model runner, licence acknowledgements, a shared install panel; Beat This!, Basic Pitch and the Salamander drum-kit entries
  b94bc58  ai-services: the wire, the app port and the panel state — one conversation, many apps
  2acb798  ai-services: wire v2 — endpoints, receiver-side caps, result disposition
  8ae0ffb  ai-services: the engine core — registry, router and conversation, tested against a scripted model
  2308736  ai-services: the real models behind the engine feature — local through the hub, Claude, and none
  c3f631d  livepipe: one reusable pipe from a camera to a fleet node and back
  ff62db3  ai libs: the runtime env-var cleanup — precision is a per-caller policy, not an environment side channel
  04a94ef  realtime: one service-log line when a live session opens and one when it closes
  0ecb81c  ai models: the model-crates env-var cleanup — 172 research knobs gone, the unset default is the code
  4ca36c1  ai hub + services: the assistant's model comes from wherever it is resident — the fleet chat box, with tools, then the local weights
  432121e  aichat engine + wm: launch, then use — the assistant continues in the same turn once the app it started is on the bus
  7a5bf69  ai-hub registry: the Salamander drumkit samples come from the makepad.nl mirror — the GitHub repo only carries the .sfz files
  102ffc5  ai-services: messages on the bus — a manifest declares topics, the engine subscribes on a tool's behalf or by ToolResult.subscribe, a service publishes Message frames, an idle conversation wakes on a message as an event turn under rate laws; the WM bus forwards the new frames; every app that matches the wire gets its arm
  a837792  hub + flow: a whitespace-only chat completion is retried once and then fails instead of passing as an answer; a flow's model is a fleet model id unless it names a weight file on disk; chat models show under the text domain in /v1/models
  bc6c620  hub + flow: what the chat review found — the in-process route retries an empty completion too, a node says whether its prefill opened thinking so a brief-mode answer is never discarded, a preferred model falls back to normal election when no node has it, discovery keeps looking for the preferred model until patience runs out
  75c3441  hub: the PRO 6000 serves image as well as chat and text
  ad5e98b  hub registry: flux2-dev's VRAM estimate is its measured peak, 30 GB
  c7241e0  hub: a node that evicted every resident releases its cached allocator pool before refusing a load or publishing usable VRAM
  30575f0  flow: route generation by request workload
  1be1e21  ai-hub: gate downloads by disk capacity and recover fleet admission
  df6b394  filesystem_watcher, bounded_http, ai services: live and tool prerequisites
  79ebdb9  ai-hub: add a native Pixal3D image-to-3D backend
  0ba0d74  ai-hub: propagate typed refusals under reject queue policy
  cc6c872  Speed up H3 conditioning and video decoding
  e512059  Fix Qwen vision residency and generated material colors
  2864f68  ai-hub http client: bound every plain TCP connect to 3 s per address
  3d93229  ai: CUDA is a Linux/Windows-only dependency; the hub library defaults to llm + stt

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 13:40:31 +02:00
Admin
c4ceb84fd3 asset-ui, asset-server, libs/asset
Squash of 39 work commits (Sep 1–9):
  0d7185e  asset chat: catalog SQL answers in-process, and the game brief stops teaching the y mistake
  c18b7f4  game brief: railways are one call too
  b7fe32c  game brief: a destructible world, path-edited railways, and models that load now
  0e94bba  game brief: rivers, highways, junctions and gates are one call
  c189b03  game brief: agents follow the rules by themselves — you never script a stop
  c86a750  chat: a game session reads its doctrine first and is not the generation assistant
  1ef6f59  game brief: on a diet and world.plan-first
  e18cc69  chat: a tool call the model wrapped inside its own think block still executes
  d7f9813  game brief: to change a map, edit the plan you were given; vegetation is a layer
  876e3c4  game brief: buildings have insides — ask for a program at the door
  47ac158  asset-client: portable primitives — completion transport, cache store, static-site config
  9ae91ac  asset-store: static export contract — versioned static index and a sanitized deterministic export-static tool
  f233756  game brief: races, rings, mocap and biomes are one call each
  942476f  vj: the whole app type-checks for wasm32 — portable hub discovery and typed-unavailable client facades
  2e48c5b  asset-client: the static-store read mode and polled runtime
  e2a262b  asset-store: the portable core — embedded feature, page-store and CAS seams, a synchronous in-process API
  9942e26  asset-store: the embedded module lives in its directory now (the browser-durability landing's deletion)
  670cc76  asset-store: the platform storage API is a hard dependency on the web, where the embedded store is the only store
  6b77318  chat: the in-game builder never asks before building
  9630337  asset-store: export-static selects what the namespace holds and says why when it does not
  0684b41  asset-store: export-static reads every content schema the server serves
  5fdd5d5  vj: on the web the explorer lists the site store's music and a deck loads it with its stems
  40e483f  vj: the web music browse actually fires — proven on the live path
  44b3a7f  dj-pack + vj: every track carries its artist, licence and source, and the DJ UI shows them
  cb75d36  asset client + store: no std clock on the web — the catalog worker no longer dies at start-up
  78a1681  bounded_http: the store's HTTP/1.1 server parser is its own crate — the store keeps only its JSON response shim; flow-server is the second consumer
  285bc98  asset-ui: workers from the runtime pool, the video player owns its frames and audio state
  90c4f59  asset-store: bump the static-export golden snapshot for the new rights fields
  b37b116  media_view: the video, audio, mesh and splat viewers leave asset-ui for one crate; flow-ui shows every media type on the Output card and in the modal viewer
  88048aa  assets: extend authoring pipelines and typed asset search
  0ac0892  Expose bounded modeling tools and reference-image continuations
  36121c3  Keep modeling conversations running until completion or manual stop
  2cea218  asset: centralize library and store path resolution
  72dbc5a  asset-creator: run pipelines through Flow graphs
  0047e77  asset-store: add guarded, alias-scoped atomic publish
  e85e36a  asset-store: refresh model-preview sessions with a lease
  933d885  asset-chat: compact repeated image diagnostics; require 45-degree views
  626e797  asset-importer: gate pack import to desktop Unix; add missing newest field
  f5e136d  asset-importer: shade world previews with vertex colours

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 13:40:30 +02:00
Admin
bb5022775e apps: terminal
Squash of 2 work commits (Sep 2–2):
  7f1fefd  terminal + browser: the assistant can read the screen, run a command, read and steer the tabs
  a772ec8  terminal + browser: a warm-pool standby joins the AI bus only once adopted

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 13:40:30 +02:00
Admin
0a12d4c976 apps: browser + CEF
Squash of 3 work commits (Sep 2–6):
  5c40761  cef: the build script downloads its CEF distribution itself, on every host, with no shell
  eec516e  cef: a Windows backend — the browser's page renders on Windows
  3dce90e  Propagate browser appearance changes to Chromium page media

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 13:40:29 +02:00
Admin
22aa634642 apps: vj
Squash of 34 work commits (Sep 2–9):
  cb97fc5  vj: the loop splat — a song sliced into per-stem, beat-quantized loops on the APC40 grid
  5b309b3  vj: 32-bit usize constants no longer overflow on wasm32
  441d604  vj: dropped or picked audio publishes into the connected store
  6be1118  vj: the whole app on the web — site store for content, embedded store for local imports, native-only seams Unavailable
  aa7345d  vj: the whole app on the web — site store for content, embedded store for local imports, native-only seams Unavailable
  511048c  vj: no filesystem paths on the web startup path — the store is the disk there
  78bfd5c  vj: the web explorer shows the store's tracks — the listing reaches the rows
  cc8ee5c  vj: the web music browse fires when the store is ready, and startup never stalls the UI thread
  53f0e96  vj: the DJ/VJ app exposes itself to the desktop assistant
  ffd62da  dj-pack + vj: the demo cache carries every per-track analysis — slices, beat grid, overviews — and the web app reads instead of computes
  f35dd1d  vj: effect thumbnails actually render and persist on the web
  6fddf9e  vj: loading a track onto a deck fetches and decodes its audio on the web
  5c78ac0  vj: web effect thumbnails show the effect, not a black frame
  21fce80  vj: stale-recipe thumbnails are swept from storage on startup
  afdffd7  vj: clicking an effect tile puts it in a channel on the web vj: clicking an effect tile puts it in a channel on the web
  cc1f256  vj: cached thumbnails appear within a second vj: cached thumbnails appear within a second and the pipeline never hangs
  4f98c73  vj: a deck shows what it is loading — fetch, decode, stems — with real progress vj: a deck shows what it is loading — fetch, decode, stems — with real progress
  66617cd  vj: stem separation is a setting — hub by default, local only on purpose, never auto-started on the operator's machine
  645de42  vj: a deck plays and draws while it decodes, and the stems swap in sample-aligned — one streaming path for native and web
  c3d9314  vj: each deck's transport sits on two larger rows under its mixer — every button visible on both decks
  b0ec2a4  vj: the audio engine owns the mix state — the UI sends commands over a wait-free ring and reads snapshots, no lock on either thread
  acb8689  vj: the loop grid is built and refined on the web too — one pool + channel path for both targets
  707d702  vj: every worker thread comes from the platform spawner — the loop ½ button no longer panics the web app
  71c0e1e  vj: a paused deck is never moved by sync or the loop grid, a loop cell plays exactly its bars with the view held, and the stems banner clears
  35ac364  vj: the catalog runtime pump no longer logs every poll
  5a9fa62  vj: no timed std waits on wasm workers — blocking channels, one tagged deck+background inbox for the stems/lyrics workers, a lost-wakeup fix in the thumb queue, native-only gates on the video decode loops; the DJ web app stops losing a worker at start-up
  32bea89  vj: a playing slice keeps the big waveform of the whole sample — the active slot is an overlay on it (accent band, bracket edges, wrapped playhead, slot number) while the lane's viewport stays fixed; the grid cells are selectors only, their mini waveforms are gone
  dd51a02  vj: a go-to-start transport button per deck — seeks to 0:00 through the normal seek path, keeps the playing state, leaves the loop alone; skip_start.svg icon; decks test
  bf9418e  vj: ironfish / synth rack, program mix, and a clean release check
  28016ca  vj: keep generated clips at the head of the grid
  f45793a  vj: batch live video publishes into grid rows
  75b295c  vj: synchronize decks and queue remote stem processing
  ba662d2  Add responsive DJ and VJ modes with system and black-orange appearances
  a18b2ee  vj: route DREAM runs through the shared Flow engine

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 13:40:29 +02:00
Admin
3811545d48 apps: director, studio, scope, aichat
Squash of 8 work commits (Sep 2–10):
  7b9ed2c  aichat: the assistant as an app — the panel owns the engine, the bus client, settings with the local-only lock
  e0c6e74  aichat: the progress bar and system lines use the theme's highlight colour
  8b46ce0  aichat: the composer's hint is a dark grey Ask AI, not the typed colour
  b99a631  toml_parser, rust_tokenizer: rewrite both for the code analyser
  3bbcea2  aichat: add Studio evaluation-feedback widget
  b61845f  studio: Architecture view, the third workspace mode
  d7a76cf  studio: add bounded code context and source APIs
  524142a  Split Studio into makepad director (public) and makepad scope (private)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 13:40:29 +02:00
Admin
3b8cd8a69a apps: WM, wm-all, phone apps, files, route, sheets, photos, image-tiles; the mp prefix rename
Squash of 59 work commits (Sep 1–12):
  b0380ba  image-tiles: the picture-wall engine as a library — tape atlases, a baker CLI, and the TileGrid widget
  83d8eac  mpsheets: demo feature and the SheetDocs seam
  daff390  finance: demo feature — generated ledger, SQLite target-gated, import hidden
  13b4c48  mpfiles: demo build — procedural fake filesystem, still-image thumbnails, chat feature
  58e1f72  mpsheets: review fixes for the demo seam
  3203e32  finance: review fixes — id remapping on persist, structural determinism tests, one cfg seam
  0537687  mpfiles: distinct repo-owned pictures for the demo thumbnail pool
  fe920c8  files: review fixes — demo scan exclusions, Zipf sizes, trash root guarded, depth bound, tests
  4144dc4  files: the spy test filesystem implements the clock
  1940f3d  fonts: leave apps/wm untouched — its font-set declaration waits for the aichat lane to land
  b0e34c2  wm: the AI pane and its bus — the aichat child seated in the slot, F10, the os service with the typed open
  de937ac  route: the web build is a one-to-one recompile — native UI, service seams behind it
  2f5a984  files: the file browser on the AI bus — four read-only tools through a correlated, cancellable runner
  2b035ff  route: the navigation session keeps time on the platform clock — the std clock traps on wasm
  1b70899  files: the space view is where a tab starts
  412b3c9  files: the space view rescans through the virtual filesystem — the web has no other disk
  0105fa5  files: the space view opens in 2.5D
  5d007e0  files: an unset projection preference means 2.5D
  6857d86  files: the projected treemap clips boxes at the near plane and keeps the camera above them
  d3cd233  wm: one desk for every target — the host seam, the assistant seated in-process, the web profile, and the assistant up at boot
  6d47c3f  wm: the omarchy themes moved to omacom/omarchy — the importer follows
  82def6b  wm: every app under the desk — the chat keeps the keyboard through an automatic refocus, a pool that gives up, polite closes for browser, sheets and aichat
  6105b61  sheets + files: the assistant reads and writes cells, makes folders, renames and trashes
  82ac768  photos: the picture wall as an app — the SMBC archive on the tile engine, a module from its first line; fabric in the launcher
  190062e  route: the maps app exposes its tool table to the desktop assistant
  3768653  image_tiles: the wall is box-packed — justified rows keep every picture's own shape and fill the width
  3691875  wm: launcher icons — a photo for Photos, a shirt for Fabric, a globe for Route, a play badge for Video, a pulse for the task manager
  8625076  aichat + photos + image_tiles: make me a picture and it lands on the wall; the wall filters as you type, tiles flying to their places
  1dcca41  wm: a theme with no wallpapers fetches them on its own
  ff6cfec  wm: minimise, maximise and close in the bar's top right on Windows and Linux
  62f38a5  route: --hour=N pins the theme hour for harness grabs
  362ac59  wm: the checkout is found from the working directory too, and a sibling binary is an .exe on Windows
  0fdfba7  files: the web's makepad home is a fixed virtual path — std's temp_dir panics there
  daf88e1  route demo: the tiles come from the repacked world archive at its own never-cached path
  15c3b11  ai-services: what the pubsub review found — modules get a subscription seam and a publish sink through both WM paths, lease end flushes unsubscribes to hosted services, the subscription cap counts closing rows, an endpoint's queue is dropped after Unregister, the prompt drops a subscription on final
  d852699  route: no tile-source dropdown — makepad.nl is the tile source through the local range cache; a world.mkmap in the saved maps folder is the only override; the stale preference file is removed on start
  aa271c3  map + route + geodata: the Terrain layer through the archive plane — TerrainSource (hosted .mkmap elevation shards fetched by range through the shared reader, a local MBTiles only as a dev override), the hillshade rendered on the pool's heavy lane with reused TerrainScratch buffers (web capped at 2048×1536 at DPR 1, native DPR-aware), one request in flight; the demo checkbox now renders terrain like native
  dbc7838  route: the maps-folder field and its save button are gone from the settings panel; the maps root is automatic
  a53d9b6  route: no clock-driven night theme — the theme is the user's toggle, remembered in cx.storage, default day (the hour rule fired on the web for the first time once wasm had a clock and darkened the map after 19:00)
  ca92ff9  route: the open sea on the web — the ocean-low/ocean-high overlays are an always-on group in the shared hosted overlay table, read through the archive plane (makepad.nl, cached); native keeps the local ocean files only as a dev override; the native-only OCEAN_MBTILES path is gone
  4cd24c4  route demo: the first location fix flies the map to the user at zoom 14, as native does; Amsterdam stays the default until a fix arrives
  2e869f8  route: the ocean-high overlay comes from the re-sharded archive (58 shards of at most 16 MB, one small leaf directory each) instead of two shards whose directories decoded to 407 MiB
  8021bb0  Add Clock and Weather with shared mobile tile views and persistent alarms
  77b8309  Make application layouts and custom widgets follow desktop and mobile themes
  b357164  Add desktop and mobile compositor shells with OS switching and dock transitions
  11dec2e  Keep hosted app output readers off the shared task pool
  c4002c5  Round complete desktop surfaces and add Windows snap layouts
  a5f8dd8  Frame home photo tiles and clip the picture wall below app controls
  678721d  Give mobile app libraries real search focus and hosted keyboard input
  4794cdf  Keep window title fills opaque at the application surface join
  2cc584e  Match Windows 2000 and NeXTSTEP window chrome to original screenshots
  4a876a8  Give Files navigation and storage tools a clearer hierarchy
  7821c90  Keep prewarmed browsers in the active desktop appearance
  5bfe694  Compact the Files toolbar and repair navigation and selection actions
  44fef36  Preserve Photos subjects and zoom across host view and aspect changes
  ae20efc  Draw larger macOS traffic lights with centered hover symbols
  2bc4d89  wm: the WM is a library plus a desktop binary, Linux controls, hosted tick pacing
  d643eb4  apps: the in-process app wave — mail, notes, calendar, reminders, calculator; clock, weather, finance, photos and route as modules; civil time, read-only sqlite, Linux CEF
  56c1dee  wm: a timer beat missed while the child renders is serviced on its acknowledgement

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 13:40:28 +02:00
Admin
4945f1873b platform: rename the CPU simulated-GPU backend from headless to gpusim
Squash of 1 work commits (Sep 12–12):
  e74b919  platform: the CPU simulated-GPU backend is `gpusim` — the word "headless" now means only window-less

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 13:40:28 +02:00
Admin
b53ea0b30a platform: macOS display-link, App Nap, waker, IME-adjacent event loop
Squash of 2 work commits (Sep 9–12):
  8d851ef  platform: per-draw alpha blend, macOS waker, texture lifetime, file drop
  cc2fa8f  platform: the Metal display link is gone; the hang line prints its tick deficit; hidden instances refuse App Nap

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 13:40:24 +02:00
Admin
2e27ce2ff1 platform: retained draw lists, shared publications and GPU residency (DL-0..DL-5)
Squash of 27 work commits (Sep 3–12):
  aa907ca  platform: a frame trace on both desktop backends, and a window that cannot present stops ticking at 600 Hz
  e3abf4d  map: the hosted-tile cache keeps the same ~2x-visible margin as the local one — a budget pinned at the visible set evicted the trailing edge of every pan on the next frame and refetched it a moment later
  8d542fe  platform: a child pass orphaned by its attaching draw list is no longer painted — the window's gauss_scene pass stayed a live_with_parent child after the map went flat and re-encoded a frozen 400-item list every pan frame with stale geometry ids (new tile meshes × old instance counts, tens of millions of triangles into a texture nobody read); make_child_pass records the recording list + redraw id, stale passes are skipped, a cached View re-attaches on a cache hit; tests for the orphan gate and the pool generation contract
  bb49fe6  map + platform: retained per-tile draw lists — each resident tile owns one DrawList2d per carto pass (fill, casing, stroke, icon, icon-high, shadow) and the label glyph batches are retained the same way, recorded when the bake, LOD ring, fringe/icon gates, flat/tilted or clip change and re-attached otherwise; a pan/zoom/tilt frame pushes this frame's uniforms onto the retained calls (DrawVars::update_uniforms_on_area, resolved slot table) and uploads zero instance bytes; the tilted per-pass depth is a pass_depth uniform; a held list's zbias resolves at entry (zbias_hold in every backend); the shimmer heartbeat patches shiny_time in place without a redraw; a freed/reused sub-list id is skipped by every draw-tree walker and the mask list re-records empty on the flat transition (contract test). Web pan tail 1,098 → 20 MiB/s, flat pan 2.4 → 0.43 MiB/frame; Metal grabs within the run-to-run noise floor
  313265d  Studio code atlas: geometry code views, live filter, 3D size lens, lanes as terminals
  9c4e0cc  Studio code atlas: performance round — retained uploads, worker labels, exact search, no forks
  7235d7e  Studio code atlas: stall fix, GPU working set, lens hard switch, filter masks, parallel index
  56a6da5  platform: per-pass GPU counter timing on Metal; retained publications replace in place
  72e2443  platform: present-path trace (1 Hz cause histograms), bounded drawable wait and retirement on macOS
  1c5d583  platform: bounded retained maintenance on empty paint beats; republish actual backend debt
  edfed73  platform: retained residency high/low water and hysteresis; no distance eviction without pressure
  acab6a0  platform: critical upload class serves present-blocking items first; identical immediate re-records upload nothing
  906c774  platform: uniform_range on DrawVars and patch_retained_uniforms on retained draw lists
  c29031c  platform/draw/widgets: heap-keyed script resources and RecordingBuffer draw items — the files today's commits depend on
  cd0964f  platform headless: homogeneous near-plane clipping before the perspective divide
  61d424d  platform: release retained bindings of released textures so pool evictions complete
  a00287a  platform: texture-tile cache support — per-item instance ranges, painted pass receipts, display-dpi pass uniform, retained render targets, present gate on the drawable pool
  74b63be  platform: retained upload floor reverted, unconfirmed presents counted
  c03fcc5  platform: tile-cache round 3 support — O(1) demand on re-recorded lists, evictions counter, allocated_size, lost-button release, Vec2d::round
  e515d75  platform: shared instance publications — the DL-0/DL-1 contract, additive beside the retained path
  142a337  platform: shared instance publications — close the seven review items (DL-1b)
  6811a19  platform: Debug for SharedInstances, WeakSharedInstances and PublishReceipt
  c061e47  platform: Metal draws are resident by construction — the hole path and its gates are gone (DL-2)
  0bdbb29  platform: drop the unreachable InstancesNotResident present cause
  de3c868  platform: `drawlist` trace names the holder of a stale draw-list id; the per-frame upload line moves to `gpu.upload`
  721c3d8  platform: publication backends — Metal per-publication backings, lease-keyed uniform ring, receipts on every backend, Vulkan draws attached items (DL-3)
  393de54  platform: integrated deletion — the draw-list system is generic again (DL-5)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 13:40:23 +02:00
Admin
9580527fdd platform: Linux hosted GPU, Vulkan, OpenGL, Wayland/X11/direct
Squash of 7 work commits (Sep 2–12):
  95fd7d6  platform: a hosted window with a dpi override lays out in its own points and gets the host's pointer remapped
  c2c7f51  platform: a hosted window on Windows draws again — the depth buffer matches the shared target's allocation
  3b1a8c2  platform: an in-app drag works without an OS drag session (web, Linux) — effect tiles drop into the channels on the web platform: an in-app drag works without an OS drag session, so effect tiles drop into the channels on the web
  5ff7397  platform: typed geometry uploads on OpenGL; the typed gate names only the backend that still lacks it
  80bf3d3  platform: Linux hosted GPU transport and routing, hosted tick pacing, WGSL packed vertex members, Vulkan typed geometry
  e2d8a0a  platform: the Linux GL and gpusim cfgs build warning-free again
  c8c757a  vulkan: honor tile draw inputs and retire submitted frames correctly

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 13:39:56 +02:00
Admin
bb5de43918 script: Splash VM host contract and script_mod apply
Squash of 2 work commits (Sep 2–2):
  a6d0338  widgets: popup menu items run in the popup owner's script VM; app_main! releases its borrow after a trap
  232909d  script: the VM reaches std and its slot through one host — no aliased references

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 13:39:47 +02:00
Admin
61b0e5e791 platform: network crate, web-server HTTP, native body cap
Squash of 10 work commits (Sep 2–12):
  9633ded  web server: site static serving plus the first nav backends
  ba1010d  asset-client: review fixes — additive web feature, shared transport contract, guarded cache and base URL
  81ef71c  network: native backends honour the response body cap
  4d75e65  web server: hardening after security review — worker-only parsing, FD-relative static opens, strict framing, bounded work, panic recovery, Cloudflare-aware limits
  dfe2087  web-server: O(1) report admission, one connection deadline, shared route sampler, static fallbacks, cache policy
  9c9c72b  web-server: bodies land before workers, client keys normalized, verbs fail closed
  6c32c88  web-server: ETags from size and mtime, deadlines from size, Allow per resource
  7613f3e  web-server: one deadline per response from its size, 404 before 405 for unknown API paths, one Earth radius
  7deb052  web-server: ETags from size and mtime, never from content; upgrades only where a socket route exists
  f407342  libs, network: in-tree shims replace crates.io libc/log in the Wayland and zune crates; deterministic math; the websocket flushes control messages at once

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 13:39:44 +02:00
Admin
906f94a949 platform: thread pool, clocks, storage, trace, game input
Squash of 10 work commits (Sep 2–12):
  3389475  trace: one switch — MAKEPAD_TRACE=<topics>, trace!(topic, …), and /trace on the bridge
  c55a315  platform: monotonic clock beside the wall clock, trap-safe dispatch, studio worker only with a studio
  89fe453  platform: wheels and flight sticks are game inputs, with an output-report handle
  e5d37e0  platform: a web file picker and file drop that hand apps bytes
  19cf377  platform: the thread runtime contract — spawner, tasks, pools, scheduler, UI waker
  cf2b8ca  platform: no std::time on web — clippy guard and the platform clock everywhere a web build runs
  c527cd8  vj: the web thumbnail pipeline never blocks the main thread
  2a064d3  workspace: add loader, haptics, voice, and runtime fixes
  541c886  platform: name heavy pool jobs over 250 ms; headless Startup sent once per Cx
  d476e52  Fix Linux worker sizing and screen recording defaults

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 13:39:43 +02:00
Admin
0410ce4ef9 platform: cfg/warning/docs chores inside the render/UI core
Squash of 3 work commits (Sep 2–12):
  debd8c1  rename: the mp prefix goes — apps/wm, files, terminal, browser, task, sheets, image, video, pdf; libs/wm_api and wm_theme
  6dcca00  workspace: no timed std waits on web-reachable paths — the clippy gate covers every web demo's dependency set
  9d8e314  platform: drop two in-band coordination notes that were committed with the tree

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 13:39:38 +02:00
Admin
573164e40c widgets/map: retained map renderer, typed streams, gestures
Squash of 39 work commits (Sep 2–3):
  ad24534  map: persistent asynchronous tile archive over a completion-based byte source
  5d28ed5  map: tile archive review fixes — legacy MBTiles path restored, decode off the UI thread, retry, cancellation, blob dedup, validation
  a7eae82  map: the tile build keeps time with the platform clock
  d931f9d  map: tile archive third pass — per-pass pruning, validated watcher metadata, shared blob bytes, timer watchdog, atomic cancellation
  4b529a1  map: tile ranges fetch once, centre-out, and never time out while queued
  04a36de  map-build: the bake produces only what the renderer reads; a bad tile is skipped and logged
  383b83e  map: the Amsterdam bake report — the real archive through the real bake path, bytes per stream and milliseconds per tile
  2d4ff16  map: POI symbols and building walls are instances — one shared mesh per symbol slot, one record per footprint edge, extruded and placed in the vertex shader
  72b60b6  map: the memory diet folded in — CPU staging freed after upload, budgets from one platform number, bakes capped by bytes in flight, per-zoom bake profiles, a memory report; instance records count as retained CPU
  a6f495d  map: street trees are instances of one template per tile; the CPU staging of every uploaded stream is dropped; the memory diet keeps its platform half only
  9047600  map: the bake report can dump the raw and decoded tiles it measured
  57995b6  map: uploaded staging is freed on a pool worker — a large free on the UI thread of the threaded web build contends the allocator lock, and a contended lock there is an Atomics.wait the main thread may not make
  02b58fc  map: ground fills on a 20-byte vertex — position, colour, variant + AA coverage, depth ticks; DrawMapFill is the fill path of the map shader
  ef25495  map: building shadows derived at draw time in a screen-space mask pass
  9d664ca  map: roads on a 32-byte vertex
  5b49fee  platform, map: the UI thread never futex-waits on wasm
  99d2180  map: roofs on a 20-byte vertex, contact shadows as instances
  5a5eda2  map: road, fill and roof streams on the typed vertex formats, u16 indices
  f987651  map: the analytic road fringe is baked only when the view is flat
  7e66991  map: finished bakes reach the screen on the next frame
  d8539d8  map: the detail parse and merge stop allocating
  7a2c09a  map: road-union faces on a 16-byte vertex
  a2cbe38  map, platform: the tile budgets follow the platform's one memory number
  fef8058  map: marker stalks and stoplights are instances
  662d141  map: building wall instances on a 20-byte record
  d6d3241  map: round road caps are a fragment SDF, dead cap rows gone
  8ae7c3c  map: every typed stream index stays u16 — streams chunk under 65,536 vertices
  4e61e44  route + map: the desktop app streams the makepad.nl archive through a persistent on-disk range cache, chosen in settings
  a8b0382  map: the water/foliage shimmer read draw_pass.time, which flags every DrawMap* shader as animated and repaints the whole map at display rate forever — a Rust-stamped shiny_time uniform instead; the 20 Hz heartbeat drives it
  0d3a9b8  map: nothing animates at rest — the water/foliage shimmer heartbeat runs only during interaction or a camera animation, plus a 1 s settle tail, then freezes at its last phase; flat and tilted views alike
  1f24428  map bake: a face-band triangle is a face only when all three records pack (a mixed triangle stays on the road path); a panicking pool job no longer takes the worker down — caught, reported once, the tile fails; the bake panic that blacked the native map and killed the web workers
  199be9e  map + route: the six overlays (EV chargers, transit, nature, districts, building age, population) read through the same archive plane as the base tiles — OverlaySource {name, TileSourceConfig}, hosted .mkmap archives on makepad.nl fetched by range through the shared archive reader and disk cache, a local .mbtiles only as a dev override; one layer table (apps/route/src/overlays.rs) for the native and demo builds, the demo checkboxes now set overlays like native
  01e77d9  map: the shimmer clock is one pass-level uniform (draw_pass.shiny_time, a map-owned slot, never draw_pass.time) written once per heartbeat tick instead of patched into every retained draw call's block — the settle tail stops re-uploading 600 uniform blocks per tick
  d641cbf  map: labels no longer vanish — the gesture label budget is charged from the placement loop, not from candidate collection (4-7 ms on the web at z15-16, up to 6000 candidates in space warp), so a place can no longer commit an empty cache; a truncated pass is never a strict cache hit, arms its own settle wake, and the at-rest follow-up chain is capped at 4
  635c3a3  map: ready tiles are inserted at most two per frame (byte budget kept), queued visible ring first then margin ring, the stale tile drawn until its replacement lands — a restyle burst of 4-9 refined tiles no longer stalls a frame for 70-120 ms
  ddc4709  map: touch gestures — one-finger pan and double-tap zoom, two-finger pinch zoom around the midpoint, rotate with a 5° dead zone, and a parallel vertical slide for tilt, one state machine native and web; the web page keeps browser pinch-zoom out (non-passive touch listeners, touch-action none, maximum-scale 1)
  8eaa6ec  map: two-finger tilt follows the phone convention — fingers up tilt into 3D, down flattens
  018ce73  map: labels hold still through a gesture and never pop — the settled placement rides the camera delta while anything moves (pan now part of the motion signature; re-place only beyond the pan/zoom law), every draw uses the rect-centre fold pivot so CPU placement and the GPU warp agree (a fresh place drew about the screen corner: the giant space-warp labels), and a re-place cross-fades: survivors keep their birth, newcomers fade in, dropped labels retire from their own camera over 250 ms
  c79e84e  map: a tilt is two fingers moving together up or down — same vertical direction for both, the pair's stroke within a fifth of vertical; spread and angle no longer matter, so real fingers trigger it

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 13:39:37 +02:00
Admin
e32b74b988 platform: Web/wasm runtime, WebGL, web audio, browser storage
Squash of 38 work commits (Sep 2–6):
  ed5b2fa  web: wasm32 portability in libs and web startup geometry deferral
  0ccd384  platform web: focus dispatch, window-zero geometry and generation-correct ids after the startup deferral
  a7af3ea  platform + mpfiles: platform clock instead of std::time on the web, unwind-safe event dispatch
  52251b7  platform: a namespaced async key/value storage API on Cx — files natively, IndexedDB on the web
  84b46c1  mbtile reader: file-backed readers are native-only so the map stack builds for wasm
  6b661bf  web: crashes report themselves — panic text, breadcrumbs, memory, workers; a dead instance stops pumping
  372bfd7  asset-store: browser durability — generation extents over cx.storage, chunked CAS, quota and GC
  0f967ce  web path: the stream trace and the sqlite pager never wait on a clock the browser does not have
  4e58ab9  vj: the output window exists only once opened — never on the web
  93232a2  platform: two pool workers on wasm until the allocator is per-thread
  c7fe851  vj: effect thumbnails render, encode and persist in browser storage on the web
  5b33781  platform: thread-caching allocator for the threaded wasm build
  463de64  web: shaders compile per draw list, link in parallel
  e7be0e3  vj + platform: the browser UI thread never waits on a lock, and the web hot paths log only errors and summaries
  7b33f7b  vj + platform: a loaded deck actually plays on the web vj + platform: a loaded deck actually plays on the web
  dc85eb0  web + vj: render-to-texture passes keep their 3D camera on WebGL, thumbnails wait for shader compile, bundled tiles re-ask — effect thumbnails match native
  b39d301  web audio: the worklet links the whole module — every platform import the audio thread does not serve is stubbed, clocks and the UI wake are real
  1e2dcd0  web: a pass without a draw list is skipped instead of taking the app down (F12 layers overlay)
  0ef0311  web audio: a throw inside the worklet's process() is reported with its real text instead of a bare ErrorEvent
  97e0701  vj + platform: the web audio thread never waits on a lock — a loaded deck plays
  58d3fd5  web audio: the output is created inside the first gesture, and a stalled worklet module load is retried on a fresh context
  410acd0  web + vj + route: the console carries failures and one-line summaries, nothing per request, per tile or per hiccup
  a3248d1  web audio: the worklet gets the audio access pointer as its context — the thread-stack call gained a request id and the audio start was still passing the pointer in its place
  9e2d65b  pdf + photos + task + wm + video + automate + widgets: no per-job threads — pool jobs and start-up workers
  639887d  webgl: texture passes get their depth target — the tilted map (and every 3D scene drawn into a texture) was draw-order only: hollow buildings, no roofs, landmarks buried
  2aa0780  webgl: BGRA uploads become RGBA at upload and sample_as_bgra is a plain sample on the web, as on every native backend — the tilt-shift's sharp band showed red/blue-swapped water
  de9aba3  vj + web: the Layer button works on the web — a second Window is a queried capability (OsType::is_single_window; the web creates none and reports it once), so the output becomes an in-page full-canvas layer with browser fullscreen (Esc, the browser's own fullscreen exit, or a double-click leave it); a pass without a draw list settles its dirty flag instead of erroring every frame; exitFullscreen fixed; fullscreenchange feeds the window geometry
  f615054  tweaker + webgl: click-to-climb continues only from the widget the climb started on, so a press on a sibling picks that sibling (the empty draw_bg was a bare View being pinned); the Shader tab says when a layer has no live draw call; the WebGL paint walk no longer resets every draw list's view_transform — the magnified material well drew at the window origin on the web
  0d7ddee  webgl: a uniform block is uploaded only when its generation moved — per draw call (uniforms_gen), per draw list (uniforms_gen, recording_gen), per pass (pass_uniforms_gen) and per shader scope block; the JS side caches (ptr, len, gen) per uniform buffer and re-records/recompiles reset it; direct camera writers (vj effects, render scene, the web flipped copy) bump the pass generation
  e48b056  Revert "webgl: a uniform block is uploaded only when its generation moved — per draw call (uniforms_gen), per draw list (uniforms_gen, recording_gen), per pass (pass_uniforms_gen) and per shader scope block; the JS side caches (ptr, len, gen) per uniform buffer and re-records/recompiles reset it; direct camera writers (vj effects, render scene, the web flipped copy) bump the pass generation"
  b043332  webgl: uniform blocks upload only when their generation moved — one global monotonic counter on Cx hands out every generation (draw call create/dirty/zbias, draw list allocate/transform/re-record, pass allocate/time/dpi/ortho/camera and the web flipped copy, shader scope writes), so a reused pool slot can never match a cached generation; clear_draw_items, pool reuse and VAO recreation reset the caches; the JS caches key on the generation alone. Proven on a local build: the pan screenshot keeps every tile, the settle tail drops 20.5 → 12.8 MiB/s
  3c2530d  web memory diet: the 805 MiB at load was the ocean-high archive's 13 M-entry leaf directory decoded to 407 MiB per lookup round and evicted at once — leaves now parse streaming into a window around the waiting tile ids (LeafParseLimits); a phone policy on the web (deviceMemory, UA, touch + short side) caps wasm at 512 MiB with a 320 MiB budget; archive leaf/range caches, reads and bakes in flight follow the budget; packed tile bytes stay packed until the bake decodes them; terrain scratch sized to the viewport and dropped with the layer; Cx::memory_report by owner. Phone viewport 1334 → 308 MiB after a minute of pans, desktop 1208 → 588
  303458d  webgl: a 2D texture pass builds its camera through set_ortho_matrix like every other backend, so the exploded z-layer view's camera reaches the GPU — the hand-built ortho branch uploaded an identity view and clipped every exploded draw, leaving the tweaker's layers view a bare window on the web; the Y flip for render-to-texture is one helper shared with the keep-camera branch
  1801e38  Harden web renderer and make Route location opt-in
  09fa1f0  Restore WebGL text with complete fallback samplers
  9e61553  Keep Route 3D buildings under bounded web memory pressure
  b005c6e  Preserve complete Route geometry within measured web memory budgets
  0832b35  platform: support float GI targets and retained mesh snapshots

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 13:39:18 +02:00
Admin
48e7a01ec9 draw: geometry, shaders, WGSL, fonts, turtle/layout
Squash of 10 work commits (Sep 2–11):
  120b7e2  score view: the engraver as a shared library, with drum and pitched score builders
  09b41a8  fonts: FontSet and FontPolicy — one application choice, selected-only loading, a font-assets manifest
  6981e90  fonts: manifest generated from the chains, app-level font assets, a symbol fallback, deprecated i18n aliases
  77d9138  wm: the module contract and the first in-process app — sheets in a tile, in an isolate of its own
  55a3810  platform: typed compact vertex formats and u16 indices
  83a8d4f  fonts: the web demos start with the Latin set — CJK and emoji faces load on the first glyph that needs them
  1980c24  platform: a draw call whose geometry id went stale is skipped, not drawn with whatever mesh now sits in the reused slot — the runaway triangle count that took a web map pan to 1 fps; reported with a power-of-ten backoff, never per frame
  a75fe91  layout: extend turtle sizing and add Grid
  4429551  draw, widgets: text clips by the list clip; GaussChain; map colour roles
  6e02468  draw: restore Cx2d::set_current_pass_dpi_factor (raster density) beside the display-dpi setter

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 13:39:02 +02:00
Admin
0f410eeb6a platform: video encode/decode, H.264, camera, Apple/Windows media
Squash of 9 work commits (Sep 1–5):
  929f822  video: the single-frame mp4 encode exists on every platform
  294cb9e  video: the single-frame mp4 is written on every platform, not stubbed
  730b5b8  video: the Windows H.264 stream decoder pulls output before it knows the format
  c1febc7  windows h264 stream decoder: low-latency mode, output type before first ProcessOutput, real MF_E codes, trace file
  d6cc49a  windows mft: PROVIDES_SAMPLES is bit 0x100, not bit 0
  dcf7d21  windows h264 decoder: ICodecAPI low-latency, per-pump trace, access-unit dumps, and the stream tests run on Windows
  d1e7a6f  windows h264 decoder: AVLowLatencyMode is a VT_UI4; the round-trip test tolerates the MF encoder's access unit delimiters
  aa816ed  windows h264 decoder: pictures come out one access unit later — rewrite the SPS level so the DPB is one picture deep
  2f44d20  apple: avoid blocking video clocks and release native players once

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 13:38:49 +02:00
Admin
874374635f widgets: glass, dock, tweaker, themes, code_editor, phone shell
Squash of 36 work commits (Sep 1–12):
  176433a  tweaker: click-again climbs the pick — the container under the children is reachable
  97e9572  speech: one STT/TTS API on every platform, through the ai-hub
  8ea3684  widgets: EventOrder reachable from the DSL; DataGrid hit-tests where it was drawn
  0fd1ea2  route: demo profile — native/demo features, side-panel and provisioning seams, hosted tiles, HTTP nav client
  cf4c84c  keys: F10 is the assistant — the exploded-view debugger moves to Shift+F10, the screen recorder to Ctrl+F10
  126d8c9  route: the demo profile runs in the browser — merged panel draw, platform clock, unavailable backends tolerated
  e21db96  route: demo review fixes — route origin, rain lifecycle, hosted route validation, request context, provisioning
  c24c206  aichat: the Window overlay — F10 in every standalone app, the in-process port, the /ai bridge routes, sheets as the pilot
  dbe43bd  wm + widgets: the AI panel on the left, pushing the body in
  5184340  platform + vj + map + files + widgets + image_tiles: the runtime owns one warm two-lane task pool — jobs never spawn threads
  697215e  route + converse + example-map: every worker comes from the runtime pool or a start-up worker
  862f3bd  asset widgets + chat ui + render: fan-outs and jobs on the runtime pool, the transcript read without a lock on draw
  976ea0e  tweaker: Shift+F10 toggles it on every platform (KeyEvent::is_tweaker_toggle, one call site; the web page swallows exactly Shift+F10 so the browser never sees it); the exploded z-layer view has no keyboard shortcut any more — it is a button in the tweaker
  4e8e4cd  flow-ui: the design pass — menu bar and toolbar with the total run bar, continuous zoom through the draw-list view transform with pointer remapping, dark checker canvas with grid steps, shadowed cards with icon labels and port icons, glowing wires, per-node progress bars, full-bleed image cards, palette cards you drag out, the fab edit controls in the inspector, a template picker behind New, model pickers from the hub; MenuBar widget in the shared crate
  075e1a7  flow-ui: pickers filled from the hub for image and text nodes, popups anchored through the canvas transform, labelled face controls, add_style explains itself, cards resize from a grip with size: vec2 kept in the file, full-bleed pictures, a click anywhere on a card selects it and still reaches the face, no remount on layout-only edits, panels float over the canvas
  a50750f  flow-ui: Flows, Running and Palette as their own rounded panels with splitters, the inspector and source pane likewise, columns resizable; gaussian frame shadows; keys and IME reach the focused face field through the canvas transform
  43302a6  flow-ui: every card owns a draw list and draws in z order, selection brings it to the front; and the design review's findings — every event kind remapped through the camera, run events keyed by run id, no remount mid-run, an input journal that survives a failed PUT, terminal states reconciled, the total bar from the planned node set, isolate ownership on instance change, popups retired before an isolate is freed, the Ask face answers on a button, the menu bar navigates by keyboard, no per-frame allocation in the canvas draw
  4ee4f4c  flow-ui: the resize path sets walks and fits through typed setters, never a script apply from the main VM on an isolate's widget — a failed apply had left a freed script object behind and wedged every frame; a resized card fills its picture box, clips its face and lets the last flexible element take the height
  cff15ac  widgets: a fab number field drops a label that cannot fit instead of crushing it to a dot
  24c927a  flow-ui + widgets: every dropdown is the searchable ComboBox
  a6c5f15  widgets: FabValueInput honours visible, so the seed picker's random mode hides the number field
  1181a3b  flow + flow-ui + widgets: every creator pipeline is a template — 55 templates in six groups (Image, Video, Audio, 3D, Vision & text, Utilities), all evaluated and engine-exercised in tests; the New picker, the flows.templates tool and the palette group the same way; the Templates menu shows them under group headings, and a menu taller than the window scrolls
  f8b9a67  widgets: preserve numeric edit completion and menu focus
  ed5f2e2  Add hotloadable OS themes and preserve widget state across style changes
  f1d3b39  Center resized app recordings on a fixed black canvas
  915fcae  Remove icon rim highlights and align compact home tile contents
  bfa7805  Keep terminal palettes theme-aware and resize above mobile keyboards
  7535ce8  Fix workspace build regressions (#1220)
  2233cbc  Replace legacy Studio with docked and canvas agent workspace
  708aa9f  code_editor: range views, anchors, prepared documents, read-only, tab stops
  0eae276  widgets: capture Studio evaluation feedback and recordings
  487c602  widgets: let Studio pump dock bodies across presentations
  c5adb93  Studio code atlas: settle-line diagnostics, index progress, chrome fades, exact search budget
  ee9ab46  widgets: every screen capture lands in the repo's local/screencap, named by app
  dcc9673  draw, widgets: the phone shell's glass, hosted-view and overlay support, app icons for the new apps
  2fbc679  wm: preserve app caption controls and add Scope to the launcher

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 13:38:48 +02:00
Kevin Boos
2be77caa39
Fix Escape and Back ownership across widget lifecycles (#1236)
* Event: trace cancel scopes, and gate StackNavigationView's Back on ownership

A scope held by a widget that has stopped being the active thing wedges
Escape and the back gesture for everything behind it, and the only symptom
is that the gesture silently stops working -- which is indistinguishable
from there being nothing to cancel. MAKEPAD_CANCEL_TRACE=1 now logs every
scope begun and ended, and which one each press was stamped to, each named
by the call site that began it.

That location comes from #[track_caller] on both Cx::begin_cancel_scope and
CxCancelScopes::begin: the attribute propagates through the chain, so
Location::caller() names the widget rather than either of makepad's own
frames. No signature changes, no caller passes anything new, and the
existing tests needed no edits. Releases are logged from Drop rather than
end(), so giving a scope up by dropping it -- including a widget being torn
down, the case most likely to leak -- is reported exactly once on either
route.

StackNavigationView called the consuming back_pressed() whenever it was
Active, with no ownership check. A modal or pane opened over a pushed stack
view owns that press, but the view could consume it first and pop: the
wrong thing acts and the owner is starved, on one gesture. It worked only
because children are dispatched before the closure request, which is
precedence by traversal order -- the thing cancel scopes exist to replace.

A pushed view genuinely is what Back should pop when nothing is in front,
so it now holds a scope while Active and acts only when it owns the press.
Its five state writes route through a single set_nav_state that moves the
state and the scope together, acquired at the transition because ownership
is stamped before dispatch. The left_button and mouse-back-button paths
stay ungated: those are unambiguous clicks on this view, matching Modal,
which gates only back_pressed().

* Fix Escape and Back ownership across widget lifecycles

Allow gesture-specific scopes, preserve held Escape ownership across Back and focus changes, and suppress repeated Android Back dispatch without invoking Activity fallback first.

Release popup, modal, drag, and navigation scopes on every exit; support suspended navigation, isolate Pop actions, and finalize wide-window hide animations. Add focused ownership and lifecycle regressions.

Validated with 14 platform cancellation tests, 14 widget cancellation tests, Android Rust and Java checks, and a release modal Escape smoke test.

* Resolve cancel ownership from the active widget hierarchy

Bind widget scopes to their owners and resolve visibility and descendant priority only when Escape or Back begins. Retained inactive pages, collapsed controls, and unfocused windows no longer require application activation callbacks.

Preserve press ownership through repeats and release, suppress scoped or repeated TextInput Escape actions, and remove the StackNavigation cancellation activation API. Cover hierarchy, container, wrapper, focus, and gesture ownership regressions.

* Simplify cancel traversal and remove unsafe root lookup

* Reuse validated widget paths for repeated activity queries

* Remove PR-added cancellation tests and tracing

* Arbitrate the mouse back button with cancel scopes

The mouse's back button is the same navigation gesture as Android Back, but it
never received a cancel owner: handle_event clears press_owner for every event
and only restores it for Escape and BackPressed. owns_cancel was therefore false
for every scope while a MouseUp was delivered, so a widget could not gate that
button on ownership at all. The ones that tried had to fall back on ad-hoc
conditions -- "is my tab the visible one" -- which cannot express the thing that
actually decides it, namely that something else is in front.

Stamp a Back press for Event::MouseUp with the back button: in
resolve_widget_owner so widget-bound scopes are resolved against the hierarchy,
and in handle_event so ownership is settled before dispatch, exactly as for the
gesture itself.

StackNavigationView's mouse-back path is gated on that ownership to match its
back_pressed(). A pane or modal opened over a pushed view now takes the first
click and the view stays put; the second pops it. The left_button path stays
ungated, being an explicit click on the view's own header rather than a gesture
something in front of it could have a better claim to.

* Close a Modal on the mouse's back button

The back button is the desktop equivalent of the back gesture, and is arbitrated
by the same cancel scope, but Modal acted only on Escape, BackPressed, and a
click on its backdrop. A back-click inside the content did nothing at all, and
one outside it closed the modal only incidentally, as a background click.

Gated on ownership like the other two, so a modal opened over another one keeps
its place, and left inside can_dismiss so a non-dismissible modal still ignores
it. This is what lets a full-screen modal's content -- an image viewer, say --
respond to the back button without handling the gesture itself.

* Fold Modal's Escape and mouse-back checks under one ownership test

Same behaviour with one ownership test instead of two, matching how the other
cancel-gesture handlers read. Back consumption stays outside can_dismiss, so a
non-dismissible modal still blocks back-navigation for the widgets behind it.
2026-09-15 06:55:18 +02:00
Kevin Boos
d3dde28f66
Event: let the foreground widget own a cancel gesture (#1232)
* Event: let the foreground widget own a cancel gesture

Several widgets act on Escape, and today more than one can act on a single
press: a modal closes and background dictation stops; a popup closes and the
microphone keeps recording. Dispatch order cannot arbitrate this. Siblings are
handled in reverse declaration order, a parent runs before its children, and
declaration order doubles as the z-order knob, so dispatch order is not
foreground order and cannot be made into it.

Add a stack of cancel scopes on Cx. A widget begins a scope when it becomes the
active thing -- a modal opens, a drag starts, a dictation session begins -- and
ends it when it stops being; the most recently begun live scope is in front. On
a fresh Escape key-down or a back gesture, call_event_handler records which
scope is in front, and that scope owns the whole press, its repeats and its
release included. A widget asks owns_cancel() and acts only if the press is its
own, so exclusivity needs no consumption primitive: only one scope is in front.

A scope that ends part-way through a press does not hand the rest of it to
whatever was behind, so an Escape that stops dictation cannot also close the
modal it was running in front of. Dropping a scope gives it up, so a widget torn
down without a tidy close cannot wedge the key for everything behind it.

Nothing changes for a widget that never begins a scope, so adoption is
incremental.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Widgets: adopt cancel scopes for Escape and the back gesture

Every widget that treats Escape or the back gesture as "cancel" now holds
a CancelScope while it is active, and acts on a press only when it owns
it. Foreground order decides who cancels, not dispatch order: a modal
opened in front of another modal takes the press, and nothing behind it
acts on the same one.

Widgets whose active state can end by several routes reconcile their
scope from that state on each event rather than trusting a single close
path. That also fixes the tweaker holding its drag state open after the
panel is toggled off with F12.

* Fix cancel-scope timing and Back gesture ownership

Acquire drag and color-popup scopes at activation and release them on each exit path, before the next cancel event chooses its owner. Gate Back consumption on scope ownership across modals and their popup/drag controls; non-dismissible foreground modals consume Back without closing.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Admin <info@makepad.nl>
2026-09-12 20:54:19 +02:00
Kevin Boos
a2fdeb325d
Turtle: compute max_height from the height axis, not the width (#1231)
max_height() forwards its walk to next_walk_width(), so a height is
resolved against the horizontal axis: for Size::Fill it takes the space
the width flow rules pick, then subtracts the walk's left and right
margins instead of its top and bottom.

Only Size::Fill diverges, which is why this has gone unseen. Both
routines return v.max(0.0) for Size::Fixed, and Size::Fit never reaches
the call thanks to the early return. DrawText::draw_walk_laidout is
where it would surface: it builds its box from max_width() and
max_height() and passes both to walk_turtle() as Size::Fixed, so text
drawn with a Fill height would take its height from the available width.
2026-09-12 08:38:55 +02:00
Kevin Boos
fc7fa10b31
TextInput: run the scroll bar down the input's edge, not over its text (#1230)
draw_scroll_bar passes the inner rect to ScrollBar, which places the bar
at `view_rect.size.x - bar_size` relative to the turtle's outer origin.
The bar therefore lands a right padding short of the edge, covering the
last few pixels of every wrapped line, and moves further into the text
the wider that padding is.

Draw it along the input's right edge instead, inside the padding. A text
input's visible area is not the area its bar runs along, so ScrollBar
gains draw_scroll_bar_along, which takes the track separately from the
visible size; draw_scroll_bar now delegates to it and every existing
caller keeps its geometry. TextInput also gains scroll_bar_inset, so an
app can keep the bar clear of anything it overlays on the input.

Sync the view total into the bar before the scroll position too:
clamping the position against the previous content height left the
handle a frame behind whenever the text grew and scrolled.
2026-09-12 08:38:43 +02:00
Kevin Boos
2235534781
apple: quieten the log lines a normal run prints (#1228)
Makepad's log! is its own printer rather than `tracing`, so an app cannot
filter it: anything logged this way is printed unconditionally, and an app
developer reading their own output has to scroll past it.

Six calls on always-taken success paths are commented out rather than
deleted, so they are one uncomment away for anyone debugging that area:

  macos_window.rs  titlebar container swapped, once per window
  macos_app.rs     display link pinned, paint pacing, once per window
  macos_app.rs     PIN stats, on every pointer-lock release
  audio_unit.rs    voice input native format and ducking level, on every
                   microphone open, which a dictating app does repeatedly

None of them report a problem or anything the app developer can act on;
they describe internal decisions in makepad's own vocabulary. Error and
warning paths beside them are untouched, as are logs already gated behind
an env var, a feature, or a once-per-process flag.
2026-09-12 08:37:22 +02:00
Kevin Boos
4fc39c49c3
macOS: preserve IME commands and consume composition key releases (#1227)
* macOS: don't deliver IME-consumed keys as KeyDown

`process_ns_event` hands each NSEvent to AppKit via `sendEvent:` before
emitting Makepad's own KeyDown. When an IME has marked (composition)
text, that dispatch lets the IME consume the key: Return/Space commit
the candidate, digits pick one, arrows navigate, Escape discards,
Backspace edits the preedit. A committing key clears the marked text
during dispatch, so the old post-dispatch `hasMarkedText` check (which
only covered Backspace) could not see it, and the Return that merely
committed a pinyin candidate was also delivered as KeyDown(ReturnKey).
TextInput then treated it as a submit.

Snapshot `hasMarkedText` before `sendEvent:` and skip the KeyDown
callback when the IME was composing. This subsumes the Backspace check
and also fixes the case where Backspace deleted the last preedit
character and then fell through to delete committed text.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017HLgZDz8vuuqqMya1nCWKf

* macOS: preserve IME commands and pair consumed key releases

---------

Co-authored-by: ymote <151983+ymote@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 08:37:11 +02:00
Kevin Boos
a843d772b5
Html: fix collapsed details with void tags corrupting table layout (#1222)
* Html: keep collapsed details with void tags from swallowing table closures

* Html: harden the parser, the walker and the widget against malformed input

Follows the void-tag fix in the `<details>` skip loop by auditing the rest of
the HTML code for the same class of defect. Robrix renders `formatted_body`
straight from Matrix events, so every parser crash here is reachable from a
message any stranger can send.

Crashes, all reachable from a chat message:

- Numeric character references were parsed as `i64` and cast to `u32`, then
  handed to `char::from_u32(..).unwrap()`. `&#xD800;`, `&#x110000;`, `&#-1;`
  and `&#99999999999999;` aborted the process. They are validated now, and a
  reference that names no scalar value stays literal text.
- An unterminated `&` stayed pending across a tag boundary or a closing
  attribute quote, so a later `;` could fire `decoded.truncate()` and
  retroactively invalidate byte ranges of nodes already emitted —
  `<p>&am<b>p;</b></p>` produced out-of-bounds and mid-character ranges.
  The pending entity is dropped at each of those boundaries.
- An unquoted attribute value beginning with a multi-byte character recorded
  `decoded.len() - 1` as its start, splitting the character.
- `</summary>` with no `<summary>` popped an empty tracker stack, and stray
  `</td>`, `</tr>`, `</li>` and friends reached `cx.end_turtle()` with nothing
  to end. The widget now tracks what it opened and ignores unmatched closes.
- `('A' as u8 + count as u8 - 1)` overflowed on an attacker-controlled `start`
  or `value`; alphabetic list markers now number a..z, aa, ab, ...

Content silently lost or mangled:

- `jump_to_close` counted every open tag toward depth, but a void element
  written without a slash emits no close tag, so it overshot and swallowed the
  rest of the document. `<a href=u>x<br>y</a>` hid everything after the link.
  Only tags with the same id affect depth now, and an element with no close tag
  leaves the walker where it is. `mod_html::find_close_tag` had it too.
- A `<` that cannot start a tag is literal text, the way a browser reads it.
  `5<10 and 6<12` used to parse `<10` as an element and drop the rest.
- `?` mid-tag-name and `<!-->` / `<!--->` ran to end of input.
- `/` in an unquoted value ended it, truncating `href=http://host/path` at the
  first slash; only a slash immediately before `>` closes the tag now.
- `<a href=>text</a>` took `>` as the value's first character, so the tag never
  closed and its content leaked out as text.
- Unquoted values never decoded entities at all, unlike quoted ones.
- `<pre>`/`<code>` whitespace preservation was a single flag that any nested
  tag cancelled, so a syntax-highlighted code block lost its indentation. It is
  a depth counter now.
- HTML's whitespace set is five ASCII characters, not Unicode's;
  `char::is_whitespace` collapsed `&nbsp;` runs and ate the full-width spaces
  in CJK text.
- `find_text` returned the zero-length node the parser emits before every tag,
  so `<a href=x><b>label</b></a>` rendered an empty link. `find_tag_text`
  matched the case-sensitive id and missed any tag carrying an attribute.
- Duplicate `id` attributes bound two elements to one cached sub-widget, so a
  second link could render its own text over the first link's href.
- `<li>a<li>b` and `<td>a<td>b` now implicitly close the previous item, and
  anything a document leaves open is unwound before `TextFlow::end`.

Entity table, which had been generated by folding names case-insensitively:

- 146 names took their case-twin's code point. `&eacute;` rendered `É`,
  `&alpha;` rendered `Α`, `&rarr;` rendered `⇒`, `&copf;` rendered `ℂ`.
- `Igrave`/`Icirc`/`Iuml` had been transcribed as `Lgrave`/`Lcirc`/`Luml`, and
  `Iacute` was missing outright; the invented l-spellings are removed.
- `permil` mapped to the Windows-1252 byte 0x89 rather than U+2030, and an
  empty-string key sat where it belonged, so `&;` decoded to `‰`.
- `tilde`, `lang` and `rang` were wrong.
The ALL-CAPS aliases the table also carries are left as they were.

Also: dropped the `unwrap` in `ElementSelfClose`, memoised table column counts
(quadratic in the number of `<table>` tags), and replaced the backward node
scan on every tag close with the depth counter.

Adds 18 tests covering each of the above. Verified by exhaustive enumeration of
all 12.2M inputs up to length 6 over a markup-heavy alphabet, and 6M randomized
structured cases, both checking that no input panics and that every node's byte
range is ordered, in bounds, on a character boundary, non-overlapping, and
agrees with its `all_ws` flag.

* Html: recover from malformed tags without leaking them into the text

A second pass over the same code, after the first round of fixes changed what
the edge cases look like.

- `</` followed by something that cannot name an element is literal text, the
  rule `<` already follows. `i </3 u` used to emit a close tag named `3` and
  drop the rest of the line.
- Junk inside a tag is discarded up to its `>` rather than resuming text in the
  middle of it, which leaked the tag's own `>` into the output: `a</p x>b` and
  `a<br/x>b` rendered `>b`.
- A custom widget with no close tag of its own is void, so it has no text.
  Reading ahead picked up the *following* sibling's text, and now that
  `jump_to_close` correctly stays put, the main loop drew that text a second
  time: `<img src=x>caption` showed `caption` twice.
- `table_columns_cache` is keyed by node index, so it has to be cleared per
  draw or a recycled widget lays a table out with a previous document's column
  count.
- `<ol start="2147483647">` overflowed the item counter.

* Html: bound jump_to_close's scan and cut the measured hot spots

Benchmarked against the branch point (best-of-7, black_box'd, release).

- `jump_to_close` stops at the first close tag belonging to an enclosing
  element instead of reading to the end of the node vector. It tracks the
  elements opened inside this one so a descendant's close tag is still
  matched correctly, and allocates nothing for the common case of an element
  whose content is plain text.
- Numeric character references were compared against all ~1500 named-entity
  arms before reaching the catch-all. Dispatching on the leading `#` first
  makes them 2.9x faster (991us -> 342us for 3000 references).
- `process_entity` is `#[inline]`; it is called once per character.
- `decoded` is reserved up front, worth ~4% on text-heavy input. `nodes`
  deliberately is not: its length tracks tag count rather than byte count, and
  sizing it from `body.len()` cost a tag-sparse document a large pointless
  allocation — that made the numeric-entity case 3x *slower* before it was
  measured and removed.
- The widget rejects an unmatched close tag from a tally instead of scanning
  the whole open-element stack, which was quadratic on a message combining
  deep nesting with stray close tags.
- `align_keyword_to_x` compares in place rather than lowercasing into a fresh
  String for every aligned cell on every draw.

Tag-heavy parsing is ~2-3% slower than the branch point, which is the standing
cost of the `<pre>` depth tracking, the literal-`<` guard and the entity state
carried across characters. Plain text is ~4% faster.

* Html: follow the tokenizer's recovery rules and resolve element ends at parse time

The parser's states now mirror the WHATWG tokenizer's, so malformed input
produces the tokens a browser would build from it rather than a guess:

- `</` followed by anything but a letter opens a bogus comment that runs to
  the next `>`, `</>` is dropped, and `<?...>` is a bogus comment too. `<`
  or `</` at the very end of input is text.
- `<a/b>` reads as `<a b>`: the slash was not a self-closing marker, so no
  close tag is synthesized. `<x/>` still emits one — the SVG parser is built
  on this walker and XML needs it — which is the one deliberate departure.
- In an unquoted attribute value a `/` is just another character, so
  `href=http://host/path` keeps its path and `<img src=x/>` is `src="x/"`.
- `<!--x--!>` closes a comment, `<!-x>` is a bogus comment, and a tag cut
  off by the end of input is dropped whole.
- Numeric character references follow the tokenizer's end state: zero, a
  surrogate, or anything past U+10FFFF becomes U+FFFD, and the C1 range is
  read as Windows-1252, so `&#151;` is an em dash as legacy content intends.
  Digits are accumulated with saturation so a forty-digit reference lands on
  U+FFFD rather than an error. A decoded space collapses like a literal one.
- `<pre>`/`<code>` are tracked as a stack: a stray `</code>` cannot cancel an
  enclosing `<pre>`, and `</pre>` closes a `<code>` left open inside it.

Every element's end is now resolved once at parse time (`HtmlDoc::closes`),
with the recovery a browser applies: a close tag ends the innermost open
element of its name and everything still open inside it, and a close tag
that matches nothing is ignored. `jump_to_close` and the new
`HtmlWalker::close_index` are lookups, which removes the last quadratic
case — a paragraph of thousands of `<img>` tags cost 3.4ms a frame — and a
stray `</span>` no longer stops a link's `</a>` from being found. The tally
that rejects stray close tags hashes `LiveId` through an identity hasher,
since it is already a 64-bit hash; with SipHash the pass cost 20%.

Widget:
- A `<summary>` left open is closed by `</details>` or the end of the
  document, so its bold run and glyph tracker no longer leak into everything
  drawn after it.
- Implicit closes follow the tree builder's scope rules — `<li>` closes an
  open item up to its list, a cell up to its row, a row with its cells, a
  heading directly following a heading, and any block element an open `<p>`
  — rather than only the innermost element.
- A custom widget's label is all the text inside it, so
  `<a href=x><b>Click</b> me</a>` reads "Click me", and a void one has none.
- Sub-widgets are keyed only by node index. Keying by the `id` attribute let
  a document choose cache keys, and a repeated id bound two links to one
  widget.
- `TrimWhitespaceInText`, `combine_spaces` and `ignore_newlines` are gone:
  all three were written at every site and read at none.
- List markers are borrowed rather than allocated per item per draw, table
  cell alignment compares in place, and link hit-testing no longer clones
  its area list on every event.

Script module: `.html` printed raw hex for every tag and attribute name,
because the document was parsed without interning; it is interned now and
text and attribute values are escaped on the way out, so the output parses
back to the same document. `find_elements` counted every open tag toward
depth, the void-element bug again; it steps by resolved close index.

23 parser tests, exhaustive enumeration of all inputs up to length 6 over a
markup-heavy alphabet, and 6M randomized structured cases, checking that no
input panics and that every node range and close index is consistent.

* Html: resolve every element's end in the tokenizer, and close the review's findings

An adversarial review of the previous commit against the WHATWG tokenizer,
the branch point and a reference parser found the gaps below. All fixed.

The parser now keeps the open-element stack as tags stream past, so each
element's end is resolved in the same pass that tokenizes it — the recovery a
browser's tree builder applies: a close tag ends the innermost open element
of its name and everything still open inside it; a close tag that matches
nothing is ignored; the spec's void elements are whole at their open tag;
what is still open at end of input ends there. `HtmlDoc` records both the
element's own close tag (`close_index`) and where it ends (`end_index`).
That distinction was missing: an element ended by an ancestor looked the
same as a void one, so the script module gave `<li>a<li>b` items empty
ranges — no `.text`, no `.html`, children promoted to siblings — and the
widget dropped the label of a link ended by `</td>`. Both read correctly now.
Because the whitespace-preserving stack is the same stack, a `<pre>` ended
by an enclosing element's close tag stops preserving at that tag, which it
did not before.

Tokenizer fixes, each per the spec's state machine:
- `<!>` and `<!->` are complete bogus comments; they used to swallow text up
  to the next `>`.
- A numeric character reference ends at the first non-digit whether or not
  `;` follows (`&#38 b` reads `& b`), and has no length limit: forty digits
  saturate to U+FFFD as the previous commit claimed but did not do.
- An end tag followed by junk and then end of input is dropped like any
  other tag cut off there; it used to emit its close tag anyway.
- `\r\n` and lone `\r` become `\n`, as the input stream preprocessing says.
- A repeated attribute name on one tag is dropped, so a consumer iterating
  attributes sees the first `data-mx-color` rather than the last.
- A comment is not content, so `a <!-- c --> b` collapses to one space.
- `find_tag_text` answers for the first matching element and does not fall
  through to a later one.

The maps that reject stray close tags and duplicate attributes are keyed
with a per-parse random seed and a multiply-fold hash: the previous identity
hasher let crafted tag names collide and made the pass quadratic, and the
standard SipHash cost a quarter of the parse time.

Widget:
- A `<summary>` is tied to the `<details>` that owns it. A `<details>` opened
  inside a summary was taken for the owner, and `</details>` then popped an
  empty tracker stack — a panic reachable from a chat message.
- `</summary>` and `</details>` end whatever was opened inside them, so an
  `<li>` or a table cell opened in a summary no longer swallows the content
  that follows.
- A collapsed body is skipped to the element's resolved end, so a
  `<details>` ended by an ancestor no longer hides everything after it.
- `count_table_columns` ends the first row at the next `<tr>` as well as
  `</tr>`; a table written without `</tr>` had every column halved.
- The `<p>` rule runs before the heading rule, as the tree builder orders
  them, so `<h1><p>a<h2>` no longer nests the second heading in the first.

Script module: ranges are `(open, end)` with an exclusive end; `parse_query`
no longer panics on `a]b[`.

30 parser tests, exhaustive enumeration of all inputs up to length 6 over a
markup-heavy alphabet, and 6M randomized structured cases, checking every
node range, every `close_index`/`end_index`, nesting consistency, and
determinism.

* Html: build the tree builder's implicit closes into the parser, and end every element where it says

Two verification rounds against the previous commit — a spec-conformance
review, a stack-based reference for element ends, a simulation of the widget's
draw loop over exhaustive and random tag soups, and a round-trip check of the
script module — found the gaps below. All fixed.

The parser now applies the tree builder's implicit closes as it builds the
element stack: a block start tag closes an open `<p>`; a heading closes a
heading that is the current node; `<li>` closes an open item up to its list,
`<dd>`/`<dt>` likewise; a cell closes an open cell up to its row; `<tr>` closes
a row and its cells; a table section closes section, row and cells; a second
`<a>` closes the first. Every consumer therefore sees the tree a browser
builds: `<li>a<li>b` is two items, `<a href=1>x<a href=2>y</a>` two links,
and `<li><a href=u>one<li>two` gives the first link the label "one" rather
than "onetwo". The widget's own copy of these rules is gone; it closes each
element at the index the parser resolved, before that node is handled, and
`<details>`/`<summary>` without a close tag of their own are ended the same
way. Two bugs that fell out of them being special:
- a `<details>` ended by an enclosing close tag stayed on the stack, a later
  `<summary>` bound to it, and the collapse-skip resumed *behind* the walker.
  One stale level drew the text twice; N of them re-walked the document 2^N
  times — a 380-byte message hung the UI. A resume is now never behind the
  walker, and no level is left behind to be claimed.
- a `<summary>` ended by an enclosing close tag never popped its bold run and
  glyph tracker, which leaked into everything drawn after it.

Per-name depth stacks replace the per-name counts, so finding the innermost
open element of a name, or the outermost one above a scope boundary, is a
lookup; scanning the stack made a document of nested `<div>`s quadratic.
A tag with thousands of attributes no longer makes every later tag pay to
clear the attribute-name set. Every nesting shape measured is linear.

Tokenizer and tree builder, per the spec: `</br>` is read as `<br>`, so
`x</br>y` breaks the line; the newline immediately after `<pre>` is not
content; NUL is dropped from text and replaced in attribute values.

Widget: a table whose first row is empty is sized by the first row that has
cells rather than falling back to 100px columns.

Script module: `.html` always writes `=""` and doubles a newline that starts
a `<pre>`, so its output parses back to the same document; `.text` is the
decoded text verbatim, no longer inventing a space inside a word split by a
comment or an inline tag; a query on a selection searches inside it, as
`querySelectorAll` does; descendant steps skip ranges already scanned, which
made `b b` on deeply nested `<b>` quadratic; `parse_query`'s grammar is
documented as implemented.

Deliberately unchanged: the entity table's omissions (`&euro;`, ...), named
references without `;`, and an unquoted attribute value ending in `/` before
`>` (per the tokenizer the slash is part of the value; XML requires quotes).

33 parser tests; exhaustive enumeration of all inputs up to length 6 over a
markup-heavy alphabet and 6M randomized structured cases, checking every node
range, every `close_index`/`end_index`, nesting consistency, attribute
dedupe and determinism.
2026-09-11 10:54:28 +02:00
Kevin Boos
1793f724da
button: honor grab_key_focus on press (#1226)
* cargo-makepad: declare native speech recognition metadata

* cargo-makepad: make Apple plist customization opt-in

* cargo-makepad: use macOS tools for plist overlays

* TextInput: add replace_range for edits that aren't typing

Anything that writes into a text field without being the keyboard —
dictation, autocomplete, a paste button — had only set_text and
restore_state to work with. Both clear the undo history, neither emits
Changed, and both end an IME composition that the platform keyboard still
thinks is in progress, so the next commit from the keyboard lands on text
the widget no longer agrees about.

replace_range(range, text, UndoGroup) goes through the same edit path as
typing: the input filter applies (text it rejects outright is refused
rather than deleting the range), the edit lands in the undo history,
Changed is emitted, and the selection is carried across it (anything that
was inside the range ends up after the replacement). UndoGroup::Extend
joins consecutive external edits into one undo step, so every revision of
a dictated phrase undoes together, while any typing in between splits
them. The IME composition belongs to the keyboard: an edit beside it moves
it and pushes the new text to the platform, and an edit overlapping it is
refused with ReplaceRangeError::Composing. is_composing() lets callers
wait for it to clear, and force_new_edit_group is now reachable from the
ref as well.

Undo and redo now end a composition, since the text the IME was composing
is gone with the rewind; the widget used to keep pointing at it, which
also stalled its IME syncing until focus was lost.

* ios: report the keyboard's marked text as the IME composition

The UITextView bridge forwarded marked text (kana awaiting conversion and
the like) to the widget as plain text with no composition range, so
TextInput never knew the keyboard was mid-composition on iOS:
is_composing() stayed false, replace_range's Composing refusal never
engaged, and the widget's next push did a whole-text setText: that
detached the keyboard's composition from the buffer.

forward_state_to_makepad now reads markedTextRange and carries it through
full_state_sync, so the widget records it exactly as it does for Android.
When the widget pushes text while it still holds a composition (an edit
beside it through replace_range), set_ime_text writes the text around the
composition and marks the composed part again with setMarkedText: instead
of committing it.

* macos: complete the handshake with a development launcher

`cargo run` starts a bare executable, which macOS gives no bundle identity.
Microphone, speech and location prompts are then attributed to the terminal
or editor that spawned it, and denied outright when that process has no
matching usage description, so a permission-using app cannot be developed
with plain `cargo run` at all. The way around it is a cargo runner that
launches a real .app through LaunchServices.

Three things are then lost, because LaunchServices forks the process itself
and starts it in `/`: the runner never learns the app's pid, so it has
nothing to forward a Ctrl-C to; it cannot pass on the terminal's working
directory; and it never sees the app's exit code, so `cargo run` always
reports success. All three are only knowable in-process.

The macOS event loop now reports them through the directory named by
MAKEPAD_DEV_LAUNCH_DIR, adopting MAKEPAD_DEV_WORKING_DIR before any
resource is loaded. Apps launched any other way see neither variable and
do nothing, so this replaces the same handshake being hand-written in every
app's main() that wants to develop against a permission-gated API.

* button: honour grab_key_focus on press

Button's FingerDown handling took keyboard focus twice: once guarded by
grab_key_focus, and again unconditionally at the end of the arm. That
second call is `self.set_key_focus(cx)`, which resolves through
WidgetNode to `cx.set_key_focus(self.area())` — and the derived `area()`
comes from the `#[redraw] draw_bg` field, so it is exactly the call the
guard above it wraps.

The upshot was that `grab_key_focus: false` did nothing on press, and a
button that deliberately opts out of focus still pulled it away from
whatever held it. Robrix's dictation hit this: tapping the microphone took
focus off the composer, hiding the caret the transcript is inserted at and
dismissing the soft keyboard on mobile, so it had to hand focus back by
hand afterwards.

Dropping the unconditional call leaves behaviour identical wherever the
flag is true, which is its default and every use in this repository.
2026-09-11 10:54:15 +02:00
Kevin Boos
4afa30f052
macos: complete the handshake with a development launcher (#1225)
* cargo-makepad: declare native speech recognition metadata

* cargo-makepad: make Apple plist customization opt-in

* cargo-makepad: use macOS tools for plist overlays

* TextInput: add replace_range for edits that aren't typing

Anything that writes into a text field without being the keyboard —
dictation, autocomplete, a paste button — had only set_text and
restore_state to work with. Both clear the undo history, neither emits
Changed, and both end an IME composition that the platform keyboard still
thinks is in progress, so the next commit from the keyboard lands on text
the widget no longer agrees about.

replace_range(range, text, UndoGroup) goes through the same edit path as
typing: the input filter applies (text it rejects outright is refused
rather than deleting the range), the edit lands in the undo history,
Changed is emitted, and the selection is carried across it (anything that
was inside the range ends up after the replacement). UndoGroup::Extend
joins consecutive external edits into one undo step, so every revision of
a dictated phrase undoes together, while any typing in between splits
them. The IME composition belongs to the keyboard: an edit beside it moves
it and pushes the new text to the platform, and an edit overlapping it is
refused with ReplaceRangeError::Composing. is_composing() lets callers
wait for it to clear, and force_new_edit_group is now reachable from the
ref as well.

Undo and redo now end a composition, since the text the IME was composing
is gone with the rewind; the widget used to keep pointing at it, which
also stalled its IME syncing until focus was lost.

* ios: report the keyboard's marked text as the IME composition

The UITextView bridge forwarded marked text (kana awaiting conversion and
the like) to the widget as plain text with no composition range, so
TextInput never knew the keyboard was mid-composition on iOS:
is_composing() stayed false, replace_range's Composing refusal never
engaged, and the widget's next push did a whole-text setText: that
detached the keyboard's composition from the buffer.

forward_state_to_makepad now reads markedTextRange and carries it through
full_state_sync, so the widget records it exactly as it does for Android.
When the widget pushes text while it still holds a composition (an edit
beside it through replace_range), set_ime_text writes the text around the
composition and marks the composed part again with setMarkedText: instead
of committing it.

* macos: complete the handshake with a development launcher

`cargo run` starts a bare executable, which macOS gives no bundle identity.
Microphone, speech and location prompts are then attributed to the terminal
or editor that spawned it, and denied outright when that process has no
matching usage description, so a permission-using app cannot be developed
with plain `cargo run` at all. The way around it is a cargo runner that
launches a real .app through LaunchServices.

Three things are then lost, because LaunchServices forks the process itself
and starts it in `/`: the runner never learns the app's pid, so it has
nothing to forward a Ctrl-C to; it cannot pass on the terminal's working
directory; and it never sees the app's exit code, so `cargo run` always
reports success. All three are only knowable in-process.

The macOS event loop now reports them through the directory named by
MAKEPAD_DEV_LAUNCH_DIR, adopting MAKEPAD_DEV_WORKING_DIR before any
resource is loaded. Apps launched any other way see neither variable and
do nothing, so this replaces the same handshake being hand-written in every
app's main() that wants to develop against a permission-gated API.
2026-09-11 10:54:04 +02:00
Kevin Boos
a80e26bba5
TextInput: add replace_range for edits that aren't typing (#1224)
* cargo-makepad: declare native speech recognition metadata

* cargo-makepad: make Apple plist customization opt-in

* cargo-makepad: use macOS tools for plist overlays

* TextInput: add replace_range for edits that aren't typing

Anything that writes into a text field without being the keyboard —
dictation, autocomplete, a paste button — had only set_text and
restore_state to work with. Both clear the undo history, neither emits
Changed, and both end an IME composition that the platform keyboard still
thinks is in progress, so the next commit from the keyboard lands on text
the widget no longer agrees about.

replace_range(range, text, UndoGroup) goes through the same edit path as
typing: the input filter applies (text it rejects outright is refused
rather than deleting the range), the edit lands in the undo history,
Changed is emitted, and the selection is carried across it (anything that
was inside the range ends up after the replacement). UndoGroup::Extend
joins consecutive external edits into one undo step, so every revision of
a dictated phrase undoes together, while any typing in between splits
them. The IME composition belongs to the keyboard: an edit beside it moves
it and pushes the new text to the platform, and an edit overlapping it is
refused with ReplaceRangeError::Composing. is_composing() lets callers
wait for it to clear, and force_new_edit_group is now reachable from the
ref as well.

Undo and redo now end a composition, since the text the IME was composing
is gone with the rewind; the widget used to keep pointing at it, which
also stalled its IME syncing until focus was lost.

* ios: report the keyboard's marked text as the IME composition

The UITextView bridge forwarded marked text (kana awaiting conversion and
the like) to the widget as plain text with no composition range, so
TextInput never knew the keyboard was mid-composition on iOS:
is_composing() stayed false, replace_range's Composing refusal never
engaged, and the widget's next push did a whole-text setText: that
detached the keyboard's composition from the buffer.

forward_state_to_makepad now reads markedTextRange and carries it through
full_state_sync, so the widget records it exactly as it does for Android.
When the widget pushes text while it still holds a composition (an edit
beside it through replace_range), set_ime_text writes the text around the
composition and marks the composed part again with setMarkedText: instead
of committing it.
2026-09-11 10:53:53 +02:00
ymote
5a86431bdb
macOS: don't deliver IME-consumed keys as KeyDown (#1223)
`process_ns_event` hands each NSEvent to AppKit via `sendEvent:` before
emitting Makepad's own KeyDown. When an IME has marked (composition)
text, that dispatch lets the IME consume the key: Return/Space commit
the candidate, digits pick one, arrows navigate, Escape discards,
Backspace edits the preedit. A committing key clears the marked text
during dispatch, so the old post-dispatch `hasMarkedText` check (which
only covered Backspace) could not see it, and the Return that merely
committed a pinyin candidate was also delivered as KeyDown(ReturnKey).
TextInput then treated it as a submit.

Snapshot `hasMarkedText` before `sendEvent:` and skip the KeyDown
callback when the IME was composing. This subsumes the Backspace check
and also fixes the case where Backspace deleted the last preedit
character and then fell through to delete committed text.


Claude-Session: https://claude.ai/code/session_017HLgZDz8vuuqqMya1nCWKf

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 08:19:40 +02:00
66cc4f15f5 fix(widgets): restore enter_isolate/leave_isolate dropped by upstream merge
The fe5b75d92 merge took upstream's widget_async.rs and dropped the
wm isolate-entry API (IsolateEntry, enter_isolate, leave_isolate)
while lib.rs and apps/flow-ui + apps/wm still use it, breaking
makepad-widgets. Restored verbatim from 77d91385f; structs unchanged
so the code applies as-is. makepad-widgets, makepad-app-flow-ui and
makepad-wm all check clean.
2026-09-10 19:01:52 +03:00
b9a083c26f fix(android): bundle robius secondary dex + wrap app_main binaries
- bundle_crate_secondary_dex: merge OUT_DIR/classes.dex from dependency
  build scripts (robius-sms/trigger/ussd javac+d8 Java for manifest
  receivers/services) into the APK as classesN.dex. Without it the
  manifest declares BootReceiver/SmsReceiver/AccessibilityService that
  are missing at runtime (ClassNotFoundException on boot).
- has_explicit_lib_target: a crate with src/main.rs containing app_main!
  is an app, not a lib — generate the android wrapper ([lib] path =
  src/main.rs) so the JNI entry (activityOnCreate) is linked. Fixes
  UnsatisfiedLinkError for app crates that also ship src/lib.rs.
2026-09-10 16:26:35 +03:00
Kevin Boos
4b25a1bf1e
cargo-makepad: support custom iOS and tvOS Info.plist entries (#1221)
* cargo-makepad: declare native speech recognition metadata

* cargo-makepad: make Apple plist customization opt-in

* cargo-makepad: use macOS tools for plist overlays
2026-09-10 07:53:29 +02:00
Jason Yau
14fe611e66
Add Apply::Rebake so script_mod re-runs stop clobbering imperative state (#1219)
Co-authored-by: jasonqiu <jasonqiuchen@outlook.com>
2026-09-05 23:26:03 +02:00
Arena Agent
fe21c07d84 skills: add Makepad game skills pack ported from threejs-game-skills
Nine agent skills for building Makepad/Rust games, ported from
majidmanzarpour/threejs-game-skills. Same director-routed workflow and
premium bar; runtime rewritten for this fork's game crates.

- makepad-game-director        entrypoint, routing, continuity, asset probe
- makepad-gameplay-systems     loop, movers vs rigid bodies, input, camera, netplay
- makepad-aaa-graphics-builder lighting, shaders, budget, visual scorecard
- makepad-game-ui-designer     HUD, menus, touch and XR UI
- makepad-debug-profiler       defect bisection and profiling
- makepad-qa-release           verification ladder, evidence, packaging
- makepad-3d-generator         CC0 model search, casts, procedural geometry
- makepad-image-generator      texgen textures, palettes, sky, icons
- makepad-audio-generator      sample bank, mixer, material impacts, 3D audio

Written against the real APIs in libs/game/*, libs/sim and apps/arcade:
the game.* verb table, GameRenderer adaptive quality, the packed 6-float
GameMeshVertex layout, script_mod! splash styling, makepad-test driving,
and the BUDGETS.md numbers. No paid generation API is required - the CC0
library plus seeded makepad-game-gen replaces them.

Includes install.sh (Codex/Claude), validate-skills.sh and a repo-aware
probe_assets.sh; both scripts verified against this checkout.
2026-09-05 21:18:58 +00:00
Arena Agent
fe5b75d92d Merge latest makepad work while preserving fork game crates and apps 2026-09-05 20:55:35 +00:00
Admin
a75fe914ff layout: extend turtle sizing and add Grid 2026-09-05 17:21:48 +02:00
Admin
b005c6e9b2 Preserve complete Route geometry within measured web memory budgets 2026-09-05 17:07:06 +02:00
Admin
9e61553c96 Keep Route 3D buildings under bounded web memory pressure 2026-09-05 15:21:31 +02:00
Admin
09fa1f080c Restore WebGL text with complete fallback samplers
Use cached transparent 2D and cube textures for unallocated optional samplers without bypassing invalid-resource, target, or framebuffer-feedback guards.

Require actual glyph draws in release smoke tests and keep shader compilation asynchronous in probe instrumentation.
2026-09-05 14:39:06 +02:00
Admin
1801e38453 Harden web renderer and make Route location opt-in
Bound drawable, geometry, texture, image, map and radar work; validate WebGL submissions and retire GPU resources safely. Treat context loss as terminal without re-entering Wasm after worker termination.

Add explicit Route location consent and regression coverage, including software-WebGL release probes for six deployed demos.
2026-09-05 12:13:36 +02:00
435135ed50 feat(arcade): port makepad game libs + arcade from makepad-internal onto the fork
Reintroduce apps/arcade and the ten game crates that were ripped out of
the fork (assets, audio, blocks, coedit, gen, net, pkg, render, script,
session), from makepad-internal/dev. Reconcile against the fork's layout:

- makepad-game-sim/math come from the fork's existing libs/sim + sim/math;
  the copied internal libs/game/{sim,math} are dropped and the kept game
  crates plus arcade point at ../../sim and ../../sim/math.
- arcade AI is rewritten off the removed makepad-ai agent API onto the
  makepad-ai-hub headless ChatProvider worker (example/cad pattern):
  AiWorker{send,cancel,poll}, ai_worker_loop driving
  ClaudeApiChatProvider(ClaudeCli), AiWorkerEvent availability/delta/
  done/error mapped into the chat feed and authoring land_edit, with
  main.rs send_message/cancel_request/event-drain rebuilt around it.
- the game-script sandbox is adapted to the fork's jailed splash storage:
  every isolate gets mod.fs = splash_storage jail; splaes splash_storage
  set_root_for_heap is now public so the ScriptHost (or Splash) can grant
  a per-game jail root. ENT font: build.rs/dispatch.rs entities fill new
  fork sim Entity/Part fields via ..Default::default(); world_raycast now
  borrows mutably and returns the material id.

cargo check -p makepad-arcade and the game + arcade test suites pass.
2026-09-05 05:49:12 +03:00
Admin
83a00d2801 flow: archive generated assets and add live source editing
Host or attach the asset service, archive generated and terminal outputs, and provide image/video/audio/text/3D viewers with fullscreen and gallery navigation. Load only visible bounded previews, generate video posters and text excerpts, and normalize legacy thumbnails off the UI thread.

Expose video frame counts and Sandbox chat providers, make failed queue cleanup idempotent, compact the panels, and correct short-wire direction. Replace App view with a movable, resizable CodeEditor overlay with debounced source saves, live graph updates, invalid-source preservation and close/reopen geometry.

Validation: release Flow, Flow UI and flowgraph suites passed 335 tests (two ignored). Updated Flow UI suite passed 107 tests; asset route suites passed nine tests after legacy preview fixes. The rebuilt release app passed remote editing, dragging, resizing, invalid-source, close/reopen and App removal checks; final All assets scrolling measured 16.5 ms median and 18.3 ms maximum over 12 inputs. Test instances were closed through the remote protocol.
2026-09-05 01:47:53 +02:00
Admin
75b295ce9a vj: synchronize decks and queue remote stem processing
Update deck and mixer synchronization, apply matched deck rates to splats, and queue remote stem extraction through the fleet. Refresh catalog and creator pipeline integration.

Validation: release VJ suite reports 890 passed, 10 failed, nine ignored. Eight mixer failures reproduce in serial execution (gain, transitions, sample alignment and video fades); two asynchronous media tests also failed in the full run. The new matched-rate splat test passes. These remaining failures are not claimed as fixed.
2026-09-05 01:45:43 +02:00
Admin
b49e2e5060 piano-model: calibrate partial gains and decay against recordings
Apply immutable pitch/velocity-interpolated modal calibration while preserving an explicit uncalibrated constructor. Add offline acoustic tooling, reference measurements and regression coverage.

Validation: 16 calibration/acoustic release tests passed, one ignored.
2026-09-05 01:42:39 +02:00
Admin
3ce27922a4 sim: use deck geometry for collision and sensing
Use deck surfaces consistently in dynamics, floor normals, sensing and voxel handling.

Validation: release makepad-game-sim tests passed.
2026-09-05 01:42:39 +02:00
Admin
3dfe5aaed3 show-control: restore sixteen DMX scenes with local save overlays
Bundle the recovered numbered scene bank, support sixteen slots, and keep editable current state and overrides in the local overlay. Validate legacy control mapping.

Validation: 27 release tests passed.
2026-09-05 01:42:39 +02:00
Admin
2f44d20b51 apple: avoid blocking video clocks and release native players once
Use output/presentation timestamps instead of AVPlayerItem.currentTime on the UI thread. Consume only fresh frames and stop polling paused players after their poster arrives. Balance Objective-C ownership and make native cleanup idempotent.

Validation: release Flow playback smoke and process sampling; the previous currentTime mutex hotspot is absent in the updated sample.
2026-09-05 01:42:39 +02:00
Admin
f8b9a670b7 widgets: preserve numeric edit completion and menu focus
Find numeric completion actions reliably and restore menu focus using tracked areas. Handle touch dismissal and focus changes without leaving menus open.
2026-09-05 01:42:39 +02:00
Admin
88048aa784 assets: extend authoring pipelines and typed asset search
Add sandbox authoring effects and character/composite creation, improve creator submission handling, and extend publishing/search metadata. Update importer conversions and the asset UI integration.

Validation: 31 store search tests, ten composite tests and two author-policy tests passed in release mode.
2026-09-05 01:42:39 +02:00
Admin
97912796a8 render: support rigged models and custom materials across viewers
Add declarative CSG joints, bindings and clips, custom render materials, and skinning/lightmap updates. Update shared media and XR viewers, including splat visibility and optional mesh HUD.

Validation: 400 release render tests and seven CSG rig tests passed.
2026-09-05 01:42:39 +02:00
Admin
1be1e2153c ai-hub: gate downloads by disk capacity and recover fleet admission
Account for missing and partial model files per volume, reserve headroom, and reject disk-constrained workers before accepting a job. Preserve typed admission failures so callers can choose another peer, and make activity gating and cancellation recover cleanly.

Validation: 592 release hub tests passed, one ignored; required-CUDA builds deployed to six idle Windows workers.
2026-09-05 01:42:38 +02:00
Kevin Boos
a13034d85d
wayland: stop inverting the scroll direction (#1216)
* wayland: stop inverting the scroll direction

Wayland's wl_pointer axis values already carry Makepad's scroll
convention -- positive vertical means scroll down, i.e. the viewport
moves down. The backend negated them, so wheel and touchpad both
scrolled backwards relative to X11, macOS, Windows and web.

The spec pins the sign in wl_pointer::axis_relative_direction, whose
`identical` case is a user's fingers moving down producing a
"vertical_scroll down" axis event. libinput, which produces the values
compositors forward, documents the same: "the positive direction being
down or right". Makepad's own convention matches -- ScrollBar applies
`scroll_pos + e.scroll.y` against a position clamped to
[0, view_total - view_visible], and the turtle draws content at
`origin - layout.scroll` inside a clip rect fixed at the unshifted
origin, so a positive delta moves the viewport down.

The negation came from #875, which read a positive axis value as content
sliding down and cited winit's negation as precedent. But winit's
MouseScrollDelta is documented as positive = content moves down, the
inverse of Makepad's convention -- winit's own comment reads "Wayland
sign convention is the inverse of winit" -- so copying it was a double
negation. Whether a toolkit negates is decided by its own convention,
not by anything about Wayland: GTK, which shares Makepad's convention,
passes the values through; SDL and Chromium negate because theirs are
inverted, and SDL negates vertical only, which is self-consistent just
in case Wayland's +y is down and +x is right. #875 also cited the web
backend as agreeing, but web forwards DOM deltaY unnegated, and deltaY
is positive when scrolling down.

The AxisDiscrete and AxisValue120 handlers added later inherited the
sign, so all six sites flip together; the spec states each expresses its
direction along the same axis as the coupled axis event.

Natural scrolling needs no client-side handling. libinput applies it in
evdev_notify_axis_*, below the compositor, so the delivered axis value
already reflects the user's setting -- the negation was not implementing
that, it inverted both settings equally. AxisRelativeDirection stays
ignored, which is correct for scrolling content; it exists so widgets
that should track the physical wheel regardless of the setting (the
spec's example is a volume slider) can recover the direction.

Fixes #1173

* wayland: classify the scroll source, and choose each axis's delta on its own

Five defects in the wl_pointer frame handler, adjacent to the sign fix in
the previous commit but independent of it.

The detent-vs-pixel choice was made once for both axes, so a frame
carrying detents on one axis and only a smooth value on the other scaled
that second axis by a zero detent count and silently dropped it. Each
axis now chooses on its own.

`scroll_is_wheel` collapsed a five-valued classification into "Wheel vs
everything else", and its false default meant "finger gesture". So a
wheel tilt discarded its detents, a continuous source — a trackpoint, or
button-held scrolling — was reported as a touchpad gesture, and so was a
frame from a compositor that sent no axis_source at all, the event being
optional and sent only when the source is known. That default is the one
classification that can strand a widget: ScrollPhase::Ended is what
springs a stretched rubber band back, only a finger source is guaranteed
an AxisStop, and the spec tells clients to treat every other source as
unterminated by default. The bool gives way to the source itself, and a
sourceless frame is classified by whether it carried detents.

A bare AxisStop no longer dispatches for a source with no gesture to end.
Compositors stop an axis whenever its value reaches zero, whatever the
source, and a zero-delta ScrollPhase::None clears a widget's overscroll
and cuts short a running bounce.

Nor is a stop arriving alongside live motion treated as lift-off. Per the
frame event: "When a wl_pointer.axis and a wl_pointer.axis_stop event
occur within the same frame, this indicates that axis movement in one
axis has stopped but continues in the other axis." And because
axis_source is per-frame and optional, a gesture in flight now carries
its classification forward, so a lift-off frame that omits the source
still ends the gesture instead of losing the terminator.

The raw-pixel fallback for an axis without detents stays unscaled, which
is a deliberate non-change rather than an oversight. No units-per-detent
constant exists to scale it by — compositors disagree, and hwdb ships
wheels from 10 to 30 degrees per click — and a physical wheel never
reaches it: the fallback is for virtual pointers, whose axis value the
protocol already defines as a distance.

Finally, the claim that ScrollPhase::Ended lets widgets run their own
momentum fling was wrong. Widgets start their fling on
ScrollPhase::Momentum, which only macOS emits, so Wayland touchpads have
no kinetic scrolling at all; the comment now says that rather than its
opposite.

The frame decision moves into `frame_scroll`, which puts every case above
under a unit test instead of leaving it to be re-derived by reading.
2026-09-05 00:18:26 +02:00
Admin
39a1b742ac flow-ui: a locked run's pictures and clips still take clicks — the viewer opens from the Output card again; only the inputs stay inert
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 21:17:11 +02:00
Admin
6f2fdfa861 flowgraph: a wire between nearly level ports takes the short run instead of a trip around the card (the fillet rule rejected any interior segment under two radii); fewer bends win only among routes of comparable length; six screenshot fixtures and the routing review
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 21:07:02 +02:00
Admin
1181a3bae4 flow + flow-ui + widgets: every creator pipeline is a template — 55 templates in six groups (Image, Video, Audio, 3D, Vision & text, Utilities), all evaluated and engine-exercised in tests; the New picker, the flows.templates tool and the palette group the same way; the Templates menu shows them under group headings, and a menu taller than the window scrolls
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 21:03:54 +02:00
Admin
2b4bff9a38 widgets + flow-ui: menu entries fire again — the bar's Closed action no longer hides the Selected one behind it, and an open menu takes pointer and key input before the panels beneath its popup
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 20:54:18 +02:00
Admin
30575f082d flow: route generation by request workload
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-04 20:27:00 +02:00
Kevin Boos
c5fb78ba09
wayland: client-side decorations that cast a real shadow (#1215)
Makepad windows on Wayland had no drop shadow, which on GNOME reads as
broken next to everything else on the desktop. Mutter implements no
server-side decoration protocol at all -- it advertises neither
zxdg_decoration_manager_v1 nor any KDE equivalent, and its shadow code
(MetaShadowFactory) lives in src/x11/ and isn't even in the
introspection surface. Every shadow on that desktop is drawn by the app
that owns the window.

So draw one, out of eight wl_subsurfaces hung outside the toplevel: four
corner tiles and four edge strips, backed by one memfd wl_shm pool and
sized with wp_viewport, with xdg_surface.set_window_geometry keeping them
out of the window's logical bounds. GTK instead oversizes its own surface
and paints the shadow into a transparent margin. Subsurfaces keep the GL
surface exactly window-sized, so the shadow costs no per-frame GPU fill,
and no margin ever crosses the platform/widget boundary -- which is the
entire class of off-by-a-margin bugs the other approach invites.

The profile is libadwaita 1.9's, computed rather than sampled. A
rectangle's Gaussian shadow is separable, so each box-shadow layer's 2-D
coverage is the product of two 1-D normal CDFs, and evaluating that for a
*square* rectangle is what makes the corners hug the window: sampling a
rounded window's shadow gives 14/255 where a square corner needs 44/255,
and fades the edge out over the last 20px before every corner. The
straight-edge profile this produces matches a capture of the real
libadwaita output to within 1/255, which is what the test pins. Corner
tiles reach 16px along each edge, far enough that they join the strips
bit-identically at any scale.

Resizing happens in the gutter, the way it does for every native app.
The shadow surfaces carry input regions whose union is the window rect
grown by 12px -- the same halo libadwaita gives its toplevels -- and each
piece maps to exactly one edge, so landing on a surface is the hit test.
Window controls no longer compete with the corner grabs for the pointer,
which is what let the close button swallow the top-right corner.

Server-side decorations are requested wherever a compositor offers them,
overridable per process with --wayland-decoration= or
MAKEPAD_WAYLAND_DECORATION, and fall back to the frame above. KWin and
wlroots grant them; GNOME cannot.

Alongside, the caption bar gains double-click-to-maximize, a right-click
window menu, resize cursors keyed off the wl_pointer.enter serial the
protocol actually asks for, and tiled/constrained edges that suppress the
grabs they cannot service -- degrading a corner to its free axis rather
than dropping it.

Finally, declare the toplevel's opaque region, under the same
`!transparent && backdrop == None` condition macOS already uses for its
layer's opaque flag. The buffer is ARGB8888, so without that promise a
compositor cannot learn the alpha is uniformly solid short of reading
every pixel: it must blend the whole window, cannot cull what the window
covers, and cannot scan a fullscreen buffer out directly.

Verified against a WAYLAND_DEBUG trace: over 67 committed frames the
shadow issues no protocol traffic at all, and set_window_geometry,
set_opaque_region and the nine wl_regions are each sent and destroyed
exactly once.
2026-09-04 20:23:43 +02:00
Admin
b0b0e41da0 flow-ui: select runs from the whole queue row
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-04 20:15:05 +02:00
Admin
f3b6c2f36c Merge branch 'work' into webdemos 2026-09-04 20:13:43 +02:00
Admin
778dbb8fc7 flow: accept bodyless 204 responses
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-04 20:12:49 +02:00
Admin
8f51704697 flow-ui: a Templates menu on the bar lists every template the server serves; choosing one creates and opens a flow from it
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 20:09:13 +02:00
Admin
f45793a8fa vj: batch live video publishes into grid rows 2026-09-04 17:47:47 +02:00
Admin
28016ca897 vj: keep generated clips at the head of the grid 2026-09-04 17:41:18 +02:00
Admin
2a064d3928 workspace: add loader, haptics, voice, and runtime fixes 2026-09-04 17:39:41 +02:00
Admin
acd23289c6 flow + flow-ui: a cancelled run clears its cards and bars at once and its live nodes read cancelled; layout edits (move, size, flip) work while a run is shown; the queue row is one centre line — name, strip, state · time, x
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 17:24:17 +02:00
Admin
a6c5f15bf7 widgets: FabValueInput honours visible, so the seed picker's random mode hides the number field
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 15:04:50 +02:00
Admin
767e260b97 flow + flow-ui: parallel runs — random seeds with a die, a fleet parallelism estimate (generation nodes bound it; the chat box never caps the image fleet), batch routes, the Run dropdown (1 / max · N), Ctrl+Enter queues a run, and the Running panel becomes a Queue of every run with per-row and per-batch cancel and Clear all
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 15:01:15 +02:00
Admin
bf9418e2bc vj: ironfish / synth rack, program mix, and a clean release check
Clocked piano/ironfish/drum rack, program-bus mix, splat/mixer/music
updates. Silence the unused warnings that show up in `cargo check -p
makepad-vj --release`.
2026-09-04 14:54:07 +02:00
Admin
b37b1168fd media_view: the video, audio, mesh and splat viewers leave asset-ui for one crate; flow-ui shows every media type on the Output card and in the modal viewer
ViewSplat gains a typed set_scene_bytes so a viewer inside a face isolate never needs a script apply; MeshView gains visible.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 13:49:32 +02:00
Admin
a7774885a1 flow + flow-ui + flowgraph: the loaded flow is the editable design (Input.value lives in the file); Play clones it into a numbered locked run instance with a Design button back; inspector text rows follow the graph
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 13:37:38 +02:00
Admin
c71d6392f9 flow + flow-ui + flowgraph: a stale keep-alive is replayed on a fresh socket; a palette drop rewrites the file text so the new card mounts; the progress bar is centred in the header and inset past the corners
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 13:27:21 +02:00
Admin
7e397cf31e flow + flow-ui: Publish end node writes to the asset library; Assets tab in the Inspector; prompt-to-library template
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 13:27:21 +02:00
Admin
9a31d2104a flow-ui + widgets: a chosen model shows no node list, and a closed ComboBox shows the start of a long label
The picker's own label counts the ready nodes, so the GPU list under a
chosen model was clutter; it stays only for a model no node can serve,
where it names why. A closed ComboBox reset its text field to the tail of
the label after set_text; the cursor now sits at the start.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 12:03:50 +02:00
Admin
24c927ad7e flow-ui + widgets: every dropdown is the searchable ComboBox
Model and format pickers, the inspector's one-of rows, the Ask face's
choice and face-declared pickers all render as ComboBox; the popup and
its input map through the canvas zoom. ComboBoxRef gains changed_label
and set_selected_by_label so the bindings did not have to change shape.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 12:01:42 +02:00
Admin
d20a20871a flowgraph: a routed wire turns right after its port and hugs its own card
The straight run out of a port is 10 px (8 px minimum) with the endpoint
fillets shrinking to fit, and the first and last vertical runs keep only
the narrow 6 px envelope from the wire's own card; other cards keep the
two-tier clearance and parallel wires keep their cable spacing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 11:48:58 +02:00
Admin
30ef92597f flow-ui: the image generator card shows only its settings; the picture lives on the Output card it feeds
The generator's own preview was clutter next to the Output card's
picture. The Image face drops its preview and generator cards are no
longer full-bleed; the two picture tests follow the picture to the
Output card.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 11:35:05 +02:00
Admin
5863c43eb1 flow-ui: an input card is one text area filling the card, with no name beside it
The host wrapped every bound face control in a labelled row, so the
prompt card showed a "value" label beside a fixed-height box. A
multi-line text area is now its own row and follows the card's size.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 10:48:03 +02:00
Admin
cff15ac045 widgets: a fab number field drops a label that cannot fit instead of crushing it to a dot
A 54 px width chip left the "w" label one pixel column, which rendered
as a stray dot beside the number.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 10:43:12 +02:00
Admin
2a5172465c flowgraph + flow-ui: two wire modes never mixed — bezier or routed — and routed wires use narrow gaps
Routed mode never falls back to a curve: a wire with no corridor takes the
least-collision orthogonal path. Clearance is two-tier: the comfortable
12 px card clearance with 16 px fillets first, then 6 px per side with
8 px fillets, so a routed wire passes a 20 px gap. Bezier mode is a pure
cubic per wire. View → Wires switches the mode (in-process until the app
has a settings store).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 10:43:12 +02:00
Admin
27f02f2443 flowgraph: the canvas's style table resolves its registered type, so node and port icons draw again
The lib's script named FlowCanvasStyles unqualified before it was in
scope; the styles object never applied and every icon lookup came back
empty (the colours survived through the per-kind colour properties). The
style types are registered as components and the default references
mod.widgets.FlowCanvasStyles.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 10:33:33 +02:00
Admin
c7241e00be hub: a node that evicted every resident releases its cached allocator pool before refusing a load or publishing usable VRAM
The 5090 kept about 1.5 GB of CUDA allocator pool after evicting every
model, so a card that fits flux2-dev refused it (30510 MB free reported,
32090 MB in a fresh process). Admission now trims the pool when the last
resident is gone and re-measures before refusing; usable VRAM is measured
after the same trim; the refusal names the pool it released.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 10:22:57 +02:00
Admin
c6890fd47a flowgraph: the flow canvas and wire router become libs/flowgraph, a widgets-only crate any UI can host; flow-ui is its first consumer
makepad-flowgraph carries the canvas (camera, cards, ports, wires,
selection, edits out), the router, a string-keyed view model
(GraphView/NodeView/PortView/EdgeView), script-registered node and port
styles keyed by kind, and a three-method NodeFaces trait for per-node
face widgets. It depends on makepad-widgets only. flow-ui projects its
splash-evaluated Graph through a 147-line adapter (graph_view.rs), keeps
its faces, file writer, panels and services, and renders pixel-identical
to before (hidden before/after grabs, zero differing pixels).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 10:22:21 +02:00
Admin
cf3b6488de flow-ui: oval port discs with a small input notch, the icon visually centred, and wires that start at the output's tip
The input notch was as wide as the output point; it is now a small notch.
The disc is a slight oval along the flow axis and the type icon shifts a
little toward the point (outputs) or away from the notch (inputs) so it
reads centred. Wires anchor at the tip of an output's point and at the
apex of an input's notch instead of the disc centre, so a wire leaves the
point along its axis.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 08:08:20 +02:00
Admin
f5ec84e671 flow-ui: the port disc's shape carries the flow direction — outputs end in a point, inputs have the matching dimple
Replaces the loose chevron beside the label. The cable side of an output
disc is drawn as a sharp > point, the cable side of an input disc as a >
dimple the point would fit into; both follow the card's flip. The type
icon stays centred and the labels sit close to the disc again.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 07:58:15 +02:00
Admin
8f23e3b6ee flow-ui: the port chevron sits between the disc and its label, never inside the disc
The direction arrow shared the disc with the type icon and crowded it.
It now centres in the gap between the disc edge and the port name, so an
input reads "disc > label" and an output "label > disc"; the type icon
returns to the disc centre and the labels move out to make the room.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 07:52:02 +02:00
Admin
ad5e98bbad hub registry: flux2-dev's VRAM estimate is its measured peak, 30 GB
Measured on the RTX 5090 (2026-09-04, 1024x1024, 8 steps, nvidia-smi 250 ms
samples): 30.5 GB used at peak, the run completed in 52 s. The old 29 GB
was a pre-measurement guess that under-reported the model by 1.5 GB.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 07:48:01 +02:00
Admin
75c34413be hub: the PRO 6000 serves image as well as chat and text
flux2-dev cannot fit on the 5090 at the default reserve, and the role
table barred the only card that can hold it. The user opened image on
10.0.0.165 ("let the rtx serve images too"); the role test and the
flow's role-aware listing test follow.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 07:41:59 +02:00
Admin
c90df1fe0a flow + hub: a node advertises only the models it can admit, the flow picks admitted nodes for the named model, retries on refusal, and names every node's reason when none can take it
The 5090 listed flux2-dev ready (total VRAM passed the gate) then refused
every job (29696 + 2048 reserve > ~30510 usable); the flow picked it by
domain ETA and never retried. Nodes now publish vram_usable_mb and mark
un-admittable models too_small; the fleet gate uses usable VRAM; the
flow's gen executor picks admitted nodes for the requested model (ready
first), retries up to three nodes after an admission refusal, and when no
node can take the model its error says why per node (role, too small with
the numbers, waiting for VRAM). The flow's model listing drops (model,
node) pairs the node's fleet role bars, so the picker no longer counts the
chat-only PRO 6000 as ready for image; its label reads ready/absent/too
small with the GPUs named.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 00:45:42 +02:00
Admin
5a9eed960c Merge branch 'work' into webdemos 2026-09-04 00:18:58 +02:00
Admin
303458d07a webgl: a 2D texture pass builds its camera through set_ortho_matrix like every other backend, so the exploded z-layer view's camera reaches the GPU — the hand-built ortho branch uploaded an identity view and clipped every exploded draw, leaving the tweaker's layers view a bare window on the web; the Y flip for render-to-texture is one helper shared with the keep-camera branch
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-04 00:18:58 +02:00
Admin
4c370488e9 flow-ui: wires are stable and selectable — a route depends only on its own obstacles and keeps its kind unless a new one is clearly better, with a fixed tie-break; a click within six pixels selects a cable, the inspector shows the connection, Delete removes it
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 00:00:26 +02:00
Admin
2e869f84ea route: the ocean-high overlay comes from the re-sharded archive (58 shards of at most 16 MB, one small leaf directory each) instead of two shards whose directories decoded to 407 MiB
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 23:58:08 +02:00
Admin
8c40d28a47 flow-ui: a close button at the top-left of the image viewer, fixed to the screen; the bar and the button fade with the viewer
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 23:51:47 +02:00
Admin
73886d1664 Merge branch 'work' into webdemos 2026-09-03 23:45:22 +02:00
Admin
3c2530d000 web memory diet: the 805 MiB at load was the ocean-high archive's 13 M-entry leaf directory decoded to 407 MiB per lookup round and evicted at once — leaves now parse streaming into a window around the waiting tile ids (LeafParseLimits); a phone policy on the web (deviceMemory, UA, touch + short side) caps wasm at 512 MiB with a 320 MiB budget; archive leaf/range caches, reads and bakes in flight follow the budget; packed tile bytes stay packed until the bake decodes them; terrain scratch sized to the viewport and dropped with the layer; Cx::memory_report by owner. Phone viewport 1334 → 308 MiB after a minute of pans, desktop 1208 → 588
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 23:45:22 +02:00
Admin
2ed495f2ed flow-ui: the flip decision scores length, crossings, bends and loops instead of length alone, so a crossed pair unflips; ports and wire midpoints carry direction chevrons that hide below half zoom
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 23:44:42 +02:00
Admin
4781378985 flow-ui: the image viewer is modal for input — wheel, pointer and keys stop at it, and the canvas ignores a wheel under any open overlay
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 23:43:19 +02:00
Admin
b41fab482f flow-ui: a wire takes the simplest shape the geometry allows — the clear S-curve wins over any corridor route, tangents scale with the hop, level ports get a straight line, and no route has more bends than an obstacle forces
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 23:20:27 +02:00
Admin
6139a7386f flow + flow-ui: a card can face right-to-left — flip: true in the file mirrors its ports; the router is directional; a card auto-flips when that cuts its cable length below 80 percent, a hand flip pins it; the flip animates
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 23:13:39 +02:00
Admin
b7d95d823e flow-ui: the image viewer opens fitted and centred — the picture syncs after the stage has drawn instead of reading its cleared area as empty; a sampling-mode field replaces the abuse of the image shader's rotation; pan clamps at the picture's edge, 1:1 means one image pixel per device pixel, the checker stays fixed to the screen
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 23:00:51 +02:00
Admin
8ff1d1c41b flow-ui: wires never pass behind a card — endpoint cards are obstacles except at their own port stub, targets below or below-left route through a clear corridor; the selection outline is a stroke on the card's own rounded body under the port discs; a press on a picture box drags the card and a still click opens the viewer
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 22:43:49 +02:00
Admin
f95e34b4c1 flow-ui: the inspector has one purpose — the selected node's header and note, the settings its card does not show, its connections as chips, its result, the raw face source folded under Advanced; a full-window image viewer with cursor-anchored zoom, pan, fit and 1:1, arrows through the instance's pictures, save and copy digest
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 22:38:44 +02:00
Admin
c79e84efca map: a tilt is two fingers moving together up or down — same vertical direction for both, the pair's stroke within a fifth of vertical; spread and angle no longer matter, so real fingers trigger it
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 22:38:16 +02:00
Admin
4aec005469 flow-ui: wires route around cards with fillets and a pulse that travels the cable when a value lands; Clear drops an instance's generated state and keeps its inputs, as a route, a toolbar button, a menu entry and a shortcut
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 22:32:05 +02:00
Admin
4ee4f4cb79 flow-ui: the resize path sets walks and fits through typed setters, never a script apply from the main VM on an isolate's widget — a failed apply had left a freed script object behind and wedged every frame; a resized card fills its picture box, clips its face and lets the last flexible element take the height
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 22:15:37 +02:00
Admin
018ce7370d map: labels hold still through a gesture and never pop — the settled placement rides the camera delta while anything moves (pan now part of the motion signature; re-place only beyond the pan/zoom law), every draw uses the rect-centre fold pivot so CPU placement and the GPU warp agree (a fresh place drew about the screen corner: the giant space-warp labels), and a re-place cross-fades: survivors keep their birth, newcomers fade in, dropped labels retire from their own camera over 250 ms
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 22:10:03 +02:00
Admin
8eaa6ec3df map: two-finger tilt follows the phone convention — fingers up tilt into 3D, down flattens
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 22:06:35 +02:00
Kevin Boos
4383a13832
View: an on_item_tap hook for script-rendered lists (#1212)
* View: an on_item_tap hook for script-rendered lists

Rows built by `on_render` can't carry `on_click` closures (they stop the
list re-rendering), so lists had no way to be tappable.

* `on_item_tap: |index|` on the container fires with the direct child
  under a tap
* Runs after the scroll bars with capture overload, so a press still
  starts a drag scroll and a Button child keeps its own click

* View: on_item_tap hit-tests rows with clipped_rect, so scrolled lists map to the right row

* View: a press that catches a fling never counts as an item tap
2026-09-03 22:03:57 +02:00
Kevin Boos
ab7e2230d8
Splash: let the host pick the theme its isolates boot with (#1211)
* an isolate's widget prelude snapshots `mod.theme` at boot, which was always the default dark theme even under a light host, so default labels and pressed buttons went light-on-light
* `set_splash_theme(SplashTheme)` names the theme applied between `theme_mod` and `widgets_mod` for every new isolate
2026-09-03 22:03:44 +02:00
Admin
8b05496651 flow + flow-ui: width and height snap to the type's step (16 for images, the doc is the authority) in the fields, the inspector, the presets and the evaluator with a warning, so a fleet backend never sees an illegal size; text in cards and the inspector scrolls inside a bounded area that follows streaming until you scroll up
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 22:01:09 +02:00
Admin
2f3e393c59 web_server: a directory path without its trailing slash (/score) redirects to /score/ instead of 404, query preserved
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 22:00:31 +02:00
Admin
ddc4709b32 map: touch gestures — one-finger pan and double-tap zoom, two-finger pinch zoom around the midpoint, rotate with a 5° dead zone, and a parallel vertical slide for tilt, one state machine native and web; the web page keeps browser pinch-zoom out (non-passive touch listeners, touch-action none, maximum-scale 1)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 21:58:36 +02:00
Kevin Boos
6d71eda34f
Splash: host->script hook calls find fns defined after a shadowing let (#1210)
`call_script_fn` looked names up in the module body scope, but a
`let`/`fn` that shadows a name already in scope opens a child scope,
and everything the script defines after it lands there, invisible from
the module scope. The Splash prefix's own `let fs` / `let host` can be
that shadow, so app hooks never resolved.

* The VM records the scope a root frame ended in (`ScriptBody::end_scope`)
* Splash looks hooks up there, falling back to the module scope
2026-09-03 21:58:22 +02:00
Admin
4cd24c4905 route demo: the first location fix flies the map to the user at zoom 14, as native does; Amsterdam stays the default until a fix arrives
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 21:54:21 +02:00
Admin
bc6c620ede hub + flow: what the chat review found — the in-process route retries an empty completion too, a node says whether its prefill opened thinking so a brief-mode answer is never discarded, a preferred model falls back to normal election when no node has it, discovery keeps looking for the preferred model until patience runs out
Verified on the LAN fleet: a new prompt → paragraph → picture.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 21:50:59 +02:00
Admin
ca92ff9da4 route: the open sea on the web — the ocean-low/ocean-high overlays are an always-on group in the shared hosted overlay table, read through the archive plane (makepad.nl, cached); native keeps the local ocean files only as a dev override; the native-only OCEAN_MBTILES path is gone
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 21:44:36 +02:00
Admin
dbb46be699 Merge branch 'work' into webdemos 2026-09-03 21:43:15 +02:00
Admin
a53d9b6b40 route: no clock-driven night theme — the theme is the user's toggle, remembered in cx.storage, default day (the hour rule fired on the web for the first time once wasm had a clock and darkened the map after 19:00)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 21:43:15 +02:00
Admin
43302a63ed flow-ui: every card owns a draw list and draws in z order, selection brings it to the front; and the design review's findings — every event kind remapped through the camera, run events keyed by run id, no remount mid-run, an input journal that survives a failed PUT, terminal states reconciled, the total bar from the planned node set, isolate ownership on instance change, popups retired before an isolate is freed, the Ask face answers on a button, the menu bar navigates by keyboard, no per-frame allocation in the canvas draw
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 21:39:05 +02:00
Admin
dbc7838094 route: the maps-folder field and its save button are gone from the settings panel; the maps root is automatic
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 21:31:09 +02:00
Admin
635c3a3063 map: ready tiles are inserted at most two per frame (byte budget kept), queued visible ring first then margin ring, the stale tile drawn until its replacement lands — a restyle burst of 4-9 refined tiles no longer stalls a frame for 70-120 ms
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 21:23:34 +02:00
Admin
aa271c36f6 map + route + geodata: the Terrain layer through the archive plane — TerrainSource (hosted .mkmap elevation shards fetched by range through the shared reader, a local MBTiles only as a dev override), the hillshade rendered on the pool's heavy lane with reused TerrainScratch buffers (web capped at 2048×1536 at DPR 1, native DPR-aware), one request in flight; the demo checkbox now renders terrain like native
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 21:20:28 +02:00
Admin
d641cbf995 map: labels no longer vanish — the gesture label budget is charged from the placement loop, not from candidate collection (4-7 ms on the web at z15-16, up to 6000 candidates in space warp), so a place can no longer commit an empty cache; a truncated pass is never a strict cache hit, arms its own settle wake, and the at-rest follow-up chain is capped at 4
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 21:17:47 +02:00
Admin
cd33943a7a web_server: radar and weather run on KNMI's documented anonymous key when no --knmi-key-file is given; without --live-cache the pollers keep an in-memory cache and say so once
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 21:10:11 +02:00
Admin
01e77d99c0 map: the shimmer clock is one pass-level uniform (draw_pass.shiny_time, a map-owned slot, never draw_pass.time) written once per heartbeat tick instead of patched into every retained draw call's block — the settle tail stops re-uploading 600 uniform blocks per tick
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 21:09:44 +02:00
Admin
b04333265e webgl: uniform blocks upload only when their generation moved — one global monotonic counter on Cx hands out every generation (draw call create/dirty/zbias, draw list allocate/transform/re-record, pass allocate/time/dpi/ortho/camera and the web flipped copy, shader scope writes), so a reused pool slot can never match a cached generation; clear_draw_items, pool reuse and VAO recreation reset the caches; the JS caches key on the generation alone. Proven on a local build: the pan screenshot keeps every tile, the settle tail drops 20.5 → 12.8 MiB/s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 20:59:55 +02:00
Admin
179124f085 Merge branch 'work' into webdemos 2026-09-03 20:46:27 +02:00
Admin
e2a37c97f7 flow-ui: a format picker with common image sizes and a swap beside width and height, the picture clipped inside the card under the selection outline, a face-declared control mounts as a labelled row on the node it sets with a hint when it binds elsewhere, the Fn face gets a strip for them
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 20:46:21 +02:00
Admin
e689aea8f5 web_server + geodata + route: live radar, wind and weather on makepad.nl/api — one bounded poller per feed, hourly, disk-backed cache served from an Arc snapshot (a restart never re-polls early), 503 warming until the first result, health reports ok/warming/unavailable with timestamps; KNMI key from --knmi-key-file, the documented anonymous open-data key otherwise; libs/geodata fetches through the platform HTTP client instead of shelling out to curl; the client retries 503 after 30 s and disables a layer only on 404
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 20:46:04 +02:00
Admin
a50750fda6 flow-ui: Flows, Running and Palette as their own rounded panels with splitters, the inspector and source pane likewise, columns resizable; gaussian frame shadows; keys and IME reach the focused face field through the canvas transform
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 20:42:45 +02:00
Admin
199be9eb1d map + route: the six overlays (EV chargers, transit, nature, districts, building age, population) read through the same archive plane as the base tiles — OverlaySource {name, TileSourceConfig}, hosted .mkmap archives on makepad.nl fetched by range through the shared archive reader and disk cache, a local .mbtiles only as a dev override; one layer table (apps/route/src/overlays.rs) for the native and demo builds, the demo checkboxes now set overlays like native
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 20:41:27 +02:00
Admin
e48b056df1 Revert "webgl: a uniform block is uploaded only when its generation moved — per draw call (uniforms_gen), per draw list (uniforms_gen, recording_gen), per pass (pass_uniforms_gen) and per shader scope block; the JS side caches (ptr, len, gen) per uniform buffer and re-records/recompiles reset it; direct camera writers (vj effects, render scene, the web flipped copy) bump the pass generation"
This reverts commit 0d7ddee3f7.
2026-09-03 20:37:35 +02:00
Admin
df9f4498b6 flow: the style picker lives on the node whose input it sets — the add_style Fn, not the image
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 20:35:25 +02:00
Admin
2dbd1b7dca flow + hub: a flow's LLM turn asks the node not to think and sends no token cap for zero, so a fleet Qwen node answers with a visible paragraph; the empty-state placeholder draws one icon
Verified on the LAN fleet: prompt → paragraph → picture in twenty seconds.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 20:34:02 +02:00
Admin
bb6d48fc6a Merge branch 'work' into webdemos 2026-09-03 20:29:04 +02:00
Admin
1f244282b1 map bake: a face-band triangle is a face only when all three records pack (a mixed triangle stays on the road path); a panicking pool job no longer takes the worker down — caught, reported once, the tile fails; the bake panic that blacked the native map and killed the web workers
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 20:29:04 +02:00
Admin
075e1a74d4 flow-ui: pickers filled from the hub for image and text nodes, popups anchored through the canvas transform, labelled face controls, add_style explains itself, cards resize from a grip with size: vec2 kept in the file, full-bleed pictures, a click anywhere on a card selects it and still reaches the face, no remount on layout-only edits, panels float over the canvas
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 20:26:13 +02:00
Admin
d852699d08 route: no tile-source dropdown — makepad.nl is the tile source through the local range cache; a world.mkmap in the saved maps folder is the only override; the stale preference file is removed on start
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 20:23:07 +02:00
Admin
7db06e8c09 Merge branch 'work' into webdemos 2026-09-03 20:22:57 +02:00
Admin
0d7ddee3f7 webgl: a uniform block is uploaded only when its generation moved — per draw call (uniforms_gen), per draw list (uniforms_gen, recording_gen), per pass (pass_uniforms_gen) and per shader scope block; the JS side caches (ptr, len, gen) per uniform buffer and re-records/recompiles reset it; direct camera writers (vj effects, render scene, the web flipped copy) bump the pass generation
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 20:22:56 +02:00
Admin
a8377920b7 hub + flow: a whitespace-only chat completion is retried once and then fails instead of passing as an answer; a flow's model is a fleet model id unless it names a weight file on disk; chat models show under the text domain in /v1/models
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 20:17:54 +02:00
Admin
fe23e067dd AGENTS.md: the execution policy — zero locking on the UI thread as one mechanism for native and wasm, no temporary threads (the pool), the standard operating flow (Codex codes, Fable designs and reviews, Grok tests), and the tweaker on Shift+F10
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 20:11:28 +02:00
Admin
7f2cc52e57 Merge branch 'work' into webdemos 2026-09-03 20:04:18 +02:00
Admin
bb49fe6989 map + platform: retained per-tile draw lists — each resident tile owns one DrawList2d per carto pass (fill, casing, stroke, icon, icon-high, shadow) and the label glyph batches are retained the same way, recorded when the bake, LOD ring, fringe/icon gates, flat/tilted or clip change and re-attached otherwise; a pan/zoom/tilt frame pushes this frame's uniforms onto the retained calls (DrawVars::update_uniforms_on_area, resolved slot table) and uploads zero instance bytes; the tilted per-pass depth is a pass_depth uniform; a held list's zbias resolves at entry (zbias_hold in every backend); the shimmer heartbeat patches shiny_time in place without a redraw; a freed/reused sub-list id is skipped by every draw-tree walker and the mask list re-records empty on the flat transition (contract test). Web pan tail 1,098 → 20 MiB/s, flat pan 2.4 → 0.43 MiB/frame; Metal grabs within the run-to-run noise floor
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 20:04:18 +02:00
Admin
64f6d41731 flow: pin the exact chat hand-off the Llm node makes (system, user turn, model) as a regression test
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 20:01:52 +02:00
Admin
662a1216e5 flow: the gen executor renews the hub lease every two seconds and says bye on shutdown, so fleet boxes stop reaping our jobs; flow-ui's bridge publishes run and instance events as messages on the bus, long runs return early with a subscription, and the AI gets templates, models and create tools
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 19:53:01 +02:00
Admin
a40ad74a63 flow-ui: the empty-state icon is the node's output type — a picture card waits with the picture icon, not the text glyph
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 19:47:41 +02:00
Admin
15c3b11da1 ai-services: what the pubsub review found — modules get a subscription seam and a publish sink through both WM paths, lease end flushes unsubscribes to hosted services, the subscription cap counts closing rows, an endpoint's queue is dropped after Unregister, the prompt drops a subscription on final
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 19:39:10 +02:00
Admin
4e8e4cd45f flow-ui: the design pass — menu bar and toolbar with the total run bar, continuous zoom through the draw-list view transform with pointer remapping, dark checker canvas with grid steps, shadowed cards with icon labels and port icons, glowing wires, per-node progress bars, full-bleed image cards, palette cards you drag out, the fab edit controls in the inspector, a template picker behind New, model pickers from the hub; MenuBar widget in the shared crate
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 19:38:21 +02:00
Admin
102ffc5fa5 ai-services: messages on the bus — a manifest declares topics, the engine subscribes on a tool's behalf or by ToolResult.subscribe, a service publishes Message frames, an idle conversation wakes on a message as an event turn under rate laws; the WM bus forwards the new frames; every app that matches the wire gets its arm
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 19:27:45 +02:00
Admin
14b194bed1 flow: the contact-sheet acceptance and an instance/run/value battery over real sockets — Http read, Fn, two images in parallel, an Ask that parks and resumes the run, a third image, an Http POST of the result
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 19:00:06 +02:00
Admin
7b10d61fc7 flow: the hub's models on the wire — GET /v1/models snapshots the discovered fleet (health + model list per node, refreshed on demand, fleet hints for tests) and /v1/nodes carries the model ids per domain
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 18:56:08 +02:00
Admin
f615054dab tweaker + webgl: click-to-climb continues only from the widget the climb started on, so a press on a sibling picks that sibling (the empty draw_bg was a bare View being pinned); the Shader tab says when a layer has no live draw call; the WebGL paint walk no longer resets every draw list's view_transform — the magnified material well drew at the window origin on the web
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 18:55:42 +02:00
Admin
de9aba3d83 vj + web: the Layer button works on the web — a second Window is a queried capability (OsType::is_single_window; the web creates none and reports it once), so the output becomes an in-page full-canvas layer with browser fullscreen (Esc, the browser's own fullscreen exit, or a double-click leave it); a pass without a draw list settles its dirty flag instead of erroring every frame; exitFullscreen fixed; fullscreenchange feeds the window geometry
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 18:53:02 +02:00
Admin
93e7dae15d Merge branch 'work' into webdemos 2026-09-03 18:38:26 +02:00
Admin
976ea0ed04 tweaker: Shift+F10 toggles it on every platform (KeyEvent::is_tweaker_toggle, one call site; the web page swallows exactly Shift+F10 so the browser never sees it); the exploded z-layer view has no keyboard shortcut any more — it is a button in the tweaker
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 18:38:26 +02:00
Admin
263d6f0ae8 flow-ui: the canvas — node frames with faces evaluated in an isolate per instance, typed port dots, bezier wires, drag-move and drag-connect, palette, inspector, source pane, App view, Running list — integrated with the chat bridge and wired to the run routes
Zoom is three discrete sizes with a pan-only camera: pointer hit-testing
ignores the draw-list view transform. Faces bind with bind := "node.port"
because typed widgets refuse unknown properties.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 18:34:29 +02:00
Admin
dd51a028aa vj: a go-to-start transport button per deck — seeks to 0:00 through the normal seek path, keeps the playing state, leaves the loop alone; skip_start.svg icon; decks test
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 18:31:18 +02:00
Admin
ec40fdb050 vj + widgets: double-click a knob or fader to reset it to its default — the Slider handles tap_count 2 and emits its normal Slide action; the deck controls carry their neutral defaults (pitch 0, gain/EQ/stems 1, filter centre, crossfader centre)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 18:27:49 +02:00
Admin
32bea89a92 vj: a playing slice keeps the big waveform of the whole sample — the active slot is an overlay on it (accent band, bracket edges, wrapped playhead, slot number) while the lane's viewport stays fixed; the grid cells are selectors only, their mini waveforms are gone
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 18:27:40 +02:00
Admin
8d542feea7 platform: a child pass orphaned by its attaching draw list is no longer painted — the window's gauss_scene pass stayed a live_with_parent child after the map went flat and re-encoded a frozen 400-item list every pan frame with stale geometry ids (new tile meshes × old instance counts, tens of millions of triangles into a texture nobody read); make_child_pass records the recording list + redraw id, stale passes are skipped, a cached View re-attaches on a cache hit; tests for the orphan gate and the pool generation contract
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 18:22:02 +02:00
Admin
ccf818313d Merge branch 'work' into webdemos 2026-09-03 18:20:07 +02:00
Admin
e3abf4dbbf map: the hosted-tile cache keeps the same ~2x-visible margin as the local one — a budget pinned at the visible set evicted the trailing edge of every pan on the next frame and refetched it a moment later
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 18:20:07 +02:00
Admin
09e4bf1e1e flow: what the batteries pinned — 3xx refused, a node object where a port reference is required is an error, undeclared Fn ports refuse edges, parameter errors point at the field, the omitted node is named, allowed HTTP methods listed, a 192 KiB source cap, an unterminated Flow{ is a parse error
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 18:15:39 +02:00
Admin
a2e0c43e4c flow: the old pipelines ship as templates — GET /v1/templates lists them with label, brief, inputs and outputs; POST /v1/flows/{name} {template} creates a definition from one
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 18:13:53 +02:00
Admin
e878c4dbb6 Merge branch 'work' into webdemos 2026-09-03 18:04:35 +02:00
Admin
0d3a9b84d6 map: nothing animates at rest — the water/foliage shimmer heartbeat runs only during interaction or a camera animation, plus a 1 s settle tail, then freezes at its last phase; flat and tilted views alike
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 18:04:35 +02:00
Admin
c36f1e78bc flow-ui: the AI service bridge — a flows service (authoring + what is running) and one service per definition on the aichat bus, per-turn context, tools answered off worker threads
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 18:04:14 +02:00
Admin
1980c24973 platform: a draw call whose geometry id went stale is skipped, not drawn with whatever mesh now sits in the reused slot — the runaway triangle count that took a web map pan to 1 fps; reported with a power-of-ten backoff, never per frame
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 18:01:27 +02:00
Admin
58f0700df3 flow: media inputs route by (domain, port) — inpaint takes image and mask as named inputs, control's image is the primary — and every gen node's declared port types are authoritative, including derived Image and Upscale; ocr template typed; empty legacy port arrays warn
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 17:58:36 +02:00
Admin
2a096e50c3 Merge branch 'work' into webdemos 2026-09-03 17:57:37 +02:00
Admin
5a9fa62eed vj: no timed std waits on wasm workers — blocking channels, one tagged deck+background inbox for the stems/lyrics workers, a lost-wakeup fix in the thumb queue, native-only gates on the video decode loops; the DJ web app stops losing a worker at start-up
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 17:57:37 +02:00
Admin
428d23e8ed flow: instances, runs and values on the wire — instance CRUD and inputs (answers a parked Ask), runs with cancel, values on the data plane with ETag/Range and PUT upload, run events fanned into the ring by topic, autostart, janitor, bounded shutdown; real seams by default with with_seams for tests
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 17:57:03 +02:00
Admin
098ba7737a flow: three hostile test batteries — the server routes over real sockets, the language, the engine — with the findings pinned as ignored tests naming the bug
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 17:54:25 +02:00
Admin
a8b0382dea map: the water/foliage shimmer read draw_pass.time, which flags every DrawMap* shader as animated and repaints the whole map at display rate forever — a Rust-stamped shiny_time uniform instead; the 20 Hz heartbeat drives it
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 17:52:44 +02:00
Admin
6691420e25 Merge branch 'work' into webdemos 2026-09-03 17:49:38 +02:00
Admin
2aa0780d0a webgl: BGRA uploads become RGBA at upload and sample_as_bgra is a plain sample on the web, as on every native backend — the tilt-shift's sharp band showed red/blue-swapped water
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 17:49:38 +02:00
Admin
b3da0c5712 flow: ports declare their types — ports: {in: {name: @type}, out: {…}} — so a second image port (mask, last_frame, reference) is as typed as the first; Inpaint, edit references, Music.lyrics, Paint.reference_image, Control.control restored; DREAM closes its loop again; the node catalog walks the prelude
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 17:39:41 +02:00
Admin
90c4f59357 asset-store: bump the static-export golden snapshot for the new rights fields
44b3a7f02 added artist/artist_url/album/source_url/license/license_url to
the exported manifest.json search block, which is part of the hashed
snapshot payload -- the contract changed on purpose, so the deterministic
golden in deterministic_golden_rewrites_graph_and_indexes_every_route was
stale (expected 78d089..., actual 7331ed62...). Verified the export is
still deterministic (same hash across repeated runs) and updated the
golden constant, with a failure message that names the printed `left`
value as the new golden so the next bump is a one-liner.
2026-09-03 17:37:46 +02:00
Admin
639887dda0 webgl: texture passes get their depth target — the tilted map (and every 3D scene drawn into a texture) was draw-order only: hollow buildings, no roofs, landmarks buried
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 17:32:55 +02:00
Admin
2b98a1f048 flow: the run engine — instances with an inputs table, one VM per run with live Fn closures, a dataflow scheduler, five executors (chat over the hub session, gen over the fleet, fn, http under an egress policy, ask that parks a run), content-addressed values with a RAM budget and spill
Real adapters by default (HubChat, FleetGen, HubHttp); seams for tests; the
gen path is proven end to end against the in-process testpattern hub service.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 17:01:53 +02:00
Admin
d74875327f wasm bridge: the page environment carries js_worker_wait so the module links — the pool landing added the import for workers only
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 17:01:52 +02:00
Admin
13f935911a flow: the server host (state thread, two planes over bounded_http, definition routes, events long-poll, lock/listen/token files, watcher) and the client (fail-closed connect, session, subscriber, embed policy); apps/flow-server bin and the flow-ui shell that hosts the server in-process
The two lanes were written blind against the same wire spec; the seams were
reconciled by hand: one FlowSummary, an optional graph on a definition, the
epoch-stamped string event cursor, and strict_json::parse_depth for bodies
that nest deeper than the default cap.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 16:55:59 +02:00
Admin
42213f95b1 Merge branch 'work' into webdemos 2026-09-03 16:41:35 +02:00
Admin
862f3bd298 asset widgets + chat ui + render: fan-outs and jobs on the runtime pool, the transcript read without a lock on draw
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 16:41:34 +02:00
Admin
2a902c646d flow: the recipe prelude loads behind the core one and every pipeline template evaluates, round-trips and is canonical — a test per template
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 16:41:34 +02:00
Admin
285bc9866f asset-ui: workers from the runtime pool, the video player owns its frames and audio state
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 16:38:48 +02:00
Admin
cb498c9c5c Merge branch 'work' into webdemos 2026-09-03 16:38:24 +02:00
Admin
9e2d65b50b pdf + photos + task + wm + video + automate + widgets: no per-job threads — pool jobs and start-up workers
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 16:38:24 +02:00
Admin
90dfcb630b flow: the language — splash prelude for nodes, evaluate a flow file into a graph with typed ports and located errors, tool schema from Input/Output, canonical writer
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 16:29:16 +02:00
Admin
72ab9b0f31 Merge branch 'work' into webdemos 2026-09-03 16:21:17 +02:00
Admin
697215e1d9 route + converse + example-map: every worker comes from the runtime pool or a start-up worker
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 16:21:17 +02:00
Admin
2d4037d141 flow: every asset-creator generation kind as a prelude prototype, and every pipeline as a flow template — inventory and gaps alongside
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 16:17:41 +02:00
Admin
b1a92b73c6 Merge branch 'work' into webdemos 2026-09-03 16:15:08 +02:00
Admin
6dcca0088b workspace: no timed std waits on web-reachable paths — the clippy gate covers every web demo's dependency set
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 16:15:08 +02:00
Admin
78a1681a49 bounded_http: the store's HTTP/1.1 server parser is its own crate — the store keeps only its JSON response shim; flow-server is the second consumer
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 16:13:00 +02:00
Admin
5184340565 platform + vj + map + files + widgets + image_tiles: the runtime owns one warm two-lane task pool — jobs never spawn threads
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 16:06:44 +02:00
Admin
c7639f0841 clippy: timed std waits (sleep, recv_timeout, wait_timeout, park_timeout) are disallowed — they read the std clock and panic on wasm workers
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 16:02:19 +02:00
Admin
dc9cce6c94 workspace: no std clock on the web in any crate the web apps link — the last start-up worker death is gone
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 15:33:31 +02:00
Admin
cb75d367d6 asset client + store: no std clock on the web — the catalog worker no longer dies at start-up
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 14:58:38 +02:00
Admin
35ac364eea vj: the catalog runtime pump no longer logs every poll
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 14:51:26 +02:00
Admin
71c0e1eedf vj: a paused deck is never moved by sync or the loop grid, a loop cell plays exactly its bars with the view held, and the stems banner clears
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 14:44:11 +02:00
Admin
4e61e44fca route + map: the desktop app streams the makepad.nl archive through a persistent on-disk range cache, chosen in settings
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 13:47:14 +02:00
Admin
707d7029e9 vj: every worker thread comes from the platform spawner — the loop ½ button no longer panics the web app
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 13:46:15 +02:00
Admin
acb8689ad7 vj: the loop grid is built and refined on the web too — one pool + channel path for both targets
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 13:33:39 +02:00
Admin
b0ec2a4d0a vj: the audio engine owns the mix state — the UI sends commands over a wait-free ring and reads snapshots, no lock on either thread
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 12:36:57 +02:00
Admin
569b4a7f7c dj-pack: every CC BY and CC BY-SA version and the public domain mark are redistributable licences
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 11:29:43 +02:00
Admin
44b3a7f02e dj-pack + vj: every track carries its artist, licence and source, and the DJ UI shows them
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 10:50:50 +02:00
Admin
83a8d4f69d fonts: the web demos start with the Latin set — CJK and emoji faces load on the first glyph that needs them
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 10:40:48 +02:00
531114e841 nigig: fix makepad-ai-hub ServiceHandle http_thread type (join network HttpServerHandle) 2026-09-03 10:55:00 +03:00
5ec5b65e8d nigig: carry workspace.dependencies into android wrapper manifest
The generated android wrapper re-creates a standalone workspace and only
forwarded [patch.*] sections from the workspace root manifest, so deps
declared via [workspace.dependencies] + workspace = true failed to inherit
in wrapped crate builds. Extract the [workspace.dependencies] section the
same way patches are handled and inject it into the wrapper manifest.
2026-09-03 10:55:00 +03:00
477421e314 nigig: NIGIG test-mode forwarding, custom manifest hook, ortho camera support
- makepad_test/runtime.rs: forward NIGIG_TEST_MODE from host env to the
  Android app via 'am start' intent extra; add wait_timeout (60s) used by
  wait_visible/wait_hidden/wait_count; make query_widgets tolerant of
  snapshot timeouts; grant READ_CONTACTS during adb setup
- makepad-platform android_jni.rs: read makepad.NIGIG_TEST_MODE intent
  extra and surface it as the NIGIG_TEST_MODE env var via apply_studio_env
- cargo_makepad compile.rs: support verbatim custom AndroidManifest.xml in
  addition to the templated variant
- makepad-xr xr_root.rs: add ortho camera controls (ortho, ortho_height,
  min/max), derive Debug on XrCamera
- docs: ANDROID.md and DESKTOP_VISIBLE.md for makepad_test
2026-09-03 10:55:00 +03:00
aeca2e551a makepad_test: grant runtime permissions after install to prevent dialog overlay
The GrantPermissionsActivity pops up during navigation and blocks the
app's event loop, preventing hub responses. Pre-grant all runtime
permissions after APK install to avoid this.
2026-09-03 10:55:00 +03:00
454d60897e makepad_test: force-stop interfering Robrix app during tests
PID 28203 (rs.robius.robrix) was the actual zombie reclaiming foreground
and killing our test app - not our own package. Force-stop both the
target package and known interfering Makepad apps (Robrix) during test
setup to prevent cross-app foreground competition.

Also remove the pm disable-user approach as it doesn't help against
a different package's zombie process.
2026-09-03 10:54:59 +03:00
5c99c410b1 makepad_test: use pm disable-user to prevent Samsung zombie resurrection
Samsung devices keep killed app processes alive and bring them back to
the foreground ~15s later, killing our fresh test instance. force-stop
and kill -9 don't prevent this. pm disable-user fully prevents the
zombie from being restarted. Re-enable before launching the new instance.
2026-09-03 10:54:59 +03:00
bc55ff94aa android: send BeforeStartup/AfterStartup over websocket
The Android platform never sent BeforeStartup or AfterStartup messages
via the studio websocket. Desktop platforms send these through their
stdin event loops, but Android uses websockets instead of stdin.

Without AfterStartup, the hub never broadcasts AppStarted to UI
clients, causing makepad-test to time out waiting for app startup.
2026-09-03 10:54:59 +03:00
dae59f1422 feat(test): extend protocol for touch, long-press, paste, IME composition
Add RemoteTouchState, RemoteTouchPoint, RemoteTouchUpdate, RemoteLongPress,
RemoteTextPaste, RemoteIMEComposition wire structs to StudioToApp enum.

Dispatch new events through cx_shared.rs (TouchUpdate→Event::TouchUpdate,
LongPress→MouseUp+MouseDown, TextPaste/IMEComposition→Event::TextInput).

TestApp: touch_down/move/up, long_press, paste_text, ime_composition.
Locator: touch_down/move/up, long_press, paste, ime_composition.
2026-09-03 10:54:59 +03:00
3e6c5fd57e feat(widgets): reexport optional Makepad sibling crates
Adds feature-gated optional deps and re-exports (makepad-test, makepad-csg,
makepad-gltf, makepad-mbtile-reader, makepad-fast-inflate) so a downstream
workspace can depend on makepad-widgets as its sole Makepad source.
2026-09-03 10:54:59 +03:00
4c94531456 feat(test): Android makepad_test via adb + in-process hub (legacy Java path)
Adds the Android test runtime to makepad_test: builds the APK with
cargo-makepad's standard Java path, installs and launches via adb with
makepad.STUDIO_* intent extras (incl. STUDIO_BUILD), connects the app to an
in-process hub over adb reverse, and waits for startup + responsiveness.
Adds clean in-process hub shutdown (HttpServerHandle + GatewayHandle Drop)
and the STUDIO_BUILD intent parsing on the app side. No native-activity or
NDK APK compilation code is included.
2026-09-03 10:54:58 +03:00
Admin
c3d9314f8b vj: each deck's transport sits on two larger rows under its mixer — every button visible on both decks
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 09:39:07 +02:00
Admin
daf88e10cb route demo: the tiles come from the repacked world archive at its own never-cached path
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 09:31:20 +02:00
Admin
645de42e52 vj: a deck plays and draws while it decodes, and the stems swap in sample-aligned — one streaming path for native and web
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 09:26:24 +02:00
Admin
a3248d1c51 web audio: the worklet gets the audio access pointer as its context — the thread-stack call gained a request id and the audio start was still passing the pointer in its place
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 08:56:04 +02:00
Admin
410acd0eaa web + vj + route: the console carries failures and one-line summaries, nothing per request, per tile or per hiccup
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 08:54:21 +02:00
Admin
58d3fd5d5d web audio: the output is created inside the first gesture, and a stalled worklet module load is retried on a fresh context
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 08:34:59 +02:00
Admin
2db9d4812c mkmap: a root record may decode to 512 MiB — the densest world shards list over five million tiles
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 05:20:46 +02:00
Admin
8ae7c3c396 map: every typed stream index stays u16 — streams chunk under 65,536 vertices
Measured first: the tilted start view carried 15.1 MiB of u16 and
11.6 MiB of u32 indices against 77.3 MiB of vertices, the u32 premium
5.7 MiB, all on the big tiles (fill up to 149 k and casing up to 98 k
vertices). Streams now split at bake time into triangle-safe chunks of
fewer than 65,536 vertices, one geometry per chunk (at most four on any
tile, 191 duplicated vertices in 10.9 M), so u32 leaves the typed
streams. Amsterdam start view: tilted 114.3 -> 108.6 MiB (fill 28.4 ->
24.7, casing 40.4 -> 38.4), flat 311.7 -> 287.7 (fill 83.9 -> 67.3,
fringe 57.1 -> 51.6); the harness prints vertex/index counts and widths
per stream. Same triangles in the same order; Metal hidden grab within
label noise of the head without it, web GPU gate clean (IDX1 lane).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 04:13:46 +02:00
7bddebb391 nigig: fix makepad-ai-hub ServiceHandle http_thread type (join network HttpServerHandle) 2026-09-03 03:29:46 +03:00
Admin
d6d324144e map: round road caps are a fragment SDF, dead cap rows gone
A round cap was a hard disc fan of up to 32 vertices per road end with no
anti-aliasing; a Butt/Square start pushed a feather pair it never
indexed. The cap is now carried by the last body pair extended by half a
width, with uv.y = 1 + cap_axis, and the fragment computes the circle's
coverage with the same derivative-based AA as the band edge. Amsterdam
start view: casing 42.5 -> 40.4 MiB (shape 100 17.1 -> 16.5, shape 110
15.2 -> 14.5), tilted total 116.4 -> 114.3 MiB, flat 313.9 -> 311.7.
Cap edges gain AA; Metal hidden grab vs the head without it 1.23 % /
0.27 % start (label placement in the crops), web GPU gate clean
(CAP1 lane).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 01:04:31 +02:00
Admin
662d141d19 map: building wall instances on a 20-byte record
A wall edge was eleven f32 (44 B): both ends, base, height, normal, AO,
colour, z-bias. It is now i16 ends, u16 centimetre heights (max error
8 um in the fixtures), f16 AO and z-bias ticks and a UNorm8x4 colour; the
normal is derived from the edge in the shader with the courtyard winding
preserved. Both the wall draw and the projected wall-shadow draw read the
new record. Amsterdam start view: wall_inst 19.3 -> 8.8 MiB, tilted total
127.0 -> 116.4 MiB, flat unchanged. Metal hidden grab within noise of the
head without it (0.99 % / 0.27 %); web GPU gate clean (WALL2 lane).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 01:04:31 +02:00
Admin
13c2500678 Merge branch 'work' into webdemos 2026-09-03 00:29:15 +02:00
Admin
66617cdd7a vj: stem separation is a setting — hub by default, local only on purpose, never auto-started on the operator's machine
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 00:29:15 +02:00
Admin
aa907cab9c platform: a frame trace on both desktop backends, and a window that cannot present stops ticking at 600 Hz
MAKEPAD_TRACE=frames prints, every two seconds per window, what woke
each tick (the waitable beat, a timeout, a message, a timer, a drain,
the display link), the NextFrame gap histogram, the present gap
histogram, the flip lead and refused presents — one bool check per
tick when off, readable over --remote. It was written to chase the DJ
beat wave's judder on Windows and found the measurable defect on the
way: when no window could present (minimised, hidden, or a disconnected
RDP session where the compositor refuses every present), the paint
tick slept a millisecond and re-ran, dispatching NextFrame and
redrawing at about 600 Hz for frames nothing would show. That case is
paced at 8 ms now, the same cadence as video pacing, the idle beat and
a hidden macOS window; a visible window that drops a frame keeps its
1 ms retry.

The judder itself could not be measured hidden — a disconnected RDP
session presents nothing at all — and the reading is that a connected
RDP session's bursty ~30 fps frame delivery is what the person saw;
the trace settles it in one visible run. platform 153 tests; both
desktop targets check.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 00:18:25 +02:00
Admin
97e0701ff8 vj + platform: the web audio thread never waits on a lock — a loaded deck plays
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 00:17:00 +02:00
Admin
0ef0311812 web audio: a throw inside the worklet's process() is reported with its real text instead of a bare ErrorEvent
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 00:10:10 +02:00
Admin
1e2dcd0da8 web: a pass without a draw list is skipped instead of taking the app down (F12 layers overlay)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 00:03:23 +02:00
Admin
b39d301ee2 web audio: the worklet links the whole module — every platform import the audio thread does not serve is stubbed, clocks and the UI wake are real
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 23:55:07 +02:00
Kevin Boos
493d23a763
Put the F10/F12/Shift+F12 dev overlays behind an opt-in (#1209)
* remote: honor MAKEPAD_REMOTE in requested(), and hush the close notices when the bridge is off

`requested()` only scanned argv, while `requested_bind()` also reads
MAKEPAD_REMOTE. So `MAKEPAD_REMOTE=1` started the bridge but everything
keyed off `requested()` still said no. Just delegate, so there's one
answer to "did this process ask for the remote bridge".

Also stop printing `[makepad-remote] user closed window ...` to stdout
from every app on every window close -- that line is for the agent
driving the app, so only print it when the bridge is actually up. The
log ring still gets it either way, for /log.

* devtools: put the F10/F12/Shift+F12 overlays behind an opt-in

Three dev tools are currently wired into every app with no way to turn
them off, each on a bare function key:

  F10        the exploded draw-list view. Intercepted in
             Cx::call_event_handler *before* the app's handler, and once
             it's up it also eats Escape, the arrows, +/-/0, I and H --
             no modifier needed -- plus every drag outside the flat band.
  F12        the design tweaker, a child of every Window. Once it's up it
             swallows every pointer event over the body, so the app looks
             frozen to the mouse.
  Shift+F12  the screen recorder, which starts writing mp4s to disk.

None of that is something a shipped app wants a user to find by accident,
and there was no flag, env var or property to stop it.

So: one gate, platform/src/devtools.rs. `--devtools`, or
MAKEPAD_DEVTOOLS=1, and --remote implies it since the /snap + /click loop
drives the tweaker. An explicit MAKEPAD_DEVTOOLS=0 wins over all of it,
which also keeps the off path testable under --remote.

Only the hotkeys are gated, not the tools. Cx::sploded_toggle,
set_tweak_on and ScreenCap::toggle are untouched and still public, so an
app that wants any of this puts it on a key of its own choosing -- that's
the app deciding, rather than a key nobody knew was bound.

Gating F10 and F12 is enough to reach all of it: everything else these
two claim sits behind `sploded.active` / `tweak_is_on()`, and with the
hotkeys gated the only remaining ways in are the /tweak routes (already
--remote, which implies devtools) and an app's own call.

* text_input: drop the Ctrl+Enter submit clause again

`|| mods.control` made Ctrl+Enter submit a multiline input. On
Linux/Windows that's already what is_primary() means, so it changed
nothing; on macOS it turned Ctrl+Enter from "insert a newline" into
"send", which is a surprise in the middle of a chat app's composer.

The comment right above it already described the old behavior, so this
puts the code back in line with it.
2026-09-02 23:41:39 +02:00
Admin
fef80585cb map: marker stalks and stoplights are instances
Every floating POI or charger marker in 3D mode drew two crossed wall
quads on the 48-byte generic layout, and every signal a pole plus three
light quads; together they were the whole 17.1 MiB fill_3d_misc stream.
They are now one 24-byte instance each (anchor, arm/height, colour,
z-bias) over one shared stalk template and one shared stoplight template
per tile, drawn by a prop draw struct on the same lighting path.
Amsterdam start view: fill_3d_misc 17.1 -> 0.0 MiB, stalk_inst 0.1 MiB
(4,887), stoplight_inst 0.2 MiB (1,866); tilted total 143.7 -> 127.0 MiB,
flat unchanged (313.9). Metal hidden grab within the label-noise band of
the head without it (1.78 % / 0.42 % start); web GPU gate clean
(STALK1 lane).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 23:39:20 +02:00
Admin
7a5bf69272 ai-hub registry: the Salamander drumkit samples come from the makepad.nl mirror — the GitHub repo only carries the .sfz files
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 23:37:48 +02:00
Admin
fad1c49827 web server: audio and text files are served, and models/ is immutable like maps/
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 23:37:33 +02:00
Admin
dc85eb0ed3 web + vj: render-to-texture passes keep their 3D camera on WebGL, thumbnails wait for shader compile, bundled tiles re-ask — effect thumbnails match native
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 23:36:53 +02:00
Admin
0fdfba76a3 files: the web's makepad home is a fixed virtual path — std's temp_dir panics there
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 23:26:01 +02:00
Admin
eec516ee06 cef: a Windows backend — the browser's page renders on Windows
The crate had a macOS backend and a stub for every other OS whose
Browser::new refused, so on Windows the app drew its chrome and the
page stayed black. The macOS file becomes native.rs with its islands
gated (the Metal blit, the IOSurface target, the app bundle and
re-exec) and Windows islands beside them: libcef.dll loaded with the
dist's Release dir on the search path, the process handle as the main
args, the exe as its own subprocess, the resources hard-linked flat
beside libcef where it looks for them (the dist keeps icudtl and the
paks in Resources, which crashes libcef), the app's own timer as the
pump, software paint into our texture, and a per-process profile when
a sibling browser holds the profile lock (the unix singleton check
does not exist there). The API hash is pinned to 13800, the layout the
bindings were written for — asking each dist for its newest misaligned
the browser settings struct on 144 and 151. Linux keeps the stub.

Verified on the Windows box with CEF 151: google.com renders in a
standalone browser and in a WM tile (a warm instance adopted in 88 ms,
the next standby up with its own profile); on this Mac with CEF 138 the
accelerated path still renders. cef 4 tests; the browser checks for
windows-msvc. Windows paints in software for now; the D3D11 shared
texture is the next lane.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 23:04:01 +02:00
Admin
bc87a3d359 Merge branch 'work' into webdemos 2026-09-02 22:48:59 +02:00
Admin
4f98c73be2 vj: a deck shows what it is loading — fetch, decode, stems — with real progress
vj: a deck shows what it is loading — fetch, decode, stems — with real progress

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 22:48:58 +02:00
Admin
a2cbe38330 map, platform: the tile budgets follow the platform's one memory number
Cx::memory_budget_bytes() is set once at startup from the device
(ProcessInfo on macOS/iOS, ActivityManager on Android, GlobalMemoryStatusEx
on Windows, /proc/meminfo on Linux, the wasm maximum on the web) through
one policy: desktop keeps 1536 MiB unless RAM is under 8 GiB (then RAM/4),
mobile takes RAM/4 clamped to 384..1536 MiB. The map's four byte budgets
are fractions of it (upload 1/64, pending 1/4, tile cache 25/32, HTTP
cache 5/32; the tile cache caps at 1/2 on web heaps under 1 GiB) instead
of constants. At 1536 MiB they are the binary sizes 24/384/1200/240 MiB,
about 5 % above the old decimal constants. Harness totals unchanged; Metal
grab and web GPU gate within noise (BUDGET1 lane).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 22:48:11 +02:00
Admin
7a2c09a873 map: road-union faces on a 16-byte vertex
Shape-0 Boolean union faces (plazas, road bodies, tunnel and bridge
faces) leave the 28-byte road vertex for FaceVertexTyped: i16 anchor,
UNorm8x4 colour, F16x2 class/material + depth tick, F16x2 deck lift +
coverage. The road shader draws them through a second vertex format on
the same pass; face streams use u16 indices. Amsterdam start view:
casing 73.6 -> 58.7 MiB (42.5 casing + 16.2 face), tilted total
158.6 -> 143.7 MiB, flat 328.8 -> 313.9, 433 ms/tile. Metal hidden grab
within the noise floor of the head without it (1.26 % / 0.23 %, label
placement); web GPU gate clean. Faces now draw as one batch per tile
before the stroke bands, so a translucent shadowed face over a crossing
casing blends ground instead of stroke at that pixel — not visible in
the crops (FACE1 lane).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 22:48:11 +02:00
Admin
cc1f25622a vj: cached thumbnails appear within a second
vj: cached thumbnails appear within a second and the pipeline never hangs

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 22:47:39 +02:00
Admin
afdffd71b9 vj: clicking an effect tile puts it in a channel on the web
vj: clicking an effect tile puts it in a channel on the web

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 22:43:45 +02:00
Admin
7b33f7bec6 vj + platform: a loaded deck actually plays on the web
vj + platform: a loaded deck actually plays on the web

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 22:43:14 +02:00
Admin
3b1a8c264f platform: an in-app drag works without an OS drag session (web, Linux) — effect tiles drop into the channels on the web
platform: an in-app drag works without an OS drag session, so effect tiles drop into the channels on the web

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 22:41:46 +02:00
Admin
e7be0e31dc vj + platform: the browser UI thread never waits on a lock, and the web hot paths log only errors and summaries
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 22:13:26 +02:00
Admin
362ac59407 wm: the checkout is found from the working directory too, and a sibling binary is an .exe on Windows
A wm started with cargo run from the repo root but with its target dir
elsewhere (CARGO_TARGET_DIR) found no checkout above its exe and fell
back to sibling binaries — which never exist as bare names on Windows —
so the launcher listed only the linked modules. The checkout is now
looked for above the exe and then above the current directory, and a
sibling binary carries the .exe extension on Windows. Every app is one
cargo run away again.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 22:12:10 +02:00
Admin
d8539d89e1 map: the detail parse and merge stop allocating
Per-layer tag arenas and views replace a HashMap per feature, the tag
whitelist is applied while parsing, and the worker's output scratch
buffers are reused. Release, Amsterdam: mvt-parse 377,074 -> 3,071 and
detail-merge 2,713,553 -> 19,817 allocations (135x fewer); parse median
5.9 -> 1.8 ms, merge 63.6 -> 9.7 ms; bake 429 -> 344 ms/tile, totals
unchanged (158.6 MiB tilted). Buffers, labels and icons byte-identical
across the 25 fixtures; Metal grab within the noise floor (0.9 % / 0.1 %)
of the head without it; web GPU gate clean (ALLOC1 lane).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 22:02:33 +02:00
Admin
463de64b33 web: shaders compile per draw list, link in parallel
The first WebGL frame compiled every registered program synchronously;
on ANGLE-Metal that is 8.6 s of from_wasm dispatch for the DJ app (35 ms
of wasm), during which no timer, fetch completion or animation frame can
run. SwiftShader hid it by compiling at first draw. Programs are now
queued only when a draw list references them and linked with
KHR_parallel_shader_compile; the first draw waits only for the programs
it needs. DJ app on the GPU: localhost fetches 8.7 s -> 0.2 s, 39
programs ready in 300 ms; route first draw at 224 ms (STARTUP1 lane).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 22:02:33 +02:00
Admin
9b4a3cac3e network: every completion raises the UI signal
A runtime the platform did not create (the asset client builds its own)
has no wake fn, so on the web its responses sat until an unrelated timer
pumped the event loop. EventSink::emit now sets the UI signal as well;
the DJ app polls its store session on Event::Signal instead of waiting
for the next poll tick.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 22:02:33 +02:00
Admin
7e66991246 map: finished bakes reach the screen on the next frame
The worker drain runs once per frame and uploads until a 6 ms / 24 MB
budget is spent (nearest tiles first). Its continuation was a redraw,
and a Draw never re-enters handle_event, so the rest of the queue waited
for an unrelated timer (2-16 s in the web demo). When tiles remain the
drain now also asks for a next frame; that event arrives after the pass
and drains again. Real GPU, no probe, no input: all 25 start-view tiles
inserted between 2 and 4 s at 60 fps (DRAIN1 lane + continuation fix).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 22:02:33 +02:00
Admin
62f38a5b33 route: --hour=N pins the theme hour for harness grabs
The route theme follows the wall clock (night from 19:00), so a grab taken
in the evening compares dark against a daytime baseline. The flag is a
harness override only; without it the clock still rules.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 22:02:33 +02:00
Admin
30fc13bba0 map-tiles: repack runs on all cores, resumes per shard, reports --status
Mac-side only: no in-place rewrite, no push, no fallback source. Shards are
written atomically and skipped on resume; root.mkidx lands once at the end.
Ranged --verify streams the output back. 16 jobs: 4.2 tiles/s on the
500-tile sample (REPACK2b lane).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 22:02:33 +02:00
Admin
cb572aee0f map-tiles: makepad-map-repack rewrites an archive to what the renderer reads
A new binary in tools/map_tiles streams an .mkmap shard by shard into a
new archive: every tile's protobuf is decoded, the osm_* tag tables are
rewritten to the reader's key whitelist, the field-101 shadow sections are
stubbed (regions and building groups stay byte-identical), field 100 is
kept, and the tile is re-brotli'd with the archive's own codec and
dictionary; leaf directories and root.mkidx are rebuilt, output is
deterministic and resumable per shard with a sidecar manifest, --tiles
limits a run to a Hilbert range or a z/x/y list, --dry-run reports and
--verify decodes both archives and checks the policy. On the 25 Amsterdam
start-view tiles: 95.1 -> 47.6 MB decoded, 30.6 -> 16.7 MB compressed.
A label sort tie-break makes the bake byte-deterministic for the parity
test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 22:02:33 +02:00
Admin
f987651778 map: the analytic road fringe is baked only when the view is flat
The fringe stream (AA skirts around the road-union faces) is drawn only
below 25 degrees of tilt, yet every tile baked it: 57 MiB of the Amsterdam
start view that the tilted demo never drew. The bake now takes a fringe
flag the way it takes the 3D flag: wanted while the tilt is under 30
degrees, dropped only above 40, decided in the frame loop next to the
2D/3D flag and re-baked through the same restyle path with a full road
core rebuild; the harness prints both the tilted and the flat totals.

Harness: 215.5 -> 158.6 MiB for the 25 start-view tiles tilted (fringe 0),
328.8 MiB flat (fringe 188.3), 429 ms/tile.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 22:02:33 +02:00
Admin
5a5eda2979 map: road, fill and roof streams on the typed vertex formats, u16 indices
Roads move from eight f32 lanes to a 28-byte RoadVertexTyped (i16 anchor
at 1/64 tile unit, f16 offset / depth / uv pairs, unorm8 colour, f16
params, exact f32 deck), ground fills and roofs to 16 bytes (i16 anchor,
unorm8 colour, f16 params; u16 depth ticks for fills, exact f32 height for
roofs); indices are u16 whenever a stream has fewer than 65,536 vertices.
The three shaders read the typed POD fields directly (the fetch converts),
uploads go through Geometry::update_typed with the shader's layout, the
space-warp subdivision decodes / interpolates / re-encodes the typed
records, and a tile that lands before its shader has drawn waits in the
pending queue instead of panicking. The shader compiler types a packed
field access as vec2f / vec4f, and the Metal name table gains the packed
short / ushort / uchar / char vector names.

Harness: 249.6 -> 215.5 MiB for the 25 start-view tiles, 384 ms/tile.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 22:02:33 +02:00
Admin
5b3378164c platform: thread-caching allocator for the threaded wasm build
std's wasm allocator is dlmalloc behind one global spin lock taken by every
thread on every allocation, so eight workers baking map tiles serialised on
it: a tile that bakes in 0.4 s natively took 15-20 s (mvt-parse 0.7-4.5 s,
detail-merge 2.3-12.5 s), and the whole start view needed 150 s. This
installs a #[global_allocator] for wasm32+atomics: per-thread size-class
free lists (16 B .. 32 KiB) carved from 64 KiB chunks of the dlmalloc heap,
remote frees pushed onto per-chunk Treiber stacks and drained by the owning
thread, exit-safe reclamation, the main thread's caches pre-filled at
startup so its hot path never touches the global lock.

Measured on the same capture with 8 workers: mvt-parse 7-68 ms, detail-merge
27-415 ms per tile (native 4-17 / 23-70), zero traps, zero Atomics.wait,
the full start view. The two-worker stop-gap in worker_count goes with it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 22:02:33 +02:00
Admin
55a38104aa platform: typed compact vertex formats and u16 indices
Draw shaders can declare geometry POD fields as F16x2/F16x4, U16x2/I16x2
(plain or normalised) and UNorm8x4/SNorm8x4; the fetch converts them to
vec2f/vec4f on Metal and WebGL2 (per-attribute vertexAttribPointer with
type, normalised flag, byte stride and offset), the headless JIT decodes
the same formats, and Vulkan/OpenGL/D3D11 refuse compact layouts with a
logged skip rather than drawing garbage. Attribute packing computes
physical offsets with natural alignment for the whole record; the all-F32
path stays byte-identical (stride = slots * 4, packed_geometry_N on GL).

Geometry::update_typed / update_typed_with_recycled_buffers take
IndexData::{U16,U32} plus a byte vector and the shader's input layout;
updates are validated (stride, whole vertices, index range) and every
geometry carries its index width and a layout signature that survive
releasing the CPU staging, so a draw checks layout and index type against
the resident buffers, never the staging enum. Compact fields are rejected
in instance PODs and in nested aggregates at shader validation; SNORM
minima clamp to -1 like WebGL2.

On Metal the decoded (logical) vertex is reached through the shader
context like the instance is, so every shader function that reads
geometry compiles.

No map stream is converted yet; that is the next cut.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 22:02:33 +02:00
Admin
99d2180aad map: roofs on a 20-byte vertex, contact shadows as instances
Lifted shape-0 roofs leave the 48-byte universal vertex for a five-slot
RoofVertexPacked (anchor, unorm8 colour, exact metre height, f16
material | zbias ticks); DrawMapRoof reconstructs the fixed vector
channels and shares the colour pass, the fade, the LOD band and the
shadow-mask projection / footprint cut-out. Roof records that need more
(parapet AO depth variants, marker stalks) stay on the generic layout in
a fill_3d_misc stream. The tree and signal contact shadows are no longer
tessellated ten-segment discs in a vertex stream but four floats per disc
drawn as an instanced unit quad into the shadow mask, with the same
radial ramp.

Harness: 281.5 -> 249.6 MiB for the 25 start-view tiles, 387 ms/tile.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 22:02:33 +02:00
Admin
5b49fee084 platform, map: the UI thread never futex-waits on wasm
std's wasm allocator spins, so the Atomics.wait that killed one WebGL2
run in four came from std Mutex / Once contended between the browser main
thread and a tile worker. Every lock the UI thread shares with a worker
(task pool state, cancellation generation, task results, the scheduler,
the web request table, the archive read states and shard file cache) now
goes through lock_from_ui, which spins on try_lock on wasm32 and is a
plain lock elsewhere. The icon mesh cache, icon slot table and tag-key
whitelist are warmed on the UI thread before the tile pool starts so no
Once can wait either.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 22:02:33 +02:00
Admin
9d664ca967 map: roads on a 32-byte vertex
Casing, stroke and fringe records leave the 48-byte universal vertex for
an 8-slot road layout: full-precision anchor and deck height, f16 offset /
depth / tessellator uv pairs, unorm8 colour, and one f16 integer that
packs class, material, dash id, kind (stroke / union face / fringe) and an
explicit expanded flag. DrawMapRoad reproduces DrawMapVector's expansion,
terrain, tilt, space-warp and shadow-mask behaviour for those streams;
casing/stroke draw through it in the colour passes, the fade cross-fade
and the shadow-mask projection, and the space-warp subdivision has a
road-stride midpoint.

Harness: 339.0 -> 281.5 MiB for the 25 start-view tiles, 437 ms/tile.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 22:02:33 +02:00
Admin
ef25495540 map: building shadows derived at draw time in a screen-space mask pass
The bake no longer unions every building's projected roof and silhouette
into a material-6 ground fill per tile (122.5 MiB of the Amsterdam start
view, plus the b-shadow bake laps). MapView renders one child pass per
frame: wall records swept along the sun as instanced quads, roof/deck
projections of the lifted geometry, footprint cut-outs, and the tree/signal
contact discs (split from the icon stream); every ground shader samples the
mask at its screen position with the same alpha the decals used.

Metal returns the pass texture bottom-up, so the sample is flipped per OS
(WebGL2 flips its render-to-texture projection instead). The draws inside
the mask pass unbind the mask sampler (feedback loop). The compact fill
shader sets the shadow varyings so land and water are shadowed too.

Harness: 475.5 -> 339.0 MiB for the 25 start-view tiles, 411 ms/tile.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 22:02:33 +02:00
Admin
02b58fc931 map: ground fills on a 20-byte vertex — position, colour, variant + AA coverage, depth ticks; DrawMapFill is the fill path of the map shader
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 22:02:33 +02:00
Admin
57995b6c92 map: uploaded staging is freed on a pool worker — a large free on the UI thread of the threaded web build contends the allocator lock, and a contended lock there is an Atomics.wait the main thread may not make
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 22:02:33 +02:00
Admin
9047600062 map: the bake report can dump the raw and decoded tiles it measured
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 22:02:33 +02:00
Admin
a6f495dfd3 map: street trees are instances of one template per tile; the CPU staging of every uploaded stream is dropped; the memory diet keeps its platform half only
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 22:02:33 +02:00
Admin
72b60b6d0c map: the memory diet folded in — CPU staging freed after upload, budgets from one platform number, bakes capped by bytes in flight, per-zoom bake profiles, a memory report; instance records count as retained CPU
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 22:02:33 +02:00
Admin
2d4ff16b93 map: POI symbols and building walls are instances — one shared mesh per symbol slot, one record per footprint edge, extruded and placed in the vertex shader
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 22:02:33 +02:00
Admin
383b83e783 map: the Amsterdam bake report — the real archive through the real bake path, bytes per stream and milliseconds per tile
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 22:02:33 +02:00
Admin
c2c7f51400 platform: a hosted window on Windows draws again — the depth buffer matches the shared target's allocation
Under the window manager a child renders into a shared texture the
host allocates at the next power of two, while the Window pass carried
a depth texture sized to the pass rect; D3D11 refuses render targets
whose colour and depth views differ in size, so the clear landed (it
goes to the view directly) and every draw was dropped — each hosted
tile on Windows showed only its clear colour, the terminal's blank
rectangle among them. The pass setup now sizes the depth buffer from
the target's allocation when it draws to a texture, from the pass rect
otherwise (a pure helper with tests). The hidden-windows switch is
honoured by the Windows backend on the first show, so a test run stays
off the desktop.

Verified on the box: a hosted terminal's first frame 720 ms after
launch with the prompt in it, a typed dir listed. platform 132 tests;
the windows-msvc target checks.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 22:01:54 +02:00
Admin
21fce80db6 vj: stale-recipe thumbnails are swept from storage on startup
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 21:57:24 +02:00
Admin
5c78ac04a3 vj: web effect thumbnails show the effect, not a black frame
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 21:48:05 +02:00
Admin
c527cd8e88 vj: the web thumbnail pipeline never blocks the main thread
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 21:27:34 +02:00
Admin
6fddf9e285 vj: loading a track onto a deck fetches and decodes its audio on the web
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 21:25:46 +02:00
Admin
0b5bff02cc Merge branch 'work' into webdemos 2026-09-02 21:11:47 +02:00
Admin
f35dd1dd8c vj: effect thumbnails actually render and persist on the web
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 21:11:47 +02:00
Admin
ff6cfec0fc wm: minimise, maximise and close in the bar's top right on Windows and Linux
The omarchy bar is the caption strip and the frame is fully
client-sized there, so a WM window had no window controls at all. The
bar's right cluster now ends in three of our SVG buttons after the
status modules — minimise, maximise (restore once maximised), close —
wired to the window's own minimize, maximize, restore and close;
hover washes the slot, the close carries the accent, tooltips name
them, and the drag query answers Client over them. macOS keeps its
traffic lights on the left. The gallery shows them on every platform.
wm 155; the WM checks for windows-msvc, linux-gnu and wasm32. A real
click on Windows is the box's to prove.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 21:11:05 +02:00
Admin
5c4076168c cef: the build script downloads its CEF distribution itself, on every host, with no shell
The browser could not build from a clean clone on Windows: the fetch
was a bash script needing python3 and tar. It is Rust in build.rs now:
the spotify index is read by a strict scanner, the newest stable
standard build for the platform is picked (or MAKEPAD_CEF_VERSION pins
one), the archive is downloaded to a .part file with its size and sha1
checked, extracted beside it, and a current-<platform>.txt pointer file
names the dist — read before the old symlink, so the Mac keeps its
pinned 138 while a fresh box gets the current build; a platform that
already has a dist is never bumped. MAKEPAD_CEF_OFFLINE refuses with the
dir and pointer named; MAKEPAD_CEF_DRY_RUN prints the plan (with
MAKEPAD_CEF_PLATFORM to resolve another host's). Build-time deps only:
ureq (rustls), bzip2 (bundled), tar, sha1_smol. The shell script is gone.

Proven on this Mac: a pinned re-download of the 138 macosarm64 archive
(255 MB, sha1 verified) differs in nothing from the existing dist over
1,193 files; dry runs for linux64 and windows64 pick the archives already
on disk; the browser checks with no download; the cef crate's build deps
compile for the windows-msvc and linux-gnu hosts. Picker and pointer
tests 5. An actual download on Windows or Linux is not yet run.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 20:56:48 +02:00
Admin
1dcca41ec4 wm: a theme with no wallpapers fetches them on its own
The bundled default theme ships its colours, not its pictures, so a
fresh install had nothing behind the desk until someone ran the
importer. The desk now fetches the current theme's wallpapers from the
omarchy repo on a thread when its backgrounds folder is empty and shows
the first one the moment it lands; the importer shares the fetch. No
network or no pictures means nothing changes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 20:49:58 +02:00
Admin
453197da4d dj-pack: analyse produces the beat grid, overview and loop-splat caches the demo cache ships
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 20:32:01 +02:00
Admin
c1f7b53dca asset-client + dj-pack: a long description never rejects a snapshot; the packer writes one bounded line
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 20:26:56 +02:00
Admin
ffd62da242 dj-pack + vj: the demo cache carries every per-track analysis — slices, beat grid, overviews — and the web app reads instead of computes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 20:22:09 +02:00
Admin
c7fe851c2e vj: effect thumbnails render, encode and persist in browser storage on the web
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 20:15:47 +02:00
Admin
a9b2185759 Merge branch 'work' into webdemos
# Conflicts:
#	apps/vj/src/main.rs
2026-09-02 20:06:45 +02:00
Admin
8625076661 aichat + photos + image_tiles: make me a picture and it lands on the wall; the wall filters as you type, tiles flying to their places
gen.image{prompt} is a service inside the assistant: on a worker it
runs the creator pipeline for the image domain against the node the
runner picks (LAN discovery, ETA-ranked, the resident flux model wins),
streams the node's progress into the tool card, and writes the picture
under the makepad home's gen/ dir; the creator library gains
generate_bytes — the request, poll and fetch that generate_and_publish
now shares — so no second pipeline exists. photos.add{path} bakes that
one file into the open library (only from the gen dir or the person's
home), re-opens the wall and glides onto the new picture; the module
executor answers it later through the host's reply sink. The model
chains them, launching photos in between. A registry seam found on the
way: a call dispatched in the same event a link was adopted reached the
port before its Registered, so the registry now answers a leading
Register at once.

The wall gained a search box: every keystroke re-cuts the packing over
the matches (every word in a title or link; title beats link, a whole
word beats a substring), each picture flies from its drawn rect to its
target over 450 ms, dropped pictures shrink and fade, returning ones
grow back; Esc clears; ⌘F or a bare slash focuses the box; the status
reads "35 of 293 · ro". photos.filter{query} sets the same from the
pane; instances are rebuilt only while a flight runs, and the clock is
the platform's.

Proven on the fleet: "make me a picture of a red bicycle on the moon
and put it in photos" → the file in 40 s, photos launched, added and
shown among the comics; typing r, ro, rob re-packs the wall live; the
filter through the pane in a process tile and as a module. ai-services
39, aichat 5, photos 9, image_tiles 12, creator 9; aichat and photos
check for wasm32.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 20:03:54 +02:00
Admin
53f0e965f8 vj: the DJ/VJ app exposes itself to the desktop assistant
A service over the engine the UI already drives: status (what is live
and next, the fader, overlay, autopilot, bpm), search over the catalog,
cue (the UI's content-click path), fader, next, autopilot with a style,
overlay, and the decks — deck_play, deck_stop, deck_load, crossfade —
through the same functions the console's controls call. A context line
follows the live item, fader and autopilot. Written by the delegate from
the sheets/files shapes; vj's ai tests 4, the binary suite 821 (the two
media decode-pool timing tests fail on this Mac before and after).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 20:03:54 +02:00
Admin
40e483f1ff vj: the web music browse actually fires — proven on the live path
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 20:02:06 +02:00
Admin
93232a2b2e platform: two pool workers on wasm until the allocator is per-thread
std's wasm allocator is one global spin lock, so workers that allocate
serialise on it and per-job throughput collapses with the worker count
while the browser main thread starves behind them. Measured on the map
bake in the route web demo: a tile takes 15-20 s with 8 workers and
0.1-0.5 s with 2; the start view completes in 80 s instead of 150 s.
Every web app's pools get the cap through worker_count. It goes when the
thread-caching allocator (ALLOC2) lands.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 59096700d56cd8f3f15724e24129c0b428bf6ae7)
2026-09-02 19:36:48 +02:00
Admin
64d2d3acee map-build: an unfinished bake resumes or restarts itself, and the maps root does not depend on the cwd
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 19:25:33 +02:00
Admin
cc8ee5c9f5 vj: the web music browse fires when the store is ready, and startup never stalls the UI thread
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 19:14:21 +02:00
Admin
04a36ded31 map-build: the bake produces only what the renderer reads; a bad tile is skipped and logged
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 19:05:11 +02:00
Admin
017dc69faf Merge branch 'work' into webdemos 2026-09-02 18:52:08 +02:00
Admin
78bfd5c27c vj: the web explorer shows the store's tracks — the listing reaches the rows
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 18:52:08 +02:00
Admin
3691875775 wm: launcher icons — a photo for Photos, a shirt for Fabric, a globe for Route, a play badge for Video, a pulse for the task manager
Five 16 px icons in the set's own style (our SVGs, stroke 1.2,
currentColor), wired into the shell's icon set and the launcher map.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 18:47:34 +02:00
Admin
0950ed7b4e Merge branch 'work' into webdemos 2026-09-02 18:46:52 +02:00
Admin
4b529a1203 map: tile ranges fetch once, centre-out, and never time out while queued
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 18:46:52 +02:00
Admin
37686534b0 image_tiles: the wall is box-packed — justified rows keep every picture's own shape and fill the width
A pure packer (pack.rs) lays pictures in rank order into rows of one
target height and scales each full row to span the wall exactly, so
tall strips sit beside wide panels with no gaps and no overlap; the
last row keeps the target height instead of stretching two pictures
into giants; a 4 % gutter is split per cell. TileGrid gains the packed
wall as its default (packed: false gives the old uniform grid), frames
its camera on the packing's bounds, hit-tests through the row
partition, and picks each atlas page's level of detail from the
largest visible tile in it rather than one unit cell. photos needs no
change. image_tiles 10 (5 new), photos 7; the photos lib checks for
wasm32. Driven hidden: the 293 comics in justified rows, zoom, pan, a
click on a comic naming it, and photos.show gliding onto one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 18:38:48 +02:00
Admin
95fd7d6c82 platform: a hosted window with a dpi override lays out in its own points and gets the host's pointer remapped
A stdin-loop child stored the host's WindowGeomChange raw and never
recorded the host dpi as the window's native dpi, while the native
path converts every OS geometry through the app's dpi_override and
remaps every pointer. VJ is the app that sets an override (its console
scale shrinks the dpi to fit a tile's height), so under the window
manager it laid out in larger points than the pointer it was handed —
every click landed short — and each host resize re-set the raw host
size under the override, so consoles of the wrong physical size were
drawn into the shared framebuffer over the previous full one: the
stacked stale consoles the user saw. Terminals never showed it; they
set no override.

The stdin arms of every platform now apply a host geometry the way the
native path does (the host dpi recorded, the size converted through
the override) and remap pointer positions with the override scale;
window hit-tests compare in native points. No host-side change: the
crop is per presented frame and the swapchain handoff only bridges a
resize.

Proven hidden under the WM: the explorer tab answers a click, typing
filters the music list, a split and its close re-lay the console out
with no ghosts. platform 128 tests; Linux and Windows targets check.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 18:37:49 +02:00
Admin
0e5e007f03 Merge branch 'work' into webdemos 2026-09-02 18:30:27 +02:00
Admin
5fdd5d5f47 vj: on the web the explorer lists the site store's music and a deck loads it with its stems
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 18:30:27 +02:00
Admin
190062e4a1 route: the maps app exposes its tool table to the desktop assistant
The same tools route's own assistant has — plan, add and remove stops,
status, along-the-way search, map fly-to and trip framing, layers,
theme, markers, geocoding, weather, trip history, navigation — go on
the AI bus from one table (the definitions local_agent already maps),
dispatched through the one execute_tool; reads are Read, anything that
moves the map or changes the trip is Act. The context line carries the
map view and the trip; the desktop pane opening closes route's own
assistant panel. Written by the delegate from the sheets shape; route
25 tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 18:29:43 +02:00
Admin
a772ec8f40 terminal + browser: a warm-pool standby joins the AI bus only once adopted
Dormant instances registered their services, so the assistant could
type into a shell or steer a page nobody could see. The port opens at
startup for a real launch and on WmEvent::Adopted for a standby, the
way files already did.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 18:25:40 +02:00
Admin
432121ebe5 aichat engine + wm: launch, then use — the assistant continues in the same turn once the app it started is on the bus
An ok os.launch result is held while the launched app is not on the
bus and delivered when it registers (within 8 s), rewritten as "<App>
is running now. Its tools: … Call them directly." with the tool table
rebound mid-turn where the model can (the local model prepends the
update to the tool result; Claude keeps its native table until the next
turn and gets the names in the text); past the patience the result says
the app has not connected yet. An assistant entry that never got
visible text is dropped before a tool call and at the end of a turn,
and the panel draws an empty one as nothing — the blank block above
the first card is gone. The model's context now opens with the running
apps and their tool names and lists the others as os.launch candidates;
the WM's launch description says a running app's tools are already
there, and its answer for one that is already running says so and
brings it to the front.

Proven on the fleet model: "open the photos app and find the pictures
about dogs" → launch (running 4 s later) → search, summary, search →
the answer, one turn, the tile beside the pane. ai-services 38, wm 153,
aichat 2; aichat checks for wasm32.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 18:23:15 +02:00
Admin
73ffe87e8b Merge branch 'work' into webdemos 2026-09-02 18:11:05 +02:00
Admin
d1910b8fe4 web-server: a store snapshot's extensionless routes are served with the types the exporter recorded
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 18:11:05 +02:00
Admin
8b46ce0279 aichat: the composer's hint is a dark grey Ask AI, not the typed colour
The placeholder used the focused-text colour, and the composer is
always focused, so the hint looked like an entry.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 18:09:14 +02:00
Admin
4ca36c1d92 ai hub + services: the assistant's model comes from wherever it is resident — the fleet chat box, with tools, then the local weights
The hub chat session's election no longer stops at this machine: a
co-located serving holder first, then a fleet chat node heard by
discovery and role-allowed for chat (a short patience for beacons),
then the weights on this machine — and an honest answer naming both
misses and where weights may be put when none of those exists. Tool
packs ride every route: the system text carries the tool table as the
node's chat_system, one splitter strips thinking and collects
<tool_call> bodies across deltas, the in-process worker's parser is the
one parser, and tool results go back as tool turns. A node that fails
mid-turn ends that turn with the node named; the person's next line
re-elects, served first on the new route. The tools-only guard on the
proxy is gone. The session exposes its route, and the panel's chip
shows it.

The local lookup is independent of the working directory: the env
override, then the makepad home's weights (Qwen preferred, largest),
then the checkout the binary came from, then the cwd — so an assistant
launched from a binary copy finds the same weights as one run from the
checkout, and the Local provider always builds even with none.

Proven live with no local weights: from the sheets overlay a plain
line reached the fleet's 27B (the node with the model resident won the
pick), the model called sheets.summary and answered in 12 s; the app
stayed at 254 MB. hub_chat + local_llm 11, services 34.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 18:06:00 +02:00
Admin
82ac76882e photos: the picture wall as an app — the SMBC archive on the tile engine, a module from its first line; fabric in the launcher
apps/photos (lib + bin) puts libs/image_tiles' TileGrid in a tile: it
opens a baked library found by name (IMAGE_TILES_HOME, the cwd's
local/image-tiles, the checkout the binary was built in — the smbc
collection first), shows a status line with the bake command when
there is none, and exposes photos.search (every query word in a title
or link), photos.show (the camera glides onto one picture) and
photos.summary — the same answer for the port and for the module's
executor. PhotosModule mints the view inside an isolate with an open
schema that takes a collection name, never a path; the WM links it
behind app-photos and lists it in the menu. image_tiles learned to
decode GIF and WebP and to read a manifest's pictures from disk, so
the bake can take local/smbc's mirror: the last 300 comics are baked
under local/image-tiles/smbc (293; seven of the mirror's files are
saved error pages). fabric joins the launcher with a title and a
polite close.

Driven hidden: the wall in a process tile beside the left pane, panned
and zoomed; search and show cards; the same wall as an in-process
module; fabric launched and closed; standalone photos with the F10
overlay on the left. photos 7, image_tiles 5, wm 152; the photos lib
checks for wasm32.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 17:59:12 +02:00
Admin
6105b61e6e sheets + files: the assistant reads and writes cells, makes folders, renames and trashes
sheets: read_range (one cell or an A1 rectangle, up to 2000 cells, rows
as tab-separated display values), find (case-insensitive over the used
range, 50 hits), write_cell and set_range (Act) — edits go through the
same commit path as paste and direct entry, so recalculation, undo and
the chrome all see them; the module executor answers the same tools.
files: mkdir, rename (a bare name, no separators) and trash (into the
app's own trash dir, never a delete) — Destructive, so the router shows
a confirm card first; every path through the jail, on the demo VFS as
on disk. Written by the delegate from the existing shapes; sheets 88,
files 158 tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 17:58:35 +02:00
Admin
43c36c1a36 Merge branch 'work' into webdemos 2026-09-02 17:57:19 +02:00
Admin
3c033977f8 geodata: a data library keeps its own clock — the GUI platform is not a dependency
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 17:57:18 +02:00
Admin
7f1fefd56d terminal + browser: the assistant can read the screen, run a command, read and steer the tabs
Two services on the AI bus. terminal: read_screen (the visible grid as
text, cursor and cwd in the note), read_scrollback (up to 2000 recent
lines) and run — which types the line into the live, unsandboxed shell
exactly as the person would, refuses control characters and anything
over 4 kB, and answers at once so the model reads the outcome with
read_screen next. browser: page (the active tab's title and url — CEF
exposes no page text, so none is promised), tabs, navigate and new_tab,
http(s) or about:blank only. Context lines: the shell's cwd, the active
tab. Written by the delegate from the sheets/files shapes; terminal
331 + 5 tests, browser checks.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 17:55:54 +02:00
Admin
4e58ab9dec vj: the output window exists only once opened — never on the web
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 17:52:32 +02:00
Admin
f80895accd Merge branch 'work' into webdemos 2026-09-02 17:47:46 +02:00
Admin
7deb052ad1 web-server: ETags from size and mtime, never from content; upgrades only where a socket route exists
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 17:47:46 +02:00
Admin
4317f58804 sim + render + chat: walk decks as a surface, the filmed body is no obstruction, the brief never asks
- libs/sim: DeckStrip (deck.rs) — every laid corridor registers its walk
  deck; movers pin to it exactly instead of climbing 2.5 m slab kerbs;
  feet inside a static box lift onto its top; the world composes floor,
  terrain, materials and voxel in one sampler
- libs/sim + libs/render: camera_path_limit takes the filmed body and never
  treats it as an obstruction (the coaster's ride boom was clamped to 1 m);
  the shadow gate's settle test gets a hair of float slack
- libs/asset/chat: the in-game builder never asks before building; the
  brief teaches game.driver and the race countdown, under budget

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 17:41:08 +02:00
Admin
dbe43bd327 wm + widgets: the AI panel on the left, pushing the body in
Decision 17: the chat never sits over the app. Under the WM the pane is
a left column of the desk row — its walk width is the reserved strip,
eased from nothing to the card plus a gap as it slides, the card
right-aligned in the strip so it comes in from off the desk's left edge,
its edge on the right — and the desk's layout takes what is left, tiles
snapping to it while the strip moves the way they follow a drag. In a
standalone Window the F10 slot does the same: it reserves the strip as
the body's left inset every frame, lays the body out beside it, and
reads the app's own ground colour after its retint. The background
switcher's index lives on App, not in a static.

Verified by hidden grabs: a terminal full width; F10 → the pane on the
left and the desk narrowed by 450 px with the terminal filling it; F10
→ full width again. sheets standalone: F10 → the grid starts right of
the card, nothing over cells; F10 → back. wm 152, widgets 138 (+ the
two known widget_tree failures), sheets 84; the WM checks for wasm32.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 17:38:39 +02:00
Admin
0b2be94917 Merge branch 'work' into webdemos 2026-09-02 17:35:00 +02:00
Admin
e6a305c7e7 ai-hub + dj-pack: a whole track fits a stems job; long tracks split into spans
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 17:35:00 +02:00
Admin
82def6b25b wm: every app under the desk — the chat keeps the keyboard through an automatic refocus, a pool that gives up, polite closes for browser, sheets and aichat
Driven native, one app at a time, with the assistant up at boot:
terminal, files, task and browser warm-adopted; sheets, image, video
and pdf launched cold; sheets also as an in-process module; the tools
answering through the pane (os.list/launch/close/open, files' listing,
a refused jail escape, a treemap, sheets' summary both ways).

What broke and is fixed: when a window closed or a client died the
layout's automatic refocus took the keyboard from the OPEN chat, so an
os.close typed in the pane sent the next console line into a sheet —
the refocus now leaves the keys with the pane while it is open (a click
on a tile still moves focus, through focus_client). A warm spawn that
cannot start (an installed layout without the binary) counts as a crash
so the pool gives that app up instead of retrying every tick. browser
and sheets link the WM API: a title in the bar and a polite close on
CloseRequested; aichat quits on CloseRequested (the desktop going down).

Measured (WM log, launched → first flip): warm-pooled apps 25–50 ms;
cold launches through cargo's freshness check 0.7–3.7 s (up to 14 s
under a parallel launch's target lock); the same binaries run plain
0.3–1.5 s after macOS has scanned them. wm 148, sheets 84, aichat 2.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 17:24:54 +02:00
Admin
6d47c3f5ac wm: the omarchy themes moved to omacom/omarchy — the importer follows
basecamp/omarchy answers 301 on the API and the importer fetched no
wallpapers; the repo is omacom/omarchy now, same quattro branch.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 17:11:26 +02:00
Admin
341f66d10a Merge branch 'work' into webdemos 2026-09-02 17:06:28 +02:00
Admin
d9f04fcabf dj-pack: pack reads caches, never creates them; dry-run writes nothing
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 17:06:28 +02:00
Admin
d3cd233e23 wm: one desk for every target — the host seam, the assistant seated in-process, the web profile, and the assistant up at boot
The desk (layout, tiles, the shell, the AI pane, the module host, the
bus) is one program everywhere; what differs around it is asked through
host.rs: a monotonic clock in platform seconds (std::time traps on
wasm), whether child processes exist at all, a child's environment, the
theme choice kept in the desk's own storage namespace. Every Instant in
the WM is gone; the hub, the warm pool and process launches are skipped
where there are no processes, and the bar's clock falls back to the
platform epoch when it cannot fork date.

The pane has a second body: where the chat is linked in as a module —
the web build, or a desktop that switched aichat to module hosting — the
pane instantiates mod.widgets.AiChatOverlay by name, exactly as a
Window's F10 slot does, and the WM's own os service and every module
instance reach it as in-process links (pane_links.rs), never as frames.
The registry's web profile makes every linked module a Module and
answers os.launch of anything else with unavailable. sheets' Open and
Save go to the instance's storage jail, asynchronously, on every
platform.

The assistant starts with the desktop (decision 15): the aichat child
(native) or the overlay (in-process) comes up at startup with the pane
closed and no focus taken, drawn just off the desk's edge so the child
is configured and presents before any F10 — which now only slides it
in. The gallery fixture leaves the desk's DSL for a by-name host (its
construction overflowed the wasm stack).

Platform bug found by the web drive and fixed in libs/wasm_bridge: the
ToWasm reader added a block's absolute end to a relative base, so two
JS→wasm messages in one buffer decoded garbage and trapped; it seeks to
the stored end, with a batched-buffer test.

Verified natively by a hidden drive: the child's first frame 1002 ms
after boot, before any F10; F10 → the pane at once with the composer
focused; F10 → gone. On the web (threaded build, 1.5 MB brotli, COOP/
COEP): desk, pane with No model, os.list, an in-process Sheets tile and
its summary, terminal answered unavailable, zero traps. wm 148, sheets
84, app-module 3, wasm-bridge 1; wasm32 checks and the std::time clippy
gate clean for wm, sheets, app-module, aichat, wasm-bridge.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 16:57:45 +02:00
Admin
6857d86461 files: the projected treemap clips boxes at the near plane and keeps the camera above them
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 16:51:13 +02:00
Admin
94b8726ffb dj-pack: tracks in, stems through the hub, a site store snapshot out
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 16:50:11 +02:00
Admin
0684b4123d asset-store: export-static reads every content schema the server serves
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 16:49:57 +02:00
Admin
5d007e0373 files: an unset projection preference means 2.5D
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 16:44:42 +02:00
Admin
0105fa5cd8 files: the space view opens in 2.5D
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 16:41:53 +02:00
Admin
9630337538 asset-store: export-static selects what the namespace holds and says why when it does not
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 16:39:46 +02:00
Admin
511048ca85 vj: no filesystem paths on the web startup path — the store is the disk there
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 16:34:27 +02:00
Admin
412b3c920e files: the space view rescans through the virtual filesystem — the web has no other disk
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 16:32:06 +02:00
Admin
106b38cede wasm bridge: the imported memory honours the module's declared limits
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 16:28:44 +02:00
Admin
aa7345d8c5 vj: the whole app on the web — site store for content, embedded store for local imports, native-only seams Unavailable
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 16:23:52 +02:00
Admin
6be1118b2d vj: the whole app on the web — site store for content, embedded store for local imports, native-only seams Unavailable
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 16:12:34 +02:00
Admin
58ad22e94b Merge branch 'work' into webdemos 2026-09-02 16:00:18 +02:00
Admin
7613f3ef2f web-server: one deadline per response from its size, 404 before 405 for unknown API paths, one Earth radius
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 16:00:18 +02:00
Admin
6b77318209 chat: the in-game builder never asks before building
"make me a giant forest with roads" answered with a plan and "Want me to
go ahead and build it now?" — the player is holding a controller. The
agentic doctrine and the game brief now say it outright: a request is an
instruction, decide the open details, build in this turn, report in a
sentence; a turn that ends in a question is a failed turn.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 15:45:03 +02:00
Admin
6f8c08f4a6 web-server: POST /api/crash stores crash reports in a rotating log on both servers
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 15:44:46 +02:00
Admin
640602cc84 Merge branch 'work' into webdemos 2026-09-02 15:42:27 +02:00
Admin
6c32c88d1a web-server: ETags from size and mtime, deadlines from size, Allow per resource
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 15:42:27 +02:00
Admin
4946c9eb0d sim + render: a repaint is not a world edit — colour and glow restyles never rebake the lightmap
Crossroads rebaked its lightmap on every stoplight phase (70 bakes a
minute): the traffic kit's lamp repaint bumped the one static revision
the renderer keys its slabs, occlusion, shadow receivers AND the GPU
lightmap kick on. The world now carries a paint revision beside the
geometry revision; the static slabs repack on either, the bake kick
only on geometry. Tests pin both.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 15:41:21 +02:00
Admin
9c9c72ba8d web-server: bodies land before workers, client keys normalized, verbs fail closed
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 15:36:09 +02:00
Admin
441d604e5f vj: dropped or picked audio publishes into the connected store
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 15:32:39 +02:00
Admin
22faab2c8b Merge branch 'work' into webdemos 2026-09-02 15:30:29 +02:00
Admin
cf2b8ca4bd platform: no std::time on web — clippy guard and the platform clock everywhere a web build runs
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 15:30:29 +02:00
Admin
d3472ebd35 tsdf: the clock-taking XR helpers are native-only — the browser has no depth camera and no Instant
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 15:29:00 +02:00
Admin
faf8112816 web path: the tessellator's lap timer, the trace span and the fusion cycle timer have no clock on the web
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 15:27:30 +02:00
Admin
82d0cfaab9 web path: the trace helper keeps its doc, the journal nonce steps a counter where there is no clock or pid
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 15:24:00 +02:00
Admin
0f967ce382 web path: the stream trace and the sqlite pager never wait on a clock the browser does not have
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 15:23:10 +02:00
Admin
942ff86df3 sqlite: the browser store has one owner — its locks never wait on a clock
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 15:21:02 +02:00
Admin
670cc76977 asset-store: the platform storage API is a hard dependency on the web, where the embedded store is the only store
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 15:17:16 +02:00
Admin
77d91385fc wm: the module contract and the first in-process app — sheets in a tile, in an isolate of its own
libs/app_module (makepad-app-module) is the contract (aicontrol §3):
AppModule (id, label, register into an isolate, an OpenSchema, create
into InstanceParts, capabilities), InstanceHandles (an InstanceScope
owner token, the storage jail as a cx.storage namespace, the viewport,
a ReplySink for calls that finish later), a ServiceExecutor addressed
apart from the root, and an OpenSchema that refuses raw paths — a file
is a handle the host issued or nothing. Only types live there.

The WM hosts one (module_host.rs): allocate the isolate, retint its
theme from the palette, register, create — one trusted entry into the
isolate, never a second &mut Cx beside the VM; teardown drops the root
first, runs shutdown in the isolate, frees it. MpModuleView is the tile
for an instance's root, drawing and dispatching with the isolate
INSTALLED on Cx (enter_isolate/leave_isolate, new in widgets) and
gating keys on the WM's focus; a TileHost trait covers both tile kinds
so the desk and the focus logic never ask which. The registry overlay
(apps.rs) links modules per app-* feature; the desktop default stays
Process, switched per app in ~/.makepad/wm/apps.splash or by a dev
--module flag. The bus gains its in-process leg: an instance is an
m<id> endpoint the pane addresses like any other — the leg the web
superbuild runs everything on.

sheets is lib + bin: SheetsModule mints MpSheets{} in the isolate and
answers sheets.summary on its executor; the standalone binary keeps its
Window and F10 overlay behind the standalone feature.

The drive found a platform bug: a shader compiled on a widget minted in
an isolate hit the draw-shader object cache by object index alone, so
an isolate's Button reused the main heap's entry and drew with zero
instance slots. The cache is keyed by (heap, object) in every backend,
and add_instance refuses a zero-slot draw call instead of dividing by
it. Isolate cost, measured (the P2 entry gate): 12 ms and ~8 MB per
isolate, flat to 32 — no pre-warming needed.

Verified by hidden grabs under the WM: /os.launch sheets → an
in-process Sheets tile (no child process), the apps row Desktop ·
Sheets, /sheets.summary answered through the in-process leg, a warm
terminal beside it, F10, close → the instance torn down and its isolate
freed. wm 145, sheets 84, app-module 3, services 33; platform and draw
check for windows-msvc, linux-gnu and wasm32.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 15:16:42 +02:00
Admin
9942e26f48 asset-store: the embedded module lives in its directory now (the browser-durability landing's deletion)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 15:16:05 +02:00
Admin
372bfd7960 asset-store: browser durability — generation extents over cx.storage, chunked CAS, quota and GC
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 15:14:48 +02:00
Admin
22b2bf9fc3 sim + chat: the composed world surface takes a map floor; the chat gets plan tools
- libs/sim: meshfloor.rs FloorRaster (one storey per column from a map's nav
  graph); composed_surface_sample_at(floor, terrain, materials, voxel) is the
  one seam nav, corridors and lots read; in-eval voxel ops no longer bump the
  history revision
- libs/asset/chat: world.get_plan / world.set_plan (shape-validated, stale
  revisions refused), the authoring state prefixed to every turn, map turns
  routed to the plan tools; game.md: the foundation line, the capability kinds
  verbatim, no order-independence claims

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 15:02:46 +02:00
Admin
0ecb81cf56 ai models: the model-crates env-var cleanup — 172 research knobs gone, the unset default is the code
The per-family model crates (flux, h3, paint, music, speech, stems,
vision, sfx, rife, trellis, beats, common) and the libs/diffusion
research binaries read some 190 environment variables that were
research knobs: tensor dumps, per-stage timing, oracle-parity and
fixture rigs, experiment toggles. The path taken with none of them set
is the one that ships; every such knob is deleted with the code it
gated, and every losing branch of an experiment toggle goes with its
toggle — dead kernels, fields and functions included. What remains are
the real configuration variables (the FLUX_*_MODE family, FLUX_GRAPH,
the VAE pool cap, the FLUX2 text-encoder residency, H3_VAE_BATCH, the
music3 caches and official modes, the stems/beats f16 switches, the
weight and data roots) and the build-script variables.

Rebased on the runtime cleanup: precision stays explicit everywhere
(GemmPrecision, f16_attention_operands, the H3 text precision, DA3's
StrictF32 in code); no act16, no H3_ACT_F16, no FLUX_ATTN_F16 or
FLUX_VAE_CONV_GEMM reads survive.

Reviewed by the delegate reviewer (APPLY, no findings) and gated on the
Windows CUDA box: all seventeen model crates check, motion and vision
tests, the hub and the diffusion bins — the gate caught one CUDA-only
tap marker the Mac never compiles, removed here. On this Mac: the same
checks plus motion 24, paint 158 and vision 23 tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 14:50:06 +02:00
Admin
f51ca0795e workspace: the wasm interpreter's tests build at opt-level 1 — its own profile setting is ignored inside a workspace, and opt-level 0 overflowed the script eval stack
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 14:45:08 +02:00
Admin
1b7089962a files: the space view is where a tab starts
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 14:42:22 +02:00
Admin
fd5d70c108 cargo-makepad: the app's own resources are packaged under its bin name, which is how self:// resolves
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 14:42:22 +02:00
Admin
04a94ef780 realtime: one service-log line when a live session opens and one when it closes
A headless box's service log showed only the startup banner for a whole
session; now it records the model and wire encodings on open, and on close the
elapsed time, frames in/out, fps, and the dropped/undecodable/unencoded counts.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 14:41:09 +02:00
Admin
6b661bf4c5 web: crashes report themselves — panic text, breadcrumbs, memory, workers; a dead instance stops pumping
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 14:37:51 +02:00
Admin
dfe2087399 web-server: O(1) report admission, one connection deadline, shared route sampler, static fallbacks, cache policy
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 14:37:36 +02:00
Admin
fb1416d8c0 cargo-makepad: the threaded wasm module is linked with the 4 GiB wasm32 memory ceiling
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 14:37:26 +02:00
Admin
f6bbee90ef wasm bridge: shared memory asks for the 4 GiB wasm32 ceiling and steps down where the engine refuses
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 14:33:35 +02:00
Admin
b0646d0714 Merge branch 'work' into webdemos 2026-09-02 14:31:16 +02:00
Admin
232909d16a script: the VM reaches std and its slot through one host — no aliased references
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 14:31:15 +02:00
Admin
aa816ed8a6 windows h264 decoder: pictures come out one access unit later — rewrite the SPS level so the DPB is one picture deep
Measured on the body node: the Microsoft H.264 decoder accepts MF_LOW_LATENCY and
CODECAPI_AVLowLatencyMode and ignores both; it holds pictures until the DPB it
derives from level_idc is full (six access units in, nothing out until DRAIN), and
appending access unit delimiters changes nothing. With level_idc rewritten to 1.0
in every SPS the DPB is one picture and each picture is emitted as the next access
unit arrives (5 of 6 while streaming, the last on flush). flush() now sends
COMMAND_DRAIN. The captured-stream test replays dumped access units and requires
the pictures while streaming; the keyframe-request check is its own test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 14:30:44 +02:00
Admin
2b035ff33c route: the navigation session keeps time on the platform clock — the std clock traps on wasm
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 14:28:20 +02:00
Admin
ff62db3c5e ai libs: the runtime env-var cleanup — precision is a per-caller policy, not an environment side channel
The AI runtime crates read their precision, activation and kernel
choices from environment variables; the code path taken with none set
is the one that ships, so every knob that selected it is now an explicit
argument and every losing path is gone. GemmPrecision { f16_accumulate,
f16_activations } is passed by each caller: the default {true, true} is
the old unset Flux route; H3's DiT and text encoder pass {false, false}
(H3's >1e4 activations saturate f16 — the policy H3 used to set through
FLUX_GEMM_F16ACC=0 on itself), its VAE {true, false}; DA3's StrictF32
selects f32 packed attention in code; Hy-Motion carries an explicit
f16_attention_operands flag through its text refiner, its double and
single blocks and the CUDA backend (true in production, false only in
its full validator). The libs/diffusion bins — a separate workspace —
are migrated to the same shapes.

Benches and validators no longer set variables on themselves: llama's
skip-logits is a session option (the CUDA bench turns it on), OCR takes
explicit use_f16_gemm and tiled_roformer options, the lane speculative
probe reads its CLI. The live gates the first cut had deleted are back
as explicit-config tests: MMQ M=129, the strided-f32 MMV path, the
RMS+MUL CPU oracle. The loader's THREADS and CHUNK_MB stay real settings.
The Metal quantized-matmul experiment (metal_qmm and its vendored MLX
kernels) was reachable only through a knob and goes with it.

Reviewed in three rounds by the delegate reviewer (the last round
accepted everything but one Hy-Motion call site, fixed in round four
and reviewed here), and gated on the Windows CUDA box: lib checks of
common/paint/loader/cuda/llm/motion/vision, motion 24 and vision 23
tests, the hub check, the diffusion bins, llm 253 passed / 1 ignored.
On this Mac: the same checks plus the motion and vision tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 14:26:48 +02:00
Admin
c24c2064fd aichat: the Window overlay — F10 in every standalone app, the in-process port, the /ai bridge routes, sheets as the pilot
Every Window now carries an AiChatSlot beside the tweaker: zero cost
while off, inert when the window manager hosts the process (the WM's
pane is the chat then), and on the first bare F10 it instantiates the
chat's module root BY NAME — mod.widgets.AiChatOverlay — which exists
when the app links makepad-aichat and calls its script_mod; an app that
does not gets one log line. The overlay slides in from the right over
the body with the WM pane's motion, owns the pointer inside its rect and
the keyboard through its composer, keeps ticking while hidden, and draws
in the window's retained overlay list so it composites over the deferred
body. Requests from outside the tree ride Cx globals, never statics.

An app exposes itself with one call: AiServicePort::open(cx, manifest)
is the hosted transport under the WM and, standalone, an in-process link
parked on Cx (PendingServiceLinks) that whichever chat root is up adopts
into its registry — the overlay today, the superbuild's pane later. The
registry wakes the UI when it sends, so an in-process call is answered
without waiting for a pointer event. NoModel leaves the engine feature
so a build without a model runtime still answers honestly and keeps the
tool console; aichat's engine is a default-on feature.

The bridge: Cx::ai_callback beside tweak_callback, /ai?on=1|0, /ai?say=…
and /ai/transcript (the overlay publishes the transcript as JSON after
each state change), installed by makepad_aichat::script_mod.

sheets links aichat and exposes sheets.summary (name, used range, header
row, selection) through the port: standalone the overlay answers it,
under the WM the bus does.

The local model no longer loads on a registration: its session starts
on the first user line, so an app joining the bus costs nothing.

Verified by hidden grabs: sheets standalone /ai?on=1 → the overlay with
the summary card and no model; F10 closes it; under the WM the sheets
tile shows no overlay and F10 toggles the WM pane. widgets 135 + 4
(two widget_tree find_within tests fail before and after this change),
aichat 33, sheets 83 + 2, services 29; widgets and aichat check for
wasm32.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 14:24:41 +02:00
Admin
e2a262b05a asset-store: the portable core — embedded feature, page-store and CAS seams, a synchronous in-process API
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 14:14:27 +02:00
Admin
19cf377238 platform: the thread runtime contract — spawner, tasks, pools, scheduler, UI waker
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 14:14:15 +02:00
Admin
d76abf387e Merge branch 'work' into webdemos 2026-09-02 14:14:14 +02:00
Admin
ab8febc66d cargo-makepad: fonts packaged from the app's font manifest
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 14:14:14 +02:00
Admin
d1e7a6fea8 windows h264 decoder: AVLowLatencyMode is a VT_UI4; the round-trip test tolerates the MF encoder's access unit delimiters
The Microsoft H.264 decoder answers a VT_BOOL CODECAPI_AVLowLatencyMode with
"VT_UI4 != pValue->vt" (seen on the body node), so send the number. The
Media Foundation encoder opens each access unit with NAL 9, which the
keyframe check now skips.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 14:13:54 +02:00
Admin
05fd7e2dae Merge branch 'work' into webdemos 2026-09-02 14:12:33 +02:00
Admin
611c9ebd8c cargo-makepad: production packaging stays off fat LTO; script VM under LTO investigated
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 14:12:33 +02:00
Admin
2e48c5bfb7 asset-client: the static-store read mode and polled runtime
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 14:10:50 +02:00
Admin
acad401218 sim: an agent with no route holds and retries instead of walking into the wall
A partial route to the nearest reachable cell, a hold at its end, and
re-planning with backoff (15 to 240 ticks) replace the old head-
straight-at-it fallback; cell derivation prefilters entities per chunk.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 14:10:23 +02:00
Admin
dcf7d21060 windows h264 decoder: ICodecAPI low-latency, per-pump trace, access-unit dumps, and the stream tests run on Windows
Trace every ProcessOutput HRESULT/status, keep the first access units beside the
trace so a stream can be replayed offline, ask for CODECAPI_AVLowLatencyMode through
ICodecAPI as well as the MF_LOW_LATENCY attribute, and let the round-trip test plus a
captured-stream replay test run on a Windows box.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 14:08:57 +02:00
Admin
60ee97858c cargo-makepad: package artifacts carry a content hash so a re-upload is a new URL
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 14:03:59 +02:00
Admin
d6cc49ad22 windows mft: PROVIDES_SAMPLES is bit 0x100, not bit 0
The Microsoft H.264 decoder reports output stream flags 0x107 (whole samples,
single sample per buffer, fixed size); reading bit 0 as "the MFT allocates its
own samples" handed ProcessOutput no buffer and every call failed with
E_INVALIDARG — seen in the MAKEPAD_H264_DEBUG trace on the body node.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 14:00:58 +02:00
Admin
c1febc7985 windows h264 stream decoder: low-latency mode, output type before first ProcessOutput, real MF_E codes, trace file
The live decoder never produced a frame on the body node: MF_E_TRANSFORM_STREAM_CHANGE
was hand-derived as 0xC00D6D60, which is MF_E_TRANSFORM_TYPE_NOT_SET; the real stream
change (0xC00D6D61) was treated as a hard error, the output type was only negotiated
lazily, and without MF_LOW_LATENCY the decoder holds a reorder window a 2-3 frame live
pipeline never fills. Set MF_LOW_LATENCY on the transform, commit NV12 before the first
ProcessOutput, re-negotiate on TYPE_NOT_SET/STREAM_CHANGE/BUFFERTOOSMALL, drain on
NOTACCEPTING, and stamp packets with monotonic 100 ns timestamps. MAKEPAD_H264_DEBUG=<file>
traces packets, HRESULTs, negotiations and frames for headless services.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 13:55:28 +02:00
Admin
4081e48c35 Merge branch 'work' into webdemos 2026-09-02 13:54:36 +02:00
Admin
e5d37e0b0b platform: a web file picker and file drop that hand apps bytes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 13:54:36 +02:00
Admin
2f5a9849c6 files: the file browser on the AI bus — four read-only tools through a correlated, cancellable runner
Hosted by the window manager, files opens one AiServicePort with a
manifest built from the same table the app's own panel reads its tool
specs from, so the two can never drift: list_dir, read_file, stat,
treemap_summary, all Read. The calls run on a worker of their own
(ai_service.rs): every job carries the engine's call id and a cancel
flag the folder walk checks on every entry, a queued job cancelled
before its turn never runs, treemap_summary reports a permille per
direct child, and results come back by id whatever the order. The old
panel's order-only runner stays beside it for the old chat, behind the
chat feature it belongs to.

The port opens at startup unless the process is a warm-pool standby,
which waits for Adopted so a dormant instance never shows as a running
Files; a context line (folder, view, selection) follows every change;
ChatOpen from the desktop closes the app's own panel; the jail resolves
every path exactly as before.

Verified by hidden grabs under the WM: /os.launch files → the pane's
apps row reads Desktop · Files; list_dir, a refused jail escape, and a
treemap summary answered as cards. 151 tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 13:45:13 +02:00
Admin
de937ac01c route: the web build is a one-to-one recompile — native UI, service seams behind it
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 13:43:19 +02:00
Admin
84b46c11a8 mbtile reader: file-backed readers are native-only so the map stack builds for wasm
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 13:42:30 +02:00
Admin
17437a2f53 Merge branch 'work' into webdemos 2026-09-02 13:35:43 +02:00
Admin
4d75e65b13 web server: hardening after security review — worker-only parsing, FD-relative static opens, strict framing, bounded work, panic recovery, Cloudflare-aware limits
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 13:34:33 +02:00
Admin
b0e34c2f57 wm: the AI pane and its bus — the aichat child seated in the slot, F10, the os service with the typed open
The chat is a child app (apps/aichat) seated in the WM's right-side pane
instead of a tile. The WM gives it the slot (shell/ai_pane.rs: 440 px
clamped to 40 % of the desk, easeOutQuint in/out, opaque card with a
2 px edge, one MpRunView that keeps ticking the child while hidden), the
bus (ai_bus.rs: up-frames stamped with the WM's own endpoint ids, every
registration remembered and replayed when the pane connects, down-frames
routed by endpoint, a synthetic Unregister when a client dies, ChatOpen
broadcast on every slide) and its own os service on that bus: list (text
+ JSON rows), launch/focus/close by registry id, and open through the
same typed OpenRequest a file browser's double-click takes.

Bare F10 toggles the pane before the keymap and before any tile
(decision 8); the pane owns the pointer inside its rect and the keyboard
while open, claimed after its run view's next draw; the child's Close
means hide; its death empties the slot; shutdown kills it. The pane's
child is invisible to everything the desk enumerates. The WM declares
its font set (International, with the emoji family it uses).

aichat: the composer takes the keyboard after its first draw and keeps
it across sends; the running card's bar is a pixel width.

Verified by hidden --remote grabs: F10 → the pane with the os
registration in the apps row; /os.list, /os.launch terminal (tile beside
the pane, keys stay on the chat), /os.focus; F10 again → pane gone.
142 tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 13:29:04 +02:00
Admin
1940f3da25 fonts: leave apps/wm untouched — its font-set declaration waits for the aichat lane to land
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 13:23:12 +02:00
Admin
6981e9052b fonts: manifest generated from the chains, app-level font assets, a symbol fallback, deprecated i18n aliases
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 13:22:42 +02:00
Admin
942476f68c vj: the whole app type-checks for wasm32 — portable hub discovery and typed-unavailable client facades
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 13:21:14 +02:00
Admin
f233756d22 game brief: races, rings, mocap and biomes are one call each
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 13:18:36 +02:00
Admin
89fe45355b platform: wheels and flight sticks are game inputs, with an output-report handle
IOHIDManager claims known wheels and joystick-class devices, parses
them by HID usage (steering, pedals, twist, throttle, hat, buttons) and
hands out an IOHIDDeviceSetReport handle so an app can drive force
feedback from userspace — no kext, no DriverKit. The studio wire learns
RemoteJoystick.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 13:18:36 +02:00
Admin
c3f631d4e3 livepipe: one reusable pipe from a camera to a fleet node and back
Source (webcam by device, a still for tests), one VideoToolbox encoder
session per run for the H.264 wire with raw as the fallback, the hub
realtime session on a node chosen by domain, typed events (status,
connected, aux, rate, ended) through a mailbox to any consumer. The
sandbox's mocap is the first; fabric is next.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 13:18:36 +02:00
Admin
730b5b8e08 video: the Windows H.264 stream decoder pulls output before it knows the format
The decode loop returned before ProcessOutput until the output type was
negotiated — but the MFT only reports MF_E_TRANSFORM_STREAM_CHANGE, the
event that starts negotiation, FROM ProcessOutput. A node fed Annex-B
access units never yielded a frame. Now the pump always pulls, a stream
change picks NV12, sets the type and refreshes size and stride, need-
more-input ends the pump, and rows are de-strided on the way out. The
decision is a pure function with a regression test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 13:18:36 +02:00
Admin
79e7526b1e sqlite: a page-store seam — the file backend as before, an in-memory backend, and open_memory / open_with
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 13:18:18 +02:00
Admin
a6d0338ad2 widgets: popup menu items run in the popup owner's script VM; app_main! releases its borrow after a trap
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 13:15:40 +02:00
Admin
d931f9d764 map: tile archive third pass — per-pass pruning, validated watcher metadata, shared blob bytes, timer watchdog, atomic cancellation
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 13:14:57 +02:00
Admin
52251b77c2 platform: a namespaced async key/value storage API on Cx — files natively, IndexedDB on the web
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 13:06:35 +02:00
Admin
e21db96d50 route: demo review fixes — route origin, rain lifecycle, hosted route validation, request context, provisioning
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 13:06:09 +02:00
Admin
81ef71c38c network: native backends honour the response body cap
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 12:52:06 +02:00
Admin
a7eae82e41 map: the tile build keeps time with the platform clock
std::time::Instant trapped on every wasm tile worker; the profilers and stage timers
now use Cx::monotonic_now, and the native watcher, writer and their tests are gated
to native targets.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 12:43:31 +02:00
Admin
38fe712728 Merge branch 'work' into webdemos 2026-09-02 12:41:06 +02:00
Admin
09b41a8dc9 fonts: FontSet and FontPolicy — one application choice, selected-only loading, a font-assets manifest
Latin (IBM Plex) is the web default, International (adds the CJK and emoji faces) the
desktop default; app_main! records the choice before widgets register, the theme only
registers the selected chains, a glyph miss never starts a load, and the resolved font
resources are emitted as the makepad.font-assets.v1 section for the packager.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 12:40:50 +02:00
Admin
e0c6e74bf2 aichat: the progress bar and system lines use the theme's highlight colour
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 12:37:33 +02:00
Admin
7b9ed2c3de aichat: the assistant as an app — the panel owns the engine, the bus client, settings with the local-only lock
apps/aichat is a lib and a bin. The panel widget owns the registry, the
model and the transcript, draws EngineState as user lines, markdown
answers, tool cards with progress and confirm buttons, and system lines
over a composer; every event pumps the engine and a busy turn asks for
the next frame. The bus client turns the window manager's stamped
studio frames into registry links under the WM's own endpoint ids
(registry.register_as). Settings persist under ~/.makepad/aichat with
the cloud lock kept in code. The binary is both the standalone window
and the WM's special child.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 12:33:32 +02:00
Admin
fde3f398d9 Merge branch 'work' into webdemos 2026-09-02 12:29:56 +02:00
Admin
2308736a2a ai-services: the real models behind the engine feature — local through the hub, Claude, and none
LocalModel drives the hub chat session; a changed tool table or system
prompt rides the next turn as an update block instead of restarting an
append-only context. ClaudeModel owns the history and rebuilds the
per-turn provider with the new native tool array, dots mapped to the
double underscore Anthropic allows. NoModel answers nothing but says so,
so the web demo's pane, services and tool console work without a
backend. Provider rows report honest availability, the local model file
policy is one function, and the crate no longer needs the converse seam.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 12:25:56 +02:00
Admin
14d0723872 cargo-makepad wasm: production packaging — strip, small profile LTO, optional binaryen, size report
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 12:24:41 +02:00
Admin
8ae0ffb951 ai-services: the engine core — registry, router and conversation, tested against a scripted model
The registry issues endpoints, answers each port's registration, keeps
where every instance lives, builds the tool table (each app once,
canonical names) and the briefs, pumps the links and forgets dead ones.
The core drives a Cx-free Model seam: routes each tool call to the
instance the model named (or the focused one), stripping the selector;
holds destructive calls for a confirm card, with host risk floors that a
service can raise but never lower; times out silent services, extended
by progress up to a hard cap; answers unknown names with the real ones;
reconfigures the model when the registry moves and restarts it when it
cannot rebind; honours result dispositions (end the turn, reset the
conversation); caps tool rounds; and offers a local tool console that
drives services without a model. Time is host-supplied seconds, never
Instant, so the same core runs on the web.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 12:23:07 +02:00
Admin
5cdcaedb8e Merge branch 'work' into webdemos 2026-09-02 12:20:14 +02:00
Admin
ba1010d7c3 asset-client: review fixes — additive web feature, shared transport contract, guarded cache and base URL
The web feature no longer hides the native API; both transport adapters validate
responses through one upper layer with framing headers refused and redirects off;
the platform transport passes a body cap to makepad-network (honoured by the web
backend); the filesystem cache verifies before deduplicating; BaseUrl canonicalizes
and rejects dot segments; ClientLocation is authoritative.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 12:19:48 +02:00
Admin
2acb7983ab ai-services: wire v2 — endpoints, receiver-side caps, result disposition
An app id says what a service is; a host-issued EndpointId says which
instance. Ports register with a nonce, learn their endpoint from the
host's Registered answer, take only frames addressed to it, and never
stamp themselves on the way up. Hosted frames are refused over a size
cap before deserialization and every variant is validated on receipt;
tool schemas must describe an argument object. Results carry bounded
structured data and a disposition (continue, end the turn, reset the
conversation); Cancelled and TimedOut are outcomes of their own.
InstanceMeta records where an instance lives for the model.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 12:14:52 +02:00
Admin
d292ec7fe4 Merge branch 'work' into webdemos 2026-09-02 12:04:26 +02:00
Admin
126d8c9168 route: the demo profile runs in the browser — merged panel draw, platform clock, unavailable backends tolerated
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 12:04:08 +02:00
Admin
cf4c84c22f keys: F10 is the assistant — the exploded-view debugger moves to Shift+F10, the screen recorder to Ctrl+F10
The platform's exploded z-layer viewer consumed F10 before any app saw
it. F10 now belongs to the AI chat (the WM pane and the Window overlay);
the debugger is Shift+F10 and the recorder Ctrl+F10, beside the
designer's F12. The recorder widget gains a hotkey_ctrl setting.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 12:01:37 +02:00
Admin
c55a315515 platform: monotonic clock beside the wall clock, trap-safe dispatch, studio worker only with a studio
Cx::monotonic_now backs every elapsed and deadline measurement (Instant natively,
performance.now on the web, reachable from workers); dispatch keeps its handler
installed across traps and rejects re-entry without panicking; the studio websocket
worker spawns only when a studio is configured and waits with park_timeout instead of
spinning.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 11:56:54 +02:00
Admin
9633ded6f4 web server: site static serving plus the first nav backends
tools/web_server becomes the deployable site server on our own HTTP stack: Range, HEAD,
brotli negotiation, ETags, cache and cross-origin headers, error reporting, and
/api/healthz, /api/search, /api/route and /api/along over map_nav with bounded query
and route workers and a data directory outside the public root. makepad-network learns
HEAD and exposes raw header lines.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 11:42:36 +02:00
Admin
0fd1ea2e92 route: demo profile — native/demo features, side-panel and provisioning seams, hosted tiles, HTTP nav client
The demo build has no AI panel, no first-run bake and no local files: tiles come from the
hosted .mkmap archive, and search, routing, along-route lookups, weather, radar and wind
go through the site's API with client-side trip state.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 11:41:14 +02:00
Admin
5d28ed551a map: tile archive review fixes — legacy MBTiles path restored, decode off the UI thread, retry, cancellation, blob dedup, validation
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 11:34:56 +02:00
Admin
4144dc414c files: the spy test filesystem implements the clock
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 11:31:46 +02:00
Admin
9ae91aca3c asset-store: static export contract — versioned static index and a sanitized deterministic export-static tool
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 11:28:31 +02:00
Admin
fe920c8988 files: review fixes — demo scan exclusions, Zipf sizes, trash root guarded, depth bound, tests
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 11:28:30 +02:00
Admin
47ac158a42 asset-client: portable primitives — completion transport, cache store, static-site config
A completion-based Transport with the native TCP adapter and a platform-HTTP adapter over a
client-owned network runtime, a CacheStore seam with the filesystem cache and a memory
LRU, BlobContent / ClientLocation / Unavailable types, and native/web features so the
client builds for wasm32; static-site connect is a typed Unavailable until the runtime lane.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 11:26:03 +02:00
Admin
68fb182bf4 Merge work into webdemos: the unified trace switch
The startup trace keeps its topic gate and runs on the platform clock so it stays
wasm-safe.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 11:25:03 +02:00
Admin
3c7566a565 Merge work into webdemos: the mp prefix rename
apps/files, apps/sheets, libs/wm_api, libs/wm_theme and the renamed env vars and script
namespaces meet the demo seams; added demo files move to the renamed paths; the files app
keeps its own makepad_home so demo builds without the chat feature do not link the AI hub.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 11:23:11 +02:00
Admin
3389475d33 trace: one switch — MAKEPAD_TRACE=<topics>, trace!(topic, …), and /trace on the bridge
The per-crate *_TRACE / *_DEBUG / *_DUMP environment switches in
platform, widgets and cef become topics of one process-wide set:
startup, frame, present, timer, studio, gpu.*, shader.*, gl.*,
runview.dpi, wayland, tweak, map.*, cef. Hierarchical matching with
negation and all; read once at init; settable on a running instance
through GET /trace?topics=…; an atomic short-circuit keeps the
no-tracing case off the lock in per-frame code; spans are absent on
wasm32 where Instant panics.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 11:23:08 +02:00
Admin
debd8c1075 rename: the mp prefix goes — apps/wm, files, terminal, browser, task, sheets, image, video, pdf; libs/wm_api and wm_theme
Packages are makepad-<name> with the short name as the binary. Env vars
follow (MAKEPAD_WM_*, MAKEPAD_FILES_*, MAKEPAD_TERMINAL_*), config moves
under ~/.makepad/<app>/, the theme namespaces are mod.wm_theme and
mod.browser_theme, the hosted AI envelope key is wm_ai. platform/video
becomes makepad-platform-video so the video app can be makepad-video.
Carries the Score entries that were pending in the WM's curated table.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 11:17:12 +02:00
Admin
a7af3ea99a platform + mpfiles: platform clock instead of std::time on the web, unwind-safe event dispatch
std::time is unsupported on wasm32-unknown-unknown; the demo paths now use Cx::time_now
and the pinned demo clock, and Cx::call_event_handler restores its handler slot on unwind
with an explicit re-entry diagnostic instead of cascading into a second panic.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 11:11:42 +02:00
Admin
0537687b96 mpfiles: distinct repo-owned pictures for the demo thumbnail pool
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 11:04:28 +02:00
Admin
3203e32f4a finance: review fixes — id remapping on persist, structural determinism tests, one cfg seam
Persisting the generated ledger remaps every id through an explicit map so category
parents and scheduled entries resolve in a fresh native database; determinism is proven
by a structural fingerprint plus reference-integrity and transfer-pair tests; native
persistence and import live behind one cfg-gated module interface with a runtime
pinned day (2026-08-28 in demo builds) used by all shared range code.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 10:58:42 +02:00
Admin
aa96dbd625 cargo-makepad wasm: package the bin target's wasm and create dirs before minifying
A package whose bin name differs from the package name (apps/finance: finance) no longer
fails at packaging; the bin is resolved from cargo metadata, default-run or --bin, and the
minified JS copies are written after their directories exist.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 10:53:12 +02:00
Admin
58e1f72d72 mpsheets: review fixes for the demo seam
Visibility wrappers around the picker and disk controls, initial document and demo
loading owned by SheetDocs, a clipboard seam instead of a platform cfg in the view,
loading a demo resets history, selection and scroll, ASCII help text, stronger
bundled-document tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 10:50:51 +02:00
Admin
ad24534b94 map: persistent asynchronous tile archive over a completion-based byte source
libs/mbtile_reader gains pure root/leaf/blob parsing; widgets/map gains archive.rs with
a ByteSource trait, a file backend on the worker pool and an HTTP-Range backend over
cx.http_request, a TileArchive that fetches the root once, caches leaf directories and
dedups in-flight reads, and MapView::set_source_config so local archives and hosted
archives share one request path into the existing tile build and ready-tile delivery.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 10:50:15 +02:00
Admin
13b4c485e5 mpfiles: demo build — procedural fake filesystem, still-image thumbnails, chat feature
The chat feature (default on) owns the AI hub dependency so a demo graph never names it;
demo builds install a seeded procedural DemoVfs of ~38k files with Zipf sizes and deep
paths, read text through Vfs::read_bytes, take thumbnails from an embedded picture pool
with one still per video, run listing/scan/thumbs inline, and keep prefs in memory.
Process spawning is confined to the native seam.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 10:48:41 +02:00
Admin
0ccd384a73 platform web: focus dispatch, window-zero geometry and generation-correct ids after the startup deferral
Review fixes: focus/lost-focus events dispatch unconditionally again, only window
zero's creation updates the shared browser geometry, the guarded sites resolve the
generation-correct id of physical slot zero, and four regression tests cover the
deferred-geometry state machine.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 10:47:35 +02:00
Admin
1e93309385 AGENTS.md: designs stay local; no OS screenshots; focus and hidden-window laws
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 10:36:02 +02:00
Admin
daff390fc6 finance: demo feature — generated ledger, SQLite target-gated, import hidden
seed::generate(years, today) is the pure deterministic generator; native first-run
persists it through the unchanged db path, demo builds keep the ledger in memory with a
pinned day so screenshots are stable. makepad-sqlite is a non-wasm dependency and the db
module only exists outside demo builds; the CSV import screen is absent in demo builds.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 10:33:55 +02:00
Admin
c17480faf1 render: a non-rigid body may carry its own orientation
A coaster car upside down at the apex of a loop is a body whose
quaternion is not the default; the renderer now honours it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 10:33:30 +02:00
Admin
5b309b311f vj: 32-bit usize constants no longer overflow on wasm32
The 4-16 GiB cache budgets are computed in u64 and saturate to usize::MAX on 32-bit
targets, same as the shared libs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 10:31:59 +02:00
Admin
83d8eac9cc mpsheets: demo feature and the SheetDocs seam
A demo build carries three bundled formula sheets instead of disk CSV open/save; the
seam is one trait with a native fs impl and a bundled impl chosen at start, the
Save/path chrome hides behind can_save, and the wasm clipboard write is skipped.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 10:23:47 +02:00
Admin
ed5b2fa509 web: wasm32 portability in libs and web startup geometry deferral
libs/ai/loader, libs/sqlite_query, libs/video_flow: 32-bit const overflows and unix-only
file locking/bulk reads gated so the app crates type-check for wasm32-unknown-unknown.
platform web: ToWasmInit arrived before the app's Window widget allocated window zero and
indexed an empty pool; the browser geometry is now stored and applied once the window
exists, and the other id_zero index sites are guarded.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 10:23:47 +02:00
Admin
876e3c4e18 game brief: buildings have insides — ask for a program at the door
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 09:50:47 +02:00
Admin
d7f9813443 game brief: to change a map, edit the plan you were given; vegetation is a layer
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 09:16:57 +02:00
Admin
b94bc58a7f ai-services: the wire, the app port and the panel state — one conversation, many apps
An app exposes an AI service (manifest, tools with a risk class, a port
that receives calls and answers them later); a host aggregates every
connected service into one chat. Hosted by mpwm the port rides the
studio protocol's Custom frames under the mpwm_ai envelope; embedded, it
is a channel pair. The engine behind the feature flag and the panel that
draws EngineState land in the next lanes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 09:15:53 +02:00
ca7bc8439b nigig: carry workspace.dependencies into android wrapper manifest
The generated android wrapper re-creates a standalone workspace and only
forwarded [patch.*] sections from the workspace root manifest, so deps
declared via [workspace.dependencies] + workspace = true failed to inherit
in wrapped crate builds. Extract the [workspace.dependencies] section the
same way patches are handled and inject it into the wrapper manifest.
2026-09-02 10:14:11 +03:00
282845b7e0 nigig: NIGIG test-mode forwarding, custom manifest hook, ortho camera support
- makepad_test/runtime.rs: forward NIGIG_TEST_MODE from host env to the
  Android app via 'am start' intent extra; add wait_timeout (60s) used by
  wait_visible/wait_hidden/wait_count; make query_widgets tolerant of
  snapshot timeouts; grant READ_CONTACTS during adb setup
- makepad-platform android_jni.rs: read makepad.NIGIG_TEST_MODE intent
  extra and surface it as the NIGIG_TEST_MODE env var via apply_studio_env
- cargo_makepad compile.rs: support verbatim custom AndroidManifest.xml in
  addition to the templated variant
- makepad-xr xr_root.rs: add ortho camera controls (ortho, ortho_height,
  min/max), derive Debug on XrCamera
- docs: ANDROID.md and DESKTOP_VISIBLE.md for makepad_test
2026-09-02 10:14:10 +03:00
318b480c0e makepad_test: grant runtime permissions after install to prevent dialog overlay
The GrantPermissionsActivity pops up during navigation and blocks the
app's event loop, preventing hub responses. Pre-grant all runtime
permissions after APK install to avoid this.
2026-09-02 10:14:10 +03:00
1f6e881880 makepad_test: force-stop interfering Robrix app during tests
PID 28203 (rs.robius.robrix) was the actual zombie reclaiming foreground
and killing our test app - not our own package. Force-stop both the
target package and known interfering Makepad apps (Robrix) during test
setup to prevent cross-app foreground competition.

Also remove the pm disable-user approach as it doesn't help against
a different package's zombie process.
2026-09-02 10:14:09 +03:00
26d831c891 makepad_test: use pm disable-user to prevent Samsung zombie resurrection
Samsung devices keep killed app processes alive and bring them back to
the foreground ~15s later, killing our fresh test instance. force-stop
and kill -9 don't prevent this. pm disable-user fully prevents the
zombie from being restarted. Re-enable before launching the new instance.
2026-09-02 10:14:09 +03:00
c17a21830e android: send BeforeStartup/AfterStartup over websocket
The Android platform never sent BeforeStartup or AfterStartup messages
via the studio websocket. Desktop platforms send these through their
stdin event loops, but Android uses websockets instead of stdin.

Without AfterStartup, the hub never broadcasts AppStarted to UI
clients, causing makepad-test to time out waiting for app startup.
2026-09-02 10:14:09 +03:00
debf970c36 feat(test): extend protocol for touch, long-press, paste, IME composition
Add RemoteTouchState, RemoteTouchPoint, RemoteTouchUpdate, RemoteLongPress,
RemoteTextPaste, RemoteIMEComposition wire structs to StudioToApp enum.

Dispatch new events through cx_shared.rs (TouchUpdate→Event::TouchUpdate,
LongPress→MouseUp+MouseDown, TextPaste/IMEComposition→Event::TextInput).

TestApp: touch_down/move/up, long_press, paste_text, ime_composition.
Locator: touch_down/move/up, long_press, paste, ime_composition.
2026-09-02 10:14:09 +03:00
757849940c feat(widgets): reexport optional Makepad sibling crates
Adds feature-gated optional deps and re-exports (makepad-test, makepad-csg,
makepad-gltf, makepad-mbtile-reader, makepad-fast-inflate) so a downstream
workspace can depend on makepad-widgets as its sole Makepad source.
2026-09-02 10:14:09 +03:00
9e7afd661e feat(test): Android makepad_test via adb + in-process hub (legacy Java path)
Adds the Android test runtime to makepad_test: builds the APK with
cargo-makepad's standard Java path, installs and launches via adb with
makepad.STUDIO_* intent extras (incl. STUDIO_BUILD), connects the app to an
in-process hub over adb reverse, and waits for startup + responsiveness.
Adds clean in-process hub shutdown (HttpServerHandle + GatewayHandle Drop)
and the STUDIO_BUILD intent parsing on the app side. No native-activity or
NDK APK compilation code is included.
2026-09-02 10:14:09 +03:00
Admin
adbb078b68 render: a water volume can be physics without a picture
The water shader blends premultiplied, so an alpha-zero sheet meant to
hide the river's buoyancy boxes became additive and painted bright
stepped rectangles over the ribbon and the meadow — every claim-level
invariant green while the screen was wrong. WaterVolume::draw_sheet
splits physical from visual: river volumes keep floating things and
never draw; declared pools still do.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 04:42:34 +02:00
Admin
e18cc694fc chat: a tool call the model wrapped inside its own think block still executes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 04:42:34 +02:00
Admin
1ef6f59c3e game brief: on a diet and world.plan-first
33.7k bytes to 16.4k: the plan doctrine at the top, the laws, the one-
call list, the build order for a new game, how to read assists and
refusals; per-verb prose left to the verb docs. Back under the context
budget the crate's own test enforces.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 04:11:26 +02:00
Admin
c86a750258 chat: a game session reads its doctrine first and is not the generation assistant
The system prompt chose the generation persona and the <<tool>> JSON
guidance whenever image.generate was among the tools — which a game
session legitimately carries for missing art — and appended the game
doctrine 19k characters later. Against the exact live prompt the model
answered "I can build that for you." and stopped; the same request
with the doctrine first emitted the call. Agentic is now decided by the
presence of world.* tools: doctrine first, an in-game builder persona,
the model's trained tool template, and no duplicate capability text.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 03:37:57 +02:00
Admin
f2b02531b7 drumkit: a sample-based kit for the score preview; the physical model parked as drumkit_phys
The kit that plays drum scores is now a sample player over the Salamander Drumkit (velocity layers with round-robins, derived toms, a synthesised clap), loaded off the audio thread and swapped in atomically. The physically modelled kit stays as makepad-drumkit-phys for a later pass.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 02:52:12 +02:00
Admin
066c78292e piano model: the lower register is hammered, not plucked
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 02:52:12 +02:00
Admin
79882b5fb6 fabric: a photo or a live camera to a fitted sewing pattern
apps/fabric runs SAM 3D Body in-process (install + licence through the hub panel), measures the rest-pose body mesh (libs/fabric/measure: 25 tape measurements from plane slices and landmarks) and drafts parametric patterns (libs/fabric/draft: T-shirt, A-line skirt, easy trousers; nesting, true-scale SVG, tiled A4 PDF). Live mode streams the webcam at a few frames a second, shows the posed body with the measurement rings riding on it, and re-drafts when the numbers settle. Measurements sit in a DataGrid that copies as tab-separated text.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 02:51:45 +02:00
Admin
8ea3684250 widgets: EventOrder reachable from the DSL; DataGrid hit-tests where it was drawn
A View can now say event_order: EventOrder.Down in script. The DataGrid cached its geometry at draw time, before a Fill sibling could shift it, so clicks in a right-hand column landed outside; it re-anchors to its drawn rect on every event.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 02:51:45 +02:00
Admin
cb97fc569f vj: the loop splat — a song sliced into per-stem, beat-quantized loops on the APC40 grid
Eight song sections by drums, bass, vocals, other and mix; launch and swap on the bar, right-click halves and quarters, per-cell waveforms with score blocks, a full-width score popup (drums, bass tab, melody with lyrics) played back through the piano and the sample kit, an NMF drum transcriber, Basic Pitch for pitched stems and Beat This! grid refinement when installed, beat jump and phase-flip buttons, a loading overlay for the grid, and the shared model install panel in INSTALL MODELS.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 02:51:44 +02:00
Admin
120b7e23ae score view: the engraver as a shared library, with drum and pitched score builders
libs/score_view carries the engraving, spacing, font and document code out of the score app so any app can show notation: a ScoreView widget with width/page/content fits, pan and zoom, a playhead, a dark palette, drum-key labels, lyrics under melody notes, and builders for drum, pitched and bass-tab scores. The score app uses it. Vector glyphs no longer write depth for transparent fragments, and the Metal screenshot staging sizes itself from the source texture.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 02:51:44 +02:00
Admin
31e5faaff5 ai-hub: local model runner, licence acknowledgements, a shared install panel; Beat This!, Basic Pitch and the Salamander drum-kit entries
LocalModels runs registry models in-process (install state, resumable downloads, recorded licence acknowledgements at $MAKEPAD_HOME/license_acks.json, weight paths by file role) and libs/ai/hub_ui is the install panel + licence modal every app can embed. New native ports: Beat This! (beats + downbeats) and Basic Pitch (note transcription) with their registry entries; the Salamander Drumkit samples (CC BY-SA 3.0, 37 files pinned by size and sha256) as a sample bank the downloader fetches like a model.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 02:51:44 +02:00
Admin
f4af6df5a0 sim: terrain knows who changed it — a plan layer over player history
Chunks keep the player's bytes apart from the composed density: plan
presses land in an owner-keyed PlanTerrainPatch that is retracted with
its feature and never touches history, so removing a railway takes its
bed with it and a human fill under it survives. Surface queries answer
Surface | Hole | Outside instead of pretending the heightfield continues
over a hole or past the border; landforms issued inside a plan eval are
plan, outside it history. Seven tests pin the layering, including
same-inputs-twice equality.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 02:38:40 +02:00
Admin
c189b035af game brief: agents follow the rules by themselves — you never script a stop
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 02:29:06 +02:00
Kevin Boos
79f938c09d
Splash isolate host: keep inserted subtrees reachable, survive foreign-heap values, contain isolate panics (#1208)
* widget_tree: keep manually inserted children linked across a refresh

A container that owns a child outside its own child vec inserts it with
`insert_child_deep` and never reports it from `children()`, so a refresh
unlinked it from every top-down search and the removal pass then deleted
its subtree. Everything under it went quiet, and invisibly: an empty
`WidgetRef` is a silent no-op.

Mark such children `manual` and keep the live ones linked, clearing the
flag once the parent reports the child itself.

* script: survive a foreign-heap value in the GC mark walk

`GenVec`'s `Index` bounds-checks the raw Vec before the generation check,
and `len` never shrinks, so an index past the end belongs to a different
heap. Aborting the process over one takes the whole app down for a fault
confined to a single script heap.

Add bounds-checked accessors and use them in `mark_value_fields!`, so
marking skips and reports a foreign value; name the table where an index
does still panic.

* splash: contain isolate panics, and report the silent failures

A Splash isolate runs user script on the UI thread, and the VM swap in
`with_isolate_installed` was not panic-safe: a panic could leave the app
VM swapped out, with later script resolving against the wrong heap.
Restore through the unwind, and contain panics at each entry point (pump
arms, timers, host callbacks, isolate GC, body eval, hook calls).

Report what used to fail silently too: callback errors, a script->widget
call whose target is gone, and wrong-VM routing. Adds three headless
regression tests.
2026-09-02 02:26:52 +02:00
Admin
7ff875a33e ai-hub: keep a peer's in-flight beats/notes/local work out of the body commits
The last two hub commits staged whole files and carried uncommitted hunks
of another lane (beats-native, notes-native, the local runner, new
domains and license keys) that reference files not yet in the tree. This
restores those files to the body changes only; the other lane's edits
stay in its working tree.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 01:47:49 +02:00
Admin
8c568dfcb0 ai-hub: drop the SAM 3D Body reference worker backend
The native port covers everything the Python reference worker did (body,
hands, mask prompt, multi-person), so the subprocess backend, its fake
worker harness, the sam3dbody-ref registry entry and the
MAKEPAD_SAM3DBODY_* environment go. The packet validator moves to the
native backend.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 01:45:22 +02:00
Admin
a648cf8808 ai-hub: body session options — hands, detect, persons=N
The sam3dbody backend reads its options from the request's prompt string
(`hands`, `detect`, `persons=N`): `hands` runs the full mode and the
packet carries which hands were fused and their boxes; `detect` finds up
to N persons with SAM 3.1 (an optional native-segment role on the body
entry, the same artifact the segment entry pins) and runs one body pass
per person with its box and mask, so the packet's people array grows.
The body crate shares one body pass between the packet, mask and hands
entry points, and infer_full takes the mask prompt too.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 01:36:25 +02:00
Admin
62dff26092 ai-body: the mask prompt — a person's segmentation mask conditions the body pass
BodyModel::infer_masked takes a provided person box and its full-frame 0/1
mask: the mask is warped with the crop's own affine (bilinear, rounded back
to 0/1 like the reference's uint8 warp), encoded by the prompt encoder's
mask CNN (four stride-2 convolutions with channel LayerNorm and erf GELU,
then a 1x1 to 1280) and added to the backbone tokens before the ray
conditioning, replacing the folded no-mask term. Oracle parity on the
mask fixture: warp exact, every CNN stage within f32 accumulation noise,
conditioned context 8e-4, end to end kp3d 2.2 mm / kp2d 0.25 px. 41 tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 01:29:15 +02:00
Admin
c9e6d88bf1 ai-body: the hands pass — hand crops, the hand decoder, the hand-mode rig and the wrist fusion
BodyModel::infer_full runs the reference's full mode natively: the body
pass's hand boxes become two 512 crops (the left one cut from the mirrored
image, padding 0.9), each goes through the backbone, its own ray
conditioning and the hand decoder (the body decoder with the *_hand
tensors), the hand-mode rig pre-transform (local_to_world_wrist, wrist and
root offsets, non-hand parameters zeroed) and the hand camera head's
scale factor of 10; the left result is un-mirrored. The fusion gates each
hand (wrist angle, box size, keypoint spread, wrist distance), re-prompts
the body decoder with the trusted wrists and elbows as point prompts (the
decoder now takes N prompt tokens and a previous estimate), and writes the
fused wrist angles, hand parameters and hand scale/shape back before the
final rig pass. pose.rs gains the roma xyz (extrinsic) and XZY (intrinsic)
euler pairs; mhr.rs returns joint global rotations.

Oracle parity on both full-mode fixtures: every hand-decoder stage per
step (tokens 7e-3, heads 4e-4, rig params 1e-4), fusion reports as the
reference (one hand trusted on the standing photo, both on the crop), and
end to end kp3d 1.6 mm / kp2d 0.85 px. 36 tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 01:26:21 +02:00
Admin
0e94bbac42 game brief: rivers, highways, junctions and gates are one call
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 01:23:58 +02:00
Admin
525ba1c2f4 metal: a device-resident two-way decoder layer, and the body decoder rides it
One backend call per SAM-style decoder layer (PE norms, token
self-attention, token-to-image cross-attention, erf-GELU feed-forward,
final norm) inside one command buffer, with the layer's f32 weights cached
on the device under their content identity and pooled transients.
gpu_two_way_layer_resident in the common backend; CUDA declines and the
per-op path stays. Body decoder loop on the M3 Max: layers 100 -> 9 ms,
frame 405 -> 266 ms, oracle parity unchanged (kp3d 1.5 mm).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 00:25:59 +02:00
Admin
d006d0a1bc metal: resident f32 linears keep their weight on the device
gpu_linear_f32_resident promised a resident weight but the Metal tensor
backend re-uploaded it every call, which for the MHR rig's 664 MB pose
corrective matrix cost 50 ms a step. Metal tensors now carry a content
identity (fresh at creation and after every in-place write, so a cache
keyed by it can never serve a stale weight), and the resident linear caches
the device buffer under it. Body frame on the M3 Max: 705 -> 405 ms (rig
302 -> 20 ms, refine 41 -> 13 ms, heads 10 -> 6 ms).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 00:20:46 +02:00
Admin
d53c77d4a5 metal: a device-resident ViT stack, and the body backbone rides it
The Metal tensor backend is host-Vec based: every op copied its inputs up
and its result back, which put the DINOv3 backbone at 2.1 s a frame on an
M3 Max. try_vit_backbone_resident_f32 runs a whole pre-norm ViT stack
(LayerNorm, q/k/v, rotate-half rope from tables, flash attention, out,
LayerNorm, SwiGLU, down; residuals in place) inside one command buffer
against cached bf16 weights, with a new kernel_makepad_rope_half_tables_f32
kernel for the rope. The precompiled metallib now carries the bf16 GEMM
kernels the runtime source compile already enabled on bfloat devices.

The common backend exposes it as gpu_vit_backbone_resident (CUDA declines;
its per-op path is already resident) and the body backbone tries it first:
233 ms a frame on the M3 Max, same oracle parity (kp3d 1.5 mm).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 00:16:31 +02:00
Admin
a2aaa8f778 ai-body: the FP8 bias rides a column-broadcast add on the device
The FP8 backbone mode added each linear's bias by downloading, tiling and
re-uploading it per call, which cost more than the FP8 GEMM saved. A
gpu_add_cols_broadcast op (CUDA kernel; host loop on the Metal tensor
backend) adds a cols-wide bias to every row on the device, and each linear
keeps its bias resident after the first upload.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 00:05:03 +02:00
Admin
8964ba6313 ai-body: an FP8 backbone mode, off by default, measured against the oracle
BodyModel::set_backbone_fp8 quantises every backbone weight to E4M3 with
a per-tensor absmax scale and runs the tensor-core FP8 GEMM (bias
broadcast after). A backend without FP8 turns it off per layer on the
first refusal, so Metal keeps bf16. The oracle test reports its
accuracy and timing next to bf16; the default stays bf16 until the
numbers say otherwise.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-01 23:54:43 +02:00
Admin
a9ce596e07 ai-body: the crop warp runs across cores
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-01 23:52:13 +02:00
Admin
7598346ff5 ai-body: tensor-core GEMMs for the backbone, and the rig's correctives only where they count
The backbone's linears now take the cuBLASLt bias-epilogue / bf16 mm
paths (bf16 operands, f32 accumulation, bf16 output — the reference's
precision) with the f32-accumulating GEMM as the fallback. The rig's
pose correctives run on the final refinement step only by default: the
intermediate steps only feed keypoints back into the decoder, and the
oracle shows the difference is 0.4 mm (2.1 vs 1.7 mm against the
reference) for half the loop's rig time; `correctives_every_step` keeps
the exact mode.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-01 23:49:46 +02:00
Admin
4be6d19695 ai-body: the test modules import the grid constants they still use
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-01 23:43:53 +02:00
Admin
45b5b98612 ai-body: the crop size is a runtime knob, and the loop reports where its time goes
The backbone, conditioning and decoder now take any square crop whose
side is a multiple of 16 (512 is the trained size); BodyModel::set_crop_size
selects it and caches the dense positional grid per size. Measured on the
oracle image against the reference: 512 gives 1.7 mm mean keypoint error,
384 and 256 about 2 cm mean (6 to 7 cm worst joint), 192 falls apart —
the knob is a real accuracy trade, not free speed. The decoder loop
reports its split (layer chain, heads, rig+camera, refinement) so the
next optimisation is chosen on numbers.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-01 23:33:26 +02:00
Admin
97e9572f42 speech: one STT/TTS API on every platform, through the ai-hub
Apps ask the hub for a recognizer or a voice and get one; where it runs is
the hub's decision. AiHub::start_stt / start_tts return poll-driven
sessions shaped like the chat session. The Auto ladder is Whisper/Kokoro in
this process (weights present, machine election), on the machine node over
loopback, on a LAN node, else the OS engine; SpeechReach::Local is the
"don't reach out" knob. Audio always comes back as PCM: the app owns the
device.

Three layers:
- makepad-ai-speech is the whole speech model family, engines only.
  libs/voice (Whisper + Silero VAD) folds in as the `whisper` and `vad`
  modules next to kokoro and indextts, each a cargo feature; the Apple
  bridges and the Speaker/VoiceTranscriber selection leave it.
- makepad-system-speech (new) is the OS speech services as blocking fns:
  Apple SpeechAnalyzer/AVSpeechSynthesizer via Swift, Windows.Media.Speech*
  on the vendored bindings, Android SpeechRecognizer/TextToSpeech through
  MakepadSpeech.java (API 26 floor), espeak-ng on Linux. It models the two
  STT shapes honestly: PCM in (Whisper, Apple) versus an engine that owns
  the microphone (Android, Windows), with capabilities the caller reads.
- the hub grows speech sessions, in-process Whisper/Kokoro workers with the
  residency election, a `whisper` wire backend (stt domain, registry entry
  pinned to ggerganov/whisper.cpp) so a Mac can serve a Quest, and a
  `language` field on the generate request.

Consumers: the Window voice input runs on an STT session and switches to
engine-mic mode when the recognizer owns the microphone; converse's
SpeechOutput is a lazily started TTS session plus a pump thread; route
drops its private speech copy for converse; vj's lyrics fallback and the
alignment bakes call the engines directly.

Verified here: speech-roundtrip through the real sessions (Apple voice in,
in-process Whisper on Metal out, 4.3% WER); system-speech-test TTS->STT
verbatim; hub/converse/system-speech unit tests; msvc, aarch64-android and
linux-gnu cross-checks; Java against android-34. Windows, Android and Linux
bridges are compile-checked only.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-01 23:32:35 +02:00
Admin
0fd356dbf0 windows: the vendored bindings are generated from a checked-in filter
libs/windows/windows-rs/src/Windows/mod.rs was a hand-pruned snapshot with
no recipe behind it; adding a WinRT namespace meant hand-copying vtables.
Now tools/windows_bindgen (standalone, windows-bindgen 0.62.1) regenerates
it from filter.txt: package mode with the COM `_Impl` traits, Win32 imports
linked through windows_core, upstream's 800-column rustfmt (the repo-root
rustfmt.toml disables formatting and has to be overridden), the filter
closed over every dependency the generator would otherwise skip a member
for, the namespace tree folded into one file with every feature gate kept,
and the two spots where the published 0.62.1 generator predates the vendored
core 0.62.2 normalized (Error::from_thread, imp::array_proxy).

The filter is the old file's item list plus Windows.Globalization.Language,
Windows.Media.SpeechRecognition and Windows.Media.SpeechSynthesis for the
OS speech engines. The generated `deprecated` gate is declared as a feature.
Checked on x86_64-pc-windows-msvc: platform, video, network, mpterm,
system-speech.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-01 23:32:35 +02:00
Admin
346f31f8c4 ai-body: flash attention for the head-dim-64 blocks
The backbone (20 heads) and the decoder (8 heads) both have 64-wide
heads, which the stack's FA2 kernel covers: f16 operands with f32
softmax and accumulation, the reference's own precision class, instead
of the composite path that materialises the 1029 x 1029 scores per head.
The composite path stays as the fallback where a backend lacks the
kernel. Oracle parity unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-01 23:15:56 +02:00
Admin
b22259b542 ai-body: the context stays on the GPU; only the pose token leaves the loop
The ray-conditioning conv used to download the 1024 x 1280 embedding,
concatenate the 99 ray features on the host and upload the result; it is
now two resident linears over the two column blocks with the no-mask
term folded into the bias, plus an add (32 ms -> a few on the 4090).
The decoder loop downloaded the whole normalised token block every
layer for its one pose row; it now slices that row and fetches the
block once at the end (or per layer under a trace). Same numbers.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-01 23:13:46 +02:00
Admin
d78411a63b ai-body: the per-step work moves to the GPU
On the 4090 the frame was 885 ms of which 840 were host scalar loops in
the decoder loop: the three 70-row refinement FFNs (about 240M
multiply-adds a step), the two heads, and the rig's 55k x 3000
corrective output layer plus its identity blendshape sum. Those are now
GPU-resident linears (heads.rs GpuStepHeads, MhrRig::prepare_gpu), the
skinning computes one transform per joint instead of one per influence,
and the results are identical (all 29 oracle tests unchanged). The rig
still runs entirely on the host when no GPU side was prepared.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-01 23:12:05 +02:00
Admin
6a1c16b2cb ai-body: third-party notices — what the port is implemented after, and what it is not
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-01 23:05:43 +02:00
Admin
9ff44e87a6 ai-hub: the body-native wiring, this time only the lane's hunks
The recut in a6341981d applied its patch against the wrong directory
and restored the six shared files to the previous tree without the
body-native hunks. This commit adds exactly those: the `body-native`
feature and optional dependency, the pinned `sam3dbody` registry entry
and its test, the backend arms and the module declaration. Working
tree untouched.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-01 23:03:47 +02:00
Admin
a6341981d8 ai-hub: the body-native commit carried a peer's in-flight hub hunks; put them back where they were
66e5e2f11 committed the working tree of the shared hub files and with it
another lane's uncommitted edits (a new domain, request fields, a
backend arm, manifest lines). This commit restores those files to the
previous tree plus only the body-native hunks. The working tree is
untouched: the peer's edits stay on disk as their uncommitted work,
exactly as before.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-01 23:03:20 +02:00
Admin
66e5e2f117 ai-hub: SAM 3D Body runs natively — sam3dbody on the body domain, oracle-matched end to end
model.rs closes the loop: crop -> backbone -> ray-conditioned context ->
six decoder steps, each through the pose head, the rig, the camera and
the projection, then the packet the sandbox already reads (kp3d/kp2d in
camera axes, the 204 rig parameters, global rotation, camera translation,
joint positions). Against the reference on the oracle image, on Metal:
3D keypoints within 1.7 mm, 2D within 0.4 px, rig parameters, camera
and rotation within 2e-3. packet.rs writes the JSON by hand with the
reference worker's rounding and field order.

The hub gains the `body-native` feature (default on): registry entry
`sam3dbody` pinned to the Comfy-Org repack by revision, size and sha,
body_native_backend.rs beside the subprocess reference backend with the
same live_step contract, the `body` capability advertised when the
feature is compiled, and a stubbed test double for the CPU-only tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-01 23:01:49 +02:00
Admin
69d842c400 ai-body: the promptable pose decoder and its refinement loop, oracle-matched on Metal
decoder.rs builds the 145-row token set (pose, previous, prompt, two
hand-box rows, 70 keypoint rows, 70 3D-keypoint rows), runs the six
layers with the SAM-style repeated positional encoding (none on the
first layer's self-attention), cross-attends against the ray-conditioned
context, and after each step hands the normalised pose token to the
heads and updates the keypoint rows from the caller's feedback: the
2D-keypoint positional FFN and the bilinearly sampled context features
on the valid rows, the pelvis-centred 3D positional FFN on the rest.
heads.rs holds the host-side ReLU FFN heads, the refinement FFNs, the
hand-box MLP and the hand classifier. Against the oracle on Metal, every
layer's residual stream is within 6e-3, every step's pose head within
8e-4 and camera head within 2e-5; token assembly and the host heads
match to 1e-4.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-01 22:58:29 +02:00
Admin
9e343a8f20 ai-body: the DINOv3 ViT-H+/16 backbone, crop and ray conditioning; Metal gains rope-half and affine layer norm
dino.rs is the 32-block ViT-H+/16 on the gpu_* surface after the TRELLIS
conditioner: bf16-resident linears with f32 accumulation, layer scale
folded into the output projections, rotate-half rope, SwiGLU, the
model's own final norm. preprocess.rs is the crop (box -> 1.25 pad ->
3:4 -> square -> 512 bilinear warp, ImageNet normalisation), the CLIFF
condition vector and the patch rays; condition.rs is the dense
positional encoding and the ray-conditioned decoder context. Against the
reference oracle on Metal: backbone 1.1% mean relative (bf16 noise),
ray-conditioned context 8e-5, crop within one u8 rounding step, rays and
dense PE 1e-7. One reference detail the paper does not state: its shrink
of the ray field is an antialiased filter whose taps clip at the image
edge, so the two edge patches sample inside their block centre (9.03 and
501.97 rather than 7.5 and 503.5); block centres left 0.1 of error.

The Metal tensor backend was missing rope_half (it aliased the
interleaved layout) and layer_norm_mul_add; both now exist with the
CUDA contract, which is what lets this backbone run on Apple silicon.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-01 22:56:46 +02:00
Admin
8211ae6d85 ai-body: the MHR rig and the pose head's parameter decoding, oracle-exact
mhr.rs is the Momentum Human Rig on the CPU: blendshapes, the 889x249
parameter transform, parents-first similarity kinematics (x,y,z,w
quaternions, Rz Ry Rx eulers, 2^s scales), the sparse-then-dense pose
corrective MLP over joints 2.., linear blend skinning and the 308-row
keypoint regression. pose.rs decodes the 519-wide head output: 6d
rotations, the 23 ball / 58 hinge / 6 translation body layout, the
mixed-dof hand layout, scale and hand component bases, the camera
translation and the perspective projection. Against the reference
oracle: vertices within 1e-4 cm with correctives on, keypoints within
1e-6 m, rig parameters within 1e-7. Two conventions the spec could not
settle on paper are now settled by the fixture: the head's global
rotation triple arrives Z,Y,X-ordered, and the corrective features start
at joint 2 (750 wide). Fixture tests skip cleanly without the oracle
directory or the weights.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-01 22:51:19 +02:00
Admin
f51b5f3bbf ai-body: the crate for the native SAM 3D Body port, with its weights reader
makepad-ai-body joins the AI model workspace: the constants of the
architecture (DINOv3 ViT-H+/16 at 512, the 6-layer promptable decoder,
the 519-wide pose head, the 127-joint / 18439-vertex MHR rig) and the
single-file safetensors reader for the Comfy-Org repack, which fails
closed on a Meta checkpoint-style header and checks the shapes the port
is written for at load. The backbone, decoder and rig modules follow
in their own lanes against the spec under local/agent_state/sam3dbody.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-01 22:31:12 +02:00
Admin
294cb9ea47 video: the single-frame mp4 is written on every platform, not stubbed
encode_intra_frame_mp4 on Windows and Linux now goes through the
platform file encoder (Media Foundation sink writer, GStreamer): one
keyframe-only stream, one frame, a scratch file read back and removed.
It pays the container machinery the Apple session path avoids, but a
tile tape baked on any machine reads on any other, which is what the
image-tiles library sits on. A round-trip test encodes a frame and
decodes it through the platform decoder on whichever platform runs it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-01 22:24:40 +02:00
Admin
929f822ca5 video: the single-frame mp4 encode exists on every platform
encode_intra_frame_mp4 was macOS-only, and libs/image_tiles imports it
unconditionally, so image-tiles, its example and source-library did not
build for Windows (found by a Windows-target cargo check sweep of the
workspace; every other crate checks clean). Other platforms now get the
same signature answering with an error, the way the rest of this file
stubs what it cannot do yet.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-01 22:20:43 +02:00
Admin
b7fe32cf07 game brief: a destructible world, path-edited railways, and models that load now
The doctrine catches up with the engine: landforms and tunnels are one
call and digging works anywhere; railways and roads are edited by their
path points (crossings generate, styles switch, any model drives);
freshly modeled aliases are live immediately — never park a display
substitute.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 22:15:45 +02:00
Admin
5692fabbdf sim: the landform world proves itself — walker through the tunnel included
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 22:15:45 +02:00
Admin
3fcccf3b47 sim: the whole world is implicitly editable, and one seam says where the ground is
The voxel layer's volume gate is gone — any edit materializes chunks
anywhere on the map (volumes remain as styling), with the lazy-chunk
economics proven by test: zero storage until the first op. The composed
surface seam (GameWorld::surface_height_at: heightfield where untouched,
voxel surface where a chunk owns it) is what ground queries, senses and
the drape corridor read, so a mountain raised mid-game and a dug crater
are the same truth the vehicles drive on. landform.rs adds the macro
shapes (mountain/hill/ridge/valley/crater/plateau, fbm detail, slope
rock + snow recolor) and the tunnel op rides the capsule-filtered
materialization.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 22:15:38 +02:00
Admin
f7093bf82f map_nav: the search db's positioned read builds on Windows
searchdb imported std::os::unix::fs::FileExt for read_exact_at, which
does not exist on Windows (reported from a Windows checkout of work).
A small ReadExactAt trait now wraps unix pread and Windows seek_read,
both cursor-free, so the shared reader keeps serving lookups from
several threads without a lock. Checked on x86_64-pc-windows-msvc.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-01 22:13:13 +02:00
Admin
914471fa31 ai-hub: a feed session whose last socket left ends on its idle timeout; skin: parent, skinned centroid and a nodes-only rig for retargets
A realtime feed session parked in its wait-for-a-frame loop forever when
the client died without sending stop (a sandbox quit left job-2 live on
.123 for five minutes holding the GPU slot). The wait loop now returns
to the top of the session loop once no socket is left, where the idle
timeout counts a socketless session down. Test covers it.

SkinnedModel gains node_parent/node_count, joint_skinned_centroid (the
direction a leaf limb actually runs, from the flesh it skins) and
from_nodes (a mesh-less rig for hierarchy-maths tests) — what the
sandbox's webcam mocap retarget needs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B626urtY1Xo4hQdLzvSK6F
2026-09-01 22:06:29 +02:00
Admin
895b9a71c3 particles: an emitter can ride a body's own frame
EmitterAnchor::EntityLocal carries an offset that yaws with the entity —
a locomotive's chimney keeps its smoke over the funnel through every
corner. The step lookup resolves (position, yaw) instead of position.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 20:51:32 +02:00
Admin
ea50c77970 chat_ui: the feed's session gets its profile brief back
Same orphaning as the sandbox: context::assemble had no caller post-P8,
so gen/vj sessions ran brief-less. The executor's capability doc leads
with the assembled profile layer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 20:36:55 +02:00
Admin
c18b7f4169 game brief: railways are one call too
The verbs existed with the right doc strings, but the model reads the
brief, not the verb table — and the brief's ONE CALL list never said
railway, so it hand-placed track pieces, horrendously. Now it says it,
with the two-line essential shape.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 20:20:04 +02:00
Admin
b0380bac34 image-tiles: the picture-wall engine as a library — tape atlases, a baker CLI, and the TileGrid widget
The Source Library wall's pixel engine, extracted for anyone's pictures:
libs/image_tiles carries the NV12 slot pyramid, the 32x32 shard atlas
geometry and the one-HEVC-intra-frame-per-file tape codec (tape.rs), a
small SQLite index (db.rs), a priority decode pool (store.rs), and the
TileGrid widget — instanced tiles batched per atlas page, anchor-locked
wheel zoom with log-space glide, continuous per-shard LOD with
crossfades, full-resolution promotion under byte budgets with LRU
eviction, and uniform-only re-presents inside the pad of the last build.
image-tiles-bake downloads a manifest of URLs (fetch pool free to be
wide, encode pool hard-capped — concurrent VT session churn has panicked
the encoder's IOMMU) and bakes a library; examples/image_tiles views one.
No JPEGs on disk; decode and encode are the hardware's.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 20:19:56 +02:00
Admin
0d7185e0de asset chat: catalog SQL answers in-process, and the game brief stops teaching the y mistake
Post-P8 the game session still advertised assets.query/assets.schema but
the in-process executor answered both Unavailable — the model's main
alias-search tool was dead, and every "find me X" turn gave up on the
store. Both now execute over the server's bounded /v1/assets/query route
(schema via sqlite_master plus the usage notes). The game brief gains the
new-game-vs-edit rule, interior design law, generate-after-search, and
explicit y semantics so player_pos().y stops being pasted into
ground-relative fields.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 20:18:31 +02:00
Admin
237da90a36 render: the sprite lane hands the screen draw back the way it found it
One DrawSceneScreen serves both the in-world video screen and every
sprite billboard, and the sprite loop left its last pose, size and atlas
behind — so the next frame's "is there a screen" gate (zero screen_size
draws nothing) read the leftover and drew the whole spritemap as one
opaque quad: under the last-drawn unit in C&C, and hanging in the sky of
any level that draws no sprites at all. Snapshot the host's pos/size/
texture before the lane and restore after.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 19:54:29 +02:00
Admin
6251f7c615 ai-hub: body domain — live pose packets ride the realtime session
New `body` backend (sam3dbody-ref): a persistent length-prefixed-PNG /
JSON-lines worker subprocess seam with ready handshake, per-frame timeout,
bounded restarts. LiveFrameOut grows aux_json — structured per-frame JSON
sent to the client before the frame — and output_encoding "none" makes a
session pose-only (refused with loop_mode feedback, also on control flips,
which upgraded apply_control to Result). Worker code+model stay
box-provisioned via MAKEPAD_SAM3DBODY_WORKER; the repo carries only the
MIT seam. Codex lane + Fable review (ready handshake, spawn timeout).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0165w1ZL1f1TruX5u2qC7mSX
2026-09-01 19:05:34 +02:00
Admin
176433a56e tweaker: click-again climbs the pick — the container under the children is reachable
The deepest-wins pick could never land on a container its children fully
cover (the chat panel, any rounded-bg pane). Now clicking inside the
pinned widget again walks the pin up one ancestor per click, skipping
design-transparent views, zero-rect wrappers and ancestors whose area
misses the click (a splitter's grab bar); at the window the climb wraps
back to the deepest pick. The window itself is never borrowed — it is
mid-dispatch when the handler runs (tree lookups decide before any
widget borrow).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 18:18:30 +02:00
Admin
915ce7c4e2 asset-server: the embed module — fully-local mode becomes shared
The VJ's attach-or-host logic (health probe, beacon listen, the main
store's server.lock as proof-of-life; loopback-only Host with the library
publisher, rooted in the main library when one exists) moves into
makepad-app-asset-server as embed::resolve, steered by <PREFIX>_ASSET_
EMBED/ROOT/PORT. vj/local_store.rs becomes the thin VJ-flavoured wrapper.
First second consumer: the sandbox.
2026-09-01 18:01:21 +02:00
Admin
0131ba49ed mpwm: --test-action launch-<app> — put one registered app on the desk, deterministically
the existing startup test hook learns the launcher's verb, which is how a
hidden instance hosts a real child for framebuffer verification.
2026-09-01 17:43:47 +02:00
Admin
df265f7a55 vj: a short console draws smaller — height joins the scale chain
console_scale defended the width and rearranged the very short, but
between the fold thresholds (~820 points) and the height the console
actually needs (~1000) lay a dead zone where the lower region simply
clipped — an mpwm tile at 844 points lost its rail from MAP down and half
a grid row. TARGET_HEIGHT_POINTS closes it: console_dpi now takes the
window's physical height and a short console shrinks exactly as a narrow
one does, to the same MIN_SCALE floor, before the folds take over —
measured in the shrunk points. A wide, short window still prefers the
lists-beside arrangement (judged at native scale, so the chain stays a
pure function of the physical window). The whole rule chain threads the
height; tests cover the tile shape that exposed this and the new ladder.
2026-09-01 17:43:47 +02:00
Admin
d5a6d64d1b vj: LIGHTS and ARCHIVE are rail chips under MAP — any content chip is the way back to the grid
The tiny icon page-tabs are gone. The special pages join the lane rail as
first-class chips: when one is up no content lane reads selected, and
clicking VIDEO (or any lane) flips the lower region back to the grid — the
persisted ARCHIVE page can no longer read as an empty library.
2026-09-01 17:21:35 +02:00
Admin
1d97d63e2f rustc 1.98 sweep: script module glob imports nothing uses, and strlen declared as libc has it
1.98's sharper macro-use tracking flags the live-id macro globs five script
modules kept without using; windows-strings' strlen extern takes *const
c_char with a cast at the two call sites instead of tripping
suspicious_runtime_symbol_definitions (PCSTR is transparent over *const u8,
so the ABI never changed).
2026-09-01 17:12:15 +02:00
Admin
e4a2e75d66 ai-hub: backend imports and helpers follow their feature gates
the vision and ocr backends' ContentBackend machinery lives behind the
llm feature; the imports and the lane-refill helpers now do too, so a
client crate compiling the hub with a leaner feature set (vj, chat-ui)
builds without warnings. pipeline_submit_collect stays feature-free for
its tests and says so.
2026-09-01 17:09:26 +02:00
Admin
89fe23fb68 vj: the self-hosted store is the full host — the library publisher rides along, loopback only, no beacon
Attach still wins whenever discovery finds a server (health probe, UDP
beacon, main-store lock). When nothing answers, the VJ now boots the
standalone asset-server's Host instead of a bare AssetServer: catalog +
CAS plus the ai-content library publisher over local/ai_content_library,
so a self-hosted VJ sees the same rows asset-ui publishes — not an empty
seed store. The fallback root is the user's main library when one holds a
catalog (server.lock arbitrates the race; losing it means attach). Both
planes bind 127.0.0.1 and no beacon runs: a private host is not a thing
the network can discover. HostConfig grows a blob_refs knob so the VJ
keeps its reference-import policy; the store decision is logged at boot.
2026-09-01 17:09:26 +02:00
Admin
e6c84d9b6a zune: the crate-root macro_use attrs go — rustc 1.98 phases the inner form out
zune-core's was load-bearing (alloc macros, no_std) and becomes the outer
form on its extern crate; bmp, jpeg and qoi never used it — deleted.
2026-09-01 17:08:31 +02:00
Admin
46cd1735d0 chore: workspace members and remaining sources
Squashed from work:
- frametween: the VJ's in-betweener becomes a library — the whole mode set, one definition
- rtsmap: one seeded generator for tiled strategy maps
- workspace: register the score crates, archive_org and mp4_index
- map: bake a runnable Amsterdam test map from inside the route app
- platform: file dialogs on Android and iOS, and unbreak the Android build
- ai-hub: makepad-asset-ai becomes makepad-ai-hub at libs/ai/hub, the chat pane becomes makepad-chat-ui, the service bin
- strict-json: the dependency-free JSON module gets its own crate; asset-client re-exports it so nothing downstream move
- asset-creator: the pipeline library is born — specs, the deps gate, and the derived-state law (aicore §9)
- makepad_ai is deleted — every backend is a hub pipe, the agent seam lives with its consumers (aicore §14, decided 2026
2026-09-01 16:46:35 +02:00
Admin
0257d6c6dc engine libs: render and sim for a strategy round, the per-unit decision hook, the mp4 sample index, stitch and xatlas quieted, example refresh
Squashed from work:
- docs: button shader annotations — widgets/button.rs complete + splash demo buttons
- tweaker: the Shader tab shows the pinned widget's ANIMATOR STATES as little posed swatches under the well — one per tr
- widgets: the glass stops reading draw_pass.time, so glass apps idle again
- render, sim, platform: what a strategy round needs underneath
- draw, platform: overlays now composite above content that uses draw_depth
- asset: mp4 sample index for range-streaming, chat tools, import profiles
- platform: native file and save dialogs, in-house on all three desktops
- sim: a per-unit decision hook — one commandable unit can think for itself, before the kit steers
- makepad_ai is deleted — every backend is a hub pipe, the agent seam lives with its consumers (aicore §14, decided 2026
- libs: the zero-warning sweep — stitch casts say what they mean, xatlas keeps upstream's surface quietly
- zero-warning sweep, round two — the first full-workspace pass
- zero-warning sweep, round three — the model lanes and the deep examples
- zero-warning sweep, round four — the last stragglers
2026-09-01 16:46:35 +02:00
Admin
87474bd68d finance: a personal ledger, budgets and reports on the DataGrid
Squashed from work:
- finance: a personal ledger, budgets and reports on the DataGrid
- zero-warning sweep, round two — the first full-workspace pass
2026-09-01 16:46:35 +02:00
Admin
79ccb10e81 mp*: the treemap camera era — canon-space packing, 2.5D and orbit views, streaming walks, the qwen sidepanel; mpwm linux desktop integration; pdf_parse recovery
Squashed from work:
- pdf_parse: indirect /Length, and recover the glyph names a symbol font uses
- mpwm: make the Linux desktop integration usable (#1204)
- mpfiles: the size map goes all the way down, and remembers what it found
- mpfiles: the treemap walk streams every directory at every depth — no more opaque growing blocks over deep heavy subtr
- mpfiles: local qwen chat sidepanel over the treemap selection
- mpfiles: a camera over the treemap — scroll zooms at the cursor with real re-layout detail, drag pans, double-click fi
- mpfiles: a 2.5D treemap — the map extrudes by nesting depth under a 3d toolbar mode
- mpfiles: a real orbit camera over the block view — left-drag orbits, right-drag pans, click stays a click; the project
- mpfiles: the wheel glides, the filter stops paying per frame and per keystroke — measured weights cached against the t
- mpfiles: the 3d view wears no name tags — labels only on hover, the tooltip carries the numbers
- mpfiles: a camera gesture never re-flows the map — mid-motion the picture rides one rigid remap of the last layout, an
- mpfiles: a small zoom keeps the map still — the layout is only remade when the camera truly leaves it (a 1.3x detail b
- mpfiles: a camera reveal is not an arrival — cells the cull swings into view appear instantly at full strength, cells
- mpfiles: the treemap packs in canon space — the arrangement is zoom-invariant by construction; zoom only refines the t
- mpfiles: the map stops breathing — insets are fractions of map space, names reserve no room and float on scrims with a
- mpfiles: a camera settle is calm — zoom detail materialises in place, no arrival fade; the ceremony stays with filter
- mpfiles: camera motion animates nothing but the camera — flat labels freeze per relayout and ride the remap; a calm se
- mpfiles: a fast zoom-out never outruns the map — cull escape relayouts the same frame, the glide's destination is laid
- mpfiles: wheel zoom in the raised modes anchors on the tile top under the cursor, not the ground hiding behind it
- mpfiles: first consumer of the ai-hub — the ask panel rides AiHub::start_local_chat, its private engine is gone (aicor
- ai-hub: chats run the machine election — route to a serving holder, wait on a loading one, claim and publish when open
- zero-warning sweep, round two — the first full-workspace pass
- zero-warning sweep, round three — the model lanes and the deep examples

Co-authored-by: Alex <blackanger.z@gmail.com>
2026-09-01 16:46:34 +02:00
Admin
517308e676 vj: deck sync as a held lock, archive.org as a content source, frametween becomes a library, runs execute in the app
Squashed from work:
- vj decks: sync is a held lock — pinned master, per-pump rate servo
- vj: the deck explorer opens on music
- frametween: the VJ's in-betweener becomes a library — the whole mode set, one definition
- frametween: the gate says which tier moved the picture, from pixels
- frametween: BGRA words go in as they are, and a host may own the clock
- vj: archive.org as a content source
- platform: native file and save dialogs, in-house on all three desktops
- vj: rounder slider caps, and the cap body reads as a blob
- ai-hub: makepad-asset-ai becomes makepad-ai-hub at libs/ai/hub, the chat pane becomes makepad-chat-ui, the service bin
- vj: DREAM runs execute in the app — pipelines.rs becomes the run it used to watch (aicore §9 / F1)
- vj: plain generations execute in the app too — the store's job queue loses its last vj client (aicore §9 / F2)
- asset-creator: the runner — generate one thing and put it in the catalog, one implementation for every surface (aicore
- importer + asset-server host: the coordination era ends (aicore P7)
- client + chat dispatcher: the dead wire comes out (aicore P7/P8)
- zero-warning sweep, round five — vj and chat-ui
- zero-warning sweep, round six — three cascades
- zero-warning sweep, round seven — the last two
2026-09-01 16:46:34 +02:00
Admin
c460905e2a maps + route: the self-baking Amsterdam test map, reversible mkmap weave, the rtsmap generator, road faces, guarded first-run
Squashed from work:
- rtsmap: one seeded generator for tiled strategy maps
- map: bake a runnable Amsterdam test map from inside the route app
- route: the first-run test map starts itself, and its buttons are guarded
- map: the test map bakes its road faces too, from the same shared pass
- map_tiles: mkmap-extract — the weave is reversible, world-cells reconstructible from world.mkmap
- route: the local dispatcher rides the hub — its copied engine is deleted, limits preserved (aicore P1)
- route + converse: off makepad_ai — the Agent seam moves to converse, route's cloud dispatcher rides the hub's Claude p
- ai-hub: chats run the machine election — route to a serving holder, wait on a loading one, claim and publish when open
- libs: the zero-warning sweep — stitch casts say what they mean, xatlas keeps upstream's surface quietly
2026-09-01 16:46:33 +02:00
Admin
145d0b1fe2 score: the notation suite — engraving, layout, playback, midi and musicxml import, the physical piano model, and the score app
Squashed from work:
- score: a headless music engraving, playback and notation engine
- score: the notation app — pianist mode, editing, playback
- piano_model: it was a plucked string by construction, and 20 voicings
- score: one document you can pan, zoom and navigate
- piano_model: the body tap was a click, and the objective was rewarding noise
- score: add the sound panel and library modules
- piano_model: a second engine, and the attack that finally sounded right
- score: two instruments, reverb and brightness — and the rest of the panel gone
- score model: a note remembers how it was struck, and the score remembers the pedal
- score import: keep the velocities and the pedal the file was carrying
- score playback: play the performance, not a flattened copy of it
- score ui: the music list moves to the sidebar, and the view stops fighting itself
- score: the application ships its font and eight performances
- piano_model: a limiter that rides the music, so the knee stops shaping chords
- piano_model: the forte bell was the treble's dynamic slope, and the bass was dying at its own prompt rate
- piano_model: the bridge decides each partial's decay, and a fixed multiplier cannot say that
- piano_model: each partial gets its own two coupled modes, from the eigen algebra
- piano_model: a median that fell between the peaks made every bass partial a drain
- score-ai: LocalBroker — the seam's in-process implementation over the session engine (aicore P8)
- client + chat dispatcher: the dead wire comes out (aicore P7/P8)
- score_pdf: the score model grew a pedal map — the pdf importer initialises it
- libs: the zero-warning sweep — stitch casts say what they mean, xatlas keeps upstream's surface quietly
- score app: the shipped-piece test speaks the PERFORMANCES table
- zero-warning sweep, round three — the model lanes and the deep examples
2026-09-01 16:46:32 +02:00
Admin
e37c263b9c ai backbone: the hub era — makepad_ai deleted, every backend is a hub pipe; machine residency elections, job leases, ETA placement; the store only stores; creator pipelines run in the app (aicore)
Squashed from work:
- asset-ai: FastH3 4-step fast video backend; clip keyframes on the wire
- asset-ui: loop video chains — text→image→video that ends where it began
- h3: safetensors -> pruned-Q4_K GGUF quantizer for the 24GB DiT tiers
- h3_quant_gguf verify: row-error gates calibrated to the measured Q4_K floor
- asset-ai realtime: the feedback loop — the source anchors, the drifted frame inits
- asset-ai realtime: a feedback loop survives a resize and travels by default
- asset-ai realtime: the feedback loop frees itself from the feed handshake and pauses for its listener
- asset-ai realtime: the outbound encode leaves the loop's critical path
- asset-ai ocr: the ocr domain — Chandra 2 at page resolution, and the tower goes planner-owned
- llm slots: a lane can hold an image span — embedding prefill and a rope cursor of its own
- vision tower on CUDA: the encode leg gets its two missing kernels
- llm/ocr: one M-RoPE grid encoder for both image paths, and a livelock made an error
- vision tower on CUDA: the f16 GEMM keeps the precision it was throwing away
- live: a feed that moves box takes its trip with it — one seed image
- vision tower on CUDA: the tiled attention becomes bit-exact, and tensor cores go
- llm prefill on CUDA: the MMA attention kernel gets the tile a 4-to-1 model needs
- asset-ai ocr: the CUDA encode lane joins the integration — vision-parity sits beside run's three arms, and the kernels
- Merge branch 'ocr-perf-integration' into work
- asset-ai: the live anchor can follow the trip, and text leaves the 5090
- asset-ai: the camera moves the world, and the world starts still
- asset-import: the EA strategy classics, in the one 2D contract
- rtsmap: one seeded generator for tiled strategy maps
- asset-ui: one card for the strategy classics, with a pack dropdown
- asset-ai: music3 reference-audio path, ocr/h3 backends, registry
- asset: mp4 sample index for range-streaming, chat tools, import profiles
- cnc: tiberium is twelve growth frames, not twelve empty variants
- platform: native file and save dialogs, in-house on all three desktops
- chat: the scan holds out for a lane home
- chat: a full home queues you — take the free lane
- chat: the preload has a percentage, and the boundless cap stops showing
- llm cuda: the 32x2 attention tile — even GQA ratios stay on MMA
- sa3 gets a bake path: the sfx model's tables precomputed by a diffusion-side bin
- sqlite_query: anti-join regression test
- td import: HARV's second frame block is its harvesting cycle, not a turret
- asset-ui: sprite enhancement runs on the 32B dev DiT — distillation, not the prompt, was the ceiling
- ai-hub: makepad-asset-ai becomes makepad-ai-hub at libs/ai/hub, the chat pane becomes makepad-chat-ui, the service bin
- asset-ui: test health fixtures grow the realtime field they were born without
- ai-hub: one home at ~/.makepad — weights/ run/ cache/ logs/, the service cache migrates from ai_content by a single re
- ai-hub: subprocess workers die with the node — process groups everywhere, PDEATHSIG on linux, one KILL_ON_JOB_CLOSE Jo
- ai-hub: the hub object — AiHub::in_process, pipes vocabulary, and the local LLM engine generalized out of mpfiles (aic
- strict-json: the dependency-free JSON module gets its own crate; asset-client re-exports it so nothing downstream move
- ai-hub: the machine layer — node entries, the 0600 machine token, and the residency election that IS the lock (aicore
- ai-hub: MPHUB1 — the fabric beacon only dedicated nodes can send (aicore §4)
- ai-hub: job leases — work lives only while it is renewed (aicore §8)
- asset-creator: the pipeline library is born — specs, the deps gate, and the derived-state law (aicore §9)
- ai-hub: RAM residency facts — the CPU-side twin of residency.rs (aicore §3)
- ai-hub: ETA placement primitives — relative GPU throughput, the four-term estimate, and an observable breakdown (aicor
- ai-hub: leases go live on the wire — origin fields on submit, /job/<id>/keepalive, /bye, and the reaper that cancels w
- ai-hub: the chat providers move in — fleet qwen, openai, grok, claude/codex/grok CLIs, the responses driver, and the w
- asset-creator: the engine — one pipeline run against the hub, deps-gated, spliced, cancellable, resumable-by-construct
- ai-hub: the machine node mode — --machine binds loopback, registers in ~/.makepad/run, and exits on its own once idle
- asset-creator: makepad-creator-run — the detached client for runs that must outlive a window (aicore §9)
- ai-hub: a native Claude Messages-API provider — API-key or Claude Code OAuth, bounded SSE streaming, injected tools (a
- route + converse: off makepad_ai — the Agent seam moves to converse, route's cloud dispatcher rides the hub's Claude p
- asset-creator: the preset tables move in — fifteen chain-policy constants shared by every creator app (aicore §9 / P6)
- makepad_ai is deleted — every backend is a hub pipe, the agent seam lives with its consumers (aicore §14, decided 2026
- ai-hub: loads hold the machine residency election — set_model_state claims on Loaded and publishes the service port (a
- ai-hub: chats run the machine election — route to a serving holder, wait on a loading one, claim and publish when open
- ai-hub: pick_for_domain_eta — ETA-ranked placement over the shared hard-filter core (aicore §6 / P4)
- asset-creator: the engine picks a provider per stage at dispatch time — a chain's later stages see fresh fleet state (
- ai-hub: the fabric secret gates the service HTTP surface — bearer on everything but /health and the ticketed peer path
- vj: DREAM runs execute in the app — pipelines.rs becomes the run it used to watch (aicore §9 / F1)
- asset-creator: the runner — generate one thing and put it in the catalog, one implementation for every surface (aicore
- chat-ui: the session runs in the app — no broker anywhere on the chat path (aicore P8 / F5)
- asset-store: assets.query is a first-class query endpoint — the bounded SQL surface outlives the broker (aicore P8 / F
- asset-creator: CreatorTools — the chat tool pack for a store that only stores (aicore §9 / P8)
- asset-store: the shrink — the store stores (aicore P7)
- importer + asset-server host: the coordination era ends (aicore P7)
- store config purge + asset-ui goes fleet-direct; the derive protocol gets its route proof (aicore P7)
- client + chat dispatcher: the dead wire comes out (aicore P7/P8)
- ai-hub: 0.3.0 — the health version says which era a node runs
- ai-hub: the default fleet is 'gen' — apps hear the LAN without env plumbing
- ai-hub: the preload note percents the prefill, not the job bar
- ai-hub: conversations keep their KV — the wire mirror, the lane identity, the in-turn dynamic context (aicore §7)
- ai-hub: an open-think model is thinking from its first token
- libs: the zero-warning sweep — stitch casts say what they mean, xatlas keeps upstream's surface quietly
- zero-warning sweep, round two — the first full-workspace pass
- zero-warning sweep, round three — the model lanes and the deep examples
- zero-warning sweep, round four — the last stragglers
- zero-warning sweep, round five — vj and chat-ui
- zero-warning sweep, round six — three cascades

Co-authored-by: Claude <info@makepad.nl>
2026-09-01 16:46:31 +02:00
Admin
dbb82b8b61 chore: the zero-warning sweeps — every target of every workspace crate compiles clean on macos, windows, linux, ios and android
Squashed from work:
- platform, draw, widgets: the cross-target zero-warning sweep
- zero-warning sweep, round two — the first full-workspace pass
2026-09-01 16:46:29 +02:00
Admin
bada23dda2 frame witnesses: presents, uploads, encode and gpu time, a pinned display link, SHIFT+F12 screen capture, MPINPUT latency
Squashed from work:
- every window can record itself: SHIFT+F12 writes picture and sound to local/screencap
- the frame has witnesses now: presents, uploads, encode time, gpu time, and a pinned display link
- the upload counter names its kind: instances and textures split, and any single item over half a megabyte logs itself
- MPINPUT: input-to-glass latency in the present pulse
2026-09-01 16:46:29 +02:00
Admin
7e6e87fa1a platform: native file and save dialogs, in-house on all three desktops plus Android and iOS
Squashed from work:
- platform: native file and save dialogs, in-house on all three desktops
- platform: file dialogs on Android and iOS, and unbreak the Android build
2026-09-01 16:46:28 +02:00
Admin
db3864b0bd map rendering: overlays and labels, per-view clip, the world-clamped centre, carto's road ladder, the @cam gate
Squashed from work:
- map: overlay and label rendering
- map: bake a runnable Amsterdam test map from inside the route app
- map: gate the @cam readout; a .mkmap archive is a directory
- map: the view's own draw list carries its clip
- the map's centre is clamped to the world, not wrapped past it
- a country is drawn in hairlines: carto's road ladder joins by zoom
2026-09-01 16:46:28 +02:00
Admin
3b0b16e8ee draw: overlays composite above draw_depth content, per-session DrawVector geometry, one uniform into a whole draw list
Squashed from work:
- draw: DrawVector reused one geometry slot for every session in a frame
- draw, platform: overlays now composite above content that uses draw_depth
- DrawVars::set_uniform_on_draw_list — one uniform into every retained call of a shader in a list, pass repainted
2026-09-01 16:46:28 +02:00
Admin
149d19fcbb platform: midi pitch-bend byte order, many http connects at once, what a strategy round needs underneath
Squashed from work:
- render, sim, platform: what a strategy round needs underneath
- platform: midi pitch bend was sent with its bytes the wrong way round
- http: the connect slot is a gate, not a turnstile — many connects at once
2026-09-01 16:46:27 +02:00
Admin
e0530b061b glass + widgets: realtime parent blur, glass button icons, idling without draw_pass.time, fold_header opens honestly
Squashed from work:
- glass: a glass button can carry an icon
- widgets: the fab palette cell's colour is a field, not an object
- widgets: the glass stops reading draw_pass.time, so glass apps idle again
- live-with-parent passes: the glass blurs the world in realtime again
- fold_header: visually open is open
- fold_header: the ease snaps to its ends
- fold_header: settled state is the truth, the pane edge is the law, the area is the fold
2026-09-01 16:46:27 +02:00
Admin
b408131172 video: hardware first-frame decode from RAM, one encoder-transform report, stills without an AVAssetWriter
Squashed from work:
- video: hardware first-frame decode straight from RAM — no temp files
- video: the encoder transform is reported once, not once per encoder
- video: a single still does not need a whole AVAssetWriter
2026-09-01 16:46:27 +02:00
Admin
b3dd79978b tweaker + shader const-table: the design-feedback campaign — annotated props, hot-patchable shader constants, animator-state wells, the theme tab, typed editors, undo through anonymous paths
Squashed from work:
- widget_tree: skip-search nodes bound path-cache invalidation
- tweaker: fold the shader source view; plain TextInput, not CodeView
- docs: button shader annotations — widgets/button.rs complete + splash demo buttons
- tweaker: the Shader tab shows the pinned widget's ANIMATOR STATES as little posed swatches under the well — one per tr
- tweaker: the animator-state swatches are full-size wells stacked vertically under the main material well (same width,
- tweaker: typed editors for structured values — a reflected Vec2/3/4 is fused x/y/z/w scrub fields (the whole vector re
- shader: const-table mode — /** name min..max step s */ float literals inside fn bodies compile to hot-patchable scope-
- tweaker: the Props tab opens with TWEAKABLES — every annotated value (a /** */ doc on the key) hot-first, each with it
- tweaker: the sidebar row templates are hoisted into shared let bindings (one source of truth for the Props list, the S
- docs: shader-code constants annotated in the six core widgets
- tweaker: SHADER CONSTANTS — the Props tab opens with the annotated literals inside the pinned widget's draw-layer fn b
- tweaker: undo/redo resolve the pinned widget when a history step's path runs through anonymous segments (a '-' or a li
- shader const table: whole-number literal operands lift too — the parser packs `x + 6.`, `y - 2. - b`, `w * 4.`, `n - 1
- tweaker: the Shader tab reads well stack → SHADER CONSTANTS for the mirrored layer → INPUTS (its uniforms/instances, a
- tweaker: the panel cleaned up to the user's list — the filter (with the exploded-view and note buttons) is the top row
- tweaker: three panel polish nits — a section whose rows are all secondary leads with its first three instead of an emp
- tweaker: the VJ session's fix batch — (1) mirrored material wells clip to their scroll viewport: the well is its own d
- tweaker: theme colours, chunk 1 — the panel reads the app's palette from mod.theme's cascade once per session (every c
- tweaker: the theme hover pulse — hovering a colour chip pulses that theme colour live across the whole app, and grabs
- tweaker: the theme palette strip in the colour popover — hover names and pulses a theme colour, a click binds the prop
- tweaker: the Theme tab — every theme colour and number in a fourth panel tab, colours edited live app-wide, all of it
- tweaker: F12 works without the bridge
- tweaker: the selection wears viewfinder corners, not a box
- tweaker: radius handles come to the hand, not the eye
2026-09-01 16:46:26 +02:00
Kevin Boos
0e19a65eac
x11/opengl/wayland/metal: implement what these backends silently skipped (#1201)
* windows: publish shader-cache entries atomically, and unwind a cancelled resize

Two ways a second instance, or an interrupted drag, leaves a window permanently
degraded. Both were found auditing the backend rather than reported, and both
are cheap.

The DXBC cache is a plain per-user directory that every makepad process on the
machine reads and writes, and entries were written straight to their final path.
`fs::write` truncates first, so a second instance starting at the same moment
could read the prefix of a shader the first was still writing -- the file exists
and the read succeeds, so nothing notices until `CreateVertexShader` is handed a
truncated blob. `shader_bytes_cached` even documented the read path as catching
this, which it did not. Entries are now written to a temporary file and renamed
into place, which is atomic, and a blob that is not a complete DXBC container
(the 32-byte header and its magic) is recompiled instead of used. The temporary
name carries the process id so two writers cannot collide on it either.

`is_in_resize` and the 8 ms resize timer are armed by WM_ENTERSIZEMOVE and were
disarmed only by WM_EXITSIZEMOVE. Win32 does not guarantee that pairing, and a
drag that ends without it leaves the timer forcing repaints forever and the
window presenting unpaced for the rest of its life -- it never returns to vsync.
WM_CANCELMODE now unwinds the loop, gated on having actually been in it, since
that message also arrives for menus and capture changes and unwinding a resize
that was not running would cost a needless ResizeBuffers each time.

Verified against a release build: two instances launched simultaneously on a
cold cache both come up correctly with no panics and no temporary files left
behind, and window resizing is unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* d3d11: validate a cached shader against the size its own header declares

Checking the magic and a 32-byte minimum accepts a blob that was truncated
after its header, which is exactly the shape a half-written cache entry takes.
The DXBC header carries the container's own total size at offset 24, so
comparing it to the length rejects those too.

Publishing entries atomically stops this backend from writing a partial one,
but the cache is a plain shared directory that older builds have already
written to, so the read path still has to be able to tell.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* windows: stop double-scaling a popup window's position

`CxOsOp::CreatePopupWindow` arrives with a position the caller has already put
in physical screen pixels -- it adds the parent window's physical origin to an
offset it scaled by the parent's per-monitor DPI -- and `new_popup` then
multiplied it by the DPI again. The popup landed at `dpi` times its intended
screen coordinates: exact at 100%, and progressively further away above it.

The size was scaled by the *system* DPI, which is the primary display's and
goes stale after a live scale change, so on a second display of a different
scale it was the wrong number twice over. It is now passed through unscaled,
because it is provisional either way: `init` runs `set_inner_size` immediately
afterwards, which scales by the window's own per-monitor DPI once the HWND
exists on its target display.

Note for reviewers: this is unreachable today. `WindowHandle::new_popup` is the
only producer of the op and nothing in the tree calls it, so there is no symptom
to reproduce -- which is also why the arithmetic was free to drift.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* x11: implement window resizing, and stop a protocol error killing the app

Two things the X11 backend did not do, both of which it silently pretended to.

`set_inner_size` and `set_outer_size` were empty function bodies, so
`CxOsOp::ResizeWindow` dispatched to nothing: an app calling `WindowHandle`'s
resize on X11 got no error, no log and no resize. They now call
`XResizeWindow`, clamped to the CARD16 range the protocol encodes an extent in,
since a zero is a BadValue. `set_outer_size` delegates rather than pretending:
the window manager owns the decoration frame, so a client can only ask for its
own extent.

Xlib's default error handler prints to stderr and calls `exit(1)`, and makepad
installed none, so a single rejected request killed the process outright -- a
bad geometry, a race against a window the WM has already destroyed, a missing
extension. Protocol errors are asynchronous and frequently not caused by the
code that happens to be running, so terminating is never the proportionate
response. A handler is now installed before the first request and logs the
error, request and minor codes plus the resource id.

`XResizeWindow` and `XSetErrorHandler` were not in the hand-written Xlib
bindings; both are added, along with the `XErrorHandler` callback type.

Untested: the author has no X11 machine. Both changes are small, local and fail
closed -- an unusable size is clamped rather than sent, and the handler only
turns an existing hard exit into a log line -- but neither has been run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* opengl/wayland/metal: name three failures the backends currently swallow

None of these is device-loss recovery -- that stays a D3D11-only feature. They
are the cases where a backend already fails and says nothing useful, so a bug
report arrives as "the window went black" with no cause attached.

EGL: `eglMakeCurrent` and `eglSwapBuffers` failures were logged unlatched on
paths that run every frame, so a persistent failure emitted thousands of lines a
second. Both are latched once per outage and cleared on the next success, and
`EGL_CONTEXT_LOST` (0x300E) is now named explicitly, since that is EGL saying
the GPU reset and every GL object is dead -- which this backend cannot yet
recover from, and should at least say so. `OpenglCx::make_current` returned `()`
while discarding the result of a call that can fail; it returns `bool` now, so a
caller can skip GL work that would otherwise run with no current context and be
silently dropped.

Wayland: `wp_viewport.set_destination` raises the `bad_value` protocol error --
which disconnects the client -- on a zero or negative extent, and a float-to-int
cast turns both a negative and a NaN into zero. `WaylandWindow::new` clamps the
size for the EGL call but stores it unclamped into the geometry, so an app
created at a degenerate size reached that request. The two destination writes
are floored the way the EGL extent already is.

Wayland `CxOsOp::ResizeWindow` was an empty arm. A client has no "set my size"
request, but window geometry defaults to whatever the surface commits, and the
paint path derives the EGL extent and viewport destination from
`window_geom.inner_size` -- so writing it is the whole operation. Refused for a
maximized or fullscreen toplevel, which must keep the configured geometry or the
compositor raises `invalid_surface_state`. `RepositionWindow` stays a no-op and
now says why: `move` is interactive and serial-gated, and `reposition` is an
xdg_popup request needing a protocol version this backend does not bind.

Metal: a command buffer that ends in `MTLCommandBufferStatusError` produced no
pixels, and nothing noticed -- the completion handler runs either way, so the
in-flight queue drains and the hang watchdog stays quiet over a stale window.
The status is now checked in the handler that already exists and the error named,
capped at 32 reports.

Untested: none of these three backends can be run here. All three are
type-checked for their targets. Deliberately NOT written: anything for Vulkan
(vulkan.rs is not compiled by any target available here, so it cannot even be
type-checked) or the web backend (no JS runtime available to syntax-check the
shim), and any Metal device-loss latch or recovery, because `MTLCreateSystemDefaultDevice`
returns the non-removable SoC GPU and loss in the D3D11 sense does not occur there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* x11/wayland: cap a window extent, and refuse a resize that says nothing

Running the branch on Linux turned up four things it and #1197 let through.

A saved size is floored but never capped, and Wayland is the one backend with
no displays to fit against, so `{"inner_size":[1e9,1e9]}` reached
`eglCreateWindowSurface` and tripped its `assert!`. The app then died before it
could rewrite the state file that was killing it, so every later launch died the
same way -- the exact failure #1197 exists to prevent, one line further down.
`sanitize_window_geom` now caps as well as floors, and both Wayland EGL surface
creations fall back to the default size rather than taking the process with them.

`CxOsOp::ResizeWindow` had the same hole and no cap at all on Wayland:
`resize(100000, 100000)` went straight into the window geometry, which left the
surface EGL_BAD_SURFACE and corrupted the size the toplevel restores to -- a
maximize/restore round trip came back 1259x1259, a fullscreen one 100000x100000.
X11 had the opposite problem: it clamped a zero or a negative up to the CARD16
floor and produced a one-pixel window, silently, where Wayland refused the same
request and said why. Both now go through one `sanitize_resize`, so they answer
a bad request identically.

Last, the new X11 error handler is an `extern "C"` frame and logging panics on a
closed stdout -- `app | head` is enough -- so a protocol error aborted the
process instead of the `exit(1)` the handler exists to prevent. Reporting is
wrapped in `catch_unwind`.

Verified on Ubuntu 25.10 / GNOME on both backends: the 1e9 file now starts and
repairs itself, the resize matrix caps at 16384 with no EGL error and restores
correctly, and the two backends log the same refusal. 76 unit tests.

Still open, deliberately not fixed here: a Wayland self-resize dispatches no
WindowGeomChange. handle_platform_ops holds the Cx borrow for its whole loop, so
sending one needs a deferred-event path that does not exist yet.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 21:50:37 +02:00
Kevin Boos
fd6f307a2b
windows: publish shader-cache entries atomically, and unwind a cancelled resize (#1200)
* windows: publish shader-cache entries atomically, and unwind a cancelled resize

Two ways a second instance, or an interrupted drag, leaves a window permanently
degraded. Both were found auditing the backend rather than reported, and both
are cheap.

The DXBC cache is a plain per-user directory that every makepad process on the
machine reads and writes, and entries were written straight to their final path.
`fs::write` truncates first, so a second instance starting at the same moment
could read the prefix of a shader the first was still writing -- the file exists
and the read succeeds, so nothing notices until `CreateVertexShader` is handed a
truncated blob. `shader_bytes_cached` even documented the read path as catching
this, which it did not. Entries are now written to a temporary file and renamed
into place, which is atomic, and a blob that is not a complete DXBC container
(the 32-byte header and its magic) is recompiled instead of used. The temporary
name carries the process id so two writers cannot collide on it either.

`is_in_resize` and the 8 ms resize timer are armed by WM_ENTERSIZEMOVE and were
disarmed only by WM_EXITSIZEMOVE. Win32 does not guarantee that pairing, and a
drag that ends without it leaves the timer forcing repaints forever and the
window presenting unpaced for the rest of its life -- it never returns to vsync.
WM_CANCELMODE now unwinds the loop, gated on having actually been in it, since
that message also arrives for menus and capture changes and unwinding a resize
that was not running would cost a needless ResizeBuffers each time.

Verified against a release build: two instances launched simultaneously on a
cold cache both come up correctly with no panics and no temporary files left
behind, and window resizing is unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* d3d11: validate a cached shader against the size its own header declares

Checking the magic and a 32-byte minimum accepts a blob that was truncated
after its header, which is exactly the shape a half-written cache entry takes.
The DXBC header carries the container's own total size at offset 24, so
comparing it to the length rejects those too.

Publishing entries atomically stops this backend from writing a partial one,
but the cache is a plain shared directory that older builds have already
written to, so the read path still has to be able to tell.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* windows: stop double-scaling a popup window's position

`CxOsOp::CreatePopupWindow` arrives with a position the caller has already put
in physical screen pixels -- it adds the parent window's physical origin to an
offset it scaled by the parent's per-monitor DPI -- and `new_popup` then
multiplied it by the DPI again. The popup landed at `dpi` times its intended
screen coordinates: exact at 100%, and progressively further away above it.

The size was scaled by the *system* DPI, which is the primary display's and
goes stale after a live scale change, so on a second display of a different
scale it was the wrong number twice over. It is now passed through unscaled,
because it is provisional either way: `init` runs `set_inner_size` immediately
afterwards, which scales by the window's own per-monitor DPI once the HWND
exists on its target display.

Note for reviewers: this is unreachable today. `WindowHandle::new_popup` is the
only producer of the op and nothing in the tree calls it, so there is no symptom
to reproduce -- which is also why the arithmetic was free to drift.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 21:50:26 +02:00
Admin
fd0ea7e264 chore: workspace members and remaining sources
Squashed from work; the fine-grained history is under tag archive/work-2026-08-29:
- mp* wave: mpwm window manager + the mp app family, WM API, theme bridge, PDF engine fix
- Modal claims no layout slot: the DJ page fills its window again
2026-08-29 09:26:27 +02:00
Admin
4ddba62d4b engine libs: csg document, stitch simd, sim providers, teamtalk LAN voice, gltf writer, render, route and maps, bench hygiene
Squashed from work; the fine-grained history is under tag archive/work-2026-08-29:
- mp* wave: mpwm window manager + the mp app family, WM API, theme bridge, PDF engine fix
- mpwm polish wave: terminal key focus, focus-history close order, pop-back-to-origin, occupied-workspace cycling, demo
- work: land the sources the last commits reference
- route: the assistant icon the committed UI references
- fast_inflate: the benches name their dev-deps
- gif/weezl: drop the vendored benches nobody can run
- weezl: the decode tests generate their own LZW fixture
2026-08-29 09:26:27 +02:00
Admin
d25cce9ae6 mp*: the window-manager app family — quick-look, warm-instance pool, pty teardown, theme bridge
Squashed from work; the fine-grained history is under tag archive/work-2026-08-29:
- mp* wave: mpwm window manager + the mp app family, WM API, theme bridge, PDF engine fix
- mpwm polish wave: terminal key focus, focus-history close order, pop-back-to-origin, occupied-workspace cycling, demo
- mpwm: ghost-launch/menu-flash root cause, scroll z-gate, browser binds, path-free status, viewers delisted, Fab opens
- mpwm: Hyprland-exact close animation over a frozen snapshot
- mpwm: warm-instance pool, flat-luminance opens, flicker-free CEF resize
- work: land the sources the last commits reference
- mpwm: quick-look gap fixes; image cache eviction on preview unload
- tweaker: material thumbnails + vibecode popup + ctrl-space notes, undo/redo over the edit ledger, capture-semantics pi
- tweaker: tabbed side panel (Props/Shader/Tree) - shader tab with checkerboard material well + prompt, complete widget-
- mpwm+mpterm: text quick-look — pty teardown deadlock fix, in-place retarget verified, debug probes stripped
2026-08-29 09:26:26 +02:00
Admin
19097335b6 vj: the responsive DJ console (PR #1199 by vjroger), the DREAM expand-flux-video pipeline as one store graph, 16:9 image particles, XF defaults
Squashed from work; the fine-grained history is under tag archive/work-2026-08-29:
- mp* wave: mpwm window manager + the mp app family, WM API, theme bridge, PDF engine fix
- mpwm polish wave: terminal key focus, focus-history close order, pop-back-to-origin, occupied-workspace cycling, demo
- work: land the sources the last commits reference
- vj: responsive DJ mixer + Windows drag-and-drop, cherry-picked from PR #1199 (vjroger)
- vj particles: image mode homes on a 16:9 picture plane
- vj: DREAM pipeline — expand -> flux1 still -> minimax i2v loop, with a canvas picker
- vj: the row shows the real prompt, a flux picker, XF default, and new clips stop vanishing
- a DREAM run is one declared graph the store runs without us
2026-08-29 09:26:26 +02:00
Admin
d24cbee60d asset platform: store pipelines with honest banded progress, vision annotation through the job queue, fleet scheduling (roles, spread, peer provisioning), the music contract, synonym search, the runs card
Squashed from work; the fine-grained history is under tag archive/work-2026-08-29:
- mp* wave: mpwm window manager + the mp app family, WM API, theme bridge, PDF engine fix
- work: land the sources the last commits reference
- kenney: catalogue all 50 free 3D kits; Modal dismissed() never fired
- asset store: central vision-annotation queue; Kenney donate prompt
- asset-ai: vision domain — image + prompt -> text on every fleet node
- annotation runs the fleet's vision services through the normal job queue
- vj: responsive DJ mixer + Windows drag-and-drop, cherry-picked from PR #1199 (vjroger)
- store search: WordNet synonym expansion, query-side; per-term seeks
- video scheduling: a cold model pin never downloads past a warm one, and a dedicated box only serves its role
- an expansion can never lose a run — and `expand: true` finally means something
- fleet panel: a slow box is not a missing box
- h3: first+last keyframe conditioning (the weights were always FL2VA)
- registry: the three H3 FL2VA tiers name their real conditioning
- fleet scheduling: spread before stacking, and a stuck job moves
- a download never steals a job from a box that has the weights
- the faster GPU takes the tie: 6000 > 5090 > 4090
- an evicted flux model gives the card its VRAM back
- a job now records what each of its stages was handed
- open a stage in RUNS and read what it sent
- every expanded song was an instrumental: the lyrics had nowhere to go
- the writer never overwrites words the person wrote
- store: a dependent job's body is spliced from its deps' results at claim
- a fleet box's job row says what that job was asked for
- the store runs a whole pipeline, and one record says how far it got
- a client can declare a whole run, watch it, and stop it
- 100% now means published, and the expander is a job you can queue
- one card says what a spawned task is doing, everywhere
- a run that ends says so, instead of being noticed later
- when every box holding the model is busy, buy another copy
2026-08-29 09:26:25 +02:00
Admin
3b4d6a4ff7 platform+widgets: sploded 3D inspect view, the tweaker design-feedback suite, script math-AOT and docs channel, pointer-pin and pixel-probe, modal layout, map warp
Squashed from work; the fine-grained history is under tag archive/work-2026-08-29:
- mp* wave: mpwm window manager + the mp app family, WM API, theme bridge, PDF engine fix
- mpwm polish wave: terminal key focus, focus-history close order, pop-back-to-origin, occupied-workspace cycling, demo
- mpwm: warm-instance pool, flat-luminance opens, flicker-free CEF resize
- work: land the sources the last commits reference
- kenney: catalogue all 50 free 3D kits; Modal dismissed() never fired
- platform: windows check green again — SetWindowTextW binding
- map: exact warp-aware inverse projections — pointer ops work folded
- mpwm: quick-look gap fixes; image cache eviction on preview unload
- tweaker: material thumbnails + vibecode popup + ctrl-space notes, undo/redo over the edit ledger, capture-semantics pi
- tweaker: vibe popup card chrome + dispatch order, ctrl-space notes verified, sploded design v2 chapter
- tweaker: tabbed side panel (Props/Shader/Tree) - shader tab with checkerboard material well + prompt, complete widget-
- sploded v2: nesting-depth z, hairline scope frames, body pass
- sploded: pin the depth convention with a test, kill the draw_depth residue
- sploded: real body-pass split (scene-pass capture, panel flat) + y-convention source of truth with anti-flip gate test
- sploded: hollow outlines, flat-band input, ray-pick unprojection
- tweaker: shader tab defaults to the selection's first draw layer, stale hint trimmed
- sploded: outlines become clipped, antialiased strips; tighter deck
- sploded: merge the lane's v2 (nesting-depth z, clipped AA strip outlines, flat-band input, unproject, SplodedStack bod
- sploded: the exploded view is a LIVE view — pointer events route through the inverse explode transform (ray -> plane -
- tweaker: tabs are real widgets (uid, tree node under the dock, own plane in 3D) and pickable; navigation-class clicks
- sploded: pinned/hover outlines render on the widget's own plane in 3D — per-widget nesting depth lives on the platform
- tweaker: the material well renders the pinned widget's actual shader — the swatch byte-copies the widget's live draw c
- tweaker: the Shader tab shows the shader as written — the layer's pixel/vertex fn source (nearest definition up the co
- sploded: I = true isometric preset (yaw 45°, pitch atan(1/√2))
- tweaker: eyedropper — the colour popover's pick button arms a pixel probe; the next press in the app samples that devi
- tweaker: the shader loop closes — /tweak/apply resolves the pinned widget by uid (anonymous path segments never round-
- vj: responsive DJ mixer + Windows drag-and-drop, cherry-picked from PR #1199 (vjroger)
- tweaker: the material well is a magnifier — the mirrored instance draws at the widget's native size in the well's own
- tweaker: per-layer material thumbnails — the Widget derive emits WidgetNode::layer_areas() (every #[live] Draw… field
- tweaker: the shader source view is the real CodeView (syntax highlighting, selection, editing) when the app registers
- tweaker: Ctrl+Enter sends on every platform (TextInput treated only Cmd as primary on macOS, so Ctrl+Enter inserted a
- Modal claims no layout slot: the DJ page fills its window again
- tweaker: every fn apply recompiles (eval_chunk ran every chunk under ONE synthetic callsite, so the script body — and
- tweaker: an apply whose draw shader fails to compile is rejected — the layer goes back (last live fns / the fn as writ
- tweaker: the Shader tab's source view owns its scrolling (the ScrollYView around the CodeView double-scrolled the care
- widgets: set_visible belongs to every widget, not just View (#1194)
- script: a dead heap's resource handles must not outlive it (#1195)
- Resources: search the executable's directory, not only the working directory (#1196)
- Windows: fit a restored window to the displays that are actually attached (#1197)
- d3d11: a failing GPU call reports the loss instead of killing the process (#1198)

Co-authored-by: Kevin Boos <1139460+kevinaboos@users.noreply.github.com>
2026-08-29 09:26:24 +02:00
Kevin Boos
7a342be4d4
d3d11: a failing GPU call reports the loss instead of killing the process (#1198)
* d3d11: a failing GPU call reports the loss instead of killing the process

The backend already notices a removed device when `Present` returns
DXGI_ERROR_DEVICE_REMOVED, but a device rarely dies at a moment as convenient
as a present. It dies between frames, and the next thing that touches it is a
resource creation or a buffer map -- of which this file had 76 unwrapped, plus
three `std::process::exit(1)`. So the usual outcome of a GPU driver reset,
a TDR or a hybrid-GPU transition across suspend/resume was a panic, and the
graceful path was unreachable.

Route the calls a dead device actually reaches through `D3d11Cx::note_error`,
which asks `GetDeviceRemovedReason` rather than pattern-matching the HRESULT the
failing call happened to return -- creation calls do not reliably return the two
DXGI device-lost codes, while the device itself always knows and keeps saying
so. That answer sets a process-wide `device_lost` latch and logs once.

The softened sites are the ones a dead device lands on first: draw-list and pass
uniform buffers, which upload every frame with no dirty gate; texture and render
target creation; and shader object creation, whose `CxOsDrawShader::new` already
returned `Option` with both callers handling `None`.

Three latent bugs fall out of auditing them, each of which loses content
permanently rather than noisily:

  - `update_vec_texture` consumed the dirty flag with `take_updated()` and then
    returned early when the pixel buffer was out on loan, so a texture that hit
    that window was never uploaded again. For the glyph atlas that means all
    text disappears for the life of the process. The Metal backend already
    guards this; D3D11 did not.
  - `hlsl_compile_shaders` drains `compile_set` destructively, so a shader whose
    object creation failed was dropped from the queue forever and everything
    drawn with it silently stopped rendering. Failed creations go back in the
    queue.
  - `render_view` unwrapped the geometry index buffer but passed the vertex
    buffer through as an `Option`, so a missing one bound null and drew nothing
    with no error anywhere. Both are now checked together, and a draw call with
    either missing is skipped under a `debug_assert!` that it only happens on a
    lost device.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit cc980805b5774253e592e183f577c5ba08ce85db)

* d3d11: rebuild the device and every GPU resource after a device loss

Detection landed already: present() sets a per-window device_lost and the paint
loop stops re-dirtying the pass. Nothing rebuilt anything, so the window stayed
frozen until the app was restarted.

Recovery runs at the top of win32_event_callback, the one place holding both
&mut D3d11Cx and &mut Vec<D3d11Window> while nothing is mid-render. It releases
each window's swap chain, back buffer, view and paint-beat registration (DXGI
allows one flip-model chain per HWND, so the dead one must be gone first),
recreates the device tier when the device really is gone, drops every GPU handle
the Cx holds, and rebuilds each swap chain against the same HWND.

Clearing handles is only half of a sweep. Geometry and instance uploads are
gated on dirty flags cleared unconditionally once the upload runs, and textures
on a dirty rect consumed by take_updated, so every gate is re-armed or the empty
slot is never refilled. The CPU-side sources all survive: texture pixels live in
TextureFormat::Vec*, geometry in CxGeometry, and shaders keep their compiled
DXBC plus the on-disk cache, so recovery recreates shader objects without
compiling any HLSL.

Retries are spaced 250ms to 4s and driven by the existing signal heartbeat
rather than a new timer, because the GPU can stay absent for a long time. While
lost, the loop is forced to Wait at both EventFlow decisions -- every condition
that would otherwise choose Poll is unsatisfiable when nothing can paint, so it
would spin for the whole outage -- and pending screenshot requests are failed,
both to release their requester and because a non-empty queue is itself one of
those conditions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit d603d3e7996cd71b5da6ed3372c9dc75eebb4a77)

* d3d11: bind the hot-path buffers by reference, not by clone

The device-loss bails introduced an AddRef/Release pair per uniform-buffer
upload and per draw call, on paths that run for every draw list, pass and
geometry every frame. Borrowing reads the same Option without touching the
refcount; only IASetVertexBuffers genuinely needs an owned Option, which is what
the code built before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit ecfab9a9355d50754a7117de8303d66f8919b2ee)

* d3d11: make the device-loss recovery actually work, and add a way to exercise it

Three defects, each of which stopped recovery dead, and none of which is visible
without running it. `MAKEPAD_D3D11_TEST_DEVICE_LOSS=<seconds>` forces a full
device recreation on a timer so the path can be exercised without a driver
reset; a real removal cannot be provoked from inside the process. It is a
stronger test than merely setting the latch, because the device really is
replaced, so any GPU object the sweep fails to rebuild still belongs to the old
device and cannot render against the new one.

  - The sweep called `set_updated` on every texture, which panics for anything
    that is not a `Vec*` format. The first render target it reached took the app
    down. Only vec textures carry a dirty rect; render targets, depth buffers
    and shared textures have no CPU-side contents and get their alloc record
    cleared instead.

  - Every rebuilt swap chain failed with `E_ACCESSDENIED`. DXGI allows one
    flip-model swap chain per HWND at a time and D3D11 destroys lazily, so the
    immediate context's own reference to the back-buffer view kept the old chain
    -- and its claim on the window -- alive after the application had dropped
    every handle it held. `ClearState` + `Flush` once, after all the windows have
    released and before any rebuild.

  - The post-recovery redraw marked every pass slot dirty, including ones
    nothing had drawn into, and `draw_pass_to_texture` unwraps
    `main_draw_list_id` immediately. Only passes that have one are marked.

Verified against a release build: six consecutive forced device recreations,
no panics, the UI rendering correctly after each (text included, so the glyph
atlas re-uploads from its retained pixels), `ResizeBuffers` working on a rebuilt
chain, handle count flat across recoveries, and the loop idle afterwards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit bb7d1c019612a4fb2668640f47b8e16c3886e1cf)

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 09:41:33 +02:00
Kevin Boos
e40a5318f7
Windows: fit a restored window to the displays that are actually attached (#1197)
An app that persists its window geometry has to restore it into a display
arrangement that may have changed completely since it was saved: the display
the window sat on can be gone, a docked laptop can be back on its built-in
panel, and the file can hold values no display ever had. Nothing validated any
of it -- `configure_window` stored the numbers and each backend passed them
straight to `CreateWindowExW` / `initWithContentRect:` / `XCreateWindow` -- so a
window could come back off-screen or too small to grab, with no way back except
deleting the state file.

Windows also manufactured those values. Win32 reports a minimized window at
`(-32000, -32000)` with a zero-sized client rect, `WM_SIZE` published that as
the window's authoritative geometry, and an app saving on shutdown wrote it
down. `WM_MOVE` meanwhile published nothing, so a window that was dragged but
not resized persisted its pre-drag position (X11's `ConfigureNotify` and macOS's
`windowDidMove:` both already published).

Add `platform/src/screen.rs`, holding the policy in one place:

  - `sanitize_window_geom` needs no display knowledge and every backend reaches
    it through `CxWindow::create_geom`. It is what protects the backends a fit
    cannot help: Wayland enumerates no displays for a client and hands the size
    to `wl_egl_window_create`, which rejects a non-positive one -- a persisted
    `0` or `NaN` panicked the app at startup -- and X11 encodes extents as
    unsigned 16-bit and answers a zero with a protocol error that, with no error
    handler installed, terminates the process.
  - `clamp_point_to_screens` pins the origin BEFORE the window is created. The
    fit alone is too late: `set_inner_size` runs in between and works relative
    to wherever the window landed.
  - `fit_window_rect_to_screens` corrects the finished rectangle. A window
    already wholly on the desktop is returned untouched, including one
    deliberately spanning two adjacent displays; anything else moves to the
    display it overlaps most, or nearest by centre, capped to that work area.

Displays come from `EnumDisplayMonitors` + `GetMonitorInfoW` on Windows (both
absent from the vendored bindings, so linked here), `NSScreen.screens` on macOS,
and the root geometry plus EWMH `_NET_WORKAREA` on X11. Wayland is unaffected by
the class of bug: a client there cannot know or choose where its windows go.

Report a minimized window from `GetWindowPlacement().rcNormalPosition`,
converted out of workspace coordinates, so what an app persists is the geometry
the window actually returns to; skip publishing on `SIZE_MINIMIZED`; publish on
`WM_MOVE`, deferred to `WM_EXITSIZEMOVE` during a user drag because the Cx
handler redraws on every geometry event.

Positions are now documented and implemented as physical screen pixels on
Windows and X11 (points on macOS) and are never DPI-scaled, matching
`get_position`, `create_position` and the platform calls. `set_position` alone
had been scaling its argument, so `set_position(get_position())` moved a window
to twice its coordinates on a 200% display.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 07:44:21 +02:00
Kevin Boos
dd4c8309d9
Resources: search the executable's directory, not only the working directory (#1196)
A packaged desktop build addresses its resources through a relative package root
-- `cargo packager` and `robius-packaging-commands` both use `.`, with the
resource trees sitting beside the executable -- and a relative `File::open`
resolves against the process working directory. Any launcher that sets no
working directory therefore starts the app somewhere unrelated and every font,
icon and image open fails: a URL-protocol handler (`HKCR\<scheme>\shell\open\
command` carries no working directory), a file association, a service, a
shortcut created without one.

The result is not a clean failure. The window comes up and lays out correctly,
shader-drawn shapes and buttons render, and network-loaded images appear, but
every glyph and every bundled icon is missing, because those are the parts that
need a file. It reads as a renderer bug rather than a missing directory.

macOS avoids this through `apple_bundle_load_dependencies`, and a Linux `deb`
package uses an absolute `/usr/lib/<name>`, so Windows is the only desktop
target whose resource lookup depends on where it was started from.

Retry a failed open against the directory holding the executable. This is
purely additive -- a path that resolves today resolves identically, and only an
open that would have failed reaches the fallback -- so a dev build's
workspace-relative dependency paths keep working unchanged.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 07:44:10 +02:00
Kevin Boos
5aad53afbc
script: a dead heap's resource handles must not outlive it (#1195)
* widgets: set_visible belongs to every widget, not just View

`ui.value_lg.set_visible(w >= 150)` logged "widget method set_visible not
found for uid WidgetUid(1236)" once per resize, and the widget simply never
reflowed. Label, Button and every other leaf refused a method that View
alone implemented — even though visibility is a Widget-trait property that
`#[visible]` derives for exactly those widgets.

it went unnoticed because the fixed-slot list pattern wraps its rows in
Views. what it breaks is the widget tiles, where a bare Label is toggled by
an on_widget_resize, and the failure reads as a layout that just doesn't
respond to its size.

handled once now, in WidgetRef::script_call, after the widget's own
script_call declines the method — so set_visible (and a visible() getter)
work on anything, and View's copy is gone. a bad argument still keeps the
current visibility and returns an error instead of guessing true.

* script: a dead heap's resource handles must not outlive it

the launcher died about one run in five, always inside the GC and never
anywhere near what caused it:

  gc.rs:300: index out of bounds: the len is 21 but the index is 22

only after an isolate had been torn down and another started — closing a
widget preview, or granting `network` (alloc-time, so the app's isolates
restart).

CxScriptResources caches (heap_key, abs_path) -> that heap's LOCAL handle,
and a heap_key is an allocation ADDRESS. the only cleanup was
CxScriptResourceGc, which runs when the owning heap's own GC sweeps that
handle — and a heap that is dropped wholesale, as a Splash isolate's is,
never sweeps anything. so the entries outlived the heap, and the next
isolate whose root_objects landed on that freed address asked for the same
font and was handed the dead heap's handle index. it stored it in its own
FontMember{res, asc, desc}, where 22 means nothing in a table of 21 —
and nothing noticed until that heap's next collection walked the font
object it had every right to walk.

gc_heaps() drops a dead heap's entries, and detaches handles no surviving
heap still maps to (handle values are heap-local, so two heaps' handles can
be equal). called from gc_dead_splash_isolates beside the storage and
bridge purges, which already runs before a new isolate can allocate.

anything keyed by heap_key needs to be in that function, for this reason.

the same hunt turned up a second crossing, fixed here too:
View::script_call(render) built its `me` object in whatever VM happened to
be calling, protoed off the SOURCE view's heap, and forwarded the caller's
args object into the target VM. render a view whose isolate has since been
torn down and that object stays behind in the CALLER's heap holding a dead
heap's index. it refuses now when the two heaps differ.
2026-08-26 10:43:58 +02:00
Kevin Boos
9c62043f9a
widgets: set_visible belongs to every widget, not just View (#1194)
`ui.value_lg.set_visible(w >= 150)` logged "widget method set_visible not
found for uid WidgetUid(1236)" once per resize, and the widget simply never
reflowed. Label, Button and every other leaf refused a method that View
alone implemented — even though visibility is a Widget-trait property that
`#[visible]` derives for exactly those widgets.

it went unnoticed because the fixed-slot list pattern wraps its rows in
Views. what it breaks is the widget tiles, where a bare Label is toggled by
an on_widget_resize, and the failure reads as a layout that just doesn't
respond to its size.

handled once now, in WidgetRef::script_call, after the widget's own
script_call declines the method — so set_visible (and a visible() getter)
work on anything, and View's copy is gone. a bad argument still keeps the
current visibility and returns an error instead of guessing true.
2026-08-26 10:43:42 +02:00
Admin
93f27f802e chore: workspace members for the new crates, makepad.splash, the naming and loader check scripts, and error_log
Squashed from work; the fine-grained history is under tag archive/work-2026-08-26:
- vj: the thumbnail pipeline becomes one honest machine, and effects go livecodable
- widgets: boxed labels center on their ink, not on the font's line box
- metal: a fresh texture forgets nothing it never had — reallocated vec textures upload whole
- fab: a 3D creation shell and the viewer built on it
- texcomp: the block codec and the container every texture will travel in
- mixer: a live LR-Mix surface for the XR18 — strips paired the way the desk is run, auto-connect, EQ that bends its own curve, and a sweep paced so the console drops nothing
2026-08-26 08:49:50 +02:00
Admin
6c8d301da9 asset-ui: the classic import surface follows what the importer now produces, and the chat tells the truth while it works
Squashed from work; the fine-grained history is under tag archive/work-2026-08-26:
- vj: the thumbnail pipeline becomes one honest machine, and effects go livecodable
- asset-ai: the chat tells the truth while it works
- audio: a FLAC decoder from the specification, beside the MP3 and Vorbis ones
- asset-ui: the classic import surface follows what the importer now produces
2026-08-26 08:49:50 +02:00
Admin
4c5228d9ca mixer: a live LR-Mix surface for the XR18
Squashed from work; the fine-grained history is under tag archive/work-2026-08-26:
- mixer: a live LR-Mix surface for the XR18 — strips paired the way the desk is run, auto-connect, EQ that bends its own curve, and a sweep paced so the console drops nothing
2026-08-26 08:49:49 +02:00
Admin
705d6c73ed vj: the console — real transports and reverse, the music explorer's IMPORT, local store, lyrics and model plumbing, DJ-tab scroll knobs, title-click reset, crossfade fix, stem headroom, and music/sfx browse models
Squashed from work; the fine-grained history is under tag archive/work-2026-08-26:
- vj: the music explorer gets IMPORT, and catalog controls fold away in local mode
- vjfx: three lanes land — engine hooks + hold stage, the videomesh engine, and the audio picture
- vj: the thumbnail pipeline becomes one honest machine, and effects go livecodable
- vj: thumbnails become mp4 — hardware-coded sheets at measured-4K cells, and the bake stops racing the GPU
- store: the ceremony dies — batch publish, one transaction, and the engine stops re-reading its own log
- store: the ceremony dies — batch publish, one transaction, and the engine stops re-reading its own log
- vj: the console grows real transports, and the deck stops lying about reverse
- vj: reverse earns a memory, and the effects stop aging
- vj: video goes NV12 end to end, and the GPU does the unpacking
- vj: windows hands the main thread one megabyte, and the script tree wants two
- vj: the GPU learns to see motion — realtime frame tweening on every deck
- vj: the tweener learns — RIFE runs on the Mac and feeds the warp
- vj: the classical tweener grows up, and every deck gets a tween chip
- models: an interrupted install can never load broken
- vj: the transport becomes a platter — velocity in, position out, one map
- vj: the tween presenter reads the platter — one clock per deck, cued once at the frame on screen
- vj: the presenter switch lands without its scaffolding
- vj: the next pair's fields are fetched ahead of the change under the capacity law — a pair change costs an ordinary beat; macos: the layer's own display link paces the frame when the system offers it, the old path stays as fallback
- vj: AI3 subdivides adaptively — one, three or seven neural frames per pair, chosen from measured synth time against the pair's own period, with classical flow between them and a 7-3-1-FL fallback; the deck shows the depth
- vj: local store, lyrics and model plumbing, and the frame-interpolator's device parity check
- vj: DJ-tab scroll knobs, title-click reset, crossfade fix, stem headroom — plus the pre-Ampere CUDA fix (#1193)
- vj: the deck explorer lists music-tagged audio, the sfx surface filters by tag not category, the track list fills the window, and the modal host has no layout footprint

Co-authored-by: Rogier de Leeuw <vjroger@gmail.com>
2026-08-26 08:49:49 +02:00
Admin
dc389d71a1 vj: the transport becomes a platter — one clock per deck, the producer contract, GPU frame tweening with RIFE, NV12 end to end, AI3 adaptive subdivision, and bit-identical decks
Squashed from work; the fine-grained history is under tag archive/work-2026-08-26:
- vj: thumbnails become mp4 — hardware-coded sheets at measured-4K cells, and the bake stops racing the GPU
- vj: the console grows real transports, and the deck stops lying about reverse
- vj: reverse earns a memory, and the effects stop aging
- vj: video goes NV12 end to end, and the GPU does the unpacking
- vj: the GPU learns to see motion — realtime frame tweening on every deck
- vj: the tweener learns — RIFE runs on the Mac and feeds the warp
- vj: the classical tweener grows up, and every deck gets a tween chip
- vj: the tween clock tells presented time, not producer time
- vj: the transport becomes a platter — velocity in, position out, one map
- vj: the tween presenter reads the platter — one clock per deck, cued once at the frame on screen
- vj: the OFF tier joins the platter — a resident clip's picture is cache[nearest(pos)]
- vj: the media thread loses its second clock — resident clips park the decoder
- vj: the producer gets a contract — keyed ladders, deadlines, and a capacity law
- vj: two decks, one law — identical inputs are bit-identical, and the warp agrees to the byte
- vj: the presenter switch lands without its scaffolding
- vj: the next pair's fields are fetched ahead of the change under the capacity law — a pair change costs an ordinary beat; macos: the layer's own display link paces the frame when the system offers it, the old path stays as fallback
- vj: AI3 subdivides adaptively — one, three or seven neural frames per pair, chosen from measured synth time against the pair's own period, with classical flow between them and a 7-3-1-FL fallback; the deck shows the depth
- video_flow: the flow debug bins, declared behind the convert feature so --no-default-features skips them instead of failing
2026-08-26 08:49:48 +02:00
Admin
cb49b032df vjfx: 104 presets, engine hooks + hold stage, the videomesh engine, the audio picture, livecodable effects, and mp4 thumbnail sheets
Squashed from work; the fine-grained history is under tag archive/work-2026-08-26:
- vjfx: 104 new presets — the transition lane fills out and the screen family goes wide
- vjfx: three lanes land — engine hooks + hold stage, the videomesh engine, and the audio picture
- vj: the thumbnail pipeline becomes one honest machine, and effects go livecodable
- vj: thumbnails become mp4 — hardware-coded sheets at measured-4K cells, and the bake stops racing the GPU
- store: the ceremony dies — batch publish, one transaction, and the engine stops re-reading its own log
- store: the ceremony dies — batch publish, one transaction, and the engine stops re-reading its own log
- vj: the console grows real transports, and the deck stops lying about reverse
- vj: reverse earns a memory, and the effects stop aging
- fab: a 3D creation shell and the viewer built on it
2026-08-26 08:49:48 +02:00
Admin
9821263b2c fab: a 3D creation shell and the viewer built on it — colour picker, material textures, dials that move the scene while they drag, the glTF loader, the tour, and the probes
Squashed from work; the fine-grained history is under tag archive/work-2026-08-26:
- fab: a 3D creation shell and the viewer built on it
- raytrace: the traced pane starts coarse and doubles to native, with the raster underneath
- fab: a colour picker, a material's textures, and dials that move the scene while they drag
- texcomp: the block codec and the container every texture will travel in
- fab: FAB_PROBE_MAT — per-material triangle counts, texture presence and uv spread in the roof probe
2026-08-26 08:49:47 +02:00
Admin
8f3c826265 csg: deterministic boolean bench + fuzz harnesses
Squashed from work; the fine-grained history is under tag archive/work-2026-08-26:
- csg: add deterministic boolean bench + fuzz harnesses (examples)
2026-08-26 08:49:47 +02:00
Admin
d72cffd0f6 raytrace: the traced pane starts coarse and doubles to native, with the raster underneath
Squashed from work; the fine-grained history is under tag archive/work-2026-08-26:
- fab: a 3D creation shell and the viewer built on it
- raytrace: the traced pane starts coarse and doubles to native, with the raster underneath
- texcomp: the block codec and the container every texture will travel in
2026-08-26 08:49:47 +02:00
Admin
87e0ce82e9 texcomp: the block codec and the container every texture will travel in
Squashed from work; the fine-grained history is under tag archive/work-2026-08-26:
- texcomp: the block codec and the container every texture will travel in
2026-08-26 08:49:47 +02:00
Admin
f788946065 render+sim+gltf: a HUD, the receiver shader with sun and cascaded shadows, metered exposure and haze, two shaders that never compiled, map facing becomes heading, and the viewer hooks
Squashed from work; the fine-grained history is under tag archive/work-2026-08-26:
- render+sim: the two hooks the model viewer already relies on
- viewport: Realtime gets the engine's cascaded shadow pass, the NOAA sun, metered exposure with sky ambient, a haze knob, a time-of-day slider in the header, and no grid — the building itself still waits for its casters and direct light
- render: the engine's receiver shader takes the sun with two-sided normals and the shadow term for the model batches
- fab: a 3D creation shell and the viewer built on it
- render: two shaders that never compiled — a let is not assignable, a var is
- sim: a declared map facing becomes a body's heading through one rule
- render: a HUD that already reads as a game's before anyone styles it
- asset+sim: the two modules their own commits already declared
2026-08-26 08:49:46 +02:00
Admin
9c3b2298ea audio: a FLAC decoder from the specification
Squashed from work; the fine-grained history is under tag archive/work-2026-08-26:
- audio: a FLAC decoder from the specification, beside the MP3 and Vorbis ones
2026-08-26 08:49:46 +02:00
Admin
229741662c libs/ai: the step cost model per device, the cold-turn speculative path, and RIFE on Metal with its device-parity check
Squashed from work; the fine-grained history is under tag archive/work-2026-08-26:
- vj: the tweener learns — RIFE runs on the Mac and feeds the warp
- asset-ai: the chat tells the truth while it works
- llm: a cold turn on the solo slot takes the session-native speculative path — think-mode turns no longer re-ingest the whole conversation through the draft head (66 → 122 tok/s on the four-lane box)
- llm: the step cost model is chosen per device — the RTX PRO 6000's measured verify curve (13.7 + 3.17·B ms) beside the 5090's; the bench warms every tail shape and times two windows
- vj: local store, lyrics and model plumbing, and the frame-interpolator's device parity check
2026-08-26 08:49:46 +02:00
Admin
69385ed1be libs/asset: batch publish in one transaction and its route with the hostile cases, hardware sha256 proved against the oracle, classic worlds stop being mirror images, sounds and sprites publish a picture, an interrupted model install can never load broken, ActorDef::scaled, and the asset-ai chat tells the truth
Squashed from work; the fine-grained history is under tag archive/work-2026-08-26:
- vj: the thumbnail pipeline becomes one honest machine, and effects go livecodable
- repo: context_ladder scratch bin stays local, not shipped
- vj: thumbnails become mp4 — hardware-coded sheets at measured-4K cells, and the bake stops racing the GPU
- store: the ceremony dies — batch publish, one transaction, and the engine stops re-reading its own log
- vj: the console grows real transports, and the deck stops lying about reverse
- models: an interrupted install can never load broken
- importer: the classic worlds stop being mirror images
- asset-ai: the chat tells the truth while it works
- sqlite: derived tables get their real names, their predicates, and all their arms
- llm: the step cost model is chosen per device — the RTX PRO 6000's measured verify curve (13.7 + 3.17·B ms) beside the 5090's; the bench warms every tail shape and times two windows
- importer: a sound and a single-tile sprite publish a picture like everything else
- asset: hardware sha256 kernels, proved against the software oracle before they run
- sim: a declared map facing becomes a body's heading through one rule
- asset: the batch publish route, with the hostile cases it has to refuse
- asset: an example that asks a live store which assets carry a thumbnail
- asset+sim: the two modules their own commits already declared
- asset: ActorDef::scaled — every linear quantity follows the map's person height — plus the place-dump and retire-stale store examples, and the game chat context stops reporting work it did not do
2026-08-26 08:49:46 +02:00
Admin
ed5de46749 sqlite_query: derived tables get their real names, their predicates, and all their arms
Squashed from work; the fine-grained history is under tag archive/work-2026-08-26:
- store: the ceremony dies — batch publish, one transaction, and the engine stops re-reading its own log
- sqlite: derived tables get their real names, their predicates, and all their arms
2026-08-26 08:49:45 +02:00
Admin
45e65e69db draw+widgets: a Splash keeps its host's walk across a body rebuild, a dock redraws its panels, boxed labels center on their ink, hsv2rgb compiles, and slider/tip/dropdown catch up
Squashed from work; the fine-grained history is under tag archive/work-2026-08-26:
- vj: the console grows real transports, and the deck stops lying about reverse
- vj: video goes NV12 end to end, and the GPU does the unpacking
- widgets: boxed labels center on their ink, not on the font's line box
- metal: a fresh texture forgets nothing it never had — reallocated vec textures upload whole
- fab: a 3D creation shell and the viewer built on it
- widgets: a dock redraws its panels, not just its own frame
- draw: hsv2rgb takes vector bounds, so it compiles
- texcomp: the block codec and the container every texture will travel in
- widgets: a Splash keeps the walk its host declared across a body rebuild, and logs a body that fails to evaluate instead of drawing nothing
- vj: DJ-tab scroll knobs, title-click reset, crossfade fix, stem headroom — plus the pre-Ampere CUDA fix (#1193)

Co-authored-by: Rogier de Leeuw <vjroger@gmail.com>
2026-08-26 08:49:45 +02:00
Admin
fd22db91ff script: the parser fix with the case that caught it, and shader calls
Squashed from work; the fine-grained history is under tag archive/work-2026-08-26:
- vj: video goes NV12 end to end, and the GPU does the unpacking
- script: parser, with the case that caught it in the test suite
2026-08-26 08:49:44 +02:00
Admin
68d80b69e5 windows: the paint beat becomes the swapchain's own beat, and /g learns to see
Squashed from work; the fine-grained history is under tag archive/work-2026-08-26:
- vj: thumbnails become mp4 — hardware-coded sheets at measured-4K cells, and the bake stops racing the GPU
- windows: the paint beat becomes the swapchain's own beat, and /g learns to see
2026-08-26 08:49:44 +02:00
Admin
d5b89df37d macos+metal: the paint beat becomes the display's own beat — link-paced frames, drawables presented plainly, instance buffers and vec textures safe under a live draw, a stalled-command-buffer watchdog, a shielded resetCursorRects
Squashed from work; the fine-grained history is under tag archive/work-2026-08-26:
- vj: thumbnails become mp4 — hardware-coded sheets at measured-4K cells, and the bake stops racing the GPU
- vj: the console grows real transports, and the deck stops lying about reverse
- vj: the GPU learns to see motion — realtime frame tweening on every deck
- macos: the paint beat becomes the display's own beat
- windows: the paint beat becomes the swapchain's own beat, and /g learns to see
- metal: vec textures ride the command stream — the CPU stops overwriting what the GPU is still reading
- metal: instance buffers stop being rewritten under a live draw
- macos: next frames and draws are stamped with the flip they aim at
- metal: a fresh texture forgets nothing it never had — reallocated vec textures upload whole
- metal: a watchdog for stalled command buffers — it names the pass, and only aborts when asked
- macos: resetCursorRects no longer aborts the app when AppKit re-enters it
- macos: nothing panics across resetCursorRects — the callback is shielded and its cursors are retained
- vj: the next pair's fields are fetched ahead of the change under the capacity law — a pair change costs an ordinary beat; macos: the layer's own display link paces the frame when the system offers it, the old path stays as fallback
- macos: a drawable from the layer's display link is presented plainly — presenting it at a time is forbidden and raised in every visible window
- macos: a window paced by the layer's display link never asks the layer for a drawable — the beat waits for the link's update; ObjC exceptions are logged with their reason before they unwind
- macos: the layer's display link is opt-in (MAKEPAD_METAL_DISPLAY_LINK=1) until it paces at the display's rate — 11 fps visible against 62 on the proven path
- macos: the layer's display link asks for the screen's maximum rate, consumes every drawable it hands out, and traces updates/consumed/presented per second — still 75 ms per present under a drag, so it stays opt-in
- fab: a 3D creation shell and the viewer built on it
- raytrace: the traced pane starts coarse and doubles to native, with the raster underneath
- macos: a link-paced beat blocks till the next flip, and an armed paint clock means wait — the main thread no longer polls at 100% CPU between frames
2026-08-26 08:49:44 +02:00
Admin
d1a0eb1cb8 platform: the paint clock contract — a beat per backend, and the time repaint stops resurrecting passes
Squashed from work; the fine-grained history is under tag archive/work-2026-08-26:
- vj: thumbnails become mp4 — hardware-coded sheets at measured-4K cells, and the bake stops racing the GPU
- vj: reverse earns a memory, and the effects stop aging
- vj: video goes NV12 end to end, and the GPU does the unpacking
- windows: the paint beat becomes the swapchain's own beat, and /g learns to see
- platform: the time repaint stops resurrecting passes their owner left behind
- metal: a fresh texture forgets nothing it never had — reallocated vec textures upload whole
- fab: a 3D creation shell and the viewer built on it
- raytrace: the traced pane starts coarse and doubles to native, with the raster underneath
2026-08-26 08:49:43 +02:00
Rogier de Leeuw
413709b565
ai-cuda: pre-Ampere machines get their CUDA store back (#1192)
A Turing box (RTX 2080 Ti, sm_75) lost ALL of CUDA because two kernel
files refused to compile for it, and one failed kernel build means the
stub store — surfaced in the VJ as "stems: model error: no compiled-graph
device" on the DJ tab.

diffusion_ops.cu used three sm_80-only pieces unguarded: bf16 wmma
fragments (the type itself is incomplete before Ampere), cp.async, and
the m16n8k16 mma shapes. The cp.async helpers now fall back to
synchronous copies below sm_80 — the f16 wmma flash/sdpa kernels lose
their prefetch overlap on Turing, not their contents — while the bf16
and FA2 kernels are compiled out and their launchers refuse pre-sm_80
devices with cudaErrorNotSupported instead of returning a buffer the
kernel never wrote.

fattn/common.cuh made mkllm_unused_vars constexpr: the no-cp.async
branch of ggml_cuda_fattn_mma_get_nstages calls it, and a non-constexpr
callee poisoned the constexpr config chain on exactly the pre-Ampere
device pass — the arch nobody had compiled for.

Stems verified on the 2080 Ti: stems-ops-check all green (SNR 137-147 dB
against the CPU reference), two tracks separated end to end, output
confirmed clean by ear.

Co-authored-by: vjroger <r.deleeuw@qogni.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 22:15:00 +02:00
Kevin Boos
6cf59e1509
StackNavigation: expose the view a transition is heading toward (#1191)
`current_view` only advances once a push or pop transition finishes, so
mid-transition it still reports the outgoing view. Callers that want to
address the view the app considers current, e.g. to retitle it, had to
either duplicate the id themselves or special-case `is_transitioning`.

* Add `destination_view()`, which reports the incoming view as soon as a
  transition starts and falls back to `current_view` when settled.
2026-08-25 06:14:32 +02:00
Admin
8b5caf41e1 video: the sample-attachments call takes a CoreFoundation Boolean, so it builds on x86_64 macOS too
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 06:03:00 +02:00
Admin
152b11f20a vj: the deck models install themselves, and Windows gets its waveform back
- INSTALL MODELS under the music explorer: a download dialog naming both
  MIT weight sets (BS-RoFormer splitter 527MB, whisper large-v3-turbo
  1.6GB), where they land and their licenses; resumable sha256-pinned
  downloads through the asset-ai downloader (featureless dep — the same
  slice the asset UI links); cancel mid-flight (the button flips to
  CANCEL, .part resumes later), MB progress, and the row disappears on a
  provisioned machine. When the last model lands the loaded decks
  separate immediately: the stems worker now re-probes the checkpoint
  per job instead of latching its absence, and the lyrics transcriber
  unlatches too (the Apple fallback yields to whisper mid-session).
- DrawWaveLane's stem palette moves from instance inputs to uniforms:
  36 vertex inputs blew D3D11's vs_5_0 limit of 32 (error X4506), which
  left the music decks with NO waveform at all on Windows.
- --remote HOST:PORT binds a named interface so another machine can
  drive an app over the LAN (fleet-box testing); bare --remote stays
  loopback.
- queue chip: the + glyph centres in its 26x18 box.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 10:16:08 +02:00
Admin
ec07fe6519 readme: the VJ build section stops pointing at the work branch
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 08:58:21 +02:00
Admin
f44616b2a7 makepad-vj effects: the effect renderstack, with the shaders inside the documents
The VJ's EFFECT surface is a small renderer of its own. An effect is a
`.splash` DOCUMENT — engine choice, stages, fields, parameters and now the
SHADERS THEMSELVES live in the document rather than in Rust. That is the
shape this commit introduces (dev has never seen an intermediate one): a
document is the whole effect, and the Rust side is the engine families that
documents draw with.

The engine families: particles and emitters, GPU sim swarms and fluid, static
meshes (firefly synchrony, harmonograph loom, domino liturgy), tiles, flock,
clouds, city, pipes, stock charts, an SDF raymarcher with a subclassable
`scene_sdf`, and a mountain-jet endless range with a beat-pulsed fighter.

Three things make them a system rather than a demo reel:

  - SIM FIELDS — a float simulation-texture primitive, so an engine can carry
    GPU state across frames (wind, particles, fluid) instead of being a pure
    function of the clock.
  - CONTENT COUPLING — `content:` in a document and `input0` on every engine's
    tex0, so an effect can consume the program video: drapes, backdrops,
    mirrors, billboards, chrome, frescoes, canopy, glass, silk, wall, swarm,
    pen and mosaic families all take the picture playing behind them.
  - MUSIC BINDING — shaders read the beat, so the whole library moves with the
    track rather than on its own clock.

Roughly a hundred seeded preset documents ship with it, each with a lazily
rendered animated thumbnail (rendered 4x and box-downscaled, 16-tap SSAA);
the thumbnail pass went from four minutes to eleven seconds. CONTRACT.md is
the document contract and its verify recipe; IDEAS.md is the campaign tracker.
2026-08-23 01:35:44 +02:00
Admin
17b8a658fc makepad-vj: the live performance console — sweep-law transport, slots, and VFR import
A VJ console that is its own asset store: five surfaces (VIDEO program tiles,
MUSIC DJ decks, SFX, EFFECT slots, LIGHTS) over the asset server, driven by
APC40 hardware or the screen.

The transport is the centre of it. The SWEEP LAW: one direction, one beat
step, and a pacer that never hiccups — a loop wrap is never fast-forwarded and
the grid keeps no holes. On top of that: bracket-to-bracket scrub math so the
playhead learns the user's trim, SCRATCH as a sprung shuttle on every deck
transport, ping-pong and per-slot mute, hot-standby decks, one rate authority,
and beat sync that fits a whole clip into N bars at a musical rate while
cached loops keep real wrapping pts so the phase survives.

VARIABLE FRAMERATE VIDEO IMPORT measures the flow field a clip needs without a
model (libs/video_flow), which is what makes free-rate bounce-looping GPU
playback possible; the enhance pipe uprezes and tweens a deck clip in one
decode and one encode, with the motion vectors inside the mp4.

The console itself: makepad orange and the brand lockup, Blender-style BPM,
system tooltips, popover sliders, bracket trim handles, one button family and
a no-push band, doc-labeled slot dials with MIDI learn and strict typing, a
BEATS dropdown and slow-biased jog, an IMPORT panel that arms, runs and stops,
and BLAST — one press invents a visual for every parallel pipe. Generation
runs six jobs in flight across the fleet, and a job row names what it made.

The library grid fills as a relay — page, detail, manifest, blob, decode,
texture — and every hand-off used to be picked up by the 20Hz poll timer, so
each hop cost a tick however fast the store answered. The hand-offs now run on
the frame while anything is owed, manifests jump the queue like the details
that produced them, and resolve/decode/worker width all widen while nobody is
on stage and narrow together the moment the program window opens or a deck
plays. The visible page resolves first. Measured on the same warm cache: a
48-tile page 4.7s -> 1.9s, a tab's first visit 4.3s -> 1.1s, decode wait 48ms
-> 9ms, a second visit painted in the same frame with zero decodes, and no
UI hitches across the session.

Also here: wave analysis (tempo, grid, downbeat, tiles) with a judge suite
that scores the detector against click tracks and a human drummer, stem
separation, karaoke lyric display, Art-Net/DMX light control, and an output
window that projects without janking on Windows mouse movement.

466 tests, zero warnings.
2026-08-23 01:35:44 +02:00
Admin
9d2e17e3b9 chore: README, workspace, agent docs, and the box-driving scripts
- README: a build quick-start for macOS and Windows, the honest Linux
    story, what CUDA is for and how to install the separation model.
  - AGENTS.md: the `--remote` control surface protocol, so the harness it
    documents is usable without reading platform/src/remote.rs.
  - Cargo.toml: workspace membership for the crates this series adds and
    removes.
  - tools/: the Windows box scripts (wincmd, winps and friends, winrun) and
    remote_smoke.sh — how a build gets driven on a remote machine.
  - apps/asset-server: the standalone server binary and its README.
  - Small follow-ups in libs/{windows,apple_sys,makepad_test,mbtile_reader,
    converse} and apps/route, plus .gitignore and makepad.splash.
2026-08-23 01:34:36 +02:00
Admin
d1fb9ad8ad examples: datagrid, portallist_hit, render_to_texture, splat_bench
Four new examples, each of which is also the test surface for the thing it
demonstrates:

  - datagrid — the DataGrid widget across five tabs (sheets with a formula
    engine, big data, charts, pixels, widgets).
  - portallist_hit — PortalList hit-testing, with a UI test suite.
  - render_to_texture — the offscreen pass, with a UI test suite.
  - splat_bench — Gaussian splat sorting and drawing under load.

uizoo gains a dropdown tab; the splash example's UI test follows the splash
host-services change.
2026-08-23 01:34:36 +02:00
Admin
e2616d2d45 asset-ui: analysis, masks, music, webcam, and a library that keeps up with the store
The asset browser becomes the front end for everything the AI stack can do:

  - analysis — a content analysis pass over imported assets.
  - mask_paint — paint a mask over an image for inpainting.
  - music_page — the music generation surface.
  - webcam — live capture as a generation input.
  - fast_presets, store_content — preset and store-content plumbing.

Around them the existing surfaces get the corrections the store and the video
widget forced: sprite-split and child-pass thumbnail fixes, one video widget
everywhere (opens stop fighting each other and the rail never reflows),
grouped classic/Duke import that lands 102 assets instead of a card flood,
chat that greets with silence rather than a banner, and a pipeline that
survives restarts.
2026-08-23 01:34:36 +02:00
Admin
0f67c0b593 libs/render + xr: levels, player navigation, and Gaussian splats that sort on the GPU
libs/render picks up the two biggest new modules in the group: `level.rs`
(the imported-world runtime) and `player_nav.rs` (walkable-surface planning
with clearance bands). The renderer, shader set, GPU lightmap and skinning
paths all grow with them — a chart-edge texel is no longer trusted with a
skirt's light, a lamp only receives what the sky is not already delivering,
and lamp photometry comes from the fixture rather than the mesh scale. A
`walk_probe` example drives the navigation directly.

xr gets splat packing and a GPU splat sort (`splat_pack.rs`, `splat_sort.rs`,
with tests), and view_splat is largely rewritten on top of them.

remesh, xatlas, gltf, splat and sim carry the supporting work: the xatlas
unwrap hang is fixed and the pass is several times faster, the glTF writer
emits the rig and vehicle contracts, and sim grows the entity layer the
imported worlds drive.
2026-08-23 01:34:35 +02:00
Admin
016a171a35 libs/audio_*: MP3, Vorbis and Ogg of our own, plus lyric alignment and audio imaging
Another dependency the app should not be asking the platform for:

  - audio_decode — MP3 (layer 3, LSF tables, synthesis) and Ogg Vorbis
    (codebooks, floor, residue, MDCT) decoders, with tag reading. Both are
    checked against oracle fixtures rather than against our own expectations.
  - audio_encode — an Ogg Vorbis encoder: MDCT, psychoacoustics, floor and
    Huffman coding, setup tables, plus `oggenc` and `audiobench` binaries.
  - audio_picture — waveform and spectrogram rendering, and compositing.
  - audio_lyrics — word-level lyric alignment (DTW plus a DP snap) and the
    baked schema behind karaoke timing.
  - audio_sidechannels — the side-channel plumbing between them.

libs/voice grows a CUDA backend and an alignment path beside its CPU decoder,
with a `whisper_parity` binary to keep the two honest.
2026-08-23 01:34:35 +02:00
Admin
7f59912916 libs/ai: one AI stack, replacing libs/ggml, llama, mlx, cuda, tts, voice2 and pbr_paint
The model code was spread across eight crates that had grown into each other:
ggml and cuda and mlx each owned part of a tensor runtime, llama and tts and
voice2 each owned part of a model, and libs/diffusion owned everything else.
They are now one tree with an explicit shape:

  libs/ai/cuda     — kernels and launch surface
  libs/ai/metal    — Metal shaders and the shim
  libs/ai/llm      — the language-model runtime (sessions, lanes, contexts,
                     the CUDA and Metal executors, the compiled Metal path)
  libs/ai/models/  — common, flux, h3, music, paint, speech, stems, vision

libs/diffusion is not deleted but demoted: what remains is the VALIDATOR
crate — several dozen `*_validate.rs` oracles that check a native
implementation against a reference, which is where they belong now that the
implementations live next door.

The functional work inside the move is mostly in the LLM runtime: N lanes that
draft while one verify batch serves all of them, per-slot prefill over a shared
folded attention arena, speculation that survives batching, and a scheduler
that reports rather than publishes. And in the CUDA build: a machine without
usable CUDA must still LINK (and say so), the default kernel arch is the
building machine's GPU, `NO_CUDA` forces the stub even where the toolkit
exists, and kernels compile in parallel with progress.

libs/video_flow is new here: classical optical flow estimation and the `mkfl`
motion-field payload — a flow field measured from a clip without a model,
which is what drives free-rate bounce-looping playback and the uprez/tween
enhance pipe.
2026-08-23 01:34:35 +02:00
Admin
2d23dba736 libs/asset: the store runs on our own SQLite, and the importers learn the whole map contract
The asset store now uses libs/sqlite_query as its ONLY engine — not a feature
flag, not a fallback. That closes the Windows gap (the embedded store starts
there now, and a SHARED->EXCLUSIVE upgrade is handled rather than assumed
free) and takes the C dependency out of the build everywhere else.

Around it:

  - store: a garbage collector, catalogued content that is referenced in place
    instead of copied, the `vjeffect` kind, and host/chat routes that keep up
    with the chat wire below.
  - importer: the unified map contract reaches quake2, quake3, doom and duke —
    world placement, nav, welding, prelit maps and glTF node handling shared
    rather than reimplemented per game. Music import, billboards and stateful
    props move to the data crate so readers stop linking the importer.
  - ai: the serving side of multi-lane chat — per-lane conversations, honest
    progress and acceptance reporting, penalties and a watchdog, context as a
    per-box number that compacts instead of erupting, a realtime session mode,
    and inpaint/flux2 backends. `chat_bench` measures the rate the way the
    client meter computes it.
  - client / chat / chat_ui: a publication can NAME a file instead of carrying
    it; the wire says whether a turn is warm and whether it is thinking, so a
    client stops guessing; transcript and feed widgets render history the way
    the model wrote it. `SessionConfig::catalog_runtime` lets a host size the
    catalog runtime's lanes itself — a browsing UI puts every listing, every
    per-tile resolve and every thumbnail blob through that one runtime and
    wants a wider fast lane than the shared default, while media lanes keep
    it (a few big transfers, not a thousand small ones).
  - widgets: the shared asset widgets — one video view (knobbed seek,
    transport, bracket trim, rail playback) used everywhere, plus thumb,
    preview, scene view, walk-world and the lyric reader.
2026-08-23 01:34:34 +02:00
Admin
19c37a3df8 libs/sqlite_query: an SQLite engine of our own
A from-scratch, dependency-free SQLite implementation: file format reader and
writer (b-tree read and write paths, pager, journal, WAL), a SQL lexer,
parser and AST, a planner, and an executor — plus locking, integrity checking
and a `sqlq` CLI.

It exists because the asset store needs a database on every platform the app
ships to, without a C toolchain in the build and without a system library
whose version is somebody else's decision. The test suite is the argument:
DML, DDL, concurrency, crash recovery, a query corpus and a DML fuzzer, all
checked against real SQLite behaviour rather than against our own reading of
the spec.
2026-08-23 00:43:20 +02:00
Admin
7e929df04d widgets: DataGrid, ComboBox, Tip, ValueInput, DropSlider, and a widget tree that keeps up
Six new widgets, all built for the console-density end of the spectrum:

  - DataGrid — a 2D-virtualised grid (rows AND columns), the table/spreadsheet
    counterpart to PortalList. Cells host arbitrary widgets from templates.
  - Chart — trend and sparkline drawing that composes inside docks and grids,
    which the old DrawVector-based chart could not.
  - ComboBox — a text input with a filtered, keyboard-navigable popup list;
    type-to-filter rather than pick-from-a-menu.
  - Tip / TipLayer — system tooltips: a shared overlay layer, hover timing and
    placement handled once instead of per widget.
  - ValueInput — a numeric field you can also drag, Blender-style.
  - DropSlider — a slider that lives in a popover, for consoles with no room
    for a permanent one.

widget_tree gets the larger share of the changed lines: observation and
patching paths reworked so a structural rebuild is not the answer to every
change. PortalList picks up the scroll-distance readout and the dead-isolate
guard from upstream; window.rs grows maximize/restore forwarders; splitter
exposes a color hook; fold_header, scroll_bar, text_flow and drop_down2 get
follow-ups.
2026-08-23 00:43:20 +02:00
Admin
5dc8ef5256 platform: a remote control surface, streaming video codecs, and float render targets
An app built with `--remote` now serves a localhost HTTP control surface:
window list, per-window PNG grabs, real mouse/key/text injection, widget
rects, a log ring buffer, and `/gq` (grab every window, then quit). It exists
so a test or an agent can DRIVE a running app instead of reasoning about it
from source — the protocol is documented in AGENTS.md. Grabs are targeted per
window (`/g?w=N`), so a multi-window app is captured window by window rather
than whichever pass happens to present first, and `log!` mirrors into the ring
buffer without anyone owning the app's stdout.

platform/video grows a streaming half beside the file half. StreamEncoder /
StreamDecoder with Apple VideoToolbox and Windows Media Foundation backends,
Annex-B framing, and all-intra bound through pEncodingParameters on Windows —
the only control that MFT actually honors, as the readbacks claim success for
everything else. The file decoder can now be asked for a SPECIFIC frame rather
than only the next one, which is what frame-exact seek and bounce playback
need. Tests cover file seek and the stream round trip.

Draw shaders gain `Rgba16F` and `Rgba32F` color formats to pair with the
float render textures: blending off, whole-texel writes, meant for GPU
simulation state (particle position/velocity, fluid fields) rather than
pictures.

Windowing and dialogs:
  - `CxOsOp::SetChromelessWhenMaximized` drops the native maximized border
    strip on Windows, so a maximized window reads as a clean picture.
  - `Cx::open_select_folder_dialog` opens the native folder picker with a
    title and start location, answered by a `FileDialogAction` in the actions
    pass; cancelling is a first-class outcome, not an error.
  - Windows reports a user close the way macos.rs already did.
  - macOS swaps the titlebar container so WindowDragQuery alone decides window
    drags, and the delegates carry a panic shield.
  - `Windows::id_iter()` enumerates window slots generation-correctly.

Headless: the virtual GPU and its rasterizer are substantially rebuilt around
the shader runtime preamble, making `MAKEPAD=headless` render-to-PNG a real
test surface rather than a smoke check. `PerfMonitor::frames_painted()` lets a
scripted driver pace itself to PRESENTED frames instead of queueing passes
faster than the GPU retires them.
2026-08-23 00:43:20 +02:00
Kevin Boos
d223bf4697
Wayland: mark windows as created, fixing dpi override and pass dpi factor (#1188)
* Wayland: mark windows as created, fixing dpi override and pass dpi

Wayland was the only backend that never set `is_created = true` on the Cx
window; every other one does it in `CxOsOp::CreateWindow`.

That flag gates `Cx::dpi_override_scale()`, so every pointer event skipped
the native->layout remap and clicks missed their widgets by the UI zoom
factor. It also gates `get_delegated_dpi_factor()`, which was returning a
hardcoded 1.0 for every draw pass on Wayland, so pixel snapping and the
shader pixel size (SDF AA fringe) used the wrong scale on HiDPI screens.
`SetWindowVisuals` and `set_topmost` were dropped for the same reason.

Also seed the Cx window's geom at creation like the x11 backend does,
otherwise it sits at dpi_factor 0.0 until the first configure arrives.

* convert the seeded wayland geom to layout points and record os_dpi_factor

Seeding the raw native geom left window_geom in native units (and the
os_dpi_factor fallback unset) until the first configure arrived, which is
exactly the pre-configure window the dpi override needs to be correct in.
Do the same conversion the WindowGeomChange path already does.
2026-08-22 13:40:23 +02:00
Kevin Boos
94743e7687
Fingers: mark a second touch on an already-captured area as handled (#1187)
When an area that already captured a touch sees another touch start,
hits() returns a FingerDown for it (so the owner can handle multi-touch
gestures like pinch) but never marked the touch as handled. Widgets
behind the owner could then capture that second touch themselves: the
second finger of a pinch atop a fullscreen overlay could drag-scroll a
list behind it, or even press a button back there.

* mark such a touch as handled if it actually hit-tests within the
  area, mirroring the normal capture path below it
* only do so if nothing else has handled it yet, preserving the claim
  of a child widget that captured it earlier in the same dispatch
2026-08-21 08:49:10 +02:00
Kevin Boos
0d7816ea68
splash: a dead isolate's widgets must not call into the app VM (#1186)
Killing a Splash isolate while widgets it minted are still in the tree
could panic the whole process in the GC, somewhere else entirely:

    platform/script/src/gc.rs:300
    index out of bounds: the len is 12 but the index is 19

script_ref_vm_id() resolves a widget's owning VM from a ref the widget
holds. Isolate heaps live in heap_to_vm; anything else was assumed to be
the app VM. But an isolate's widgets outlive it by a frame or two — a
tile dropped mid-gesture, an app force-stopped while its buttons are
still on screen — and their refs were minted by a heap that is gone. So
those calls were routed INTO the app VM, which then stored a dead heap's
object ids in an args object of its own (make_call_args_object_with_
context). Nothing complained: the checked stores silently skip an index
they can't resolve. The next GC walked that object, indexed the app heap
with the other heap's index, and blew up with no trace of the cause.

Reclaimed heaps are now remembered, so a ref from one is told apart from
an app-VM ref and its call is dropped rather than redirected — the same
thing script_timer_dispatch_hook already does with a dead isolate's
timers. Three related tightenings while here:

- with_script_vm_id checks "is this VM already installed?" BEFORE the
  main-VM shortcut, and debug_asserts in the main branch. with_vm runs
  against whatever VM is parked on Cx, which during an isolate's own
  execution is that isolate's — so the old order could silently run
  app-VM work in an isolate's heap.
- gc_dead_splash_isolates purges the queues holding a dying isolate's
  values before dropping the heap those values live in, matching what
  gc_bridge above it already did.
- splash_host_respond checks it landed in the heap the request came
  from before minting the answer object there. One usize compare turns
  a future routing mistake into an undeliverable answer instead of a
  corrupted heap.

Found by a launcher that force-stops a mini-app for hammering the host
bridge: ~40 requests in flight, isolate torn down in the same event
pass, reopened moments later. That reproduced it every time; with this
it no longer does.
2026-08-21 08:48:59 +02:00
Admin
9daab06dcf DropDown2: fix type-mismatch errors applying instance() markers in the style block
The styled mod.widgets.DropDown2 block re-applied hover/active/up/enabled
as instance(...) markers onto the already-typed f32 fields of the draw
shaders, which fails at widget instantiation with "type mismatch:
expected f32, got object". The instance() declarations belong to the
script_shader type registrations; the style block just sets plain
float defaults.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 21:36:37 +02:00
Kevin Boos
04d5c5527a
PortalList: expose the total distance the user has scrolled (#1185)
Adds `PortalListRef::user_scroll_travel()`, the running sum of every
user-driven scroll delta: wheel/trackpad (including OS momentum), touch
drags, scroll-bar drags, and selection auto-scroll. Deltas absorbed at a
clamped edge still count, while the list's own movement (smooth scrolls,
fling coasting, bounce-back, tail-following) does not.

Sampling it at two points and subtracting tells a caller whether the
user scrolled in between and in which direction, which comparing
`first_id()` cannot do once items are inserted or removed.
2026-08-20 11:01:21 +02:00
Kevin Boos
5a251fba12
Shader: keep the value of an if body's last statement (#1182)
A bare `if cond { ... }` statement whose final body statement is a
non-void expression compiled to an empty `if(cond){ }`: the expression
never reached the generated shader, so its side effects were lost. In
robrix this silently removed every border drawn as

    if self.border_size > 0.0 {
        sdf.stroke(self.border_color, self.border_size)
    }

A call is not written to the output when it is compiled, it is pushed on
the stack as a string, and it only reaches the output via POP_TO_ME.
Since e0a5a23f2 the enclosing statement's POP_TO_ME is emitted as a
standalone opcode at the if's jump target instead of being fused onto the
body's last call, and the shader compiler closes an `IfBody` as soon as
`ip >= target_ip`, so the opcode sitting exactly at the target is never
seen while the body is open. The body's value was then dropped on the
floor by the `no outer phi` arm, whose comment assumed that could not
happen.

* Emit the leftover value as a statement inside the branch when nothing
  consumes it, mirroring the void path a few lines above.
* Add `gpu_stage_4m`, which asserts the call survives into the generated
  shader. It fails without the fix.

The parser side is deliberately untouched: `last_jump_target` is load
bearing for the widget-loss fix that `on_render_emission` guards.
2026-08-19 20:06:12 +02:00
Kevin Boos
6dd0b2c133
Splash: a host-services bridge for mini-apps, plus three VM/parser fixes (#1181)
* Splash: a host-services bridge so isolates can ask for brokered capabilities

Mini-apps are sandboxed hard (fs/run/res stripped, net gated), which also
means they can't do anything real. This adds the one doorway back: a
mod.host module in every isolate whose host.request(service, args, cb)
queues {app_tag, heap_key, req_id, service, args_json} on a thread-local
the EMBEDDING HOST drains and answers (splash_host_respond re-enters the
isolate under the normal budget and calls the callback with {ok, data,
error}). No policy lives in makepad: an undrained request never resolves,
tags are host-assigned (Splash::set_host_tag) so scripts can't spoof who
they are, and host.capabilities() just echoes whatever grant list the
host last pushed (set_host_caps). Callbacks are rooted ScriptFnRefs keyed
by heap, GC'd with the isolate alongside the storage-jail roots.

Also: call_script_fn_with_strings (string args must be minted in the
callee's own heap), 'let host = mod.host' in both Splash prefixes (line
offsets documented per prefix; the net prefix was already one line off),
and mod.cx.quit is now nil'd in isolates - a mini-app could quit the
whole host process with one call.

* script: stop validation from blessing scripts that failed to parse

The parser RECOVERS from errors (dangling else, missing expression), logs
them, sets had_error - which nothing ever read - and hands back a runnable
module. Nothing enters the trap queue, so a host validating with a
captured_errors sink + take_errors() got an empty list and reported
success; three freshly-written mini-apps shipped real parse errors straight
through host_launcher's validate this way, visible only as stray [E] log
lines.

report_error now also records the formatted message on the parser
(ScriptParser::errors), and both eval paths (eval_with_source and the
streaming eval_with_append_source) drain that into bx.captured_errors when
a sink is installed. No sink = logs only, exactly as before. Regression
tests in tests/parse_error_capture.rs, including the exact fn-final
if/else shape that slipped through.

* splash_host: review fixes — is_ok result field, silent surfaces, JSON hardening

Three classes of fixes from an adversarial review of the bridge:

- The result object's success field is now is_ok. 'ok' is the script
  dialect's ok-test KEYWORD, so r.ok never parsed as a field access — every
  callback that read it silently died. A pure-VM regression test
  (fn_ref_callback.rs) now exercises the exact store-callback-then-answer
  flow the bridge uses.

- SplashHostRequest carries may_prompt, set per isolate via
  Splash::set_host_prompts: background surfaces (home-screen widget tiles)
  are marked silent so a host can fail their permission-needing requests
  instead of popping consent dialogs nobody asked for. splash_host_respond
  also reports an outcome now (Delivered / NoCallback / IsolateGone) so
  hosts can log undeliverable answers, and Splash::isolate_heap_key lets a
  host relate a request to a specific widget (IPC fan-out skips the
  sender's own isolate with it).

- heap.to_json hardening: a cyclic object graph (script-buildable, host-
  serialized on every bridge request) recursed to a stack overflow — now a
  depth cap emits null leaves; backslashes were mis-escaped as a single
  backslash (invalid JSON downstream), tab and other control chars weren't
  escaped at all, and a handle serialized as unquoted junk.

* script: a closure's captured varargs must not shadow its own parameters

A call binds positional args by INDEXING the fn object's vec, which holds
declared parameters — but also, past that, any varargs the call received
(unnamed_fn_arg pushes them with a NIL key). A closure captures the scope
it was minted in, so those leftovers ride along ahead of the closure's own
parameters.

Concretely: script timers invoke their callback with one number (the time).
Hand start_timeout a zero-arg closure and that number lands in the scope as
a NIL-keyed vararg; any closure created in that body then binds its FIRST
parameter against the leftover — first failing the typecheck ("arg 0 (nil)
type mismatch: expected number, got object"), and once that was relaxed,
binding the value under the NIL key so the real parameter stayed nil. It
cost a full debug cycle in host_launcher, where every host-service callback
created inside a boot timer silently never ran.

Both binding paths now walk the DECLARED (named) entries in order, so
captured varargs can never be mistaken for a parameter. Regression test in
tests/extra_arg_typecheck.rs reproduces the timer shape exactly.

(Pre-existing and unrelated: widget_tree's test_observe_and_find_single_node
and test_property_patch_no_structural_rebuild fail on upstream dev too.)

* script: stop parse_json silently dropping negative numbers

The tokenizer emits a leading `-` as its own Operator token, and none of
the three JSON value positions (object value, array element, root) had a
case for it. The sign was swallowed — and inside an object the KEY went
with it, because the minus consumed the value slot and the parser resynced
on the next token.

So `{"lat":37.7,"lon":-122.4}` parsed to `{"lat":37.7}`. No error, no
warning, just a missing field. That is how it was found: a mini-app asked
the host where it was, got coordinates with no longitude, and quietly fell
back to a default city. Sub-zero temperatures and negative UTC offsets
(New York is -14400) were being dropped the same way.

A pending-sign flag is applied to the next number in all three positions.
Bare scalar roots stay unsupported ("42" never parsed either) — separate
pre-existing gap, not touched here. Tests in
platform/script/tests/json_negative_numbers.rs.

* splash_storage: let the host raise a single isolate's jail quota

The jail's 16MB whole-app cap is a constant, so "this app may keep more
than the standard amount" had nowhere to live. A per-heap quota map beside
SANDBOX_ROOTS gives the host one, set through Splash::set_storage_quota
and cleared with the isolate like every other per-isolate binding. Script
still can't see or raise its own cap.

Lowering a quota never deletes anything — it just stops further growth —
so revoking the grant is safe on an app that already wrote past the
default.

host_launcher uses this for a `storage-large` permission (64MB), which is
the point: a capability the user can revoke and have it actually mean
something.
2026-08-19 20:06:01 +02:00
Jason Yau
2c49150e3a
Don’t panic on a stale Area::Rect (#1184)
Co-authored-by: jasonqiu <jasonqiuchen@outlook.com>
2026-08-19 13:50:04 +02:00
Admin
6d708d9391 Clean the public tree for a checkout other people can use.
Drop the archived old/ tree, root AI notes, splashgame.md (Arcade lives
in the private sandbox), widgets-dll leftovers, and LAN/oracle helper
scripts. Move download_*.sh into tools/, and move sim/math out of
libs/game/ so the public repo no longer has a game/ directory.

Also quiet first-party compile noise and skip asset-ui tests that need
sandbox kits or uncommitted fixtures.
2026-08-18 15:23:19 +02:00
Admin
5421bc71c6 Remove widgets-dll, hotload_ui, and the weezl tokio test.
hotload_ui and xr pulled a dylib widgets wrapper that made
`cargo build --workspace` fail. XR now uses normal widgets.
The vendored weezl async test needed tokio, which we do not ship.
2026-08-18 14:45:09 +02:00
Admin
3b1ed5107b Drop unused examples from the public tree.
Remove godot, scratchpad, arracing, comfyui, exf, ddgo, and git.
Flux workflow JSON used by diffusion tests moves into libs/diffusion.
2026-08-18 14:41:52 +02:00
Admin
765f4785fc Let a clean makepad checkout load and compile.
Drop the private sandbox clone from required workspace members
and Studio runnables. Finish the platform_ops VecDeque merge
(push_back / Option remove), land the mip-repeat texture API
the renderer already calls, and unbreak the Q3 importer plus
the godot example template that .gitignore had hidden.
2026-08-18 14:36:05 +02:00
Admin
f0e1f83831 Close the rik2 land: workspace, splash, and sandbox cleanup.
Point the workspace at libs/asset and libs/render. Drop Arcade
frontend, gamemaker, rig, pack registry, physics, and the old
libs/game gameplay crates. Those live in the private sandbox repo.
2026-08-18 14:24:00 +02:00
Admin
bdc7c0a227 Land Asset UI and VJ from rik2.
Catalog/import/viewers and the live performance app. They use
libs/render preview play, not the Arcade sim crate as a dep.
2026-08-18 14:24:00 +02:00
Admin
7437b08f0e Land asset-ai and the pack/classic importer from rik2.
Fleet generate service and licensed/classic pack compiler.
2026-08-18 14:24:00 +02:00
Admin
8436f955d6 Land the Asset Server protocol stack from rik2.
data, client, store, and chat — the contract Asset UI and VJ speak.
2026-08-18 14:23:59 +02:00
Admin
3f1da40e89 Land native diffusion and TTS backends from rik2.
Flux2, SAM3, Music3, ACE, paint, DA3, RealESRGAN, Trellis, motion.
2026-08-18 14:23:59 +02:00
Admin
8fce54a7a8 Land the house Art-Net/DMX desk from rik2.
libs/show_control plus the automate example. Not Arcade gameplay.
2026-08-18 14:23:59 +02:00
Admin
55dde7b02a Land the 3D renderer and leftover sim/math from rik2.
libs/render (Renderer, SceneDraws, preview play) plus the Arcade
sim/math it still draws through. Gameplay crates stay out.
2026-08-18 14:23:59 +02:00
Admin
fbe877d746 Land glTF writers, remesh, PBR paint, and xatlas from rik2.
Animation/skin writers, COLOR_0, and the mesh tools the renderer uses.
2026-08-18 14:23:58 +02:00
Admin
27d0b1179d Land ggml, llama, and Metal/CUDA tensor runtimes from rik2.
GpuTensor, QMM, k-quant, and op-parity tests the gen backends sit on.
2026-08-18 14:23:58 +02:00
Admin
2a46d2a405 Land platform, studio, and widget infra from rik2.
HTTP progress, OS file drag, RunView controllers, DropDown2, video,
remote process helpers, and the Studio runbook.
2026-08-18 14:23:57 +02:00
5409 changed files with 1937471 additions and 561995 deletions

59
.gitignore vendored
View file

@ -2,6 +2,19 @@
# will have compiled files and executables
**/target/
# Private Scope (the Architecture map). Clone into this path:
# git clone git@github.com:makepad/scope.git apps/scope
/apps/scope/
# Private Arcade frontend. Clone into this path:
# git clone git@github.com:makepad/sandbox.git apps/sandbox
/apps/sandbox/
# git clone git@github.com:makepad/source-library.git apps/source-library
/apps/source-library/
# Private Stage performance app. Clone into this path:
# git clone git@github.com:makepad/stage.git apps/stage
/apps/stage/
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
Cargo.lock
@ -38,6 +51,7 @@ GOOGLE_KEY
experiments/websocket-windows/nodejs-websocketserver
experiments/svgloader
CLAUDE.md
!examples/godot/resources/template/CLAUDE.md
/tools/__pycache__/
**/*.obsidian
@ -80,3 +94,48 @@ tesla_credentials.json
/route.md
/shiny.md
/status.md
# Baked AO atlases: regenerate with tools/ao_bake, never commit (41 MB).
**/ao_atlas.png
# LAN fleet configs — machine-local, never committed
fleet-hosts*.txt
# Local secrets and AI-session state — never commit
/GOOGLE_API_KEY
/aichat_history.json
/godot_chat_history.json
/codex2
/hello_world.py
/package-lock.json
/mapplan.md
/mix.md
/overlay.md
# Local data dumps, benches and scratch checkouts — never commit
/*.csv
/*.mbtiles
/kokoro_ref*
/silero_vad.onnx
/.rustup/
/build/
/box3d/
/sub/
/refdump/
/traces/
/xr/dump/
/examples/splash_preview/
/examples/automate/local/
/examples/cad/generated/
window_*_frame_*.png
remote-jobs/
apps/asset-ui/resources/
# Alternate cargo target directories (cross-checks, gpusim runs, lane
# verification) live beside the checkout as `target-<name>`; anywhere in the
# tree they are build output, never source. Same for the agents' scratch
# dirs at the root and a stray log.
target-*/
/scratchpad/
/.grok/
/*.log

409
AGENTS.md
View file

@ -1,113 +1,294 @@
# Studio Remote Runbook
# Makepad Agent Runbook
Repository-wide rules. Read the linked references when the task needs them;
use current source for API signatures and working examples.
## Execution Policy
- Visual UI programs must be launched and controlled through the Makepad Studio remote protocol.
- Always use release builds for runtime validation, profiling, benchmarks, timing checks, or any performance-sensitive command. Use `--release` unless the user explicitly asks for a debug build.
- Do not use mount observation or runnable discovery from the bridge client. The bridge must not claim mount ownership from Studio desktop.
- Do not launch UI programs with raw `cargo run`, `cargo makepad`, or ad hoc cargo invocation when a runnable item exists.
- Do not use bridge `Cargo` requests to run applications. Only launch apps from runnable items via bridge `RunItem`.
- For UI runnable targets, do not prebuild or precheck the app from the shell before launching it in Studio. Let the Studio `RunItem` build be the single build path so Cargo fingerprints, env vars, target dirs, and flags stay identical.
- Before starting a new UI run for the same target, send `ClearBuild` for the previous build so Studio stops it and removes its run/log/profiler tabs.
- `cargo check` or `cargo build` never counts as UI verification. After changing UI/runtime code, you must clear the old build and start a fresh Studio run before trusting screenshots, widget dumps, or interaction results.
- Do not keep inspecting an older already-running app after code changes. Re-run the target and verify against the new `build_id`.
- Command-line-only tasks (builds, tests, linting, file ops, grep/ripgrep, etc.) can be run directly in the shell.
- Prefer studio remote control for any workflow that needs screenshots, widget queries, clicks, typing, or runtime UI inspection.
- Before using Studio protocol tools (`FindInFiles`, `ReadTextRange`, `WidgetTreeDump`, `WidgetQuery`, `Screenshot`, `Click`, `TypeText`, `Return`), always start one persistent Studio remote bridge process and reuse it for the entire interaction.
- When adding a new example crate, update both the Cargo workspace and `makepad.splash` so Studio exposes the new example as a runnable item.
## Assumptions
- Studio is started manually by the user.
- Studio remote target is `ip:port` only (no `http://`, no `ws://`), normally `127.0.0.1:8001`.
- Use `127.0.0.1:8002` only if Studio reports fallback because `8001` is occupied.
- Keep one persistent studio remote process for the whole interaction.
- **Designs stay local.** Design documents, plans, and reports are local files
(`local/agent_state/<topic>/DESIGN.md`) that lanes read from disk. Never
publish them to the web (no Artifacts, no hosted pages); summarize in the
terminal instead.
- Launch UI programs as standalone release binaries from this checkout. Do
not use the Studio remote bridge, `ObserveMount`, `RunItem`, or any
`cargo-makepad studio` websocket client.
- Launch with `--remote` whenever you intend to look at or drive the app,
and finish with `GET /gq`. **Nothing of yours may outlive your task** —
never leave a test window on the user's screen.
- Always use release builds for runtime validation, profiling, benchmarks,
timing checks, or any performance-sensitive command. Use `--release`
unless the user explicitly asks for a debug build.
- Build with `cargo build --release -p <package>`, then launch the
resulting executable so its provenance is unambiguous. Do not use raw
`cargo run` / `cargo makepad` to start a UI you will keep inspecting.
- Stop or replace an older standalone instance of the same target before
launching a freshly built one.
- Keep an interactive standalone app running when the user asks to play
with it. Use a separate self-terminating capture run only when a
screenshot is also needed.
- `cargo check` or `cargo build` never counts as UI verification. After
changing UI/runtime code, rebuild and relaunch before trusting what you
see. Do not keep inspecting an older already-running binary.
- Command-line-only tasks (builds, tests, linting, file ops, grep, etc.)
can be run directly in the shell.
- A standalone app's built-in screenshot/capture hook is valid for visual
inspection.
- **System-level screenshots are FORBIDDEN.** Never run `screencapture`,
`CGWindowListCreateImage`/`CGDisplayCreateImage` scripts, `xcap`,
`import`, `scrot`, `grim`, `xwd`, PowerShell/Win32 screen grabs, or any
other OS screen capture — not of the display, not of a window, not
"just the caption". The user's screen is private. The only image of a
running app you may ever take is the app's own `--remote` grab (`/g`,
`/gq`, `/tweak/grab`), which renders the app's own drawable and nothing
else. If something only shows in the OS layer (native caption buttons,
other apps, the desktop), ask the user for a screenshot instead of
taking one.
- When adding a new example crate, update both the Cargo workspace and
`makepad.splash`.
- **Zero locking on the UI thread, one mechanism everywhere.** The UI
thread never takes a `Mutex`/`RwLock`/`Condvar` that another thread can
hold, and never blocks on a channel. UI → workers/audio is commands over
a channel (bounded, non-blocking send; a full queue is reported and
retried next frame). Workers/audio → UI is snapshots over atomics, a
triple buffer, or a channel read with `try_recv`. Large payloads (PCM,
stems, grids, images) travel as `Arc` through the channel; a replaced
payload is handed back so the UI thread does the drop, never a realtime
thread. A realtime callback (audio) owns its state, never takes a lock
the UI can hold, never allocates on the hot path. This is ONE code path
for native and wasm — no `cfg` fork where desktop keeps shared mutexes.
On wasm both the browser UI thread and the AudioWorklet thread abort on
`Atomics.wait`, and a spinning fallback against a busy audio callback is
a 100 % CPU feedback loop that kills audio and frame rate together (DJ
web, 2026-09-03). `lock_from_ui` is only acceptable on state provably
touched by the UI thread alone.
- **Standard operating flow — who does what.** The main session (Fable)
designs, briefs, manages and reviews; it does not write the code itself
except one-line fixes. **Codex writes the code**: every implementation lane
is a Codex lane with a precise brief (observations, files, rules, the
verification commands) launched through `local/tools/delegate` /
`local/agent_state/webdemos/tasks/queue.sh` and landed through
`local/tools/integrate`. **Grok does the tests and the token-heavy work**:
test suites, audits, surveys, log reading, conflict resolution passes,
reviews of large diffs (`delegate grok` / `research-grok` / `review-grok`).
A Fable subagent is the exception, only for a design-level change the
other two cannot carry (a new platform mechanism), and it stops as soon as
the API is fixed so Codex can do the conversions. Keep at most six lanes
per provider; land everything through the integrator; the user tries the
result — no routine captures.
- **No temporary threads — use the pool.** Never spawn a thread for one
job (`std::thread::spawn` is unsupported on wasm anyway; the platform
spawner works everywhere). Background work goes to the platform thread
pool (`cx.thread_spawner()` / the pool `TaskHandle` API) or to a
long-lived worker created once at start-up and fed over a channel. On
the web a Web Worker takes hundreds of milliseconds to come up, so a
per-job thread is a stall; on desktop it is still churn. One mechanism
on both targets.
## Start Studio Remote
- Command:
- `target/release/cargo-makepad studio --studio=127.0.0.1:8001`
- Send newline-delimited JSON requests on stdin.
- Read newline-delimited JSON responses on stdout.
- Protocol shape is raw `ClientToHub` requests on stdin and filtered `HubToClient` responses on stdout.
- Do not send `ObserveMount` from the bridge. It can take `primary` UI ownership for the mount and divert RunView/framebuffer traffic away from Studio desktop.
## Standalone Launch
1. `cargo build --release -p <package>` from this checkout.
2. Kill any older process of that same executable.
3. Run `target/release/<bin> --remote` from the repo root (so resource paths
resolve), parse the port from the startup line, drive it over HTTP.
4. After code changes, repeat 1–3 before drawing conclusions.
5. `GET /gq` when you are done. Always.
## Request Protocol (JSON Lines)
- `{"ListBuilds":[]}`
- `{"ClearBuild":{"build_id":[6]}}` stops a running build and immediately clears its Studio UI tabs; use this before rerunning the same app.
- `{"StopBuild":{"build_id":[6]}}` stops/kills a running build but does not clear Studio tabs.
- `{"RunItem":{"mount":"makepad","name":"makepad-example-todo"}}`
- `{"RunItem":{"mount":"makepad","name":"makepad-example-xr-quest"}}`
- `{"FindInFiles":{"mount":"makepad","pattern":"ClientToHub::","is_regex":false,"glob":null,"max_results":200}}`
- `{"FindInFiles":{"mount":"makepad","pattern":"ClientToHub::(FindInFiles|ReadTextRange)","is_regex":true,"glob":"**/*.rs","max_results":200}}`
- `{"ReadTextRange":{"path":"makepad/studio/backend/src/dispatch.rs","start_line":640,"end_line":720}}`
- `{"WidgetTreeDump":{"build_id":[6]}}`
- `{"WidgetQuery":{"build_id":[6],"query":"id:todo_input"}}`
- `{"Screenshot":{"build_id":[6],"kind_id":0}}` (`kind_id` optional; defaults to `0`)
- `{"Click":{"build_id":[6],"x":1274,"y":342}}`
- `{"TypeText":{"build_id":[6],"text":"hello"}}`
- `{"Return":{"build_id":[6],"auto_dump":false}}`
- `{"ForwardToApp":{"build_id":[6],"msg_bin":[...]}}` (advanced; binary payload)
## App Remote Control (`--remote`)
## `StudioToApp` API (Updated)
- The studio remote bridge supports raw app event passthrough via `UIToStudio::ForwardToApp`.
- Current `StudioToApp` variants include:
- `Screenshot`, `WidgetTreeDump`, `KeepAlive`, `LiveChange`, `Swapchain`, `WindowGeomChange`, `Tick`
- `MouseDown`, `MouseUp`, `MouseMove`, `Scroll`
- `KeyDown`, `KeyUp`, `TextInput`, `TextCopy`, `TextCut`
- `None`, `Kill`
- Use direct studio remote requests (`Screenshot`, `WidgetTreeDump`, `Click`, `TypeText`, `Return`) for normal automation.
- Use raw `StudioToApp` only for low-level event injection/debugging.
> **Focus law.** A `--remote` app opens its window VISIBLE BUT UNFOCUSED and
> stays that way: it never activates, never becomes key, and bridge clicks
> never raise it. The user keeps typing wherever they were. Everything the
> bridge does (grabs, `/m`, `/k`, `/t`, `/snap`) works without focus because
> input is injected through the app's event loop, not the OS. Do not work
> around this (`MAKEPAD_FOCUS=1` exists only for a run the user asks to see
> in front); `MAKEPAD_NO_FOCUS=1` gives a non-remote launch the same manners.
## Response Notes (Current)
- Bridge stdout is filtered to: `Hello`, `Error`, `TextFileRead`, `TextFileRange`, `FindFileResults`, `SearchFileResults`, `Builds`, `RunItems`, `BuildStarted`, `BuildStopped`, `BuildCleared`, `AppStarted`, `RunViewCreated`, `QueryLogResults`, `Screenshot`, `WidgetTreeDump`, `WidgetQuery`, `QueryCancelled`.
- `BuildCleared` is a Studio frontend cleanup signal routed to the primary UI for the build's mount; bridge clients should not wait for it before starting the next run.
- `RunViewFrame` and the terminal stream are not exposed by the bridge.
- `Screenshot` responses include file metadata (`path`, `width`, `height`) and not inline PNG bytes.
- `WidgetTreeDump` responses include text dump content keyed by `request_id`.
- `FindInFiles` responds as `SearchFileResults` with concise entries (`path`, `line`, `column`, `line_text`) and `done`.
- `FindInFiles` defaults to searching only `.rs`, `.md`, `.toml` files unless `glob` is provided.
- `ReadTextRange` responds as `TextFileRange` with `path`, requested `start_line`/`end_line`, `total_lines`, and `content`.
- Query-scoped responses are lane-filtered by `query_id.client_id`; only this bridge client's query results are emitted.
- Build ids and query ids are `QueryId` tuple structs, so JSON encodes them as one-element arrays like `[6]`.
- `FindInFiles`/`SearchFiles` execution is worker-pooled in backend (not main dispatch thread).
> **Who may open a visible window.** Subagent/lane verification runs HIDDEN:
> launch with `MAKEPAD_HIDE_WINDOWS=1 <bin> --remote` — the window never
> appears, grabs (`/g`), `/snap`, `/m`, `/k`, `/t` all still work offscreen.
> Only the integrating session opens the one visible, unfocused window the
> user watches; several look-alike windows on screen made the user "go
> insane" (2026-08-26).
## Recommended Control Flow
1. Start studio remote process once.
2. Determine the target runnable item name locally from the repo or from the user request.
3. Call `ListBuilds` and find any existing build for the same runnable item.
4. Send `ClearBuild` for that old `build_id`; do not wait for an acknowledgment before the next launch.
5. Start the new UI app through `RunItem`, and wait for `BuildStarted` and `AppStarted`.
6. After any code change that affects runtime/UI behavior, repeat steps 3-5 before doing screenshots, widget dumps, clicks, or visual conclusions.
7. For code search, use `FindInFiles` first, then `ReadTextRange` to window exact regions.
8. Use direct shell cargo commands for non-launch tasks such as `check`, `build`, `test`, or `bench`.
9. Use `WidgetQuery` / `WidgetTreeDump` to get click targets.
10. For text input, click field first, then send text, then return.
11. Keep control packets compact (`auto_dump:false` on click/type/return for low latency).
## `RunItem` Launch
- `RunItem` executes a Studio-defined runnable item by name.
- Use the runnable item name shown in Studio, not a Cargo package name.
- `RunItem` does not implicitly replace an older build tab; agents should clear the old build themselves first with `ClearBuild`.
Any makepad app launched with `--remote` runs a localhost HTTP server inside
the process and prints one line before the UI appears:
## One-Flow Input Burst
- Send this as one stdin write (multiple JSON lines, no sleeps):
- `Click` (input field center)
- `TypeText`
- `Return`
- Then request `WidgetTreeDump` or `Screenshot` to confirm.
```
[makepad-remote] listening on 127.0.0.1:53412 pid=9931 app=makepad-example-splash grabs=/var/folders/…/T/makepad-remote/makepad-example-splash-9931
```
## Coordinates
- Use coordinates from dump as-is.
- `W3` dump uses integer pixel coordinates in the same space expected by `Click`.
- Do not apply extra DPI math in the agent loop.
Port, pid, app name and the grab directory — everything needed to drive and
clean up the instance, with no discovery step. `--remote=PORT` pins the port;
`MAKEPAD_REMOTE=1` (or `=PORT`) does the same via the environment. No app code
is involved: it lives in `app_main!`, so every app gets it for free.
## Reliability Notes
- `Screenshot` can arrive before visible redraw after rapid input bursts.
- If screenshot looks stale, request a follow-up `WidgetTreeDump`/`Screenshot`.
- If input does nothing:
- Verify `build_id` with `ListBuilds`.
- Refresh dump and retry click on input before typing.
- If request errors with no active websocket:
- app is not connected yet; wait for startup completion and retry.
### Cheat sheet
Every route is a plain `GET`. Every answer is **one line of JSON** with short
keys and real numbers. Errors are `{"err":"..."}` with HTTP 404.
`GET /` returns this table as plain text, so an agent that finds the port
learns the whole API in one request.
| Route | Answer | Notes |
|---|---|---|
| `/` `/help` | plain-text cheat sheet | self-describing; read this first |
| `/s` `?w=ID` | `{"app":…,"pid":…,"w":[{"i":0,"t":"Title","sz":[w,h],"px":[w,h],"dpi":2,"pos":[x,y]}]}` | `sz` = layout points, `px` = physical pixels |
| `/g` `?w=&scale=&raw=` | `{"png":"/abs/path.png","w":0,"sz":[w,h]}` | writes a file and returns the **path** (agents read images as files). `raw=1` sends `image/png` bytes instead. `scale=0.5` halves it |
| `/gq` `?w=&scale=` | `{"png":[paths…],"quit":1}` | **grab every window, then quit.** The canonical last call of a session |
| `/m` `?k=&x=&y=&w=&b=&dx=&dy=&wait=` | `{"ok":1,"f":frame}` | `k=move\|down\|up\|click\|scroll`; `b=0` left, `1` right, `2` middle |
| `/click` `?x=&y=&w=&wait=` | `{"ok":1}` | alias for `/m?k=click` (move + down + up) |
| `/k` `?t=TEXT` or `?k=down\|up\|press&c=CODE` | `{"ok":1}` | `t=` goes through the IME text path; `c=` takes `KeyA`/`a`/`enter`/`Escape`/`ArrowLeft`/`F1`/`Key1`… plus `&shift=1&ctrl=1&alt=1&cmd=1` |
| `/t` `?t=TEXT` | `{"ok":1}` | same as `/k?t=` |
| `/snap` `?q=&w=&all=` | `{"s":[{"i":"id","ty":"Button","r":[x,y,w,h],"w":0,"t":"Click me"}]}` | **how you find things to click.** `q=` filters id/type/text; rects are window-local, ready to feed to `/click` |
| `/d` `/dump` | plain text widget tree | one indented line per widget, ending `x y w h` |
| `/log` `?n=50&since=N` | `{"n":lastseq,"l":["[E] …"]}` | ring buffer of the app's own log output — see errors without owning stdout |
| `/close` `?w=ID` | `{"ok":1}` | closes one window the normal way |
| `/quit` | `{"ok":1}` | graceful shutdown, no final grab |
Add `&wait=1` to any input route to have it answer only **after the next frame
is drawn**, so a following `/g` sees the result with no `sleep`.
Add `&w=ID` to target a window; omit it for the first one.
`POST` the same routes with a flat JSON body (`{"x":10,"y":20}`) when quoting a
query string is painful; the key names are the long ones (`window`, `kind`,
`button`, `text`, `code`).
### The standard pattern
```bash
cargo build --release -p makepad-example-splash
./target/release/makepad-example-splash --remote > /tmp/app.log 2>&1 &
sleep 4
P=$(grep -o 'listening on 127.0.0.1:[0-9]*' /tmp/app.log | grep -o '[0-9]*$')
curl -s "http://127.0.0.1:$P/s" # {"app":…,"w":[{"i":0,…}]}
curl -s "http://127.0.0.1:$P/snap?q=press_demo" # find the button's rect
curl -s "http://127.0.0.1:$P/click?x=352&y=472&wait=1"
curl -s "http://127.0.0.1:$P/snap?q=press_status" # assert the app reacted
curl -s "http://127.0.0.1:$P/log?n=20" # any errors?
curl -s "http://127.0.0.1:$P/gq?scale=0.5" # final PNGs + quit
```
Read the returned `png` path with your image tool. `tools/remote_smoke.sh` is
this pattern as an executable end-to-end test across three example apps.
### Rules
- **Close what you open.** When you are done with an instance you launched,
`GET /gq` (or `/close` each window, then `/quit`). Never leave test windows
on the user's screen, and never `pkill` when the protocol is available.
- **Never touch an instance the user is running.** Launch your own.
- **`/g` is the only camera.** No OS-level screen capture of any kind (see
Execution Policy) — the remote grab is what you get.
- **A vanished window or app with `[makepad-remote] user closed …` in the log
means the human dismissed it — it was in their way.** Do **not** treat that
as a crash and do **not** relaunch it. The app prints
`[makepad-remote] user closed window 1 ("Inspector Panel")` and, when that
was the last window, `[makepad-remote] app exit: user closed the last
window`. Both lines go to stdout with or without `--remote`, and into the
`/log` ring. While the app lives, `/s?w=1` on such a window answers
`{"err":"window 1 closed by user"}` rather than "no window 1".
- **`--remote` windows are tagged.** Their title gets a ` [remote]` suffix
(both the OS title bar and makepad's own caption bar) so a human who finds
one lingering knows it is an agent instance and can close it guilt-free.
`--remote-title-tag=NAME` changes the tag; `--remote-title-tag=off` removes
it.
### Semantics worth knowing
- **Coordinates** are layout points, window-local, y down — the same space
`MouseDownEvent.abs` uses, and the same space `/snap` reports rects in. No
dpi maths: a rect from `/snap` goes straight into `/click`.
- **Window ids** are stable `usize` slots (`/s` `"i"`). Every window-targeting
route takes `w=`; omitting it means the first created window. A request for
a window that never existed 404s with `{"err":"no window 3"}`.
- **Input takes the real path.** Events are injected through
`Cx::dispatch_studio_msg`, the same function the studio bridge uses, with
the same `fingers` bookkeeping — so hits, capture, tap counts and gestures
behave exactly as they do for a human. `/click` sends move + down + up so
hover-dependent widgets see what they expect.
- **Grabs are real frames**, read back from the window's own presented
drawable on the frame after the request (the studio screenshot pipeline,
extended with per-window targeting). The UI thread is never blocked; the
HTTP thread waits. Grabs are written to
`$TMPDIR/makepad-remote/<app>-<pid>/grab-w<window>-<seq>.png`, monotonically
numbered, with the last 32 per window retained.
- **Backends:** macOS/Metal is fully supported. Linux GL and Vulkan support
grabs too. Windows/D3D11 has no screenshot readback yet, so `/g` there times
out with `{"err":"grab timeout …"}` while every other route works. Android,
OHOS and wasm compile to a no-op.
- **Cost when idle is zero.** The event loop only upshifts its paint clock
while a remote request is in flight.
### The TWEAKER (`/tweak/*`) — design feedback and live styling
Every `--remote` app carries a design-feedback overlay (plan of record:
repo-root `tweaker.md`; implementation: `widgets/src/tweaker.rs`). Off it
costs nothing. On, the person (or you) points at the UI: pointer events over
the window body are swallowed before widget dispatch — **clicking a Button in
tweak mode outlines it and never fires it** — and the window grows a property
sidebar next to the (compressed) app UI. Shift+F10 toggles it in-app; every edit,
theirs or yours, lands in one shared diff log.
| Route | Answer | Notes |
|---|---|---|
| `/tweak` `?on=1\|0&annotate=1\|0` | `{"on":1,"annotate":0}` | toggle the overlay / the freehand draw mode (Alt-drag draws too) |
| `/tweak/state` | `{"on":1,"sel":{path,ty,r,band},"props":[{n,v,set}],"hover":…,"diff":[…],"ann":[…]}` | the STRUCTURE feedback: pinned selection, its real reflected properties (`set:1` = explicitly applied), the edit log, annotation strokes with the widget paths they touch |
| `/tweak/apply` (POST) | `{"ok":1,"path":…,"changed":[{path,prop,old,new}]}` | body `{"path":"a.b.c","splash":"{padding: Inset{left: 20}}"}` or the one-property shorthand `{"path":…,"prop":"draw_bg.border_radius","value":"8"}`. Evaluates the chunk onto that ONE instance through the ordinary apply machinery (`+:` merge rules intact) and triggers a full relayout. Answers after the next drawn frame |
| `/tweak/diff` | `{"diff":[{path,prop,old,new}…]}` | the raw edit log, in order |
| `/tweak/clear` | `{"ok":1}` | reset diff + annotations |
| `/tweak/final` | `{"final":[…coalesced…],"ann":[…],"drew":0\|1,"png":path?}` | **read this when tweaking is done**: per (path, prop) only the original and final value, churn collapsed. When the user drew, `png` is the composited screenshot — look at it, the strokes mean something |
| `/tweak/grab` | like `/g` | the overlay (outlines, strokes, sidebar) draws in the window's own pass, so any grab is already composited |
`local/tools/tweak` wraps all of this:
`tweak PORT on`, `tweak PORT state`, `tweak PORT apply PATH PROP VALUE`,
`tweak PORT splash PATH 'CHUNK'`, `tweak PORT final`, …
**How to listen.** Sidebar edits push to you: each one emits a marked
`TWEAK sidebar <path> <prop> <old> -> <new>` line into the app log — the
`/log` tail is your ear; you never poll `/tweak/state` for changes. Talk back
on `/tweak/apply` (values or whole shader chunks) to the same selected
instance.
**Write-back (you do this part — the overlay never writes source).** When the
session is done, take `/tweak/final` and edit the splash source:
1. Resolve each entry's widget path to its DSL site: the dotted path mirrors
the `script_mod!` tree (`/d` shows the same ids). `-` segments are
anonymous containers — skip them when searching the source.
2. Write each property at the **most specific existing site** — the widget's
own `name := Type{…}` block if it has one; create one only when none
exists.
3. Respect the merge law: a property inside a typed sub-struct goes through
`+:` (`draw_bg +: { border_radius: 8 }`), never a replacing
`draw_bg: {…}`. Plain walk/layout values (`padding`, `margin`, `width`)
are set directly (`padding: Inset{left: 20}`).
4. Values come back in source spelling (`#rrggbbaa` colors, plain numbers) —
paste them as-is. Mind the Rust-tokenizer hex-`e` trap in `script_mod!`:
`#1e1e2e` must be written `#x1e1e2e`.
5. Rebuild and relaunch; verify the value survived with `/tweak/state` or
`/snap` before calling it done.
Reflection truth: `props` come from the widget's live Rust fields plus the
type's DSL-declared shader inputs (`instance()`/`uniform()`), so the list is
what the widget actually exposes — there is no synthetic schema to drift.
### Studio remote bridge (the older path)
The studio (`studio/desktop` + `studio/hub`) drives a hosted app over a
websocket with the `StudioToApp` / `AppToStudio` protocol
(`platform/studio/src/studio.rs`): `MouseDown/Up/Move/Scroll`, `KeyDown/Up`,
`TextInput`, `TextCopy/Cut`, `GameInput`, `Screenshot`, `RunViewFrameRequest`,
`WidgetTreeDump`, `WidgetQuery`, `WidgetSnapshot`, `LiveChange`, `Custom`,
`Kill`, plus the shared-swapchain messages `Swapchain` / `WindowGeomChange` /
`Tick`. `libs/makepad_test` is the programmatic client for it
(`TestApp::try_click_center`, `try_type_text`, `try_screenshot`, …) and
`examples/*/tests/ui.rs` are its test suites.
`--remote` reuses that vocabulary — the same message types, the same injection
function, the same screenshot pipeline — but exposes it as HTTP on the app
itself, with no studio, no hub, no build ids, and with per-window targeting
that the studio path lacks. Use `--remote` for agent work; the studio bridge
remains for the studio and for `libs/makepad_test`.
## CLAUDE.md Body
The following is the current body of CLAUDE.md included verbatim for agent guidance parity.
@ -127,19 +308,35 @@ grep -r "texture_2d" widgets/src/
## Running UI Programs
Use the Studio bridge runnable-item flow instead of launching UI apps directly from the shell:
Launch UI apps as standalone release binaries from this checkout. Do not
use the Studio remote bridge.
1. Start the Studio remote bridge once.
2. Determine the runnable item name locally.
3. If an older instance is still running, clear it with `{"ClearBuild":{"build_id":[N]}}` and launch the replacement immediately without waiting for an acknowledgment.
4. Launch it with `{"RunItem":{"mount":"makepad","name":"<runnable-name>"}}`.
5. After editing UI/runtime code, do not inspect the previously running build. Always verify against the newly started build id from step 4.
```bash
cargo build --release -p makepad-app-asset-ui
# stop any older instance of the same binary, then:
./target/release/makepad-app-asset-ui
```
Do not use `ObserveMount` from the bridge. That call is for mount ownership/subscription and can steal RunView/framebuffer routing away from Studio desktop.
For one-shot visual smoke of a small example:
Use direct shell cargo commands only for non-UI tasks such as library checks, tests, and file/search operations. Do not run shell `cargo check`, `cargo build`, or `cargo run` for UI runnable targets that will be launched via Studio.
```bash
RUST_BACKTRACE=1 cargo run -p makepad-example-splash --release & PID=$!; sleep 15; kill $PID 2>/dev/null; echo "Process $PID killed"
```
When those non-UI tasks are used for runtime behavior or performance measurements, prefer their release variants (`cargo run --release`, `cargo test --release`, `cargo build --release`).
To look at or drive a running app, add `--remote`: the app serves a localhost
HTTP control surface (window list, PNG grabs, real mouse/key/text injection,
widget rects, log tail) and prints its port on startup. Finish every session
with `GET /gq`, which grabs each window and quits — never leave a test window
on screen. Full protocol: repo-root `AGENTS.md`.
```bash
./target/release/makepad-example-splash --remote > /tmp/app.log 2>&1 &
P=$(grep -o 'listening on 127.0.0.1:[0-9]*' /tmp/app.log | grep -o '[0-9]*$')
curl -s "http://127.0.0.1:$P/" # cheat sheet
curl -s "http://127.0.0.1:$P/gq" # final grab + quit
```
When measuring runtime or performance, prefer `--release`.
## Cargo.toml Setup

View file

@ -1,11 +1,24 @@
workspace.members = [
# === app ===
"apps/browser",
"apps/terminal",
"apps/wm",
"apps/aichat",
"apps/finance",
"apps/sheets",
"apps/photos",
"libs/wm_theme",
"libs/wm_api",
"libs/app_module",
"apps/task",
"apps/image",
"apps/video",
"apps/pdf",
"apps/files",
"apps/route",
# === arcade (game.md) ===
# === arcade (game.md) — networked AI game sandbox ===
"apps/arcade",
"libs/game/math",
"libs/game/render",
"libs/game/sim",
"libs/game/blocks",
"libs/game/gen",
"libs/game/audio",
@ -15,65 +28,167 @@ workspace.members = [
"libs/game/pkg",
"libs/game/session",
"libs/game/assets",
"apps/asset-ui",
"apps/asset-server",
"apps/flow-server",
"apps/flow-ui",
"libs/flowgraph",
"libs/media_view",
"apps/ai-hub",
"apps/mixer",
"apps/storybook",
# The tiled-RTS map generator, shared by the importer and the sandbox.
"libs/rtsmap",
"libs/texcomp",
"libs/ai/hub_ui",
"libs/ai/services",
"libs/render",
"libs/raytrace",
"libs/fab",
"libs/fab_tour",
"apps/fab/loaders/gltf",
"libs/scene",
"libs/soft_body",
"libs/sim",
"libs/sim/math",
"libs/show_control",
"libs/bounded_http",
# zero-dependency shared primitives (SHA-256, reuse-address UDP bind)
"libs/core_util",
"libs/strict_json",
"libs/loader_bundle",
# === remesh (FaithC port) / xatlas (jpcy port) ===
"libs/remesh",
"libs/xatlas",
# Editable polygon topology and worker-owned model documents.
"libs/mesh_edit",
"libs/model",
# === examples ===
"examples/hotload_ui",
"examples/teamtalk",
"examples/automate",
"examples/map",
"examples/arracing",
"examples/splash",
"examples/slides",
"examples/isolate",
"examples/uizoo",
"examples/video_player",
"examples/video_codec_test",
"examples/charts",
"examples/scratchpad",
"examples/ddgo",
"examples/datagrid",
"examples/todo",
"examples/git",
"examples/portallist_hit",
"examples/modal_footprint",
"examples/vector",
"examples/comfyui",
"examples/aichat",
"examples/godot",
"examples/gamemaker",
"examples/pdf",
"examples/exf",
"examples/shader",
"examples/raytrace",
"examples/text_input",
"examples/text_atlas_stress",
"examples/text_selection",
"examples/text_center",
"examples/counter",
"examples/browser",
"examples/camera",
"examples/windows_blur",
"examples/floating_panel",
"examples/glass",
"examples/skeuomorph",
"examples/bugfix",
"examples/move_after_draw",
"examples/xr",
"examples/splat_bench",
"examples/cad",
"examples/box3d",
"examples/hello_world",
"examples/render_to_texture",
# === digital-fabrication product ===
"apps/fab",
"apps/fabric",
"libs/fabric/measure",
"libs/fabric/draft",
# === xr app ===
"xr",
# === studio ===
"studio/hub",
"studio/desktop",
# === director ===
"apps/director",
# === own SQLite-format database engine (P0 reader, sqlq CLI) ===
"libs/sqlite_query",
# === headless music score engine ===
"libs/score",
"libs/score_layout",
"libs/score_play",
"libs/score_render",
"libs/score_view",
"libs/score_view/tests/embed_app",
"libs/score_import",
"libs/score_pdf",
"libs/score_ui",
"apps/score",
"libs/midi_file",
"libs/soundfont",
"libs/piano_model",
"libs/drumkit",
"libs/drumkit_phys",
"libs/musicxml",
# === own MP3 / Ogg Vorbis decoders ===
"libs/audio_decode",
# === own Ogg Vorbis encoder (stem side-channels) ===
"libs/audio_encode",
"libs/teamtalk",
# === pictures of audio (spectrogram, wave strip, composite) ===
"libs/audio_picture",
# === per-application audio tap, mixer input for a host equalizer ===
"libs/audio_route",
# === mkfl motion payload + classical optical flow + all-intra re-encode
# (shared by the enhance backend and the VJ's flow-warp import) ===
"libs/video_flow",
# === realtime frame tweening: the classical GPU optical-flow pass
# chain, the RIFE producer behind the same warp, and the mode set
# (extracted from the VJ, which is still its reference user) ===
"libs/frametween",
# === archive.org search + download content input (VJ / asset-ui) ===
"libs/archive_org",
# === image tile wall: HEVC tape atlases + baker CLI + TileGrid widget
# (the engine extracted from the Source Library picture wall) ===
"libs/image_tiles",
"examples/image_tiles",
# === mp4 sample index for range-streaming playback ===
"libs/mp4_index",
# === necessary tools ===
"platform/video",
"tools/cargo_makepad",
"tools/makepad_loader",
# === OSM PBF -> tile archive + nav artifact bake passes (CLI + in-app) ===
"libs/map_build",
"tools/map_tiles",
"tools/map_bake",
"tools/remote",
"tools/arcade_eval",
"tools/dj_pack",
"libs/system_speech",
# === tests ===
"platform/script/test",
]
workspace.exclude = [
# Private Scope (the Architecture map) and its code-intelligence crates.
# Clone into apps/scope; path deps stay ../../ like a subdirectory. Not a
# required member so a clean makepad tree still loads.
"apps/scope",
# Private Arcade frontend. Clone into apps/sandbox; path deps stay ../../
# like a subdirectory. Not a required member so a clean makepad tree
# still loads.
"apps/sandbox",
# Private Source Library picture wall. Same shape as sandbox: clone into
# apps/source-library; path deps stay ../../.
"apps/source-library",
# Private Stage performance app. Own workspace with ../../ path deps.
"apps/stage",
"libs/terminal_core",
"libs/ggml",
"libs/voice",
"libs/voice2",
"libs/tts",
# standalone GPU generate service (own workspace, like diffusion)
"libs/ai/hub",
"libs/diffusion",
# the AI model workspace (loader + cuda/metal stores + model crates) — aiarch.md
"libs/ai",
"widgets/test",
"libs/stitch",
"libs/wasm_bridge/test",
@ -164,7 +279,6 @@ workspace.exclude = [
"libs/linux/wayland-protocols",
"libs/linux/wayland-sys",
"tools/windows_strip",
"tools/web_server",
"tools/file_router",
"tools/wasm_strip",
]
@ -190,6 +304,9 @@ strip = true
inherits = "release"
debug = true
[profile.test.package.makepad-stitch]
opt-level = 1
#[profile.dev.package.makepad-live-tokenizer]
#opt-level = 3
#[profile.dev.package.makepad-live-compiler]

View file

@ -61,13 +61,13 @@ sudo apt-get update && sudo apt-get install -y --no-install-recommends build-ess
Makepad Studio is the main entry point for exploring examples and iterating on UI.
```bash
cargo run -p makepad-studio --release
cargo run -p makepad-director --release
```
If you want a local install (note: may lag the repo):
```bash
cargo install makepad-studio
cargo install makepad-director
```
## Examples
@ -90,8 +90,8 @@ cargo run -p makepad-example-map --release
For built-in maps and voice support, download the assets first:
```bash
./download_map.sh
./download_voice.sh
./tools/download_map.sh
./tools/download_voice.sh
```
## Run A WASM App

39
apps/ai-hub/Cargo.toml Normal file
View file

@ -0,0 +1,39 @@
[package]
name = "makepad-app-ai-hub"
version = "0.1.0"
edition = "2021"
publish = false
[[bin]]
name = "makepad-ai-hub"
path = "src/main.rs"
[dependencies]
makepad-ai-hub = { path = "../../libs/ai/hub", default-features = false }
# Feature passthrough: the headless node exposes exactly the library's
# feature set, so box launch scripts keep their --features flags working.
[features]
default = ["flux", "paint", "paint-cuda", "llm", "tts", "indextts", "video", "interpolate", "audio", "mesh", "matte-native", "depth-native", "segment-native", "body-native", "upscale-native", "motion-native", "rig-native", "splat-native", "stems-native"]
python-backends = ["makepad-ai-hub/python-backends"]
flux = ["makepad-ai-hub/flux"]
paint = ["makepad-ai-hub/paint"]
paint-cuda = ["makepad-ai-hub/paint-cuda"]
llm = ["makepad-ai-hub/llm"]
tts = ["makepad-ai-hub/tts"]
indextts = ["makepad-ai-hub/indextts"]
speech = ["makepad-ai-hub/speech"]
stt = ["makepad-ai-hub/stt"]
video = ["makepad-ai-hub/video"]
interpolate = ["makepad-ai-hub/interpolate"]
audio = ["makepad-ai-hub/audio"]
mesh = ["makepad-ai-hub/mesh"]
matte-native = ["makepad-ai-hub/matte-native"]
depth-native = ["makepad-ai-hub/depth-native"]
segment-native = ["makepad-ai-hub/segment-native"]
body-native = ["makepad-ai-hub/body-native"]
upscale-native = ["makepad-ai-hub/upscale-native"]
motion-native = ["makepad-ai-hub/motion-native"]
splat-native = ["makepad-ai-hub/splat-native"]
rig-native = ["makepad-ai-hub/rig-native"]
stems-native = ["makepad-ai-hub/stems-native"]

View file

@ -0,0 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#1b3354"/>
<stop offset="1" stop-color="#0b1627"/>
</linearGradient>
<linearGradient id="sheen" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#ffffff" stop-opacity="0.10"/>
<stop offset="0.5" stop-color="#ffffff" stop-opacity="0"/>
</linearGradient>
<linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#7ef0da"/><stop offset="1" stop-color="#3fc9b0"/></linearGradient>
</defs>
<rect x="64" y="64" width="896" height="896" rx="200" fill="url(#bg)"/>
<rect x="64" y="64" width="896" height="896" rx="200" fill="url(#sheen)"/>
<rect x="66" y="66" width="892" height="892" rx="198" fill="none" stroke="#ffffff" stroke-opacity="0.08" stroke-width="4"/>
<g stroke="#4aa3e0" stroke-width="36" stroke-linecap="round">
<path d="M512 512 L512 262 M512 512 L728.5 387 M512 512 L728.5 637 M512 512 L512 762 M512 512 L295.5 637 M512 512 L295.5 387"/>
</g>
<g fill="#4aa3e0"><circle cx="512" cy="262" r="62"/><circle cx="728.5" cy="387" r="62"/><circle cx="728.5" cy="637" r="62"/><circle cx="512" cy="762" r="62"/><circle cx="295.5" cy="637" r="62"/><circle cx="295.5" cy="387" r="62"/></g>
<circle cx="512" cy="512" r="124" fill="url(#g)"/>
<circle cx="512" cy="512" r="46" fill="#0e1d33"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

216
apps/ai-hub/src/main.rs Normal file
View file

@ -0,0 +1,216 @@
//! makepad-ai-hub service binary. Runs on each GPU box; wraps all AI
//! content generation behind a port.
//!
//! ```text
//! makepad-ai-hub [--port N] [--host ADDR] [--cache-dir PATH] [--registry PATH]
//!
//! --port listen port (env MAKEPAD_ASSET_AI_PORT, default 8765)
//! --host bind address (default 0.0.0.0)
//! --fleet partition name (env MAKEPAD_ASSET_AI_FLEET, default default)
//! --cache-dir model + artifact dir (env MAKEPAD_ASSET_AI_CACHE,
//! default <home>/.makepad/weights)
//! --registry registry json path (default: <cache-dir>/registry.json if it
//! exists, else the embedded registry)
//!
//! env HF_TOKEN bearer token for gated HF repos (flux1-dev)
//! env MAKEPAD_ASSET_AI_HF_BASE alternate HF endpoint / LAN mirror
//! ```
use makepad_ai_hub::download::Downloader;
use makepad_ai_hub::registry::Registry;
use makepad_ai_hub::server::{start_service, ServiceConfig};
use makepad_ai_hub::{AssetAiError, DEFAULT_PORT, SERVICE_NAME, SERVICE_VERSION};
use std::path::PathBuf;
fn main() {
if let Err(err) = run() {
eprintln!("{SERVICE_NAME}: {err}");
std::process::exit(1);
}
}
fn run() -> Result<(), AssetAiError> {
let mut port: Option<u16> = None;
let mut host = "0.0.0.0".to_string();
let mut fleet: Option<String> = None;
let mut cache_dir: Option<PathBuf> = None;
let mut registry_path: Option<PathBuf> = None;
let mut machine = false;
let mut activity_probe = None;
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
match arg.as_str() {
"--activity-probe" => {
activity_probe = Some(args.next().ok_or_else(|| AssetAiError::Io("--activity-probe needs seconds (1..3600)".into()))?.parse::<u64>().map_err(|_| AssetAiError::Io("invalid activity probe seconds".into()))?);
}
"--port" => {
let value = args
.next()
.ok_or_else(|| AssetAiError::Io("--port needs a value".into()))?;
port = Some(
value
.parse()
.map_err(|_| AssetAiError::Io(format!("bad --port {value:?}")))?,
);
}
"--host" => {
host = args
.next()
.ok_or_else(|| AssetAiError::Io("--host needs a value".into()))?;
}
"--fleet" => {
fleet = Some(args.next().ok_or_else(|| {
AssetAiError::Io("--fleet needs a value".into())
})?);
}
"--cache-dir" => {
cache_dir = Some(PathBuf::from(args.next().ok_or_else(|| {
AssetAiError::Io("--cache-dir needs a value".into())
})?));
}
"--registry" => {
registry_path = Some(PathBuf::from(args.next().ok_or_else(|| {
AssetAiError::Io("--registry needs a value".into())
})?));
}
// The machine node (aicore §3): loopback-only, registered in
// ~/.makepad/run for the apps on this machine, and gone on its
// own once nothing needs it — a cache, not a daemon.
"--machine" => {
machine = true;
}
"--help" | "-h" => {
println!(
"{SERVICE_NAME} {SERVICE_VERSION}\nusage: {SERVICE_NAME} [--port N] [--host ADDR] [--fleet NAME] [--cache-dir PATH] [--registry PATH] [--machine] [--activity-probe SECONDS]"
);
return Ok(());
}
other => {
return Err(AssetAiError::Io(format!("unknown argument {other:?}")));
}
}
}
if let Some(seconds) = activity_probe {
return makepad_ai_hub::activity::run_probe(seconds);
}
let port = match port {
Some(port) => port,
None => match std::env::var("MAKEPAD_ASSET_AI_PORT") {
Ok(value) => value
.parse()
.map_err(|_| AssetAiError::Io(format!("bad MAKEPAD_ASSET_AI_PORT {value:?}")))?,
Err(_) => DEFAULT_PORT,
},
};
let cache_dir = cache_dir.unwrap_or_else(default_cache_dir);
let fleet = makepad_ai_hub::discovery::normalize_fleet(
&fleet.unwrap_or_else(makepad_ai_hub::discovery::fleet_from_env),
);
// Registry: explicit path > registry.json dropped into the cache dir
// (per-box extension without a rebuild) > embedded seed registry.
let registry = if let Some(path) = registry_path {
Registry::load_file(&path)?
} else {
let cache_registry = cache_dir.join("registry.json");
if cache_registry.is_file() {
Registry::load_file(&cache_registry)?
} else {
Registry::embedded()?
}
};
// The machine node is machine-local by definition: loopback bind, no
// matter what --host said.
if machine {
host = "127.0.0.1".to_string();
}
let downloader = Downloader::from_env()?;
let handle = start_service(ServiceConfig {
host,
port,
cache_dir: cache_dir.clone(),
registry,
downloader,
// Peer-cache lane: everything resolves from env / cache-dir files
// (MAKEPAD_AI_PEER_SECRET or <cache>/peer-secret enables serving;
// MAKEPAD_AI_PEER_SOURCES injects download sources).
peer: Default::default(),
fleet: fleet.clone(),
})?;
println!("{SERVICE_NAME} {SERVICE_VERSION}");
println!(" listening on http://{}", handle.addr);
println!(" fleet {fleet}");
println!(" cache dir {}", cache_dir.display());
if std::env::var("HF_TOKEN").is_ok() {
println!(" HF_TOKEN present (gated repos enabled)");
}
println!(" lora dir {}", cache_dir.join("loras").display());
println!(
" endpoints: /health /models /jobs /loras POST:/generate /job/<id> POST:/job/<id>/cancel /artifact/<id> /v1/model_inventory /v1/model_blob/<sha256> POST:/realtime GET(ws):/realtime/<id>"
);
if machine {
return run_machine_node(handle);
}
// The http listener thread runs until the process is killed.
let _ = handle.http_thread.join();
Ok(())
}
/// The machine node's life: register in ~/.makepad/run so the apps on this
/// machine find it, then idle down and exit once nothing has needed it for
/// the TTL — it reads as a cache, not a daemon (aicore §3). Visible in any
/// process list as makepad-ai-hub.
fn run_machine_node(handle: makepad_ai_hub::server::ServiceHandle) -> Result<(), AssetAiError> {
use makepad_ai_hub::machine::{write_node_entry, NodeEntry};
use std::time::{Duration, Instant};
let ttl_min: u64 = std::env::var("MAKEPAD_AI_HUB_MACHINE_TTL_MIN")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(15);
let entry = NodeEntry {
pid: std::process::id() as u64,
port: handle.addr.port(),
pipes_hash: 0,
};
let entry_path = write_node_entry(&handle.shared.node_key, &entry)
.map_err(|e| AssetAiError::Io(format!("write node entry: {e}")))?;
println!(" machine node: registered {} (ttl {ttl_min}m idle)", entry_path.display());
let mut idle_since = Instant::now();
loop {
std::thread::sleep(Duration::from_secs(30));
// Busy = queued/running work, or a model somebody paid to load.
let pending = handle.shared.jobs.with(|store| store.pending_count()) > 0;
let resident = handle
.shared
.models
.lock()
.unwrap()
.values()
.any(|track| matches!(track, makepad_ai_hub::server::ModelTrack::Loaded));
if pending || resident {
idle_since = Instant::now();
} else if idle_since.elapsed() > Duration::from_secs(ttl_min * 60) {
println!("{SERVICE_NAME}: machine node idle for {ttl_min}m — exiting");
let _ = std::fs::remove_file(&entry_path);
return Ok(());
}
}
}
fn default_cache_dir() -> PathBuf {
if let Some(dir) = std::env::var_os("MAKEPAD_ASSET_AI_CACHE") {
return PathBuf::from(dir);
}
makepad_ai_hub::home::default_weights_dir_with_migration(&mut |message| {
eprintln!("{SERVICE_NAME}: {message}");
})
}

46
apps/aichat/Cargo.toml Normal file
View file

@ -0,0 +1,46 @@
# aichat — the assistant, as an app.
#
# One conversation that drives every application through the AI services
# layer. It is hosted three ways with one code base: under the window
# manager as a special child seated in the pane slot (this binary,
# `--stdin-loop`), standalone as its own window (this binary), and inside
# any app's Window as the F10 overlay or inside the mobile/web superbuild
# (this crate's lib, `makepad_aichat::script_mod` + `AiChatPanel{}`).
#
# The panel widget OWNS the engine: the service registry, the model, the
# transcript. Hosts only feed it links (in-process) or bus frames (the
# window manager's studio Custom frames) and give it a place to draw.
#
# Plan of record: repo-root aicontrol.md.
[package]
name = "makepad-aichat"
version = "0.1.0"
edition = "2021"
default-run = "aichat"
[lib]
name = "makepad_aichat"
path = "src/lib.rs"
[[bin]]
name = "aichat"
path = "src/main.rs"
[features]
default = ["engine"]
# The real models (the hub's local runtime, the cloud providers). Off for a
# build without a model runtime — the web page answers with NoModel and the
# tool console still works.
engine = ["makepad-ai-services/engine", "dep:makepad-asset-creator"]
[dependencies]
makepad-widgets = { path = "../../widgets" }
makepad-wm-theme = { path = "../../libs/wm_theme" }
makepad-wm-api = { path = "../../libs/wm_api" }
makepad-ai-services = { path = "../../libs/ai/services", default-features = false }
makepad-strict-json = { path = "../../libs/strict_json" }
# The generative pipelines behind the assistant's own `gen` service
# (src/gen.rs): the creator runner picks a fleet node and brings the
# picture back. Native only, with the engine.
makepad-asset-creator = { path = "../../libs/asset/creator", optional = true }

24
apps/aichat/ci.splash Normal file
View file

@ -0,0 +1,24 @@
// CI smoke test for apps/aichat. Run by tools/ci, which finds every `ci.splash`
// in the checkout. The script decides every input; the vision model only
// judges the grab against the acceptance text below. Basic on purpose: does
// it check everywhere, build and come up here, and look like what it is.
use mod.std.*
use mod.ci;
ci.step("check other platforms", fn(){
ci.check_targets({packages: ["makepad-aichat"] targets: ci.host.targets})
})
let app = ci.launch({package: "makepad-aichat" binary: "aichat" cwd: "."})
ci.step("comes up", fn(){
ci.sleep(3.0)
app.no_errors()
let shot = app.grab("start")
ci.accept(shot, "This is a screenshot of one application window taken from a Mac. A thin bright red frame runs around the very edge of the picture; it is the test harness's marker and is not part of the application. Ignore it. The application is a chat window for talking to an AI assistant, with a message list and a text input. It came up when these are true: 1. The picture is not blank: it is not one flat colour from edge to edge, and it is not entirely black or entirely white. 2. Some user interface is visible: at least two different things among text labels, buttons, icons, panels, lists, input fields, a toolbar, a drawing or a picture. 3. No error panel covers the window: there is no large block of text with words such as panic, error, failed, backtrace or unwrap. Write one short bullet line for each numbered point saying what you see. YES means points 1, 2 and 3 are all satisfied. Then write the verdict as the last line, in exactly this form: VERDICT: YES or VERDICT: NO")
})
app.no_errors()
app.quit()
nil

View file

@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#1b3354"/>
<stop offset="1" stop-color="#0b1627"/>
</linearGradient>
<linearGradient id="sheen" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#ffffff" stop-opacity="0.10"/>
<stop offset="0.5" stop-color="#ffffff" stop-opacity="0"/>
</linearGradient>
<linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#b99dff"/><stop offset="1" stop-color="#8f6cf5"/></linearGradient>
</defs>
<rect x="64" y="64" width="896" height="896" rx="200" fill="url(#bg)"/>
<rect x="64" y="64" width="896" height="896" rx="200" fill="url(#sheen)"/>
<rect x="66" y="66" width="892" height="892" rx="198" fill="none" stroke="#ffffff" stroke-opacity="0.08" stroke-width="4"/>
<path d="M352 262 H672 A120 120 0 0 1 792 382 V562 A120 120 0 0 1 672 682 H470 L318 790 L344 682 A120 120 0 0 1 232 562 V382 A120 120 0 0 1 352 262 Z" fill="url(#g)"/>
<g fill="#e8eef6"><circle cx="382" cy="472" r="44"/><circle cx="512" cy="472" r="44"/><circle cx="642" cy="472" r="44"/></g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

144
apps/aichat/src/attach.rs Normal file
View file

@ -0,0 +1,144 @@
//! Pictures for the model: reference images a person drops into the chat,
//! and captures a host takes of its own window. Every picture reaches the
//! model as one still PNG of at most [`MAX_SIDE`] pixels a side, whatever
//! it started as (PNG, JPEG or WebP; a window grab at device pixels).
//!
//! Decoding and scaling are CPU work: hosts run [`normalize`] on the task
//! pool, never on the UI thread.
use makepad_widgets::*;
use std::path::Path;
use std::sync::Arc;
/// The longest side a picture keeps; what the CLIs accept everywhere.
pub const MAX_SIDE: usize = 1024;
/// Bytes a dropped file may have.
pub const MAX_FILE_BYTES: usize = 32 * 1024 * 1024;
/// Pixels a dropped file may decode to.
const MAX_PIXELS: usize = 40 * 1024 * 1024;
/// A picture on its way to the model.
#[derive(Clone, Debug)]
pub struct Attachment {
/// A short name for the transcript and the model (`keys.webp`).
pub name: String,
pub png: Arc<[u8]>,
pub width: usize,
pub height: usize,
/// A small copy for the composer's chip (dropped pictures only).
pub thumb: Option<Arc<[u8]>>,
}
pub fn image_name_supported(name: &str) -> bool {
Path::new(name)
.extension()
.and_then(|s| s.to_str())
.is_some_and(|ext| matches!(ext.to_ascii_lowercase().as_str(), "png" | "jpg" | "jpeg" | "webp"))
}
/// Where a dropped picture comes from.
#[derive(Clone, Debug)]
pub enum Source {
Path(std::path::PathBuf),
Bytes { name: String, bytes: Arc<[u8]> },
}
impl Source {
/// The picture a drag carries, if it carries one.
pub fn from_drag_item(item: &DragItem) -> Option<Source> {
match item {
DragItem::FilePath { path, .. } if image_name_supported(path) && Path::new(path).is_absolute() && path.len() <= 4096 => {
Some(Source::Path(std::path::PathBuf::from(path)))
}
DragItem::VirtualFile(file) if image_name_supported(&file.name) && file.bytes.len() <= MAX_FILE_BYTES => {
Some(Source::Bytes { name: file.name.clone(), bytes: file.bytes.clone() })
}
_ => None,
}
}
/// A pasted line that names an image file (a path or a `file://` URL).
pub fn from_pasted_text(text: &str) -> Option<Source> {
let text = text.trim();
let path = text.strip_prefix("file://").unwrap_or(text);
let path = path.replace("%20", " ");
let path = Path::new(&path);
(path.is_absolute() && image_name_supported(&path.to_string_lossy()) && path.is_file()).then(|| Source::Path(path.to_path_buf()))
}
pub fn name(&self) -> String {
match self {
Source::Path(path) => path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "image".into()),
Source::Bytes { name, .. } => name.clone(),
}
}
}
/// Read, decode and bring a dropped picture down to the model's size.
pub fn load(source: Source) -> Result<Attachment, String> {
let name = source.name();
let bytes: Arc<[u8]> = match source {
Source::Path(path) => {
let meta = std::fs::metadata(&path).map_err(|e| format!("{name}: {e}"))?;
if !meta.is_file() || meta.len() as usize > MAX_FILE_BYTES {
return Err(format!("{name}: not a regular image file of at most 32 MiB"));
}
Arc::from(std::fs::read(&path).map_err(|e| format!("{name}: {e}"))?)
}
Source::Bytes { bytes, .. } => bytes,
};
let decoded = decode_image_from_data(&bytes).map_err(|e| format!("{name}: cannot decode ({e})"))?;
if decoded.width == 0 || decoded.height == 0 || decoded.width.saturating_mul(decoded.height) > MAX_PIXELS {
return Err(format!("{name}: the picture is empty or too large"));
}
let mut rgba = Vec::with_capacity(decoded.width * decoded.height * 4);
for p in &decoded.data[..decoded.width * decoded.height] {
rgba.extend_from_slice(&[(p >> 16) as u8, (p >> 8) as u8, *p as u8, (p >> 24) as u8]);
}
let mut out = normalize(name.clone(), decoded.width, decoded.height, &rgba)?;
out.thumb = scale_to(name, decoded.width, decoded.height, &rgba, 96).ok().map(|t| t.png);
Ok(out)
}
/// RGBA pixels (top-down, tightly packed) to a model-sized PNG: a box
/// filter down to at most [`MAX_SIDE`] a side.
pub fn normalize(name: String, width: usize, height: usize, rgba: &[u8]) -> Result<Attachment, String> {
scale_to(name, width, height, rgba, MAX_SIDE)
}
fn scale_to(name: String, width: usize, height: usize, rgba: &[u8], max_side: usize) -> Result<Attachment, String> {
if width == 0 || height == 0 || rgba.len() < width * height * 4 {
return Err(format!("{name}: no pixels"));
}
let scale = (max_side as f64 / width.max(height) as f64).min(1.0);
let (w, h) = (((width as f64 * scale).round() as usize).max(1), ((height as f64 * scale).round() as usize).max(1));
let pixels = if (w, h) == (width, height) {
rgba[..width * height * 4].to_vec()
} else {
let mut out = vec![0u8; w * h * 4];
for y in 0..h {
let y0 = y * height / h;
let y1 = (((y + 1) * height).div_ceil(h)).clamp(y0 + 1, height);
for x in 0..w {
let x0 = x * width / w;
let x1 = (((x + 1) * width).div_ceil(w)).clamp(x0 + 1, width);
let mut sum = [0u32; 4];
for sy in y0..y1 {
let row = &rgba[(sy * width + x0) * 4..(sy * width + x1) * 4];
for px in row.chunks_exact(4) {
for c in 0..4 {
sum[c] += px[c] as u32;
}
}
}
let n = ((y1 - y0) * (x1 - x0)) as u32;
for c in 0..4 {
out[(y * w + x) * 4 + c] = (sum[c] / n) as u8;
}
}
}
out
};
let png = Cx::encode_rgba_as_png(w as u32, h as u32, &pixels)?;
Ok(Attachment { name, png: Arc::from(png), width: w, height: h, thumb: None })
}

213
apps/aichat/src/bus.rs Normal file
View file

@ -0,0 +1,213 @@
//! The client half of the window manager's service bus.
//!
//! Under the WM the other apps are not in this process. The WM forwards
//! their up-frames to the aichat child as studio `Custom` frames, each
//! stamped with the endpoint the WM issued to the sender, and forwards
//! the aichat child's down-frames (which name their target endpoint) back
//! to the right client. This adapter turns those frames into ordinary
//! [`ServiceLink`]s in the panel's registry, so the engine never knows
//! whether a service is a channel away or a process away.
//!
//! One link per endpoint. A `Register` from an endpoint the registry does
//! not know creates the link and registers it under the WM's endpoint id
//! (`register_as`); a later `Register` from the same endpoint is just the
//! manifest going down the existing link, where the registry answers it.
//! The WM tells us about a dead client by sending `Unregister` on its
//! behalf. Everything the registry sends down a bus link is drained here
//! and put on the wire to the WM.
use makepad_ai_services::engine::ServiceRegistry;
use makepad_ai_services::port::{ServiceLink, ServiceLinkHost};
use makepad_ai_services::wire::*;
use makepad_widgets::makepad_platform::studio::AppToStudio;
use makepad_widgets::*;
use std::collections::HashMap;
#[derive(Default)]
pub struct ServiceBus {
hosts: HashMap<EndpointId, ServiceLinkHost>,
}
impl ServiceBus {
/// An up-frame from the WM. `None` for frames that are not the bus's.
pub fn on_custom(&mut self, registry: &ServiceRegistry, json: &str) -> bool {
let Some(frame) = HostedUp::parse(json) else { return false };
let Some(from) = frame.from.clone() else { return true };
match (&frame.msg, self.hosts.get(&from)) {
(ServiceUp::Register { manifest, .. }, None) => {
let (link, host) = ServiceLink::pair(manifest.clone());
if registry.register_as(link, from.clone(), "", None).is_ok() {
let _ = host.up.send(frame);
self.hosts.insert(from, host);
}
}
(_, Some(host)) => {
let _ = host.up.send(frame);
}
(_, None) => {}
}
true
}
/// Put every frame the registry sent down a bus link on the wire.
/// Links whose registry side is gone are dropped.
pub fn relay_down(&mut self, registry: &ServiceRegistry) {
for frame in self.drain_down(registry) {
Cx::send_studio_message(AppToStudio::Custom(frame.to_json()));
}
}
fn drain_down(&mut self, registry: &ServiceRegistry) -> Vec<HostedDown> {
let mut gone: Vec<EndpointId> = Vec::new();
let mut frames = Vec::new();
for (endpoint, host) in &self.hosts {
loop {
match host.down.try_recv() {
Ok(mut frame) => {
// `Registered` travels without a target; the WM
// routes it by the endpoint we register as.
if frame.to.is_none() {
frame.to = Some(endpoint.clone());
}
frames.push(frame);
}
Err(std::sync::mpsc::TryRecvError::Empty) => break,
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
gone.push(endpoint.clone());
break;
}
}
}
}
for endpoint in gone {
self.hosts.remove(&endpoint);
registry.unregister(&endpoint);
}
frames
}
pub fn len(&self) -> usize {
self.hosts.len()
}
pub fn is_empty(&self) -> bool {
self.hosts.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
use makepad_ai_services::engine::{EngineCore, NoModelWithReason, RegistryUp};
#[test]
fn hosted_subscriptions_and_messages_cross_the_adapter_unchanged() {
let registry = ServiceRegistry::new();
let mut bus = ServiceBus::default();
let endpoint = EndpointId("w4".into());
let manifest = ServiceManifest::new("flow", "Flow", "A flow.")
.with_topic(TopicDef::new("run", "Run events."));
let register = HostedUp {
from: Some(endpoint.clone()),
msg: ServiceUp::Register { manifest, port_tag: 4 },
};
assert!(bus.on_custom(&registry, &register.to_json()));
registry.pump();
let host = bus.hosts.get(&endpoint).unwrap();
let _registered = host.down.try_recv().unwrap();
assert!(registry.send(
&endpoint,
ServiceDown::Subscribe {
sub_id: "s1".into(),
topic: "run".into(),
filter: None,
},
));
assert!(matches!(
host.down.try_recv().unwrap().msg,
ServiceDown::Subscribe { sub_id, topic, filter: None }
if sub_id == "s1" && topic == "run"
));
let message = HostedUp {
from: Some(endpoint.clone()),
msg: ServiceUp::Message {
sub_id: "s1".into(),
topic: "run".into(),
text: "finished".into(),
data: None,
final_: true,
},
};
assert!(bus.on_custom(&registry, &message.to_json()));
assert!(matches!(
registry.pump().as_slice(),
[RegistryUp::Message { endpoint: from, sub_id, message }]
if from == &endpoint && sub_id == "s1" && message.final_
));
}
#[test]
fn engine_shutdown_is_flushed_through_the_hosted_service_bus() {
let registry = ServiceRegistry::new();
let mut bus = ServiceBus::default();
let endpoint = EndpointId("w4".into());
let manifest = ServiceManifest::new("flow", "Flow", "A flow.")
.with_tool(ToolDef::new(
"watch",
"Watch a run.",
r#"{"type":"object","properties":{}}"#,
Risk::Read,
))
.with_topic(TopicDef::new("run", "Run events."));
assert!(bus.on_custom(
&registry,
&HostedUp {
from: Some(endpoint.clone()),
msg: ServiceUp::Register { manifest, port_tag: 4 },
}
.to_json(),
));
let mut engine = EngineCore::new(
registry.clone(),
Box::new(NoModelWithReason::new("not used by the tool console")),
None,
0x44,
);
engine.send("/flow.watch {}", 0.0);
let call_id = bus
.drain_down(&registry)
.into_iter()
.find_map(|frame| match frame.msg {
ServiceDown::Call(call) => Some(call.call_id),
_ => None,
})
.expect("the hosted service receives the call");
assert!(bus.on_custom(
&registry,
&HostedUp {
from: Some(endpoint.clone()),
msg: ServiceUp::Result(
ToolResult::ok(call_id, "watching", "")
.with_subscription(SubscriptionRequest::new("run")),
),
}
.to_json(),
));
engine.pump(0.1);
let sub_id = bus
.drain_down(&registry)
.into_iter()
.find_map(|frame| match frame.msg {
ServiceDown::Subscribe { sub_id, .. } => Some(sub_id),
_ => None,
})
.expect("the hosted service receives the subscription");
assert_eq!(sub_id, "l44-s1");
engine.shutdown();
assert!(matches!(
bus.drain_down(&registry).as_slice(),
[HostedDown { to: Some(to), msg: ServiceDown::Unsubscribe { sub_id: ended } }]
if to == &endpoint && ended == &sub_id
));
}
}

1074
apps/aichat/src/feedback.rs Normal file

File diff suppressed because it is too large Load diff

302
apps/aichat/src/gen.rs Normal file
View file

@ -0,0 +1,302 @@
//! The assistant's own generative service: `gen.image{prompt}`.
//!
//! The hub's pipelines are not an app, so no app registers them; the
//! panel registers this service in-process beside whatever apps join.
//! An `image` call runs the hub's one-job runner on a worker thread (it
//! is blocking: node pick over the LAN fleet, request, poll, fetch),
//! streams the node's progress into the card, writes the picture under
//! the makepad home's `gen` folder and answers with the path — the
//! model then hands that path to `photos.add`, which puts it on the wall.
//! Nothing goes through the asset store. Without the `engine` feature (the
//! web page) the service still exists and says it cannot.
use makepad_ai_services::engine::ServiceRegistry;
use makepad_ai_services::port::{AiServicePort, PortEvent};
use makepad_ai_services::wire::{Risk, ServiceCall, ServiceManifest, ToolDef, ToolResult};
use makepad_widgets::*;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{Receiver, TryRecvError};
use std::sync::Arc;
/// The service id on the bus.
pub const SERVICE_ID: &str = "gen";
pub fn manifest() -> ServiceManifest {
ServiceManifest::new(
SERVICE_ID,
"Generate",
"The machine's generative pipelines, on the fleet's GPU nodes: a \
picture from a prompt. The picture is saved on this machine under \
the makepad home's gen folder and the answer carries its path. To \
SHOW it, call photos.add with that path (launch Photos first with \
os.launch if it is not running) — the wall then glides onto it. A \
generation takes half a minute on a warm node, longer when a node \
must load the model.",
)
.with_tool(ToolDef::new(
"image",
"Generate one picture from a text prompt on a fleet image node; saves it under the makepad home's gen folder and returns the path.",
r#"{"type":"object","properties":{"prompt":{"type":"string","description":"what the picture shows, in plain words"},"width":{"type":"integer","description":"pixels, optional (default 1024)"},"height":{"type":"integer","description":"pixels, optional (default 1024)"}},"required":["prompt"]}"#,
Risk::Act,
))
}
/// What the worker reports back.
enum GenMsg {
Progress(String, u16),
Done(Result<GenDone, String>),
}
struct GenDone {
path: PathBuf,
node: String,
}
struct Job {
call_id: String,
cancel: Arc<AtomicBool>,
rx: Receiver<GenMsg>,
}
/// The in-process port plus the jobs in flight.
pub struct GenService {
port: AiServicePort,
jobs: Vec<Job>,
}
impl GenService {
/// Open the service and register it in the panel's registry. `None`
/// only when the manifest does not validate (a programming error).
pub fn open(registry: &ServiceRegistry) -> Option<GenService> {
let (port, link) = AiServicePort::in_process(manifest()).ok()?;
registry.register(link, "built in", None).ok()?;
Some(GenService { port, jobs: Vec::new() })
}
/// Drain the port and the workers; called on every panel event.
pub fn handle_event(&mut self, cx: &mut Cx, event: &Event) {
for ev in self.port.handle_event(cx, event) {
match ev {
PortEvent::Call(call) => self.start(call),
PortEvent::Cancel { call_id } => {
if let Some(job) = self.jobs.iter().find(|j| j.call_id == call_id) {
job.cancel.store(true, Ordering::Relaxed);
}
}
PortEvent::Registered(_)
| PortEvent::ChatOpen { .. }
| PortEvent::Subscribe { .. }
| PortEvent::Unsubscribe { .. } => {}
}
}
self.poll();
}
fn start(&mut self, call: ServiceCall) {
let id = call.call_id.clone();
if call.tool != "image" {
self.port.reply(ToolResult::refused(&id, format!("gen has no tool `{}`; it has image", call.tool)));
return;
}
let args = match parse_image_args(&call.args) {
Ok(a) => a,
Err(why) => {
self.port.reply(ToolResult::refused(&id, why));
return;
}
};
#[cfg(not(feature = "engine"))]
{
let _ = args;
self.port.reply(ToolResult::unavailable(&id, "this build has no pipeline runtime; pictures need the native app"));
}
#[cfg(feature = "engine")]
{
use makepad_widgets::makepad_platform::thread::SignalToUI;
let cancel = Arc::new(AtomicBool::new(false));
let (tx, rx) = std::sync::mpsc::channel();
let worker_cancel = cancel.clone();
let spawned = std::thread::Builder::new().name("gen-image".into()).spawn(move || {
let progress_tx = tx.clone();
let mut progress = |note: &str, permille: u16| {
let _ = progress_tx.send(GenMsg::Progress(note.to_string(), permille));
SignalToUI::set_ui_signal();
};
let result = run_image(&args, &worker_cancel, &mut progress);
let _ = tx.send(GenMsg::Done(result));
SignalToUI::set_ui_signal();
});
match spawned {
Ok(_) => self.jobs.push(Job { call_id: id, cancel, rx }),
Err(e) => self.port.reply(ToolResult::failed(&id, format!("could not start the generation: {e}"))),
}
}
}
fn poll(&mut self) {
let mut done: Vec<usize> = Vec::new();
for (i, job) in self.jobs.iter().enumerate() {
loop {
match job.rx.try_recv() {
Ok(GenMsg::Progress(note, permille)) => self.port.progress(&job.call_id, &note, permille),
Ok(GenMsg::Done(Ok(out))) => {
let path = out.path.to_string_lossy().to_string();
self.port.reply(
ToolResult::ok(
&job.call_id,
format!("saved {path} (made on {}). Show it on the wall with photos.add {{\"path\":\"{path}\"}}.", out.node),
"saved",
)
.with_data(format!("{{\"path\":{},\"node\":{}}}", json_string(&path), json_string(&out.node))),
);
done.push(i);
break;
}
Ok(GenMsg::Done(Err(e))) => {
let result = if e == "cancelled" { ToolResult::cancelled(&job.call_id) } else { ToolResult::failed(&job.call_id, e) };
self.port.reply(result);
done.push(i);
break;
}
Err(TryRecvError::Empty) => break,
Err(TryRecvError::Disconnected) => {
self.port.reply(ToolResult::failed(&job.call_id, "the generation worker died"));
done.push(i);
break;
}
}
}
}
for i in done.into_iter().rev() {
self.jobs.remove(i);
}
}
}
/// The arguments of one `image` call, checked.
#[derive(Clone, Debug, PartialEq)]
pub struct ImageArgs {
pub prompt: String,
pub width: u32,
pub height: u32,
}
/// Sizes are clamped to what the image nodes serve; a prompt is required.
pub fn parse_image_args(args: &str) -> Result<ImageArgs, String> {
use makepad_strict_json as json;
let fields = match json::parse(args.as_bytes()) {
Ok(json::Value::Obj(fields)) => fields,
_ => return Err("image needs a JSON object with a `prompt`".to_string()),
};
let get = |key: &str| fields.iter().find(|(k, _)| k == key).map(|(_, v)| v.clone());
let prompt = get("prompt").and_then(|v| v.as_str().map(|s| s.trim().to_string())).unwrap_or_default();
if prompt.is_empty() {
return Err("image needs a `prompt`".to_string());
}
if prompt.len() > 4000 {
return Err("the prompt is longer than 4000 bytes".to_string());
}
let dim = |key: &str| -> Result<u32, String> {
match get(key) {
None => Ok(1024),
Some(json::Value::Int(i)) if (256..=2048).contains(&i) => Ok(i as u32),
Some(_) => Err(format!("`{key}` must be an integer from 256 to 2048")),
}
};
Ok(ImageArgs { prompt, width: dim("width")?, height: dim("height")? })
}
/// A short file-name-safe slug of the prompt.
pub fn slug(prompt: &str) -> String {
let mut out = String::new();
for ch in prompt.chars().flat_map(|c| c.to_lowercase()) {
if ch.is_ascii_alphanumeric() {
out.push(ch);
} else if !out.ends_with('-') && !out.is_empty() {
out.push('-');
}
if out.len() >= 40 {
break;
}
}
let out = out.trim_matches('-').to_string();
if out.is_empty() { "picture".to_string() } else { out }
}
/// The file extension for a content type the nodes produce.
pub fn extension_for(content_type: &str) -> &'static str {
match content_type.split(';').next().unwrap_or("").trim() {
"image/jpeg" => "jpg",
"image/webp" => "webp",
"image/gif" => "gif",
_ => "png",
}
}
fn json_string(s: &str) -> String {
makepad_strict_json::s(s.to_string()).to_json()
}
#[cfg(feature = "engine")]
fn run_image(args: &ImageArgs, cancel: &Arc<AtomicBool>, progress: &mut dyn FnMut(&str, u16)) -> Result<GenDone, String> {
use makepad_ai_hub::generate::{generate, JobExpect};
use makepad_ai_hub::home::makepad_home;
use makepad_ai_hub::protocol::GenerateRequestJson;
use makepad_ai_hub::registry::Domain;
let seed = (Cx::time_now().max(0.0) * 1_000_000_000.0) as u64;
let wire = GenerateRequestJson {
prompt: Some(args.prompt.clone()),
width: Some(args.width),
height: Some(args.height),
seed: Some(seed),
..Default::default()
};
progress("finding an image node", 0);
let expect = JobExpect { label: "image.generate", artifact: Some(&["image/png"]) };
let generated = generate(Domain::Image, wire, &expect, &|| cancel.load(Ordering::Relaxed), progress, std::time::Duration::from_millis(500))
.map_err(|e| e.to_string())?;
let artifact = generated.artifact.ok_or("the node returned no picture")?;
let dir = makepad_home().join("gen");
std::fs::create_dir_all(&dir).map_err(|e| format!("cannot make {}: {e}", dir.display()))?;
let stamp = Cx::time_now().max(0.0) as u64;
let path = dir.join(format!("{stamp}-{}.{}", slug(&args.prompt), extension_for(&artifact.content_type)));
std::fs::write(&path, &artifact.bytes).map_err(|e| format!("cannot write {}: {e}", path.display()))?;
Ok(GenDone { path, node: generated.node })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_manifest_validates_and_the_tool_acts() {
let m = manifest();
m.validate().expect("a manifest the wire accepts");
assert_eq!(m.id, "gen");
assert_eq!(m.tools.len(), 1);
assert_eq!(m.tools[0].name, "image");
assert_eq!(m.tools[0].risk, Risk::Act);
}
#[test]
fn image_args_need_a_prompt_and_sane_sizes() {
let a = parse_image_args(r#"{"prompt":"a red bicycle on the moon"}"#).unwrap();
assert_eq!((a.width, a.height), (1024, 1024));
let b = parse_image_args(r#"{"prompt":"x","width":512,"height":768}"#).unwrap();
assert_eq!((b.width, b.height), (512, 768));
assert!(parse_image_args(r#"{"width":512}"#).unwrap_err().contains("prompt"));
assert!(parse_image_args(r#"{"prompt":"x","width":16}"#).unwrap_err().contains("256"));
assert!(parse_image_args("nope").is_err());
}
#[test]
fn file_names_are_slugs_with_the_right_extension() {
assert_eq!(slug("A red bicycle, on the Moon!"), "a-red-bicycle-on-the-moon");
assert_eq!(slug(" "), "picture");
assert!(slug(&"word ".repeat(30)).len() <= 41);
assert_eq!(extension_for("image/png"), "png");
assert_eq!(extension_for("image/jpeg; charset=binary"), "jpg");
assert_eq!(extension_for("application/octet-stream"), "png");
}
}

91
apps/aichat/src/lib.rs Normal file
View file

@ -0,0 +1,91 @@
//! aichat as a library: the panel widget that owns the engine, the WM-bus
//! client half, the settings, and the module root. A host links this,
//! calls [`script_mod`] after the widgets' own, and puts `AiChatPanel{}`
//! where the chat goes — the standalone binary and the WM pane child do
//! that themselves; a plain `Window` does it for free through its F10
//! slot, which instantiates `mod.widgets.AiChatOverlay{}` by name; the
//! superbuild seats the same overlay in its pane.
//!
//! Linking this crate also gives the bridge its `/ai` routes: `script_mod`
//! installs `Cx::ai_callback`, the way the widgets crate installs the
//! tweaker's, so `/ai?on=1`, `/ai?say=…` and `/ai/transcript` drive and
//! read the chat in a hidden instance.
pub use makepad_widgets;
use makepad_widgets::ai_slot::AiSlotRequests;
use makepad_widgets::makepad_platform::mcp_relay;
use makepad_widgets::makepad_platform::ScriptVmCx;
use makepad_widgets::*;
pub mod attach;
pub mod bus;
pub mod gen;
pub mod overlay;
pub mod panel;
pub mod settings;
pub use bus::ServiceBus;
pub use overlay::{AiChatOverlay, AiTranscript};
pub use panel::{AiChatPanel, AiChatPanelAction};
pub use settings::AiSettings;
/// Register the panel and the overlay, and give the bridge its `/ai`
/// routes. Call once after `makepad_widgets::script_mod`.
pub fn script_mod(vm: &mut ScriptVm) {
crate::panel::script_mod(vm);
crate::overlay::script_mod(vm);
vm.cx_mut().ai_callback = Some(ai_callback);
}
fn arg<'a>(args: &'a [(String, String)], keys: &[&str]) -> Option<&'a str> {
for key in keys {
if let Some((_, value)) = args.iter().find(|(k, _)| k == key) {
return Some(value.as_str());
}
}
None
}
/// The bridge's `/ai` dispatcher. `toggle` (`on=1|0`, or flip) and `say`
/// (`say=TEXT`, opening the overlay if it is closed) are requests the
/// slot and the overlay take on their next event; `transcript` is what
/// the overlay last published. Never borrows a widget.
fn ai_callback(cx: &mut Cx, op: &str, args: &[(String, String)]) -> Result<String, String> {
match op {
"toggle" => {
let is_open = cx.global::<AiSlotRequests>().is_open;
let on = match arg(args, &["on"]) {
Some(value) => !matches!(value, "0" | "false" | "off" | "no"),
None => !is_open,
};
cx.global::<AiSlotRequests>().open = Some(on);
// The slot takes the request on its next event: make one.
cx.new_next_frame();
cx.redraw_all();
Ok(format!("{{\"on\":{}}}", on as u8))
}
"say" => {
let text = arg(args, &["say", "t"]).unwrap_or("").trim().to_string();
if text.is_empty() {
return Err("need say=TEXT".into());
}
let req = cx.global::<AiSlotRequests>();
if !req.is_open {
req.open = Some(true);
}
req.say.push(text);
cx.new_next_frame();
cx.redraw_all();
Ok("{\"ok\":1}".into())
}
"transcript" => {
let json = cx.global::<AiTranscript>().json.clone();
if json.is_empty() {
Ok("{\"status\":\"closed\",\"provider\":\"\",\"apps\":[],\"entries\":[],\"generation\":0}".into())
} else {
Ok(json)
}
}
other => Err(format!("no ai op `{other}`; there are toggle, say, transcript")),
}
}

79
apps/aichat/src/main.rs Normal file
View file

@ -0,0 +1,79 @@
//! aichat: the assistant as its own window, and as the window manager's
//! special child. Both are this binary: standalone it is a window with
//! the panel in it and whatever services are linked in-process (none,
//! until an app embeds it); under the WM (`--stdin-loop`) the WM seats it
//! in the pane slot and every other app's service reaches it over the bus
//! as studio `Custom` frames, which the panel turns into registry links.
pub use makepad_widgets;
use makepad_aichat::AiChatPanelAction;
use makepad_widgets::*;
app_main!(App);
script_mod! {
use mod.prelude.widgets.*
use mod.widgets.*
startup() do #(App::script_component(vm)){
ui: Root{
main_window := Window{
window.inner_size: vec2(460, 760)
window.title: "AI"
pass +: { clear_color: theme.color_bg_app }
body +: {
panel := AiChatPanel{
width: Fill
height: Fill
}
}
}
}
}
}
#[derive(Script, ScriptHook)]
pub struct App {
#[live]
ui: WidgetRef,
}
impl MatchEvent for App {
fn handle_startup(&mut self, cx: &mut Cx) {
makepad_wm_api::set_title(cx, "AI");
}
fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions) {
for action in actions {
if let Some(widget_action) = action.as_widget_action() {
if let AiChatPanelAction::Close = widget_action.cast() {
// Under the WM the pane hides; standalone the window stays.
let _ = makepad_wm_api::send(cx, &makepad_wm_api::WmRequest::Close);
}
}
}
}
}
impl AppMain for App {
fn script_mod(vm: &mut ScriptVm) -> ScriptValue {
crate::makepad_widgets::script_mod(vm);
makepad_wm_theme::apply(vm);
makepad_aichat::script_mod(vm);
self::script_mod(vm)
}
fn handle_event(&mut self, cx: &mut Cx, event: &Event) {
// Bus frames reach the panel through its own handle_event; of the
// WM's own frames only the polite close is ours (the pane hides us
// by leaving us running; this is the desktop going down).
if let Event::Custom(json) = event {
if let Some(makepad_wm_api::WmEvent::CloseRequested) = makepad_wm_api::WmEvent::parse(json) {
cx.quit();
return;
}
}
self.match_event(cx, event);
self.ui.handle_event(cx, event, &mut Scope::empty());
}
}

261
apps/aichat/src/overlay.rs Normal file
View file

@ -0,0 +1,261 @@
//! The chat's module root: what a host seats in-process.
//!
//! `mod.widgets.AiChatOverlay{}` is what the Window's AI slot
//! (`widgets/src/ai_slot.rs`) instantiates by name on F10, and what the
//! superbuild seats in its pane. It is a `View` with the panel in it and
//! three duties around it: adopt the in-process service links the apps
//! parked on `Cx` ([`PendingServiceLinks`]) into the panel's registry,
//! send the lines the bridge asked to say ([`AiSlotRequests::say`]),
//! and publish the transcript as JSON ([`AiTranscript`]) after each draw
//! so `/ai/transcript` answers without touching a widget. The panel's
//! Escape (an idle, empty composer) asks the slot to close.
use crate::panel::{AiChatPanel, AiChatPanelAction};
use makepad_ai_services::port::PendingServiceLinks;
use makepad_ai_services::state::{Entry, EngineState, Status, ToolStatus};
use makepad_widgets::ai_slot::AiSlotRequests;
use makepad_widgets::makepad_micro_serde::*;
use makepad_widgets::*;
script_mod! {
use mod.prelude.widgets_internal.*
use mod.widgets.*
mod.widgets.AiChatOverlayBase = #(AiChatOverlay::register_widget(vm))
mod.widgets.AiChatOverlay = set_type_default() do mod.widgets.AiChatOverlayBase{
width: Fill
height: Fill
panel := AiChatPanel{
width: Fill
height: Fill
}
}
}
/// The transcript as the bridge reads it: a `Cx` global the overlay
/// rewrites after every draw of the panel.
#[derive(Default)]
pub struct AiTranscript {
pub json: String,
}
#[derive(SerJson)]
struct TranscriptRow {
kind: String,
text: String,
title: String,
status: String,
note: String,
}
#[derive(SerJson)]
struct Transcript {
status: String,
provider: String,
apps: Vec<String>,
entries: Vec<TranscriptRow>,
generation: u64,
}
/// The transcript JSON for a state: `status`, `provider`, the connected
/// apps, and one row per entry with what its card says.
pub fn transcript_json(state: &EngineState) -> String {
let status = match &state.status {
Status::Idle => "idle".to_string(),
Status::Loading { phase, fraction } => format!("loading {phase} {:.0}%", fraction * 100.0),
Status::Thinking => "thinking".to_string(),
Status::Streaming => "streaming".to_string(),
Status::WaitingForTool => "waiting_for_tool".to_string(),
Status::Error(e) => format!("error: {e}"),
};
let entries = state
.entries
.iter()
.map(|entry| match entry {
Entry::User { text } => TranscriptRow {
kind: "user".into(),
text: text.clone(),
title: String::new(),
status: String::new(),
note: String::new(),
},
Entry::Event(event) => TranscriptRow {
kind: "event".into(),
text: event.text.clone(),
title: format!("{} · {}", event.service_label, event.topic),
status: if event.final_ { "final".into() } else { "message".into() },
note: if event.dropped == 0 {
format!("sub_id: {}", event.sub_id)
} else {
format!("sub_id: {} · dropped: {}", event.sub_id, event.dropped)
},
},
Entry::Assistant { text, streaming } => TranscriptRow {
kind: "assistant".into(),
text: text.clone(),
title: String::new(),
status: if *streaming { "streaming".into() } else { "done".into() },
note: String::new(),
},
Entry::Tool(t) => {
let (status, note, text) = match &t.status {
ToolStatus::Confirm => ("confirm".to_string(), String::new(), String::new()),
ToolStatus::Running { note, permille } => ("running".to_string(), format!("{note} {permille}‰"), String::new()),
ToolStatus::Done { outcome, note, text } => (outcome.slug().to_string(), note.clone(), text.clone()),
};
TranscriptRow { kind: "tool".into(), text, title: t.title.clone(), status, note }
}
Entry::System { text } => TranscriptRow {
kind: "system".into(),
text: text.clone(),
title: String::new(),
status: String::new(),
note: String::new(),
},
})
.collect();
Transcript {
status,
provider: state.provider_label.clone(),
apps: state.services.iter().filter(|s| s.connected).map(|s| s.label.clone()).collect(),
entries,
generation: state.generation,
}
.serialize_json()
}
#[derive(Script, ScriptHook, Widget)]
pub struct AiChatOverlay {
#[source]
source: ScriptObjectRef,
#[deref]
view: View,
/// The state generation last published as JSON.
#[rust]
published: Option<u64>,
}
impl AiChatOverlay {
/// The transcript JSON, rewritten when the engine's state moved — on
/// every event as well as every draw, so a closed slot (no draws) still
/// answers `/ai/transcript` with the turn that finished behind it.
fn publish_transcript(&mut self, cx: &mut Cx) {
let json = self
.view
.widget(cx, ids!(panel))
.borrow::<AiChatPanel>()
.and_then(|panel| {
let state = panel.state()?;
if self.published == Some(state.generation) {
return None;
}
Some((state.generation, transcript_json(state)))
});
if let Some((generation, json)) = json {
self.published = Some(generation);
cx.global::<AiTranscript>().json = json;
}
}
/// The links the apps parked and the lines the bridge asked for, into
/// the panel.
fn adopt_requests(&mut self, cx: &mut Cx) {
let links = cx.global::<PendingServiceLinks>().take();
let says = std::mem::take(&mut cx.global::<AiSlotRequests>().say);
if links.is_empty() && says.is_empty() {
return;
}
let panel = self.view.widget(cx, ids!(panel));
let Some(mut panel) = panel.borrow_mut::<AiChatPanel>() else {
return;
};
for link in links {
let label = link.manifest.label.clone();
match panel.registry().register(link, "in this window", None) {
Ok(endpoint) => log!("aichat: {label} joined in-process as {}", endpoint.as_str()),
Err(e) => log!("aichat: {label} refused: {e}"),
}
}
for text in says {
panel.say(cx, text);
}
}
}
impl Widget for AiChatOverlay {
fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) {
self.adopt_requests(cx);
self.view.handle_event(cx, event, scope);
self.publish_transcript(cx);
if let Event::Actions(actions) = event {
for action in actions {
if let Some(widget_action) = action.as_widget_action() {
if let AiChatPanelAction::Close = widget_action.cast() {
cx.global::<AiSlotRequests>().open = Some(false);
cx.redraw_all();
}
}
}
}
}
fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep {
let step = self.view.draw_walk(cx, scope, walk);
if step.is_done() {
self.publish_transcript(cx);
}
step
}
}
#[cfg(test)]
mod tests {
use super::*;
use makepad_ai_services::state::{EventEntry, ServiceInfo, ToolEntry};
use makepad_ai_services::wire::ToolOutcome;
#[test]
fn the_transcript_reads_as_json_rows() {
let mut state = EngineState::default();
state.provider_label = "No model".into();
state.services.push(ServiceInfo {
id: "sheets".into(),
endpoint: "e1".into(),
label: "Sheets".into(),
parent: None,
location: "in this window".into(),
connected: true,
launchable: false,
tool_count: 1,
});
state.entries.push(Entry::User { text: "/sheets.summary {}".into() });
state.entries.push(Entry::Event(EventEntry {
sub_id: "s1".into(),
service_label: "Sheets".into(),
topic: "changes".into(),
text: "A1 changed".into(),
data: None,
dropped: 2,
final_: false,
}));
state.entries.push(Entry::Tool(ToolEntry {
call_id: "c1".into(),
service: "sheets".into(),
service_label: "Sheets".into(),
tool: "summary".into(),
title: "Sheets · summary".into(),
args: "{}".into(),
status: ToolStatus::Done { outcome: ToolOutcome::Ok, note: "3 × 4".into(), text: "Sheet1, 3 rows".into() },
preview: false,
expanded: false,
}));
state.generation = 7;
let json = transcript_json(&state);
assert!(json.contains(r#""status":"idle""#), "{json}");
assert!(json.contains(r#""apps":["Sheets"]"#), "{json}");
assert!(json.contains(r#""kind":"user""#) && json.contains(r#""kind":"tool""#), "{json}");
assert!(json.contains(r#""kind":"event""#) && json.contains("sub_id: s1") && json.contains("dropped: 2"), "{json}");
assert!(json.contains(r#""title":"Sheets · summary""#) && json.contains(r#""status":"ok""#), "{json}");
assert!(json.contains(r#""generation":7"#), "{json}");
}
}

963
apps/aichat/src/panel.rs Normal file
View file

@ -0,0 +1,963 @@
//! The chat panel: the one widget every host shows. It owns the engine —
//! the service registry, the model, the transcript — and draws
//! `EngineState` as a transcript of user lines, assistant text, tool cards
//! (running, done, waiting for a confirm), and system lines, over a
//! composer.
//!
//! Hosts talk to it in two ways: they hand it service links
//! (`registry()`) or bus frames (`on_custom`), and they listen for
//! [`AiChatPanelAction`]s. The engine runs on the panel's own events: every
//! event pumps it, and while a turn is in flight the panel asks for the
//! next frame so streaming, deadlines and the cloud provider's polling all
//! advance without a host timer.
use crate::attach::{self, Attachment, Source};
use crate::bus::ServiceBus;
use crate::gen::GenService;
use crate::settings::AiSettings;
#[cfg(feature = "engine")]
use makepad_ai_services::engine::models::{build_model, provider_rows};
use makepad_ai_services::engine::NoModelWithReason;
use makepad_ai_services::engine::{EngineCore, EngineEvent, ModelImage, ServiceRegistry};
use makepad_ai_services::mcp::host::{McpActivity, McpHost};
use makepad_ai_services::mcp::mcpb;
use makepad_ai_services::state::*;
use makepad_ai_services::wire::ToolOutcome;
use makepad_widgets::ai_slot::AiSlotRequests;
use makepad_widgets::makepad_platform::mcp_relay::{self, CLAUDE_DESKTOP};
use makepad_widgets::*;
script_mod! {
use mod.prelude.widgets_internal.*
use mod.widgets.*
mod.widgets.AiChatPanelBase = #(AiChatPanel::register_widget(vm))
let Line = Label{
width: Fill
height: Fit
draw_text +: {
color: theme.color_text
text_style: theme.font_regular{font_size: 9.5}
}
}
let Row = View{
width: Fill
height: Fit
flow: Down
padding: Inset{left: 14 right: 14 top: 4 bottom: 4}
}
mod.widgets.AiChatPanel = set_type_default() do mod.widgets.AiChatPanelBase{
width: Fill
height: Fill
flow: Down
draw_bg +: { color: theme.color_bg_app }
header := SolidView{
width: Fill
height: 36
flow: Right
spacing: 10
padding: Inset{left: 14 right: 8}
align: Align{y: 0.5}
draw_bg +: { color: theme.color_bg_container }
title := Label{
text: "AI"
draw_text +: {
color: theme.color_text
text_style: theme.font_bold{font_size: 10.5}
}
}
provider := Label{
width: Fill
text: ""
draw_text +: {
color: theme.color_text_meta
text_style: theme.font_regular{font_size: 8.5}
}
}
// Which model answers: the choices that exist on this machine.
provider_pick := DropDown{
width: 150
labels: ["Model"]
}
clear_button := ButtonFlatter{ text: "Clear" }
}
// Claude Desktop is the provider: this app's tools are served to it.
mcp_row := View{
visible: false
width: Fill
height: Fit
flow: Down
spacing: 4
padding: Inset{left: 14 right: 14 top: 8 bottom: 4}
mcp_connect := Button{ text: "Connect to Claude Desktop" }
mcp_status := Label{
width: Fill
text: ""
draw_text +: {
color: theme.color_text
text_style: theme.font_regular{font_size: 8.5}
}
}
mcp_hint := Label{
width: Fill
text: "Type in Claude Desktop; its calls show here."
draw_text +: {
color: theme.color_text_meta
text_style: theme.font_regular{font_size: 8.5}
}
}
}
apps_row := Label{
width: Fill
height: Fit
padding: Inset{left: 14 right: 14 top: 6 bottom: 2}
max_lines: 2
text: ""
draw_text +: {
color: theme.color_text_meta
text_style: theme.font_regular{font_size: 8.5}
}
}
transcript := PortalList{
width: Fill
height: Fill
auto_tail: true
UserRow := Row{
padding: Inset{left: 14 right: 14 top: 10 bottom: 4}
user_text := Line{
draw_text +: {
color: theme.color_text
text_style: theme.font_bold{font_size: 9.5}
}
}
}
EventRow := Row{
margin: Inset{left: 10 right: 10 top: 5 bottom: 5}
padding: Inset{left: 10 right: 10 top: 7 bottom: 7}
draw_bg +: { color: theme.color_bg_container }
event_title := Line{
draw_text +: {
color: theme.color_text_hl
text_style: theme.font_bold{font_size: 8.5}
}
}
event_text := Line{}
event_meta := Line{
draw_text +: {
color: theme.color_text_meta
text_style: theme.font_regular{font_size: 8.0}
}
}
}
AssistantRow := Row{
assistant_md := Markdown{
width: Fill
height: Fit
body: ""
}
}
StreamRow := Row{
stream_text := Line{}
}
ToolRow := Row{
padding: Inset{left: 22 right: 14 top: 3 bottom: 3}
tool_head := View{
width: Fill
height: Fit
cursor: MouseCursor.Hand
tool_title := Line{
draw_text +: {
color: theme.color_text_meta
text_style: theme.font_regular{font_size: 8.5}
}
}
}
tool_note := Line{
draw_text +: {
color: theme.color_text_meta
text_style: theme.font_regular{font_size: 8.5}
}
}
tool_bar := SolidView{
width: Fill
height: 2
margin: Inset{top: 3}
draw_bg +: { color: theme.color_text_hl }
}
tool_detail := Line{
visible: false
draw_text +: {
color: theme.color_text_meta
text_style: theme.font_regular{font_size: 8.0}
}
}
}
ConfirmRow := Row{
padding: Inset{left: 22 right: 14 top: 6 bottom: 6}
confirm_title := Line{
draw_text +: {
color: theme.color_text
text_style: theme.font_regular{font_size: 9.0}
}
}
confirm_buttons := View{
width: Fill
height: Fit
flow: Right
spacing: 8
margin: Inset{top: 4}
run_button := Button{ text: "Run" }
deny_button := ButtonFlat{ text: "Cancel" }
}
}
SystemRow := Row{
system_text := Line{
draw_text +: {
color: theme.color_text_hl
text_style: theme.font_regular{font_size: 8.5}
}
}
}
}
status := Label{
width: Fill
height: Fit
padding: Inset{left: 14 right: 14 top: 4 bottom: 2}
max_lines: 2
text: ""
draw_text +: {
color: theme.color_text_meta
text_style: theme.font_regular{font_size: 8.5}
}
}
// Pictures dropped on the panel, sent with the next line.
attach_row := View{
visible: false
width: Fill
height: Fit
flow: Right
spacing: 8
padding: Inset{left: 12 right: 12 top: 4 bottom: 0}
align: Align{y: 0.5}
attach_thumb := Image{width: 44 height: 32 fit: ImageFit.Smallest}
attach_label := Label{
width: Fill
text: ""
draw_text +: {
color: theme.color_text_meta
text_style: theme.font_regular{font_size: 8.5}
}
}
attach_clear := ButtonFlatter{ text: "Remove" }
}
composer := View{
width: Fill
height: Fit
flow: Right
spacing: 8
padding: Inset{left: 12 right: 12 top: 6 bottom: 10}
align: Align{y: 0.5}
input := TextInput{
width: Fill
height: Fit
empty_text: "Ask AI"
// The prompt is a hint, not text: a dark grey in every state,
// never the typed colour (the composer is always focused).
draw_text +: {
color_empty: #666666
color_empty_hover: #777777
color_empty_focus: #666666
}
}
send_button := Button{ text: "Send" }
}
}
}
/// What the panel tells its host.
#[derive(Clone, Debug, PartialEq, Default)]
pub enum AiChatPanelAction {
/// Esc with an empty composer and no turn in flight: the host may hide
/// the pane.
Close,
#[default]
None,
}
#[derive(Script, ScriptHook, Widget)]
pub struct AiChatPanel {
#[source]
source: ScriptObjectRef,
#[deref]
view: View,
/// The host's model for this panel, by provider slug (`claude-cli`,
/// `codex-cli`, `local`, …); empty keeps the person's saved choice.
/// `MAKEPAD_AI_PROVIDER` overrides both.
#[live]
provider: String,
/// Pictures waiting for the next line.
#[rust]
attachments: Vec<Attachment>,
/// Dropped pictures being decoded on the task pool.
#[rust]
loading: Vec<TaskHandle<Result<Attachment, String>>>,
/// The attachment chip shows this many pictures.
#[rust]
attach_shown: Option<(usize, usize)>,
/// The choices the provider menu lists, in its order.
#[rust]
provider_rows: Vec<ProviderRow>,
/// The provider menu shows these rows with this one picked.
#[rust]
pick_shown: Option<(usize, usize)>,
/// A line for the status row until the next send (a picture that
/// could not be read, a provider that is not installed).
#[rust]
notice: Option<String>,
#[rust]
engine: Option<EngineCore>,
#[rust]
registry: ServiceRegistry,
#[rust]
bus: ServiceBus,
/// The assistant's own `gen` service (pictures from the fleet), joined
/// to the registry with the engine.
#[rust]
gen: Option<GenService>,
#[rust]
settings: Option<AiSettings>,
/// This app's tools served to Claude Desktop, while it is the provider.
#[rust]
mcp: Option<McpHost>,
/// The connection as the panel last drew it.
#[rust]
mcp_shown: McpActivity,
#[rust]
drawn_generation: u64,
#[rust]
next_frame: NextFrame,
/// The composer took the keyboard once it existed on screen — a
/// focus set before the first draw lands on no area at all.
#[rust]
composer_focused: bool,
}
impl AiChatPanel {
/// The registry a host plugs in-process links into.
pub fn registry(&self) -> &ServiceRegistry {
&self.registry
}
/// A studio `Custom` frame that may be a bus frame from the WM.
pub fn on_custom(&mut self, json: &str) -> bool {
self.bus.on_custom(&self.registry, json)
}
pub fn state(&self) -> Option<&EngineState> {
self.engine.as_ref().map(|e| e.state())
}
fn settings(&mut self) -> &AiSettings {
if self.settings.is_none() {
self.settings = Some(AiSettings::load());
}
self.settings.as_ref().unwrap()
}
/// The engine comes up on first use — a local model is a long load
/// and no host pays it before the person opens the pane.
fn ensure_engine(&mut self) -> &mut EngineCore {
if self.engine.is_none() {
let lease_id = self.widget_uid().0;
let mut settings = self.settings().clone();
// A host (or the environment) naming its model picks it for
// this panel: that is the person's explicit choice for the app.
let host = std::env::var("MAKEPAD_AI_PROVIDER").ok().filter(|s| !s.trim().is_empty()).unwrap_or_else(|| self.provider.clone());
if !host.trim().is_empty() {
settings.provider = ProviderChoice::from_slug(&host);
settings.local_only = settings.provider.is_local() || settings.provider.slug() == "none";
}
// The real models ride the `engine` feature; a build without a
// runtime (the web page) says so and keeps the tool console.
#[cfg(feature = "engine")]
let (model, rows): (Box<dyn makepad_ai_services::Model>, Vec<ProviderRow>) = (
match build_model(&settings.provider, settings.local_only) {
Ok(m) => m,
Err(reason) => Box::new(NoModelWithReason::new(reason)),
},
provider_rows(false),
);
#[cfg(not(feature = "engine"))]
let (model, rows): (Box<dyn makepad_ai_services::Model>, Vec<ProviderRow>) =
(Box::new(NoModelWithReason::new("this build has no model runtime")), Vec::new());
let mut core = EngineCore::new(self.registry.clone(), model, None, lease_id);
// The state is the panel's window into the core; the core owns
// it, so provider facts go in through the core.
self.provider_rows = rows.clone();
core.set_provider_facts(settings.provider.clone(), rows, settings.local_only);
self.engine = Some(core);
self.gen = GenService::open(&self.registry);
}
self.engine.as_mut().unwrap()
}
/// Serve this app's tools to Claude Desktop exactly while it is the
/// provider: started on the switch to it, stopped (and the discovery
/// file removed) on the switch away.
fn apply_mcp(&mut self) {
let wanted = self.engine.as_ref().is_some_and(|e| e.state().provider.slug() == CLAUDE_DESKTOP);
if !wanted {
if self.mcp.take().is_some() {
// Its waiting clients are answered before the server stops.
if let Some(engine) = self.engine.as_mut() {
engine.cancel_external();
}
}
return;
}
if self.mcp.is_none() {
match McpHost::start(self.registry.clone(), &self.app_title()) {
Ok(host) => self.mcp = Some(host),
Err(e) => self.notice = Some(format!("the Claude Desktop connection could not start: {e}")),
}
}
}
/// The app as Claude Desktop names it: its own service's label (not
/// the assistant's built-in ones), else the executable's name.
fn app_title(&self) -> String {
self.registry
.services()
.into_iter()
.find(|s| s.connected && !Self::own_service(&s.id))
.map(|s| s.label)
.unwrap_or_else(mcp_relay::app_id)
}
fn own_service(id: &str) -> bool {
#[cfg(feature = "gen")]
if id == crate::gen::SERVICE_ID {
return true;
}
let _ = id;
false
}
/// Hand Claude Desktop's waiting calls to the engine; keep the title
/// in the discovery file current. True when the panel should redraw.
fn pump_mcp(&mut self, cx: &mut Cx, now: f64) -> bool {
if self.mcp.is_none() {
return false;
}
let title = self.app_title();
let Some(mcp) = self.mcp.as_mut() else { return false };
if let Err(e) = mcp.set_title(&title) {
error!("aichat: the Claude Desktop discovery file: {e}");
}
let calls = mcp.take_calls();
let activity = mcp.activity();
let mut changed = activity != self.mcp_shown;
for call in calls {
if let Some(EngineEvent::Confirm { .. }) = self.ensure_engine().call_external(&call.name, &call.args, call.reply, now) {
// The person confirms in the pane: bring it up.
cx.global::<AiSlotRequests>().open = Some(true);
cx.new_next_frame();
}
changed = true;
}
changed
}
/// Write this app's Claude Desktop extension and open it: Claude
/// Desktop shows its install dialog.
fn connect_claude_desktop(&mut self, cx: &mut Cx) {
let Some(mcp) = self.mcp.as_ref() else { return };
let written = std::env::current_exe()
.map_err(|e| format!("cannot find this app's executable: {e}"))
.and_then(|exe| mcpb::write(&mcp_relay::discovery_dir(), mcp.app_id(), &mcp.title(), &exe, &mcp.tools()))
.and_then(|path| mcpb::open_in_claude_desktop(&path).map(|_| path));
match written {
Ok(_) => self.note(cx, "Claude Desktop asks to install it: click Install, then type in Claude Desktop".into()),
Err(e) => self.note(cx, e),
}
}
fn mcp_status(activity: &McpActivity) -> String {
if activity.requests == 0 {
return "Waiting for Claude Desktop…".into();
}
let calls = match activity.calls {
0 => "no calls yet".to_string(),
1 => "1 call".to_string(),
n => format!("{n} calls"),
};
if activity.recent.is_empty() {
format!("Claude Desktop connected · {calls}")
} else {
format!("Claude Desktop connected · {calls} · last: {}", activity.recent.join(", "))
}
}
fn now(cx: &Cx) -> f64 {
cx.seconds_since_app_start()
}
/// A line as if typed and sent — the bridge's `/ai?say=`.
pub fn say(&mut self, cx: &mut Cx, text: String) {
self.send(cx, text);
}
/// Images the model sees with its next input — for a host's tool that
/// captured something while a tool round is in flight (its results
/// carry the pictures). `Err` when the model cannot see images.
pub fn attach_for_model(&mut self, images: Vec<ModelImage>) -> Result<(), String> {
self.ensure_engine().model_mut().attach_images(images)
}
/// A picture for the person's next line, as if dropped on the panel.
pub fn attach(&mut self, cx: &mut Cx, source: Source) {
match cx.task_pool().submit(Lane::Light, move || attach::load(source)) {
Ok(handle) => self.loading.push(handle),
Err(e) => self.note(cx, format!("could not read the picture ({e:?})")),
}
}
/// Switch the model; the conversation restarts.
pub fn set_provider(&mut self, cx: &mut Cx, slug: &str) {
let choice = ProviderChoice::from_slug(slug);
let _ = self.ensure_engine();
let _ = &choice;
#[cfg(feature = "engine")]
{
let model: Box<dyn makepad_ai_services::Model> = match build_model(&choice, false) {
Ok(m) => m,
Err(reason) => Box::new(NoModelWithReason::new(reason)),
};
let rows = self.provider_rows.clone();
let engine = self.engine.as_mut().unwrap();
engine.set_model(model);
engine.set_provider_facts(choice, rows, false);
}
self.apply_mcp();
self.view.redraw(cx);
}
fn note(&mut self, cx: &mut Cx, text: String) {
self.notice = Some(text);
self.view.redraw(cx);
}
fn poll_loading(&mut self, cx: &mut Cx) {
let mut i = 0;
while i < self.loading.len() {
match self.loading[i].try_take() {
Some(result) => {
self.loading.remove(i).detach();
match result {
Ok(Ok(picture)) => {
self.attachments.push(picture);
// At most four pictures ride with one line.
if self.attachments.len() > 4 {
self.attachments.remove(0);
}
}
Ok(Err(e)) => self.note(cx, e),
Err(e) => self.note(cx, format!("could not read the picture ({e:?})")),
}
self.view.redraw(cx);
}
None => i += 1,
}
}
}
fn handle_drop(&mut self, cx: &mut Cx, event: &Event) {
if !matches!(event, Event::Drag(_) | Event::Drop(_)) {
return;
}
match event.drag_hits(cx, self.view.area()) {
DragHit::Drag(drag) => {
let accept = drag.items.iter().any(|item| Source::from_drag_item(item).is_some());
if let Ok(mut response) = drag.response.try_lock() {
*response = if accept { DragResponse::Copy } else { DragResponse::None };
}
}
DragHit::Drop(drop) => {
let sources: Vec<Source> = drop.items.iter().filter_map(Source::from_drag_item).collect();
if sources.is_empty() {
self.note(cx, "drop a PNG, JPEG or WebP picture".into());
}
for source in sources.into_iter().take(4) {
self.attach(cx, source);
}
}
_ => {}
}
}
fn send(&mut self, cx: &mut Cx, text: String) {
let now = Self::now(cx);
self.notice = None;
// A pasted path to a picture is an attachment, not a line.
if let Some(source) = Source::from_pasted_text(&text) {
self.attach(cx, source);
self.view.text_input(cx, ids!(input)).set_text(cx, "");
return;
}
let mut text = text;
if !self.attachments.is_empty() && !text.trim().starts_with('/') {
let pictures = std::mem::take(&mut self.attachments);
let names: Vec<String> = pictures.iter().map(|p| p.name.clone()).collect();
let images = pictures.into_iter().map(|p| ModelImage { label: format!("reference: {}", p.name), png: p.png }).collect();
match self.ensure_engine().model_mut().attach_images(images) {
Ok(()) => {
if text.trim().is_empty() {
text = "Use the attached picture as the reference.".into();
}
text.push_str(&format!("\n[attached: {}]", names.join(", ")));
}
Err(e) => self.note(cx, format!("the pictures were not sent: {e}")),
}
self.attach_shown = None;
}
self.ensure_engine().send(&text, now);
let input = self.view.text_input(cx, ids!(input));
input.set_text(cx, "");
// The widget drops the keyboard on submit; a chat composer keeps
// it, so the next line can be typed straight away.
input.take_key_focus(cx);
self.view.redraw(cx);
}
fn apps_line(&self, state: &EngineState) -> String {
if state.services.is_empty() {
return "No apps connected.".into();
}
state
.services
.iter()
.map(|s| if s.connected { s.label.clone() } else { format!("{} (not running)", s.label) })
.collect::<Vec<_>>()
.join(" · ")
}
fn status_line(state: &EngineState) -> String {
let rate = state.rate.map(|r| format!(" · {r:.0} tok/s")).unwrap_or_default();
match &state.status {
Status::Idle => rate.trim_start_matches(" · ").to_string(),
Status::Loading { phase, fraction } => format!("loading {phase} {:.0}%", fraction * 100.0),
Status::Thinking => {
if state.thinking.is_empty() {
"thinking…".to_string()
} else {
let tail: String = state.thinking.chars().rev().take(120).collect::<Vec<_>>().into_iter().rev().collect();
format!("thinking… {tail}")
}
}
Status::Streaming => format!("writing{rate}"),
Status::WaitingForTool => "waiting for the app…".to_string(),
Status::Error(e) => e.clone(),
}
}
}
impl Drop for AiChatPanel {
fn drop(&mut self) {
// The engine only enqueues Unsubscribe frames. Flush them while the
// hosted bus and registry still exist; field destruction is too late.
if let Some(engine) = self.engine.as_mut() {
// Claude Desktop's waiting calls are answered before the MCP
// server (a field, dropped after this) stops.
engine.cancel_external();
engine.shutdown();
}
self.bus.relay_down(&self.registry);
}
}
impl Widget for AiChatPanel {
fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) {
if let Event::Custom(json) = event {
self.on_custom(json);
}
self.handle_drop(cx, event);
if !self.loading.is_empty() {
self.poll_loading(cx);
}
// Esc: stop a running turn; with nothing running and an empty
// composer, ask the host to hide the pane.
if let Event::KeyDown(ke) = event {
if ke.key_code == KeyCode::Escape {
let busy = self.engine.as_ref().map(|e| e.state().status.is_busy()).unwrap_or(false);
let empty = self.view.text_input(cx, ids!(input)).text().trim().is_empty();
if busy {
let now = Self::now(cx);
self.ensure_engine().cancel(now);
self.view.redraw(cx);
} else if empty {
cx.widget_action(self.widget_uid(), AiChatPanelAction::Close);
}
}
}
// The built-in services answer before the engine pumps, so a
// finished picture lands in this same event.
if let Some(gen) = self.gen.as_mut() {
gen.handle_event(cx, event);
}
// An app started for Claude Desktop runs its engine from the start,
// with the pane closed.
if self.engine.is_none() && cx.global::<AiSlotRequests>().engine_at_start {
self.ensure_engine();
}
// Drive the engine on every event; the bus relays what it sent.
let now = Self::now(cx);
let mut changed = self.pump_mcp(cx, now);
let mut busy = false;
if let Some(engine) = self.engine.as_mut() {
for ev in engine.pump(now) {
match ev {
EngineEvent::Changed => changed = true,
EngineEvent::Confirm { .. } => changed = true,
}
}
busy = engine.needs_pump()
|| engine.state().status.is_busy()
|| matches!(engine.state().status, Status::Loading { .. });
}
self.bus.relay_down(&self.registry);
if changed {
self.view.redraw(cx);
}
if busy {
self.next_frame = cx.new_next_frame();
}
self.view.handle_event(cx, event, scope);
self.widget_match_event(cx, event, scope);
}
fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep {
// The engine exists once the panel is on screen.
let _ = self.ensure_engine();
let state = self.engine.as_ref().unwrap().state().clone();
self.drawn_generation = state.generation;
self.view.label(cx, ids!(provider)).set_text(cx, &format!("{}{}", state.provider_label, if state.local_only { " · local only" } else { "" }));
self.view.label(cx, ids!(apps_row)).set_text(cx, &self.apps_line(&state));
let mcp_row = self.view.view(cx, ids!(mcp_row));
mcp_row.set_visible(cx, self.mcp.is_some());
if let Some(mcp) = &self.mcp {
self.mcp_shown = mcp.activity();
self.view.label(cx, ids!(mcp_status)).set_text(cx, &Self::mcp_status(&self.mcp_shown));
}
let status = match &self.notice {
Some(notice) => notice.clone(),
None => Self::status_line(&state),
};
self.view.label(cx, ids!(status)).set_text(cx, &status);
let shown = (self.attachments.len(), self.loading.len());
if self.attach_shown != Some(shown) {
self.attach_shown = Some(shown);
self.view.view(cx, ids!(attach_row)).set_visible(cx, shown.0 + shown.1 > 0);
let mut names: Vec<String> = self.attachments.iter().map(|a| format!("{} ({}×{})", a.name, a.width, a.height)).collect();
if !self.loading.is_empty() {
names.push("reading…".into());
}
self.view.label(cx, ids!(attach_label)).set_text(cx, &names.join(", "));
if let Some(thumb) = self.attachments.last().and_then(|a| a.thumb.clone()) {
let _ = self.view.image(cx, ids!(attach_thumb)).load_png_from_data(cx, &thumb);
}
}
if !self.provider_rows.is_empty() {
let labels: Vec<String> = self
.provider_rows
.iter()
.map(|r| match &r.unavailable {
Some(_) => format!("{} (unavailable)", r.label),
None => r.label.clone(),
})
.collect();
let at = self.provider_rows.iter().position(|r| r.choice == state.provider).unwrap_or(0);
if self.pick_shown != Some((labels.len(), at)) {
self.pick_shown = Some((labels.len(), at));
let pick = self.view.drop_down(cx, ids!(provider_pick));
pick.set_labels(cx, labels);
pick.set_selected_item(cx, at);
}
}
while let Some(item) = self.view.draw_walk(cx, scope, walk).step() {
let Some(mut list) = item.borrow_mut::<PortalList>() else { continue };
let total = state.entries.len();
list.set_item_range(cx, 0, total);
while let Some(index) = list.next_visible_item(cx) {
if index >= total {
continue;
}
let entry = &state.entries[index];
let row = match entry {
Entry::User { text } => {
let row = list.item(cx, index, id!(UserRow));
row.label(cx, ids!(user_text)).set_text(cx, text);
row
}
Entry::Event(event) => {
let row = list.item(cx, index, id!(EventRow));
row.label(cx, ids!(event_title))
.set_text(cx, &format!("→ {} · {}", event.service_label, event.topic));
row.label(cx, ids!(event_text)).set_text(cx, &event.text);
let mut meta = format!("sub_id: {}", event.sub_id);
if event.dropped != 0 {
meta.push_str(&format!(" · dropped: {}", event.dropped));
}
if event.final_ {
meta.push_str(" · final");
}
if let Some(data) = &event.data {
meta.push_str(&format!("\n{data}"));
}
row.label(cx, ids!(event_meta)).set_text(cx, &meta);
row
}
Entry::Assistant { text, streaming: false } => {
let row = list.item(cx, index, id!(AssistantRow));
// No text, no row: a blank block above a card is a gap.
row.set_visible(cx, !text.trim().is_empty());
if let Some(mut md) = row.widget(cx, ids!(assistant_md)).borrow_mut::<Markdown>() {
md.set_text(cx, text);
}
row
}
Entry::Assistant { text, streaming: true } => {
let row = list.item(cx, index, id!(StreamRow));
row.set_visible(cx, !text.trim().is_empty());
row.label(cx, ids!(stream_text)).set_text(cx, text);
row
}
Entry::Tool(t) if matches!(t.status, ToolStatus::Confirm) => {
let row = list.item(cx, index, id!(ConfirmRow));
row.label(cx, ids!(confirm_title)).set_text(cx, &format!("{} — this changes things outside the app. Run it?", t.title));
row
}
Entry::Tool(t) => {
let row = list.item(cx, index, id!(ToolRow));
let (glyph, note, permille, detail) = match &t.status {
ToolStatus::Running { note, permille } => ("›", note.clone(), *permille, String::new()),
ToolStatus::Done { outcome, note, text } => {
let glyph = if outcome.is_ok() { "✓" } else { "✗" };
let note = if note.is_empty() { outcome.slug().to_string() } else { note.clone() };
(glyph, note, 1000, text.clone())
}
ToolStatus::Confirm => ("?", String::new(), 0, String::new()),
};
row.label(cx, ids!(tool_title)).set_text(cx, &format!("{glyph} {}", t.title));
row.label(cx, ids!(tool_note)).set_text(cx, &note);
let bar_visible = matches!(t.status, ToolStatus::Running { .. });
row.view(cx, ids!(tool_bar)).set_visible(cx, bar_visible);
if bar_visible {
// The bar is a fraction of the row's inner width
// (the row's rect is last frame's; the first
// frame of a card draws it at a token width).
let inner = (row.area().rect(cx).size.x - 36.0).max(24.0);
let px = inner * (permille as f64 / 1000.0).max(0.05);
let mut bar = row.view(cx, ids!(tool_bar));
script_apply_eval!(cx, bar, { width: #(px) });
}
let detail_label = row.label(cx, ids!(tool_detail));
detail_label.set_visible(cx, t.expanded && !detail.is_empty());
if t.expanded {
detail_label.set_text(cx, &format!("{}\n{}", t.args, detail));
}
let _ = matches!(t.status, ToolStatus::Done { outcome: ToolOutcome::Ok, .. });
row
}
Entry::System { text } => {
let row = list.item(cx, index, id!(SystemRow));
row.label(cx, ids!(system_text)).set_text(cx, text);
row
}
};
row.draw_all(cx, &mut Scope::empty());
}
}
if !self.composer_focused {
self.composer_focused = true;
self.view.text_input(cx, ids!(input)).take_key_focus(cx);
}
DrawStep::done()
}
}
impl WidgetMatchEvent for AiChatPanel {
fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions, scope: &mut Scope) {
let input = self.view.text_input(cx, ids!(input));
let returned = input.returned(actions).map(|(text, _)| text);
if let Some(text) = returned {
self.send(cx, text);
} else if self.view.button(cx, ids!(send_button)).clicked(actions) {
let text = input.text();
self.send(cx, text);
}
if self.view.button(cx, ids!(attach_clear)).clicked(actions) {
self.attachments.clear();
self.attach_shown = None;
self.view.redraw(cx);
}
if let Some(at) = self.view.drop_down(cx, ids!(provider_pick)).changed(actions) {
if let Some(row) = self.provider_rows.get(at).cloned() {
if let Some(why) = &row.unavailable {
self.note(cx, format!("{}: {why}", row.label));
} else {
self.set_provider(cx, &row.choice.slug());
}
}
}
if self.view.button(cx, ids!(mcp_connect)).clicked(actions) {
self.connect_claude_desktop(cx);
}
if self.view.button(cx, ids!(clear_button)).clicked(actions) {
let now = Self::now(cx);
self.ensure_engine().clear(now);
self.view.redraw(cx);
}
// Cards: confirm buttons and click-to-expand.
let list = self.view.portal_list(cx, ids!(transcript));
let items = list.items_with_actions(actions);
if !items.is_empty() {
let now = Self::now(cx);
let call_ids: Vec<Option<String>> = {
let state = self.ensure_engine().state();
items
.iter()
.map(|(index, _)| match state.entries.get(*index) {
Some(Entry::Tool(t)) => Some(t.call_id.clone()),
_ => None,
})
.collect()
};
for ((_, item), call_id) in items.iter().zip(call_ids) {
let Some(call_id) = call_id else { continue };
if item.button(cx, ids!(run_button)).clicked(actions) {
self.ensure_engine().confirm(&call_id, true, now);
self.view.redraw(cx);
} else if item.button(cx, ids!(deny_button)).clicked(actions) {
self.ensure_engine().confirm(&call_id, false, now);
self.view.redraw(cx);
// A press taken away (the list scrolled under it) toggles nothing.
} else if item.view(cx, ids!(tool_head)).finger_up(actions).is_some_and(|e| !e.cancelled) {
self.ensure_engine().toggle_tool(&call_id);
self.view.redraw(cx);
}
}
}
let _ = scope;
}
}

101
apps/aichat/src/settings.rs Normal file
View file

@ -0,0 +1,101 @@
//! What the person chose: which model answers, and whether cloud models
//! are locked out. Two lines in `~/.makepad/aichat/settings`, the lock a
//! promise kept in code (a cloud choice under the lock normalises to
//! local at load and is refused at set), never a menu filter.
use makepad_ai_services::state::ProviderChoice;
use std::path::PathBuf;
#[derive(Clone, Debug, PartialEq)]
pub struct AiSettings {
pub provider: ProviderChoice,
/// Default ON: the person must switch it off before any cloud model
/// can be picked.
pub local_only: bool,
}
impl Default for AiSettings {
fn default() -> Self {
AiSettings { provider: ProviderChoice::Local, local_only: true }
}
}
impl AiSettings {
/// The lock, applied: a cloud provider under the lock becomes local.
/// "none" (no model) is always allowed — it reaches nothing.
pub fn normalized(mut self) -> Self {
if self.local_only {
if let ProviderChoice::Cloud(slug) = &self.provider {
if slug != "none" {
self.provider = ProviderChoice::Local;
}
}
}
self
}
/// Can this choice be made under the current lock?
pub fn allows(&self, choice: &ProviderChoice) -> Result<(), String> {
match choice {
ProviderChoice::Cloud(slug) if self.local_only && slug != "none" => Err("Local AI only is on".into()),
_ => Ok(()),
}
}
pub fn parse(text: &str) -> AiSettings {
let mut s = AiSettings::default();
for line in text.lines() {
let Some((k, v)) = line.split_once('=') else { continue };
match k.trim() {
"provider" => s.provider = ProviderChoice::from_slug(v.trim()),
"local_only" => s.local_only = v.trim() != "false",
_ => {}
}
}
s.normalized()
}
pub fn render(&self) -> String {
format!("provider={}\nlocal_only={}\n", self.provider.slug(), self.local_only)
}
pub fn path() -> Option<PathBuf> {
if cfg!(target_arch = "wasm32") {
return None;
}
let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"))?;
Some(PathBuf::from(home).join(".makepad").join("aichat").join("settings"))
}
pub fn load() -> AiSettings {
match Self::path().and_then(|p| std::fs::read_to_string(p).ok()) {
Some(text) => Self::parse(&text),
None => AiSettings::default(),
}
}
pub fn save(&self) -> Result<(), String> {
let Some(path) = Self::path() else { return Ok(()) };
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
}
std::fs::write(&path, self.render()).map_err(|e| e.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_lock_is_kept_at_load_and_at_set() {
let s = AiSettings::parse("provider=claude-api\nlocal_only=true\n");
assert_eq!(s.provider, ProviderChoice::Local, "a cloud pref under the lock cannot resurrect a cloud model");
assert!(s.allows(&ProviderChoice::Cloud("claude-api".into())).is_err());
assert!(s.allows(&ProviderChoice::Cloud("none".into())).is_ok());
let open = AiSettings::parse("provider=claude-api\nlocal_only=false\n");
assert_eq!(open.provider, ProviderChoice::Cloud("claude-api".into()));
assert_eq!(AiSettings::parse(&open.render()), open);
assert_eq!(AiSettings::parse("garbage"), AiSettings::default());
}
}

View file

@ -15,8 +15,8 @@ path = "src/main.rs"
[dependencies]
makepad-widgets = { path = "../../widgets", version = "2.0.0" }
makepad-game-math = { path = "../../libs/game/math" }
makepad-game-sim = { path = "../../libs/game/sim" }
makepad-game-math = { path = "../../libs/sim/math" }
makepad-game-sim = { path = "../../libs/sim" }
makepad-game-session = { path = "../../libs/game/session" }
makepad-game-blocks = { path = "../../libs/game/blocks" }
makepad-game-render = { path = "../../libs/game/render" }
@ -26,7 +26,9 @@ makepad-game-script = { path = "../../libs/game/script" }
makepad-game-net = { path = "../../libs/game/net" }
makepad-game-coedit = { path = "../../libs/game/coedit" }
makepad-game-pkg = { path = "../../libs/game/pkg" }
makepad-ai = { path = "../../libs/makepad_ai" }
# The improved AI layer replaced the old makepad-ai agent crate: chat and
# provider flows now come from makepad-ai-hub's headless ChatProvider model.
makepad-ai-hub = { path = "../../libs/ai/hub", default-features = false }
# default-features off drops speech synthesis (~10 MB of binary, ~327 MB
# resident). Re-enabled by `voice` below: a device that can capture speech is
# the one that has any use for producing it.

View file

@ -6,17 +6,15 @@
//! thing that writes code. Where there is no local compute, the text box IS
//! the gate and typing goes straight through.
use crate::capability::{Capabilities, Tier};
use crate::capability::Tier;
use crate::library::{classify_deterministic, Action, Librarian, Library, Manifest};
use crate::pairing::{has_key, load_key, Provider};
use makepad_ai::agent::{Agent, SessionConfig, StatelessBackendAdapter};
use makepad_ai::backend::BackendConfig;
use makepad_ai::backends::claude::ClaudeBackend;
use makepad_ai::backends::claude_code::ClaudeCodeAgent;
use makepad_ai::backends::gemini::GeminiBackend;
use makepad_ai::backends::openai::OpenAiBackend;
use makepad_converse::filter::{FilterDecision, PassthroughFilter, TranscriptFilter};
use makepad_converse::pipeline::ConversePipeline;
use makepad_ai_hub::chat_wire::{ChatMessage, ChatRole, ProviderAvailability, ProviderKind};
use makepad_ai_hub::providers::claude_api::ClaudeApiChatProvider;
use makepad_ai_hub::providers::provider::{ChatProvider, ProviderEvent, TurnInput};
use makepad_converse::filter::{FilterDecision, TranscriptFilter};
use std::sync::mpsc::{self, Receiver, Sender};
use std::time::Duration;
use makepad_widgets::*;
/// Tools the authoring session may use. Without this the CLI is launched with
/// `--tools ""` and the agent is chat-only: it answers, sounds correct, and
@ -31,17 +29,118 @@ pub const PERMISSION_POLICY: &str = r#"{"permissions":{
"allow":["Read","Glob","Grep","Edit(./**)","Write(./**)"],
"deny":["Bash","Edit(../**)","Write(../**)"]}}"#;
/// The session config for authoring one game in `dir`.
pub fn authoring_session(dir: Option<String>, api: &str, model: Option<String>) -> SessionConfig {
SessionConfig {
cwd: dir,
system_prompt: Some(system_prompt(api)),
model,
allowed_tools: AUTHORING_TOOLS.iter().map(|t| t.to_string()).collect(),
permission_mode: Some("dontAsk".to_string()),
settings_json: Some(PERMISSION_POLICY.to_string()),
..Default::default()
// --- AI worker ------------------------------------------------------------
// The improved makepad-ai-hub layer drives the agent headless on a worker
// thread (exactly the `example/cad` pattern): the UI sends a [`TurnInput]
// and drains [`AiWorkerEvent`]s back from `poll`. The CLI owns its own tool
// loop, so it is the natural single provider; direct-API providers remain
// available through the same seam via their `ProviderKind`.
enum AiWorkerCommand {
Send(TurnInput),
Cancel,
}
pub enum AiWorkerEvent {
Availability(ProviderAvailability),
Delta(String),
Done(String),
Error(String),
}
pub struct AiWorker {
command_tx: Sender<AiWorkerCommand>,
event_rx: Receiver<AiWorkerEvent>,
}
impl AiWorker {
pub fn new(cx: &mut Cx) -> Self {
let (command_tx, command_rx) = mpsc::channel();
let (event_tx, event_rx) = mpsc::channel();
cx.spawn_thread(move || ai_worker_loop(command_rx, event_tx));
Self {
command_tx,
event_rx,
}
}
pub fn send(&self, input: TurnInput) -> Result<(), String> {
self.command_tx
.send(AiWorkerCommand::Send(input))
.map_err(|_| "AI provider worker ended".to_string())
}
pub fn cancel(&self) {
let _ = self.command_tx.send(AiWorkerCommand::Cancel);
}
pub fn poll(&self) -> Vec<AiWorkerEvent> {
self.event_rx.try_iter().collect()
}
}
pub fn emit_ai_worker_event(event_tx: &Sender<AiWorkerEvent>, event: AiWorkerEvent) -> bool {
if event_tx.send(event).is_err() {
return false;
}
SignalToUI::set_ui_signal();
true
}
fn ai_worker_loop(command_rx: Receiver<AiWorkerCommand>, event_tx: Sender<AiWorkerEvent>) {
let mut provider = ClaudeApiChatProvider::from_env(ProviderKind::ClaudeCli, None);
if !emit_ai_worker_event(
&event_tx,
AiWorkerEvent::Availability(provider.availability()),
) {
return;
}
loop {
match command_rx.recv_timeout(Duration::from_millis(16)) {
Ok(AiWorkerCommand::Send(input)) => {
if let Err(error) = provider.begin_turn(&input) {
if !emit_ai_worker_event(&event_tx, AiWorkerEvent::Error(error)) {
return;
}
}
}
Ok(AiWorkerCommand::Cancel) => provider.cancel(),
Err(mpsc::RecvTimeoutError::Timeout) => {}
Err(mpsc::RecvTimeoutError::Disconnected) => {
provider.cancel();
return;
}
}
for event in provider.poll() {
let event = match event {
ProviderEvent::Delta(text) => Some(AiWorkerEvent::Delta(text)),
ProviderEvent::Done { text } => Some(AiWorkerEvent::Done(text)),
ProviderEvent::Error(error) => Some(AiWorkerEvent::Error(error)),
ProviderEvent::FunctionCall { .. } => Some(AiWorkerEvent::Error(
"AI provider requested an unsupported function".to_string(),
)),
ProviderEvent::Status { .. } | ProviderEvent::Serving(_) => None,
};
if let Some(event) = event {
if !emit_ai_worker_event(&event_tx, event) {
provider.cancel();
return;
}
}
}
}
}
/// Build the turn for one authoring request against one game. `api` is the
/// `game.*` verb table from [`makepad_game_script::api_text`]; the CLI runs in
/// the game directory so it can edit `game.splash` with its own tools.
pub fn build_turn(api: &str, request: &str) -> TurnInput {
TurnInput::new(
system_prompt(api),
vec![ChatMessage::new(ChatRole::User, request)],
)
}
/// A worked example is worth more than any amount of prose: this is the whole
@ -111,118 +210,6 @@ pub fn system_prompt(api: &str) -> String {
)
}
/// Which backend this device should use, and why — the reason is worth showing
/// in settings, because "no key" and "no CLI" need different fixes.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum BackendChoice {
Ready(Provider),
/// A direct-API provider is selected but has no key yet: this device can
/// still play and join, and in a hosted room it can still ASK the host's
/// agent — it just cannot create on its own.
NeedsKey(Provider),
/// Nothing configured at all.
None,
}
/// Prefer a CLI agent when one exists (it runs its own tool loop and needs no
/// key); otherwise fall back to whichever direct API has a key.
pub fn choose_backend(preferred: Option<Provider>) -> BackendChoice {
if let Some(p) = preferred {
if p == Provider::ClaudeCode && !ClaudeCodeAgent::is_available() {
// Asked for the CLI but it is not installed — say so rather than
// silently using something else.
return BackendChoice::None;
}
return if has_key(p) {
BackendChoice::Ready(p)
} else {
BackendChoice::NeedsKey(p)
};
}
if ClaudeCodeAgent::is_available() {
return BackendChoice::Ready(Provider::ClaudeCode);
}
for p in [Provider::Anthropic, Provider::OpenAi, Provider::Gemini] {
if has_key(p) {
return BackendChoice::Ready(p);
}
}
BackendChoice::None
}
/// Default models per provider: small edits should not cost flagship prices
/// (game.md: "default small edits to cheaper models").
pub fn default_model(provider: Provider) -> &'static str {
match provider {
Provider::ClaudeCode => "claude-sonnet-5",
Provider::Anthropic => "claude-sonnet-5",
Provider::OpenAi => "gpt-5",
Provider::Gemini => "gemini-2.5-flash",
}
}
pub fn build_agent(provider: Provider, model: Option<String>) -> Option<Box<dyn Agent>> {
let model = model.unwrap_or_else(|| default_model(provider).to_string());
Some(match provider {
// The CLI runs its own tool loop, so it is an Agent directly.
Provider::ClaudeCode => Box::new(ClaudeCodeAgent::new()) as Box<dyn Agent>,
// The HTTP backends are stateless; the adapter owns the session and
// executes tool calls on our side (the Quest/mobile path).
Provider::Anthropic => Box::new(StatelessBackendAdapter::new(Box::new(
ClaudeBackend::new(BackendConfig::Claude {
api_key: Some(load_key(provider)?),
oauth_token: None,
model,
}),
))) as Box<dyn Agent>,
Provider::OpenAi => Box::new(StatelessBackendAdapter::new(Box::new(
OpenAiBackend::new(BackendConfig::OpenAI {
api_key: load_key(provider)?,
model,
base_url: None,
reasoning_effort: None,
}),
))) as Box<dyn Agent>,
Provider::Gemini => Box::new(StatelessBackendAdapter::new(Box::new(
GeminiBackend::new(BackendConfig::Gemini {
api_key: load_key(provider)?,
model,
}),
))) as Box<dyn Agent>,
})
}
/// Build the conversational pipeline for this device's tier.
///
/// The filter is constructed ON the worker thread (a local LLM session is not
/// Send), which is why this takes capabilities rather than a built filter.
pub fn build_pipeline(
agent: Box<dyn Agent>,
caps: &Capabilities,
voice: &str,
) -> ConversePipeline {
let tier = caps.tier();
#[cfg(feature = "local-llm")]
let qwen = caps.qwen_model.clone();
let make_filter = move || -> Box<dyn TranscriptFilter> {
#[cfg(feature = "local-llm")]
{
if matches!(tier, Tier::Voice) {
if let Some(path) = qwen {
if let Some(f) = makepad_converse::qwen_filter::QwenFilter::new(&path) {
return Box::new(f);
}
}
}
}
// No judge available: every transcript passes. Safe because in this
// tier the mic is push-to-talk, so the human is the gate.
let _ = tier;
Box::new(PassthroughFilter)
};
ConversePipeline::new(agent, make_filter, voice)
}
/// Route one utterance. Voice goes through the pipeline's filter; typed text
/// bypasses it (typing IS the gate) — but BOTH go through the librarian first,
/// so "play the racing game" never costs a cloud call.
@ -268,29 +255,11 @@ mod tests {
use super::*;
#[test]
fn a_missing_key_is_reported_not_silently_swapped() {
let _guard = crate::pairing::tests::KEY_STORE_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
// Point the key store at an empty dir so nothing is configured.
let dir = std::env::temp_dir().join(format!("arcade-ai-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::env::set_var("ARCADE_CONFIG_DIR", &dir);
for var in ["ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GOOGLE_API_KEY"] {
std::env::remove_var(var);
}
assert_eq!(
choose_backend(Some(Provider::Anthropic)),
BackendChoice::NeedsKey(Provider::Anthropic)
);
std::env::set_var("ANTHROPIC_API_KEY", "sk-test");
assert_eq!(
choose_backend(Some(Provider::Anthropic)),
BackendChoice::Ready(Provider::Anthropic)
);
std::env::remove_var("ANTHROPIC_API_KEY");
std::fs::remove_dir_all(&dir).ok();
std::env::remove_var("ARCADE_CONFIG_DIR");
fn build_turn_carries_the_request_and_verb_system() {
let turn = build_turn(&makepad_game_script::api_text(), "add a ramp over the lake");
assert!(turn.system.contains("game.car"), "prompt must list the blocks");
assert_eq!(turn.messages.len(), 1);
assert!(turn.messages[0].text.contains("ramp"));
}
#[test]

View file

@ -488,6 +488,7 @@ fn spawn(
restitution: 0.0,
// 0.0 reads as 1.0 (see Entity::push_mass): normal shove resistance.
push_mass: 0.0,
..Default::default()
});
id
}
@ -2857,7 +2858,7 @@ impl ArcadeView {
let world = self.world.borrow();
let lookup = |id: u64| {
makepad_game_sim::entity_index_sorted(&world.entities, id)
.map(|i| world.entities[i].pos)
.map(|i| (world.entities[i].pos, world.entities[i].yaw))
};
self.particles.step(TICK_DT, &lookup);
}

View file

@ -21,9 +21,6 @@ pub mod xr_input;
use crate::authoring::{Applied, Authoring};
use crate::capability::{arcade_home, Capabilities};
use crate::chat::{ChatData, ChatRole};
// Named rather than glob-imported: `makepad_ai::*` and the widgets prelude
// both re-export a `makepad_widgets`, and the collision is a warning.
use makepad_ai::agent::{Agent, AgentEvent, PromptId, SessionId};
use makepad_widgets::*;
app_main!(App);
@ -144,11 +141,11 @@ pub struct App {
#[rust]
caps: Option<Capabilities>,
#[rust]
agent: Option<Box<dyn Agent>>,
ai_worker: Option<ai::AiWorker>,
#[rust]
session_id: Option<SessionId>,
backend_available: bool,
#[rust]
current_prompt: Option<PromptId>,
current_prompt: bool,
/// The intent log. Every edit — local agent or remote Claude — goes
/// through it; there is deliberately no faster path for the local one.
#[rust]
@ -188,7 +185,7 @@ impl App {
// it or a spoken sentence lands nowhere.
input.set_key_focus(cx);
let (Some(agent), Some(session_id)) = (&mut self.agent, self.session_id) else {
let Some(worker) = &self.ai_worker else {
ChatData::push(
ChatRole::System,
"No AI is connected. Open Settings to choose a provider and add a key.",
@ -196,18 +193,31 @@ impl App {
self.ui.redraw(cx);
return;
};
if self.current_prompt.is_some() {
if !self.backend_available {
ChatData::push(
ChatRole::System,
"The AI provider is not available. Open Settings to pair a key.",
);
self.ui.redraw(cx);
return;
}
if self.current_prompt {
// The newest instruction wins; the partial reply stays in the log.
if let Some(prompt) = self.current_prompt.take() {
agent.cancel_prompt(cx, prompt);
}
worker.cancel();
}
if let Some(authoring) = &mut self.authoring {
authoring.begin_turn();
}
let turn = ai::build_turn(&makepad_game_script::api_text(), &text.trim());
ChatData::begin_stream();
ChatData::set_activity("Thinking");
self.current_prompt = Some(agent.send_prompt(cx, session_id, &text));
if let Err(error) = worker.send(turn) {
ChatData::end_stream();
ChatData::push(ChatRole::System, format!("Error: {error}"));
self.ui.redraw(cx);
return;
}
self.current_prompt = true;
self.ui.widget(cx, ids!(stop_button)).set_visible(cx, true);
if let Some(speech) = self.speech.as_mut() {
@ -220,10 +230,13 @@ impl App {
if let Some(speech) = self.speech.as_mut() {
speech.stop();
}
let (Some(agent), Some(prompt)) = (&mut self.agent, self.current_prompt.take()) else {
if !self.current_prompt {
return;
};
agent.cancel_prompt(cx, prompt);
}
if let Some(worker) = &self.ai_worker {
worker.cancel();
}
self.current_prompt = false;
ChatData::end_stream();
self.ui.widget(cx, ids!(stop_button)).set_visible(cx, false);
self.set_status(cx, "Stopped.");
@ -287,18 +300,6 @@ impl App {
self.set_status(cx, &format!("{} {activity}", SPINNER[self.spinner_phase]));
cx.redraw_all();
}
/// Turn a tool call into something a child can understand.
fn describe_tool(tool_name: &str, subject: &str) -> Option<String> {
let file = subject.rsplit('/').next().unwrap_or(subject);
match tool_name {
"Edit" | "Write" => Some("Changing the game".to_string()),
"Read" => Some(format!("Looking at {file}")),
"Glob" | "Grep" => Some("Looking around".to_string()),
"Bash" => Some("Working on it".to_string()),
_ => None,
}
}
}
impl MatchEvent for App {
@ -396,38 +397,12 @@ impl MatchEvent for App {
}
}
// No explicit preference: prefer a CLI agent (no key needed), else
// whichever direct API has a key. Settings can override per device.
let backend = ai::choose_backend(None);
match &backend {
ai::BackendChoice::Ready(provider) => {
if let Some(mut agent) = ai::build_agent(*provider, None) {
let dir = self.game_path().parent().map(|p| p.to_path_buf());
if let Some(dir) = &dir {
let _ = std::fs::create_dir_all(dir);
}
let config = ai::authoring_session(
dir.map(|d| d.to_string_lossy().to_string()),
&makepad_game_script::api_text(),
None,
);
self.session_id = Some(agent.create_session(cx, config));
self.agent = Some(agent);
}
}
ai::BackendChoice::NeedsKey(p) => {
ChatData::push(
ChatRole::System,
format!("{p:?} needs an API key — open Settings to add one, or pair from another device."),
);
}
ai::BackendChoice::None => {
ChatData::push(
ChatRole::System,
"No AI backend found. Open Settings to choose a provider.",
);
}
}
// The AI worker probes the provider on its thread and reports
// availability back through `AiWorkerEvent::Availability`. The CLI
// needs no key; a direct-API provider needs one (Settings/pairing).
let worker = ai::AiWorker::new(cx);
self.ai_worker = Some(worker);
self.backend_available = false;
// The intent log starts from whatever game is on disk.
let path = self.game_path();
@ -518,57 +493,57 @@ impl AppMain for App {
self.next_frame = cx.new_next_frame();
}
let Some(agent) = &mut self.agent else { return };
for event in agent.handle_event(cx, event) {
let events = if let Some(worker) = &self.ai_worker {
worker.poll()
} else {
Vec::new()
};
for event in events {
match event {
AgentEvent::TextDelta { text, .. } => {
ai::AiWorkerEvent::Availability(
makepad_ai_hub::chat_wire::ProviderAvailability::Available { .. },
) => {
self.backend_available = true;
self.set_status(cx, "Ready.");
}
ai::AiWorkerEvent::Availability(
makepad_ai_hub::chat_wire::ProviderAvailability::Unavailable { reason },
) => {
self.backend_available = false;
self.current_prompt = false;
ChatData::push(
ChatRole::System,
format!("AI provider unavailable: {reason}"),
);
self.ui.widget(cx, ids!(stop_button)).set_visible(cx, false);
self.ui.redraw(cx);
}
ai::AiWorkerEvent::Delta(text) => {
ChatData::push_delta(&text);
if let Some(speech) = self.speech.as_mut() {
speech.feed(&text);
}
cx.redraw_all();
}
AgentEvent::ToolRequest {
tool_name,
tool_input,
..
} => {
if let Some(activity) = Self::describe_tool(&tool_name, &tool_input) {
ChatData::set_activity(&activity);
}
// An edit landed: propose it now so the world rebuilds
// while the agent keeps working.
if matches!(tool_name.as_str(), "Edit" | "Write") {
self.land_edit(cx, "edit");
}
}
AgentEvent::TurnComplete { .. } => {
ai::AiWorkerEvent::Done(_full_text) => {
ChatData::end_stream();
if let Some(speech) = self.speech.as_mut() {
speech.flush();
}
self.current_prompt = None;
self.current_prompt = false;
self.ui.widget(cx, ids!(stop_button)).set_visible(cx, false);
// Catch a final edit the tool stream did not announce.
self.land_edit(cx, "turn");
self.set_status(cx, "Ready.");
self.ui.text_input(cx, ids!(input)).set_key_focus(cx);
cx.redraw_all();
}
AgentEvent::SessionError { error, .. } => {
ChatData::push(ChatRole::System, format!("Session error: {error}"));
cx.redraw_all();
}
AgentEvent::PromptError { prompt_id, error } => {
if self.current_prompt == Some(prompt_id) {
ChatData::end_stream();
self.current_prompt = None;
self.ui.widget(cx, ids!(stop_button)).set_visible(cx, false);
}
ai::AiWorkerEvent::Error(error) => {
self.current_prompt = false;
ChatData::end_stream();
ChatData::push(ChatRole::System, format!("Error: {error}"));
cx.redraw_all();
self.ui.widget(cx, ids!(stop_button)).set_visible(cx, false);
self.ui.redraw(cx);
}
AgentEvent::SessionReady { .. } => {}
}
}
}

16100
apps/asset-ui/src/main.rs Normal file

File diff suppressed because it is too large Load diff

29
apps/browser/Cargo.toml Normal file
View file

@ -0,0 +1,29 @@
# browser — a Chrome-like browser as a plain full-window Makepad app.
#
# Chromium (CEF, libs/cef) renders ONLY the page, GPU-accelerated straight
# into an IOSurface-backed Makepad texture. All browser chrome — the tab
# strip, the toolbar with back/forward/reload, the omnibox and the menu —
# is Makepad splash UI, styled from the wm theme when hosted by
# makepad-wm (`MAKEPAD_WM_THEME_SPLASH`) and from a Chrome-dark palette otherwise.
#
# Runs standalone, and unmodified inside makepad-wm / Studio tiles via the
# shared --stdin-loop client runtime every Makepad app has.
[package]
name = "makepad-browser"
version = "0.1.0"
edition = "2021"
default-run = "browser"
[[bin]]
name = "browser"
path = "src/main.rs"
[dependencies]
makepad-widgets = { path = "../../widgets", features = ["cef"] }
makepad-cef = { path = "../../libs/cef" }
makepad-ai-services = { path = "../../libs/ai/services" }
makepad-strict-json = { path = "../../libs/strict_json" }
makepad-wm-theme = { path = "../../libs/wm_theme" }
# The window manager's vocabulary: the bar's title, the polite close.
makepad-wm-api = { path = "../../libs/wm_api" }

24
apps/browser/ci.splash Normal file
View file

@ -0,0 +1,24 @@
// CI smoke test for apps/browser. Run by tools/ci, which finds every `ci.splash`
// in the checkout. The script decides every input; the vision model only
// judges the grab against the acceptance text below. Basic on purpose: does
// it check everywhere, build and come up here, and look like what it is.
use mod.std.*
use mod.ci;
ci.step("check other platforms", fn(){
ci.check_targets({packages: ["makepad-browser"] targets: ci.host.targets})
})
let app = ci.launch({package: "makepad-browser" binary: "browser" cwd: "."})
ci.step("comes up", fn(){
ci.sleep(3.0)
app.no_errors()
let shot = app.grab("start")
ci.accept(shot, "This is a screenshot of one application window taken from a Mac. A thin bright red frame runs around the very edge of the picture; it is the test harness's marker and is not part of the application. Ignore it. The application is a web browser. It came up when these are true: 1. The picture is not blank: it is not one flat colour from edge to edge, and it is not entirely black or entirely white. 2. Some user interface is visible: at least two different things among text labels, buttons, icons, panels, lists, input fields, a toolbar, a drawing or a picture. 3. No error panel covers the window: there is no large block of text with words such as panic, error, failed, backtrace or unwrap. 4. A browser is visible: an address bar, which is a wide text field near the top, with a web page or an empty page area below it. Write one short bullet line for each numbered point saying what you see. YES means points 1, 2, 3 and 4 are all satisfied. Then write the verdict as the last line, in exactly this form: VERDICT: YES or VERDICT: NO")
})
app.no_errors()
app.quit()
nil

View file

@ -0,0 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#1b3354"/>
<stop offset="1" stop-color="#0b1627"/>
</linearGradient>
<linearGradient id="sheen" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#ffffff" stop-opacity="0.10"/>
<stop offset="0.5" stop-color="#ffffff" stop-opacity="0"/>
</linearGradient>
</defs>
<rect x="64" y="64" width="896" height="896" rx="200" fill="url(#bg)"/>
<rect x="64" y="64" width="896" height="896" rx="200" fill="url(#sheen)"/>
<rect x="66" y="66" width="892" height="892" rx="198" fill="none" stroke="#ffffff" stroke-opacity="0.08" stroke-width="4"/>
<circle cx="512" cy="512" r="270" fill="#7cc4ff" fill-opacity="0.16"/>
<g fill="none" stroke="#7cc4ff" stroke-width="38">
<ellipse cx="512" cy="512" rx="118" ry="270"/>
<path d="M242 512 H782 M279 377 H745 M279 647 H745"/>
</g>
<circle cx="512" cy="512" r="270" fill="none" stroke="#7cc4ff" stroke-width="48"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<svg width="24px" height="24px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M 15.60 4.40 L 17.00 5.80 L 10.80 12.00 L 17.00 18.20 L 15.60 19.60 L 8.00 12.00 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 231 B

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<svg width="24px" height="24px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M 6.00 7.40 L 7.40 6.00 L 12.00 10.60 L 16.60 6.00 L 18.00 7.40 L 13.40 12.00 L 18.00 16.60 L 16.60 18.00 L 12.00 13.40 L 7.40 18.00 L 6.00 16.60 L 10.60 12.00 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 310 B

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<svg width="24px" height="24px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M 8.40 4.40 L 7.00 5.80 L 13.20 12.00 L 7.00 18.20 L 8.40 19.60 L 16.00 12.00 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 228 B

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<svg width="24px" height="24px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M 20.00 12.00 L 19.73 14.07 L 18.93 16.00 L 17.66 17.66 L 16.00 18.93 L 14.07 19.73 L 12.00 20.00 L 9.93 19.73 L 8.00 18.93 L 6.34 17.66 L 5.07 16.00 L 4.27 14.07 L 4.00 12.00 L 4.27 9.93 L 5.07 8.00 L 6.34 6.34 L 8.00 5.07 L 9.93 4.27 L 12.00 4.00 L 14.07 4.27 L 16.00 5.07 L 17.66 6.34 L 18.93 8.00 L 19.73 9.93 L 20.00 12.00 L 18.40 12.00 L 18.18 10.34 L 17.54 8.80 L 16.53 7.47 L 15.20 6.46 L 13.66 5.82 L 12.00 5.60 L 10.34 5.82 L 8.80 6.46 L 7.47 7.47 L 6.46 8.80 L 5.82 10.34 L 5.60 12.00 L 5.82 13.66 L 6.46 15.20 L 7.47 16.53 L 8.80 17.54 L 10.34 18.18 L 12.00 18.40 L 13.66 18.18 L 15.20 17.54 L 16.53 16.53 L 17.54 15.20 L 18.18 13.66 L 18.40 12.00 Z"/>
<path d="M 11.20 4.40 L 12.80 4.40 L 12.80 19.60 L 11.20 19.60 Z"/>
<path d="M 4.40 11.20 L 19.60 11.20 L 19.60 12.80 L 4.40 12.80 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 950 B

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<svg width="24px" height="24px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M 10.80 4.60 L 13.20 4.60 L 13.20 7.00 L 10.80 7.00 Z"/>
<path d="M 10.80 10.80 L 13.20 10.80 L 13.20 13.20 L 10.80 13.20 Z"/>
<path d="M 10.80 17.00 L 13.20 17.00 L 13.20 19.40 L 10.80 19.40 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 346 B

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<svg width="24px" height="24px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M 11.00 5.00 L 13.00 5.00 L 13.00 11.00 L 19.00 11.00 L 19.00 13.00 L 13.00 13.00 L 13.00 19.00 L 11.00 19.00 L 11.00 13.00 L 5.00 13.00 L 5.00 11.00 L 11.00 11.00 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 314 B

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<svg width="24px" height="24px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M 15.75 5.50 L 17.07 6.47 L 18.14 7.70 L 18.93 9.13 L 19.39 10.70 L 19.49 12.33 L 19.24 13.94 L 18.65 15.46 L 17.75 16.82 L 16.57 17.95 L 15.17 18.80 L 13.62 19.32 L 12.00 19.50 L 10.38 19.32 L 8.83 18.80 L 7.43 17.95 L 6.25 16.82 L 5.35 15.46 L 4.76 13.94 L 4.51 12.33 L 4.61 10.70 L 5.07 9.13 L 5.86 7.70 L 6.93 6.47 L 8.25 5.50 L 9.25 7.24 L 8.28 7.94 L 7.49 8.85 L 6.92 9.90 L 6.58 11.04 L 6.51 12.24 L 6.69 13.42 L 7.12 14.54 L 7.79 15.54 L 8.65 16.36 L 9.68 16.98 L 10.81 17.37 L 12.00 17.50 L 13.19 17.37 L 14.32 16.98 L 15.35 16.36 L 16.21 15.54 L 16.88 14.54 L 17.31 13.42 L 17.49 12.24 L 17.42 11.04 L 17.08 9.90 L 16.51 8.85 L 15.72 7.94 L 14.75 7.24 Z"/>
<path d="M 18.45 5.17 L 14.25 2.77 L 14.65 8.77 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 867 B

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<svg width="24px" height="24px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M 16.00 10.00 L 15.80 11.55 L 15.20 13.00 L 14.24 14.24 L 13.00 15.20 L 11.55 15.80 L 10.00 16.00 L 8.45 15.80 L 7.00 15.20 L 5.76 14.24 L 4.80 13.00 L 4.20 11.55 L 4.00 10.00 L 4.20 8.45 L 4.80 7.00 L 5.76 5.76 L 7.00 4.80 L 8.45 4.20 L 10.00 4.00 L 11.55 4.20 L 13.00 4.80 L 14.24 5.76 L 15.20 7.00 L 15.80 8.45 L 16.00 10.00 L 14.00 10.00 L 13.86 8.96 L 13.46 8.00 L 12.83 7.17 L 12.00 6.54 L 11.04 6.14 L 10.00 6.00 L 8.96 6.14 L 8.00 6.54 L 7.17 7.17 L 6.54 8.00 L 6.14 8.96 L 6.00 10.00 L 6.14 11.04 L 6.54 12.00 L 7.17 12.83 L 8.00 13.46 L 8.96 13.86 L 10.00 14.00 L 11.04 13.86 L 12.00 13.46 L 12.83 12.83 L 13.46 12.00 L 13.86 11.04 L 14.00 10.00 Z"/>
<path d="M 14.20 13.00 L 15.60 14.40 L 20.60 19.40 L 19.20 20.80 L 14.20 15.80 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 892 B

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<svg width="24px" height="24px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M 12.00 4.60 L 14.00 9.85 L 19.61 10.13 L 15.23 13.65 L 16.70 19.07 L 12.00 16.00 L 7.30 19.07 L 8.77 13.65 L 4.39 10.13 L 10.00 9.85 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 284 B

220
apps/browser/src/ai.rs Normal file
View file

@ -0,0 +1,220 @@
//! The browser on the desktop's AI bus.
//!
//! The current CEF wrapper mirrors navigation metadata but does not bind its
//! frame text/source callbacks, so `page` reports the active title and URL.
use makepad_ai_services::wire::{Risk, ServiceCall, ServiceManifest, ToolDef, ToolResult};
use makepad_strict_json::{self as json, Value};
pub struct PageState {
pub title: String,
pub url: String,
}
pub struct TabState {
pub title: String,
pub url: String,
pub active: bool,
}
/// The bus-facing subset of the webview. Keeping it as a trait makes the
/// closed dispatcher testable without starting CEF or a window.
pub trait BrowserTarget {
fn page(&self) -> Option<PageState>;
fn tabs(&self) -> Vec<TabState>;
fn navigate(&mut self, url: &str) -> bool;
fn new_tab(&mut self, url: &str);
}
pub fn manifest() -> ServiceManifest {
ServiceManifest::new(
"browser",
"Browser",
"The live web browser. Its read tools report the active page and all tabs; its action tools steer the active tab or open a new one.",
)
.with_tool(ToolDef::new(
"page",
"Read the active tab's displayed title and URL. The current CEF binding does not expose visible page text or source, so this tool cannot return page text.",
r#"{"type":"object","properties":{},"additionalProperties":false}"#,
Risk::Read,
))
.with_tool(ToolDef::new(
"tabs",
"Read every open tab's displayed title and URL, with the active tab marked.",
r#"{"type":"object","properties":{},"additionalProperties":false}"#,
Risk::Read,
))
.with_tool(ToolDef::new(
"navigate",
"Navigate the active tab to an http:// or https:// URL, or to about:blank.",
r#"{"type":"object","properties":{"url":{"type":"string"}},"required":["url"],"additionalProperties":false}"#,
Risk::Act,
))
.with_tool(ToolDef::new(
"new_tab",
"Open and activate a new tab at an http:// or https:// URL, or at about:blank.",
r#"{"type":"object","properties":{"url":{"type":"string"}},"required":["url"],"additionalProperties":false}"#,
Risk::Act,
))
}
/// Answer one browser call through a closed match over the four advertised
/// names. URL actions use the webview's existing navigation methods.
pub fn answer(call: &ServiceCall, target: &mut impl BrowserTarget) -> ToolResult {
match call.tool.as_str() {
"page" => {
if let Err(error) = empty_args(&call.args) {
return ToolResult::refused(&call.call_id, error);
}
match target.page() {
Some(page) => ToolResult::ok(
&call.call_id,
format!("title: {}\nurl: {}", page.title, page.url),
format!("{} — {}", page.title, page.url),
),
None => ToolResult::unavailable(&call.call_id, "there is no active browser tab"),
}
}
"tabs" => {
if let Err(error) = empty_args(&call.args) {
return ToolResult::refused(&call.call_id, error);
}
let tabs = target.tabs();
let mut text = String::new();
for (index, tab) in tabs.iter().enumerate() {
if index > 0 {
text.push('\n');
}
text.push_str(if tab.active { "[active] " } else { " " });
text.push_str(&tab.title);
text.push_str(" — ");
text.push_str(&tab.url);
}
if text.is_empty() {
text.push_str("no tabs open");
}
ToolResult::ok(&call.call_id, text, format!("{} tabs", tabs.len()))
}
"navigate" => {
let url = match url_arg(&call.args, "navigate") {
Ok(url) => url,
Err(error) => return ToolResult::refused(&call.call_id, error),
};
if target.navigate(&url) {
ToolResult::ok(&call.call_id, format!("navigating to {url}"), "navigating")
} else {
ToolResult::unavailable(&call.call_id, "there is no active browser tab")
}
}
"new_tab" => {
let url = match url_arg(&call.args, "new_tab") {
Ok(url) => url,
Err(error) => return ToolResult::refused(&call.call_id, error),
};
target.new_tab(&url);
ToolResult::ok(&call.call_id, format!("opened a new tab at {url}"), "navigating")
}
other => ToolResult::refused(
&call.call_id,
format!("browser has no tool `{other}`; it has page, tabs, navigate, new_tab"),
),
}
}
fn empty_args(args: &str) -> Result<(), String> {
let fields = object_args(args)?;
if let Some((key, _)) = fields.first() {
return Err(format!("unknown argument `{key}`"));
}
Ok(())
}
fn url_arg(args: &str, tool: &str) -> Result<String, String> {
let fields = object_args(args)?;
if let Some((key, _)) = fields.iter().find(|(key, _)| key != "url") {
return Err(format!("unknown argument `{key}`"));
}
let Some(url) = fields
.iter()
.find(|(key, _)| key == "url")
.and_then(|(_, value)| value.as_str())
else {
return Err(format!("{tool}.url must be a string"));
};
if !allowed_url(url) {
return Err(format!(
"{tool}.url must start with http:// or https://, or be exactly about:blank"
));
}
Ok(url.to_string())
}
fn object_args(args: &str) -> Result<Vec<(String, Value)>, String> {
match json::parse(args.as_bytes()) {
Ok(Value::Obj(fields)) => Ok(fields),
Ok(_) => Err("tool arguments must be a JSON object".to_string()),
Err(error) => Err(format!("invalid tool arguments: {error}")),
}
}
fn allowed_url(url: &str) -> bool {
if url == "about:blank" {
return true;
}
if url.chars().any(char::is_whitespace) || url.chars().any(char::is_control) {
return false;
}
url.strip_prefix("http://")
.or_else(|| url.strip_prefix("https://"))
.is_some_and(|rest| !rest.is_empty())
}
#[cfg(test)]
mod tests {
use super::*;
use makepad_ai_services::wire::ToolOutcome;
#[derive(Default)]
struct FakeTarget {
navigated: Vec<String>,
}
impl BrowserTarget for FakeTarget {
fn page(&self) -> Option<PageState> {
None
}
fn tabs(&self) -> Vec<TabState> {
Vec::new()
}
fn navigate(&mut self, url: &str) -> bool {
self.navigated.push(url.to_string());
true
}
fn new_tab(&mut self, url: &str) {
self.navigated.push(url.to_string());
}
}
#[test]
fn browser_manifest_validates() {
manifest().validate().expect("a valid browser manifest");
}
#[test]
fn navigate_refuses_non_http_urls() {
let mut target = FakeTarget::default();
for url in ["file:///etc/passwd", "javascript:alert(1)", "data:text/plain,no"] {
let call = ServiceCall {
call_id: "c1".into(),
tool: "navigate".into(),
args: format!(r#"{{"url":"{url}"}}"#),
};
let result = answer(&call, &mut target);
assert_eq!(result.outcome, ToolOutcome::Refused);
}
assert!(target.navigated.is_empty());
}
}

512
apps/browser/src/chrome.rs Normal file
View file

@ -0,0 +1,512 @@
//! The browser chrome: a custom-drawn Chrome-style tab strip (favicon +
//! title tabs with a close x, a + for a new tab) and the toolbar
//! (back / forward / reload, the omnibox with search icon and bookmark star,
//! the menu button). Hard-square Omarchy look, colours from `mod.browser_theme`.
use crate::tabs::{TabId, TabSummary};
use makepad_widgets::image::DrawImage;
use makepad_widgets::*;
script_mod! {
use mod.prelude.widgets_internal.*
use mod.widgets.*
mod.widgets.TabStripBase = #(TabStrip::register_widget(vm))
mod.widgets.TabStrip = set_type_default() do mod.widgets.TabStripBase{
width: Fill
height: 36
draw_bg +: {
color: uniform(mod.browser_theme.darker_background)
pixel: fn() {
return self.color
}
}
draw_tab +: {
color: uniform(mod.browser_theme.darker_background)
pixel: fn() {
return self.color
}
}
draw_tab_hover +: {
color: uniform(mod.browser_theme.dark_background)
pixel: fn() {
return self.color
}
}
draw_tab_active +: {
color: uniform(mod.browser_theme.background)
pixel: fn() {
return self.color
}
}
draw_sep +: {
color: uniform(mod.browser_theme.muted)
pixel: fn() {
return self.color
}
}
draw_text +: {
color: mod.browser_theme.foreground
text_style: theme.font_regular{
font_size: 9.5
}
}
draw_text_dim +: {
color: mod.browser_theme.dark_foreground
text_style: theme.font_regular{
font_size: 9.5
}
}
draw_close +: {
svg: crate_resource("self:resources/icons/close.svg")
color: mod.browser_theme.dark_foreground
}
draw_close_active +: {
svg: crate_resource("self:resources/icons/close.svg")
color: mod.browser_theme.foreground
}
draw_plus +: {
svg: crate_resource("self:resources/icons/plus.svg")
color: mod.browser_theme.foreground
}
draw_globe +: {
svg: crate_resource("self:resources/icons/globe.svg")
color: mod.browser_theme.dark_foreground
}
}
// A square, flat icon button for the toolbar. A `let` so this block can
// instantiate it below (a `use mod.widgets.*` glob is a snapshot).
let MpToolButton = ButtonFlatterIcon{
width: 32
height: 32
padding: Inset{left: 0 right: 0 top: 0 bottom: 0}
margin: Inset{left: 0 right: 0 top: 0 bottom: 0}
align: Align{x: 0.5 y: 0.5}
icon_walk: Walk{width: 16 height: 16}
draw_icon +: {
color: mod.browser_theme.foreground
}
draw_bg +: {
border_radius: 0.0
border_size: 0.0
color: #00000000
color_hover: mod.browser_theme.lighter_background
color_down: mod.browser_theme.muted
color_focus: #00000000
}
}
mod.widgets.MpToolButton = MpToolButton
// A row of the ⋮ menu.
mod.widgets.MpMenuItem = ButtonFlatter{
width: Fill
height: 30
align: Align{x: 0.0 y: 0.5}
padding: Inset{left: 14 right: 14 top: 0 bottom: 0}
margin: Inset{left: 0 right: 0 top: 0 bottom: 0}
draw_text +: {
color: mod.browser_theme.foreground
color_hover: mod.browser_theme.bright_foreground
text_style: theme.font_regular{
font_size: 10
}
}
draw_bg +: {
border_radius: 0.0
border_size: 0.0
color: #00000000
color_hover: mod.browser_theme.lighter_background
color_down: mod.browser_theme.muted
}
}
// No `align y: 0.5` anywhere on the omnibox's ancestry: Makepad applies
// such alignment as a deferred shift that moves walked content (text)
// but not `draw_abs` quads — the TextInput's caret and selection would
// end up above the field and clipped. Heights and paddings centre
// everything explicitly instead.
mod.widgets.MpToolbar = SolidView{
width: Fill
height: 40
flow: Right
align: Align{x: 0.0 y: 0.0}
padding: Inset{left: 6 right: 6 top: 4 bottom: 4}
spacing: 2
draw_bg +: {
color: mod.browser_theme.background
}
back_btn := MpToolButton{
draw_icon.svg: crate_resource("self:resources/icons/back.svg")
}
forward_btn := MpToolButton{
draw_icon.svg: crate_resource("self:resources/icons/forward.svg")
}
reload_btn := MpToolButton{
draw_icon.svg: crate_resource("self:resources/icons/reload.svg")
}
View{width: 4 height: Fit}
// The omnibox: a darker square well with the search glyph, the
// text field and the bookmark star.
omnibox_frame := SolidView{
width: Fill
height: 32
flow: Right
align: Align{x: 0.0 y: 0.0}
padding: Inset{left: 10 right: 2 top: 0 bottom: 0}
spacing: 6
draw_bg +: {
color: mod.browser_theme.darker_background
}
Icon{
margin: Inset{top: 9 bottom: 0 left: 0 right: 0}
icon_walk: Walk{width: 14 height: 14}
draw_icon +: {
svg: crate_resource("self:resources/icons/search.svg")
color: mod.browser_theme.dark_foreground
}
}
omnibox := TextInputFlat{
width: Fill
height: 32
empty_text: "Search Google or type a URL"
// The line box is 16.8pt for this font size; text reads as
// centred by its x-height, not its full ink box, so 1pt
// less on top puts the x-height middle on the field's
// middle, level with the search glyph and the star.
padding: Inset{left: 4 right: 4 top: 6.6 bottom: 8.6}
margin: Inset{left: 0 right: 0 top: 0 bottom: 0}
draw_bg +: {
border_radius: 0.0
border_size: 0.0
color: #00000000
color_hover: #00000000
color_focus: #00000000
color_down: #00000000
color_empty: #00000000
border_color: #00000000
border_color_hover: #00000000
border_color_focus: #00000000
border_color_down: #00000000
border_color_empty: #00000000
}
draw_cursor +: {
color: mod.browser_theme.bright_foreground
}
draw_text +: {
color: mod.browser_theme.foreground
color_hover: mod.browser_theme.foreground
color_focus: mod.browser_theme.bright_foreground
color_empty: mod.browser_theme.dark_foreground
color_empty_hover: mod.browser_theme.dark_foreground
text_style: theme.font_regular{
font_size: 10.5
}
}
}
star_btn := MpToolButton{
width: 28
height: 28
margin: Inset{top: 2 bottom: 0 left: 0 right: 0}
icon_walk: Walk{width: 15 height: 15}
draw_icon +: {
svg: crate_resource("self:resources/icons/star.svg")
color: mod.browser_theme.dark_foreground
}
}
}
View{width: 4 height: Fit}
menu_btn := MpToolButton{
draw_icon.svg: crate_resource("self:resources/icons/menu.svg")
}
}
}
#[derive(Clone, Debug, Default, PartialEq)]
pub enum TabStripAction {
#[default]
None,
Activate(TabId),
Close(TabId),
New,
}
#[derive(Clone, Copy, Debug)]
struct TabHit {
id: TabId,
rect: Rect,
close: Rect,
}
#[derive(Script, ScriptHook, Widget)]
pub struct TabStrip {
#[uid]
uid: WidgetUid,
#[source]
source: ScriptObjectRef,
#[walk]
walk: Walk,
#[layout]
layout: Layout,
#[redraw]
#[live]
draw_bg: DrawQuad,
#[live]
draw_tab: DrawQuad,
#[live]
draw_tab_hover: DrawQuad,
#[live]
draw_tab_active: DrawQuad,
#[live]
draw_sep: DrawQuad,
#[live]
draw_text: DrawText,
#[live]
draw_text_dim: DrawText,
#[live]
draw_close: DrawSvg,
#[live]
draw_close_active: DrawSvg,
#[live]
draw_plus: DrawSvg,
#[live]
draw_globe: DrawSvg,
#[live]
draw_favicon: DrawImage,
#[rust]
tabs: Vec<TabSummary>,
#[rust]
hits: Vec<TabHit>,
#[rust]
plus_rect: Rect,
#[rust]
hover_tab: Option<TabId>,
#[rust]
hover_close: bool,
#[rust]
hover_plus: bool,
}
impl TabStrip {
const TAB_MAX_WIDTH: f64 = 240.0;
const TAB_MIN_WIDTH: f64 = 56.0;
const TOP_GAP: f64 = 6.0;
const LEFT_PAD: f64 = 8.0;
const PLUS_SIZE: f64 = 28.0;
pub fn set_tabs(&mut self, cx: &mut Cx, tabs: Vec<TabSummary>) {
self.tabs = tabs;
self.redraw(cx);
}
fn hit_at(&self, pos: Vec2d) -> (Option<TabId>, bool, bool) {
if self.plus_rect.contains(pos) {
return (None, false, true);
}
for hit in &self.hits {
if hit.rect.contains(pos) {
return (Some(hit.id), hit.close.contains(pos), false);
}
}
(None, false, false)
}
fn update_hover(&mut self, cx: &mut Cx, pos: Option<Vec2d>) {
let (tab, close, plus) = pos.map(|p| self.hit_at(p)).unwrap_or((None, false, false));
if tab != self.hover_tab || close != self.hover_close || plus != self.hover_plus {
self.hover_tab = tab;
self.hover_close = close;
self.hover_plus = plus;
self.redraw(cx);
}
}
}
impl Widget for TabStrip {
fn handle_event(&mut self, cx: &mut Cx, event: &Event, _scope: &mut Scope) {
match event.hits(cx, self.draw_bg.area()) {
Hit::FingerDown(fe) => {
let (tab, close, plus) = self.hit_at(fe.abs);
let middle = fe.mouse_button().map(|b| b.is_middle()).unwrap_or(false);
if plus {
cx.widget_action(self.uid, TabStripAction::New);
} else if let Some(id) = tab {
if close || middle {
cx.widget_action(self.uid, TabStripAction::Close(id));
} else {
cx.widget_action(self.uid, TabStripAction::Activate(id));
}
}
}
Hit::FingerHoverIn(fe) | Hit::FingerHoverOver(fe) => {
self.update_hover(cx, Some(fe.abs));
}
Hit::FingerMove(fe) => {
self.update_hover(cx, Some(fe.abs));
}
Hit::FingerHoverOut(_) => {
self.update_hover(cx, None);
}
_ => {}
}
}
fn draw_walk(&mut self, cx: &mut Cx2d, _scope: &mut Scope, walk: Walk) -> DrawStep {
self.draw_bg.begin(cx, walk, self.layout);
let strip = cx.turtle().rect();
self.hits.clear();
let count = self.tabs.len().max(1) as f64;
let available = (strip.size.x - Self::LEFT_PAD - Self::PLUS_SIZE - 12.0).max(0.0);
let tab_width = (available / count)
.min(Self::TAB_MAX_WIDTH)
.max(Self::TAB_MIN_WIDTH.min(available.max(1.0)));
let tab_height = strip.size.y - Self::TOP_GAP;
let mut x = strip.pos.x + Self::LEFT_PAD;
let y = strip.pos.y + Self::TOP_GAP;
let tabs = self.tabs.clone();
for (i, tab) in tabs.iter().enumerate() {
let rect = Rect {
pos: dvec2(x, y),
size: dvec2(tab_width, tab_height),
};
let hovered = self.hover_tab == Some(tab.id);
if tab.active {
self.draw_tab_active.draw_abs(cx, rect);
} else if hovered {
self.draw_tab_hover.draw_abs(cx, rect);
} else {
self.draw_tab.draw_abs(cx, rect);
// Separator between neighbouring inactive tabs.
let next_active = tabs.get(i + 1).map(|t| t.active).unwrap_or(true);
let next_hovered = tabs
.get(i + 1)
.map(|t| self.hover_tab == Some(t.id))
.unwrap_or(false);
if !next_active && !next_hovered {
self.draw_sep.draw_abs(
cx,
Rect {
pos: dvec2(x + tab_width - 0.5, y + 8.0),
size: dvec2(1.0, tab_height - 16.0),
},
);
}
}
let wide = tab_width >= 96.0;
let show_close = tab.active || hovered || wide;
let icon_size = 16.0;
let icon_y = y + (tab_height - icon_size) * 0.5;
let mut text_x = x + 10.0;
if tab_width >= 72.0 {
let icon_rect = Rect {
pos: dvec2(x + 10.0, icon_y),
size: dvec2(icon_size, icon_size),
};
match &tab.favicon {
Some(favicon) => {
self.draw_favicon.draw_vars.set_texture(0, favicon);
self.draw_favicon.draw_abs(cx, icon_rect);
}
None => {
self.draw_globe.draw_abs(cx, icon_rect);
}
}
text_x += icon_size + 8.0;
}
let close_size = 16.0;
let close_rect = Rect {
pos: dvec2(
x + tab_width - close_size - 8.0,
y + (tab_height - close_size) * 0.5,
),
size: dvec2(close_size, close_size),
};
let text_right = if show_close {
close_rect.pos.x - 6.0
} else {
x + tab_width - 8.0
};
let text_width = (text_right - text_x).max(0.0);
if text_width > 4.0 {
let title = if tab.loading && tab.title.is_empty() {
"Loading…".to_string()
} else {
tab.title.clone()
};
cx.begin_turtle(
Walk {
abs_pos: Some(dvec2(text_x, y)),
width: Size::Fixed(text_width),
height: Size::Fixed(tab_height),
..Walk::default()
},
Layout {
clip_x: true,
clip_y: true,
align: Align { x: 0.0, y: 0.5 },
..Layout::default()
},
);
if tab.active {
self.draw_text
.draw_walk(cx, Walk::fit(), Align::default(), &title);
} else {
self.draw_text_dim
.draw_walk(cx, Walk::fit(), Align::default(), &title);
}
cx.end_turtle();
}
if show_close {
let glyph = Rect {
pos: close_rect.pos + dvec2(3.0, 3.0),
size: dvec2(close_size - 6.0, close_size - 6.0),
};
if tab.active || (hovered && self.hover_close) {
self.draw_close_active.draw_abs(cx, glyph);
} else {
self.draw_close.draw_abs(cx, glyph);
}
}
self.hits.push(TabHit {
id: tab.id,
rect,
close: if show_close { close_rect } else { Rect::default() },
});
x += tab_width;
}
// The new-tab "+" square.
let plus_rect = Rect {
pos: dvec2(x + 4.0, y + (tab_height - Self::PLUS_SIZE) * 0.5),
size: dvec2(Self::PLUS_SIZE, Self::PLUS_SIZE),
};
if self.hover_plus {
self.draw_tab_hover.draw_abs(cx, plus_rect);
}
self.draw_plus.draw_abs(
cx,
Rect {
pos: plus_rect.pos + dvec2(7.0, 7.0),
size: dvec2(Self::PLUS_SIZE - 14.0, Self::PLUS_SIZE - 14.0),
},
);
self.plus_rect = plus_rect;
self.draw_bg.end(cx);
DrawStep::done()
}
}

749
apps/browser/src/main.rs Normal file
View file

@ -0,0 +1,749 @@
//! browser: a Chrome-like browser as a plain full-window Makepad app.
//! CEF renders the page (GPU-accelerated into a shared IOSurface texture);
//! every bit of chrome is Makepad. Runs standalone or inside makepad-wm /
//! Studio tiles via the shared --stdin-loop client runtime.
pub use makepad_widgets;
use makepad_ai_services::port::{AiServicePort, PortEvent};
use makepad_cef::BootstrapResult;
use makepad_widgets::*;
mod ai;
mod chrome;
mod tabs;
mod theme;
mod webview;
use chrome::{TabStrip, TabStripAction};
use tabs::TabId;
use std::cell::RefCell;
use std::rc::Rc;
use theme::Palette;
use webview::{WebView, WebViewAction};
script_mod! {
use mod.prelude.widgets.*
use mod.widgets.*
load_all_resources() do #(App::script_component(vm)){
ui: Root{
main_window := Window{
window.inner_size: vec2(1280, 860)
window.title: "browser"
body +: {
flow: Overlay
View{
width: Fill
height: Fill
flow: Down
tab_strip := TabStrip{}
toolbar := MpToolbar{}
webview := WebView{}
}
// The ⋮ menu: a square panel under the button.
menu_layer := View{
width: Fill
height: Fill
flow: Right
align: Align{x: 1.0 y: 0.0}
padding: Inset{top: 78 right: 6 left: 0 bottom: 0}
menu := SolidView{
visible: false
width: 240
height: Fit
flow: Down
padding: Inset{top: 4 bottom: 4 left: 0 right: 0}
draw_bg +: {
color: mod.browser_theme.background
}
menu_new_tab := MpMenuItem{text: "New tab"}
menu_close_tab := MpMenuItem{text: "Close tab"}
menu_reload := MpMenuItem{text: "Reload"}
Hr{}
menu_gpu := MpMenuItem{text: "chrome://gpu"}
menu_about := MpMenuItem{text: "About browser"}
}
}
}
}
}
}
}
thread_local! {
static PALETTE: RefCell<Option<Palette>> = const { RefCell::new(None) };
}
static START: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
/// Milliseconds since `main` started (startup measurements).
pub fn uptime_ms() -> u128 {
START.get_or_init(std::time::Instant::now).elapsed().as_millis()
}
/// Microseconds since `main` started (resize tracing).
pub fn uptime_us() -> u128 {
START.get_or_init(std::time::Instant::now).elapsed().as_micros()
}
fn palette() -> Palette {
PALETTE.with(|p| {
p.borrow_mut()
.get_or_insert_with(Palette::current)
.clone()
})
}
/// Cmd shortcuts the browser chrome owns (never forwarded to the page).
pub fn is_app_shortcut(key_event: &KeyEvent) -> bool {
if !key_event.modifiers.logo {
return false;
}
matches!(
key_event.key_code,
KeyCode::KeyT
| KeyCode::KeyW
| KeyCode::KeyL
| KeyCode::KeyR
| KeyCode::KeyN
| KeyCode::LBracket
| KeyCode::RBracket
| KeyCode::Key1
| KeyCode::Key2
| KeyCode::Key3
| KeyCode::Key4
| KeyCode::Key5
| KeyCode::Key6
| KeyCode::Key7
| KeyCode::Key8
| KeyCode::Key9
)
}
/// URLs given on the command line (everything that is not a flag).
fn initial_urls() -> Vec<String> {
let mut urls = Vec::new();
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
if arg == "--cwd" || arg == "--message-format" {
let _ = args.next();
continue;
}
if arg.starts_with("--") {
continue;
}
urls.push(tabs::resolve_omnibox(&arg));
}
urls
}
#[derive(Script, ScriptHook)]
pub struct App {
#[live]
ui: WidgetRef,
#[rust]
omnibox_focused: bool,
#[rust]
shown_url: String,
#[rust]
shown_tab: Option<TabId>,
#[rust]
menu_open: bool,
#[rust]
reported_mode: String,
#[rust]
focus_omnibox_pending: bool,
#[rust]
focus_frame: NextFrame,
#[rust]
focus_retries: u32,
#[rust]
ai_port: Option<AiServicePort>,
#[rust]
ai_context: String,
}
struct AiBrowserTarget<'a> {
cx: &'a mut Cx,
webview: &'a mut WebView,
}
impl ai::BrowserTarget for AiBrowserTarget<'_> {
fn page(&self) -> Option<ai::PageState> {
let info = self.webview.active_info();
info.id.map(|_| ai::PageState {
title: info.title,
url: info.url,
})
}
fn tabs(&self) -> Vec<ai::TabState> {
self.webview
.ai_tabs()
.into_iter()
.map(|(title, url, active)| ai::TabState { title, url, active })
.collect()
}
fn navigate(&mut self, url: &str) -> bool {
if self.webview.active_id().is_none() {
return false;
}
self.webview.navigate(self.cx, url);
true
}
fn new_tab(&mut self, url: &str) {
self.webview.new_tab(self.cx, url, true);
}
}
impl App {
fn with_webview<R>(&self, cx: &mut Cx, f: impl FnOnce(&mut Cx, &mut WebView) -> R) -> Option<R> {
let webview = self.ui.widget(cx, ids!(webview));
let mut inner = webview.borrow_mut::<WebView>()?;
Some(f(cx, &mut inner))
}
fn new_tab(&mut self, cx: &mut Cx, url: Option<String>) {
let url = url.unwrap_or_else(|| palette().new_tab_url());
let is_ntp = theme::is_new_tab_url(&url);
self.with_webview(cx, |cx, wv| {
wv.new_tab(cx, &url, true);
});
self.refresh_chrome(cx);
if is_ntp {
self.focus_omnibox(cx);
}
}
fn close_active_tab(&mut self, cx: &mut Cx) {
let closed_last = self
.with_webview(cx, |cx, wv| {
if let Some(id) = wv.active_id() {
wv.close_tab(cx, id);
}
wv.tab_count() == 0
})
.unwrap_or(false);
if closed_last {
self.new_tab(cx, None);
} else {
self.refresh_chrome(cx);
}
}
fn focus_omnibox(&mut self, cx: &mut Cx) {
let omnibox = self.ui.text_input(cx, ids!(omnibox));
omnibox.set_key_focus(cx);
omnibox.borrow_mut().map(|mut inner| inner.select_all(cx));
// A focus set while a mouse click is still being dispatched (the +
// button, a tab close) does not survive the rest of that click; the
// key-path (Cmd+L) proves the focus itself works. Re-assert it on the
// next frame.
self.focus_omnibox_pending = true;
self.focus_frame = cx.new_next_frame();
}
fn set_menu_open(&mut self, cx: &mut Cx, open: bool) {
if self.menu_open == open {
return;
}
self.menu_open = open;
self.ui.view(cx, ids!(menu)).set_visible(cx, open);
self.ui.redraw(cx);
}
/// Push the model into the chrome: tab strip, omnibox text (unless the
/// user is typing in it), nav button states, window title.
fn refresh_chrome(&mut self, cx: &mut Cx) {
let (summaries, info) = self
.with_webview(cx, |_cx, wv| (wv.summaries(), wv.active_info()))
.unwrap_or_default();
self.refresh_ai_context(&info);
if let Some(mut strip) = self.ui.widget(cx, ids!(tab_strip)).borrow_mut::<TabStrip>() {
strip.set_tabs(cx, summaries);
}
let shown = if theme::is_new_tab_url(&info.url) {
String::new()
} else {
info.url.clone()
};
// The omnibox follows the page unless the user is typing in it — but
// switching tabs always replaces what it shows.
let tab_changed = info.id != self.shown_tab;
self.shown_tab = info.id;
if shown != self.shown_url || tab_changed {
self.shown_url = shown.clone();
if !self.omnibox_focused || tab_changed {
self.ui.text_input(cx, ids!(omnibox)).set_text(cx, &shown);
}
}
let palette = palette();
let on = theme::parse_hex(&palette.foreground).unwrap_or_default();
let off = theme::parse_hex(&palette.muted).unwrap_or_default();
let mut back = self.ui.button(cx, ids!(back_btn));
let back_color = if info.can_go_back { on } else { off };
script_apply_eval!(cx, back, {
draw_icon +: {
color: #(back_color)
}
});
let mut forward = self.ui.button(cx, ids!(forward_btn));
let forward_color = if info.can_go_forward { on } else { off };
script_apply_eval!(cx, forward, {
draw_icon +: {
color: #(forward_color)
}
});
let title = if info.title.is_empty() {
"browser".to_string()
} else {
format!("{} — browser", info.title)
};
self.ui.window(cx, ids!(main_window)).set_title(cx, &title);
// The bar shows the page title too when the window manager hosts us.
makepad_wm_api::set_title(cx, &title);
if info.render_mode != self.reported_mode && info.render_mode != "None" {
self.reported_mode = info.render_mode.clone();
log!(
"browser: page rendering is {} (accelerated frames so far: {}, last blit {}us)",
info.render_mode,
info.accelerated_frames,
info.last_blit_micros
);
}
self.ui.redraw(cx);
}
fn refresh_ai_context(&mut self, info: &webview::ActiveInfo) {
let context = if info.id.is_some() {
format!("active tab: {} — {}", info.title, info.url)
} else {
"no active tab".to_string()
};
if context == self.ai_context {
return;
}
self.ai_context = context.clone();
if let Some(port) = self.ai_port.as_ref() {
port.set_context(&context);
}
}
fn refresh_ai_context_from_webview(&mut self, cx: &mut Cx) {
let info = self
.with_webview(cx, |_cx, webview| webview.active_info())
.unwrap_or_default();
self.refresh_ai_context(&info);
}
fn drain_ai_port(&mut self, cx: &mut Cx, event: &Event) {
let events = match self.ai_port.as_mut() {
Some(port) => port.handle_event(cx, event),
None => return,
};
for event in events {
match event {
PortEvent::Registered(endpoint) => {
log!("browser: AI service registered as {}", endpoint.as_str());
self.ai_context.clear();
self.refresh_ai_context_from_webview(cx);
}
PortEvent::Call(call) => {
let result = self
.with_webview(cx, |cx, webview| {
let mut target = AiBrowserTarget { cx, webview };
ai::answer(&call, &mut target)
})
.unwrap_or_else(|| {
makepad_ai_services::wire::ToolResult::unavailable(
&call.call_id,
"the browser view is not ready",
)
});
if let Some(port) = self.ai_port.as_ref() {
port.reply(result);
}
self.refresh_chrome(cx);
}
PortEvent::Cancel { .. } | PortEvent::ChatOpen { .. } => {}
PortEvent::Subscribe { .. } | PortEvent::Unsubscribe { .. } => {}
}
}
}
fn navigate_from_omnibox(&mut self, cx: &mut Cx, text: &str) {
let url = tabs::resolve_omnibox(text);
if url.is_empty() {
return;
}
self.with_webview(cx, |cx, wv| {
if wv.tab_count() == 0 {
wv.new_tab(cx, &url, true);
} else {
wv.navigate(cx, &url);
}
});
self.refresh_chrome(cx);
}
fn handle_shortcut(&mut self, cx: &mut Cx, ke: &KeyEvent) -> bool {
// Ctrl+Tab / Ctrl+Shift+Tab cycle tabs (Chrome), as do Cmd+Shift+] / [.
if ke.modifiers.control && ke.key_code == KeyCode::Tab {
let delta = if ke.modifiers.shift { -1 } else { 1 };
self.with_webview(cx, |cx, wv| wv.activate_offset(cx, delta));
self.refresh_chrome(cx);
return true;
}
if !ke.modifiers.logo {
return false;
}
if ke.modifiers.shift
&& matches!(ke.key_code, KeyCode::LBracket | KeyCode::RBracket)
{
let delta = if ke.key_code == KeyCode::LBracket { -1 } else { 1 };
self.with_webview(cx, |cx, wv| wv.activate_offset(cx, delta));
self.refresh_chrome(cx);
return true;
}
match ke.key_code {
KeyCode::KeyT | KeyCode::KeyN => self.new_tab(cx, None),
KeyCode::KeyW => self.close_active_tab(cx),
KeyCode::KeyL => self.focus_omnibox(cx),
KeyCode::KeyR => {
self.with_webview(cx, |cx, wv| wv.reload(cx));
}
KeyCode::LBracket => {
self.with_webview(cx, |cx, wv| wv.go_back(cx));
}
KeyCode::RBracket => {
self.with_webview(cx, |cx, wv| wv.go_forward(cx));
}
KeyCode::Key1
| KeyCode::Key2
| KeyCode::Key3
| KeyCode::Key4
| KeyCode::Key5
| KeyCode::Key6
| KeyCode::Key7
| KeyCode::Key8 => {
let index = match ke.key_code {
KeyCode::Key1 => 0,
KeyCode::Key2 => 1,
KeyCode::Key3 => 2,
KeyCode::Key4 => 3,
KeyCode::Key5 => 4,
KeyCode::Key6 => 5,
KeyCode::Key7 => 6,
_ => 7,
};
self.with_webview(cx, |cx, wv| wv.activate_index(cx, index));
self.refresh_chrome(cx);
}
KeyCode::Key9 => {
self.with_webview(cx, |cx, wv| {
let last = wv.tab_count().saturating_sub(1);
wv.activate_index(cx, last);
});
self.refresh_chrome(cx);
}
_ => return false,
}
true
}
}
impl MatchEvent for App {
fn handle_startup(&mut self, cx: &mut Cx) {
// A warm-pool standby is not a running Browser: its service opens on
// `WmEvent::Adopted`, never while dormant — the assistant must not
// steer a page nobody can see.
if !makepad_wm_api::warm_start() {
self.ai_port = AiServicePort::open(cx, ai::manifest());
}
match makepad_cef::startup_phases() {
Some((bundle_ms, exec_gap_ms)) => log!(
"browser: window up at {} ms after main (app bundle prepared in {} ms, exec-to-main gap {} ms)",
uptime_ms(),
bundle_ms,
exec_gap_ms
),
None => log!("browser: window up at {} ms after main", uptime_ms()),
}
let urls = initial_urls();
if urls.is_empty() {
// Never boot empty: the first tab opens the web (Cmd+T tabs
// still get the themed New Tab page).
self.new_tab(cx, Some("https://www.google.com/".to_string()));
} else {
for url in urls {
self.new_tab(cx, Some(url));
}
}
}
fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions) {
// Tab strip.
for action in actions {
let Some(action) = action.as_widget_action() else {
continue;
};
match action.cast::<TabStripAction>() {
TabStripAction::Activate(id) => {
self.with_webview(cx, |cx, wv| wv.activate(cx, id));
self.refresh_chrome(cx);
}
TabStripAction::Close(id) => {
let closed_last = self
.with_webview(cx, |cx, wv| {
wv.close_tab(cx, id);
wv.tab_count() == 0
})
.unwrap_or(false);
if closed_last {
self.new_tab(cx, None);
} else {
self.refresh_chrome(cx);
}
}
TabStripAction::New => self.new_tab(cx, None),
TabStripAction::None => {}
}
if let WebViewAction::TabsChanged = action.cast::<WebViewAction>() {
self.refresh_chrome(cx);
}
}
// Toolbar.
if self.ui.button(cx, ids!(back_btn)).clicked(actions) {
self.with_webview(cx, |cx, wv| wv.go_back(cx));
}
if self.ui.button(cx, ids!(forward_btn)).clicked(actions) {
self.with_webview(cx, |cx, wv| wv.go_forward(cx));
}
if self.ui.button(cx, ids!(reload_btn)).clicked(actions) {
// Chrome semantics: the button stops a page that is still loading.
self.with_webview(cx, |cx, wv| {
if wv.active_info().loading {
wv.stop(cx);
} else {
wv.reload(cx);
}
});
}
if self.ui.button(cx, ids!(star_btn)).clicked(actions) {
// Bookmarks are not wired yet; the star just re-focuses the page.
self.with_webview(cx, |cx, wv| wv.focus(cx));
}
if self.ui.button(cx, ids!(menu_btn)).clicked(actions) {
let open = !self.menu_open;
self.set_menu_open(cx, open);
}
// Menu.
if self.ui.button(cx, ids!(menu_new_tab)).clicked(actions) {
self.set_menu_open(cx, false);
self.new_tab(cx, None);
}
if self.ui.button(cx, ids!(menu_close_tab)).clicked(actions) {
self.set_menu_open(cx, false);
self.close_active_tab(cx);
}
if self.ui.button(cx, ids!(menu_reload)).clicked(actions) {
self.set_menu_open(cx, false);
self.with_webview(cx, |cx, wv| wv.reload(cx));
}
if self.ui.button(cx, ids!(menu_gpu)).clicked(actions) {
self.set_menu_open(cx, false);
self.new_tab(cx, Some("chrome://gpu".to_string()));
}
if self.ui.button(cx, ids!(menu_about)).clicked(actions) {
self.set_menu_open(cx, false);
let info = self
.with_webview(cx, |_cx, wv| wv.active_info())
.unwrap_or_default();
let html = format!(
"<!doctype html><title>About browser</title><body style=\"background:{bg};color:{fg};font:14px -apple-system,Helvetica,sans-serif;padding:32px\">\
<h2 style=\"font-weight:500\">browser</h2>\
<p>Makepad chrome, Chromium Embedded Framework {cef} page rendering.</p>\
<p>Page rendering path: <b>{mode}</b> (accelerated frames: {frames}, last GPU blit: {blit}µs)</p>\
<p>ANGLE backend: {angle}</p></body>",
bg = palette().darker_background,
fg = palette().foreground,
cef = makepad_cef::CEF_VERSION,
mode = info.render_mode,
frames = info.accelerated_frames,
blit = info.last_blit_micros,
angle = std::env::var("MAKEPAD_CEF_USE_ANGLE").unwrap_or_else(|_| "default".into()),
);
let url = format!("data:text/html;charset=utf-8,{}", theme::percent_encode(&html));
self.new_tab(cx, Some(url));
}
// Omnibox.
let omnibox = self.ui.text_input(cx, ids!(omnibox));
if let Some((text, _modifiers)) = omnibox.returned(actions) {
self.navigate_from_omnibox(cx, &text);
}
if omnibox.escaped(actions) {
let shown = self.shown_url.clone();
omnibox.set_text(cx, &shown);
self.with_webview(cx, |cx, wv| wv.focus(cx));
}
for action in actions {
let Some(widget_action) = action.as_widget_action() else {
continue;
};
if widget_action.widget_uid != omnibox.widget_uid() {
continue;
}
match widget_action.cast::<TextInputAction>() {
TextInputAction::KeyFocus => {
self.omnibox_focused = true;
if let Some(mut inner) = omnibox.borrow_mut() {
inner.select_all(cx);
}
}
TextInputAction::KeyFocusLost => {
if std::env::var_os("MAKEPAD_CEF_DEBUG").is_some() {
let now = cx.keyboard.key_focus();
let webview = self.ui.widget(cx, ids!(webview)).area();
log!(
"omnibox lost key focus to {}",
if now == webview { "the webview" } else if now == Area::Empty { "nothing (Area::Empty)" } else { "another widget" }
);
}
self.omnibox_focused = false;
let shown = self.shown_url.clone();
omnibox.set_text(cx, &shown);
}
_ => {}
}
}
}
}
impl AppMain for App {
fn script_mod(vm: &mut ScriptVm) -> ScriptValue {
crate::makepad_widgets::script_mod(vm);
// The family theme bridge retints the stock widgets from the WM
// theme; the chrome roles go into mod.browser_theme.
makepad_wm_theme::apply(vm);
palette().apply(vm);
chrome::script_mod(vm);
webview::script_mod(vm);
self::script_mod(vm)
}
fn handle_event(&mut self, cx: &mut Cx, event: &Event) {
// The window manager asked politely (SUPER+W): go now, ahead of the
// kill that follows its grace. A warm-pool browser needs no waking —
// CEF paints nothing until a tile presents it.
if let Event::Custom(json) = event {
match makepad_wm_api::WmEvent::parse(json) {
Some(makepad_wm_api::WmEvent::Adopted) if self.ai_port.is_none() => {
self.ai_port = AiServicePort::open(cx, ai::manifest());
}
_ => {}
}
if let Some(makepad_wm_api::WmEvent::CloseRequested) = makepad_wm_api::WmEvent::parse(json) {
cx.quit();
return;
}
}
self.drain_ai_port(cx, event);
if let Event::KeyDown(ke) = event {
if self.handle_shortcut(cx, ke) {
return;
}
}
if self.focus_omnibox_pending && self.focus_frame.is_event(event).is_some() {
let omnibox = self.ui.text_input(cx, ids!(omnibox));
if omnibox.area().is_valid(cx) || self.focus_retries >= 60 {
self.focus_omnibox_pending = false;
self.focus_retries = 0;
omnibox.set_key_focus(cx);
omnibox.borrow_mut().map(|mut inner| inner.select_all(cx));
} else {
// At startup the first tab arrives before the chrome has been
// drawn: no area to focus yet, so try again next frame.
self.focus_retries += 1;
self.focus_frame = cx.new_next_frame();
}
}
if let Event::MouseDown(_) = event {
if self.menu_open {
// Any click outside the menu closes it; the menu's own
// buttons still get the event through the ui below.
let menu = self.ui.view(cx, ids!(menu)).area();
if let Event::MouseDown(md) = event {
if !(menu.is_valid(cx) && menu.rect(cx).contains(md.abs)) {
self.set_menu_open(cx, false);
}
}
}
}
self.match_event(cx, event);
self.ui.handle_event(cx, event, &mut Scope::empty());
}
}
fn main() {
app_main();
}
/// The CEF-aware entry point: helper-process bootstrap (`cef_execute_process`),
/// the app-bundle re-exec macOS needs for Chromium's subprocesses, then the
/// ordinary Makepad event loop with the `--remote` control surface.
#[cfg(not(any(target_arch = "wasm32", target_os = "android", target_env = "ohos")))]
pub fn app_main() {
let _ = uptime_ms();
if let Err(err) = makepad_cef::reexec_into_app_bundle_if_needed() {
panic!("CEF bundle re-exec failed: {err}");
}
match makepad_cef::bootstrap() {
Ok(BootstrapResult::Continue) => {}
Ok(BootstrapResult::Exit(code)) => std::process::exit(code),
Err(err) => panic!("CEF bootstrap failed: {err}"),
}
Cx::init_log();
if Cx::pre_start() {
return;
}
// Chromium composites the page on this colour. Left at CEF's default the
// first frames of every page are BLACK — a dark dip then a bright jump on
// open, and a black margin wherever a resize outruns the reflow.
makepad_cef::set_background_color(palette().page_background_argb());
// Only the cheap NSApp/pump preparation here: the window goes up first,
// the WebView runs `cef_initialize` on the frame after it is drawn.
if let Err(err) = makepad_cef::prepare() {
panic!("CEF prepare failed: {err}");
}
let cx = Rc::new(RefCell::new(Cx::new(
makepad_widgets::_app_main_event_closure!(App),
)));
let studio_http = makepad_widgets::resolve_studio_http();
cx.borrow_mut().init_websockets(&studio_http);
if makepad_widgets::should_run_stdin_loop_from_env() {
cx.borrow_mut().in_makepad_studio = true;
}
cx.borrow_mut().init_cx_os();
makepad_widgets::makepad_platform::remote::start_if_requested(&mut cx.borrow_mut());
Cx::event_loop(cx.clone());
drop(cx);
makepad_cef::shutdown();
}
#[cfg(any(target_arch = "wasm32", target_os = "android", target_env = "ohos"))]
pub fn app_main() {
panic!("browser is desktop-only");
}

321
apps/browser/src/tabs.rs Normal file
View file

@ -0,0 +1,321 @@
//! The tab model. Each tab owns one CEF browser (created lazily when the
//! view first knows its size), the texture that browser paints into, and the
//! navigation state mirrored from CEF's display/load handlers.
use makepad_widgets::*;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct TabId(pub u64);
pub struct Tab {
pub id: TabId,
pub browser: Option<makepad_cef::Browser>,
/// The URL to load once the browser exists.
pub initial_url: String,
pub title: String,
pub url: String,
pub loading: bool,
pub can_go_back: bool,
pub can_go_forward: bool,
/// What the quad samples: an IOSurface-backed texture on the GPU path,
/// a BGRA upload texture on the software path. This is the LAST GOOD
/// frame — it is never dropped for a resize, only replaced once its
/// successor holds a page frame.
pub texture: Option<Texture>,
/// CEF has put at least one frame in `texture`. The GPU path hands the
/// browser an IOSurface up front, so the texture EXISTS long before the
/// page is on it; drawing it meanwhile paints an opaque black hole over
/// the themed ground — which, inside the WM, reads as the window
/// flashing black on the way in. Nothing samples the texture until this
/// is true.
pub painted: bool,
/// Pixel size `texture` is allocated at. The GPU surface is rounded up to
/// a coarse grid so a drag walks many sizes inside one surface.
pub texture_alloc: Option<(usize, usize)>,
/// The sub-rect of `texture` that actually holds page pixels (the last
/// blit's copy region). The quad samples exactly this and stretches it
/// over the current rect, so a frame from a slightly older size shows
/// scaled instead of cropped-with-a-blank-margin.
pub texture_valid: Option<(usize, usize)>,
/// A larger/smaller surface already handed to CEF that has NOT been
/// painted yet. It is not drawn until it holds a frame; `texture` keeps
/// covering the page area meanwhile.
pub pending_texture: Option<Texture>,
pub pending_alloc: Option<(usize, usize)>,
/// Allocation size of the surface CEF is painting into right now.
pub accel_target_size: Option<(usize, usize)>,
/// Page size last handed to `browser.resize`, and when — `was_resized` is
/// rate-limited, because a resize faster than CEF can paint starves the
/// paint callback completely (measured: 0 frames at ~2 kHz).
pub resized_to: Option<(usize, usize)>,
pub resized_at: Option<std::time::Instant>,
pub deferred_resize: Option<(usize, usize, f32)>,
/// Page size the layout last asked for, and when it last changed — the
/// settle detector behind shrinking a surface back down.
pub wanted_size: Option<(usize, usize)>,
pub wanted_at: Option<std::time::Instant>,
pub accel_frame_counter: u64,
pub nav_generation: u64,
pub favicon: Option<Texture>,
pub init_error: Option<String>,
pub render_mode: makepad_cef::RenderMode,
}
impl Tab {
fn new(id: TabId, url: &str) -> Self {
Self {
id,
browser: None,
initial_url: url.to_string(),
title: String::new(),
url: url.to_string(),
loading: true,
can_go_back: false,
can_go_forward: false,
texture: None,
painted: false,
texture_alloc: None,
texture_valid: None,
pending_texture: None,
pending_alloc: None,
accel_target_size: None,
resized_to: None,
resized_at: None,
deferred_resize: None,
wanted_size: None,
wanted_at: None,
accel_frame_counter: 0,
nav_generation: 0,
favicon: None,
init_error: None,
render_mode: makepad_cef::RenderMode::None,
}
}
/// Title for the strip: the page title, else the host, else "New Tab".
pub fn display_title(&self) -> String {
if !self.title.trim().is_empty() {
return self.title.clone();
}
if crate::theme::is_new_tab_url(&self.url) || self.url.is_empty() {
return "New Tab".to_string();
}
host_of(&self.url).unwrap_or_else(|| self.url.clone())
}
}
/// What the tab strip needs to draw one tab.
#[derive(Clone, Debug)]
pub struct TabSummary {
pub id: TabId,
pub title: String,
pub loading: bool,
pub active: bool,
pub favicon: Option<Texture>,
}
#[derive(Default)]
pub struct TabModel {
pub tabs: Vec<Tab>,
pub active: usize,
next_id: u64,
}
impl TabModel {
pub fn len(&self) -> usize {
self.tabs.len()
}
pub fn index_of(&self, id: TabId) -> Option<usize> {
self.tabs.iter().position(|t| t.id == id)
}
pub fn active(&self) -> Option<&Tab> {
self.tabs.get(self.active)
}
pub fn active_mut(&mut self) -> Option<&mut Tab> {
self.tabs.get_mut(self.active)
}
pub fn active_id(&self) -> Option<TabId> {
self.active().map(|t| t.id)
}
/// Insert a tab right after the active one (Chrome's placement), or at
/// the end when there is none.
pub fn insert(&mut self, url: &str, activate: bool) -> TabId {
self.next_id += 1;
let id = TabId(self.next_id);
let at = if self.tabs.is_empty() {
0
} else {
(self.active + 1).min(self.tabs.len())
};
self.tabs.insert(at, Tab::new(id, url));
if activate || self.tabs.len() == 1 {
self.active = at;
} else if at <= self.active {
self.active += 1;
}
id
}
/// Remove a tab. Returns the removed tab (dropping it closes its
/// browser) and whether it was the active one.
pub fn remove(&mut self, id: TabId) -> Option<(Tab, bool)> {
let index = self.index_of(id)?;
let was_active = index == self.active;
let tab = self.tabs.remove(index);
if self.tabs.is_empty() {
self.active = 0;
} else if index < self.active {
self.active -= 1;
} else if was_active {
// Chrome activates the tab to the right, else the new last one.
self.active = index.min(self.tabs.len() - 1);
}
Some((tab, was_active))
}
pub fn activate(&mut self, id: TabId) -> bool {
if let Some(index) = self.index_of(id) {
self.active = index;
true
} else {
false
}
}
pub fn activate_offset(&mut self, delta: isize) {
if self.tabs.is_empty() {
return;
}
let len = self.tabs.len() as isize;
let next = ((self.active as isize + delta) % len + len) % len;
self.active = next as usize;
}
pub fn activate_index(&mut self, index: usize) {
if index < self.tabs.len() {
self.active = index;
}
}
pub fn summaries(&self) -> Vec<TabSummary> {
self.tabs
.iter()
.enumerate()
.map(|(i, t)| TabSummary {
id: t.id,
title: t.display_title(),
loading: t.loading,
active: i == self.active,
favicon: t.favicon.clone(),
})
.collect()
}
}
pub fn host_of(url: &str) -> Option<String> {
let rest = url.split_once("://")?.1;
let host = rest.split(['/', '?', '#']).next()?;
if host.is_empty() {
return None;
}
Some(host.trim_start_matches("www.").to_string())
}
/// Turn omnibox input into a URL: keep explicit schemes, prefix `https://`
/// for things that look like hosts, otherwise search.
pub fn resolve_omnibox(input: &str) -> String {
let input = input.trim();
if input.is_empty() {
return String::new();
}
let lower = input.to_ascii_lowercase();
if lower.contains("://")
|| lower.starts_with("about:")
|| lower.starts_with("data:")
|| lower.starts_with("chrome:")
|| lower.starts_with("file:")
|| lower.starts_with("javascript:")
|| lower.starts_with("view-source:")
{
return input.to_string();
}
let has_space = input.contains(char::is_whitespace);
let first = input.split(['/', '?', '#']).next().unwrap_or("");
let (host, port) = match first.rsplit_once(':') {
Some((h, p)) if !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()) => (h, Some(p)),
_ => (first, None),
};
let looks_like_host = !has_space
&& !host.is_empty()
&& (host == "localhost"
|| host.parse::<std::net::IpAddr>().is_ok()
|| (host.contains('.')
&& !host.starts_with('.')
&& !host.ends_with('.')
&& host
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '.')
&& host
.rsplit('.')
.next()
.map(|tld| tld.len() >= 2 && tld.chars().all(|c| c.is_ascii_alphabetic()))
.unwrap_or(false)));
let _ = port;
if looks_like_host {
format!("https://{input}")
} else {
format!(
"https://www.google.com/search?q={}",
crate::theme::percent_encode(input)
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn omnibox_resolution() {
assert_eq!(resolve_omnibox("makepad.nl"), "https://makepad.nl");
assert_eq!(resolve_omnibox("http://x.org/a b"), "http://x.org/a b");
assert_eq!(resolve_omnibox("localhost:8080/x"), "https://localhost:8080/x");
assert_eq!(
resolve_omnibox("rust async traits"),
"https://www.google.com/search?q=rust%20async%20traits"
);
assert_eq!(
resolve_omnibox("hello"),
"https://www.google.com/search?q=hello"
);
assert_eq!(resolve_omnibox("about:blank"), "about:blank");
}
#[test]
fn model_insert_remove() {
let mut m = TabModel::default();
let a = m.insert("a", true);
let b = m.insert("b", true);
assert_eq!(m.active_id(), Some(b));
let c = m.insert("c", false);
assert_eq!(m.active_id(), Some(b));
assert_eq!(m.index_of(c), Some(2));
m.remove(b);
assert_eq!(m.active_id(), Some(c));
m.remove(c);
assert_eq!(m.active_id(), Some(a));
m.activate_offset(1);
assert_eq!(m.active_id(), Some(a));
}
#[test]
fn hosts() {
assert_eq!(host_of("https://www.makepad.nl/x"), Some("makepad.nl".into()));
assert_eq!(host_of("nope"), None);
}
}

226
apps/browser/src/theme.rs Normal file
View file

@ -0,0 +1,226 @@
//! The browser-chrome palette. Theming lives in splash: the chrome reads
//! `mod.browser_theme.*` (tab strip, toolbar, omnibox, icon roles), which this
//! module evaluates into the VM before the UI modules.
//!
//! Under makepad-wm the roles come from the WM's theme.splash
//! (`MAKEPAD_WM_THEME_SPLASH`, line-scanned by `makepad_wm_theme`, the family bridge);
//! standalone runs get Chrome's own dark palette.
use makepad_widgets::*;
/// Chrome-dark roles, keyed like the wm theme so one mapping serves both.
#[derive(Clone, Debug)]
pub struct Palette {
/// Tab strip background (the "frame").
pub darker_background: String,
/// Active tab + toolbar.
pub background: String,
/// Hovered inactive tab.
pub dark_background: String,
/// Button hover squares, omnibox focus fill.
pub lighter_background: String,
pub foreground: String,
pub dark_foreground: String,
pub bright_foreground: String,
pub muted: String,
pub selection: String,
pub accent: String,
}
impl Palette {
pub fn chrome_dark() -> Self {
Self {
darker_background: "#202124".into(),
background: "#35363a".into(),
dark_background: "#2b2c2f".into(),
lighter_background: "#3c4043".into(),
foreground: "#e8eaed".into(),
dark_foreground: "#9aa0a6".into(),
bright_foreground: "#ffffff".into(),
muted: "#5f6368".into(),
selection: "#264f78".into(),
accent: "#8ab4f8".into(),
}
}
/// The WM palette when wm exported one, else Chrome dark.
pub fn current() -> Self {
let fallback = Self::chrome_dark();
let Some(p) = makepad_wm_theme::current() else {
return fallback;
};
Self {
darker_background: p.hex("darker_background", &fallback.darker_background),
background: p.hex("background", &fallback.background),
dark_background: p.hex("dark_background", &fallback.dark_background),
lighter_background: p.hex("lighter_background", &fallback.lighter_background),
foreground: p.hex("foreground", &fallback.foreground),
dark_foreground: p.hex("dark_foreground", &fallback.dark_foreground),
bright_foreground: p.hex("bright_foreground", &fallback.bright_foreground),
muted: p.hex("muted", &fallback.muted),
selection: p.hex("selection", &fallback.selection),
accent: p.hex("accent", &fallback.accent),
}
}
/// The `mod.browser_theme = {...}` splash source. Runtime-evaluated, so plain
/// `#hex` (the `#x` escape is a proc-macro-only hazard).
pub fn splash_source(&self) -> String {
format!(
"mod.browser_theme = {{\n\
\x20 darker_background: {}\n\
\x20 background: {}\n\
\x20 dark_background: {}\n\
\x20 lighter_background: {}\n\
\x20 foreground: {}\n\
\x20 dark_foreground: {}\n\
\x20 bright_foreground: {}\n\
\x20 muted: {}\n\
\x20 selection: {}\n\
\x20 accent: {}\n\
}}\n\
true\n",
self.darker_background,
self.background,
self.dark_background,
self.lighter_background,
self.foreground,
self.dark_foreground,
self.bright_foreground,
self.muted,
self.selection,
self.accent,
)
}
/// Evaluate `mod.browser_theme` into the VM. Call after
/// `makepad_widgets::script_mod(vm)` and before the chrome modules.
pub fn apply(&self, vm: &mut ScriptVm) {
let script_mod_id = ScriptMod {
cargo_manifest_path: env!("CARGO_MANIFEST_DIR").to_string(),
module_path: "browser_theme".to_string(),
file: "browser_theme.splash".to_string(),
line: 0,
column: 0,
code: self.splash_source(),
values: vec![],
};
vm.eval(script_mod_id);
for e in vm.take_errors() {
log!("browser theme: {}", e);
}
}
/// What CEF fills the page with before the site has composited anything,
/// and behind area a reflow has not reached yet. The same colour as the
/// ground the page quad sits on, so neither a cold tab nor a mid-resize
/// page shows a black hole.
pub fn page_background_argb(&self) -> u32 {
parse_argb(&self.darker_background).unwrap_or(0xff20_2124)
}
pub fn is_dark(&self) -> bool {
let c = parse_hex(&self.background).unwrap_or(vec4(0.0, 0.0, 0.0, 1.0));
0.2126 * c.x + 0.7152 * c.y + 0.0722 * c.z < 0.5
}
/// The new-tab page: a data URL in the theme's colours, so a fresh tab
/// never flashes white.
pub fn new_tab_url(&self) -> String {
let html = format!(
"<!doctype html><html><head><meta charset=utf-8><title>New Tab</title>\
<style>html,body{{margin:0;height:100%;background:{bg};color:{fg};\
font:14px -apple-system,Helvetica,Arial,sans-serif}}\
.c{{display:flex;height:100%;align-items:center;justify-content:center;\
flex-direction:column;gap:10px}}.n{{font-size:28px;letter-spacing:1px;color:{fgb}}}\
.s{{color:{fgd}}}</style></head><body><div class=c>\
<div class=n>browser</div><div class=s>Type a URL or search in the box above</div>\
</div></body></html>",
bg = self.darker_background,
fg = self.foreground,
fgb = self.bright_foreground,
fgd = self.dark_foreground,
);
format!("data:text/html;charset=utf-8,{}", percent_encode(&html))
}
}
pub fn parse_hex(s: &str) -> Option<Vec4f> {
let s = s.trim().trim_start_matches('#');
if s.len() != 6 {
return None;
}
let r = u8::from_str_radix(&s[0..2], 16).ok()?;
let g = u8::from_str_radix(&s[2..4], 16).ok()?;
let b = u8::from_str_radix(&s[4..6], 16).ok()?;
Some(vec4(
r as f32 / 255.0,
g as f32 / 255.0,
b as f32 / 255.0,
1.0,
))
}
/// A `#rrggbb` role as opaque ARGB (`0xFFRRGGBB`) — the form CEF wants for
/// `cef_browser_settings_t::background_color`.
pub fn parse_argb(s: &str) -> Option<u32> {
let s = s.trim().trim_start_matches('#');
if s.len() != 6 {
return None;
}
let rgb = u32::from_str_radix(s, 16).ok()?;
Some(0xff00_0000 | rgb)
}
/// Minimal percent-encoding for a `data:` URL payload.
pub fn percent_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len() * 3);
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char)
}
_ => out.push_str(&format!("%{:02X}", b)),
}
}
out
}
/// Is this new-tab-page URL (never shown in the omnibox)?
pub fn is_new_tab_url(url: &str) -> bool {
url.starts_with("data:text/html;charset=utf-8,%3C%21doctype%20html%3E%3Chtml%3E%3Chead%3E%3Cmeta%20charset%3Dutf-8%3E%3Ctitle%3ENew%20Tab")
|| url == "about:blank"
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_tab_url_is_recognised() {
let p = Palette::chrome_dark();
assert!(is_new_tab_url(&p.new_tab_url()));
assert!(!is_new_tab_url("https://makepad.nl"));
}
#[test]
fn hex_parses() {
let c = parse_hex("#8ab4f8").unwrap();
assert!((c.x - 0x8a as f32 / 255.0).abs() < 1e-6);
assert!(parse_hex("#12345").is_none());
}
#[test]
fn argb_is_opaque() {
// CEF paints the page on this before the site composites; a
// transparent (or zero) colour is what makes a page open black.
assert_eq!(parse_argb("#202124"), Some(0xff20_2124));
assert_eq!(parse_argb("202124"), Some(0xff20_2124));
assert_eq!(parse_argb("#nope"), None);
assert_eq!(
Palette::chrome_dark().page_background_argb(),
0xff20_2124,
"the CEF fill must match the themed ground the page sits on"
);
}
}

1030
apps/browser/src/webview.rs Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,35 @@
# calculator — part of the Makepad app family: standalone window, and a module
# (src/module.rs) the window manager seats in-process. Spec of record:
# local/agent_state/wm-all/apps/calculator-spec.md; contract: .../CONTRACT.md.
[package]
name = "makepad-calculator"
version = "0.1.0"
edition = "2021"
default-run = "calculator"
[lib]
name = "makepad_calculator"
path = "src/lib.rs"
[[bin]]
name = "calculator"
path = "src/main.rs"
required-features = ["standalone"]
[features]
# On-demand dylib for the Android super-app (apps/wm-dyn). No engine dep
# here: the host that builds the lib binds it to its engine dylib itself
# (apps/wm/src/dylib_host.rs), so this crate's manifest is the desktop's.
dynamic-module = []
default = ["standalone"]
# The standalone window (src/main.rs); a host that links the module leaves it off.
standalone = []
[dependencies]
makepad-widgets = { path = "../../widgets" }
makepad-wm-theme = { path = "../../libs/wm_theme" }
makepad-ai-services = { path = "../../libs/ai/services" }
makepad-app-module = { path = "../../libs/app_module" }
makepad-strict-json = { path = "../../libs/strict_json" }

24
apps/calculator/ci.splash Normal file
View file

@ -0,0 +1,24 @@
// CI smoke test for apps/calculator. Run by tools/ci, which finds every `ci.splash`
// in the checkout. The script decides every input; the vision model only
// judges the grab against the acceptance text below. Basic on purpose: does
// it check everywhere, build and come up here, and look like what it is.
use mod.std.*
use mod.ci;
ci.step("check other platforms", fn(){
ci.check_targets({packages: ["makepad-calculator"] targets: ci.host.targets})
})
let app = ci.launch({package: "makepad-calculator" binary: "calculator" cwd: "."})
ci.step("comes up", fn(){
ci.sleep(3.0)
app.no_errors()
let shot = app.grab("start")
ci.accept(shot, "This is a screenshot of one application window taken from a Mac. A thin bright red frame runs around the very edge of the picture; it is the test harness's marker and is not part of the application. Ignore it. The application is a calculator. It came up when these are true: 1. The picture is not blank: it is not one flat colour from edge to edge, and it is not entirely black or entirely white. 2. Some user interface is visible: at least two different things among text labels, buttons, icons, panels, lists, input fields, a toolbar, a drawing or a picture. 3. No error panel covers the window: there is no large block of text with words such as panic, error, failed, backtrace or unwrap. 4. A calculator keypad is visible: a grid of round or square buttons carrying digits and operators such as + and =, with a number display above it. Write one short bullet line for each numbered point saying what you see. YES means points 1, 2, 3 and 4 are all satisfied. Then write the verdict as the last line, in exactly this form: VERDICT: YES or VERDICT: NO")
})
app.no_errors()
app.quit()
nil

View file

@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#1b3354"/>
<stop offset="1" stop-color="#0b1627"/>
</linearGradient>
<linearGradient id="sheen" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#ffffff" stop-opacity="0.10"/>
<stop offset="0.5" stop-color="#ffffff" stop-opacity="0"/>
</linearGradient>
<linearGradient id="o" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#ff7a52"/><stop offset="1" stop-color="#ff4f2b"/></linearGradient>
</defs>
<rect x="64" y="64" width="896" height="896" rx="200" fill="url(#bg)"/>
<rect x="64" y="64" width="896" height="896" rx="200" fill="url(#sheen)"/>
<rect x="66" y="66" width="892" height="892" rx="198" fill="none" stroke="#ffffff" stroke-opacity="0.08" stroke-width="4"/>
<g fill="#f5c84c"><rect x="242" y="242" width="250" height="250" rx="56"/><rect x="532" y="242" width="250" height="250" rx="56"/><rect x="242" y="532" width="250" height="250" rx="56"/></g>
<rect x="532" y="532" width="250" height="250" rx="56" fill="url(#o)"/>
<g stroke="#0e1d33" stroke-width="40" stroke-linecap="round" fill="none">
<path d="M367 307 V427 M307 367 H427"/>
<path d="M597 367 H717"/>
<path d="M320 610 L414 704 M414 610 L320 704"/>
</g>
<path d="M597 624 H717 M597 690 H717" stroke="#e8eef6" stroke-width="40" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#FFFFFF" d="M9 4h9a2 2 0 0 1 2 2v9h-2V6H9V4zm-4 4h9a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-9a2 2 0 0 1 2-2zm0 2v9h9v-9H5z"/></svg>

After

Width:  |  Height:  |  Size: 211 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#FFFFFF" d="M12 3.5a8.5 8.5 0 1 0 8.2 6.4h-2.06A6.5 6.5 0 1 1 12 5.5V8l4-3-4-3v2.5zm.75 4.25v4.02l3.1 1.84-.75 1.26-3.85-2.28V7.75h1.5z"/></svg>

After

Width:  |  Height:  |  Size: 217 B

166
apps/calculator/src/ai.rs Normal file
View file

@ -0,0 +1,166 @@
//! calculator.eval: a read-only evaluator over the root's current angle.
use crate::engine::{self, format_number, MAX_SOURCE_BYTES};
use crate::model::{AngleMode, CalculatorDoc, ErrorCode};
use makepad_ai_services::wire::{Risk, ServiceCall, ServiceManifest, ToolDef, ToolResult};
use makepad_strict_json::{self as json, Value};
pub fn manifest() -> ServiceManifest {
ServiceManifest::new(
"calculator",
"Calculator",
"Evaluate a mathematical expression in the calculator's current angle mode.",
)
.with_tool(ToolDef::new(
"eval",
"Evaluate an expression. Uses the on-screen angle mode. Does not change the calculation, memory, or history.",
r#"{"type":"object","properties":{"expression":{"type":"string","maxLength":1024}},"required":["expression"],"additionalProperties":false}"#,
Risk::Read,
))
}
pub fn answer(doc: &CalculatorDoc, call: &ServiceCall) -> ToolResult {
match call.tool.as_str() {
"eval" => eval_on(doc, call),
other => ToolResult::refused(&call.call_id, format!("unknown tool `{other}`; this app only has `eval`")),
}
}
pub fn eval_on(doc: &CalculatorDoc, call: &ServiceCall) -> ToolResult {
let expression = match parse_args(&call.args) {
Ok(s) => s,
Err(msg) => return ToolResult::refused(&call.call_id, msg),
};
if expression.len() > MAX_SOURCE_BYTES {
return ToolResult::refused(&call.call_id, "expression exceeds 1024 bytes");
}
match engine::evaluate(&expression, doc.angle) {
Ok(value) => {
let formatted = format_number(value);
let data = json::obj(vec![
("value", json_num(value)),
("formatted", json::s(&formatted)),
("angle", json::s(doc.angle.as_str())),
])
.to_json();
ToolResult::ok(&call.call_id, formatted, "").with_data(data)
}
Err(e) if e.code == ErrorCode::Limit => ToolResult::refused(&call.call_id, "expression limit exceeded"),
Err(e) => {
let data = json::obj(vec![
("code", json::s(e.code.as_str())),
("byte_offset", Value::Int(e.byte_offset as i64)),
])
.to_json();
ToolResult::failed(
&call.call_id,
format!("{} at {}", e.code.as_str(), e.byte_offset),
)
.with_data(data)
}
}
}
fn json_num(v: f64) -> Value {
if v.fract() == 0.0 && v.abs() < i64::MAX as f64 && (v as i64) as f64 == v {
Value::Int(v as i64)
} else {
Value::F64(v)
}
}
fn parse_args(args: &str) -> Result<String, String> {
if args.len() > 8192 { return Err("arguments too large".into()); }
let value = json::parse(args.as_bytes()).map_err(|_| "invalid arguments".to_string())?;
let Value::Obj(pairs) = value else {
return Err("arguments must be an object".into());
};
let mut expression = None;
for (k, v) in &pairs {
match k.as_str() {
"expression" => {
let s = v.as_str().ok_or_else(|| "expression must be a string".to_string())?;
if s.len() > 1024 {
return Err("expression exceeds maxLength 1024".into());
}
expression = Some(s.to_string());
}
other => return Err(format!("unexpected property `{other}`")),
}
}
expression.ok_or_else(|| "missing expression".to_string())
}
/// Used by tests that do not construct a view.
pub fn eval_expression(expression: &str, angle: AngleMode) -> Result<(f64, String), ErrorCode> {
engine::evaluate(expression, angle)
.map(|v| (v, format_number(v)))
.map_err(|e| e.code)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::seed;
use makepad_ai_services::wire::ToolOutcome;
fn call(tool: &str, args: &str) -> ServiceCall {
ServiceCall {
call_id: "c1".into(),
tool: tool.into(),
args: args.into(),
}
}
#[test]
fn the_manifest_declares_one_read_tool() {
let m = manifest();
assert_eq!(m.id, "calculator");
assert!(m.validate().is_ok());
assert_eq!(m.tools.len(), 1);
assert_eq!(m.tools[0].name, "eval");
assert_eq!(m.tools[0].risk, Risk::Read);
}
#[test]
fn eval_succeeds_refuses_bad_args_and_fails_math() {
let doc = seed::initial();
let ok = eval_on(&doc, &call("eval", r#"{"expression":"200+10%"}"#));
assert_eq!(ok.outcome, ToolOutcome::Ok);
assert!(ok.data.contains("\"value\":220"));
assert!(ok.data.contains("\"angle\":\"deg\""));
let refused = eval_on(&doc, &call("eval", r#"{"expression":"1","extra":true}"#));
assert_eq!(refused.outcome, ToolOutcome::Refused);
let missing = eval_on(&doc, &call("eval", r#"{}"#));
assert_eq!(missing.outcome, ToolOutcome::Refused);
let fail = eval_on(&doc, &call("eval", r#"{"expression":"1/0"}"#));
assert_eq!(fail.outcome, ToolOutcome::Failed);
assert!(fail.data.contains("division_by_zero"));
let unknown = answer(&doc, &call("nope", "{}"));
assert_eq!(unknown.outcome, ToolOutcome::Refused);
}
#[test]
fn eval_does_not_mutate_the_document() {
let mut doc = seed::initial();
doc.session.source = "9".into();
doc.session.value = 9.0;
let before = doc.clone();
let _ = eval_on(&doc, &call("eval", r#"{"expression":"2+2"}"#));
assert_eq!(doc, before);
}
#[test]
fn tool_refuses_all_engine_limits_and_unknown_tools() {
let doc = seed::initial();
for expression in ["(".repeat(100), "1+".repeat(200), "1".repeat(1025)] {
let args = json::obj(vec![("expression", json::s(&expression))]).to_json();
assert_eq!(answer(&doc, &call("eval", &args)).outcome, ToolOutcome::Refused);
}
assert_eq!(answer(&doc, &call("nope", "{}")).outcome, ToolOutcome::Refused);
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,20 @@
//! calculator as a library: the expression engine, the one root widget, and
//! the module the window manager seats in-process.
pub use makepad_widgets;
pub mod ai;
pub mod engine;
pub mod model;
pub mod module;
pub mod seed;
pub mod view;
pub use module::{CalculatorModule, CALCULATOR_MODULE};
pub use view::CalculatorView;
use makepad_widgets::*;
pub fn script_mod(vm: &mut ScriptVm) -> ScriptValue {
view::script_mod(vm)
}

162
apps/calculator/src/main.rs Normal file
View file

@ -0,0 +1,162 @@
//! calculator — the standalone window.
pub use makepad_widgets;
use makepad_ai_services::port::{AiServicePort, PortEvent};
use makepad_calculator::{ai, view::CalculatorView};
use makepad_strict_json as json;
use makepad_widgets::*;
app_main!(App);
script_mod! {
use mod.prelude.widgets.*
use mod.widgets.*
startup() do #(App::script_component(vm)){
ui: Root{
main_window := Window{
window.title: "Calculator"
window.inner_size: vec2(1240, 800)
pass +: {clear_color: theme.color_bg_app}
body +: {
padding: 0 margin: 0 spacing: 0
calculator := CalculatorView{}
}
}
}
}
}
#[derive(Script, ScriptHook)]
pub struct App {
#[live]
ui: WidgetRef,
#[rust]
ai_port: Option<AiServicePort>,
#[rust]
ai_context: String,
}
impl App {
fn ai_summary(&self, cx: &mut Cx) -> String {
self.ui
.widget(cx, ids!(calculator))
.borrow::<CalculatorView>()
.map(|view| view.ai_summary())
.unwrap_or_default()
}
fn ai_answer(
&self,
cx: &mut Cx,
call: &makepad_ai_services::wire::ServiceCall,
) -> makepad_ai_services::wire::ToolResult {
self.ui
.widget(cx, ids!(calculator))
.borrow::<CalculatorView>()
.map(|view| ai::answer(view.doc(), call))
.unwrap_or_else(|| {
makepad_ai_services::wire::ToolResult::unavailable(&call.call_id, "the calculator is gone")
})
}
fn refresh_ai_context(&mut self, cx: &mut Cx) {
if self.ai_port.is_none() {
return;
}
let text = self.ai_summary(cx);
if text == self.ai_context {
return;
}
self.ai_context = text.clone();
if let Some(port) = self.ai_port.as_ref() {
port.set_context(&text);
}
}
fn drain_ai_port(&mut self, cx: &mut Cx, event: &Event) {
let events = match self.ai_port.as_mut() {
Some(port) => port.handle_event(cx, event),
None => return,
};
for ev in events {
match ev {
PortEvent::Registered(endpoint) => {
log!("calculator: AI service registered as {}", endpoint.as_str());
self.ai_context.clear();
self.refresh_ai_context(cx);
}
PortEvent::Call(call) => {
let result = self.ai_answer(cx, &call);
if let Some(port) = self.ai_port.as_ref() {
port.reply(result);
}
}
PortEvent::Cancel { .. } | PortEvent::ChatOpen { .. } => {}
PortEvent::Subscribe { .. } | PortEvent::Unsubscribe { .. } => {}
}
}
}
}
impl MatchEvent for App {
fn handle_startup(&mut self, cx: &mut Cx) {
self.ai_port = AiServicePort::open(cx, ai::manifest());
if let Some(mut view) = self.ui.widget(cx, ids!(calculator)).borrow_mut::<CalculatorView>() {
view.set_storage(cx.storage("calculator"));
}
}
fn handle_actions(&mut self, _cx: &mut Cx, _actions: &Actions) {}
}
impl AppMain for App {
fn script_mod(vm: &mut ScriptVm) -> ScriptValue {
makepad_widgets::script_mod(vm);
makepad_wm_theme::apply(vm);
makepad_calculator::script_mod(vm);
self::script_mod(vm)
}
fn handle_event(&mut self, cx: &mut Cx, event: &Event) {
if let Event::Custom(json) = event {
if is_close_requested(json) {
let proceed = self
.ui
.widget(cx, ids!(calculator))
.borrow_mut::<CalculatorView>()
.map(|mut view| view.handle_close(cx))
.unwrap_or(true);
if proceed {
cx.quit();
}
return;
}
}
if let Event::WindowCloseRequested(ev) = event {
let proceed = self
.ui
.widget(cx, ids!(calculator))
.borrow_mut::<CalculatorView>()
.map(|mut view| view.handle_close(cx))
.unwrap_or(true);
if !proceed {
ev.accept_close.set(false);
}
}
self.drain_ai_port(cx, event);
self.match_event(cx, event);
self.ui.handle_event(cx, event, &mut Scope::empty());
self.refresh_ai_context(cx);
}
}
fn is_close_requested(raw: &str) -> bool {
if !raw.contains("CloseRequested") {
return false;
}
let Ok(value) = json::parse(raw.as_bytes()) else {
return false;
};
value.get("wm").and_then(|wm| wm.get("CloseRequested")).is_some()
}

2066
apps/calculator/src/model.rs Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,195 @@
//! calculator as a module: the app the window manager seats in a tile
//! in-process, in an isolate of its own.
use crate::view::CalculatorView;
use makepad_ai_services::wire::{ServiceCall, ServiceManifest};
use makepad_app_module::*;
use makepad_widgets::*;
pub struct CalculatorModule;
/// The one linked instance of the module description: immutable, no state.
pub static CALCULATOR_MODULE: CalculatorModule = CalculatorModule;
#[cfg(feature = "dynamic-module")]
makepad_app_module::export_app_module!(CALCULATOR_MODULE);
impl AppModule for CalculatorModule {
fn id(&self) -> &'static str {
"calculator"
}
fn label(&self) -> &'static str {
"Calculator"
}
fn register(&self, vm: &mut ScriptVm) {
crate::script_mod(vm);
}
fn open_schema(&self) -> OpenSchema {
OpenSchema::new(1)
}
fn create(&self, vm: &mut ScriptVm, _open: ValidatedOpen, handles: InstanceHandles) -> InstanceParts {
let value = script_eval!(vm, {
use mod.widgets.*
CalculatorView {}
});
let root = WidgetRef::script_from_value(vm, value);
if let Some(mut view) = root.borrow_mut::<CalculatorView>() {
view.set_storage(handles.storage);
}
let shutdown_root = root.clone();
InstanceParts {
root: root.clone(),
executor: Box::new(CalculatorExecutor { root }),
shutdown: Box::new(move |vm| {
if let Some(mut view) = shutdown_root.borrow_mut::<CalculatorView>() {
vm.with_cx_mut(|cx| view.shutdown(cx));
}
}),
}
}
fn capabilities(&self) -> &'static [&'static str] {
&["storage"]
}
}
struct CalculatorExecutor {
root: WidgetRef,
}
impl ServiceExecutor for CalculatorExecutor {
fn manifest(&self) -> ServiceManifest {
crate::ai::manifest()
}
fn execute(&mut self, _cx: &mut Cx, call: &ServiceCall) -> ExecOutcome {
let result = self
.root
.borrow::<CalculatorView>()
.map(|view| crate::ai::answer(view.doc(), call))
.unwrap_or_else(|| {
makepad_ai_services::wire::ToolResult::unavailable(&call.call_id, "the calculator is gone")
});
ExecOutcome::Done(result)
}
}
#[cfg(test)]
mod tests {
use super::*;
use makepad_ai_services::wire::{ServiceCall, ToolOutcome};
#[test]
fn the_module_describes_itself_and_opens_empty() {
let m = &CALCULATOR_MODULE;
assert_eq!(m.id(), "calculator");
assert_eq!(m.label(), "Calculator");
assert!(m.capabilities().contains(&"storage"));
let schema = m.open_schema();
assert_eq!(schema.version, 1);
assert!(schema.empty_open().is_ok(), "no argument is required");
assert!(
schema.validate(r#"{"city":"Amsterdam"}"#, &[]).is_err(),
"the schema takes no arguments at all"
);
}
#[test]
fn the_module_mints_its_root_in_a_fresh_isolate() {
let mut cx = Cx::new(Box::new(|_, _| {}));
cx.init_cx_os();
cx.with_vm(makepad_widgets::script_mod);
let vm_id = cx.alloc_splash_vm_with_network(false);
let storage = cx.storage("calculator.test");
let (replies, _upstream) = ReplySink::pair();
let handles = InstanceHandles {
scope: InstanceScope::new(1, 1),
storage,
viewport: Viewport {
size: dvec2(320.0, 200.0),
},
replies,
};
let open = CALCULATOR_MODULE.open_schema().empty_open().unwrap();
let InstanceParts {
root,
executor,
shutdown,
} = cx.with_script_vm_id_trusted(vm_id, |vm| {
CALCULATOR_MODULE.register(vm);
let parts = CALCULATOR_MODULE.create(vm, open, handles);
assert!(
vm.take_errors().is_empty(),
"the isolate evaluated the view without errors"
);
parts
});
assert!(
root.borrow::<CalculatorView>().is_some(),
"the root is a CalculatorView"
);
{
let view = root.borrow::<CalculatorView>().unwrap();
assert!(!view.ai_summary().is_empty());
assert_eq!(view.doc().session.source, "0");
}
let storage2 = cx.storage("calculator.test.b");
let (replies2, _upstream2) = ReplySink::pair();
let handles2 = InstanceHandles {
scope: InstanceScope::new(1, 2),
storage: storage2,
viewport: Viewport {
size: dvec2(320.0, 200.0),
},
replies: replies2,
};
let open2 = CALCULATOR_MODULE.open_schema().empty_open().unwrap();
let InstanceParts {
root: root2,
executor: executor2,
shutdown: shutdown2,
} = cx.with_script_vm_id_trusted(vm_id, |vm| {
CALCULATOR_MODULE.create(vm, open2, handles2)
});
let call = ServiceCall {
call_id: "t1".into(),
tool: "eval".into(),
args: r#"{"expression":"2+3*4"}"#.into(),
};
let mut exec = executor;
let ExecOutcome::Done(result) = exec.execute(&mut cx, &call) else {
panic!("executor should finish");
};
assert_eq!(result.outcome, ToolOutcome::Ok);
assert!(result.data.contains("14"), "{}", result.data);
{
let view = root.borrow::<CalculatorView>().unwrap();
assert_eq!(view.doc().session.source, "0", "eval must not mutate the document");
}
{
let mut a = root.borrow_mut::<CalculatorView>().unwrap();
a.apply_command_for_test(crate::model::Command::Digit(7));
assert_eq!(a.doc().session.source, "7");
}
{
let a = root.borrow::<CalculatorView>().unwrap();
let b = root2.borrow::<CalculatorView>().unwrap();
assert_eq!(a.doc().session.source, "7");
assert_eq!(b.doc().session.source, "0");
}
cx.with_script_vm_id_trusted(vm_id, |vm| shutdown(vm));
cx.with_script_vm_id_trusted(vm_id, |vm| shutdown2(vm));
drop(root);
drop(root2);
drop(exec);
drop(executor2);
cx.free_splash_vm(vm_id);
}
}

View file

@ -0,0 +1,42 @@
//! Deterministic empty calculator document. No clock, no RNG.
use crate::model::{AngleMode, CalculatorDoc, Phase, Session};
use std::collections::VecDeque;
/// Version 1, degrees, empty memory and history, source `"0"`.
pub fn initial() -> CalculatorDoc {
CalculatorDoc {
version: 1,
angle: AngleMode::Degrees,
memory: None,
session: Session {
source: "0".into(),
phase: Phase::Editing,
value: 0.0,
repeat: None,
reuse_repeat: false,
},
next_id: 1,
history: VecDeque::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_seed_is_degrees_zero_and_has_no_history() {
let d = initial();
assert_eq!(d.version, 1);
assert_eq!(d.angle, AngleMode::Degrees);
assert!(d.memory.is_none());
assert_eq!(d.session.source, "0");
assert_eq!(d.session.value, 0.0);
assert_eq!(d.session.phase, Phase::Editing);
assert!(d.session.repeat.is_none());
assert!(!d.session.reuse_repeat);
assert_eq!(d.next_id, 1);
assert!(d.history.is_empty());
}
}

1750
apps/calculator/src/view.rs Normal file

File diff suppressed because it is too large Load diff

32
apps/calendar/Cargo.toml Normal file
View file

@ -0,0 +1,32 @@
# calendar — part of the Makepad app family: standalone window, and a module
# (src/module.rs) the window manager seats in-process. Spec of record:
# local/agent_state/wm-all/apps/calendar-redesign.md; contract: .../CONTRACT.md.
[package]
name = "makepad-calendar"
version = "0.1.0"
edition = "2021"
default-run = "calendar"
[lib]
name = "makepad_calendar"
path = "src/lib.rs"
[[bin]]
name = "calendar"
path = "src/main.rs"
required-features = ["standalone"]
[features]
dynamic-module = []
default = ["standalone"]
# The standalone window (src/main.rs); a host that links the module leaves it off.
standalone = []
[dependencies]
makepad-widgets = { path = "../../widgets" }
makepad-wm-theme = { path = "../../libs/wm_theme" }
makepad-ai-services = { path = "../../libs/ai/services" }
makepad-app-module = { path = "../../libs/app_module" }
makepad-strict-json = { path = "../../libs/strict_json" }
makepad-civil-time = { path = "../../libs/civil_time" }

24
apps/calendar/ci.splash Normal file
View file

@ -0,0 +1,24 @@
// CI smoke test for apps/calendar. Run by tools/ci, which finds every `ci.splash`
// in the checkout. The script decides every input; the vision model only
// judges the grab against the acceptance text below. Basic on purpose: does
// it check everywhere, build and come up here, and look like what it is.
use mod.std.*
use mod.ci;
ci.step("check other platforms", fn(){
ci.check_targets({packages: ["makepad-calendar"] targets: ci.host.targets})
})
let app = ci.launch({package: "makepad-calendar" binary: "calendar" cwd: "."})
ci.step("comes up", fn(){
ci.sleep(3.0)
app.no_errors()
let shot = app.grab("start")
ci.accept(shot, "This is a screenshot of one application window taken from a Mac. A thin bright red frame runs around the very edge of the picture; it is the test harness's marker and is not part of the application. Ignore it. The application is a calendar. It came up when these are true: 1. The picture is not blank: it is not one flat colour from edge to edge, and it is not entirely black or entirely white. 2. Some user interface is visible: at least two different things among text labels, buttons, icons, panels, lists, input fields, a toolbar, a drawing or a picture. 3. No error panel covers the window: there is no large block of text with words such as panic, error, failed, backtrace or unwrap. 4. A calendar is visible: a grid of days with day numbers, or a week view with hours down the side. Write one short bullet line for each numbered point saying what you see. YES means points 1, 2, 3 and 4 are all satisfied. Then write the verdict as the last line, in exactly this form: VERDICT: YES or VERDICT: NO")
})
app.no_errors()
app.quit()
nil

View file

@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#1b3354"/>
<stop offset="1" stop-color="#0b1627"/>
</linearGradient>
<linearGradient id="sheen" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#ffffff" stop-opacity="0.10"/>
<stop offset="0.5" stop-color="#ffffff" stop-opacity="0"/>
</linearGradient>
</defs>
<rect x="64" y="64" width="896" height="896" rx="200" fill="url(#bg)"/>
<rect x="64" y="64" width="896" height="896" rx="200" fill="url(#sheen)"/>
<rect x="66" y="66" width="892" height="892" rx="198" fill="none" stroke="#ffffff" stroke-opacity="0.08" stroke-width="4"/>
<rect x="232" y="262" width="560" height="520" rx="72" fill="#e8eef6"/>
<path d="M232 334 A72 72 0 0 1 304 262 H720 A72 72 0 0 1 792 334 V402 H232 Z" fill="#ff7ab6"/>
<g fill="#e8eef6" stroke="#0e1d33" stroke-width="14"><rect x="348" y="214" width="48" height="108" rx="24"/><rect x="628" y="214" width="48" height="108" rx="24"/></g>
<g fill="#0e1d33" fill-opacity="0.22">
<rect x="292" y="438" width="80" height="80" rx="18"/><rect x="412" y="438" width="80" height="80" rx="18"/><rect x="532" y="438" width="80" height="80" rx="18"/><rect x="652" y="438" width="80" height="80" rx="18"/>
<rect x="292" y="546" width="80" height="80" rx="18"/><rect x="532" y="546" width="80" height="80" rx="18"/><rect x="652" y="546" width="80" height="80" rx="18"/>
<rect x="292" y="654" width="80" height="80" rx="18"/><rect x="412" y="654" width="80" height="80" rx="18"/>
</g>
<rect x="412" y="546" width="80" height="80" rx="18" fill="#ff7ab6"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path fill="#000" fill-rule="evenodd" d="M9 2h2v7h7v2h-7v7H9v-7H2V9h7z"/></svg>

After

Width:  |  Height:  |  Size: 140 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path fill="#000" fill-rule="evenodd" d="M4 1h2v2h8V1h2v2h3v16H1V3h3zm-1 7v9h14V8zm0-3v1h14V5z"/></svg>

After

Width:  |  Height:  |  Size: 164 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path fill="#000" fill-rule="evenodd" d="M7.7 2 6 3.7l6.3 6.3L6 16.3 7.7 18l8-8z"/></svg>

After

Width:  |  Height:  |  Size: 150 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path fill="#000" fill-rule="evenodd" d="M12.3 2 14 3.7 7.7 10 14 16.3 12.3 18l-8-8z"/></svg>

After

Width:  |  Height:  |  Size: 154 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path fill="#000" fill-rule="evenodd" d="M8 1a7 7 0 1 0 4.2 12.6l5.1 5.1 1.4-1.4-5.1-5.1A7 7 0 0 0 8 1zm0 2a5 5 0 1 1 0 10A5 5 0 0 1 8 3z"/></svg>

After

Width:  |  Height:  |  Size: 207 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path fill="#000" fill-rule="evenodd" d="M2 3h4v4H2zm6 1h10v2H8zM2 8h4v4H2zm6 1h10v2H8zM2 13h4v4H2zm6 1h10v2H8z"/></svg>

After

Width:  |  Height:  |  Size: 181 B

166
apps/calendar/src/agenda.rs Normal file
View file

@ -0,0 +1,166 @@
//! Explicit agenda ownership: every nonempty result has a rendered occurrence row.
use crate::components::*;
use crate::model::*;
use crate::presentation::*;
use makepad_widgets::*;
use std::collections::BTreeMap;
script_mod! {
use mod.prelude.widgets_internal.*
use mod.widgets.*
use mod.calendar.*
mod.widgets.CalendarAgendaListBase = #(CalendarAgendaList::register_widget(vm))
mod.widgets.CalendarAgendaList = set_type_default() do mod.widgets.CalendarAgendaListBase{
width: Fill height: Fill flow: Down show_bg: false padding: 0 margin: 0
scrolling: ScrollBars{show_scroll_x: false show_scroll_y: true}
}
mod.widgets.CalendarAgendaPage = Plain{flow: Down
agenda_heading := Ink{height: 52 width: Fill padding: Inset{left: 20} text: "Upcoming" draw_text.text_style: theme.font_bold{font_size: 21}}
agenda_range := Ink{height: 32 width: Fill padding: Inset{left: 20} draw_text +: {color: secondary text_style: theme.font_regular{font_size: 9.0}}}
upcoming_agenda := mod.widgets.CalendarAgendaList{}
}
mod.widgets.CalendarAgendaHeading = Ink{width: Fill height: 52 padding: Inset{left: 20} text: "Upcoming" draw_text.text_style: theme.font_bold{font_size: 25.5}}
}
#[derive(Clone, Debug, Default)]
pub struct AgendaFrame {
pub events: Vec<PresentedOccurrence>,
pub grouped: bool,
pub empty: String,
pub allow_add: bool,
}
#[derive(Script, ScriptHook, Widget)]
pub struct CalendarAgendaList {
#[deref]
view: View,
#[live]
#[area]
scrolling: ScrollBars,
#[rust]
pub frame: AgendaFrame,
#[rust]
rows: BTreeMap<LiveId, WidgetRef>,
#[rust]
content_height: f64,
}
impl CalendarAgendaList {
pub fn scroll(&self) -> f64 {
self.scrolling.get_scroll_pos().y
}
pub fn set_scroll(&mut self, cx: &mut Cx, y: f64) {
self.scrolling.set_scroll_pos_no_clip(cx, dvec2(0.0, y));
}
fn populate(&mut self, cx: &mut Cx) {
let c = cx.with_vm(CalendarColors::resolve);
self.frame.events.sort_by_cached_key(|e| occurrence_sort_key(&e.occurrence, &e.title, e.occurrence.key.event_id));
let mut children = Vec::new();
let mut last_day = None;
let mut height = 0.0;
for event in &self.frame.events {
let day = event.occurrence.timing.start_day();
if self.frame.grouped && last_day != Some(day) {
let id = LiveId::from_str(&format!("date_{day}"));
let row=self.rows.entry(id).or_insert_with(||cx.with_vm(|vm|{
let v=script_eval!(vm,{use mod.prelude.widgets_internal.* use mod.widgets.* use mod.calendar.* Ink{width:Fill height:32 padding:Inset{left:20 right:20} draw_text.text_style:theme.font_bold{font_size:11.25}}});
WidgetRef::script_from_value(vm,v)
})).clone();
row.as_label()
.set_text(cx, &format_heading_compact_day(day));
row.as_label().set_text_color(cx, c.ink);
children.push((id, row));
height += 32.0;
last_day = Some(day);
}
let id = occurrence_id("occurrence", event.occurrence.key);
let row = self
.rows
.entry(id)
.or_insert_with(|| {
cx.with_vm(|vm| {
let v = script_eval!(vm,{use mod.prelude.widgets_internal.* use mod.widgets.* mod.widgets.EventRow{}});
WidgetRef::script_from_value(vm, v)
})
})
.clone();
target(&row, CalendarAction::Event(event.occurrence.key));
let (start, end) = match event.occurrence.timing {
Timing::AllDay { .. } => ("All-day".to_string(), String::new()),
Timing::Timed { start, end } => (start.format_hm(), end.format_hm()),
};
label(&row, cx, ids!(starts), &start, c.ink);
label(&row, cx, ids!(ends), &end, c.secondary);
label(&row, cx, ids!(title), &event.title, c.ink);
label(
&row,
cx,
ids!(calendar_name),
&event.calendar_name,
c.secondary,
);
let color = c.category(event.colour);
let mut stripe = row.widget(cx, ids!(category_stripe));
script_apply_eval!(cx,stripe,{draw_bg.color: #(color)});
children.push((id, row));
height += 72.0;
}
if children.is_empty() {
let id = live_id!(empty_state);
let row=self.rows.entry(id).or_insert_with(||cx.with_vm(|vm|{
let v=script_eval!(vm,{use mod.prelude.widgets_internal.* use mod.widgets.* use mod.calendar.* Plain{height:120 flow:Down padding:20 spacing:12
empty_title := Ink{width:Fill height:Fit flow:Right{wrap:true} max_lines:0 draw_text.text_style:theme.font_regular{font_size:12.75}}
empty_action := mod.widgets.CalendarHitRow{height:44
text := Ink{text:"Add Event" height:Fill draw_text.color:action}
}
}});WidgetRef::script_from_value(vm,v)
})).clone();
label(&row, cx, ids!(empty_title), &self.frame.empty, c.secondary);
row.widget(cx, ids!(empty_action))
.set_visible(cx, self.frame.allow_add);
target(&row.widget(cx, ids!(empty_action)), CalendarAction::Add);
children.push((id, row));
height = 120.0;
}
self.rows
.retain(|id, _| children.iter().any(|(key, _)| key == id));
let changed = self.view.children.len() != children.len()
|| self
.view
.children
.iter()
.zip(children.iter())
.any(|(a, b)| a.0 != b.0);
self.view.children.clear();
self.view.children.extend(children);
self.content_height = height;
if changed {
cx.widget_tree_mark_dirty(self.widget_uid());
}
}
}
impl Widget for CalendarAgendaList {
fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) {
self.scrolling.handle_event(cx, event, scope);
self.view.handle_event(cx, event, scope);
}
fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep {
self.populate(cx);
self.scrolling.begin(
cx,
walk,
Layout {
flow: Flow::Down,
..Layout::default()
},
);
self.view.draw_walk(
cx,
scope,
Walk {
width: Size::fill(),
height: Size::Fixed(self.content_height),
..Walk::default()
},
)?;
self.scrolling.end(cx);
DrawStep::done()
}
}

310
apps/calendar/src/ai.rs Normal file
View file

@ -0,0 +1,310 @@
//! Assistant tools: `calendar.next` and `calendar.day`. Read-only.
use crate::engine::{occurrences_for_day, qualifying_occurrences};
use crate::model::*;
use crate::view::CalendarView;
use makepad_ai_services::wire::{Risk, ServiceCall, ServiceManifest, ToolDef, ToolResult};
use makepad_civil_time::{self as civil, Day};
use makepad_strict_json::Value;
pub fn manifest() -> ServiceManifest {
ServiceManifest::new(
"calendar",
"Calendar",
"The calendar on screen: upcoming events and the agenda for one civil day.",
)
.with_tool(ToolDef::new(
"next",
"Upcoming events from now through midnight of today+days. Includes events already in progress.",
r#"{"type":"object","properties":{"days":{"type":"integer","minimum":1,"maximum":31}},"required":["days"],"additionalProperties":false}"#,
Risk::Read,
))
.with_tool(ToolDef::new(
"day",
"Every occurrence intersecting one Gregorian civil day.",
r#"{"type":"object","properties":{"date":{"type":"string","description":"YYYY-MM-DD"}},"required":["date"],"additionalProperties":false}"#,
Risk::Read,
))
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ToolAvailability {
Ready,
Unavailable(&'static str),
}
pub fn answer(view: &CalendarView, call: &ServiceCall) -> ToolResult {
match view.tool_availability() {
ToolAvailability::Ready => {}
ToolAvailability::Unavailable(why) => {
return ToolResult::unavailable(&call.call_id, why);
}
}
let Some(doc) = view.document() else {
return ToolResult::unavailable(&call.call_id, "the calendar is still loading");
};
let clock = view.clock();
dispatch(doc, clock, call)
}
pub fn dispatch(doc: &CalendarDocument, clock: ClockSnapshot, call: &ServiceCall) -> ToolResult {
match call.tool.as_str() {
"next" => tool_next(doc, clock, call),
"day" => tool_day(doc, call),
other => ToolResult::refused(
&call.call_id,
format!("unknown tool `{other}`; this app has `next` and `day`"),
),
}
}
fn parse_object<'a>(
args: &'a str,
allowed: &[&str],
) -> Result<Value, String> {
let value = makepad_strict_json::parse(args.as_bytes()).map_err(|e| e.to_string())?;
match &value {
Value::Obj(pairs) => {
for (k, _) in pairs {
if !allowed.iter().any(|a| a == k) {
return Err(format!("unknown field `{k}`"));
}
}
Ok(value)
}
_ => Err("arguments must be an object".into()),
}
}
fn tool_next(doc: &CalendarDocument, clock: ClockSnapshot, call: &ServiceCall) -> ToolResult {
let value = match parse_object(&call.args, &["days"]) {
Ok(v) => v,
Err(e) => return ToolResult::refused(&call.call_id, format!("invalid arguments: {e}")),
};
let days = match value.get("days").and_then(Value::as_i64) {
Some(d) if (1..=31).contains(&d) => d as i32,
Some(_) => {
return ToolResult::refused(&call.call_id, "days must be an integer from 1 to 31");
}
None => {
return ToolResult::refused(&call.call_id, "days is required and must be an integer");
}
};
let range_start_min = clock.now_minute();
let range_end_day = clock.today + days;
let list = qualifying_occurrences(
doc, clock.today, range_end_day, false, TOOL_OCCURRENCE_LIMIT,
|occ| match occ.timing {
Timing::Timed { end, .. } => end > range_start_min,
Timing::AllDay { end_exclusive, .. } => end_exclusive > clock.today,
},
);
envelope(doc, clock.today, range_end_day, list.items, list.truncated, &call.call_id)
}
fn tool_day(doc: &CalendarDocument, call: &ServiceCall) -> ToolResult {
let value = match parse_object(&call.args, &["date"]) {
Ok(v) => v,
Err(e) => return ToolResult::refused(&call.call_id, format!("invalid arguments: {e}")),
};
let date = match value.get("date").and_then(Value::as_str).and_then(civil::parse_iso) {
Some(d) => d,
None => {
return ToolResult::refused(&call.call_id, "date must be a strict Gregorian YYYY-MM-DD");
}
};
let list = occurrences_for_day(doc, date, false, TOOL_OCCURRENCE_LIMIT + 8);
envelope(doc, date, date + 1, list.items, list.truncated, &call.call_id)
}
fn encode_event(doc: &CalendarDocument, occ: &Occurrence) -> Value {
let event = event_by_id(doc, occ.key.event_id);
let cal = event.and_then(|e| calendar_by_id(doc, e.calendar_id));
let (kind, start, end) = match occ.timing {
Timing::Timed { start, end } => (
"timed",
start.format_iso(),
end.format_iso(),
),
Timing::AllDay {
start,
end_exclusive,
} => (
"all_day",
civil::format_iso(start),
civil::format_iso(end_exclusive),
),
};
let mut timing = vec![
("kind".into(), Value::Str(kind.into())),
("start".into(), Value::Str(start)),
];
if kind == "timed" {
timing.push(("end".into(), Value::Str(end)));
} else {
timing.push(("end_exclusive".into(), Value::Str(end)));
}
Value::Obj(vec![
("id".into(), Value::Str(occ.key.as_tool_id())),
(
"master_id".into(),
Value::Int(occ.key.event_id.0 as i64),
),
(
"title".into(),
Value::Str(event.map(|e| e.title.clone()).unwrap_or_default()),
),
(
"calendar_id".into(),
Value::Int(event.map(|e| e.calendar_id.0 as i64).unwrap_or(0)),
),
(
"calendar_name".into(),
Value::Str(cal.map(|c| c.name.clone()).unwrap_or_default()),
),
(
"calendar_visible".into(),
Value::Bool(cal.map(|c| c.visible).unwrap_or(false)),
),
("timing".into(), Value::Obj(timing)),
(
"repeat".into(),
Value::Str(event.map(|e| e.repeat.as_str().to_string()).unwrap_or_else(|| "none".into())),
),
])
}
fn envelope(
doc: &CalendarDocument,
range_start: Day,
range_end: Day,
mut items: Vec<Occurrence>,
already_truncated: bool,
call_id: &str,
) -> ToolResult {
let mut truncated = already_truncated;
if items.len() > TOOL_OCCURRENCE_LIMIT {
items.truncate(TOOL_OCCURRENCE_LIMIT);
truncated = true;
}
let events: Vec<Value> = items.iter().map(|o| encode_event(doc, o)).collect();
let mut payload = Value::Obj(vec![
(
"range_start".into(),
Value::Str(civil::format_iso(range_start)),
),
(
"range_end".into(),
Value::Str(civil::format_iso(range_end)),
),
("events".into(), Value::Arr(events)),
("truncated".into(), Value::Bool(truncated)),
]);
let mut json = payload.to_json();
if json.len() > TOOL_BYTE_LIMIT {
// Drop from the end until it fits, always telling the truth.
let mut n = items.len();
while n > 0 && json.len() > TOOL_BYTE_LIMIT {
n -= 1;
let events: Vec<Value> = items[..n].iter().map(|o| encode_event(doc, o)).collect();
payload = Value::Obj(vec![
(
"range_start".into(),
Value::Str(civil::format_iso(range_start)),
),
(
"range_end".into(),
Value::Str(civil::format_iso(range_end)),
),
("events".into(), Value::Arr(events)),
("truncated".into(), Value::Bool(true)),
]);
json = payload.to_json();
}
truncated = true;
}
let _ = truncated;
ToolResult::ok(call_id, json.clone(), "").with_data(json)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::seed::seed;
fn call(tool: &str, args: &str) -> ServiceCall {
ServiceCall {
call_id: "c1".into(),
tool: tool.into(),
args: args.into(),
}
}
#[test]
fn tool_arguments_ongoing_recurrence_truncation_unavailable() {
let m = manifest();
assert_eq!(m.id, "calendar");
assert!(m.validate().is_ok());
assert_eq!(m.tools.len(), 2);
assert_eq!(m.tools[0].name, "next");
assert_eq!(m.tools[0].risk, Risk::Read);
let doc = seed(civil::from_ymd(2026, 9, 9));
let clock = ClockSnapshot {
today: doc.seed_anchor,
minute: 9 * 60 + 15,
};
let bad = dispatch(&doc, clock, &call("next", r#"{"days":7,"extra":true}"#));
assert_eq!(bad.outcome, makepad_ai_services::wire::ToolOutcome::Refused);
let bad = dispatch(&doc, clock, &call("next", r#"{"days":0}"#));
assert_eq!(bad.outcome, makepad_ai_services::wire::ToolOutcome::Refused);
let bad = dispatch(&doc, clock, &call("day", r#"{"date":"09/09/2026"}"#));
assert_eq!(bad.outcome, makepad_ai_services::wire::ToolOutcome::Refused);
let bad = dispatch(&doc, clock, &call("day", r#"{"date":"2026-09-09","n":1}"#));
assert_eq!(bad.outcome, makepad_ai_services::wire::ToolOutcome::Refused);
let ok = dispatch(&doc, clock, &call("next", r#"{"days":7}"#));
assert_eq!(ok.outcome, makepad_ai_services::wire::ToolOutcome::Ok);
assert!(ok.text.contains("Standup") || ok.data.contains("Standup") || ok.text.contains("range_start"));
// 09:00–10:00 is still ongoing at 09:15.
assert!(ok.text.contains("09:00") || ok.data.contains("09:00"));
let day = dispatch(&doc, clock, &call("day", r#"{"date":"2026-09-09"}"#));
assert_eq!(day.outcome, makepad_ai_services::wire::ToolOutcome::Ok);
assert!(day.text.contains("calendar_visible"));
// Hidden calendars still appear in assistant reads.
let mut hidden = doc.clone();
crate::engine::set_calendar_visible(&mut hidden, CAL_WORK, false);
let day = dispatch(&hidden, clock, &call("day", r#"{"date":"2026-09-09"}"#));
assert!(day.text.contains("\"calendar_visible\":false") || day.data.contains("\"calendar_visible\":false"));
}
#[test]
fn next_filters_expired_events_before_the_limit() {
let today = civil::from_ymd(2026,9,9);
let mut doc = seed(today);
let mut template = doc.events[0].clone();
template.repeat = Repeat::None;
template.timing = Timing::Timed {start:LocalMinute::new(today,60).unwrap(),end:LocalMinute::new(today,120).unwrap()};
doc.events = (1..=208).map(|id| {let mut e=template.clone();e.id=EventId(id);e}).collect();
template.id = EventId(209);
template.title = "Upcoming 日本語".into();
template.timing = Timing::Timed {start:LocalMinute::new(today,600).unwrap(),end:LocalMinute::new(today,660).unwrap()};
doc.events.push(template);
let result = dispatch(&doc,ClockSnapshot{today,minute:540},&call("next",r#"{"days":1}"#));
let json = makepad_strict_json::parse(result.data.as_bytes()).unwrap();
assert!(result.data.contains("Upcoming 日本語"));
assert_eq!(json.get("truncated"),Some(&Value::Bool(false)));
if let Some(Value::Arr(events)) = json.get("events") {assert_eq!(events.len(),1);} else {panic!("events missing")}
}
#[test]
fn day_refuses_multibyte_dates() {
let doc = seed(civil::from_ymd(2026,9,9));
for date in ["202é-09-9", "2026-😀-9", "日本語"] {
let result = dispatch(&doc,ClockSnapshot::default(),&call("day", &format!(r#"{{"date":"{date}"}}"#)));
assert_eq!(result.outcome,makepad_ai_services::wire::ToolOutcome::Refused);
}
}
}

View file

@ -0,0 +1,721 @@
//! Quiet controls and row-owned hit regions shared by Calendar presentations.
use crate::model::*;
use crate::presentation::*;
// The calendar's own colour contrast, not the theme store's: both come in
// through globs and rustc will not pick one for us.
use crate::presentation::contrast;
use crate::presentation::rect;
use makepad_civil_time::Day;
use makepad_widgets::animator::Ease;
use makepad_widgets::makepad_platform::event::TouchState;
use makepad_widgets::*;
use std::collections::HashMap;
script_mod! {
use mod.prelude.widgets_internal.*
use mod.prelude.widgets.*
use mod.widgets.*
use mod.shader.*
use mod.calendar.*
mod.calendar.Plain = View{
width: Fill height: Fill show_bg: false
padding: 0 margin: 0 spacing: 0
}
mod.calendar.Ink = Label{
padding: 0 margin: 0
flow: Right max_lines: 1 text_overflow: Ellipsis
draw_text +: {color: ink text_style: theme.font_regular{font_size: 9.75} font_scale: 1.0}
}
mod.calendar.QuietAction = ButtonFlat{
width: 44 height: 44 text: "" padding: 0 margin: 0
draw_bg +: {pixel: fn(){return vec4(0.0,0.0,0.0,0.0)}}
draw_text +: {
color: ink color_hover: ink color_down: ink
text_style: theme.font_regular{font_size: 9.75}
}
animator +: {hover: {default:@off
off: AnimatorState{ease: Ease.OutCubic from: {all: Forward{duration: 0.12}}}
on: AnimatorState{ease: Ease.OutCubic from: {all: Forward{duration: 0.1} down: Forward{duration: 0.12}}
apply: {draw_bg: {down: 0.0 hover: 1.0} draw_text: {down: 0.0 hover: 1.0}}}
down: AnimatorState{ease: Ease.OutCubic from: {all: Forward{duration: 0.07}}
apply: {draw_bg: {down: 1.0 hover: 1.0} draw_text: {down: 1.0 hover: 1.0}}}
}}
}
mod.calendar.QuietField = TextInput{
width: Fill height: 44 padding: 0 margin: 0
draw_bg +: {pixel: fn(){return vec4(0.0,0.0,0.0,0.0)}}
draw_text +: {color: ink text_style: theme.font_regular{font_size: 12.75}}
}
mod.calendar.QuietChoice = DropDown{
width: Fill height: 44 padding: Inset{left: 0 right: 16} margin: 0
draw_bg +: {pixel: fn(){return vec4(0.0,0.0,0.0,0.0)}}
draw_text +: {color: ink text_style: theme.font_regular{font_size: 12.75}}
}
mod.widgets.CalendarEditorScrollBase = #(CalendarEditorScroll::register_widget(vm))
mod.widgets.CalendarEditorScroll = set_type_default() do mod.widgets.CalendarEditorScrollBase{
width:Fill height:Fill flow:Down padding:16 spacing:16 show_bg:false
scrolling:ScrollBars{show_scroll_x:false show_scroll_y:true}
}
mod.widgets.CalendarControlGroupBase = #(CalendarControlGroup::register_widget(vm))
mod.widgets.CalendarControlGroup = set_type_default() do mod.widgets.CalendarControlGroupBase{
width:Fill height:Fill show_bg:false padding:0 margin:0 spacing:0
draw_wash.color:ink
}
mod.widgets.CalendarHitRowBase = #(CalendarHitRow::register_widget(vm))
mod.widgets.CalendarHitRow = set_type_default() do mod.widgets.CalendarHitRowBase{
width: Fill height: 44 show_bg: false padding: 0 margin: 0 spacing: 0
draw_wash +: {color: ink}
}
mod.widgets.AppRuleBase = #(AppRule::register_widget(vm))
mod.widgets.AppRule = set_type_default() do mod.widgets.AppRuleBase{
width: Fill height: 0.5 draw_line.color: rule
}
mod.widgets.CalendarStripe = SolidView{width: 2 height: 40 margin: 0 padding: 0 draw_bg.color: action}
mod.widgets.CalendarDateMarkBase = #(CalendarDateMark::register_widget(vm))
mod.widgets.CalendarDateMark = set_type_default() do mod.widgets.CalendarDateMarkBase{
width: 26 height: 26
draw_mark +: {
color: selection
focus_color: uniform(focus)
focus: instance(0.0)
checked: instance(0.0)
ring: instance(0.0)
check_color: instance(ink)
pixel: fn(){
let sdf = Sdf2d.viewport(self.pos * self.rect_size)
sdf.circle(self.rect_size.x * 0.5, self.rect_size.y * 0.5, self.rect_size.x * 0.5 - 1.0)
sdf.fill(self.color * (1.0 - self.ring))
sdf.circle(self.rect_size.x * 0.5, self.rect_size.y * 0.5, self.rect_size.x * 0.5 - 1.0)
sdf.stroke(self.color, self.ring)
sdf.circle(self.rect_size.x * 0.5, self.rect_size.y * 0.5, self.rect_size.x * 0.5 - 0.5)
sdf.stroke(self.focus_color, self.focus)
sdf.move_to(self.rect_size.x * 0.25, self.rect_size.y * 0.5)
sdf.line_to(self.rect_size.x * 0.44, self.rect_size.y * 0.68)
sdf.line_to(self.rect_size.x * 0.75, self.rect_size.y * 0.32)
sdf.stroke(self.check_color, self.checked)
return sdf.result
}
}
}
mod.widgets.CalendarDayPresentation = mod.widgets.CalendarHitRow{
width: Fill height: Fill flow: Overlay
date_mark := mod.widgets.CalendarDateMark{}
date_number := mod.calendar.Ink{width: 26 height: 26 align: Align{x: 0.5 y: 0.5}}
events := mod.calendar.Plain{flow: Overlay}
overflow := mod.widgets.CalendarHitRow{visible: false height: 18
label := mod.calendar.Ink{width: Fill height: Fill draw_text +: {color: secondary text_style: theme.font_regular{font_size: 9.0}}}
}
}
mod.widgets.CalendarMiniDate = mod.widgets.CalendarHitRow{
width: 28 height: 24 flow: Overlay
selection_mark := mod.widgets.CalendarDateMark{width: 22 height: 22}
numeral := mod.calendar.Ink{width: Fill height: Fill align: Align{x: 0.5 y: 0.5}}
}
mod.widgets.CalendarEventPresentation = mod.widgets.CalendarHitRow{
height: 18 flow: Right spacing: 4 align: Align{y: 0.5}
category_mark := RoundedView{width:4 height:4 padding:0 margin:0 draw_bg +: {color:action border_radius:1.0}}
event_title := mod.calendar.Ink{width: Fill height: Fill draw_text.text_style: theme.font_regular{font_size: 9.0}}
event_time := mod.calendar.Ink{width: 34 height: Fill align: Align{x: 1.0 y: 0.5} draw_text +: {color: secondary text_style: theme.font_regular{font_size: 8.25}}}
}
mod.widgets.CalendarRibbon = mod.widgets.CalendarHitRow{
height: 18 flow: Right spacing: 6 show_bg: true
draw_bg +: {
color: instance(selection)
radius: instance(1.5)
continuation_left:instance(0.0) continuation_right:instance(0.0)
pixel: fn(){
let sdf = Sdf2d.viewport(self.pos * self.rect_size)
sdf.box(-6.0*self.continuation_left,0.0,self.rect_size.x+6.0*(self.continuation_left+self.continuation_right),self.rect_size.y,self.radius)
sdf.fill(self.color)
return sdf.result
}
}
category_mark := mod.widgets.CalendarStripe{width: 2 height: Fill}
event_title := mod.calendar.Ink{width: Fill height: Fill draw_text.text_style: theme.font_regular{font_size: 9.0}}
event_time := mod.calendar.Ink{visible: false}
}
mod.widgets.EventRow = mod.widgets.CalendarHitRow{
width: Fill height: 72 flow: Down show_bg: false
content := mod.calendar.Plain{
flow: Right padding: Inset{left: 16 right: 16 top: 12 bottom: 12}
times := mod.calendar.Plain{width: 56 flow: Down spacing: 4
starts := mod.calendar.Ink{width: Fill height: 20 draw_text.text_style: theme.font_regular{font_size: 9.75}}
ends := mod.calendar.Ink{width: Fill height: 18 draw_text +: {color: secondary text_style: theme.font_regular{font_size: 9.75}}}
}
time_gap := mod.calendar.Plain{width: 12}
category_stripe := mod.widgets.CalendarStripe{width: 2 height: 40}
title_gap := mod.calendar.Plain{width: 10}
text := mod.calendar.Plain{flow: Down spacing: 4
title := mod.calendar.Ink{width: Fill height: 24 draw_text.text_style: theme.font_bold{font_size: 12.75}}
calendar_name := mod.calendar.Ink{width: Fill height: 18 draw_text.color: secondary}
}
}
separator := mod.widgets.AppRule{height: 0.5 margin: Inset{left: 96 right: 16}}
}
mod.widgets.CalendarRows = mod.calendar.Plain{
height: Fit flow: Down
cal0 := mod.widgets.CalendarHitRow{height: 36 calendar_choice:true flow:Overlay
indicator := mod.widgets.CalendarDateMark{width: 12 height: 12}
calendar_name := mod.calendar.Ink{width: Fill height: Fill align: Align{x:0.0 y:0.5}}
}
cal1 := mod.widgets.CalendarHitRow{height: 36 calendar_choice:true flow:Overlay
indicator := mod.widgets.CalendarDateMark{width: 12 height: 12}
calendar_name := mod.calendar.Ink{width: Fill height: Fill align: Align{x:0.0 y:0.5}}
}
cal2 := mod.widgets.CalendarHitRow{height: 36 calendar_choice:true flow:Overlay
indicator := mod.widgets.CalendarDateMark{width: 12 height: 12}
calendar_name := mod.calendar.Ink{width: Fill height: Fill align: Align{x:0.0 y:0.5}}
}
cal3 := mod.widgets.CalendarHitRow{height: 36 calendar_choice:true flow:Overlay
indicator := mod.widgets.CalendarDateMark{width: 12 height: 12}
calendar_name := mod.calendar.Ink{width: Fill height: Fill align: Align{x:0.0 y:0.5}}
}
}
mod.widgets.AppStorageStatus = mod.calendar.Plain{
height: 28 flow: Right align: Align{y: 0.5} padding: Inset{left: 12 right: 12}
status := mod.calendar.Ink{width: Fill draw_text +: {color: secondary text_style: theme.font_regular{font_size: 8.25}}}
retry := mod.calendar.QuietAction{width: 52 visible: false text: "Retry"}
}
}
fn calendar_row_rects(row: Rect) -> (Rect, Rect) {
(rect(row.pos.x + 16.0, row.pos.y + (row.size.y - 12.0) * 0.5, 12.0, 12.0),
rect(row.pos.x + 36.0, row.pos.y, row.size.x - 52.0, row.size.y))
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub enum CalendarAction {
#[default]
None,
SelectDay(Day),
OpenDay(Day),
Event(OccurrenceKey),
Agenda(Day),
Calendar(usize),
Period(i32),
Create {
day: Day,
minute: u16,
},
Add,
}
#[derive(Script, ScriptHook, Widget)]
pub struct CalendarHitRow {
#[deref]
pub view: View,
#[live]
draw_wash: DrawColor,
#[rust]
pub target: CalendarAction,
#[live(false)]
calendar_choice: bool,
#[rust]
hover: f64,
#[rust]
pressed: bool,
#[rust]
hover_target: f64,
#[rust]
motion: NextFrame,
#[rust]
last_time: f64,
#[rust]
duration: f64,
#[rust]
from: f64,
#[rust]
elapsed: f64,
#[rust]
pub selected: bool,
#[live(false)]
pub reduced_motion: bool,
}
impl CalendarHitRow {
fn animate(&mut self, cx: &mut Cx, target: f64, duration: f64) {
self.from = self.hover;
self.hover_target = target;
self.duration = if self.reduced_motion {
duration.min(0.08)
} else {
duration
};
self.elapsed = 0.0;
self.last_time = 0.0;
self.motion = cx.new_next_frame();
self.view.redraw(cx);
}
}
impl Widget for CalendarHitRow {
fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) {
if !self.visible && event.requires_visibility() {
return;
}
self.view.handle_event(cx, event, scope);
if let Some(e) = self.motion.is_event(event) {
if self.last_time != 0.0 {
self.elapsed += (e.time - self.last_time).max(0.0);
}
self.last_time = e.time;
let p = (self.elapsed / self.duration.max(0.001)).min(1.0);
self.hover = self.from + (self.hover_target - self.from) * Ease::OutCubic.map(p);
if p < 1.0 {
self.motion = cx.new_next_frame();
}
self.view.redraw(cx);
}
if self.target == CalendarAction::None {
return;
}
match event.hits(cx, self.view.area()) {
Hit::FingerDown(_) => {
cx.set_key_focus(self.view.area());
self.pressed = true;
self.animate(cx, 1.0, 0.07);
}
Hit::FingerUp(e) => {
self.pressed = false;
self.animate(cx, 0.0, 0.12);
if e.is_primary_hit() && e.was_tap() {
let target = match self.target {
CalendarAction::SelectDay(d) if e.tap_count >= 2 => {
CalendarAction::OpenDay(d)
}
other => other,
};
cx.widget_action(self.widget_uid(), target);
}
}
Hit::FingerHoverIn(_) | Hit::FingerHoverOver(_) => {
cx.set_cursor(MouseCursor::Hand);
if self.hover_target == 0.0 {
self.animate(cx, 0.5, 0.1);
}
}
Hit::FingerHoverOut(_) => self.animate(cx, 0.0, 0.1),
Hit::KeyDown(e) if matches!(e.key_code, KeyCode::ReturnKey | KeyCode::Space) => {
cx.widget_action(self.widget_uid(), self.target);
}
_ => {}
}
}
fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep {
if self.calendar_choice {
let (indicator, mut name) = calendar_row_rects(cx.peek_walk_turtle(walk));
let label = self.view.widget(cx, ids!(calendar_name));
if let Some(label) = label.borrow::<Label>() {
// DrawText aligns horizontally; center the measured line in this row.
let text = label.text();
let line = label.draw_text.layout(
cx, 0.0, 0.0, Some(name.size.x as f32), false, Align::default(), &text,
);
let height = line.size_in_lpxs.height as f64 * label.draw_text.font_scale as f64;
name.pos.y += (name.size.y - height) * 0.5;
name.size.y = height;
}
place(&self.view.widget(cx, ids!(indicator)), cx, indicator);
place(&label, cx, name);
}
self.view.draw_walk(cx, scope, walk)?;
if self.visible && (self.hover > 0.0 || self.selected) {
let c = cx.with_vm(CalendarColors::resolve);
self.draw_wash.color = Vec4f {
w: (self.hover as f32 * 0.06).max(if self.selected { 0.06 } else { 0.0 }),
..c.ink
};
self.draw_wash.draw_abs(cx, self.view.area().rect(cx));
}
DrawStep::done()
}
}
#[derive(Script, ScriptHook, Widget)]
pub struct AppRule {
#[uid]
uid: WidgetUid,
#[source]
source: ScriptObjectRef,
#[walk]
walk: Walk,
#[live]
draw_line: DrawColor,
#[live(false)]
vertical: bool,
#[live(1.0)]
strength: f32,
// Without it `set_visible(false)` was a no-op and the phone month,
// which hides its rules, drew them anyway.
#[live(true)]
#[visible]
visible: bool,
#[redraw]
#[rust]
area: Area,
}
impl Widget for AppRule {
fn handle_event(&mut self, _cx: &mut Cx, _event: &Event, _scope: &mut Scope) {}
fn draw_walk(&mut self, cx: &mut Cx2d, _scope: &mut Scope, walk: Walk) -> DrawStep {
if !self.visible {
return DrawStep::done();
}
let c = cx.with_vm(CalendarColors::resolve);
cx.begin_turtle(walk, Layout::default());
let mut r = cx.turtle().rect();
self.draw_line.color = mix(c.paper, c.rule, self.strength);
if self.vertical {
r.pos.x = snap_px(cx, r.pos.x);
r.size.x = hairline(cx);
} else {
r.pos.y = snap_px(cx, r.pos.y);
r.size.y = hairline(cx);
}
self.draw_line.draw_abs(cx, r);
cx.end_turtle_with_area(&mut self.area);
DrawStep::done()
}
}
#[derive(Script, ScriptHook, Widget)]
pub struct CalendarDateMark {
#[uid]
uid: WidgetUid,
#[source]
source: ScriptObjectRef,
#[walk]
walk: Walk,
#[live]
pub draw_mark: DrawColor,
#[redraw]
#[rust]
area: Area,
#[rust]
color: Vec4f,
#[rust]
from: Vec4f,
#[rust]
target: Vec4f,
#[rust]
progress: f64,
#[rust]
last: f64,
#[rust]
next: NextFrame,
#[rust]
initialized: bool,
#[rust]
pub focused: bool,
#[rust]
checked: bool,
#[rust]
ring: bool,
#[live(false)]
pub reduced_motion: bool,
}
impl CalendarDateMark {
pub fn set_checked(&mut self, cx: &mut Cx, checked: bool, color: Vec4f) {
self.checked = checked;
self.ring = !checked;
self.set_color(cx, color);
}
pub fn set_color(&mut self, cx: &mut Cx, color: Vec4f) {
if !self.initialized {
self.color = color;
self.initialized = true;
}
if self.target != color {
self.from = self.color;
self.target = color;
self.progress = 0.0;
self.last = 0.0;
self.next = cx.new_next_frame();
self.redraw(cx);
}
}
}
impl Widget for CalendarDateMark {
fn handle_event(&mut self, cx: &mut Cx, event: &Event, _scope: &mut Scope) {
if let Some(e) = self.next.is_event(event) {
if self.last != 0.0 {
self.progress = (self.progress
+ (e.time - self.last) / if self.reduced_motion { 0.08 } else { 0.12 })
.min(1.0);
}
self.last = e.time;
self.color = mix(
self.from,
self.target,
Ease::OutCubic.map(self.progress) as f32,
);
if self.progress < 1.0 {
self.next = cx.new_next_frame();
}
self.redraw(cx);
}
}
fn draw_walk(&mut self, cx: &mut Cx2d, _scope: &mut Scope, walk: Walk) -> DrawStep {
cx.begin_turtle(walk, Layout::default());
self.draw_mark.color = self.color;
let c = cx.with_vm(CalendarColors::resolve);
let check = if contrast(c.paper, self.color) > contrast(c.ink, self.color) {
c.paper
} else {
c.ink
};
self.draw_mark.draw_vars.set_dyn_instance(
cx,
live_id!(checked),
&[if self.checked { 1.0 } else { 0.0 }],
);
self.draw_mark.draw_vars.set_dyn_instance(
cx,
live_id!(ring),
&[if self.ring { 1.0 } else { 0.0 }],
);
self.draw_mark.draw_vars.set_dyn_instance(
cx,
live_id!(focus),
&[if self.focused { 1.0 } else { 0.0 }],
);
self.draw_mark.draw_vars.set_dyn_instance(
cx,
live_id!(check_color),
&[check.x, check.y, check.z, check.w],
);
self.draw_mark.draw_abs(cx, cx.turtle().rect());
cx.end_turtle_with_area(&mut self.area);
DrawStep::done()
}
}
pub fn target(widget: &WidgetRef, action: CalendarAction) {
if let Some(mut row) = widget.borrow_mut::<CalendarHitRow>() {
row.target = action;
}
}
pub fn place(widget: &WidgetRef, cx: &mut Cx, r: Rect) {
let mut widget = widget.clone();
script_apply_eval!(cx, widget, {width: #(r.size.x) height: #(r.size.y) abs_pos: #(r.pos)});
}
pub fn add_child(view: &mut View, cx: &mut Cx, id: LiveId, child: WidgetRef) {
view.children.push((id, child.clone()));
cx.widget_tree_insert_child_deep(view.widget_uid(), id, child);
}
pub fn label(widget: &WidgetRef, cx: &mut Cx, id: &[LiveId], text: &str, color: Vec4f) {
widget.label(cx, id).set_text(cx, text);
widget.label(cx, id).set_text_color(cx, color);
}
#[derive(Default)]
struct ControlWash {
value: f64,
from: f64,
target: f64,
elapsed: f64,
duration: f64,
}
#[derive(Script, ScriptHook, Widget)]
pub struct CalendarControlGroup {
#[deref]
view: View,
#[live]
draw_wash: DrawColor,
#[rust]
controls: HashMap<WidgetUid, ControlWash>,
#[rust]
next: NextFrame,
#[rust]
last: f64,
#[live(false)]
pub reduced_motion: bool,
}
impl CalendarControlGroup {
fn controls(&self) -> Vec<WidgetRef> {
let mut nodes: Vec<_> = self.view.children.iter().map(|(_, w)| w.clone()).collect();
let mut i = 0;
while i < nodes.len() {
let w = nodes[i].clone();
w.try_children(&mut |_, child| nodes.push(child));
i += 1;
}
nodes
.into_iter()
.filter(|w| {
w.borrow::<Button>().is_some()
|| w.borrow::<TextInput>().is_some()
|| w.borrow::<DropDown>().is_some()
})
.collect()
}
}
impl Widget for CalendarControlGroup {
fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) {
self.view.handle_event(cx, event, scope);
if let Some(e) = self.next.is_event(event) {
let dt = if self.last == 0.0 {
0.0
} else {
e.time - self.last
};
self.last = e.time;
let mut moving = false;
for wash in self.controls.values_mut() {
wash.elapsed += dt;
let p = (wash.elapsed / wash.duration.max(0.001)).min(1.0);
wash.value = wash.from + (wash.target - wash.from) * Ease::OutCubic.map(p);
moving |= p < 1.0;
}
if moving {
self.next = cx.new_next_frame();
}
self.view.redraw(cx);
}
let pointer = match event {
Event::MouseMove(e) => Some((e.abs, 0.5, 0.1)),
Event::MouseDown(e) => Some((e.abs, 1.0, 0.07)),
Event::MouseUp(e) => Some((e.abs, 0.5, 0.12)),
// The press itself taken away: the pressed look lets go.
Event::FingerCancel(e) if cx.fingers.press_taken_away(e.digit_id) => Some((e.abs, 0.0, 0.12)),
Event::TouchUpdate(e) => e.touches.first().map(|t| {
(
t.abs,
if t.state == TouchState::Stop {
0.0
} else {
1.0
},
if t.state == TouchState::Stop {
0.12
} else {
0.07
},
)
}),
_ => None,
};
if let Some((point, amount, duration)) = pointer {
for widget in self.controls() {
let area = widget.area().rect(cx);
let target = if area.contains(point) { amount } else { 0.0 };
let wash = self.controls.entry(widget.widget_uid()).or_default();
if wash.target != target {
wash.from = wash.value;
wash.target = target;
wash.elapsed = 0.0;
wash.duration = if self.reduced_motion {
duration.min(0.08)
} else {
duration
};
self.last = 0.0;
self.next = cx.new_next_frame();
}
}
}
}
fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep {
self.view.draw_walk(cx, scope, walk)?;
let c = cx.with_vm(CalendarColors::resolve);
for widget in self.controls() {
let r = widget.area().rect(cx);
if r.size.x <= 0.0 || r.size.y <= 0.0 {
continue;
}
if let Some(wash) = self.controls.get(&widget.widget_uid()) {
if wash.value > 0.0 {
self.draw_wash.color = Vec4f {
w: wash.value as f32 * 0.06,
..c.ink
};
self.draw_wash.draw_abs(cx, r);
}
}
if cx.has_key_focus(widget.area()) {
self.draw_wash.color = c.focus;
self.draw_wash.draw_abs(
cx,
crate::presentation::rect(r.pos.x, r.pos.y + r.size.y - 1.0, r.size.x, 1.0),
);
}
}
DrawStep::done()
}
}
/// The editor owns one scroll viewport for its complete, persistent field tree.
#[derive(Script, ScriptHook, Widget)]
pub struct CalendarEditorScroll {
#[deref]
view: View,
#[live]
#[area]
scrolling: ScrollBars,
#[rust]
pub reveal_focus: bool,
}
impl Widget for CalendarEditorScroll {
fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) {
self.scrolling.handle_event(cx, event, scope);
self.view.handle_event(cx, event, scope);
if matches!(event, Event::KeyFocus(_)) {
self.reveal_focus = true;
self.view.redraw(cx);
}
}
fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep {
let viewport = cx.peek_walk_turtle(walk);
self.scrolling.begin(
cx,
walk,
Layout {
flow: Flow::Down,
..Layout::default()
},
);
self.view.draw_walk(
cx,
scope,
Walk {
width: Size::fill(),
height: Size::fit(),
..Walk::default()
},
)?;
self.scrolling.end(cx);
if self.reveal_focus {
self.reveal_focus = false;
let focus = cx.key_focus();
if !focus.is_empty() {
let r = focus.rect(cx);
if r.size.y > 0.0
&& (r.pos.y < viewport.pos.y + 8.0
|| r.pos.y + r.size.y > viewport.pos.y + viewport.size.y - 8.0)
{
self.scrolling.scroll_into_view_abs(
cx,
crate::presentation::rect(
r.pos.x,
r.pos.y - 8.0,
r.size.x,
r.size.y + 16.0,
),
);
self.view.redraw(cx);
}
}
}
DrawStep::done()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn calendar_check_leads_the_name_on_one_row() {
for (width,height) in [(402.0,44.0),(320.0,44.0),(180.0,36.0)] {
let row=rect(20.0,100.0,width,height);
let (check,name)=calendar_row_rects(row);
assert_eq!(check.pos.y+check.size.y*0.5,name.pos.y+name.size.y*0.5);
assert_eq!(name.pos.x-(check.pos.x+check.size.x),8.0);
assert!(check.pos.y>=row.pos.y && check.pos.y+check.size.y<=row.pos.y+row.size.y);
assert!(name.pos.x+name.size.x<=row.pos.x+row.size.x);
}
}
}

1328
apps/calendar/src/engine.rs Normal file

File diff suppressed because it is too large Load diff

36
apps/calendar/src/lib.rs Normal file
View file

@ -0,0 +1,36 @@
//! calendar as a library: one root widget, a pure engine, and the module
//! the window manager seats in-process. Built from
//! `local/agent_state/wm-all/apps/calendar-redesign.md`.
pub use makepad_widgets;
pub mod agenda;
pub mod ai;
pub mod components;
pub mod engine;
pub mod model;
pub mod module;
pub mod month;
pub mod navigation;
pub mod presentation;
pub mod seed;
pub mod storage;
pub mod timeline;
pub mod view;
pub use module::{CalendarModule, CALENDAR_MODULE};
pub use view::CalendarView;
use makepad_widgets::*;
/// This crate's Splash widgets. The host (or standalone `AppMain`) registers
/// stock widgets first; this only adds Calendar's family.
pub fn script_mod(vm: &mut ScriptVm) {
presentation::script_mod(vm);
components::script_mod(vm);
navigation::script_mod(vm);
month::script_mod(vm);
agenda::script_mod(vm);
timeline::script_mod(vm);
view::script_mod(vm);
}

146
apps/calendar/src/main.rs Normal file
View file

@ -0,0 +1,146 @@
//! calendar — the standalone window.
pub use makepad_widgets;
use makepad_ai_services::port::{AiServicePort, PortEvent};
use makepad_calendar::{ai, view::CalendarView};
use makepad_strict_json::Value;
use makepad_widgets::*;
app_main!(App);
script_mod! {
use mod.prelude.widgets.*
use mod.widgets.*
startup() do #(App::script_component(vm)){
ui: Root{
main_window := Window{
window.title: "Calendar"
window.inner_size: vec2(1240, 800)
pass +: {clear_color: theme.color_bg_app}
body +: {
padding: 0 margin: 0 spacing: 0
calendar := CalendarView{}
}
}
}
}
}
fn is_wm_close_requested(json: &str) -> bool {
let Ok(value) = makepad_strict_json::parse(json.as_bytes()) else {
return false;
};
match value.get("wm") {
Some(Value::Str(s)) => s == "CloseRequested",
_ => false,
}
}
#[derive(Script, ScriptHook)]
pub struct App {
#[live]
ui: WidgetRef,
#[rust]
ai_port: Option<AiServicePort>,
#[rust]
ai_context: String,
}
impl App {
fn ai_summary(&self, cx: &mut Cx) -> String {
self.ui
.widget(cx, ids!(calendar))
.borrow::<CalendarView>()
.map(|view| view.ai_summary())
.unwrap_or_default()
}
fn ai_answer(
&self,
cx: &mut Cx,
call: &makepad_ai_services::wire::ServiceCall,
) -> makepad_ai_services::wire::ToolResult {
self.ui
.widget(cx, ids!(calendar))
.borrow::<CalendarView>()
.map(|view| ai::answer(&view, call))
.unwrap_or_else(|| {
makepad_ai_services::wire::ToolResult::unavailable(&call.call_id, "the calendar is gone")
})
}
fn refresh_ai_context(&mut self, cx: &mut Cx) {
if self.ai_port.is_none() {
return;
}
let text = self.ai_summary(cx);
if text == self.ai_context {
return;
}
self.ai_context = text.clone();
if let Some(port) = self.ai_port.as_ref() {
port.set_context(&text);
}
}
fn drain_ai_port(&mut self, cx: &mut Cx, event: &Event) {
let events = match self.ai_port.as_mut() {
Some(port) => port.handle_event(cx, event),
None => return,
};
for ev in events {
match ev {
PortEvent::Registered(endpoint) => {
log!("calendar: AI service registered as {}", endpoint.as_str());
self.ai_context.clear();
self.refresh_ai_context(cx);
}
PortEvent::Call(call) => {
let result = self.ai_answer(cx, &call);
if let Some(port) = self.ai_port.as_ref() {
port.reply(result);
}
}
PortEvent::Cancel { .. } | PortEvent::ChatOpen { .. } => {}
PortEvent::Subscribe { .. } | PortEvent::Unsubscribe { .. } => {}
}
}
}
}
impl MatchEvent for App {
fn handle_startup(&mut self, cx: &mut Cx) {
self.ai_port = AiServicePort::open(cx, ai::manifest());
if let Some(mut view) = self.ui.widget(cx, ids!(calendar)).borrow_mut::<CalendarView>() {
view.set_storage(cx.storage("calendar"));
}
}
fn handle_actions(&mut self, _cx: &mut Cx, _actions: &Actions) {}
}
impl AppMain for App {
fn script_mod(vm: &mut ScriptVm) -> ScriptValue {
makepad_widgets::script_mod(vm);
makepad_wm_theme::apply(vm);
makepad_calendar::script_mod(vm);
self::script_mod(vm)
}
fn handle_event(&mut self, cx: &mut Cx, event: &Event) {
if let Event::Custom(json) = event {
if is_wm_close_requested(json) {
cx.quit();
return;
}
}
if let Event::WindowClosed(_) = event {
cx.quit();
}
self.drain_ai_port(cx, event);
self.match_event(cx, event);
self.ui.handle_event(cx, event, &mut Scope::empty());
self.refresh_ai_context(cx);
}
}

947
apps/calendar/src/model.rs Normal file
View file

@ -0,0 +1,947 @@
//! Document types, layout decision, local clock, and validation.
use makepad_civil_time::{self as civil, Day};
use std::cmp::Ordering;
pub const SCHEMA_VERSION: u32 = 1;
pub const SEED_VERSION: u32 = 1;
pub const MAX_TITLE_SCALARS: usize = 240;
pub const MAX_NOTES_SCALARS: usize = 4096;
pub const MAX_MASTERS: usize = 1000;
pub const MAX_DURATION_DAYS: i32 = 366;
pub const MAX_QUERY_DAYS: i32 = 366;
pub const UI_EXPAND_CAP: usize = 4096;
pub const SEARCH_LIMIT: usize = 100;
pub const TOOL_OCCURRENCE_LIMIT: usize = 200;
pub const TOOL_BYTE_LIMIT: usize = 64 * 1024;
pub const SEARCH_BACK_DAYS: i32 = 90;
pub const SEARCH_FORWARD_DAYS: i32 = 276;
pub const LAYOUT_COMPACT_BELOW: f64 = 700.0;
pub const LAYOUT_MID_BELOW: f64 = 1040.0;
pub const SHORT_HEIGHT_BELOW: f64 = 420.0;
pub const MINUTES_PER_DAY: u16 = 1440;
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct EventId(pub u32);
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct CalendarId(pub u8);
pub const CAL_HOME: CalendarId = CalendarId(1);
pub const CAL_WORK: CalendarId = CalendarId(2);
pub const CAL_BIRTHDAYS: CalendarId = CalendarId(3);
pub const CAL_HOLIDAYS: CalendarId = CalendarId(4);
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CalendarColour {
Home,
Work,
Birthdays,
Holidays,
}
impl CalendarColour {
pub fn as_str(self) -> &'static str {
match self {
Self::Home => "home",
Self::Work => "work",
Self::Birthdays => "birthdays",
Self::Holidays => "holidays",
}
}
pub fn parse(s: &str) -> Option<Self> {
match s {
"home" => Some(Self::Home),
"work" => Some(Self::Work),
"birthdays" => Some(Self::Birthdays),
"holidays" => Some(Self::Holidays),
_ => None,
}
}
/// Light-theme calendar colour, then dark-theme colour, as 0xRRGGBB.
pub fn rgb_pair(self) -> (u32, u32) {
match self {
Self::Home => (0x3478C5, 0x64A8F5),
Self::Work => (0x8552B8, 0xB68CDF),
Self::Birthdays => (0x2D8556, 0x71C698),
Self::Holidays => (0xA76524, 0xE3A566),
}
}
pub fn rgb(self, dark: bool) -> u32 {
let (light, dark_rgb) = self.rgb_pair();
if dark {
dark_rgb
} else {
light
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Calendar {
pub id: CalendarId,
pub name: String,
pub colour: CalendarColour,
pub visible: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct LocalMinute {
pub day: Day,
pub minute: u16,
}
impl LocalMinute {
pub fn new(day: Day, minute: u16) -> Option<Self> {
if minute < MINUTES_PER_DAY {
Some(Self { day, minute })
} else {
None
}
}
pub fn from_hm(day: Day, hour: u32, minute: u32) -> Option<Self> {
if hour > 23 || minute > 59 {
return None;
}
Self::new(day, (hour * 60 + minute) as u16)
}
pub fn hour(self) -> u32 {
self.minute as u32 / 60
}
pub fn minute_of_hour(self) -> u32 {
self.minute as u32 % 60
}
pub fn add_minutes(self, minutes: i32) -> Option<Self> {
let abs = self.day as i64 * MINUTES_PER_DAY as i64 + self.minute as i64 + minutes as i64;
let day = abs.div_euclid(MINUTES_PER_DAY as i64);
let minute = abs.rem_euclid(MINUTES_PER_DAY as i64) as u16;
if day < i32::MIN as i64 || day > i32::MAX as i64 {
return None;
}
Some(Self {
day: day as Day,
minute,
})
}
pub fn abs_minutes(self) -> i64 {
self.day as i64 * MINUTES_PER_DAY as i64 + self.minute as i64
}
pub fn format_hm(self) -> String {
format!("{:02}:{:02}", self.hour(), self.minute_of_hour())
}
pub fn format_iso(self) -> String {
format!("{}T{}", civil::format_iso(self.day), self.format_hm())
}
pub fn parse_iso(text: &str) -> Option<Self> {
let text = text.trim();
if !text.is_ascii() || text.len() != 16 || text.as_bytes()[10] != b'T' || text.as_bytes()[13] != b':' {
return None;
}
if text.contains('Z') || text.contains('+') || text.ends_with('Z') {
return None;
}
let day = civil::parse_iso(&text[..10])?;
let hour: u32 = text[11..13].parse().ok()?;
let minute: u32 = text[14..16].parse().ok()?;
if !text[11..13].bytes().all(|b| b.is_ascii_digit())
|| !text[14..16].bytes().all(|b| b.is_ascii_digit())
{
return None;
}
Self::from_hm(day, hour, minute)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Timing {
Timed {
start: LocalMinute,
end: LocalMinute,
},
AllDay {
start: Day,
end_exclusive: Day,
},
}
impl Timing {
pub fn start_day(self) -> Day {
match self {
Self::Timed { start, .. } => start.day,
Self::AllDay { start, .. } => start,
}
}
pub fn end_day_exclusive(self) -> Day {
match self {
Self::Timed { end, .. } => {
if end.minute == 0 {
end.day
} else {
end.day + 1
}
}
Self::AllDay { end_exclusive, .. } => end_exclusive,
}
}
pub fn duration_minutes(self) -> Option<i32> {
match self {
Self::Timed { start, end } => {
let d = end.abs_minutes() - start.abs_minutes();
if d > 0 && d <= MAX_DURATION_DAYS as i64 * MINUTES_PER_DAY as i64 {
Some(d as i32)
} else {
None
}
}
Self::AllDay { .. } => None,
}
}
pub fn duration_days(self) -> Option<i32> {
match self {
Self::AllDay {
start,
end_exclusive,
} => {
let d = end_exclusive - start;
if d > 0 && d <= MAX_DURATION_DAYS {
Some(d)
} else {
None
}
}
Self::Timed { .. } => None,
}
}
/// Half-open interval intersection with `[day, day+1)`.
pub fn intersects_day(self, day: Day) -> bool {
self.intersects_range(day, day + 1)
}
pub fn intersects_range(self, start: Day, end_exclusive: Day) -> bool {
match self {
Self::Timed { start: s, end: e } => {
let a = s.abs_minutes();
let b = e.abs_minutes();
let rs = start as i64 * MINUTES_PER_DAY as i64;
let re = end_exclusive as i64 * MINUTES_PER_DAY as i64;
a < re && b > rs
}
Self::AllDay {
start: s,
end_exclusive: e,
} => s < end_exclusive && e > start,
}
}
pub fn shift_start_keep_duration(
self,
new_start_day: Day,
new_minute: Option<u16>,
) -> Option<Self> {
match self {
Self::Timed { start, end } => {
let dur = end.abs_minutes() - start.abs_minutes();
let minute = new_minute.unwrap_or(start.minute);
let new_start = LocalMinute::new(new_start_day, minute)?;
let new_end = new_start.add_minutes(dur as i32)?;
Some(Self::Timed {
start: new_start,
end: new_end,
})
}
Self::AllDay {
start,
end_exclusive,
} => {
let dur = end_exclusive - start;
Some(Self::AllDay {
start: new_start_day,
end_exclusive: new_start_day + dur,
})
}
}
}
pub fn to_all_day(self) -> Option<Self> {
match self {
Self::AllDay { .. } => Some(self),
Self::Timed { start, end } => {
let mut end_day = end.day;
if end.minute > 0 {
end_day += 1;
}
if end_day <= start.day {
end_day = start.day + 1;
}
if end_day - start.day > MAX_DURATION_DAYS {
return None;
}
Some(Self::AllDay {
start: start.day,
end_exclusive: end_day,
})
}
}
}
pub fn to_timed(self, start_minute: u16, end_minute_offset: i32) -> Option<Self> {
match self {
Self::Timed { .. } => Some(self),
Self::AllDay {
start,
end_exclusive,
} => {
let s = LocalMinute::new(start, start_minute)?;
let e = s.add_minutes(end_minute_offset)?;
if e.abs_minutes() <= s.abs_minutes() {
return None;
}
let _ = end_exclusive;
Some(Self::Timed { start: s, end: e })
}
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Repeat {
None,
Daily,
Weekly,
Monthly,
Yearly,
}
impl Repeat {
pub fn as_str(self) -> &'static str {
match self {
Self::None => "none",
Self::Daily => "daily",
Self::Weekly => "weekly",
Self::Monthly => "monthly",
Self::Yearly => "yearly",
}
}
pub fn parse(s: &str) -> Option<Self> {
match s {
"none" => Some(Self::None),
"daily" => Some(Self::Daily),
"weekly" => Some(Self::Weekly),
"monthly" => Some(Self::Monthly),
"yearly" => Some(Self::Yearly),
_ => None,
}
}
pub fn label(self) -> &'static str {
match self {
Self::None => "None",
Self::Daily => "Daily",
Self::Weekly => "Weekly",
Self::Monthly => "Monthly",
Self::Yearly => "Yearly",
}
}
pub fn description(self) -> &'static str {
match self {
Self::None => "",
Self::Daily => "Repeats daily",
Self::Weekly => "Repeats weekly",
Self::Monthly => "Repeats monthly",
Self::Yearly => "Repeats yearly",
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CalendarEvent {
pub id: EventId,
pub calendar_id: CalendarId,
pub title: String,
pub timing: Timing,
pub repeat: Repeat,
pub notes: String,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum CalendarMode {
#[default]
Month,
Week,
Day,
}
impl CalendarMode {
pub fn as_str(self) -> &'static str {
match self {
Self::Month => "month",
Self::Week => "week",
Self::Day => "day",
}
}
pub fn parse(s: &str) -> Option<Self> {
match s {
"month" => Some(Self::Month),
"week" => Some(Self::Week),
"day" => Some(Self::Day),
_ => None,
}
}
pub fn index(self) -> usize {
match self {
Self::Month => 0,
Self::Week => 1,
Self::Day => 2,
}
}
pub fn from_index(i: usize) -> Self {
match i {
1 => Self::Week,
2 => Self::Day,
_ => Self::Month,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Preferences {
pub wide_mode: CalendarMode,
pub default_calendar: CalendarId,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CalendarDocument {
pub schema_version: u32,
pub revision: u32,
pub seed_version: u32,
pub seed_anchor: Day,
pub next_event_id: u32,
pub calendars: Vec<Calendar>,
pub events: Vec<CalendarEvent>,
pub preferences: Preferences,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct OccurrenceKey {
pub event_id: EventId,
pub start_day: Day,
}
impl OccurrenceKey {
pub fn as_tool_id(self) -> String {
format!("{}@{}", self.event_id.0, civil::format_iso(self.start_day))
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Occurrence {
pub key: OccurrenceKey,
pub timing: Timing,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct EventDraft {
pub editing: Option<EventId>,
pub value: CalendarEvent,
pub original: Option<CalendarEvent>,
pub end_was_edited: bool,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct ClockSnapshot {
pub today: Day,
pub minute: u16,
}
impl ClockSnapshot {
pub fn now_minute(self) -> LocalMinute {
LocalMinute {
day: self.today,
minute: self.minute.min(MINUTES_PER_DAY - 1),
}
}
/// Next half-hour boundary strictly after now, used as the default start
/// when creating an event on today.
pub fn next_half_hour(self) -> LocalMinute {
let mut minute = ((self.minute / 30) + 1) * 30;
let mut day = self.today;
if minute >= MINUTES_PER_DAY {
minute -= MINUTES_PER_DAY;
day += 1;
}
LocalMinute { day, minute }
}
pub fn from_epoch_secs(secs: i64) -> Self {
let local = civil::local_or_utc(secs);
let today = local.day_number();
let minute = (local.hour * 60 + local.minute).min(MINUTES_PER_DAY as u32 - 1) as u16;
Self { today, minute }
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum LayoutKind {
Compact,
#[default]
Wide,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct LayoutDecision {
pub kind: LayoutKind,
pub short_height: bool,
pub mid_width: bool,
}
impl LayoutDecision {
/// Widths ≤ 1 are unusable first-draw values: keep the previous decision.
pub fn decide(width: f64, height: f64, previous: Option<Self>) -> Self {
if width <= 1.0 {
return previous.unwrap_or(Self {
kind: LayoutKind::Wide,
short_height: false,
mid_width: false,
});
}
let kind = if width < LAYOUT_COMPACT_BELOW {
LayoutKind::Compact
} else {
LayoutKind::Wide
};
Self {
kind,
short_height: height < SHORT_HEIGHT_BELOW,
mid_width: width >= LAYOUT_COMPACT_BELOW && width < LAYOUT_MID_BELOW,
}
}
pub fn sidebar_width(self) -> f64 {
if self.kind != LayoutKind::Wide || self.short_height {
0.0
} else if self.mid_width {
180.0
} else {
220.0
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct DraftErrors {
pub title: Option<String>,
pub start: Option<String>,
pub end: Option<String>,
pub calendar: Option<String>,
}
impl DraftErrors {
pub fn is_empty(&self) -> bool {
self.title.is_none()
&& self.start.is_none()
&& self.end.is_none()
&& self.calendar.is_none()
}
}
pub fn scalar_len(s: &str) -> usize {
s.chars().count()
}
pub fn validate_title(title: &str) -> Result<String, String> {
let trimmed = title.trim();
if trimmed.is_empty() {
return Err("Title is required".into());
}
if scalar_len(trimmed) > MAX_TITLE_SCALARS {
return Err("Title is too long".into());
}
Ok(trimmed.to_string())
}
pub fn validate_notes(notes: &str) -> Result<String, String> {
if scalar_len(notes) > MAX_NOTES_SCALARS {
return Err("Notes are too long".into());
}
Ok(notes.to_string())
}
pub fn validate_timing(timing: Timing) -> Result<(), String> {
match timing {
Timing::Timed { start, end } => {
if start.minute >= MINUTES_PER_DAY || end.minute >= MINUTES_PER_DAY {
return Err("Invalid time".into());
}
let dur = end.abs_minutes() - start.abs_minutes();
if dur <= 0 {
return Err("End must be after start".into());
}
if dur > MAX_DURATION_DAYS as i64 * MINUTES_PER_DAY as i64 {
return Err("Event is too long".into());
}
Ok(())
}
Timing::AllDay {
start,
end_exclusive,
} => {
let d = end_exclusive - start;
if d <= 0 {
return Err("End must be after start".into());
}
if d > MAX_DURATION_DAYS {
return Err("Event is too long".into());
}
Ok(())
}
}
}
pub fn calendar_by_id(doc: &CalendarDocument, id: CalendarId) -> Option<&Calendar> {
doc.calendars.iter().find(|c| c.id == id)
}
pub fn event_by_id(doc: &CalendarDocument, id: EventId) -> Option<&CalendarEvent> {
doc.events.iter().find(|e| e.id == id)
}
pub fn validate_document(doc: &CalendarDocument) -> Result<(), String> {
if doc.schema_version != SCHEMA_VERSION {
return Err(format!("unsupported schema_version {}", doc.schema_version));
}
if doc.events.len() > MAX_MASTERS {
return Err("too many events".into());
}
let mut seen_cal = std::collections::BTreeSet::new();
for cal in &doc.calendars {
if !seen_cal.insert(cal.id) {
return Err(format!("duplicate calendar id {}", cal.id.0));
}
if cal.name.is_empty() {
return Err("calendar name is empty".into());
}
}
if calendar_by_id(doc, doc.preferences.default_calendar).is_none() {
return Err("default calendar is unknown".into());
}
let mut seen_ev = std::collections::BTreeSet::new();
for event in &doc.events {
if !seen_ev.insert(event.id) {
return Err(format!("duplicate event id {}", event.id.0));
}
if event.id.0 >= doc.next_event_id {
return Err("next_event_id does not follow the masters".into());
}
if calendar_by_id(doc, event.calendar_id).is_none() {
return Err(format!("unknown calendar {}", event.calendar_id.0));
}
validate_title(&event.title)?;
validate_notes(&event.notes)?;
validate_timing(event.timing)?;
}
Ok(())
}
pub fn monday_of(day: Day) -> Day {
day - civil::weekday(day) as Day
}
pub fn month_grid_monday(year: i32, month: u32) -> [[civil::GridDay; 7]; 6] {
civil::month_grid(year, month, 0)
}
/// Shift the displayed month by `delta` months. The selected date clamps
/// into the new month while `anchor_dom` (the original day-of-month) is
/// kept so January 31 → February 28 → March 31.
pub fn shift_month(displayed: Day, _selected: Day, anchor_dom: u32, delta: i32) -> (Day, Day, u32) {
let next = civil::add_months(civil::month_start(displayed), delta);
let (y, m, _) = civil::to_ymd(next);
let dom = anchor_dom.clamp(1, civil::days_in_month(y, m));
(next, civil::from_ymd(y, m, dom), anchor_dom)
}
pub fn shift_week(selected: Day, delta_weeks: i32) -> Day {
civil::add_days(selected, delta_weeks * 7)
}
pub fn format_heading_wide(day: Day) -> (String, String) {
let (y, m, d) = civil::to_ymd(day);
let left = format!("{} {}", civil::month_name(m), d);
(left, y.to_string())
}
pub fn format_heading_compact_day(day: Day) -> String {
let (y, m, d) = civil::to_ymd(day);
let _ = y;
format!(
"{}, {} {}",
civil::weekday_name(civil::weekday(day)),
d,
civil::month_name(m)
)
}
pub fn format_month_year(day: Day) -> String {
let (y, m, _) = civil::to_ymd(day);
format!("{} {y}", civil::month_name(m))
}
pub fn parse_hm(text: &str) -> Option<(u32, u32)> {
let text = text.trim();
let bytes = text.as_bytes();
if bytes.len() != 5 || bytes[2] != b':' {
return None;
}
if !bytes[..2].iter().all(u8::is_ascii_digit) || !bytes[3..].iter().all(u8::is_ascii_digit) {
return None;
}
let hour: u32 = text[..2].parse().ok()?;
let minute: u32 = text[3..].parse().ok()?;
if hour > 23 || minute > 59 {
return None;
}
Some((hour, minute))
}
pub fn snap_minute_15(minute: u16) -> u16 {
((minute + 7) / 15 * 15).min(MINUTES_PER_DAY - 15)
}
pub fn occurrence_sort_key(occ: &Occurrence, title: &str, event_id: EventId) -> OccSort {
let (all_day, start, end) = match occ.timing {
Timing::AllDay {
start,
end_exclusive,
} => (
0u8,
start as i64 * MINUTES_PER_DAY as i64,
end_exclusive as i64 * MINUTES_PER_DAY as i64,
),
Timing::Timed { start, end } => (1u8, start.abs_minutes(), end.abs_minutes()),
};
OccSort {
all_day,
start,
end,
title_folded: title.to_lowercase(),
event_id,
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct OccSort {
pub all_day: u8,
pub start: i64,
pub end: i64,
pub title_folded: String,
pub event_id: EventId,
}
impl PartialOrd for OccSort {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for OccSort {
fn cmp(&self, other: &Self) -> Ordering {
self.start.div_euclid(MINUTES_PER_DAY as i64)
.cmp(&other.start.div_euclid(MINUTES_PER_DAY as i64))
.then(self.all_day.cmp(&other.all_day))
.then(self.start.cmp(&other.start))
.then(self.end.cmp(&other.end))
.then(self.title_folded.cmp(&other.title_folded))
.then(self.event_id.cmp(&other.event_id))
}
}
pub fn mix_rgb(ground: u32, accent: u32, t: f32) -> u32 {
let unpack = |c: u32| -> (f32, f32, f32) {
(
((c >> 16) & 0xff) as f32 / 255.0,
((c >> 8) & 0xff) as f32 / 255.0,
(c & 0xff) as f32 / 255.0,
)
};
let pack = |r: f32, g: f32, b: f32| -> u32 {
let ch = |x: f32| (x.clamp(0.0, 1.0) * 255.0).round() as u32;
(ch(r) << 16) | (ch(g) << 8) | ch(b)
};
let (gr, gg, gb) = unpack(ground);
let (ar, ag, ab) = unpack(accent);
pack(gr + (ar - gr) * t, gg + (ag - gg) * t, gb + (ab - gb) * t)
}
pub fn luminance(rgb: u32) -> f32 {
let r = ((rgb >> 16) & 0xff) as f32 / 255.0;
let g = ((rgb >> 8) & 0xff) as f32 / 255.0;
let b = (rgb & 0xff) as f32 / 255.0;
0.2126 * r + 0.7152 * g + 0.0722 * b
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn layout_at_phone_tablet_and_desktop_widths() {
assert_eq!(
LayoutDecision::decide(402.0, 780.0, None).kind,
LayoutKind::Compact
);
assert_eq!(
LayoutDecision::decide(699.0, 800.0, None).kind,
LayoutKind::Compact
);
let wide = LayoutDecision::decide(700.0, 800.0, None);
assert_eq!(wide.kind, LayoutKind::Wide);
assert!(wide.mid_width);
assert!(!wide.short_height);
let desktop = LayoutDecision::decide(1240.0, 800.0, None);
assert_eq!(desktop.kind, LayoutKind::Wide);
assert!(!desktop.mid_width);
assert_eq!(desktop.sidebar_width(), 220.0);
assert_eq!(wide.sidebar_width(), 180.0);
}
#[test]
fn short_height_and_unusable_first_width() {
let short = LayoutDecision::decide(874.0, 300.0, None);
assert_eq!(short.kind, LayoutKind::Wide);
assert!(short.short_height);
assert!(short.mid_width);
assert_eq!(short.sidebar_width(), 0.0);
let prev = LayoutDecision::decide(1240.0, 800.0, None);
let kept = LayoutDecision::decide(0.0, 0.0, Some(prev));
assert_eq!(kept, prev);
let kept1 = LayoutDecision::decide(1.0, 800.0, Some(prev));
assert_eq!(kept1, prev);
let first = LayoutDecision::decide(0.5, 800.0, None);
assert_eq!(first.kind, LayoutKind::Wide);
}
#[test]
fn monday_first_grids_cover_edges() {
// Year boundary: January 2026 starts on Thursday.
let jan = month_grid_monday(2026, 1);
assert_eq!(jan[0][0].day, civil::from_ymd(2025, 12, 29));
assert!(!jan[0][0].in_month);
assert_eq!(jan[0][3].day, civil::from_ymd(2026, 1, 1));
assert!(jan[0][3].in_month);
// Four-week February 2015 starts on Sunday — still six rows.
let feb = month_grid_monday(2015, 2);
assert_eq!(feb.len(), 6);
assert_eq!(feb.iter().flatten().filter(|c| c.in_month).count(), 28);
// Six-week October 2022 starts on Saturday.
let oct = month_grid_monday(2022, 10);
assert!(oct[0][0].in_month == false);
assert_eq!(oct[0][5].day, civil::from_ymd(2022, 10, 1));
assert_eq!(oct[5][0].day, civil::from_ymd(2022, 10, 31));
// Leap February 2024 starts on Thursday.
let leap = month_grid_monday(2024, 2);
assert_eq!(leap.iter().flatten().filter(|c| c.in_month).count(), 29);
assert_eq!(leap[4][3].day, civil::from_ymd(2024, 2, 29));
}
#[test]
fn month_navigation_keeps_the_day_of_month_anchor() {
let jan31 = civil::from_ymd(2024, 1, 31);
let (feb, sel, anchor) = shift_month(jan31, jan31, 31, 1);
assert_eq!(civil::to_ymd(feb), (2024, 2, 1));
assert_eq!(civil::to_ymd(sel), (2024, 2, 29));
assert_eq!(anchor, 31);
let (mar, sel, anchor) = shift_month(feb, sel, anchor, 1);
assert_eq!(civil::to_ymd(mar), (2024, 3, 1));
assert_eq!(civil::to_ymd(sel), (2024, 3, 31));
assert_eq!(anchor, 31);
}
#[test]
fn timed_strings_and_minutes_reject_seconds_and_zones() {
let m = LocalMinute::parse_iso("2026-09-09T09:00").unwrap();
assert_eq!(m.hour(), 9);
assert_eq!(m.format_iso(), "2026-09-09T09:00");
assert!(LocalMinute::parse_iso("2026-09-09T09:00:00").is_none());
assert!(LocalMinute::parse_iso("2026-09-09T09:00Z").is_none());
assert!(LocalMinute::new(0, 1440).is_none());
assert!(parse_hm("24:00").is_none());
assert_eq!(parse_hm("09:30"), Some((9, 30)));
}
#[test]
fn midnight_end_does_not_occupy_the_next_day() {
let start = LocalMinute::from_hm(10, 22, 0).unwrap();
let end = LocalMinute::from_hm(11, 0, 0).unwrap();
let t = Timing::Timed { start, end };
assert!(t.intersects_day(10));
assert!(!t.intersects_day(11));
}
#[cfg(any(unix, windows))]
#[test]
fn clock_snapshot_is_the_local_day() {
let local = civil::local_or_utc(1_788_698_096);
assert!(local.zoned, "the host must supply a zone");
assert!(local.hour < 24 && local.minute < 60);
let snap = ClockSnapshot::from_epoch_secs(1_788_698_096);
assert_eq!(
civil::to_ymd(snap.today),
(local.year, local.month, local.day)
);
}
#[test]
fn next_half_hour_snaps_forward() {
let c = ClockSnapshot {
today: 0,
minute: 9 * 60 + 5,
};
let n = c.next_half_hour();
assert_eq!(n.minute, 9 * 60 + 30);
let c = ClockSnapshot {
today: 0,
minute: 23 * 60 + 50,
};
let n = c.next_half_hour();
assert_eq!(n.day, 1);
assert_eq!(n.minute, 0);
}
#[test]
fn title_and_notes_scalar_caps() {
assert!(validate_title(" ").is_err());
assert!(validate_title(&"é".repeat(240)).is_ok());
assert!(validate_title(&"é".repeat(241)).is_err());
assert!(validate_notes(&"😀".repeat(4096)).is_ok());
assert!(validate_notes(&"😀".repeat(4097)).is_err());
}
#[test]
fn iso_minutes_and_hm_reject_multibyte_tokens() {
for value in ["2026-09-09Té:00", "2026-09-09T09:é", "202é-09-9T09:00", "2026-09-09T😀00", "日本語"] {
assert!(LocalMinute::parse_iso(value).is_none(), "{value}");
}
for value in ["é:00", "09:é", "😀:", "日:0"] { assert!(parse_hm(value).is_none()); }
}
}

174
apps/calendar/src/module.rs Normal file
View file

@ -0,0 +1,174 @@
//! calendar as a module: the window manager seats one instance per isolate.
use crate::view::CalendarView;
use makepad_ai_services::wire::{ServiceCall, ServiceManifest};
use makepad_app_module::*;
use makepad_widgets::*;
pub struct CalendarModule;
pub static CALENDAR_MODULE: CalendarModule = CalendarModule;
#[cfg(feature = "dynamic-module")]
makepad_app_module::export_app_module!(CALENDAR_MODULE);
impl AppModule for CalendarModule {
fn id(&self) -> &'static str {
"calendar"
}
fn label(&self) -> &'static str {
"Calendar"
}
fn register(&self, vm: &mut ScriptVm) {
crate::script_mod(vm);
}
fn open_schema(&self) -> OpenSchema {
OpenSchema::new(1)
}
fn create(&self, vm: &mut ScriptVm, _open: ValidatedOpen, handles: InstanceHandles) -> InstanceParts {
let value = script_eval!(vm, {
use mod.widgets.*
CalendarView {}
});
let root = WidgetRef::script_from_value(vm, value);
if let Some(mut view) = root.borrow_mut::<CalendarView>() {
view.set_storage(handles.storage);
}
InstanceParts {
root: root.clone(),
executor: Box::new(CalendarExecutor { root }),
shutdown: Box::new(|_vm| {}),
}
}
fn capabilities(&self) -> &'static [&'static str] {
&["storage"]
}
}
struct CalendarExecutor {
root: WidgetRef,
}
impl ServiceExecutor for CalendarExecutor {
fn manifest(&self) -> ServiceManifest {
crate::ai::manifest()
}
fn execute(&mut self, _cx: &mut Cx, call: &ServiceCall) -> ExecOutcome {
let result = self
.root
.borrow::<CalendarView>()
.map(|view| crate::ai::answer(&view, call))
.unwrap_or_else(|| {
makepad_ai_services::wire::ToolResult::unavailable(&call.call_id, "the calendar is gone")
});
ExecOutcome::Done(result)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::ClockSnapshot;
use makepad_civil_time::from_ymd;
#[test]
fn the_module_describes_itself_and_opens_empty() {
let m = &CALENDAR_MODULE;
assert_eq!(m.id(), "calendar");
assert_eq!(m.label(), "Calendar");
assert!(m.capabilities().contains(&"storage"));
let schema = m.open_schema();
assert_eq!(schema.version, 1);
assert!(schema.empty_open().is_ok(), "no argument is required");
assert!(
schema.validate(r#"{"date":"2026-09-09"}"#, &[]).is_err(),
"the schema takes no arguments at all"
);
}
#[test]
fn the_module_mints_its_root_in_a_fresh_isolate() {
let mut cx = Cx::new(Box::new(|_, _| {}));
cx.init_cx_os();
cx.with_vm(makepad_widgets::script_mod);
let vm_id = cx.alloc_splash_vm_with_network(false);
let storage = cx.storage("calendar.test");
let (replies, _upstream) = ReplySink::pair();
let handles = InstanceHandles {
scope: InstanceScope::new(1, 1),
storage,
viewport: Viewport {
size: dvec2(320.0, 200.0),
},
replies,
};
let open = CALENDAR_MODULE.open_schema().empty_open().unwrap();
let InstanceParts {
root,
executor,
shutdown,
} = cx.with_script_vm_id_trusted(vm_id, |vm| {
CALENDAR_MODULE.register(vm);
let parts = CALENDAR_MODULE.create(vm, open, handles);
assert!(
vm.take_errors().is_empty(),
"the isolate evaluated the view without errors"
);
parts
});
assert!(
root.borrow::<CalendarView>().is_some(),
"the root is a CalendarView"
);
let storage2 = cx.storage("calendar.test.b");
let (replies2, _upstream2) = ReplySink::pair();
let handles2 = InstanceHandles {
scope: InstanceScope::new(1, 2),
storage: storage2,
viewport: Viewport {
size: dvec2(320.0, 200.0),
},
replies: replies2,
};
let open2 = CALENDAR_MODULE.open_schema().empty_open().unwrap();
let InstanceParts {
root: root2,
executor: executor2,
shutdown: shutdown2,
} = cx.with_script_vm_id_trusted(vm_id, |vm| {
let parts = CALENDAR_MODULE.create(vm, open2, handles2);
assert!(vm.take_errors().is_empty());
parts
});
{
let mut a = root.borrow_mut::<CalendarView>().unwrap();
let mut b = root2.borrow_mut::<CalendarView>().unwrap();
a.seed_for_test(from_ymd(2026, 9, 9));
b.seed_for_test(from_ymd(2024, 2, 29));
assert_ne!(
a.document().unwrap().seed_anchor,
b.document().unwrap().seed_anchor,
"two roots have independent state"
);
let _ = ClockSnapshot {
today: from_ymd(2026, 9, 9),
minute: 0,
};
}
cx.with_script_vm_id_trusted(vm_id, |vm| shutdown(vm));
cx.with_script_vm_id_trusted(vm_id, |vm| shutdown2(vm));
drop(root);
drop(root2);
drop(executor);
drop(executor2);
cx.free_splash_vm(vm_id);
}
}

772
apps/calendar/src/month.rs Normal file
View file

@ -0,0 +1,772 @@
//! Month and mini-month presentations, with geometry shared by drawing and hits.
use crate::components::*;
use crate::engine::{all_day_lanes_for_days, AllDayRect};
use crate::model::*;
use crate::presentation::rect;
use crate::presentation::*;
use makepad_civil_time::{self as civil, Day};
use makepad_widgets::makepad_platform::event::TouchState;
use makepad_widgets::*;
use std::collections::{BTreeMap, BTreeSet};
script_mod! {
use mod.prelude.widgets_internal.*
use mod.widgets.*
use mod.calendar.*
use mod.shader.*
mod.widgets.CalendarMonthCanvasBase = #(CalendarMonthCanvas::register_widget(vm))
mod.widgets.CalendarMonthCanvas = set_type_default() do mod.widgets.CalendarMonthCanvasBase{
width: Fill height: Fill flow: Overlay show_bg: false
draw_indicator +: {color: action pixel:fn(){let sdf=Sdf2d.viewport(self.pos*self.rect_size) sdf.box(0.0,0.0,self.rect_size.x,self.rect_size.y,0.75) sdf.fill(self.color) return sdf.result}}
draw_weekend +: {color: ink}
}
mod.widgets.CalendarPhoneMonth = mod.widgets.CalendarBodyDeck{active:@dates
dates := mod.widgets.CalendarMonthCanvas{phone:true}
}
mod.widgets.CalendarWeekdaysBase = #(CalendarWeekdays::register_widget(vm))
mod.widgets.CalendarWeekdays = set_type_default() do mod.widgets.CalendarWeekdaysBase{
width: Fill height: 24
draw_text +: {color: secondary text_style: theme.font_regular{font_size: 8.25} max_lines: 1 text_overflow: Ellipsis}
}
mod.widgets.CalendarMiniDatesBase = #(CalendarMiniDates::register_widget(vm))
mod.widgets.CalendarMiniDates = set_type_default() do mod.widgets.CalendarMiniDatesBase{
width: Fill height: 144 flow: Overlay show_bg: false
}
mod.widgets.CalendarMiniMonthBase = #(CalendarMiniMonth::register_widget(vm))
mod.widgets.CalendarMiniMonth = set_type_default() do mod.widgets.CalendarMiniMonthBase{
width: Fill height: 208 flow: Down show_bg: false padding: 0 margin: Inset{left: 12 right: 12 bottom: 16}
mini_header := Plain{height: 44 flow: Right align: Align{y: 0.5}
mini_previous := QuietAction{width: 24 text: "‹"}
mini_title := Ink{width: Fill height: Fill align: Align{x: 0.5 y: 0.5} draw_text.text_style: theme.font_bold{font_size: 9.75}}
mini_next := QuietAction{width: 24 text: "›"}
}
mini_weekdays := mod.widgets.CalendarWeekdays{height: 20 abbreviated: true sidebar: true}
mini_dates := mod.widgets.CalendarMiniDates{}
}
mod.widgets.CalendarMonthPage = Plain{flow: Down show_bg:true draw_bg.color:paper
weekdays := mod.widgets.CalendarWeekdays{height: 24}
month_canvas := mod.widgets.CalendarMonthCanvas{}
}
mod.widgets.CalendarShortMonth = Plain{flow: Down show_bg:true draw_bg.color:paper
weekdays := mod.widgets.CalendarWeekdays{height: 24}
month_scroll := ScrollYView{show_bg: false padding: 0 margin: 0 flow: Down
month_canvas := mod.widgets.CalendarMonthCanvas{height: 384}
}
}
}
#[derive(Clone, Debug, Default)]
pub struct MonthFrame {
pub displayed: Day,
pub selected: Day,
pub today: Day,
pub events: Vec<PresentedOccurrence>,
}
pub fn month_slots(cell_height: f64) -> usize {
((cell_height - 6.0 - 28.0 - 4.0 - 6.0 + 2.0) / 20.0)
.floor()
.max(0.0) as usize
}
#[derive(Debug)]
struct WeekSlots {
ribbons: Vec<AllDayRect>,
timed: Vec<(usize, usize, usize)>, // column, occurrence index, slot
overflow: Vec<Option<(usize, usize)>>, // slot, hidden count
}
fn week_slots(occs: &[Occurrence], days: &[Day], slots: usize) -> WeekSlots {
let counts: Vec<_> = days.iter().map(|d| occs.iter().filter(|o| o.timing.intersects_day(*d)).count()).collect();
let limits: Vec<_> = counts.iter().map(|n| if *n > slots { slots.saturating_sub(1) } else { slots }).collect();
let mut occupied = vec![vec![false; slots]; days.len()];
let mut shown = vec![0; days.len()];
let mut ribbons = Vec::new();
for lane in all_day_lanes_for_days(occs, days) {
let covered = lane.day_index..lane.day_index + lane.day_span;
if covered.clone().any(|d| lane.lane >= limits[d]) { continue; }
for d in covered {
occupied[d][lane.lane] = true;
shown[d] += 1;
}
ribbons.push(lane);
}
let mut timed = Vec::new();
for (col, day) in days.iter().enumerate() {
for (index, occ) in occs.iter().enumerate().filter(|(_, o)| matches!(o.timing, Timing::Timed { .. }) && o.timing.intersects_day(*day)) {
let Some(slot) = (0..limits[col]).find(|slot| !occupied[col][*slot]) else { break; };
let _ = occ;
occupied[col][slot] = true;
shown[col] += 1;
timed.push((col, index, slot));
}
}
let overflow = counts.iter().enumerate().map(|(col, count)| {
let hidden = count - shown[col];
if hidden == 0 { None } else {
(0..slots).rev().find(|slot| !occupied[col][*slot]).map(|slot| (slot, hidden))
}
}).collect();
WeekSlots { ribbons, timed, overflow }
}
fn month_hit(hits: &[(Rect, CalendarAction)], point: Vec2d, min_hit: f64, day: Day) -> CalendarAction {
let mut matched = hits.iter().filter(|(r, _)| {
let w = r.size.x.max(min_hit);
let h = r.size.y.max(min_hit);
rect(r.pos.x - (w-r.size.x)*0.5, r.pos.y - (h-r.size.y)*0.5, w, h).contains(point)
});
let Some((_, action)) = matched.next() else { return CalendarAction::None; };
if matched.next().is_some() { CalendarAction::Agenda(day) } else { *action }
}
#[derive(Script, ScriptHook, Widget)]
pub struct CalendarWeekdays {
#[uid]
uid: WidgetUid,
#[source]
source: ScriptObjectRef,
#[walk]
walk: Walk,
#[live]
draw_text: DrawText,
#[live(false)]
abbreviated: bool,
#[live(0.0)]
inset: f64,
#[live(false)]
sidebar: bool,
#[redraw]
#[rust]
area: Area,
}
impl Widget for CalendarWeekdays {
fn handle_event(&mut self, _cx: &mut Cx, _event: &Event, _scope: &mut Scope) {}
fn draw_walk(&mut self, cx: &mut Cx2d, _scope: &mut Scope, walk: Walk) -> DrawStep {
cx.begin_turtle(walk, Layout::default());
let r = cx.turtle().rect();
let c = cx.with_vm(CalendarColors::resolve);
self.draw_text.color = if self.sidebar { c.on_surface(c.sidebar).secondary } else { c.secondary };
let w = (r.size.x - self.inset * 2.0) / 7.0;
for i in 0..7 {
let day = civil::WEEKDAY_ABBR[i];
let day = if self.abbreviated { &day[..1] } else { day };
text_at(
cx,
&mut self.draw_text,
rect(r.pos.x + self.inset + i as f64 * w, r.pos.y, w, r.size.y),
day,
Align { x: 0.5, y: 0.5 },
);
}
cx.end_turtle_with_area(&mut self.area);
DrawStep::done()
}
}
#[derive(Script, ScriptHook, Widget)]
pub struct CalendarMonthCanvas {
#[deref]
view: View,
#[live(false)]
phone: bool,
#[live]
draw_indicator: DrawColor,
#[live]
draw_weekend: DrawColor,
#[rust]
pub frame: MonthFrame,
#[rust]
swipe_origin: Option<Vec2d>,
#[rust]
event_widgets: BTreeMap<LiveId, WidgetRef>,
#[rust]
hits: Vec<(Rect, CalendarAction)>,
}
impl CalendarMonthCanvas {
fn ensure_children(&mut self, cx: &mut Cx) {
if self.view.children.len() >= 53 {
return;
}
for i in 0..42 {
let child = cx.with_vm(|vm| {
let v = script_eval!(vm,{use mod.prelude.widgets_internal.* use mod.widgets.* mod.widgets.CalendarDayPresentation{}});
WidgetRef::script_from_value(vm, v)
});
add_child(
&mut self.view,
cx,
LiveId::from_str(&format!("day_{i:02}")),
child,
);
}
for i in 0..5 {
let child = cx.with_vm(|vm| {
let v = script_eval!(vm,{use mod.prelude.widgets_internal.* use mod.widgets.* mod.widgets.AppRule{}});
WidgetRef::script_from_value(vm, v)
});
add_child(
&mut self.view,
cx,
LiveId::from_str(&format!("week_rule_{i}")),
child,
);
}
for i in 0..6 {
let child = cx.with_vm(|vm| {
let v = script_eval!(vm,{use mod.prelude.widgets_internal.* use mod.widgets.* mod.widgets.AppRule{vertical:true strength:0.5}});
WidgetRef::script_from_value(vm, v)
});
add_child(
&mut self.view,
cx,
LiveId::from_str(&format!("day_rule_{i}")),
child,
);
}
}
fn event_widget(&mut self, cx: &mut Cx, key: LiveId, ribbon: bool) -> WidgetRef {
if let Some(w) = self.event_widgets.get(&key) {
return w.clone();
}
let w = cx.with_vm(|vm| {
let value = if ribbon {
script_eval!(vm,{use mod.prelude.widgets_internal.* use mod.widgets.* mod.widgets.CalendarRibbon{}})
} else {
script_eval!(vm,{use mod.prelude.widgets_internal.* use mod.widgets.* mod.widgets.CalendarEventPresentation{}})
};
WidgetRef::script_from_value(vm, value)
});
self.event_widgets.insert(key, w.clone());
w
}
fn populate(&mut self, cx: &mut Cx, r: Rect) {
self.ensure_children(cx);
let colors = cx.with_vm(CalendarColors::resolve);
let (year, month, _) = civil::to_ymd(self.frame.displayed);
let days = month_grid_monday(year, month);
let w = r.size.x / 7.0;
let h = r.size.y / 6.0;
let mut used = BTreeSet::new();
self.hits.clear();
// Reparent event presentations by occurrence key; dates and their labels never change identity.
for i in 0..42 {
let day = days[i / 7][i % 7];
let child = self.view.children[i].1.clone();
place(
&child,
cx,
rect(
r.pos.x + (i % 7) as f64 * w,
r.pos.y + (i / 7) as f64 * h,
w,
h,
),
);
target(&child, CalendarAction::SelectDay(day.day));
let size = if self.phone { 32.0 } else { 26.0 };
let x = if self.phone {
(w - size) * 0.5
} else {
w - 6.0 - size
};
let y = if self.phone {
(h - 44.0) * 0.5 + 2.0
} else {
6.0
};
let mark = child.widget(cx, ids!(date_mark));
// Child positions are absolute in the current allocation, never last frame's bounds.
let origin = r.pos + dvec2((i % 7) as f64 * w, (i / 7) as f64 * h);
// Lining digits have no descenders, so their ink sits about a
// fifth of the font size above the centre of the line box they
// are centred in; the today/selected disc rides up by that much
// to centre on them (20 pt phone numerals, 13 pt desktop).
let mark_lift = if self.phone { 4.0 } else { 2.5 };
place(&mark, cx, rect(origin.x + x, origin.y + y - mark_lift, size, size));
let color = if day.day == self.frame.today {
colors.action
} else if day.day == self.frame.selected {
colors.selection
} else {
Vec4f::default()
};
if let Some(mut mark) = mark.borrow_mut::<CalendarDateMark>() {
mark.set_color(cx, color);
mark.focused = cx.has_key_focus(child.area());
}
let numeral = child.widget(cx, ids!(date_number));
place(&numeral, cx, rect(origin.x + x, origin.y + y, size, size));
let mut numeral_style = numeral.clone();
let font_size = if self.phone { 15.0 } else { 9.75 };
script_apply_eval!(cx,numeral_style,{draw_text +: {text_style +: {font_size: #(font_size)}}});
label(
&child,
cx,
ids!(date_number),
&civil::to_ymd(day.day).2.to_string(),
if day.day == self.frame.today {
colors.on_action()
} else if day.in_month {
colors.ink
} else {
colors.secondary
},
);
if let Some(mut events) = child.widget(cx, ids!(events)).borrow_mut::<View>() {
events.children.clear();
}
child.widget(cx, ids!(overflow)).set_visible(cx, false);
}
// Horizontal/vertical rules belong to the canvas, with no outer border.
for i in 0..11 {
let rule = self.view.children[42 + i].1.clone();
rule.set_visible(cx, !self.phone);
if i < 5 {
place(
&rule,
cx,
rect(r.pos.x, r.pos.y + (i + 1) as f64 * h, r.size.x, 0.5),
);
} else {
place(
&rule,
cx,
rect(r.pos.x + (i - 4) as f64 * w, r.pos.y, 1.0, r.size.y),
);
}
}
// Drop last allocation's direct ribbon children before adding this allocation's ribbons.
self.view.children.truncate(53);
if !self.phone {
let slots = month_slots(h);
for week in 0..6 {
let week_days: Vec<Day> = days[week].iter().map(|d| d.day).collect();
let events: Vec<_> = self
.frame
.events
.iter()
.filter(|e| {
e.occurrence
.timing
.intersects_range(week_days[0], week_days[0] + 7)
})
.cloned()
.collect();
let occs: Vec<_> = events.iter().map(|e| e.occurrence).collect();
let layout = week_slots(&occs, &week_days, slots);
for lane in &layout.ribbons {
let Some(event) = events.iter().find(|e| e.occurrence.key == lane.key) else {
continue;
};
let start = lane.day_index;
let key =
LiveId::from_str(&format!("ribbon_{}_week_{week}", lane.key.as_tool_id()));
let widget = self.event_widget(cx, key, true);
used.insert(key);
target(&widget, CalendarAction::Event(lane.key));
set_event(&widget, cx, event, colors, true);
let left = if event.occurrence.timing.start_day() < week_days[0] {
0.0
} else {
6.0
};
let right = if event.occurrence.timing.end_day_exclusive() > week_days[6] + 1 {
0.0
} else {
6.0
};
let mut style = widget.clone();
let continuation_left = if left == 0.0 { 1.0 } else { 0.0 };
let continuation_right = if right == 0.0 { 1.0 } else { 0.0 };
script_apply_eval!(cx,style,{draw_bg +: {continuation_left:#(continuation_left) continuation_right:#(continuation_right)}});
place(
&widget,
cx,
rect(
r.pos.x + start as f64 * w + left,
r.pos.y + week as f64 * h + 38.0 + lane.lane as f64 * 20.0,
lane.day_span as f64 * w - left - right,
18.0,
),
);
add_child(&mut self.view, cx, key, widget);
self.hits.push((rect(
r.pos.x + start as f64 * w + left,
r.pos.y + week as f64 * h + 38.0 + lane.lane as f64 * 20.0,
lane.day_span as f64 * w - left - right, 18.0,
), CalendarAction::Event(lane.key)));
}
for col in 0..7 {
let child = self.view.children[week * 7 + col].1.clone();
let day = week_days[col];
for &(_, index, slot) in layout.timed.iter().filter(|(d, _, _)| *d == col) {
let event = &events[index];
let key = LiveId::from_str(&format!(
"event_{}_day_{day}",
event.occurrence.key.as_tool_id()
));
let widget = self.event_widget(cx, key, false);
used.insert(key);
target(&widget, CalendarAction::Event(event.occurrence.key));
set_event(&widget, cx, event, colors, w > 110.0);
place(
&widget,
cx,
rect(
r.pos.x + col as f64 * w + 6.0,
r.pos.y + week as f64 * h + 38.0 + slot as f64 * 20.0,
w - 12.0,
18.0,
),
);
if let Some(mut event_view) =
child.widget(cx, ids!(events)).borrow_mut::<View>()
{
add_child(&mut event_view, cx, key, widget);
}
self.hits.push((rect(
r.pos.x + col as f64 * w + 6.0,
r.pos.y + week as f64 * h + 38.0 + slot as f64 * 20.0,
w - 12.0, 18.0,
), CalendarAction::Event(event.occurrence.key)));
}
if let Some((slot, more)) = layout.overflow[col] {
let overflow = child.widget(cx, ids!(overflow));
overflow.set_visible(cx, true);
target(&overflow, CalendarAction::Agenda(day));
label(
&overflow,
cx,
ids!(label),
&format!("+{more} more"),
colors.secondary,
);
place(
&overflow,
cx,
rect(
r.pos.x + col as f64 * w + 6.0,
r.pos.y
+ week as f64 * h
+ 38.0
+ slot as f64 * 20.0,
w - 12.0,
18.0,
),
);
self.hits.push((rect(
r.pos.x + col as f64 * w + 6.0,
r.pos.y + week as f64 * h + 38.0 + slot as f64 * 20.0,
w - 12.0, 18.0,
), CalendarAction::Agenda(day)));
}
}
}
}
self.event_widgets.retain(|id, _| used.contains(id));
cx.widget_tree_mark_dirty(self.widget_uid());
}
}
pub fn set_event(
widget: &WidgetRef,
cx: &mut Cx,
event: &PresentedOccurrence,
colors: CalendarColors,
show_time: bool,
) {
label(widget, cx, ids!(event_title), &event.title, colors.ink);
let time = match event.occurrence.timing {
Timing::Timed { start, .. } => start.format_hm(),
_ => String::new(),
};
label(widget, cx, ids!(event_time), &time, colors.secondary);
widget
.widget(cx, ids!(event_time))
.set_visible(cx, show_time && !time.is_empty());
let mut stripe = widget.widget(cx, ids!(category_mark));
let color = colors.category(event.colour);
script_apply_eval!(cx,stripe,{draw_bg.color: #(color)});
if matches!(event.occurrence.timing, Timing::AllDay { .. }) {
let mut widget = widget.clone();
let color = colors.tint(event.colour);
script_apply_eval!(cx,widget,{draw_bg.color: #(color)});
}
}
impl Widget for CalendarMonthCanvas {
fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) {
// Claim the canvas before child rows, so expanded touch regions are resolved together.
match event.hits(cx, self.view.area()) {
Hit::FingerDown(_) => cx.set_key_focus(self.view.area()),
Hit::FingerUp(e) if e.is_primary_hit() && e.was_tap() => {
let r = self.view.area().rect(cx);
let (year, month, _) = civil::to_ymd(self.frame.displayed);
let grid = month_grid_monday(year, month);
let col = ((e.abs.x - r.pos.x) / (r.size.x / 7.0)).floor().clamp(0.0, 6.0) as usize;
let row = ((e.abs.y - r.pos.y) / (r.size.y / 6.0)).floor().clamp(0.0, 5.0) as usize;
let day = grid[row][col].day;
let action = month_hit(&self.hits, e.abs, if e.device.is_touch() {44.0} else {0.0}, day);
cx.widget_action(self.widget_uid(), if action != CalendarAction::None { action }
else if e.tap_count >= 2 { CalendarAction::OpenDay(day) }
else { CalendarAction::SelectDay(day) });
}
_ => {}
}
self.view.handle_event(cx, event, scope);
if !self.phone {
return;
}
let r = self.view.area().rect(cx);
let mut finish = None;
match event {
Event::MouseDown(e) if r.contains(e.abs) => self.swipe_origin = Some(e.abs),
Event::MouseUp(e) => finish = Some(e.abs),
// The press itself taken away swipes nowhere.
Event::FingerCancel(e) if cx.fingers.press_taken_away(e.digit_id) => self.swipe_origin = None,
Event::TouchUpdate(e) => {
for t in &e.touches {
match t.state {
TouchState::Start if r.contains(t.abs) => self.swipe_origin = Some(t.abs),
TouchState::Stop => finish = Some(t.abs),
_ => {}
}
}
}
_ => {}
}
if let Some(p) = finish {
if let Some(origin) = self.swipe_origin.take() {
let delta = p - origin;
if delta.x.abs() > 60.0 && delta.x.abs() > 1.5 * delta.y.abs() {
cx.widget_action(
self.widget_uid(),
CalendarAction::Period(if delta.x < 0.0 { 1 } else { -1 }),
);
}
}
}
}
fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep {
let r = cx.peek_walk_turtle(walk);
self.populate(cx, r);
let c = cx.with_vm(CalendarColors::resolve);
let w = r.size.x / 7.0;
let h = r.size.y / 6.0;
if !self.phone {
self.draw_weekend.color = Vec4f { w: 0.02, ..c.ink };
self.draw_weekend
.draw_abs(cx, rect(r.pos.x + 5.0 * w, r.pos.y, 2.0 * w, r.size.y));
}
self.view.draw_walk(cx, scope, walk)?;
if self.phone {
let (y, m, _) = civil::to_ymd(self.frame.displayed);
let days = month_grid_monday(y, m);
for i in 0..42 {
let day = days[i / 7][i % 7].day;
let mut categories = Vec::new();
let mut count = 0;
for e in self
.frame
.events
.iter()
.filter(|e| e.occurrence.timing.intersects_day(day))
{
count += 1;
if !categories.contains(&e.colour) {
categories.push(e.colour);
}
}
let visible = categories.len().min(3);
let width = visible as f64 * 8.0 - 2.0;
for (j, cat) in categories.iter().take(3).enumerate() {
self.draw_indicator.color = c.category(*cat);
self.draw_indicator.draw_abs(
cx,
rect(
r.pos.x + (i % 7) as f64 * w + (w - width) * 0.5 + j as f64 * 8.0,
r.pos.y + (i / 7) as f64 * h + (h - 44.0) * 0.5 + 37.0,
6.0,
3.0,
),
);
}
if count > 3 {
self.draw_indicator.color = c.secondary;
self.draw_indicator.draw_abs(
cx,
rect(
r.pos.x + (i % 7) as f64 * w + (w + width) * 0.5 + 3.0,
r.pos.y + (i / 7) as f64 * h + (h - 44.0) * 0.5 + 37.0,
2.0,
3.0,
),
);
}
}
}
DrawStep::done()
}
}
#[derive(Script, ScriptHook, Widget)]
pub struct CalendarMiniDates {
#[deref]
view: View,
#[rust]
pub frame: MonthFrame,
}
impl Widget for CalendarMiniDates {
fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) {
self.view.handle_event(cx, event, scope);
}
fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep {
if self.view.children.is_empty() {
for i in 0..42 {
let w = cx.with_vm(|vm| {
let v = script_eval!(vm,{use mod.prelude.widgets_internal.* use mod.widgets.* mod.widgets.CalendarMiniDate{}});
WidgetRef::script_from_value(vm, v)
});
add_child(
&mut self.view,
cx,
LiveId::from_str(&format!("date_{i:02}")),
w,
);
}
}
let r = cx.peek_walk_turtle(walk);
let c = cx.with_vm(CalendarColors::resolve);
let c = c.on_surface(c.sidebar);
let (y, m, _) = civil::to_ymd(self.frame.displayed);
let days = month_grid_monday(y, m);
for i in 0..42 {
let gd = days[i / 7][i % 7];
let w = self.view.children[i].1.clone();
let width = r.size.x / 7.0;
let origin = r.pos + dvec2((i % 7) as f64 * width, (i / 7) as f64 * 24.0);
place(&w, cx, rect(origin.x, origin.y, width, 24.0));
target(&w, CalendarAction::SelectDay(gd.day));
let mark = w.widget(cx, ids!(selection_mark));
place(
&mark,
cx,
rect(origin.x + (width - 22.0) * 0.5, origin.y + 1.0, 22.0, 22.0),
);
let color = if gd.day == self.frame.today {
c.action
} else if gd.day == self.frame.selected {
c.selection
} else {
Vec4f::default()
};
if let Some(mut mark) = mark.borrow_mut::<CalendarDateMark>() {
mark.set_color(cx, color);
}
label(
&w,
cx,
ids!(numeral),
&civil::to_ymd(gd.day).2.to_string(),
if gd.day == self.frame.today {
c.on_action()
} else if gd.in_month {
c.ink
} else {
c.secondary
},
);
}
self.view.draw_walk(cx, scope, walk)
}
}
#[derive(Script, ScriptHook, Widget)]
pub struct CalendarMiniMonth {
#[deref]
view: View,
#[rust]
pub frame: MonthFrame,
}
impl Widget for CalendarMiniMonth {
fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) {
self.view.handle_event(cx, event, scope);
if let Event::Actions(a) = event {
if self.view.button(cx, ids!(mini_previous)).clicked(a) {
cx.widget_action(self.widget_uid(), CalendarAction::Period(-1));
}
if self.view.button(cx, ids!(mini_next)).clicked(a) {
cx.widget_action(self.widget_uid(), CalendarAction::Period(1));
}
}
}
fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep {
self.view
.label(cx, ids!(mini_title))
.set_text(cx, &format_month_year(self.frame.displayed));
if let Some(mut dates) = self
.view
.widget(cx, ids!(mini_dates))
.borrow_mut::<CalendarMiniDates>()
{
dates.frame = self.frame.clone();
}
self.view.draw_walk(cx, scope, walk)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn slots_respect_cell_geometry_and_overflow_reservation() {
assert_eq!(month_slots(112.0), 3);
assert_eq!(month_slots(64.0), 1);
assert_eq!(month_slots(44.0), 0);
let d = civil::from_ymd(2026, 9, 9);
let doc = crate::seed::seed(d);
let events = crate::engine::occurrences_for_day(&doc, d, true, UI_EXPAND_CAP);
let (shown, extra) = crate::engine::day_chips(&events.items, month_slots(112.0));
assert_eq!(shown.len() + extra, events.items.len());
assert!(shown.len() + usize::from(extra > 0) <= 3);
}
#[test]
fn sparse_ribbon_lanes_leave_timed_and_overflow_slots_free() {
let monday = civil::from_ymd(2026,9,7);
let days: Vec<_> = (0..7).map(|i| monday+i).collect();
let mut occs = Vec::new();
for (id, start, end) in [(1,0,2),(2,0,4),(3,1,7)] {
occs.push(Occurrence {key:OccurrenceKey{event_id:EventId(id),start_day:monday+start},timing:Timing::AllDay{start:monday+start,end_exclusive:monday+end}});
}
let day = monday+2;
occs.push(Occurrence { key:OccurrenceKey{event_id:EventId(4),start_day:day},timing:Timing::Timed{start:LocalMinute::new(day,540).unwrap(),end:LocalMinute::new(day,600).unwrap()} });
let layout = week_slots(&occs,&days,3);
assert!(layout.timed.contains(&(2,3,1)), "timed event fills the gap between ribbon lanes 0 and 2");
assert_eq!(layout.overflow[2],None);
occs.push(Occurrence{key:OccurrenceKey{event_id:EventId(5),start_day:day},..occs[3]});
for slots in 0..=4 {
let layout = week_slots(&occs,&days,slots);
for col in 0..7 {
let mut taken = std::collections::BTreeSet::new();
for ribbon in &layout.ribbons {
if (ribbon.day_index..ribbon.day_index+ribbon.day_span).contains(&col) {assert!(taken.insert(ribbon.lane));}
}
for &(d,_,slot) in &layout.timed {if d==col {assert!(taken.insert(slot));}}
let count = occs.iter().filter(|o| o.timing.intersects_day(days[col])).count();
if let Some((slot,hidden)) = layout.overflow[col] {
assert_eq!(taken.len()+hidden,count);
assert!(taken.insert(slot), "overflow must never overlap a ribbon");
} else if slots>0 {assert_eq!(taken.len(),count);}
assert!(taken.iter().all(|slot| *slot<slots));
}
}
}
#[test]
fn month_touch_targets_expand_and_ambiguous_hits_open_the_day_agenda() {
let day=20;
let key=OccurrenceKey{event_id:EventId(1),start_day:day};
let mut hits=vec![(rect(100.0,38.0,100.0,18.0),CalendarAction::Event(key))];
assert_eq!(month_hit(&hits,dvec2(110.0,25.0),44.0,day),CalendarAction::Event(key));
assert_eq!(month_hit(&hits,dvec2(110.0,25.0),0.0,day),CalendarAction::None);
hits.push((rect(100.0,58.0,100.0,18.0),CalendarAction::Agenda(day)));
assert_eq!(month_hit(&hits,dvec2(110.0,55.0),44.0,day),CalendarAction::Agenda(day));
assert_eq!(month_hit(&hits,dvec2(110.0,55.0),0.0,day),CalendarAction::Event(key));
assert_eq!(month_hit(&hits,dvec2(110.0,88.0),44.0,day),CalendarAction::Agenda(day));
}
}

View file

@ -0,0 +1,477 @@
//! Exclusive body dispatch and viewport-owned overlay placement.
use crate::presentation::rect;
use crate::presentation::*;
use makepad_widgets::animator::Ease;
use makepad_widgets::*;
script_mod! {
use mod.prelude.widgets_internal.*
use mod.widgets.*
use mod.calendar.*
mod.widgets.CalendarBodyDeckBase = #(CalendarBodyDeck::register_widget(vm))
mod.widgets.CalendarBodyDeck = set_type_default() do mod.widgets.CalendarBodyDeckBase{
width:Fill height:Fill flow:Overlay show_bg:false texture_caching:true
active: @month
draw_bg +: {image:texture_2d(float) pixel:fn(){return self.image.sample(self.pos)}}
draw_old +: {image:texture_2d(float) opacity:uniform(1.0) pixel:fn(){return self.image.sample(self.pos)*self.opacity}}
draw_paper.color:paper
}
mod.widgets.CalendarOverlayHostBase = #(CalendarOverlayHost::register_widget(vm))
mod.widgets.CalendarOverlayHost = set_type_default() do mod.widgets.CalendarOverlayHostBase{
width:Fill height:Fill flow:Overlay show_bg:false texture_caching:true
draw_bg +: {image:texture_2d(float) opacity:uniform(1.0) pixel:fn(){return self.image.sample(self.pos)*self.opacity}}
}
mod.widgets.CalendarStackPage = StackNavigationView{
show_bg:true draw_bg.color:paper
offset:0.0
header +: {
height:52 padding:Inset{left:16 right:16 top:4 bottom:4}
draw_bg.color:paper
content +: {height:Fill show_bg:false
title_container +: {height:Fill show_bg:false
title +: {padding:0 margin:0 draw_text +: {color:ink text_style:theme.font_bold{font_size:12.75}}}
}
button_container +: {height:Fill show_bg:false
left_button +: {width:44 height:44 padding:0 margin:0
draw_bg +: {pixel:fn(){return vec4(0.0,0.0,0.0,0.0)}}
draw_icon.color:action
}
}
right_container := Plain{width:Fill height:Fill align:Align{x:1.0 y:0.5}
header_action := QuietAction{width:72 text:"Done" draw_text.color:action}
}
}
}
body +: {margin:Inset{top:52} padding:0 show_bg:true draw_bg.color:paper}
animator +: {slide: {default:@hide
hide: AnimatorState{ease:Ease.Bezier{cp0:0.2 cp1:0.0 cp2:0.0 cp3:1.0} from:{all:Forward{duration:0.2}} apply:{offset:1.0}}
show: AnimatorState{ease:Ease.Bezier{cp0:0.2 cp1:0.0 cp2:0.0 cp3:1.0} from:{all:Forward{duration:0.24}} apply:{offset:0.0}}
}}
}
}
fn curve() -> Ease {
Ease::Bezier {
cp0: 0.2,
cp1: 0.0,
cp2: 0.0,
cp3: 1.0,
}
}
#[derive(Script, ScriptHook, Widget)]
pub struct CalendarBodyDeck {
#[deref]
view: View,
#[live]
active: LiveId,
#[live]
draw_old: DrawQuad,
#[live]
draw_paper: DrawColor,
#[live(false)]
pub reduced_motion: bool,
#[rust]
frozen: Vec<(ViewTextureSnapshot, f32, f64)>,
#[rust]
progress: f64,
#[rust]
last: f64,
#[rust]
next: NextFrame,
#[rust]
duration: f64,
#[rust]
direction: f64,
}
impl CalendarBodyDeck {
pub fn activate(&mut self, cx: &mut Cx, id: LiveId) {
if self.active == id {
return;
}
self.transition(cx, 0);
self.active = id;
self.view.redraw(cx);
}
pub fn cut(&mut self, cx: &mut Cx) {
self.frozen.clear();
self.progress = 1.0;
self.last = 0.0;
self.direction = 0.0;
self.view.redraw(cx);
}
pub fn transition(&mut self, cx: &mut Cx, direction: i32) {
if let Some(frame) = self.view.take_texture_snapshot(cx) {
let t = curve().map(self.progress) as f32;
for (_, weight, offset) in &mut self.frozen {
*weight *= 1.0 - t;
*offset -= self.direction * t as f64;
}
let weight = if self.frozen.is_empty() { 1.0 } else { t };
self.frozen
.push((frame, weight, self.direction * (1.0 - t as f64)));
self.frozen.retain(|(_, weight, _)| *weight > 0.001);
if self.frozen.len() > 4 {
self.frozen.remove(0);
}
let sum: f32 = self.frozen.iter().map(|(_, w, _)| w).sum();
for (_, w, _) in &mut self.frozen {
*w /= sum.max(0.001);
}
self.progress = 0.0;
self.last = 0.0;
self.next = cx.new_next_frame();
}
self.direction = if self.reduced_motion {
0.0
} else {
direction as f64
};
self.duration = if self.reduced_motion {
0.08
} else if direction == 0 {
0.12
} else {
0.18
};
self.view.redraw(cx);
}
}
impl Widget for CalendarBodyDeck {
fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) {
if let Some(e) = self.next.is_event(event) {
if self.last != 0.0 {
self.progress =
(self.progress + (e.time - self.last) / self.duration.max(0.001)).min(1.0);
}
self.last = e.time;
if self.progress < 1.0 {
self.next = cx.new_next_frame();
} else {
self.frozen.clear();
}
self.view.redraw(cx);
}
// Outgoing cached pixels never dispatch; only the selected child is live.
if let Some((_, child)) = self.view.children.iter().find(|(id, _)| *id == self.active) {
child.handle_event(cx, event, scope);
}
}
fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep {
let r = cx.peek_walk_turtle(walk);
let t = if self.frozen.is_empty() {
1.0
} else {
curve().map(self.progress)
};
let slide = self.direction != 0.0 && !self.frozen.is_empty();
cx.begin_turtle(walk, Layout::default());
self.draw_paper.color = cx.with_vm(CalendarColors::resolve).paper;
self.draw_paper.draw_abs(cx, r);
if slide {
for (frame, weight, offset) in &self.frozen {
self.draw_old.draw_vars.set_texture(0, frame.texture());
self.draw_old
.draw_vars
.set_uniform(cx, live_id!(opacity), &[*weight]);
self.draw_old.draw_abs(
cx,
rect(
r.pos.x + (offset - self.direction * t) * r.size.x,
r.pos.y,
r.size.x,
r.size.y,
),
);
}
}
let all = std::mem::take(&mut self.view.children);
self.view
.children
.extend(all.iter().filter(|(id, _)| *id == self.active).cloned());
let incoming = if slide {
self.direction * (1.0 - t) * r.size.x
} else {
0.0
};
// Both the pixels and the live incoming hit geometry travel within the same clipped viewport.
self.view.draw_walk(
cx,
scope,
fixed(rect(r.pos.x + incoming, r.pos.y, r.size.x, r.size.y)),
)?;
self.view.children = all;
if !slide {
let mut accumulated = t as f32;
for (frame, weight, _) in &self.frozen {
let contribution = weight * (1.0 - t as f32);
accumulated += contribution;
self.draw_old.draw_vars.set_texture(0, frame.texture());
self.draw_old.draw_vars.set_uniform(
cx,
live_id!(opacity),
&[contribution / accumulated.max(0.00001)],
);
self.draw_old.draw_abs(cx, r);
}
}
cx.end_turtle();
DrawStep::done()
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub enum OverlayKind {
#[default]
None,
Detail,
Editor,
Search,
Calendars,
Confirmation,
Agenda,
}
#[derive(Clone, Debug, Default)]
pub enum OverlayAction {
#[default]
None,
Dismiss,
}
pub fn overlay_rect(
bounds: Rect,
kind: OverlayKind,
compact: bool,
short: bool,
anchor: Rect,
) -> Rect {
if short {
return bounds;
}
if compact {
return if kind == OverlayKind::Editor {
rect(
bounds.pos.x,
bounds.pos.y + 20.0,
bounds.size.x,
bounds.size.y - 20.0,
)
} else {
bounds
};
}
let (w, h) = match kind {
OverlayKind::Editor => (480.0, 640.0),
OverlayKind::Calendars => (320.0, 252.0),
OverlayKind::Confirmation => (320.0, 188.0),
_ => (360.0, 600.0),
};
let w = w.min((bounds.size.x - 32.0).max(0.0));
let h = h.min((bounds.size.y - 32.0).max(0.0));
if matches!(kind, OverlayKind::Editor | OverlayKind::Confirmation) {
return rect(
bounds.pos.x + (bounds.size.x - w) * 0.5,
bounds.pos.y + (bounds.size.y - h) * 0.5,
w,
h,
);
}
let x = if anchor.pos.x + anchor.size.x + 8.0 + w <= bounds.pos.x + bounds.size.x - 16.0 {
anchor.pos.x + anchor.size.x + 8.0
} else {
anchor.pos.x - w - 8.0
};
rect(
x.clamp(
bounds.pos.x + 16.0,
bounds.pos.x + (bounds.size.x - w - 16.0).max(16.0),
),
anchor.pos.y.clamp(
bounds.pos.y + 16.0,
bounds.pos.y + (bounds.size.y - h - 16.0).max(16.0),
),
w,
h,
)
}
#[derive(Script, ScriptHook, Widget)]
pub struct CalendarOverlayHost {
#[deref]
view: View,
#[rust]
pub active: OverlayKind,
#[rust]
pub compact: bool,
#[rust]
pub short: bool,
#[rust]
pub anchor: Rect,
#[rust]
pub keyboard_occlusion: f64,
#[rust]
panel_rect: Rect,
#[rust]
opening: bool,
#[rust]
amount: f64,
#[rust]
from: f64,
#[rust]
progress: f64,
#[rust]
last: f64,
#[rust]
next: NextFrame,
#[live(false)]
pub reduced_motion: bool,
}
impl CalendarOverlayHost {
pub fn show(&mut self, cx: &mut Cx, kind: OverlayKind, anchor: Rect) {
if self.active != kind {
self.amount = 0.0;
}
self.active = kind;
self.anchor = anchor;
self.start(cx, true);
}
pub fn hide_immediately(&mut self, cx: &mut Cx) {
self.active = OverlayKind::None;
self.amount = 0.0;
self.view.redraw(cx);
}
pub fn close(&mut self, cx: &mut Cx) {
self.start(cx, false);
}
fn start(&mut self, cx: &mut Cx, open: bool) {
self.opening = open;
self.from = self.amount;
self.progress = 0.0;
self.last = 0.0;
self.next = cx.new_next_frame();
self.view.redraw(cx);
}
pub fn id(&self) -> LiveId {
match self.active {
OverlayKind::Detail => live_id!(detail_panel),
OverlayKind::Editor => live_id!(editor_panel),
OverlayKind::Search => live_id!(search_panel),
OverlayKind::Calendars => live_id!(calendars_panel),
OverlayKind::Confirmation => live_id!(confirmation),
OverlayKind::Agenda => live_id!(agenda_panel),
OverlayKind::None => LiveId(0),
}
}
pub fn is_open(&self) -> bool {
self.active != OverlayKind::None
}
}
impl Widget for CalendarOverlayHost {
fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) {
if !self.is_open() {
return;
}
if let Some(e) = self.next.is_event(event) {
let duration = if self.reduced_motion {
0.08
} else if self.active == OverlayKind::Editor {
if self.opening {
0.26
} else {
0.20
}
} else if self.opening {
0.14
} else {
0.10
};
if self.last != 0.0 {
self.progress = (self.progress + (e.time - self.last) / duration).min(1.0);
}
self.last = e.time;
let t = curve().map(self.progress);
self.amount = self.from + ((if self.opening { 1.0 } else { 0.0 }) - self.from) * t;
if self.progress < 1.0 {
self.next = cx.new_next_frame();
} else if !self.opening {
self.active = OverlayKind::None;
}
self.view.redraw(cx);
}
if self.opening {
if let Some((_, panel)) = self.view.children.iter().find(|(id, _)| *id == self.id()) {
panel.handle_event(cx, event, scope);
}
match event.hits(cx, self.view.area()) {
Hit::FingerDown(_) => {}
Hit::FingerUp(e) if e.was_tap() && !self.panel_rect.contains(e.abs) => {
cx.widget_action(self.widget_uid(), OverlayAction::Dismiss)
}
_ => {}
}
}
}
fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep {
if !self.is_open() {
return DrawStep::done();
}
let bounds = cx.peek_walk_turtle(walk);
let usable = rect(
bounds.pos.x,
bounds.pos.y,
bounds.size.x,
(bounds.size.y - self.keyboard_occlusion).max(52.0),
);
let mut r = overlay_rect(usable, self.active, self.compact, self.short, self.anchor);
if !self.reduced_motion {
r.pos.y += (1.0 - self.amount)
* if self.active == OverlayKind::Editor && self.compact {
r.size.y
} else {
6.0
};
}
self.panel_rect = r;
let all = std::mem::take(&mut self.view.children);
let id = self.id();
if let Some((_, panel)) = all.iter().find(|(key, _)| *key == id) {
let mut panel = panel.clone();
script_apply_eval!(cx,panel,{width:#(r.size.x) height:#(r.size.y) abs_pos:#(r.pos)});
self.view.children.push((id, panel));
}
self.view
.draw_bg
.draw_vars
.set_uniform(cx, live_id!(opacity), &[self.amount as f32]);
self.view.draw_walk(cx, scope, walk)?;
self.view.children = all;
DrawStep::done()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn overlays_center_and_clamp_at_all_acceptance_sizes() {
let wide = rect(0.0, 0.0, 1240.0, 800.0);
assert_eq!(
overlay_rect(wide, OverlayKind::Editor, false, false, Rect::default()),
rect(380.0, 80.0, 480.0, 640.0)
);
let compact = rect(0.0, 0.0, 402.0, 780.0);
assert_eq!(
overlay_rect(compact, OverlayKind::Editor, true, false, Rect::default()),
rect(0.0, 20.0, 402.0, 760.0)
);
let short = rect(0.0, 0.0, 874.0, 300.0);
assert_eq!(
overlay_rect(short, OverlayKind::Editor, false, true, Rect::default()),
short
);
let detail = overlay_rect(
wide,
OverlayKind::Detail,
false,
false,
rect(1230.0, 795.0, 10.0, 5.0),
);
assert!(detail.pos.x >= 16.0 && detail.pos.x + detail.size.x <= 1224.0);
assert!(detail.pos.y >= 16.0 && detail.pos.y + detail.size.y <= 784.0);
}
}

View file

@ -0,0 +1,444 @@
//! App-local theme roles and pure presentation adapters. No document ownership.
use crate::model::*;
use makepad_civil_time::{self as civil, Day};
use makepad_widgets::*;
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct CalendarColors {
pub paper: Vec4f,
pub chrome: Vec4f,
pub sidebar: Vec4f,
pub ink: Vec4f,
pub secondary: Vec4f,
pub rule: Vec4f,
pub selection: Vec4f,
pub focus: Vec4f,
pub action: Vec4f,
pub categories: [Vec4f; 4],
pub dark: bool,
raw_secondary: Vec4f,
raw_action: Vec4f,
raw_categories: [Vec4f; 4],
}
pub fn mix(a: Vec4f, b: Vec4f, t: f32) -> Vec4f {
a * (1.0 - t) + b * t
}
fn luminance(c: Vec4f) -> f32 {
let linear = |v: f32| {
if v <= 0.04045 {
v / 12.92
} else {
((v + 0.055) / 1.055).powf(2.4)
}
};
0.2126 * linear(c.x) + 0.7152 * linear(c.y) + 0.0722 * linear(c.z)
}
pub fn composite(color: Vec4f, surface: Vec4f) -> Vec4f {
Vec4f { w: 1.0, ..mix(surface, color, color.w.clamp(0.0, 1.0)) }
}
pub fn contrast(a: Vec4f, b: Vec4f) -> f32 {
let (a, b) = (luminance(composite(a, b)), luminance(b));
(a.max(b) + 0.05) / (a.min(b) + 0.05)
}
pub fn readable(color: Vec4f, surface: Vec4f, ink: Vec4f, minimum: f32) -> Vec4f {
let color = composite(color, surface);
let ink = composite(ink, surface);
let ink = if contrast(ink, surface) >= minimum { ink } else {
let black = vec4(0.0, 0.0, 0.0, 1.0);
let white = vec4(1.0, 1.0, 1.0, 1.0);
if contrast(black, surface) > contrast(white, surface) { black } else { white }
};
if contrast(color, surface) >= minimum {
return color;
}
let mut low = 0.0;
let mut high = 1.0;
for _ in 0..16 {
let mid = (low + high) * 0.5;
if contrast(mix(color, ink, mid), surface) >= minimum {
high = mid;
} else {
low = mid;
}
}
mix(color, ink, high)
}
fn unpack(c: u32) -> Vec4f {
vec4(
(c >> 24) as f32 / 255.0,
((c >> 16) & 255) as f32 / 255.0,
((c >> 8) & 255) as f32 / 255.0,
(c & 255) as f32 / 255.0,
)
}
impl CalendarColors {
pub fn resolve(vm: &mut ScriptVm) -> Self {
// Resolve in the current isolate, including theme refreshes made by the host.
let _palette = makepad_wm_theme::current_for_vm(vm);
let theme = vm.module(id!(theme));
let role = |name: &str| {
unpack(
vm.bx
.heap
.value(theme, LiveId::from_str(name).into(), NoTrap)
.as_color()
.unwrap_or(0),
)
};
let chrome = role("color_bg_app");
// Opaque: sheets, pages and panels are painted in paper over the
// content they cover. Themes give the inset as a tint (15% black in
// the desktop dark theme); over the window's background it is the
// colour the root view shows.
let paper = composite(role("color_inset"), chrome);
let ink = role("color_text");
let sidebar = role("color_bg_container");
let raw_secondary = role("color_text_meta");
let raw_action = role("color_error");
let raw_categories = [role("color_map_1"), role("color_map_4"), role("color_map_6"), role("color_map_3")];
let secondary = readable(raw_secondary, paper, ink, 4.5);
let action = readable(role("color_error"), paper, ink, 4.5);
let dark = luminance(paper) < luminance(ink);
Self {
raw_secondary,
raw_action,
raw_categories,
paper,
chrome,
sidebar,
ink,
secondary,
action,
dark,
rule: mix(paper, ink, if dark { 0.22 } else { 0.16 }),
selection: role("color_bg_highlight"),
focus: role("color_focus"),
categories: raw_categories.map(|c| readable(c, paper, ink, 3.0)),
}
}
pub fn on_surface(self, surface: Vec4f) -> Self {
let paper = composite(surface, self.paper);
Self {
paper,
secondary: readable(self.raw_secondary, paper, self.ink, 4.5),
action: readable(self.raw_action, paper, self.ink, 4.5),
categories: self.raw_categories.map(|c| readable(c, paper, self.ink, 3.0)),
rule: mix(paper, self.ink, if self.dark {0.22} else {0.16}),
..self
}
}
pub fn category(self, c: CalendarColour) -> Vec4f {
self.categories[match c {
CalendarColour::Home => 0,
CalendarColour::Work => 1,
CalendarColour::Birthdays => 2,
CalendarColour::Holidays => 3,
}]
}
pub fn tint(self, c: CalendarColour) -> Vec4f {
mix(
self.paper,
self.category(c),
if self.dark { 0.22 } else { 0.12 },
)
}
pub fn on_action(self) -> Vec4f {
if contrast(self.paper, self.action) > contrast(self.ink, self.action) {
self.paper
} else {
self.ink
}
}
}
pub fn period_heading(mode: CalendarMode, displayed: Day, selected: Day) -> String {
match mode {
CalendarMode::Month => format_month_year(displayed),
CalendarMode::Day => format!(
"{}, {}",
format_heading_compact_day(selected),
civil::to_ymd(selected).0
),
CalendarMode::Week => {
let start = monday_of(selected);
let end = start + 6;
let (sy, sm, sd) = civil::to_ymd(start);
let (ey, em, ed) = civil::to_ymd(end);
if sy != ey {
format!(
"{} {sd}, {sy} – {} {ed}, {ey}",
civil::month_name(sm),
civil::month_name(em)
)
} else if sm != em {
format!(
"{} {sd} – {} {ed}, {ey}",
civil::month_name(sm),
civil::month_name(em)
)
} else {
format!("{} {sd}–{ed}, {ey}", civil::month_name(sm))
}
}
}
}
pub fn occurrence_for_key(doc: &CalendarDocument, key: OccurrenceKey) -> Option<Occurrence> {
let event = event_by_id(doc, key.event_id)?;
crate::engine::expand_range(
std::slice::from_ref(event),
key.start_day,
key.start_day + 1,
UI_EXPAND_CAP,
)
.ok()?
.items
.into_iter()
.find(|o| o.key == key)
}
pub fn occurrence_dates(timing: Timing) -> String {
match timing {
Timing::Timed { start, end } if start.day == end.day => format!(
"{}\n{} – {}",
period_heading(CalendarMode::Day, start.day, start.day),
start.format_hm(),
end.format_hm()
),
Timing::Timed { start, end } => format!(
"{} at {}\n– {} at {}",
format_heading_compact_day(start.day),
start.format_hm(),
format_heading_compact_day(end.day),
end.format_hm()
),
Timing::AllDay {
start,
end_exclusive,
} if end_exclusive == start + 1 => format!(
"{}\nAll-day",
period_heading(CalendarMode::Day, start, start)
),
Timing::AllDay {
start,
end_exclusive,
} => format!(
"{} – {}\nAll-day",
format_heading_compact_day(start),
format_heading_compact_day(end_exclusive - 1)
),
}
}
pub fn occurrence_id(prefix: &str, key: OccurrenceKey) -> LiveId {
LiveId::from_str(&format!("{prefix}_{}", key.as_tool_id()))
}
pub fn fixed(rect: Rect) -> Walk {
Walk {
abs_pos: Some(rect.pos),
width: Size::Fixed(rect.size.x.max(0.0)),
height: Size::Fixed(rect.size.y.max(0.0)),
..Walk::default()
}
}
pub fn rect(x: f64, y: f64, w: f64, h: f64) -> Rect {
Rect {
pos: dvec2(x, y),
size: dvec2(w.max(0.0), h.max(0.0)),
}
}
/// One physical pixel.
pub fn hairline(cx: &Cx2d) -> f64 {
1.0 / cx.current_dpi_factor()
}
/// `v` moved onto the nearest physical-pixel boundary, so a hairline at a
/// fractional column edge covers one device pixel instead of two half ones.
pub fn snap_px(cx: &Cx2d, v: f64) -> f64 {
let dpi = cx.current_dpi_factor();
(v * dpi).round() / dpi
}
pub fn text_at(cx: &mut Cx2d, text: &mut DrawText, r: Rect, value: &str, align: Align) {
if r.size.x < 2.0 || r.size.y < 2.0 {
return;
}
cx.begin_turtle(
fixed(r),
Layout {
flow: Flow::right(),
..Layout::default()
},
);
text.draw_walk(
cx,
Walk {
width: Size::fill(),
height: Size::fill(),
..Walk::default()
},
align,
value,
);
cx.end_turtle();
}
pub fn script_mod(vm: &mut ScriptVm) {
let c = CalendarColors::resolve(vm);
script_eval!(vm, {
use mod.prelude.widgets.*
use mod.widgets.*
mod.calendar = {}
mod.calendar.paper = #(c.paper)
mod.calendar.chrome = #(c.chrome)
mod.calendar.sidebar = #(c.sidebar)
mod.calendar.ink = #(c.ink)
mod.calendar.secondary = #(c.secondary)
mod.calendar.rule = #(c.rule)
mod.calendar.action = #(c.action)
mod.calendar.selection = #(c.selection)
mod.calendar.focus = #(c.focus)
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn period_titles_and_occurrence_dates_follow_presentation() {
let d = civil::from_ymd(2026, 9, 9);
assert_eq!(period_heading(CalendarMode::Month, d, d), "September 2026");
assert_eq!(
period_heading(CalendarMode::Week, d, d),
"September 7–13, 2026"
);
let doc = crate::seed::seed(d);
let series = doc.events.iter().find(|e| e.title == "Standup").unwrap();
let key = OccurrenceKey {
event_id: series.id,
start_day: monday_of(d) + 7,
};
let occ = occurrence_for_key(&doc, key).unwrap();
assert_eq!(occ.timing.start_day(), key.start_day);
assert_ne!(occ.timing.start_day(), series.timing.start_day());
assert!(occurrence_dates(occ.timing).contains("14 September"));
}
#[test]
fn contrast_adjustment_meets_text_and_mark_thresholds() {
for (paper, ink) in [
(vec4(1.0, 1.0, 1.0, 1.0), vec4(0.0, 0.0, 0.0, 1.0)),
(vec4(0.06, 0.06, 0.06, 1.0), vec4(0.95, 0.95, 0.95, 1.0)),
] {
for ratio in [3.0, 4.5] {
assert!(
contrast(readable(mix(paper, ink, 0.15), paper, ink, ratio), paper)
>= ratio - 0.001
);
}
}
}
#[test]
fn translucent_metadata_is_composited_against_each_actual_surface() {
let white=vec4(1.0,1.0,1.0,1.0);
let black=vec4(0.0,0.0,0.0,1.0);
let meta=vec4(0.0,0.0,0.0,0.4);
assert!((contrast(meta,white)-2.849).abs()<0.01);
for (paper, ink) in [(white,black),(black,white)] {
let raw = if paper==white {meta} else {vec4(1.0,1.0,1.0,0.4)};
let c=CalendarColors{paper,ink,raw_secondary:raw,raw_action:raw,raw_categories:[raw;4],..CalendarColors::default()};
for surface in [paper,mix(paper,ink,0.08),mix(paper,ink,0.2)] {
let adjusted=c.on_surface(surface);
assert!(contrast(adjusted.secondary,surface)>=4.5-0.001);
assert!(contrast(adjusted.action,surface)>=4.5-0.001);
assert!(adjusted.categories.iter().all(|mark|contrast(*mark,surface)>=3.0-0.001));
assert_eq!(adjusted.secondary.w,1.0);
}
}
}
}
#[derive(Clone, Debug)]
pub struct PresentedOccurrence {
pub occurrence: Occurrence,
pub title: String,
pub calendar_name: String,
pub colour: CalendarColour,
}
pub fn present(doc: &CalendarDocument, occurrences: Vec<Occurrence>) -> Vec<PresentedOccurrence> {
occurrences
.into_iter()
.filter_map(|occurrence| {
let event = event_by_id(doc, occurrence.key.event_id)?;
let cal = calendar_by_id(doc, event.calendar_id)?;
Some(PresentedOccurrence {
occurrence,
title: event.title.clone(),
calendar_name: cal.name.clone(),
colour: cal.colour,
})
})
.collect()
}
/// Refresh static recipe colours without rehydrating any text, selection, or undo state.
pub fn retint_widget(
cx: &mut Cx,
widget: &WidgetRef,
recipe: CalendarColors,
current: CalendarColors,
surface: Vec4f,
apply: bool,
) -> Vec4f {
let source = widget.script_source();
let colors = cx.with_vm(|vm| {
[live_id!(draw_bg), live_id!(draw_text), live_id!(draw_icon)].map(|field| {
let draw = vm.bx.heap.value(source, field.into(), NoTrap);
let mut value = vm
.bx
.heap
.value(draw.as_object()?, live_id!(color).into(), NoTrap);
if let Some(object) = value.as_object() {
value = vm.bx.heap.value(object, live_id!(value).into(), NoTrap);
}
if value.as_color().is_some() || value.as_pod().is_some() {
Some(Vec4f::script_from_value(vm, value))
} else {
None
}
})
});
let mapped = |value: Vec4f| {
[
(recipe.paper, current.paper),
(recipe.chrome, current.chrome),
(recipe.sidebar, current.sidebar),
(recipe.ink, current.ink),
(recipe.secondary, current.secondary),
(recipe.rule, current.rule),
(recipe.selection, current.selection),
(recipe.action, current.action),
(recipe.focus, current.focus),
]
.into_iter()
.find_map(|(old, new)| if old == value { Some(new) } else { None })
};
let paints = cx.with_vm(|vm| vm.bx.heap.value(source, live_id!(show_bg).into(), NoTrap).as_bool().unwrap_or(false));
let surface = if paints {
colors[0].and_then(mapped).map(|c| composite(c, surface)).unwrap_or(surface)
} else { surface };
if !apply { return surface; }
let mut widget = widget.clone();
if let Some(color) = colors[0].and_then(mapped) {
script_apply_eval!(cx,widget,{draw_bg.color:#(color)});
}
if let Some(color) = colors[2].and_then(mapped) {
let color = readable(color, surface, current.ink, 3.0);
script_apply_eval!(cx,widget,{draw_icon +: {color:#(color) color_hover:#(color) color_down:#(color)}});
}
if let Some(color) = colors[1].and_then(mapped) {
let color = readable(color, surface, current.ink, 4.5);
if widget.borrow::<Button>().is_some() || widget.borrow::<DropDown>().is_some() {
script_apply_eval!(cx,widget,{draw_text +: {color:#(color) color_hover:#(color) color_down:#(color)}});
} else {
script_apply_eval!(cx,widget,{draw_text.color:#(color)});
}
}
surface
}

365
apps/calendar/src/seed.rs Normal file
View file

@ -0,0 +1,365 @@
//! Deterministic fake calendar: 40 masters, recipe version 1.
use crate::model::*;
use makepad_civil_time::{self as civil, Day};
const WORK_TITLES: [&str; 5] = [
"Planning",
"Design review",
"Project check-in",
"Research session",
"Team lunch",
];
const HOME_TITLES: [&str; 4] = [
"Dentist",
"Dinner with friends",
"Bike ride",
"Family visit",
];
const NOTES: [&str; 8] = [
"Review the updated navigation.",
"Bring the slides.",
"Confirm the room.",
"Pack a charger.",
"Leave 10 minutes early.",
"Send notes after.",
"Optional: walk over.",
"Check the guest list.",
];
const ONE_OFF_OFFSETS: [i32; 29] = [
-84, -77, -70, -63, -56, -49, -42, -35, -28, -21, -14, -7, -3, -1, 0, 0, 0, 1, 2, 3, 7, 14,
21, 28, 35, 42, 56, 70, 84,
];
const BIRTHDAY_OFFSETS: [i32; 6] = [-70, -35, -7, 3, 35, 70];
const BIRTHDAY_NAMES: [&str; 6] = [
"Maya Chen",
"Jordan Hale",
"Priya Nair",
"Alex Rivera",
"Sam Okonkwo",
"Elena Voss",
];
fn note_for(id: u32, title: &str) -> String {
format!("{} — {}", NOTES[(id as usize - 1) % NOTES.len()], title)
}
fn work_time(id: u32, day: Day) -> Timing {
// Anchor-day overlap fixtures occupy ids 15–17 (the three 0 offsets).
match id {
15 => Timing::Timed {
start: LocalMinute::from_hm(day, 9, 0).unwrap(),
end: LocalMinute::from_hm(day, 10, 0).unwrap(),
},
16 => Timing::Timed {
start: LocalMinute::from_hm(day, 9, 30).unwrap(),
end: LocalMinute::from_hm(day, 10, 30).unwrap(),
},
17 => Timing::Timed {
start: LocalMinute::from_hm(day, 14, 0).unwrap(),
end: LocalMinute::from_hm(day, 15, 0).unwrap(),
},
_ => {
let hour = 9 + ((id - 1) % 7);
let hour = if hour == 12 { 13 } else { hour };
Timing::Timed {
start: LocalMinute::from_hm(day, hour, 0).unwrap(),
end: LocalMinute::from_hm(day, hour + 1, 0).unwrap(),
}
}
}
}
/// Window: three calendar months before through three months after `anchor`.
pub fn seed_window(anchor: Day) -> (Day, Day) {
let start = civil::month_start(civil::add_months(anchor, -3));
let end = civil::month_end(civil::add_months(anchor, 3));
(start, end)
}
fn first_monday_on_or_after(day: Day) -> Day {
let wd = civil::weekday(day);
if wd == 0 {
day
} else {
day + (7 - wd as i32)
}
}
/// Pure seed. Recipe version 1. No clock, no random source.
pub fn seed(anchor_today: Day) -> CalendarDocument {
let (window_start, _window_end) = seed_window(anchor_today);
let mut events = Vec::with_capacity(40);
let mut id: u32 = 1;
for (i, offset) in ONE_OFF_OFFSETS.iter().copied().enumerate() {
let day = anchor_today + offset;
if i < 20 {
let title = WORK_TITLES[(id as usize - 1) % WORK_TITLES.len()].to_string();
events.push(CalendarEvent {
id: EventId(id),
calendar_id: CAL_WORK,
title: title.clone(),
timing: work_time(id, day),
repeat: Repeat::None,
notes: note_for(id, &title),
});
} else if i == 27 {
// Home event crossing midnight.
events.push(CalendarEvent {
id: EventId(id),
calendar_id: CAL_HOME,
title: "Late concert".into(),
timing: Timing::Timed {
start: LocalMinute::from_hm(day, 22, 0).unwrap(),
end: LocalMinute::from_hm(day + 1, 1, 0).unwrap(),
},
repeat: Repeat::None,
notes: note_for(id, "Late concert"),
});
} else if i == 28 {
// Three-day all-day Home event.
events.push(CalendarEvent {
id: EventId(id),
calendar_id: CAL_HOME,
title: "Weekend away".into(),
timing: Timing::AllDay {
start: day,
end_exclusive: day + 3,
},
repeat: Repeat::None,
notes: note_for(id, "Weekend away"),
});
} else {
let title = HOME_TITLES[(id as usize) % HOME_TITLES.len()].to_string();
let hour = 18 + ((id % 3) as u32);
events.push(CalendarEvent {
id: EventId(id),
calendar_id: CAL_HOME,
title: title.clone(),
timing: Timing::Timed {
start: LocalMinute::from_hm(day, hour.min(21), 0).unwrap(),
end: LocalMinute::from_hm(day, (hour.min(21) + 1).min(23), 0).unwrap(),
},
repeat: Repeat::None,
notes: note_for(id, &title),
});
}
id += 1;
}
for (i, offset) in BIRTHDAY_OFFSETS.iter().copied().enumerate() {
let day = anchor_today + offset;
let name = BIRTHDAY_NAMES[i];
let title = format!("{name}'s birthday");
events.push(CalendarEvent {
id: EventId(id),
calendar_id: CAL_BIRTHDAYS,
title: title.clone(),
timing: Timing::AllDay {
start: day,
end_exclusive: day + 1,
},
repeat: Repeat::Yearly,
notes: note_for(id, &title),
});
id += 1;
}
let (ay, am, _) = civil::to_ymd(anchor_today);
let prev = civil::add_months(civil::from_ymd(ay, am, 1), -1);
let next = civil::add_months(civil::from_ymd(ay, am, 1), 1);
let (py, pm, _) = civil::to_ymd(prev);
let (ny, nm, _) = civil::to_ymd(next);
let holidays = [
(
civil::from_ymd(py, pm, 15.min(civil::days_in_month(py, pm))),
"Demo Independence Day",
),
(
civil::from_ymd(ay, am, 20.min(civil::days_in_month(ay, am))),
"Demo Harvest Festival",
),
(
civil::from_ymd(ny, nm, 12.min(civil::days_in_month(ny, nm))),
"Demo Founders' Day",
),
];
for (day, title) in holidays {
events.push(CalendarEvent {
id: EventId(id),
calendar_id: CAL_HOLIDAYS,
title: title.into(),
timing: Timing::AllDay {
start: day,
end_exclusive: day + 1,
},
repeat: Repeat::None,
notes: note_for(id, title),
});
id += 1;
}
let standup_start = first_monday_on_or_after(window_start);
events.push(CalendarEvent {
id: EventId(id),
calendar_id: CAL_WORK,
title: "Standup".into(),
timing: Timing::Timed {
start: LocalMinute::from_hm(standup_start, 9, 30).unwrap(),
end: LocalMinute::from_hm(standup_start, 9, 45).unwrap(),
},
repeat: Repeat::Weekly,
notes: note_for(id, "Standup"),
});
id += 1;
let (wy, wm, _) = civil::to_ymd(window_start);
let review_day = civil::from_ymd(wy, wm, 15.min(civil::days_in_month(wy, wm)));
events.push(CalendarEvent {
id: EventId(id),
calendar_id: CAL_WORK,
title: "Monthly review".into(),
timing: Timing::Timed {
start: LocalMinute::from_hm(review_day, 15, 0).unwrap(),
end: LocalMinute::from_hm(review_day, 16, 0).unwrap(),
},
repeat: Repeat::Monthly,
notes: note_for(id, "Monthly review"),
});
CalendarDocument {
schema_version: SCHEMA_VERSION,
revision: 1,
seed_version: SEED_VERSION,
seed_anchor: anchor_today,
next_event_id: 41,
calendars: vec![
Calendar {
id: CAL_HOME,
name: "Home".into(),
colour: CalendarColour::Home,
visible: true,
},
Calendar {
id: CAL_WORK,
name: "Work".into(),
colour: CalendarColour::Work,
visible: true,
},
Calendar {
id: CAL_BIRTHDAYS,
name: "Birthdays".into(),
colour: CalendarColour::Birthdays,
visible: true,
},
Calendar {
id: CAL_HOLIDAYS,
name: "Holidays".into(),
colour: CalendarColour::Holidays,
visible: true,
},
],
events,
preferences: Preferences {
wide_mode: CalendarMode::Month,
default_calendar: CAL_HOME,
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::engine::occurrences_for_day;
use crate::model::validate_document;
#[test]
fn seed_is_forty_valid_masters_with_overlap_and_window() {
let anchor = civil::from_ymd(2026, 9, 9);
let doc = seed(anchor);
assert_eq!(doc.events.len(), 40);
assert_eq!(doc.next_event_id, 41);
assert_eq!(doc.seed_anchor, anchor);
assert_eq!(doc.seed_version, 1);
assert_eq!(doc.calendars.len(), 4);
assert!(validate_document(&doc).is_ok());
for (i, event) in doc.events.iter().enumerate() {
assert_eq!(event.id.0, (i as u32) + 1);
}
let ids: std::collections::BTreeSet<_> = doc.events.iter().map(|e| e.id).collect();
assert_eq!(ids.len(), 40);
let day = occurrences_for_day(&doc, anchor, false, 50);
let timed: Vec<_> = day
.items
.iter()
.filter(|o| matches!(o.timing, Timing::Timed { .. }))
.collect();
assert!(timed.len() >= 3, "anchor day has the overlap fixture");
let starts: Vec<_> = timed
.iter()
.filter_map(|o| match o.timing {
Timing::Timed { start, .. } => Some(start.minute),
_ => None,
})
.collect();
assert!(starts.contains(&(9 * 60)));
assert!(starts.contains(&(9 * 60 + 30)));
assert!(starts.contains(&(14 * 60)));
assert!(doc.events.iter().any(|e| matches!(
e.timing,
Timing::Timed { start, end } if end.day == start.day + 1
)));
assert!(doc.events.iter().any(|e| matches!(
e.timing,
Timing::AllDay { start, end_exclusive } if end_exclusive - start == 3
)));
let (ws, we) = seed_window(anchor);
let birthdays: Vec<_> = doc
.events
.iter()
.filter(|e| e.calendar_id == CAL_BIRTHDAYS)
.collect();
assert_eq!(birthdays.len(), 6);
for b in birthdays {
assert_eq!(b.repeat, Repeat::Yearly);
let d = b.timing.start_day();
assert!(d >= ws - 1 && d <= we + 1);
}
let holidays: Vec<_> = doc
.events
.iter()
.filter(|e| e.calendar_id == CAL_HOLIDAYS)
.collect();
assert_eq!(holidays.len(), 3);
for h in &holidays {
assert!(h.title.starts_with("Demo "), "{}", h.title);
let (y, m, _) = civil::to_ymd(h.timing.start_day());
let (ay, am, _) = civil::to_ymd(anchor);
let delta = (y * 12 + m as i32) - (ay * 12 + am as i32);
assert!((-1..=1).contains(&delta), "holiday month {y}-{m}");
}
assert!(doc.events.iter().any(|e| e.title == "Standup" && e.repeat == Repeat::Weekly));
assert!(doc
.events
.iter()
.any(|e| e.title == "Monthly review" && e.repeat == Repeat::Monthly));
assert_eq!(doc.events.iter().filter(|e| e.repeat == Repeat::None && e.calendar_id == CAL_WORK).count(), 20);
let home_one_offs = doc
.events
.iter()
.filter(|e| e.repeat == Repeat::None && e.calendar_id == CAL_HOME)
.count();
assert_eq!(home_one_offs, 9);
}
#[test]
fn seed_is_deterministic() {
let a = seed(civil::from_ymd(2026, 9, 9));
let b = seed(civil::from_ymd(2026, 9, 9));
assert_eq!(a, b);
}
}

View file

@ -0,0 +1,471 @@
//! JSON document encode/decode and save coalescing.
use crate::model::*;
use crate::seed::seed;
use makepad_civil_time::{self as civil, Day};
use makepad_strict_json::{parse, Value};
pub const JAIL_KEY: &str = "calendar.json";
pub const MAX_DOCUMENT_BYTES: usize = 4 * 1024 * 1024;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum LoadOutcome {
Missing,
Loaded(CalendarDocument),
Error { message: String },
}
pub fn load_bytes(bytes: Option<&[u8]>) -> LoadOutcome {
match bytes {
None => LoadOutcome::Missing,
Some(raw) => match decode(raw) {
Ok(doc) => LoadOutcome::Loaded(doc),
Err(message) => LoadOutcome::Error { message },
},
}
}
pub fn seed_if_missing(bytes: Option<&[u8]>, today: Day) -> LoadOutcome {
match load_bytes(bytes) {
LoadOutcome::Missing => LoadOutcome::Loaded(seed(today)),
other => other,
}
}
fn obj_get<'a>(v: &'a Value, key: &str) -> Result<&'a Value, String> {
v.get(key)
.ok_or_else(|| format!("missing field `{key}`"))
}
fn expect_obj<'a>(v: &'a Value, ctx: &str) -> Result<&'a [(String, Value)], String> {
match v {
Value::Obj(pairs) => Ok(pairs),
_ => Err(format!("{ctx} must be an object")),
}
}
fn as_u32(v: &Value, ctx: &str) -> Result<u32, String> {
v.as_i64()
.filter(|i| *i >= 0 && *i <= u32::MAX as i64)
.map(|i| i as u32)
.ok_or_else(|| format!("{ctx} must be a non-negative integer"))
}
fn as_u8(v: &Value, ctx: &str) -> Result<u8, String> {
v.as_i64()
.filter(|i| *i >= 0 && *i <= u8::MAX as i64)
.map(|i| i as u8)
.ok_or_else(|| format!("{ctx} must be a small integer"))
}
fn as_bool(v: &Value, ctx: &str) -> Result<bool, String> {
v.as_bool()
.ok_or_else(|| format!("{ctx} must be a boolean"))
}
fn as_str<'a>(v: &'a Value, ctx: &str) -> Result<&'a str, String> {
v.as_str()
.ok_or_else(|| format!("{ctx} must be a string"))
}
fn as_arr<'a>(v: &'a Value, ctx: &str) -> Result<&'a [Value], String> {
v.as_arr()
.ok_or_else(|| format!("{ctx} must be an array"))
}
fn known_keys(obj: &[(String, Value)], allowed: &[&str], ctx: &str) -> Result<(), String> {
for (k, _) in obj {
if !allowed.iter().any(|a| a == k) {
return Err(format!("unknown field `{k}` in {ctx}"));
}
}
Ok(())
}
fn decode_timing(v: &Value) -> Result<Timing, String> {
let pairs = expect_obj(v, "timing")?;
known_keys(pairs, &["kind", "start", "end", "end_exclusive"], "timing")?;
let kind = as_str(obj_get(v, "kind")?, "timing.kind")?;
match kind {
"timed" => {
let start = LocalMinute::parse_iso(as_str(obj_get(v, "start")?, "timing.start")?)
.ok_or_else(|| "malformed timed start".to_string())?;
let end = LocalMinute::parse_iso(as_str(obj_get(v, "end")?, "timing.end")?)
.ok_or_else(|| "malformed timed end".to_string())?;
Ok(Timing::Timed { start, end })
}
"all_day" => {
let start = civil::parse_iso(as_str(obj_get(v, "start")?, "timing.start")?)
.ok_or_else(|| "malformed all-day start".to_string())?;
let end_exclusive =
civil::parse_iso(as_str(obj_get(v, "end_exclusive")?, "timing.end_exclusive")?)
.ok_or_else(|| "malformed all-day end".to_string())?;
Ok(Timing::AllDay {
start,
end_exclusive,
})
}
other => Err(format!("unknown timing kind `{other}`")),
}
}
fn decode_event(v: &Value) -> Result<CalendarEvent, String> {
let pairs = expect_obj(v, "event")?;
known_keys(
pairs,
&["id", "calendar_id", "title", "timing", "repeat", "notes"],
"event",
)?;
Ok(CalendarEvent {
id: EventId(as_u32(obj_get(v, "id")?, "event.id")?),
calendar_id: CalendarId(as_u8(obj_get(v, "calendar_id")?, "event.calendar_id")?),
title: as_str(obj_get(v, "title")?, "event.title")?.to_string(),
timing: decode_timing(obj_get(v, "timing")?)?,
repeat: Repeat::parse(as_str(obj_get(v, "repeat")?, "event.repeat")?)
.ok_or_else(|| "unknown repeat".to_string())?,
notes: as_str(obj_get(v, "notes")?, "event.notes")?.to_string(),
})
}
fn decode_calendar(v: &Value) -> Result<Calendar, String> {
let pairs = expect_obj(v, "calendar")?;
known_keys(pairs, &["id", "name", "colour", "visible"], "calendar")?;
Ok(Calendar {
id: CalendarId(as_u8(obj_get(v, "id")?, "calendar.id")?),
name: as_str(obj_get(v, "name")?, "calendar.name")?.to_string(),
colour: CalendarColour::parse(as_str(obj_get(v, "colour")?, "calendar.colour")?)
.ok_or_else(|| "unknown colour".to_string())?,
visible: as_bool(obj_get(v, "visible")?, "calendar.visible")?,
})
}
pub fn decode(bytes: &[u8]) -> Result<CalendarDocument, String> {
let value = parse(bytes).map_err(|e| e.to_string())?;
let pairs = expect_obj(&value, "document")?;
known_keys(
pairs,
&[
"schema_version",
"revision",
"seed_version",
"seed_anchor",
"next_event_id",
"preferences",
"calendars",
"events",
],
"document",
)?;
let prefs_v = obj_get(&value, "preferences")?;
let prefs_pairs = expect_obj(prefs_v, "preferences")?;
known_keys(prefs_pairs, &["wide_mode", "default_calendar"], "preferences")?;
let doc = CalendarDocument {
schema_version: as_u32(obj_get(&value, "schema_version")?, "schema_version")?,
revision: as_u32(obj_get(&value, "revision")?, "revision")?,
seed_version: as_u32(obj_get(&value, "seed_version")?, "seed_version")?,
seed_anchor: civil::parse_iso(as_str(obj_get(&value, "seed_anchor")?, "seed_anchor")?)
.ok_or_else(|| "malformed seed_anchor".to_string())?,
next_event_id: as_u32(obj_get(&value, "next_event_id")?, "next_event_id")?,
preferences: Preferences {
wide_mode: CalendarMode::parse(as_str(
obj_get(prefs_v, "wide_mode")?,
"preferences.wide_mode",
)?)
.ok_or_else(|| "unknown wide_mode".to_string())?,
default_calendar: CalendarId(as_u8(
obj_get(prefs_v, "default_calendar")?,
"preferences.default_calendar",
)?),
},
calendars: as_arr(obj_get(&value, "calendars")?, "calendars")?
.iter()
.map(decode_calendar)
.collect::<Result<Vec<_>, _>>()?,
events: as_arr(obj_get(&value, "events")?, "events")?
.iter()
.map(decode_event)
.collect::<Result<Vec<_>, _>>()?,
};
validate_document(&doc)?;
Ok(doc)
}
fn s(v: impl Into<String>) -> Value {
Value::Str(v.into())
}
fn i(v: i64) -> Value {
Value::Int(v)
}
fn b(v: bool) -> Value {
Value::Bool(v)
}
fn encode_timing(t: Timing) -> Value {
match t {
Timing::Timed { start, end } => Value::Obj(vec![
("kind".into(), s("timed")),
("start".into(), s(start.format_iso())),
("end".into(), s(end.format_iso())),
]),
Timing::AllDay {
start,
end_exclusive,
} => Value::Obj(vec![
("kind".into(), s("all_day")),
("start".into(), s(civil::format_iso(start))),
(
"end_exclusive".into(),
s(civil::format_iso(end_exclusive)),
),
]),
}
}
pub fn encode_value(doc: &CalendarDocument) -> Value {
let calendars = doc
.calendars
.iter()
.map(|c| {
Value::Obj(vec![
("id".into(), i(c.id.0 as i64)),
("name".into(), s(c.name.clone())),
("colour".into(), s(c.colour.as_str())),
("visible".into(), b(c.visible)),
])
})
.collect();
let events = doc
.events
.iter()
.map(|e| {
Value::Obj(vec![
("id".into(), i(e.id.0 as i64)),
("calendar_id".into(), i(e.calendar_id.0 as i64)),
("title".into(), s(e.title.clone())),
("timing".into(), encode_timing(e.timing)),
("repeat".into(), s(e.repeat.as_str())),
("notes".into(), s(e.notes.clone())),
])
})
.collect();
Value::Obj(vec![
("schema_version".into(), i(doc.schema_version as i64)),
("revision".into(), i(doc.revision as i64)),
("seed_version".into(), i(doc.seed_version as i64)),
("seed_anchor".into(), s(civil::format_iso(doc.seed_anchor))),
("next_event_id".into(), i(doc.next_event_id as i64)),
(
"preferences".into(),
Value::Obj(vec![
("wide_mode".into(), s(doc.preferences.wide_mode.as_str())),
(
"default_calendar".into(),
i(doc.preferences.default_calendar.0 as i64),
),
]),
),
("calendars".into(), Value::Arr(calendars)),
("events".into(), Value::Arr(events)),
])
}
pub fn encode(doc: &CalendarDocument) -> Result<Vec<u8>, String> {
let json = encode_value(doc).to_json();
let bytes = json.into_bytes();
if bytes.len() > MAX_DOCUMENT_BYTES {
return Err(format!(
"document is {} bytes; the cap is {MAX_DOCUMENT_BYTES}",
bytes.len()
));
}
Ok(bytes)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PersistStatus {
Loading,
Ready,
Saving,
Saved,
LoadError,
SaveError,
}
impl PersistStatus {
pub fn label(self) -> &'static str {
match self {
Self::Loading => "Loading",
Self::Ready => "",
Self::Saving => "Saving",
Self::Saved => "Saved",
Self::LoadError => "Could not load — Retry",
Self::SaveError => "Changes not saved — Retry",
}
}
}
#[derive(Clone, Debug, Default)]
pub struct Persist {
pub load_id: Option<u64>,
pub save_id: Option<u64>,
pub in_flight_revision: Option<u32>,
pub pending_revision: Option<u32>,
pub status: PersistStatus,
pub preserved_bytes: Option<Vec<u8>>,
}
impl Default for PersistStatus {
fn default() -> Self {
Self::Loading
}
}
impl Persist {
pub fn begin_load(&mut self, id: u64) {
self.load_id = Some(id);
self.status = PersistStatus::Loading;
}
/// After a mutation: coalesce onto the in-flight write.
pub fn on_mutate(&mut self, revision: u32) -> bool {
if self.save_id.is_some() {
self.pending_revision = Some(revision);
self.status = PersistStatus::Saving;
false
} else {
self.pending_revision = Some(revision);
self.status = PersistStatus::Saving;
true
}
}
pub fn take_pending(&mut self, id: u64) -> Option<u32> {
let rev = self.pending_revision.take()?;
self.save_id = Some(id);
self.in_flight_revision = Some(rev);
self.status = PersistStatus::Saving;
Some(rev)
}
pub fn on_save_response(&mut self, id: u64, ok: bool) -> SaveAck {
if self.save_id != Some(id) {
return SaveAck::Ignored;
}
self.save_id = None;
let saved_rev = self.in_flight_revision.take();
if !ok {
self.status = PersistStatus::SaveError;
if let Some(rev) = saved_rev {
self.pending_revision = Some(self.pending_revision.unwrap_or(rev).max(rev));
}
return SaveAck::Failed;
}
if self.pending_revision.is_some() {
self.status = PersistStatus::Saving;
SaveAck::WriteNext
} else {
self.status = PersistStatus::Saved;
SaveAck::Idle
}
}
pub fn on_load_response(&mut self, id: u64) -> bool {
if self.load_id != Some(id) {
return false;
}
self.load_id = None;
true
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SaveAck {
Ignored,
Failed,
WriteNext,
Idle,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::seed::seed;
#[test]
fn json_round_trip_unicode_and_refusals() {
let mut doc = seed(civil::from_ymd(2026, 9, 9));
doc.events[0].title = "Design review — 日本語 📅".into();
doc.events[0].notes = "é".repeat(40);
let bytes = encode(&doc).unwrap();
let back = decode(&bytes).unwrap();
assert_eq!(back.events[0].title, doc.events[0].title);
assert_eq!(back.events[0].notes, doc.events[0].notes);
assert_eq!(back.events.len(), 40);
assert!(matches!(load_bytes(None), LoadOutcome::Missing));
assert!(matches!(
load_bytes(Some(b"")),
LoadOutcome::Error { .. }
));
assert!(matches!(
load_bytes(Some(b"{")),
LoadOutcome::Error { .. }
));
let dup = br#"{"schema_version":1,"schema_version":1,"revision":1,"seed_version":1,"seed_anchor":"2026-09-09","next_event_id":41,"preferences":{"wide_mode":"month","default_calendar":1},"calendars":[],"events":[]}"#;
assert!(decode(dup).is_err(), "duplicate keys");
let future = br#"{"schema_version":99,"revision":1,"seed_version":1,"seed_anchor":"2026-09-09","next_event_id":1,"preferences":{"wide_mode":"month","default_calendar":1},"calendars":[{"id":1,"name":"Home","colour":"home","visible":true}],"events":[]}"#;
assert!(decode(future).is_err(), "future schema");
let dup_id = br#"{"schema_version":1,"revision":1,"seed_version":1,"seed_anchor":"2026-09-09","next_event_id":3,"preferences":{"wide_mode":"month","default_calendar":1},"calendars":[{"id":1,"name":"Home","colour":"home","visible":true}],"events":[{"id":1,"calendar_id":1,"title":"A","timing":{"kind":"all_day","start":"2026-09-09","end_exclusive":"2026-09-10"},"repeat":"none","notes":""},{"id":1,"calendar_id":1,"title":"B","timing":{"kind":"all_day","start":"2026-09-09","end_exclusive":"2026-09-10"},"repeat":"none","notes":""}]}"#;
assert!(decode(dup_id).is_err(), "duplicate event ids");
let bad_end = br#"{"schema_version":1,"revision":1,"seed_version":1,"seed_anchor":"2026-09-09","next_event_id":2,"preferences":{"wide_mode":"month","default_calendar":1},"calendars":[{"id":1,"name":"Home","colour":"home","visible":true}],"events":[{"id":1,"calendar_id":1,"title":"A","timing":{"kind":"timed","start":"2026-09-09T10:00","end":"2026-09-09T09:00"},"repeat":"none","notes":""}]}"#;
assert!(decode(bad_end).is_err(), "invalid endpoints");
let empty = br#"{"schema_version":1,"revision":1,"seed_version":1,"seed_anchor":"2026-09-09","next_event_id":1,"preferences":{"wide_mode":"month","default_calendar":1},"calendars":[{"id":1,"name":"Home","colour":"home","visible":true}],"events":[]}"#;
let empty_doc = decode(empty).unwrap();
assert!(empty_doc.events.is_empty());
assert!(!matches!(
seed_if_missing(Some(empty), civil::from_ymd(2026, 9, 9)),
LoadOutcome::Loaded(d) if !d.events.is_empty()
));
match seed_if_missing(None, civil::from_ymd(2026, 9, 9)) {
LoadOutcome::Loaded(d) => assert_eq!(d.events.len(), 40),
_ => panic!("missing seeds"),
}
assert!(matches!(
seed_if_missing(Some(b""), civil::from_ymd(2026, 9, 9)),
LoadOutcome::Error { .. }
));
}
#[test]
fn save_coalescing_stale_ids_and_retry() {
let mut p = Persist::default();
p.begin_load(1);
assert!(p.on_load_response(1));
assert!(!p.on_load_response(1), "stale load id");
assert!(p.on_mutate(2));
assert_eq!(p.take_pending(10), Some(2));
assert!(!p.on_mutate(3), "coalesce while in flight");
assert_eq!(p.on_save_response(99, true), SaveAck::Ignored);
assert_eq!(p.on_save_response(10, true), SaveAck::WriteNext);
assert_eq!(p.take_pending(11), Some(3));
assert_eq!(p.on_save_response(11, true), SaveAck::Idle);
assert_eq!(p.status, PersistStatus::Saved);
p.on_mutate(4);
p.take_pending(12);
assert_eq!(p.on_save_response(12, false), SaveAck::Failed);
assert_eq!(p.status, PersistStatus::SaveError);
assert_eq!(p.pending_revision, Some(4));
assert!(p.on_mutate(4) || p.pending_revision == Some(4));
}
#[test]
fn persisted_multibyte_dates_are_errors_not_panics() {
let doc = seed(civil::from_ymd(2026,9,9));
let json = String::from_utf8(encode(&doc).unwrap()).unwrap();
for date in ["202é-09-9", "2026-😀-9", "日本語"] {
let invalid = json.replace("2026-09-09",date);
assert!(decode(invalid.as_bytes()).is_err());
assert!(matches!(load_bytes(Some(invalid.as_bytes())),LoadOutcome::Error{..}));
}
}
}

View file

@ -0,0 +1,664 @@
//! Fixed date/all-day headers over one scrollable 24-hour presentation.
use crate::components::*;
use crate::engine::{
all_day_lanes_for_days, hit_timed, initial_scroll_for_day, timed_rects_for_days, HitResult,
TimedRect, HOUR_HEIGHT, TIMELINE_CONTENT_HEIGHT,
};
use crate::model::*;
use crate::presentation::rect;
use crate::presentation::*;
use makepad_civil_time::{self as civil, Day};
use makepad_widgets::*;
script_mod! {
use mod.prelude.widgets_internal.*
use mod.widgets.*
use mod.calendar.*
use mod.shader.*
mod.widgets.TimelineGridBase = #(TimelineGrid::register_widget(vm))
mod.widgets.TimelineGrid = set_type_default() do mod.widgets.TimelineGridBase{
width: Fill height: 1536 flow: Overlay show_bg: false
draw_ground.color: paper
draw_rule.color: rule
draw_now +: {color: action}
draw_now_dot +: {
color: action
pixel: fn(){
let sdf = Sdf2d.viewport(self.pos * self.rect_size)
sdf.circle(2.5,2.5,2.5)
sdf.fill(self.color)
return sdf.result
}
}
draw_meta +: {color: secondary text_style: theme.font_regular{font_size: 8.25} max_lines:1 text_overflow:Ellipsis}
}
mod.widgets.CalendarTimelineBlock = mod.widgets.CalendarHitRow{
width: Fill height: Fill flow: Down padding: Inset{left:6 right:6 top:2 bottom:2} spacing: 2
show_bg: true
draw_bg +: {
color: instance(selection)
stripe: instance(action)
pixel: fn(){
let sdf = Sdf2d.viewport(self.pos * self.rect_size)
sdf.box(0.0,0.0,self.rect_size.x,self.rect_size.y,2.0)
sdf.fill(self.color)
sdf.rect(0.0,0.0,2.0,self.rect_size.y)
sdf.fill(self.stripe)
return sdf.result
}
}
event_title := Ink{height:16 width:Fill draw_text.text_style:theme.font_bold{font_size:9.0}}
event_time := Ink{height:14 width:Fill draw_text.text_style:theme.font_regular{font_size:8.25}}
}
mod.widgets.CalendarTimelineHeadersBase = #(CalendarTimelineHeaders::register_widget(vm))
mod.widgets.CalendarTimelineHeaders = set_type_default() do mod.widgets.CalendarTimelineHeadersBase{
height:44 width:Fill flow:Overlay show_bg:false
}
mod.widgets.CalendarDateStrip = mod.widgets.CalendarTimelineHeaders{height:64 strip:true}
mod.widgets.CalendarAllDayBandBase = #(CalendarAllDayBand::register_widget(vm))
mod.widgets.CalendarAllDayBand = set_type_default() do mod.widgets.CalendarAllDayBandBase{
height:44 width:Fill flow:Overlay show_bg:false
caption := Ink{width:52 height:44 text:"All-day" align:Align{x:0.5 y:0.5} draw_text +: {color:secondary text_style:theme.font_regular{font_size:8.25}}}
}
mod.widgets.CalendarTimelineScrollBase = #(CalendarTimelineScroll::register_widget(vm))
mod.widgets.CalendarTimelineScroll = set_type_default() do mod.widgets.CalendarTimelineScrollBase{
height:Fill width:Fill flow:Down show_bg:false
scrolling: ScrollBars{show_scroll_x:false show_scroll_y:true}
grid := mod.widgets.TimelineGrid{}
}
mod.widgets.CalendarTimelineBase = #(CalendarTimeline::register_widget(vm))
mod.widgets.CalendarTimeline = set_type_default() do mod.widgets.CalendarTimelineBase{
height:Fill width:Fill flow:Down show_bg:true draw_bg.color:paper padding:0 margin:0 spacing:0
headers := mod.widgets.CalendarTimelineHeaders{height:44}
all_day := mod.widgets.CalendarAllDayBand{height:44}
body := mod.widgets.CalendarTimelineScroll{}
}
}
#[derive(Clone, Debug, Default)]
pub struct TimelineFrame {
pub days: Vec<Day>,
pub events: Vec<PresentedOccurrence>,
pub clock: ClockSnapshot,
pub selected: Day,
pub gutter: f64,
pub compact: bool,
pub short: bool,
}
impl TimelineFrame {
fn occurrences(&self) -> Vec<Occurrence> {
self.events.iter().map(|e| e.occurrence).collect()
}
}
#[derive(Script, ScriptHook, Widget)]
pub struct TimelineGrid {
#[deref]
view: View,
#[live]
draw_ground: DrawColor,
#[live]
draw_rule: DrawColor,
#[live]
draw_now: DrawColor,
#[live]
draw_now_dot: DrawColor,
#[live]
draw_meta: DrawText,
#[rust]
frame: TimelineFrame,
#[rust]
rects: Vec<TimedRect>,
}
impl Widget for TimelineGrid {
fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) {
// Blocks are presentational; the shared engine resolves overlapping touch regions.
self.view.handle_event(cx, event, scope);
match event.hits(cx, self.view.area()) {
Hit::FingerDown(_) => cx.set_key_focus(self.view.area()),
Hit::FingerUp(e) if e.is_primary_hit() && e.was_tap() => {
let r = self.view.area().rect(cx);
let p = e.abs - r.pos;
let hit = hit_timed(
&self.rects,
&self.frame.days,
p.x,
p.y,
if e.device.is_touch() { 44.0 } else { 0.0 },
);
let action = match hit {
HitResult::Event(k) => CalendarAction::Event(k),
HitResult::Ambiguous(d) => CalendarAction::Agenda(d),
HitResult::Empty => {
if p.x < self.frame.gutter {
return;
}
let n = self.frame.days.len().max(1);
let w = (r.size.x - self.frame.gutter) / n as f64;
let i = ((p.x - self.frame.gutter) / w)
.floor()
.clamp(0.0, (n - 1) as f64) as usize;
let day = self
.frame
.days
.get(i)
.copied()
.unwrap_or(self.frame.selected);
CalendarAction::Create {
day,
minute: snap_minute_15(
(p.y / HOUR_HEIGHT * 60.0).clamp(0.0, 1439.0) as u16
),
}
}
};
cx.widget_action(self.widget_uid(), action);
}
_ => {}
}
}
fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep {
let r = cx.peek_walk_turtle(walk);
let c = cx.with_vm(CalendarColors::resolve);
self.draw_ground.color = c.paper;
self.draw_ground.draw_abs(cx, r);
let width = r.size.x - if self.frame.compact { 12.0 } else { 0.0 };
let col_w = (width - self.frame.gutter) / self.frame.days.len().max(1) as f64;
self.draw_meta.color = c.secondary;
let line = hairline(cx);
for hour in 0..24 {
let y = r.pos.y + hour as f64 * HOUR_HEIGHT;
self.draw_rule.color = c.rule;
self.draw_rule.draw_abs(
cx,
rect(
r.pos.x + self.frame.gutter,
snap_px(cx, y),
width - self.frame.gutter,
line,
),
);
self.draw_rule.color = mix(c.paper, c.rule, 0.5);
self.draw_rule.draw_abs(
cx,
rect(
r.pos.x + self.frame.gutter,
snap_px(cx, y + 32.0),
width - self.frame.gutter,
line,
),
);
text_at(
cx,
&mut self.draw_meta,
rect(r.pos.x + 4.0, y + 2.0, self.frame.gutter - 12.0, 16.0),
&format!("{hour:02}:00"),
Align { x: 1.0, y: 0.5 },
);
}
for i in 0..self.frame.days.len() {
self.draw_rule.color = mix(c.paper, c.rule, 0.5);
self.draw_rule.draw_abs(
cx,
rect(
snap_px(cx, r.pos.x + self.frame.gutter + i as f64 * col_w),
r.pos.y,
line,
r.size.y,
),
);
}
self.rects = timed_rects_for_days(
&self.frame.occurrences(),
&self.frame.days,
width,
self.frame.gutter,
);
let mut children = Vec::new();
for tr in &self.rects {
let Some(event) = self
.frame
.events
.iter()
.find(|e| e.occurrence.key == tr.key)
else {
continue;
};
let id = LiveId::from_str(&format!("block_{}_{}", tr.key.as_tool_id(), tr.day_index));
let widget = self
.view
.children
.iter()
.find(|(key, _)| *key == id)
.map(|(_, w)| w.clone())
.unwrap_or_else(|| {
cx.with_vm(|vm| {
let v = script_eval!(vm,{use mod.prelude.widgets_internal.* use mod.widgets.* mod.widgets.CalendarTimelineBlock{}});
WidgetRef::script_from_value(vm, v)
})
});
place(
&widget,
cx,
rect(
r.pos.x + tr.x,
r.pos.y + tr.y,
tr.w,
tr.h.min(TIMELINE_CONTENT_HEIGHT - tr.y),
),
);
let mut style = widget.clone();
let color = c.tint(event.colour);
let stripe = c.category(event.colour);
script_apply_eval!(cx,style,{draw_bg +: {color:#(color) stripe:#(stripe)}});
label(&widget, cx, ids!(event_title), &event.title, c.ink);
let time = match event.occurrence.timing {
Timing::Timed { start, end } => {
format!("{}–{}", start.format_hm(), end.format_hm())
}
_ => String::new(),
};
label(&widget, cx, ids!(event_time), &time, c.secondary);
widget
.widget(cx, ids!(event_time))
.set_visible(cx, tr.h >= 36.0);
children.push((id, widget));
}
self.view.children.clear();
self.view.children.extend(children);
cx.widget_tree_mark_dirty(self.widget_uid());
self.view.draw_walk(cx, scope, walk)?;
if let Some(i) = self
.frame
.days
.iter()
.position(|d| *d == self.frame.clock.today)
{
let y = r.pos.y + self.frame.clock.minute as f64 / 60.0 * HOUR_HEIGHT;
let x = r.pos.x + self.frame.gutter + i as f64 * col_w;
self.draw_now.color = c.action;
self.draw_now.draw_abs(cx, rect(x, y, col_w, 1.0));
self.draw_now_dot.color = c.action;
self.draw_now_dot
.draw_abs(cx, rect(x - 2.5, y - 2.0, 5.0, 5.0));
}
DrawStep::done()
}
}
#[derive(Script, ScriptHook, Widget)]
pub struct CalendarTimelineHeaders {
#[deref]
view: View,
#[live(false)]
strip: bool,
#[rust]
pub frame: TimelineFrame,
}
impl Widget for CalendarTimelineHeaders {
fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) {
self.view.handle_event(cx, event, scope);
}
fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep {
let r = cx.peek_walk_turtle(walk);
let c = cx.with_vm(CalendarColors::resolve);
let days = if self.strip {
(0..7).map(|i| monday_of(self.frame.selected) + i).collect()
} else {
self.frame.days.clone()
};
let gutter = if self.strip { 0.0 } else { self.frame.gutter };
let w = (r.size.x - gutter) / days.len().max(1) as f64;
let mut children = Vec::new();
for (i, day) in days.iter().copied().enumerate() {
let id = LiveId::from_str(&format!("date_{i}"));
let child=self.view.children.iter().find(|(key,_)|*key==id).map(|(_,w)|w.clone()).unwrap_or_else(||cx.with_vm(|vm|{
let v=script_eval!(vm,{use mod.prelude.widgets_internal.* use mod.widgets.* use mod.calendar.*
use mod.shader.* mod.widgets.CalendarHitRow{flow:Down align:Align{x:0.5} spacing:0
weekday := Ink{height:16 width:Fill align:Align{x:0.5 y:0.5} draw_text.text_style:theme.font_regular{font_size:8.25}}
numeral := Ink{height:28 width:Fill align:Align{x:0.5 y:0.5} draw_text.text_style:theme.font_regular{font_size:15}}
}});WidgetRef::script_from_value(vm,v)
}));
target(
&child,
if self.strip {
CalendarAction::SelectDay(day)
} else {
CalendarAction::OpenDay(day)
},
);
place(
&child,
cx,
rect(r.pos.x + gutter + i as f64 * w, r.pos.y, w, r.size.y),
);
label(
&child,
cx,
ids!(weekday),
civil::WEEKDAY_ABBR[civil::weekday(day) as usize],
c.secondary,
);
label(
&child,
cx,
ids!(numeral),
&civil::to_ymd(day).2.to_string(),
if day == self.frame.clock.today {
c.action
} else {
c.ink
},
);
if let Some(mut row) = child.borrow_mut::<CalendarHitRow>() {
row.selected = self.strip && day == self.frame.selected;
}
children.push((id, child));
}
self.view.children.clear();
self.view.children.extend(children);
cx.widget_tree_mark_dirty(self.widget_uid());
self.view.draw_walk(cx, scope, walk)
}
}
#[derive(Script, ScriptHook, Widget)]
pub struct CalendarAllDayBand {
#[deref]
view: View,
#[rust]
frame: TimelineFrame,
#[rust]
hits: Vec<(Rect, OccurrenceKey)>,
}
impl Widget for CalendarAllDayBand {
fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) {
self.view.handle_event(cx, event, scope);
match event.hits(cx, self.view.area()) {
Hit::FingerDown(_) => {}
Hit::FingerUp(e) if e.is_primary_hit() && e.was_tap() => {
let r = self.view.area().rect(cx);
let p = e.abs;
let hits: Vec<_> = self
.hits
.iter()
.filter(|(r, _)| {
let w = r.size.x.max(44.0);
let h = r.size.y.max(44.0);
crate::presentation::rect(
r.pos.x - (w - r.size.x) * 0.5,
r.pos.y - (h - r.size.y) * 0.5,
w,
h,
)
.contains(p)
})
.collect();
let action = if hits.len() == 1 {
CalendarAction::Event(hits[0].1)
} else {
let column = ((p.x - r.pos.x - self.frame.gutter)
/ ((r.size.x - self.frame.gutter) / self.frame.days.len().max(1) as f64))
.floor()
.max(0.0) as usize;
CalendarAction::Agenda(
self.frame
.days
.get(column)
.copied()
.unwrap_or(self.frame.selected),
)
};
cx.widget_action(self.widget_uid(), action);
}
_ => {}
}
}
fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep {
let r = cx.peek_walk_turtle(walk);
let c = cx.with_vm(CalendarColors::resolve);
let n = self.frame.days.len().max(1);
let w = (r.size.x - self.frame.gutter) / n as f64;
let lanes = all_day_lanes_for_days(&self.frame.occurrences(), &self.frame.days);
let max_rows = ((r.size.y - 4.0) / 20.0).floor() as usize;
let mut children = Vec::new();
self.hits.clear();
if let Some(caption) = self
.view
.children
.iter()
.find(|(id, _)| *id == live_id!(caption))
{
children.push(caption.clone());
}
for lane in lanes.iter().filter(|l| l.lane < max_rows) {
let Some(event) = self
.frame
.events
.iter()
.find(|e| e.occurrence.key == lane.key)
else {
continue;
};
let id = occurrence_id("ribbon", lane.key);
let child = self
.view
.children
.iter()
.find(|(key, _)| *key == id)
.map(|(_, w)| w.clone())
.unwrap_or_else(|| {
cx.with_vm(|vm| {
let v = script_eval!(vm,{use mod.prelude.widgets_internal.* use mod.widgets.* mod.widgets.CalendarRibbon{}});
WidgetRef::script_from_value(vm, v)
})
});
target(&child, CalendarAction::None);
self.hits.push((
rect(
r.pos.x + self.frame.gutter + lane.day_index as f64 * w + 4.0,
r.pos.y + 2.0 + lane.lane as f64 * 20.0,
lane.day_span as f64 * w - 8.0,
18.0,
),
lane.key,
));
crate::month::set_event(&child, cx, event, c, false);
let mut style = child.clone();
let first = self
.frame
.days
.first()
.copied()
.unwrap_or(self.frame.selected);
let last = self.frame.days.last().copied().unwrap_or(first) + 1;
let continuation_left = if event.occurrence.timing.start_day() < first {
1.0
} else {
0.0
};
let continuation_right = if event.occurrence.timing.end_day_exclusive() > last {
1.0
} else {
0.0
};
script_apply_eval!(cx,style,{draw_bg +: {continuation_left:#(continuation_left) continuation_right:#(continuation_right)}});
place(
&child,
cx,
rect(
r.pos.x + self.frame.gutter + lane.day_index as f64 * w + 4.0,
r.pos.y + 2.0 + lane.lane as f64 * 20.0,
lane.day_span as f64 * w - 8.0,
18.0,
),
);
children.push((id, child));
}
if lanes.iter().any(|l| l.lane >= max_rows) {
let id = live_id!(overflow);
let child = self
.view
.children
.iter()
.find(|(key, _)| *key == id)
.map(|(_, w)| w.clone())
.unwrap_or_else(|| {
cx.with_vm(|vm| {
let v = script_eval!(vm,{use mod.prelude.widgets_internal.* use mod.widgets.* use mod.calendar.*
use mod.shader.* mod.widgets.CalendarHitRow{label := Ink{text:"More" width:Fill height:Fill}}});
WidgetRef::script_from_value(vm, v)
})
});
target(&child, CalendarAction::Agenda(self.frame.selected));
place(&child, cx, rect(r.pos.x, r.pos.y, self.frame.gutter, 44.0));
children.retain(|(id, _)| *id != live_id!(caption));
children.push((id, child));
}
self.view.children.clear();
self.view.children.extend(children);
cx.widget_tree_mark_dirty(self.widget_uid());
self.view.draw_walk(cx, scope, walk)
}
}
#[derive(Script, ScriptHook, Widget)]
pub struct CalendarTimelineScroll {
#[deref]
view: View,
#[live]
#[area]
scrolling: ScrollBars,
#[rust]
frame: TimelineFrame,
#[rust]
initialized: bool,
#[rust]
pending: Option<f64>,
}
impl Widget for CalendarTimelineScroll {
fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) {
self.scrolling.handle_event(cx, event, scope);
self.view.handle_event(cx, event, scope);
}
fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep {
let r = cx.peek_walk_turtle(walk);
if !self.initialized {
self.pending = Some(initial_scroll_for_day(
self.frame.clock,
self.frame.selected,
r.size.y,
));
self.initialized = true;
}
if let Some(y) = self.pending.take() {
self.scrolling.set_scroll_pos_no_clip(cx, dvec2(0.0, y));
}
if let Some(mut grid) = self
.view
.widget(cx, ids!(grid))
.borrow_mut::<TimelineGrid>()
{
grid.frame = self.frame.clone();
}
self.scrolling.begin(
cx,
walk,
Layout {
flow: Flow::Down,
..Layout::default()
},
);
self.view.draw_walk(
cx,
scope,
Walk {
width: Size::fill(),
height: Size::Fixed(TIMELINE_CONTENT_HEIGHT),
..Walk::default()
},
)?;
self.scrolling.end(cx);
DrawStep::done()
}
}
#[derive(Script, ScriptHook, Widget)]
pub struct CalendarTimeline {
#[deref]
view: View,
#[rust]
pub frame: TimelineFrame,
#[live(true)]
show_headers: bool,
}
impl CalendarTimeline {
pub fn scroll_state(&self, cx: &mut Cx) -> Option<f64> {
self.view
.widget(cx, ids!(body))
.borrow::<CalendarTimelineScroll>()
.and_then(|b| b.initialized.then(|| b.scrolling.get_scroll_pos().y))
}
pub fn scroll(&self, cx: &mut Cx) -> f64 {
self.view
.widget(cx, ids!(body))
.borrow::<CalendarTimelineScroll>()
.map(|b| b.scrolling.get_scroll_pos().y)
.unwrap_or(0.0)
}
pub fn set_scroll_y(&self, cx: &mut Cx, y: f64) {
if let Some(mut b) = self
.view
.widget(cx, ids!(body))
.borrow_mut::<CalendarTimelineScroll>()
{
b.pending = Some(y);
b.initialized = true;
}
}
pub fn scroll_to_today(&self, cx: &mut Cx) {
if let Some(mut b) = self
.view
.widget(cx, ids!(body))
.borrow_mut::<CalendarTimelineScroll>()
{
b.initialized = false;
}
}
}
impl Widget for CalendarTimeline {
fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) {
self.view.handle_event(cx, event, scope);
}
fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep {
self.view
.widget(cx, ids!(headers))
.set_visible(cx, self.show_headers);
if let Some(mut h) = self
.view
.widget(cx, ids!(headers))
.borrow_mut::<CalendarTimelineHeaders>()
{
h.frame = self.frame.clone();
}
let rows = all_day_lanes_for_days(&self.frame.occurrences(), &self.frame.days)
.iter()
.map(|r| r.lane + 1)
.max()
.unwrap_or(0);
let height = if !self.frame.short && rows > 2 {
88.0
} else {
44.0
};
let mut band = self.view.widget(cx, ids!(all_day));
script_apply_eval!(cx,band,{height:#(height)});
if let Some(mut b) = band.borrow_mut::<CalendarAllDayBand>() {
b.frame = self.frame.clone();
}
if let Some(mut b) = self
.view
.widget(cx, ids!(body))
.borrow_mut::<CalendarTimelineScroll>()
{
b.frame = self.frame.clone();
}
self.view.draw_walk(cx, scope, walk)
}
}

2819
apps/calendar/src/view.rs Normal file

File diff suppressed because it is too large Load diff

42
apps/clock/Cargo.toml Normal file
View file

@ -0,0 +1,42 @@
# clock — a small Makepad app with two resident faces: the full clock with a
# stopwatch and a countdown, and a compact home-widget tile. The WM picks the
# face on the same window/swapchain via `HostedView`; one process, one model —
# or, hosted in-process, one module instance in its own isolate (src/module.rs).
[package]
name = "makepad-clock"
version = "0.1.0"
edition = "2021"
default-run = "clock"
[[bin]]
name = "clock"
path = "src/main.rs"
required-features = ["standalone"]
[lib]
name = "makepad_clock"
path = "src/lib.rs"
[features]
# On-demand dylib for the Android super-app (apps/wm-dyn). No engine dep
# here: the host that builds the lib binds it to its engine dylib itself
# (apps/wm/src/dylib_host.rs), so this crate's manifest is the desktop's.
dynamic-module = []
default = ["standalone"]
# The standalone window: the F10 overlay and the port toward it. A host
# that links the MODULE (makepad-wm) leaves this off and gets the lib only.
standalone = ["dep:makepad-aichat", "dep:makepad-wm-api"]
[dependencies]
makepad-widgets = { path = "../../widgets" }
makepad-wm-theme = { path = "../../libs/wm_theme" }
makepad-civil-time = { path = "../../libs/civil_time" }
# The assistant: the F10 overlay in this window, and the clock's one tool.
makepad-aichat = { path = "../aichat", default-features = false, features = ["engine"], optional = true }
# The window manager's vocabulary for the standalone window: the bar's
# title and the polite close (the module has no window of its own).
makepad-wm-api = { path = "../../libs/wm_api", optional = true }
makepad-ai-services = { path = "../../libs/ai/services" }
# The module contract: what the window manager seats in-process (src/module.rs).
makepad-app-module = { path = "../../libs/app_module" }

24
apps/clock/ci.splash Normal file
View file

@ -0,0 +1,24 @@
// CI smoke test for apps/clock. Run by tools/ci, which finds every `ci.splash`
// in the checkout. The script decides every input; the vision model only
// judges the grab against the acceptance text below. Basic on purpose: does
// it check everywhere, build and come up here, and look like what it is.
use mod.std.*
use mod.ci;
ci.step("check other platforms", fn(){
ci.check_targets({packages: ["makepad-clock"] targets: ci.host.targets})
})
let app = ci.launch({package: "makepad-clock" binary: "clock" cwd: "."})
ci.step("comes up", fn(){
ci.sleep(3.0)
app.no_errors()
let shot = app.grab("start")
ci.accept(shot, "This is a screenshot of one application window taken from a Mac. A thin bright red frame runs around the very edge of the picture; it is the test harness's marker and is not part of the application. Ignore it. The application is a clock. It came up when these are true: 1. The picture is not blank: it is not one flat colour from edge to edge, and it is not entirely black or entirely white. 2. Some user interface is visible: at least two different things among text labels, buttons, icons, panels, lists, input fields, a toolbar, a drawing or a picture. 3. No error panel covers the window: there is no large block of text with words such as panic, error, failed, backtrace or unwrap. 4. The time is visible: a round clock face with hands, or large digits showing hours and minutes. Write one short bullet line for each numbered point saying what you see. YES means points 1, 2, 3 and 4 are all satisfied. Then write the verdict as the last line, in exactly this form: VERDICT: YES or VERDICT: NO")
})
app.no_errors()
app.quit()
nil

View file

@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#1b3354"/>
<stop offset="1" stop-color="#0b1627"/>
</linearGradient>
<linearGradient id="sheen" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#ffffff" stop-opacity="0.10"/>
<stop offset="0.5" stop-color="#ffffff" stop-opacity="0"/>
</linearGradient>
</defs>
<rect x="64" y="64" width="896" height="896" rx="200" fill="url(#bg)"/>
<rect x="64" y="64" width="896" height="896" rx="200" fill="url(#sheen)"/>
<rect x="66" y="66" width="892" height="892" rx="198" fill="none" stroke="#ffffff" stroke-opacity="0.08" stroke-width="4"/>
<circle cx="512" cy="512" r="290" fill="#e8eef6"/>
<g fill="#0e1d33"><rect x="494" y="262" width="36" height="64" rx="14"/><rect x="494" y="698" width="36" height="64" rx="14"/><rect x="262" y="494" width="64" height="36" rx="14"/><rect x="698" y="494" width="64" height="36" rx="14"/></g>
<g stroke="#0e1d33" stroke-linecap="round"><path d="M512 512 L398 446" stroke-width="46"/><path d="M512 512 L638 336" stroke-width="34"/></g>
<path d="M512 512 L560 660" stroke="#ff5c39" stroke-width="22" stroke-linecap="round"/>
<circle cx="512" cy="512" r="34" fill="#ff5c39"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#FFFFFF" d="M12 4.2a8 8 0 1 0 0 16 8 8 0 0 0 0-16zm0 2a6 6 0 1 1 0 12 6 6 0 0 1 0-12zm-.9 2.3h1.8v3.9l2.7 1.6-.9 1.5-3.6-2.1V8.5zM4.6 2.3 7.5 4.7 6.2 6.2 3.3 3.8zM19.4 2.3l2.9 1.5-1.3 1.5-2.9-2.4zM6.4 19.5l1.4 1.4-2 1.9-1.4-1.4zm11.2 0 2 1.9-1.4 1.4-2-1.9z"/></svg>

After

Width:  |  Height:  |  Size: 338 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#FFFFFF" d="M12 2.5a1.5 1.5 0 0 1 1.5 1.5v.7a6 6 0 0 1 4.5 5.8v4l1.7 2.4a1 1 0 0 1-.8 1.6H5.1a1 1 0 0 1-.8-1.6L6 14.5v-4a6 6 0 0 1 4.5-5.8V4A1.5 1.5 0 0 1 12 2.5zm-2.3 17h4.6a2.3 2.3 0 0 1-4.6 0z"/></svg>

After

Width:  |  Height:  |  Size: 277 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#FFFFFF" d="m8.6 4.6 7.4 7.4-7.4 7.4-1.6-1.6 5.8-5.8-5.8-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 143 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#FFFFFF" d="M12 2.5a9.5 9.5 0 1 0 0 19 9.5 9.5 0 0 0 0-19zm0 2a7.5 7.5 0 1 1 0 15 7.5 7.5 0 0 1 0-15zm-.9 3.2h1.8v4.7l3.4 2-.9 1.5-4.3-2.5V7.7z"/></svg>

After

Width:  |  Height:  |  Size: 225 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#FFFFFF" d="M12 4.5a2 2 0 1 1 0 4 2 2 0 0 1 0-4zm0 5.5a2 2 0 1 1 0 4 2 2 0 0 1 0-4zm0 5.5a2 2 0 1 1 0 4 2 2 0 0 1 0-4z"/></svg>

After

Width:  |  Height:  |  Size: 200 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#FFFFFF" d="M11 4h2v7h7v2h-7v7h-2v-7H4v-2h7z"/></svg>

After

Width:  |  Height:  |  Size: 126 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#FFFFFF" d="M9.5 1.8h5v2h-1.5v1.3a8.5 8.5 0 1 1-2 0V3.8H9.5v-2zM12 7a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13zm-.9 2.4h1.8v4.2h3.2v1.8h-5V9.4zM18.4 4.6l1.5 1.4-1.6 1.6-1.4-1.5z"/></svg>

After

Width:  |  Height:  |  Size: 255 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#FFFFFF" d="M12 2.5a9.5 9.5 0 1 1-6.7 2.8l1.4 1.4A7.5 7.5 0 1 0 12 4.5v3l-4.5-3.5L12 .5v2z"/></svg>

After

Width:  |  Height:  |  Size: 172 B

45
apps/clock/src/ai.rs Normal file
View file

@ -0,0 +1,45 @@
//! clock on the AI bus: one read tool over the state on screen — the same
//! tool the standalone service answers over its port and the module
//! executor answers on the root at call time.
use crate::view::ClockView;
use makepad_ai_services::wire::{Risk, ServiceCall, ServiceManifest, ToolDef, ToolResult};
/// The manifest shared by the standalone service and the module executor.
pub fn manifest() -> ServiceManifest {
ServiceManifest::new(
"clock",
"Clock",
"The clock on screen: local time and date, the next alarm, and whether a timer or the stopwatch is running.",
)
.with_tool(ToolDef::new(
"now",
"The current local time and date, the next alarm, and any running timer or stopwatch.",
r#"{"type":"object","properties":{}}"#,
Risk::Read,
))
}
/// Answer one call against the clock on screen. Every branch answers; an
/// unknown name is refused with the name that exists.
pub fn answer(view: &ClockView, call: &ServiceCall) -> ToolResult {
match call.tool.as_str() {
"now" => ToolResult::ok(&call.call_id, view.ai_summary(), ""),
other => ToolResult::failed(&call.call_id, format!("unknown tool `{other}`; this app only has `now`")),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_manifest_declares_one_read_tool() {
let m = manifest();
assert_eq!(m.id, "clock");
assert!(m.validate().is_ok());
assert_eq!(m.tools.len(), 1);
assert_eq!(m.tools[0].name, "now");
assert_eq!(m.tools[0].risk, Risk::Read);
}
}

148
apps/clock/src/alarm.rs Normal file
View file

@ -0,0 +1,148 @@
use crate::local_time::LocalTime;
use makepad_widgets::*;
use makepad_widgets::makepad_micro_serde::*;
use std::{sync::{Arc, atomic::{AtomicBool, Ordering}}, time::{Duration, Instant}};
#[derive(Clone, Debug, PartialEq, SerJson, DeJson)]
pub struct AlarmSpec {
pub id: u64,
pub hour: u32,
pub minute: u32,
pub label: String,
pub enabled: bool,
}
impl AlarmSpec {
pub fn time(&self) -> String { format!("{:02}:{:02}", self.hour, self.minute) }
}
pub struct Alarm {
pub spec: AlarmSpec,
pub ringing: bool,
last_day: Option<(i32, u32, u32)>,
snooze: Option<Instant>,
}
impl Alarm {
fn new(spec: AlarmSpec) -> Self { Self { spec, ringing: false, last_day: None, snooze: None } }
fn tick_at(&mut self, now: LocalTime, monotonic: Instant) {
if !self.spec.enabled { return; }
if self.snooze.is_some_and(|t| monotonic >= t) { self.snooze = None; self.ringing = true; }
let day = (now.year, now.month, now.day);
if self.last_day != Some(day) && now.hour == self.spec.hour && now.minute == self.spec.minute {
self.last_day = Some(day); self.ringing = true;
}
}
fn stop(&mut self) { self.ringing = false; self.snooze = None; }
}
#[derive(Default)]
pub struct AlarmBook {
pub alarms: Vec<Alarm>,
next_id: u64,
}
impl AlarmBook {
pub fn specs(&self) -> Vec<AlarmSpec> { self.alarms.iter().map(|a| a.spec.clone()).collect() }
pub fn find(&self, id: u64) -> Option<&AlarmSpec> { self.alarms.iter().find(|a| a.spec.id == id).map(|a| &a.spec) }
pub fn save(&mut self, id: Option<u64>, hour: u32, minute: u32, label: String) -> u64 {
if let Some(alarm) = id.and_then(|id| self.alarms.iter_mut().find(|a| a.spec.id == id)) {
alarm.spec.hour = hour % 24; alarm.spec.minute = minute % 60; alarm.spec.label = label;
alarm.stop();
return alarm.spec.id;
}
self.next_id += 1;
let id = self.next_id;
self.alarms.push(Alarm::new(AlarmSpec { id, hour: hour % 24, minute: minute % 60, label, enabled: true }));
id
}
pub fn toggle(&mut self, id: u64) {
if let Some(a) = self.alarms.iter_mut().find(|a| a.spec.id == id) {
a.spec.enabled = !a.spec.enabled;
if !a.spec.enabled { a.stop(); }
}
}
pub fn remove(&mut self, id: u64) { self.alarms.retain(|a| a.spec.id != id); }
pub fn tick(&mut self, now: LocalTime) {
let monotonic = Instant::now();
for alarm in &mut self.alarms { alarm.tick_at(now, monotonic); }
}
pub fn ringing(&self) -> bool { self.alarms.iter().any(|a| a.ringing) }
pub fn stop(&mut self) { for a in self.alarms.iter_mut().filter(|a| a.ringing) { a.stop(); } }
pub fn snooze(&mut self) {
for a in self.alarms.iter_mut().filter(|a| a.ringing) {
a.ringing = false; a.snooze = Some(Instant::now() + Duration::from_secs(300));
}
}
pub fn next(&self, now: LocalTime) -> Option<&AlarmSpec> {
let minute = now.hour * 60 + now.minute;
self.alarms.iter().filter(|a| a.spec.enabled)
.min_by_key(|a| (a.spec.hour * 60 + a.spec.minute + 1440 - minute) % 1440)
.map(|a| &a.spec)
}
pub fn encode(&self) -> Vec<u8> { self.specs().serialize_json().into_bytes() }
pub fn decode(bytes: &[u8]) -> Option<Self> {
if bytes.len() > 256 * 1024 { return None; }
let specs = Vec::<AlarmSpec>::deserialize_json(std::str::from_utf8(bytes).ok()?).ok()?;
let mut book = Self::default();
for spec in specs.into_iter().take(1000) {
if spec.id == 0 || spec.id == u64::MAX || spec.hour >= 24 || spec.minute >= 60 || spec.label.len() > 512 || book.find(spec.id).is_some() { return None; }
book.next_id = book.next_id.max(spec.id);
book.alarms.push(Alarm::new(spec));
}
Some(book)
}
}
/// One persistent audio callback. The UI changes only its atomic gate.
pub fn install_sound(cx: &mut Cx) -> Arc<AtomicBool> {
let signal = Arc::new(AtomicBool::new(false));
let gate = signal.clone();
let mut time = 0.0f64;
cx.audio_output(0, move |info, output| {
output.zero();
if !gate.load(Ordering::Relaxed) { time = 0.0; return; }
let rate = info.sample_rate.max(1.0);
for frame in 0..output.frame_count() {
let beat = time % 1.6;
let pulse = beat % 0.4;
let envelope = if beat < 1.2 && pulse < 0.24 { (std::f64::consts::PI * pulse / 0.24).sin().powi(2) } else { 0.0 };
let sample = ((time * std::f64::consts::TAU * 660.0).sin() * envelope * 0.12) as f32;
for channel in 0..output.channel_count() { output.channel_mut(channel)[frame] = sample; }
time += 1.0 / rate;
}
});
signal
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn alarms_ring_independently_once_per_day_and_remove_by_stable_id() {
let mut book = AlarmBook::default();
let a = book.save(None, 7, 0, "Morning".into());
let b = book.save(None, 7, 1, "Tea".into());
let mut now = LocalTime {year: 2026, month: 9, day: 6, hour: 7, minute: 0, ..Default::default()};
book.tick(now); assert!(book.alarms[0].ringing && !book.alarms[1].ringing);
book.stop(); book.tick(now); assert!(!book.ringing());
now.minute = 1; book.tick(now); assert!(book.alarms[1].ringing);
book.remove(a); assert_eq!(book.alarms[0].spec.id, b);
book.remove(b); assert!(!book.ringing());
}
#[test]
fn snoozing_one_alarm_does_not_change_another_and_disabling_cancels_it() {
let mut book = AlarmBook::default();
let a = book.save(None, 7, 0, String::new()); book.save(None, 8, 0, String::new());
let now = LocalTime {year:2026, month:9, day:6, hour:7, minute:0, ..Default::default()};
book.tick(now); book.snooze();
assert!(book.alarms[0].snooze.is_some() && book.alarms[1].snooze.is_none());
book.toggle(a); assert!(book.alarms[0].snooze.is_none());
book.alarms[0].tick_at(now, Instant::now() + Duration::from_secs(400)); assert!(!book.ringing());
}
#[test]
fn persisted_alarms_keep_ids_and_settings_without_resuming_a_ring() {
let mut book = AlarmBook::default();
let id = book.save(None, 6, 30, "Early train".into());
let mut restored = AlarmBook::decode(&book.encode()).unwrap();
assert_eq!(restored.find(id), book.find(id)); assert!(!restored.ringing());
assert!(restored.save(None, 9, 0, String::new()) > id);
}
}

View file

@ -0,0 +1,390 @@
//! The alarms as continuous 108 pt rows: the time large and light, the
//! label and repeat beneath, a hairline between rows, a green/grey toggle
//! at the trailing edge (its knob slides 220 ms, ease-out), a 76 pt Delete
//! reveal on a leftward swipe, and the empty state with its Add action.
//! Ids stay stable across edits; the view owns the book, this draws it.
use crate::alarm::AlarmSpec;
use makepad_widgets::*;
script_mod! {
use mod.prelude.widgets_internal.*
mod.widgets.AlarmList = set_type_default() do #(AlarmList::register_widget(vm)) {
width: Fill height: Fill
time_text +: {text_style: theme.font_regular{font_size: 42} color: theme.color_text}
caption +: {text_style: theme.font_regular{font_size: 11.25} color: theme.color_text}
empty_title +: {text_style: theme.font_bold{font_size: 16.5} color: theme.color_text}
secondary: theme.color_text_disabled
toggle_on: #34c759
toggle_off: #d1d1d6
delete: #c52f35
hairline_alpha: 0.12
shape +: {
radius: instance(12.0)
pixel: fn() {
let sdf = Sdf2d.viewport(self.pos * self.rect_size)
sdf.box(0.0, 0.0, self.rect_size.x, self.rect_size.y, self.radius)
sdf.fill(self.color)
return sdf.result
}
}
}
}
#[derive(Clone, Debug, Default)]
pub enum AlarmListAction {
Edit(u64),
Toggle(u64),
Remove(u64),
Add,
#[default]
None,
}
#[derive(Clone, Copy, PartialEq, Default)]
enum DragAxis {
#[default]
Pending,
Horizontal,
Vertical,
}
#[derive(Clone, Copy)]
struct Drag {
start: Vec2d,
scroll: f64,
reveal: f64,
row: Option<u64>,
axis: DragAxis,
}
#[derive(Script, ScriptHook, Widget)]
pub struct AlarmList {
#[uid]
uid: WidgetUid,
#[source]
source: ScriptObjectRef,
#[walk]
walk: Walk,
#[redraw]
#[rust]
area: Area,
#[live]
shape: DrawColor,
#[live]
time_text: DrawText,
#[live]
caption: DrawText,
#[live]
empty_title: DrawText,
#[live]
secondary: Vec4f,
#[live]
toggle_on: Vec4f,
#[live]
toggle_off: Vec4f,
#[live]
delete: Vec4f,
#[live(0.12)]
hairline_alpha: f32,
#[rust]
rows: Vec<AlarmSpec>,
#[rust]
scroll: f64,
#[rust]
reveal: f64,
#[rust]
revealed: Option<u64>,
#[rust]
drag: Option<Drag>,
/// Each toggle's knob position, 0..1, animated toward its state.
#[rust]
knobs: Vec<(u64, f64)>,
#[rust]
next: NextFrame,
#[rust]
last: f64,
/// The empty state's Add button rect, for hits.
#[rust]
add_rect: Option<Rect>,
/// Footer text drawn under the rows (the runtime note).
#[live]
footer: String,
}
pub const ROW: f64 = 108.0;
const DELETE: f64 = 76.0;
const KNOB_SECS: f64 = 0.22;
fn drag_axis(delta: Vec2d) -> DragAxis {
if delta.x.abs().max(delta.y.abs()) < 8.0 {
DragAxis::Pending
} else if delta.x.abs() > delta.y.abs() * 1.2 {
DragAxis::Horizontal
} else {
DragAxis::Vertical
}
}
impl AlarmList {
pub fn set_rows(&mut self, cx: &mut Cx, rows: Vec<AlarmSpec>) {
if rows != self.rows {
// A toggle that changed state animates from where it is; a new
// row starts at its state.
let mut animate = false;
for row in &rows {
let target = if row.enabled { 1.0 } else { 0.0 };
match self.knobs.iter_mut().find(|(id, _)| *id == row.id) {
Some((_, pos)) => {
if (*pos - target).abs() > 0.001 {
animate = true;
}
}
None => self.knobs.push((row.id, target)),
}
}
self.knobs.retain(|(id, _)| rows.iter().any(|r| r.id == *id));
self.rows = rows;
if !self.rows.iter().any(|a| Some(a.id) == self.revealed) {
self.revealed = None;
self.reveal = 0.0;
}
self.clamp_scroll(cx);
if animate {
self.last = 0.0;
self.next = cx.new_next_frame();
}
self.redraw(cx);
}
}
pub fn set_footer(&mut self, cx: &mut Cx, footer: &str) {
if self.footer != footer {
self.footer = footer.to_string();
self.redraw(cx);
}
}
fn content_height(&self) -> f64 {
self.rows.len() as f64 * ROW + 40.0
}
fn clamp_scroll(&mut self, cx: &Cx) {
self.scroll = self.scroll.clamp(0.0, (self.content_height() - self.area.rect(cx).size.y).max(0.0));
}
fn at(&self, cx: &Cx, p: Vec2d) -> Option<u64> {
if !self.area.rect(cx).contains(p) {
return None;
}
let row = ((p.y - self.area.rect(cx).pos.y + self.scroll) / ROW).floor();
if row < 0.0 {
return None;
}
self.rows.get(row as usize).map(|a| a.id)
}
fn rounded(&mut self, cx: &mut Cx2d, r: Rect, radius: f32, color: Vec4f) {
self.shape.color = color;
self.shape.draw_vars.set_dyn_instance(cx, live_id!(radius), &[radius]);
self.shape.draw_abs(cx, r);
}
}
impl Widget for AlarmList {
fn handle_event(&mut self, cx: &mut Cx, event: &Event, _scope: &mut Scope) {
if let Some(frame) = self.next.is_event(event) {
let dt = if self.last == 0.0 { 1.0 / 60.0 } else { (frame.time - self.last).clamp(0.001, 0.05) };
self.last = frame.time;
let mut moving = false;
for row in &self.rows {
let target = if row.enabled { 1.0 } else { 0.0 };
if let Some((_, pos)) = self.knobs.iter_mut().find(|(id, _)| *id == row.id) {
let step = dt / KNOB_SECS;
if (*pos - target).abs() > step {
*pos += step * (target - *pos).signum();
moving = true;
} else {
*pos = target;
}
}
}
if moving {
self.next = cx.new_next_frame();
}
self.redraw(cx);
}
match event.hits(cx, self.area) {
Hit::FingerDown(e) if e.device.is_primary_hit() => {
let row = self.at(cx, e.abs);
if row != self.revealed {
self.revealed = None;
self.reveal = 0.0;
}
self.drag = Some(Drag { start: e.abs, scroll: self.scroll, reveal: self.reveal, row, axis: DragAxis::Pending });
}
Hit::FingerMove(e) => {
if let Some(mut drag) = self.drag {
let delta = e.abs - drag.start;
if drag.axis == DragAxis::Pending {
drag.axis = drag_axis(delta);
}
match drag.axis {
DragAxis::Horizontal => {
if drag.row.is_some() {
self.revealed = drag.row;
self.reveal = (drag.reveal - delta.x).clamp(0.0, DELETE);
}
}
DragAxis::Vertical => {
self.scroll = drag.scroll - delta.y;
self.clamp_scroll(cx);
}
_ => {}
}
self.drag = Some(drag);
self.redraw(cx);
}
}
Hit::FingerUp(e) if e.is_primary_hit() => {
if let Some(drag) = self.drag.take() {
if drag.axis == DragAxis::Horizontal {
self.reveal = if self.reveal > DELETE * 0.35 { DELETE } else { 0.0 };
if self.reveal == 0.0 {
self.revealed = None;
}
} else if drag.axis == DragAxis::Pending && !e.cancelled {
let r = self.area.rect(cx);
if self.rows.is_empty() {
if self.add_rect.is_some_and(|a| a.contains(e.abs)) {
cx.widget_action(self.uid, AlarmListAction::Add);
}
} else if self.at(cx, e.abs) == drag.row {
if let Some(id) = drag.row {
let row_top = r.pos.y + (e.abs.y - r.pos.y + self.scroll).div_euclid(ROW) * ROW - self.scroll;
let local = e.abs - dvec2(r.pos.x, row_top);
let action = if self.revealed == Some(id) && e.abs.x > r.pos.x + r.size.x - DELETE {
AlarmListAction::Remove(id)
} else if self.reveal > 0.0 {
self.revealed = None;
self.reveal = 0.0;
AlarmListAction::None
} else if local.x >= r.size.x - 100.0 && local.y >= 26.0 && local.y <= 82.0 {
// The toggle's 60×56 hit box around its 51×31 track.
AlarmListAction::Toggle(id)
} else {
AlarmListAction::Edit(id)
};
cx.widget_action(self.uid, action);
}
}
}
self.redraw(cx);
}
}
Hit::FingerScroll(e) => {
self.scroll += e.scroll.y;
self.clamp_scroll(cx);
self.redraw(cx);
}
Hit::FingerHoverIn(_) | Hit::FingerHoverOver(_) => cx.set_cursor(MouseCursor::Hand),
_ => {}
}
}
fn draw_walk(&mut self, cx: &mut Cx2d, _scope: &mut Scope, walk: Walk) -> DrawStep {
cx.begin_turtle(walk, Layout { clip_x: true, clip_y: true, ..Default::default() });
let r = cx.turtle().rect();
self.scroll = self.scroll.clamp(0.0, (self.content_height() - r.size.y).max(0.0));
let color = self.time_text.color;
let caption_color = self.caption.color;
if self.rows.is_empty() {
// Title, explanation, then a 144×48 Add: a 122 pt group placed
// 92 pt down a tall page, centred in a short (landscape) one.
let cy = r.pos.y + 132.0f64.min((r.size.y - 122.0) * 0.5 + 40.0);
if let Some(run) = self.empty_title.prepare_single_line_run(cx, "No alarms") {
self.empty_title.draw_abs(cx, dvec2(r.pos.x + (r.size.x - run.width_in_lpxs as f64) * 0.5, cy - 40.0), "No alarms");
}
self.caption.color = self.secondary;
let line = "Add one and it rings while Clock is running.";
if let Some(run) = self.caption.prepare_single_line_run(cx, line) {
self.caption.draw_abs(cx, dvec2(r.pos.x + (r.size.x - run.width_in_lpxs as f64) * 0.5, cy - 6.0), line);
}
let add = Rect { pos: dvec2(r.pos.x + (r.size.x - 144.0) * 0.5, cy + 34.0), size: dvec2(144.0, 48.0) };
self.rounded(cx, add, 12.0, self.toggle_on);
self.caption.color = vec4(1.0, 1.0, 1.0, 1.0);
if let Some(run) = self.caption.prepare_single_line_run(cx, "Add alarm") {
let ink = (run.ascender_in_lpxs - run.descender_in_lpxs) as f64;
self.caption.draw_abs(cx, dvec2(add.pos.x + (add.size.x - run.width_in_lpxs as f64) * 0.5, add.pos.y + (add.size.y - ink) * 0.5), "Add alarm");
}
self.add_rect = Some(add);
self.caption.color = caption_color;
} else {
self.add_rect = None;
}
let from = (self.scroll / ROW).floor() as usize;
let to = ((self.scroll + r.size.y) / ROW).ceil() as usize;
let rows = self.rows.clone();
for index in from..to.min(rows.len()) {
let row = &rows[index];
let y = r.pos.y + index as f64 * ROW - self.scroll;
let offset = if self.revealed == Some(row.id) { self.reveal } else { 0.0 };
if offset > 0.0 {
self.rounded(cx, Rect { pos: dvec2(r.pos.x + r.size.x - DELETE, y + 8.0), size: dvec2(DELETE, ROW - 16.0) }, 10.0, self.delete);
self.caption.color = vec4(1.0, 1.0, 1.0, 1.0);
if let Some(run) = self.caption.prepare_single_line_run(cx, "Delete") {
let ink = (run.ascender_in_lpxs - run.descender_in_lpxs) as f64;
self.caption.draw_abs(cx, dvec2(r.pos.x + r.size.x - DELETE + (DELETE - run.width_in_lpxs as f64) * 0.5, y + (ROW - ink) * 0.5), "Delete");
}
self.caption.color = caption_color;
}
let x = r.pos.x - offset;
// The time: 56/300, disabled in Secondary; the label/repeat beneath.
self.time_text.color = if row.enabled { color } else { self.secondary };
if let Some(run) = self.time_text.prepare_single_line_run(cx, &row.time()) {
let ink = (run.ascender_in_lpxs - run.descender_in_lpxs) as f64;
self.time_text.draw_abs(cx, dvec2(x, y + 8.0 + (62.0 - ink) * 0.5), &row.time());
}
let title = if row.label.trim().is_empty() { "Alarm" } else { row.label.as_str() };
self.caption.color = if row.enabled { caption_color } else { self.secondary };
let line = format!("{} · Every day", title);
if let Some(run) = self.caption.prepare_single_line_run(cx, &line) {
let ink = (run.ascender_in_lpxs - run.descender_in_lpxs) as f64;
self.caption.draw_abs(cx, dvec2(x, y + 75.0 + (21.0 - ink) * 0.5), &line);
}
self.caption.color = caption_color;
// The toggle: a 51×31 track at (w−51, 38), the 27 pt knob travelling 20.
let knob_pos = self.knobs.iter().find(|(id, _)| *id == row.id).map(|(_, p)| *p).unwrap_or(if row.enabled { 1.0 } else { 0.0 });
let track = Rect { pos: dvec2(x + r.size.x - 51.0, y + 38.0), size: dvec2(51.0, 31.0) };
let track_color = self.toggle_off + (self.toggle_on - self.toggle_off) * knob_pos as f32;
self.rounded(cx, track, 7.75, track_color);
self.rounded(cx, Rect { pos: track.pos + dvec2(2.0 + 20.0 * knob_pos, 2.0), size: dvec2(27.0, 27.0) }, 6.75, vec4(1.0, 1.0, 1.0, 1.0));
// The hairline under the row.
let hair = vec4(color.x, color.y, color.z, self.hairline_alpha);
self.rounded(cx, Rect { pos: dvec2(x, y + ROW - 0.5), size: dvec2(r.size.x, 0.5) }, 0.0, hair);
}
if !rows.is_empty() && !self.footer.is_empty() {
let y = r.pos.y + rows.len() as f64 * ROW - self.scroll + 12.0;
self.caption.color = self.secondary;
let size = self.caption.text_style.font_size;
self.caption.text_style.font_size = 9.75;
let footer = self.footer.clone();
self.caption.draw_abs(cx, dvec2(r.pos.x, y), &footer);
self.caption.text_style.font_size = size;
self.caption.color = caption_color;
}
self.time_text.color = color;
cx.end_turtle_with_area(&mut self.area);
DrawStep::done()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scrolling_does_not_reveal_delete() {
assert!(drag_axis(dvec2(4.0, 30.0)) == DragAxis::Vertical);
assert!(drag_axis(dvec2(-30.0, 4.0)) == DragAxis::Horizontal);
assert!(drag_axis(dvec2(-4.0, 2.0)) == DragAxis::Pending);
}
}

100
apps/clock/src/digits.rs Normal file
View file

@ -0,0 +1,100 @@
//! A numeric readout with stable digit advances: every digit is drawn in a
//! cell as wide as the widest digit, so a ticking stopwatch does not
//! jitter sideways (the shaper exposes no tabular figures). Separators
//! keep their natural width. Centred in its box on the ink.
use makepad_widgets::*;
script_mod! {
use mod.prelude.widgets_internal.*
mod.widgets.TabularLabelBase = #(TabularLabel::register_widget(vm))
mod.widgets.TabularLabel = set_type_default() do mod.widgets.TabularLabelBase {
width: Fill height: 68
draw_text +: {text_style: theme.font_regular{font_size: 42} color: theme.color_text}
}
}
#[derive(Script, ScriptHook, Widget)]
pub struct TabularLabel {
#[uid]
uid: WidgetUid,
#[source]
source: ScriptObjectRef,
#[walk]
walk: Walk,
#[redraw]
#[rust]
area: Area,
#[live]
draw_text: DrawText,
#[live]
text: String,
/// 0 = left, 0.5 = centred, 1 = right, within the box.
#[live(0.5)]
align_x: f64,
}
impl TabularLabel {
pub fn set_text(&mut self, cx: &mut Cx, text: &str) {
if self.text != text {
self.text = text.to_string();
self.redraw(cx);
}
}
/// Where the run sits across the box: 0 left, 0.5 centred, 1 right.
pub fn set_align_x(&mut self, cx: &mut Cx, align_x: f64) {
if self.align_x != align_x {
self.align_x = align_x;
self.redraw(cx);
}
}
pub fn text(&self) -> &str {
&self.text
}
/// The ink the digits draw with (the theme's text role).
pub fn ink(&self) -> Vec4f {
self.draw_text.color
}
}
impl Widget for TabularLabel {
fn handle_event(&mut self, _cx: &mut Cx, _event: &Event, _scope: &mut Scope) {}
fn draw_walk(&mut self, cx: &mut Cx2d, _scope: &mut Scope, walk: Walk) -> DrawStep {
cx.begin_turtle(walk, Layout::default());
let r = cx.turtle().rect();
let text = self.text.clone();
let Some(zero) = self.draw_text.prepare_single_line_run(cx, "0") else {
cx.end_turtle_with_area(&mut self.area);
return DrawStep::done();
};
let cell = zero.width_in_lpxs as f64;
let ink = (zero.ascender_in_lpxs - zero.descender_in_lpxs) as f64;
// Measure: digits take the cell, everything else its own width.
let mut widths = Vec::with_capacity(text.chars().count());
let mut total = 0.0;
for ch in text.chars() {
let w = if ch.is_ascii_digit() {
cell
} else {
let s = ch.to_string();
self.draw_text.prepare_single_line_run(cx, &s).map(|run| run.width_in_lpxs as f64).unwrap_or(0.0)
};
widths.push(w);
total += w;
}
let mut x = r.pos.x + (r.size.x - total) * self.align_x;
let y = r.pos.y + (r.size.y - ink) * 0.5;
for (ch, w) in text.chars().zip(widths) {
let s = ch.to_string();
if ch.is_ascii_digit() {
let glyph_w = self.draw_text.prepare_single_line_run(cx, &s).map(|run| run.width_in_lpxs as f64).unwrap_or(w);
self.draw_text.draw_abs(cx, dvec2(x + (w - glyph_w) * 0.5, y), &s);
} else {
self.draw_text.draw_abs(cx, dvec2(x, y), &s);
}
x += w;
}
cx.end_turtle_with_area(&mut self.area);
DrawStep::done()
}
}

Some files were not shown because too many files have changed in this diff Show more