Standalone pieces (front-end and backend, no core changes)
A piece is a reusable Day widget. Day ships built-in pieces (button, slider, list, …), but
anyone can publish a piece as an independent crate that adds both its cross-platform front-end
(Rust) and its per-toolkit native backend (Objective-C via objc2, C++ shims, Android Java, …),
with no edits to any core Day crate. day-piece-searchfield is the reference implementation.
Scaffold a new piece with day new. Don’t hand-assemble the crate: day new piece <name>
generates a ready-to-build project (remote Day deps by default; --local <path> for a local Day
checkout). With no --toolkits it emits a composite piece (front-end only); with
--toolkits appkit,gtk,qt,uikit,mdc,xaml (any subset) it emits a native piece with a renderer
per backend plus the C++/Java/Swift glue each one needs. Either kind comes with demo/, a one-page
app that shows the piece and runs its walkthrough (--no-demo skips it). The companion day new part <name> scaffolds
a headless part. For full walkthroughs see the tutorials:
composite piece,
native piece, and
part.
Extensibility rests on two mechanisms:
- Renderers register link-time into each backend’s
RENDERERSslice (vialinkme), so a backend dispatches an unknownkindto the piece’smake/update/measurewith no registry edits. (web-dom is the one exception:linkmehas no wasm32 implementation, soday-domkeeps a runtime registry and the piece callsday_dom::register_rendererfrom its own constructor. Same three functions, same dispatch, different moment; see media.md.) - Native backend assets (C++ shims, Android Java, Gradle deps) are declared in the crate’s own
Cargo.toml/build.rsand folded into the app’s native build automatically. Browser JavaScript can ship in aday_bridge::bridge!arm: the CLI stages and registers its module. Browser DOM access covers element access, events, and cleanup for browser renderers.
1. The front-end (any backend)
use day_core::{BuildCx, Flex, Piece, RNode, with_tree};
use day_reactive::{Signal, bind_seeded};
use day_spec::Event;
pub const KIND: &str = "my.piece.gauge";
pub struct Gauge { /* … + a Signal for two-way binding */ }
impl Piece for Gauge {
fn build(self, cx: &mut BuildCx) -> RNode {
let node = cx.leaf(KIND, &props, Flex::default()); // a native leaf of `KIND`
bind_seeded(seed, move || value.get(), move |v| { with_tree(|t| t.patch(node, patch, false)); });
cx.on(node, move |ev| { /* native events → write the Signal */ });
node
}
}
impl Piece gives you .id()/.a11y()/.frame() for free (blanket Decorate). Props are the full
realize payload; a sparse Patch enum carries changes.
Those modifiers return Decorated<YourPiece> rather than erasing, so a chain keeps your type. To
make your own builder methods reachable after one, declare them in a *Builder trait and forward
it through Decorated::map_inner, the pattern every built-in piece follows, written up in
docs/api-style.md “Typed builders and erasure”:
pub trait GaugeBuilder: Sized {
fn ticks(self, n: usize) -> Self;
}
impl GaugeBuilder for Gauge {
fn ticks(self, n: usize) -> Self { Gauge::ticks(self, n) }
}
impl<Inner: GaugeBuilder + Piece> GaugeBuilder for Decorated<Inner> {
fn ticks(self, n: usize) -> Self { self.map_inner(|p| p.ticks(n)) }
}
Skip any method whose name Decorate already defines (id, background, scale, …): the
inherent method on Decorated wins resolution, so a forwarded twin would never be called.
2. Per-backend renderers: the renderer! macro
Each backend module registers its native renderer into that backend’s RENDERERS slice, the same
slice the built-ins use, so no Day edit is needed. Declare the per-toolkit glue modules with one
line: day_pieces::glue_modules!(appkit, gtk, qt, uikit, mdc, xaml) expands to the
feature-and-target-gated mod block binding each lib-<toolkit>.rs (list only the toolkits you
implement; a non-standard gate, like webview’s Linux-only GTK, stays a hand-written block beside
it). Then write typed make/update (no &dyn Any
downcast) and one macro line; day_pieces::renderer! expands to the linkme registration + the props/
patch downcast:
#[cfg(all(feature = "appkit", target_os = "macos"))]
mod appkit_impl {
use super::*;
fn make(backend: &mut AppKit, props: &MyProps, id: NodeId) -> Retained<NSView> { … }
fn update(backend: &mut AppKit, h: &Retained<NSView>, patch: &MyPatch) { … }
day_pieces::renderer!(day_appkit::RENDERERS, AppKit,
kind: KIND, props: MyProps, patch: MyPatch, make: make, update: update);
}
Add measure: f for custom sizing: measure: day_pieces::fill_measure for a growing leaf (a web
view, a canvas), or omit it for the backend’s default. A patchless piece (configured once, e.g. Lottie)
drops patch:/update:: renderer!(day_uikit::RENDERERS, Uikit, kind: KIND, props: MyProps, make: make).
Do the same for day_gtk::RENDERERS, day_qt::RENDERERS, day_uikit::RENDERERS,
day_android::RENDERERS, day_xaml::RENDERERS. Each backend is behind a cargo feature that pulls in
that toolkit crate; the app enables my-piece/<backend> alongside day/<backend>.
Reporting events back. A renderer calls day_<backend>::emit(node, event). Beyond the fixed
Event variants, a piece defines its own event with Event::custom("my:tag", text) (in-process); its
cx.on reads it. Across a native boundary (JNI/C-ABI) the tag can’t be a &'static str, so it’s empty
and the payload rides in num/text: on Android the shim calls DayBridge.nativeOnEvent(id, 12, num, text) (kind 12 = the open Custom channel). day-piece-webview reports its URL this way.
3. Native backend assets
A piece often needs native code the Rust FFI alone can’t express. Day gives each toolkit a local extension path so that code lives in the piece crate:
Data assets a piece ships ([package.metadata.day.piece].assets)
A piece that carries files rather than code — a web view’s inline site, a model, a shader, a sample — declares the directories holding them:
[package.metadata.day.piece]
backends = ["appkit", "gtk", "qt", "xaml", "arkui", "dom"]
assets = ["web"] # directories, relative to the crate root
day build stages each one into the app’s bundle under the crate name, beside the app’s own
resource/assets/, on every target: web/index.html in day-piece-lottie becomes
day-piece-lottie/index.html, which the piece then resolves by that name with resource(…),
AssetDir, or a web view’s inline site. The namespace is the crate’s and is not configurable, so
two pieces cannot collide with each other and neither can collide with a name the app chose.
The app does nothing: it depends on the piece, and the files arrive. A dev day launch stages
them under build/day/assets/ and points DAY_PIECE_ASSET_ROOT there, because the app’s own
assets are read where they lie and nothing may be written into the app’s source tree.
day-piece-lottie is the worked example: on the six backends with no native Lottie player it ships lottie-web and a host page this way, and composes day-piece-webview to show them.
C++ shims: Qt & XAML (build.rs)
The piece carries its own src/lib-qt-shim.cpp / src/lib-xaml-shim.cpp and compiles them in build.rs
(gated on the feature). Qt widgets are plain C++ objects and the handle is a raw QWidget*, so a Qt
shim is self-contained. XAML handles are a private boxed type owned by day-xaml-sys, so the piece
boxes its XAML element through the exported day_xaml_box / day_xaml_unbox functions (a
stable WinRT COM-ABI). Both reuse the sys crate’s generic measure (day_qt_size_hint /
day_xaml_measure).
See pieces/day-piece-searchfield/{build.rs,src/lib-qt-shim.cpp,src/lib-xaml-shim.cpp}.
Android Java + Gradle deps ([package.metadata.day.android])
The piece carries its own Java/Kotlin under a crate dir and declares it in Cargo.toml:
[package.metadata.day.android]
java = ["platform/android/java"] # → Gradle java srcDirs (a dir, or one .java/.kt file)
res = ["platform/android/res"] # → Gradle res srcDirs (optional)
gradle-dependencies = ["com.google.android.material:material:1.11.0"] # → app dependencies { }
gradle-repositories = ["https://jitpack.io"] # → extra Maven repos (optional)
permissions = ["android.permission.INTERNET"] # → <uses-permission> in the manifest
proguard = ["platform/android/proguard-rules.pro"] # → R8 keep rules (see below)
manifest-components = ["platform/android/components.xml"] # → <receiver>/<service> (see below)
day build (for android-mdc) runs cargo metadata, walks the app’s dependency closure, collects
every piece’s contributions, and writes build/day/android/day-pieces.json. Day’s Gradle plugins
read that file generically (a loop, so per-piece edits are never needed) and add the Java dirs, res
dirs, dependencies, and repos.
Day’s Gradle plugins. An app’s Gradle scripts stay small. settings.gradle.kts includes
build/day/android/gradle-plugin and applies dev.daybrite.day.settings, which adds the
repositories. app/build.gradle.kts applies dev.daybrite.day.android, which applies
com.android.application and configures the module from build/day/android/: Day.toml identity,
the SDK levels, the day-android shim and piece sources, libraries, the manifest overlay, R8 rules, and
release signing. The plugins are Java sources in toolkits/day-android/gradle-plugin, built on
AGP 9.4, which needs Android Studio 2026.1.4 or newer to sync. Raise that version only to one a
current Android Studio release supports, and update the Android Studio prerequisite on the
website’s system requirements and android-mdc pages with it. day build,
day prepare, and day open stage them from the day-android crate the app resolves, so an app
always builds with the plugin that matches its Java shim. The app’s own android {} and
dependencies {} blocks run after the plugin, so a value set there overrides Day’s. A change to
Day’s Android build goes in the plugin: every app picks it up on its next day build, while a
scaffold change reaches only new apps.
A single Java file. A java entry may name one .java or .kt file instead of a directory, so
a piece or part keeps its Android code beside its Rust (java = ["src/DaySearch.java"], the layout
day new piece and day new part scaffold; --java-in-src=false keeps a platform/android/java/
tree). Gradle compiles source roots, so day build links each such file into
build/day/android/piece-java/<package path>/, the directory its package line names, and adds that
one root to the Java dirs. The link keeps the file’s name, and javac requires a public top-level class
to live in a file of the same name, so a .java file is named after its class. A host that cannot
create symlinks (Windows without the privilege) gets a copy instead, rewritten when the source
changes.
Piece resources. res dirs compile into the app’s resource table, so a piece can ship the styles
or drawables its Java needs (e.g. a theme overlay for a dialog). The app’s R package differs per
app, so the piece’s Java resolves its own resources by name at runtime:
ctx.getResources().getIdentifier("SomeStyleName", "style", ctx.getPackageName()). Prefix names with
the piece to avoid collisions (resource names are one flat namespace per app).
day-piece-datetime/android/res is the reference.
Manifest components. A part whose Java half is a BroadcastReceiver or Service needs it
declared in the manifest, or Android never instantiates it. manifest-components names files
holding only the elements that belong inside <application> (day build adds the <manifest>
and <application> wrapper), and every class must be fully qualified, since the overlay merges
into an app whose package the part cannot know:
<!-- platform/android/components.xml -->
<receiver android:name="dev.daybrite.day.notify.DayNotifyAlarmReceiver"
android:exported="false" />
They merge into the same day-pieces-manifest.xml overlay the permissions use. A declared file
that is missing is a hard build error, not a warning, because a dropped receiver produces an APK
that installs, runs, and never delivers. A scaffold generated before this key existed gates the
overlay on the permission list being non-empty; day build warns, with the one-line fix, when a
part contributes components but no permissions.
Manifest permissions. A piece that needs a permission (a web view needs INTERNET) can’t reach the
app’s AndroidManifest.xml, so day build also writes the collected permissions into a generated
overlay manifest (build/day/android/day-pieces-manifest.xml). Day’s Gradle plugin points the
debug and release source-set manifests at that overlay, and AGP’s manifest merger folds the <uses-permission>
entries into the app manifest (deduping against any the app already declares). So a WebView-using app
needs no manual manifest edit: the piece declares the permission and it shows up in the merged
manifest. day-piece-webview is the reference. (A piece can only add a permission; it never
removes or narrows the app’s own.)
Release minification (R8/ProGuard). A day build --profile release (and day pack) minifies with
R8: it shrinks unused code and renames classes and methods. But Day reaches Java from native
(Rust) code by name: JNI FindClass("dev/daybrite/day/piece/searchfield/DaySearch"), dcall_static on a
method name, WorkManager instantiating a Worker from its class-name string, Room looking up a
<Database>_Impl. A renamed class breaks every one of those lookups, so an un-kept release APK
installs and then crashes at launch (NoClassDefFound / ClassNotFoundException / UnsatisfiedLinkError).
Two layers keep the right names.
-
The framework keeps its own namespace.
day-androidships aproguard-rules.pro(bundled byday buildfrom the crate, like the Java shim) that keeps all ofdev.daybrite.day.**(the render bridge and every official Part/Piece shim) plus every class withnativemethods. So a first-party piece needs no rules of its own. It also sets-dontoptimize: AGP forces theproguard-android-optimizebase, whose aggressive optimizations break reflection-heavy libraries (WorkManager’s Room database is the classic casualty), and Day accepts the size cost for predictable release builds. R8 still shrinks and renames everything a keep rule doesn’t protect. -
Everything outside
dev.daybrite.day.**keeps itself. An app’s own JNI classes (its install bridge, a backgroundWorker) live in the app’s package, and a third-party piece lives in its own namespace. Neither is covered by the framework rule. Each ships aproguard-rules.proand lists it inproguard = [...].day buildcollects all of them (framework + every piece + the app) into the release build’s proguard configuration, exactly like it collects Java dirs and Gradle deps. An app also keeps here anything its dependencies reach reflectively that their own consumer rules miss (e.g.-keep class * extends androidx.room.RoomDatabase { *; }for a WorkManager user).
# platform/android/proguard-rules.pro — keep the classes native code reaches by name.
-keep class com.example.mypiece.MyPieceView { *; }
Day’s Gradle plugin reads dayProguardFile and proguardFiles from
day-pieces.json and applies them in the release build type. pieces/day-piece-searchfield (framework
side) and App Fair’s platform/android/proguard-rules.pro (app side) are the references.
The piece’s Java uses only day-android’s public surface: DayBridge.ctx (the Context) and
DayBridge.nativeOnEvent(id, kind, num, str) (the event trampoline, kind per §14.2, 4 = selection).
The Rust side calls its own Java class through the re-exported jni (with_env +
call_static_method + AHandle); day_android::make_view is a convenience hardcoded to
DayBridge, so a standalone piece calls day_android::try_make_view_on(env, ITS_CLASS, …) (the
same non-panicking path with the class as a parameter) and falls back to placeholder_view on a
throw. See
pieces/day-piece-searchfield/android/java/dev/daybrite/day/piece/searchfield/DaySearch.java, and
pieces/day-piece-texteditor/src/lib-android.rs for the fallback.
iOS Swift shims + SwiftPM packages ([package.metadata.day.ios])
Many iOS libraries (and any Swift class with a non-@objc API) can’t be driven from Rust directly, and
they ship as SwiftPM packages. A piece declares both in its Cargo.toml: the Swift shim it
carries, and the packages it needs.
[package.metadata.day.ios]
swift = ["platform/ios/swift"] # dirs of Swift shim sources
swift-packages = [ # SwiftPM package dependencies
{ url = "https://github.com/airbnb/lottie-ios", from = "4.5.0", products = ["Lottie"] },
]
frameworks = ["WebKit"] # system frameworks to link
frameworks links system frameworks via the generated package’s linkerSettings. A piece that
drives a class from an unlinked framework (e.g. a hand-rolled WKWebView) declares it here instead of
dlopening or hand-#[link]ing (which doesn’t survive the cargo-staticlib → xcode link). day-piece-webview
uses frameworks = ["WebKit"].
{from, exact, branch, revision} map to the matching SwiftPM version requirement; products are the
library products to link. Xcode is not script-driven like Gradle, so day build (ios-uikit) instead
generates a local SwiftPM package at build/day/ios/DayPieces. Its Package.swift lists every
piece’s swift-packages as dependencies and compiles every piece’s staged Swift shims (each under a
per-crate subfolder). The app’s checked-in .xcodeproj depends on that one local package (the iOS
analog of the checked-in Gradle scaffold: a XCLocalSwiftPackageReference + a product dependency in a
Frameworks phase). So adding an iOS piece is pure Cargo.toml data; no .xcodeproj edits are needed.
Two further keys are shared with the macOS table below (docs/swiftui.md covers them in full):
- A
swift-packagesentry may be local ({ path = "swiftui", products = ["MyViews"] }, relative to the declaring crate). The package’s transitive SwiftPM dependencies come along, and its public SwiftUI views are scanned and exported: generated hosting glue on this side, generated typed constructors (crate::swiftui::MyView(…)) on the Rust side.productsdefaults to the directory name. platform = "16.0"raises the generated package’s minimum OS (the max across contributions wins). On iOS the CLI conveys it as anIPHONEOS_DEPLOYMENT_TARGETcommand-line setting, which reaches the app and package targets without editing the scaffold; ⌘R builds in Xcode need the pbxproj raised by hand.
macOS Swift ([package.metadata.day.macos])
The macos-appkit leg uses the same table shape: swift, swift-packages (remote or local),
frameworks, platform. day build generates build/day/macos/DayPieces from the table and
the platform/macos/DayApp.xcodeproj host project’s pbxproj references it; xcodebuild compiles
and links the package with the Runner, the same shape as iOS (the Swift runtime resolves against
the OS dylibs). day-piece-swiftui declares the same shim dir under both tables, one
DaySwiftUI.swift with #if os(...) arms.
HarmonyOS ArkTS components ([package.metadata.day.ohos])
Some HarmonyOS components exist only in ArkTS: the ArkUI C node API (arkui/native_node.h)
stops at the container kinds, so there is no way to construct a declarative Web (or Map) from
native code at all. A piece wrapping one carries its own ArkTS the way an Android piece carries Java.
[package.metadata.day.ohos]
ets = ["platform/harmony/ets"] # dirs of ArkTS sources; each needs an Index.ets exporting `dayPiece`
Each declared dir must contain an Index.ets exporting a DayPieceModule:
import { DayPieceModule } from '../DayPiece'; // generated next to the staged dirs
export const dayPiece: DayPieceModule = {
kind: 'day.piece.webview', // matches the Rust KIND
make: (ui, id, props) => frameNode | undefined, // build it; undefined declines the kind
update: (id, cmd, arg) => {}, // the piece's own command vocabulary
dispose: (id) => {} // Day disposed the node — release it
};
day build -p harmony-arkui stages every piece’s dirs under entry/src/main/ets/daypieces/<crate>/
(gitignored) and generates two files beside them: DayPiece.ets (the interface above) and
DayPieces.ets, whose registerDayPieces(uiContext) hands the native shim one factory, command sink,
and disposer for all pieces. The framework’s host page (staged beside them from the day-arkui crate,
docs/harmonyos.md) calls it once, before start(), so adding an ArkTS piece is pure
Cargo.toml data, like the iOS leg, and the shim never grows a case per piece.
On the Rust side the renderer is the thinnest of all the backends, because there is no native widget
to build. day_arkui::piece::make returns the ArkTS component’s FrameNode as an ordinary handle:
fn make(_b: &mut ArkUi, p: &WebProps, id: NodeId) -> AHandle { piece::make(KIND, id, &p.url) }
fn update(_b: &mut ArkUi, h: &AHandle, patch: &WebPatch) { piece::update(h, "load", url) }
Events come back through the shim’s pieceEvent(id, text) as Event::Custom, the same open channel
(§8.2) the Android bridge uses, payload only. The bridge enforces two rules. A declined make
yields Day’s placeholder leaf rather than a null handle (a null would take the whole parent’s layout
down), and release routes an ArkTS-owned node to dispose instead of disposing it natively.
Sizing. Day owns layout and sets each node’s position + size through the C API, so the ArkTS
component must not size itself with percentages: a BuilderNode is built detached, where '100%'
resolves against the whole window and the component covers the page.
The Swift shim exposes a flat C ABI (@_cdecl) that the piece’s Rust calls (mirroring the Android Java
shim); it imports the SwiftPM product and returns a native UIView that Rust wraps via
Retained::from_raw. See platform/ios/swift/DayLottie.swift and src/lib-uikit.rs in
day-piece-lottie.
The Android bridging contract
These are the guarantees a part or piece can rely on when its Rust calls its Java sidecar (all
provided by day-android, all exercised in production by day-part-http). A daybridge
arm rides these same guarantees (its generated JNI wrapper is written against them), so this list
is what that generated code does on your behalf, and what to know when you write the call by hand:
- Any thread may call.
day_android::with_env(|env| …)attaches the calling thread to the JVM (and detaches scoped attachments). Blocking Java work runs on the caller’s thread; keep it off the UI thread, exactly like any other blocking Rust. - App classes resolve from any thread.
env.dfind/dcall_static/dfieldfall back to the appClassLoadercached at startup, so a Rust-spawned worker resolves your sidecar class even though a bare JNIFindClassthere only sees system classes. - Post to the UI thread with
DayBridge.main.post(...)on the Java side; on the Rust side, capture aday_reactive::Setter(docs/focus.md, DESIGN §4.5) rather than touching UI state from a worker. - Bulk payloads cross as one
byte[]envelope:[status i32 BE][meta-len i32 BE] ["k\nv\n…" meta][payload]; a negative status is your error sentinel with the message riding the meta block. Build it withDayEnvelope.pack/errorin Java and parse it withday_android::envelope::Envelopein Rust; the two encode identically and Rust unit tests pin the format. There is one JNI copy each way and no per-field JNI traffic. - Piece-defined events ride the
K_CUSTOMkind (DayBridge.nativeOnEvent(id, DayBridge.K_CUSTOM, num, text)→Event::Custom): the tag can’t cross JNI, so the piece reads the rawnum/textpayload. The full kind table isday_spec::bridge::BridgeKind; parity tests keep the Java constants in step.
4. Cargo wiring
[features]
appkit = ["dep:day-appkit", …]
gtk = ["dep:day-gtk", "dep:gtk4"]
qt = ["dep:day-qt"] # + a build.rs that compiles src/lib-qt-shim.cpp
uikit = ["dep:day-uikit", …]
mdc = ["dep:day-android"] # + [package.metadata.day.android]
xaml = ["dep:day-xaml", "dep:day-xaml-sys"] # + build.rs compiles src/lib-xaml-shim.cpp
The app mirrors each: my-piece/<backend> in the matching feature. Adding a piece needs no
changes to day, the toolkit crates, the CLI, or the Gradle scaffold.
Dependency layering. The extension graph stays acyclic by rule: pieces may depend on
parts (day-piece-remote-image → day-part-http is the shipped example) and on core crates;
parts must not depend on day-pieces or any day-piece-*; tweaks may depend on day-pieces
(the built-ins they configure) but not on any satellite day-piece-* or day-part-*. A
workspace test (crates/day-cli/tests/layering.rs) enforces this over cargo metadata, so a
violating dependency fails cargo test.
5. Container pieces (hosting a Day child)
A piece is not limited to leaves: it can be a container whose native view hosts a Day-built
subtree. day-core mounts children by handle, not by kind (the tree walks to the nearest native
ancestor and calls Toolkit::insert(ancestor_handle, child_handle, index) without consulting the
ancestor’s kind), so a piece-realized node is a valid insertion parent on every backend. The recipe
(established by pieces/day-piece-pullrefresh, the reference container piece; see docs/pullrefresh.md):
let node = cx.native(
KIND,
&MyProps { /* … */ },
Rc::new(day_core::FrameLayout { width: None, height: None }), // child fills the container
Flex { grow_w: true, grow_h: true, ..Default::default() },
day_core::Boundary::Yes,
);
cx.under(node, |cx| { let _ = child.build(cx); }); // mount the Day child inside
- Supply a layout (
FrameLayout/PassThrough, or your ownday_core::Layout). A container node measures/places through its layout, not through the renderer’smeasurefn. - Your per-backend
makemust return a container-capable native view: anyNSView/UIView, anyQWidget, any ArkUI FrameNode, but on GTK agtk4::Fixed-backed view, on Android aViewGroup, and on XAML aPanel, or the genericinsertsilently drops the child. (Native wrappers like Android’sSwipeRefreshLayoutare ViewGroups.) - Events still flow through the single sink (
Event::Customfor piece-defined ones) and commands throughwith_tree(|t| t.patch(node, …)), identical to leaf pieces.
External toolkits: registering a platform-toolkit pair (experimental)
A toolkit implemented in a separate repository registers its platform-toolkit pair by declaring it in
the toolkit crate’s Cargo.toml:
[package.metadata.day.toolkit]
target = "netbsd-wxwidgets" # <os>-<toolkit>; the toolkit half names the app's cargo feature
host = "any" # optional: restrict to "macos" | "linux" | "windows"
label = "wxWidgets" # optional: pickers and error listings
doctor = "wx-config --version" # optional: `day doctor` probe (command + space-separated args)
The CLI resolves -p <name> against the builtin catalog first, then against declarations found in
the app’s dependency graph (via cargo metadata --all-features, since the toolkit crate sits
behind the very feature its declaration names). A declared target inherits the desktop
pipeline. That is the only kind accepted today, because a new pipeline kind (another mobile
OS) means new build/launch/pack code in the CLI, which cannot come from a crate.
The app wires the toolkit the same way it would a builtin, plus one entry call:
# the app's Cargo.toml
[features]
wxwidgets = ["dep:day-toolkit-wx"] # feature = the toolkit half of the target name
[dependencies]
day-toolkit-wx = { git = "…", optional = true }
// the app's main.rs — the toolkit's entry wraps `day::launch_external`, the cfg-free
// launcher that starts the dayscript engine exactly as the builtin launchers do.
#[cfg(feature = "wxwidgets")]
day_toolkit_wx::launch(options, root);
With targets = ["netbsd-wxwidgets"] in Day.toml, the target behaves like a builtin desktop
target: day launch (build + run + log streaming), --script walkthroughs, day drive,
day stop/relaunch, the session registry, day doctor (running the declared probe), day metadata (the catalog entry carries external: true and the declaring crate), and day lint.
What a declared target does not get:
day pack— packaging formats are per-OS CLI code; the guard says so explicitly.day new/day project add-target— scaffolding stays builtin; the toolkit crate documents its own project shape.- The pieces’ native renderers: no in-repo piece ships a
wxwidgetsfeature arm, and neither does the externalday-piece-webview, so extension-piece kinds render Day’s visible⟨kind⟩placeholders unless the external ecosystem ships renderer crates for its backend (theRegistry/renderer!registration path is the same one in-repo pieces use). The built-in vocabulary is the backend’s ownrealize: cover what you support and placeholder the rest; theassert_no_placeholdersallow-lists record which kinds still render a placeholder on each backend.
Reference
pieces/day-piece-searchfield implements all of the above: a native search input on six backends,
its own Qt + XAML C++ shims, and its own Android Java. It’s verified on AppKit / GTK / Qt / iOS /
Android and CI-built on XAML. Use it as a template. Its layout keeps the shared front-end and each
toolkit backend in a separate file:
pieces/day-piece-searchfield/
├── Cargo.toml # features + [package.metadata.day.android]
├── build.rs # compiles lib-qt-shim.cpp / lib-xaml-shim.cpp per feature
└── src/
├── lib.rs # front-end (the `Piece`) + `day_pieces::glue_modules!(…)`
├── lib-appkit.rs # one file per toolkit renderer …
├── lib-gtk.rs
├── lib-qt.rs (+ lib-qt-shim.cpp)
├── lib-uikit.rs
├── lib-android.rs (+ DaySearch.java)
├── lib-xaml.rs (+ lib-xaml-shim.cpp)
├── lib-qt-shim.cpp
└── lib-xaml-shim.cpp
lib.rs declares the backends with one day_pieces::glue_modules!(appkit, gtk, qt, uikit, mdc, xaml) line (§2), so every lib-<toolkit>.rs is compiled only for its feature+target and the whole
native surface for a toolkit lives in one place.
day-piece-webview is a second reference, in its own
repository since 2026-09 (its docs/webview.md covers the piece): a heavier native backend (an
embedded browser) that additionally contributes an Android permission, hand-rolls the iOS
WKWebView (linking WebKit.framework through the frameworks key above), and returns the proposal
from measure so a growing leaf fills on Android. It follows the external-repository rules
described under day-piece-lottie below, and its demo/ app’s dayscript is its on-device test. The
JavaScript evaluation seam it registers with stays here, in webview-eval.md.
day-piece-lottie is a third reference, and the first
piece to live in a separate repository: an iOS/Android-only piece that pulls an external native package
on each platform, the lottie-ios SwiftPM package (via the [package.metadata.day.ios] mechanism
above) and com.airbnb.android:lottie (Gradle). Its Swift and Java shims each wrap a
LottieAnimationView behind a flat C ABI / static method. It is also the reference for an external
repository: bare canonical git = "https://github.com/daybrite/day.git" dependencies with no ref (so
the consuming app’s Cargo.lock picks one day revision for the whole graph), a
[package.metadata.day] compat = "0.4" line naming the day minor it was tested against, a demo/
app whose dayscript is the on-device test, and a headless model module tested on the host. In
this tree, scripts/ci/scaffold-check.sh keeps swift-packages exercised on the ios-uikit leg
with a fixture piece that pulls swift-collections.
day-part-speech is the reference for the other mechanism, and the
first part to live in a separate repository: a headless part whose every platform implementation
is a bridge arm — Swift, Java, ArkTS, JavaScript, C++, and C — inline in one
src/lib.rs beside the Rust declaration they share. Its docs/speech.md walks through the arms.
day-piece-camera is a fourth: a native camera
on the three mobile toolkits — a Swift shim that reports back through a C function pointer, a
Java class over CameraX that is its own LifecycleOwner, and a HarmonyOS arm where the ArkTS
half only hosts an XComponent surface and a C shim over the NDK camera kit (compiled by the
crate’s own build.rs) owns the session. It is also the first piece to declare
[package.metadata.day.permissions] uses = ["camera"], and the app supplies the reason.
As an external repository it follows the same rules as day-piece-lottie above: bare canonical day
dependencies, a compat line, and a demo/ app whose dayscript is the on-device test.
parts/day-part-battery (see battery.md) is a fourth reference, the first part:
a headless crate with no UI Piece at all. Where pieces/ holds UI-library extensions (each
registers a renderer), parts/ is the non-UI counterpart: capability crates (day-part-*) that extend
Day apps with platform services. It shows the backend-contribution mechanism accommodates non-UI
capabilities: it contributes Android Java (for BatteryManager, now through a
bridge arm) but registers nothing into any RENDERERS slice, and selects its per-OS impl by
#[cfg(target_os)] rather than a toolkit feature. Any Rust code can depend on it and call
day_part_battery::status().
Named piece operations
A piece can expose operations without making Day depend on its crate or native backend.
Register a handler with day_core::register_piece_operation(kind, name, handler) and call it
through day_core::piece_operation(node, name, input, done). Names should include a namespace,
such as example.document.search; the piece defines the textual input and result formats.
The registry is keyed by both piece kind and operation name. Different pieces and different
operations coexist; re-registering the same pair replaces its handler. Dispatch looks up the
live node’s kind and releases the registry and tree borrows before invoking the handler.
It returns false for a missing node or operation, without calling the completion callback.
A handler that accepts a request must complete it with a result or error, either immediately
or later. Callers must keep the event loop running while awaiting asynchronous results.
Dayscript’s existing web_eval command is an adapter for the external webview’s day.webview.eval
operation. The core registry does not interpret that name or its payload.
Flatpak dependencies
A dependency can declare the Flatpak base its native library requires:
[[package.metadata.day.flatpak.bases]]
toolkit = "qt"
library-prefix = "libExampleEngine"
id = "org.example.Engine.BaseApp"
# version = "1" # Omit to follow the target runtime version.
day pack reads declarations from the app’s resolved dependency graph, including the app
itself, with the toolkit and discovered piece features enabled. It selects declarations for
the target toolkit whose library-prefix matches a direct ELF DT_NEEDED entry. The prefix
is literal, not a glob. A known non-match omits the base; an unreadable or unsupported binary
retains the declared requirement so a failed probe cannot silently drop a runtime dependency.
Flatpak supports one base. Identical base IDs and resolved versions coalesce; conflicting
requirements stop packaging with an error. Malformed declarations also fail packaging.
If version is omitted, it follows the selected runtime version, including DAY_KDE_RUNTIME
or DAY_GNOME_RUNTIME overrides. Engine IDs, prefixes, and version pins belong to the declaring
crate; Day only resolves and renders the declarations.
When running Day’s full lint script against an external crate under development, set
DAY_LINT_LOCAL_CHECKOUTS to its checkout path. Multiple paths are separated by newlines.
The script adds them to Showcase’s local patch table alongside Day, so its Clippy checks use
the same sources as your local app builds.
AppImage environment
A dependency can set an environment variable in one toolkit’s AppImage launcher:
[[package.metadata.day.appimage.env]]
toolkit = "qt"
name = "QT_MEDIA_BACKEND"
value = "ffmpeg"
An AppImage runs against the libraries bundled inside it, and a bundled library can need a
setting that the build machine did not. day pack reads declarations from the app’s resolved
dependency graph, including the app itself, the same way it reads Flatpak bases. It writes each
declaration for the target toolkit into the AppImage’s AppRun. At launch, the variable is set
only when the user’s environment leaves it unset or empty, so the user’s own setting wins. The
value is literal; the launcher expands nothing in it.
A name uses letters, digits, and _, and must not start with a digit or with DAY_, which the
launcher uses for its own paths. Identical declarations coalesce. Two crates giving one variable
different values stop packaging with an error, and so does a malformed declaration. The Flatpak
launcher does not apply these defaults, because a Flatpak takes its toolkit from the runtime.
day-piece-media is the reference. linuxdeploy bundles libgstreamer but no GStreamer element
plugins, and the bundled library looks for plugins only beside itself. Qt Multimedia’s GStreamer
backend then finds no elements and crashes, so the crate selects Qt’s FFmpeg backend, whose codec
libraries linuxdeploy does bundle.