Compare commits
1 commit
nigig-traf
...
portallist
| Author | SHA1 | Date | |
|---|---|---|---|
| ecf5a572ab |
59
.gitignore
vendored
|
|
@ -2,19 +2,6 @@
|
|||
# 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
|
||||
|
|
@ -51,7 +38,6 @@ GOOGLE_KEY
|
|||
experiments/websocket-windows/nodejs-websocketserver
|
||||
experiments/svgloader
|
||||
CLAUDE.md
|
||||
!examples/godot/resources/template/CLAUDE.md
|
||||
|
||||
/tools/__pycache__/
|
||||
**/*.obsidian
|
||||
|
|
@ -94,48 +80,3 @@ 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
|
|
@ -1,294 +1,113 @@
|
|||
# Makepad Agent Runbook
|
||||
|
||||
Repository-wide rules. Read the linked references when the task needs them;
|
||||
use current source for API signatures and working examples.
|
||||
# Studio Remote Runbook
|
||||
|
||||
## 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.
|
||||
|
||||
- **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.
|
||||
## 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.
|
||||
|
||||
## 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.
|
||||
## 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.
|
||||
|
||||
## App Remote Control (`--remote`)
|
||||
## 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)
|
||||
|
||||
> **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.
|
||||
## `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.
|
||||
|
||||
> **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).
|
||||
## 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).
|
||||
|
||||
## 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).
|
||||
|
||||
Any makepad app launched with `--remote` runs a localhost HTTP server inside
|
||||
the process and prints one line before the UI appears:
|
||||
## `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`.
|
||||
|
||||
```
|
||||
[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
|
||||
```
|
||||
## 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.
|
||||
|
||||
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.
|
||||
## 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.
|
||||
|
||||
### Cheat sheet
|
||||
## 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.
|
||||
|
||||
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.
|
||||
|
|
@ -308,35 +127,19 @@ grep -r "texture_2d" widgets/src/
|
|||
|
||||
## Running UI Programs
|
||||
|
||||
Launch UI apps as standalone release binaries from this checkout. Do not
|
||||
use the Studio remote bridge.
|
||||
Use the Studio bridge runnable-item flow instead of launching UI apps directly from the shell:
|
||||
|
||||
```bash
|
||||
cargo build --release -p makepad-app-asset-ui
|
||||
# stop any older instance of the same binary, then:
|
||||
./target/release/makepad-app-asset-ui
|
||||
```
|
||||
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.
|
||||
|
||||
For one-shot visual smoke of a small example:
|
||||
Do not use `ObserveMount` from the bridge. That call is for mount ownership/subscription and can steal RunView/framebuffer routing away from Studio desktop.
|
||||
|
||||
```bash
|
||||
RUST_BACKTRACE=1 cargo run -p makepad-example-splash --release & PID=$!; sleep 15; kill $PID 2>/dev/null; echo "Process $PID killed"
|
||||
```
|
||||
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.
|
||||
|
||||
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`.
|
||||
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`).
|
||||
|
||||
## Cargo.toml Setup
|
||||
|
||||
|
|
|
|||
159
Cargo.toml
|
|
@ -1,24 +1,11 @@
|
|||
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) — networked AI game sandbox ===
|
||||
# === arcade (game.md) ===
|
||||
"apps/arcade",
|
||||
"libs/game/math",
|
||||
"libs/game/render",
|
||||
"libs/game/sim",
|
||||
"libs/game/blocks",
|
||||
"libs/game/gen",
|
||||
"libs/game/audio",
|
||||
|
|
@ -28,167 +15,65 @@ 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/datagrid",
|
||||
"examples/scratchpad",
|
||||
"examples/ddgo",
|
||||
"examples/todo",
|
||||
"examples/portallist_hit",
|
||||
"examples/modal_footprint",
|
||||
"examples/git",
|
||||
"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",
|
||||
# === 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",
|
||||
# === studio ===
|
||||
"studio/hub",
|
||||
"studio/desktop",
|
||||
# === 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/dj_pack",
|
||||
"libs/system_speech",
|
||||
"tools/arcade_eval",
|
||||
# === 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",
|
||||
# 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",
|
||||
"libs/ggml",
|
||||
"libs/voice",
|
||||
"libs/voice2",
|
||||
"libs/tts",
|
||||
"widgets/test",
|
||||
"libs/stitch",
|
||||
"libs/wasm_bridge/test",
|
||||
|
|
@ -279,6 +164,7 @@ workspace.exclude = [
|
|||
"libs/linux/wayland-protocols",
|
||||
"libs/linux/wayland-sys",
|
||||
"tools/windows_strip",
|
||||
"tools/web_server",
|
||||
"tools/file_router",
|
||||
"tools/wasm_strip",
|
||||
]
|
||||
|
|
@ -304,9 +190,6 @@ 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]
|
||||
|
|
|
|||
|
|
@ -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-director --release
|
||||
cargo run -p makepad-studio --release
|
||||
```
|
||||
|
||||
If you want a local install (note: may lag the repo):
|
||||
|
||||
```bash
|
||||
cargo install makepad-director
|
||||
cargo install makepad-studio
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
|
@ -90,8 +90,8 @@ cargo run -p makepad-example-map --release
|
|||
For built-in maps and voice support, download the assets first:
|
||||
|
||||
```bash
|
||||
./tools/download_map.sh
|
||||
./tools/download_voice.sh
|
||||
./download_map.sh
|
||||
./download_voice.sh
|
||||
```
|
||||
|
||||
## Run A WASM App
|
||||
|
|
|
|||
|
|
@ -1,39 +0,0 @@
|
|||
[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"]
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
<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>
|
||||
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 111 KiB |
|
|
@ -1,216 +0,0 @@
|
|||
//! 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}");
|
||||
})
|
||||
}
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
# 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 }
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
// 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
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
<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>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 108 KiB |
|
|
@ -1,144 +0,0 @@
|
|||
//! 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 })
|
||||
}
|
||||
|
|
@ -1,213 +0,0 @@
|
|||
//! 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(®istry, ®ister.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(®istry, &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(
|
||||
®istry,
|
||||
&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(®istry)
|
||||
.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(
|
||||
®istry,
|
||||
&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(®istry)
|
||||
.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(®istry).as_slice(),
|
||||
[HostedDown { to: Some(to), msg: ServiceDown::Unsubscribe { sub_id: ended } }]
|
||||
if to == &endpoint && ended == &sub_id
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,302 +0,0 @@
|
|||
//! 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, ¬e, 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,91 +0,0 @@
|
|||
//! 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")),
|
||||
}
|
||||
}
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
//! 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,261 +0,0 @@
|
|||
//! 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}");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,963 +0,0 @@
|
|||
//! 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, ¬e);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,101 +0,0 @@
|
|||
//! 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -15,8 +15,8 @@ path = "src/main.rs"
|
|||
|
||||
[dependencies]
|
||||
makepad-widgets = { path = "../../widgets", version = "2.0.0" }
|
||||
makepad-game-math = { path = "../../libs/sim/math" }
|
||||
makepad-game-sim = { path = "../../libs/sim" }
|
||||
makepad-game-math = { path = "../../libs/game/math" }
|
||||
makepad-game-sim = { path = "../../libs/game/sim" }
|
||||
makepad-game-session = { path = "../../libs/game/session" }
|
||||
makepad-game-blocks = { path = "../../libs/game/blocks" }
|
||||
makepad-game-render = { path = "../../libs/game/render" }
|
||||
|
|
@ -26,9 +26,7 @@ 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" }
|
||||
# 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 }
|
||||
makepad-ai = { path = "../../libs/makepad_ai" }
|
||||
# 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.
|
||||
|
|
|
|||
|
|
@ -6,15 +6,17 @@
|
|||
//! thing that writes code. Where there is no local compute, the text box IS
|
||||
//! the gate and typing goes straight through.
|
||||
|
||||
use crate::capability::Tier;
|
||||
use crate::capability::{Capabilities, Tier};
|
||||
use crate::library::{classify_deterministic, Action, Librarian, Library, Manifest};
|
||||
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::*;
|
||||
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;
|
||||
|
||||
/// 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
|
||||
|
|
@ -29,118 +31,17 @@ pub const PERMISSION_POLICY: &str = r#"{"permissions":{
|
|||
"allow":["Read","Glob","Grep","Edit(./**)","Write(./**)"],
|
||||
"deny":["Bash","Edit(../**)","Write(../**)"]}}"#;
|
||||
|
||||
// --- 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,
|
||||
}
|
||||
/// 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()
|
||||
}
|
||||
|
||||
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
|
||||
|
|
@ -210,6 +111,118 @@ 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.
|
||||
|
|
@ -255,11 +268,29 @@ mod tests {
|
|||
use super::*;
|
||||
|
||||
#[test]
|
||||
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"));
|
||||
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");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -488,7 +488,6 @@ 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
|
||||
}
|
||||
|
|
@ -2858,7 +2857,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, world.entities[i].yaw))
|
||||
.map(|i| world.entities[i].pos)
|
||||
};
|
||||
self.particles.step(TICK_DT, &lookup);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,9 @@ 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);
|
||||
|
|
@ -141,11 +144,11 @@ pub struct App {
|
|||
#[rust]
|
||||
caps: Option<Capabilities>,
|
||||
#[rust]
|
||||
ai_worker: Option<ai::AiWorker>,
|
||||
agent: Option<Box<dyn Agent>>,
|
||||
#[rust]
|
||||
backend_available: bool,
|
||||
session_id: Option<SessionId>,
|
||||
#[rust]
|
||||
current_prompt: bool,
|
||||
current_prompt: Option<PromptId>,
|
||||
/// The intent log. Every edit — local agent or remote Claude — goes
|
||||
/// through it; there is deliberately no faster path for the local one.
|
||||
#[rust]
|
||||
|
|
@ -185,7 +188,7 @@ impl App {
|
|||
// it or a spoken sentence lands nowhere.
|
||||
input.set_key_focus(cx);
|
||||
|
||||
let Some(worker) = &self.ai_worker else {
|
||||
let (Some(agent), Some(session_id)) = (&mut self.agent, self.session_id) else {
|
||||
ChatData::push(
|
||||
ChatRole::System,
|
||||
"No AI is connected. Open Settings to choose a provider and add a key.",
|
||||
|
|
@ -193,31 +196,18 @@ impl App {
|
|||
self.ui.redraw(cx);
|
||||
return;
|
||||
};
|
||||
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 {
|
||||
if self.current_prompt.is_some() {
|
||||
// The newest instruction wins; the partial reply stays in the log.
|
||||
worker.cancel();
|
||||
if let Some(prompt) = self.current_prompt.take() {
|
||||
agent.cancel_prompt(cx, prompt);
|
||||
}
|
||||
}
|
||||
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");
|
||||
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.current_prompt = Some(agent.send_prompt(cx, session_id, &text));
|
||||
self.ui.widget(cx, ids!(stop_button)).set_visible(cx, true);
|
||||
|
||||
if let Some(speech) = self.speech.as_mut() {
|
||||
|
|
@ -230,13 +220,10 @@ impl App {
|
|||
if let Some(speech) = self.speech.as_mut() {
|
||||
speech.stop();
|
||||
}
|
||||
if !self.current_prompt {
|
||||
let (Some(agent), Some(prompt)) = (&mut self.agent, self.current_prompt.take()) else {
|
||||
return;
|
||||
}
|
||||
if let Some(worker) = &self.ai_worker {
|
||||
worker.cancel();
|
||||
}
|
||||
self.current_prompt = false;
|
||||
};
|
||||
agent.cancel_prompt(cx, prompt);
|
||||
ChatData::end_stream();
|
||||
self.ui.widget(cx, ids!(stop_button)).set_visible(cx, false);
|
||||
self.set_status(cx, "Stopped.");
|
||||
|
|
@ -300,6 +287,18 @@ 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 {
|
||||
|
|
@ -397,12 +396,38 @@ impl MatchEvent for App {
|
|||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
// 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 intent log starts from whatever game is on disk.
|
||||
let path = self.game_path();
|
||||
|
|
@ -493,57 +518,57 @@ impl AppMain for App {
|
|||
self.next_frame = cx.new_next_frame();
|
||||
}
|
||||
|
||||
let events = if let Some(worker) = &self.ai_worker {
|
||||
worker.poll()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
for event in events {
|
||||
let Some(agent) = &mut self.agent else { return };
|
||||
for event in agent.handle_event(cx, event) {
|
||||
match event {
|
||||
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) => {
|
||||
AgentEvent::TextDelta { text, .. } => {
|
||||
ChatData::push_delta(&text);
|
||||
if let Some(speech) = self.speech.as_mut() {
|
||||
speech.feed(&text);
|
||||
}
|
||||
cx.redraw_all();
|
||||
}
|
||||
ai::AiWorkerEvent::Done(_full_text) => {
|
||||
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 { .. } => {
|
||||
ChatData::end_stream();
|
||||
if let Some(speech) = self.speech.as_mut() {
|
||||
speech.flush();
|
||||
}
|
||||
self.current_prompt = false;
|
||||
self.current_prompt = None;
|
||||
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();
|
||||
}
|
||||
ai::AiWorkerEvent::Error(error) => {
|
||||
self.current_prompt = false;
|
||||
ChatData::end_stream();
|
||||
ChatData::push(ChatRole::System, format!("Error: {error}"));
|
||||
self.ui.widget(cx, ids!(stop_button)).set_visible(cx, false);
|
||||
self.ui.redraw(cx);
|
||||
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);
|
||||
}
|
||||
ChatData::push(ChatRole::System, format!("Error: {error}"));
|
||||
cx.redraw_all();
|
||||
}
|
||||
AgentEvent::SessionReady { .. } => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,29 +0,0 @@
|
|||
# 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" }
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
// 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
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
<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>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 112 KiB |
|
|
@ -1,4 +0,0 @@
|
|||
<?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>
|
||||
|
Before Width: | Height: | Size: 231 B |
|
|
@ -1,4 +0,0 @@
|
|||
<?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>
|
||||
|
Before Width: | Height: | Size: 310 B |
|
|
@ -1,4 +0,0 @@
|
|||
<?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>
|
||||
|
Before Width: | Height: | Size: 228 B |
|
|
@ -1,6 +0,0 @@
|
|||
<?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>
|
||||
|
Before Width: | Height: | Size: 950 B |
|
|
@ -1,6 +0,0 @@
|
|||
<?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>
|
||||
|
Before Width: | Height: | Size: 346 B |
|
|
@ -1,4 +0,0 @@
|
|||
<?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>
|
||||
|
Before Width: | Height: | Size: 314 B |
|
|
@ -1,5 +0,0 @@
|
|||
<?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>
|
||||
|
Before Width: | Height: | Size: 867 B |
|
|
@ -1,5 +0,0 @@
|
|||
<?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>
|
||||
|
Before Width: | Height: | Size: 892 B |
|
|
@ -1,4 +0,0 @@
|
|||
<?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>
|
||||
|
Before Width: | Height: | Size: 284 B |
|
|
@ -1,220 +0,0 @@
|
|||
//! 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,512 +0,0 @@
|
|||
//! 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,749 +0,0 @@
|
|||
//! 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");
|
||||
}
|
||||
|
|
@ -1,321 +0,0 @@
|
|||
//! 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,226 +0,0 @@
|
|||
//! 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
# 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" }
|
||||
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
// 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
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
<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>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 98 KiB |
|
|
@ -1 +0,0 @@
|
|||
<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>
|
||||
|
Before Width: | Height: | Size: 211 B |
|
|
@ -1 +0,0 @@
|
|||
<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>
|
||||
|
Before Width: | Height: | Size: 217 B |
|
|
@ -1,166 +0,0 @@
|
|||
//! 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);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
//! 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)
|
||||
}
|
||||
|
|
@ -1,162 +0,0 @@
|
|||
//! 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()
|
||||
}
|
||||
|
|
@ -1,195 +0,0 @@
|
|||
//! 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
//! 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
# 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" }
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
// 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
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
<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>
|
||||
|
Before Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 83 KiB |
|
|
@ -1 +0,0 @@
|
|||
<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>
|
||||
|
Before Width: | Height: | Size: 140 B |
|
|
@ -1 +0,0 @@
|
|||
<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>
|
||||
|
Before Width: | Height: | Size: 164 B |
|
|
@ -1 +0,0 @@
|
|||
<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>
|
||||
|
Before Width: | Height: | Size: 150 B |
|
|
@ -1 +0,0 @@
|
|||
<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>
|
||||
|
Before Width: | Height: | Size: 154 B |
|
|
@ -1 +0,0 @@
|
|||
<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>
|
||||
|
Before Width: | Height: | Size: 207 B |
|
|
@ -1 +0,0 @@
|
|||
<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>
|
||||
|
Before Width: | Height: | Size: 181 B |
|
|
@ -1,166 +0,0 @@
|
|||
//! 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,310 +0,0 @@
|
|||
//! 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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,721 +0,0 @@
|
|||
//! 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
//! 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);
|
||||
}
|
||||
|
|
@ -1,146 +0,0 @@
|
|||
//! 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,947 +0,0 @@
|
|||
//! 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()); }
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,174 +0,0 @@
|
|||
//! 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,772 +0,0 @@
|
|||
//! 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));
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,477 +0,0 @@
|
|||
//! 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,444 +0,0 @@
|
|||
//! 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
|
||||
}
|
||||
|
|
@ -1,365 +0,0 @@
|
|||
//! 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,471 +0,0 @@
|
|||
//! 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{..}));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,664 +0,0 @@
|
|||
//! 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
# 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" }
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
// 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
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
<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>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 97 KiB |
|
|
@ -1 +0,0 @@
|
|||
<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>
|
||||
|
Before Width: | Height: | Size: 338 B |
|
|
@ -1 +0,0 @@
|
|||
<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>
|
||||
|
Before Width: | Height: | Size: 277 B |
|
|
@ -1 +0,0 @@
|
|||
<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>
|
||||
|
Before Width: | Height: | Size: 143 B |
|
|
@ -1 +0,0 @@
|
|||
<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>
|
||||
|
Before Width: | Height: | Size: 225 B |
|
|
@ -1 +0,0 @@
|
|||
<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>
|
||||
|
Before Width: | Height: | Size: 200 B |
|
|
@ -1 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#FFFFFF" d="M11 4h2v7h7v2h-7v7h-2v-7H4v-2h7z"/></svg>
|
||||
|
Before Width: | Height: | Size: 126 B |
|
|
@ -1 +0,0 @@
|
|||
<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>
|
||||
|
Before Width: | Height: | Size: 255 B |
|
|
@ -1 +0,0 @@
|
|||
<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>
|
||||
|
Before Width: | Height: | Size: 172 B |
|
|
@ -1,45 +0,0 @@
|
|||
//! 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,148 +0,0 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,390 +0,0 @@
|
|||
//! 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,100 +0,0 @@
|
|||
//! 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()
|
||||
}
|
||||
}
|
||||