diff --git a/examples/Cargo.toml b/examples/Cargo.toml
index 2672f20..b2d434f 100644
--- a/examples/Cargo.toml
+++ b/examples/Cargo.toml
@@ -2,6 +2,7 @@
resolver = "2"
members = [
"animations",
+ "bundle-wry",
"canvas_stress_test",
"canvas_waves",
"clipboard",
diff --git a/examples/bundle-wry/.gitignore b/examples/bundle-wry/.gitignore
new file mode 100644
index 0000000..1521c8b
--- /dev/null
+++ b/examples/bundle-wry/.gitignore
@@ -0,0 +1 @@
+dist
diff --git a/examples/bundle-wry/Cargo.toml b/examples/bundle-wry/Cargo.toml
new file mode 100644
index 0000000..25f8ba9
--- /dev/null
+++ b/examples/bundle-wry/Cargo.toml
@@ -0,0 +1,45 @@
+[package]
+name = "bundle-wry"
+version = "0.1.0"
+edition = "2021"
+description = "Ratzilla web and wry desktop app example"
+publish = false
+
+[package.metadata.bundle]
+name = "bundle-wry"
+identifier = "rs.ratatui.bundle-wry"
+icon = ["icons/icon.png"]
+category = "Utility"
+short_description = "ratatui-ratzilla bundle wry example"
+
+[package.metadata.bundle.bin.bundle-wry]
+name = "bundle-wry"
+identifier = "rs.ratatui.bundle-wry"
+icon = ["icons/icon.png"]
+category = "Utility"
+short_description = "bundle-wry"
+resources = ["dist"]
+
+[features]
+default = ["bundle-wry"]
+bundle-wry = ["dep:tao", "dep:wry", "dep:muda"]
+
+[dependencies]
+critical-section = { workspace = true, features = ["std"] }
+muda = { version = "0.19", optional = true }
+rand = { version = "0.9.2", default-features = false, features = ["small_rng"] }
+ratzilla = { workspace = true }
+tachyonfx = { version = "0.23.0", default-features = false, features = ["wasm"] }
+tao = { version = "0.35.3", optional = true }
+wasm-bindgen = "0.2.108"
+## web-sys = { version = "0.3.81", features = [
+## "Window",
+## "Document",
+## "Element",
+## "HtmlElement",
+## "Location",
+## "Url",
+## "UrlSearchParams",
+## ], optional = true }
+web-time = "1.1.0"
+wry = { version = "0.55.1", optional = true }
diff --git a/examples/bundle-wry/Trunk.toml b/examples/bundle-wry/Trunk.toml
new file mode 100644
index 0000000..1ff03d2
--- /dev/null
+++ b/examples/bundle-wry/Trunk.toml
@@ -0,0 +1,59 @@
+[build]
+# The index HTML file to drive the bundling process.
+target = "index.html"
+# Build in release mode (equivalent to --release).
+release = false
+# The output directory for all final assets.
+dist = "dist"
+# The public URL from which assets are to be served.
+public_url = "./"
+# Whether to include hash values in the output file names.
+filehash = true
+
+[watch]
+# Paths to watch for changes (beyond the build.target parent folder).
+watch = ["src"]#, "assets"]
+# Paths to ignore.
+ignore = []
+
+[serve]
+addresses = ["127.0.0.1"]
+aliases = ["rs.ratatui.bundle-wry", "localhost"]
+# The port to serve on.
+port = 8080
+# Open a browser tab once the initial build is complete.
+open = true
+# Disable auto-reload of the web app.
+no_autoreload = true
+
+[clean]
+# The output directory to clean.
+dist = "dist"
+# Whether to perform a cargo clean as well.
+cargo = false
+
+[tools]
+# Versions of tools to download if not present in the system.
+sass = "1.69.5"
+wasm_bindgen = "0.2.126"
+wasm_opt = "version_126"
+
+# --- Proxies ---
+# Proxy requests from the development server to a backend.
+[[proxy]]
+# Requests to /api/ will be proxied to the backend.
+backend = "http://localhost:9000/api/"
+# The prefix to strip from the incoming request URI before sending it to the backend.
+rewrite = "/api/"
+
+# --- Hooks ---
+# Hooks are executed as part of the Trunk pipeline.
+[[hooks]]
+stage = "pre_build"
+command = "echo"
+command_arguments = ["Starting build process..."]
+
+[[hooks]]
+stage = "build"
+command = "sh"
+command_arguments = ["-c", "echo Staging directory: $TRUNK_STAGING_DIR"]
diff --git a/examples/bundle-wry/icons/icon.png b/examples/bundle-wry/icons/icon.png
new file mode 100644
index 0000000..d194741
Binary files /dev/null and b/examples/bundle-wry/icons/icon.png differ
diff --git a/examples/bundle-wry/index.html b/examples/bundle-wry/index.html
new file mode 100644
index 0000000..3211e14
--- /dev/null
+++ b/examples/bundle-wry/index.html
@@ -0,0 +1,43 @@
+
+
+
+
+
+ Ratzilla Demo
+
+
+
+
+
+
diff --git a/examples/bundle-wry/scripts/bundle.sh b/examples/bundle-wry/scripts/bundle.sh
new file mode 100755
index 0000000..3e9fa16
--- /dev/null
+++ b/examples/bundle-wry/scripts/bundle.sh
@@ -0,0 +1,163 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+source "$(dirname "${BASH_SOURCE[0]}")/common.sh"
+
+usage() {
+ cat <<'EOF'
+Usage: scripts/bundle.sh [--build] [--run] [--test]
+
+Builds the macOS app bundle for bundle-wry.
+--run opens the bundled macOS app.
+--test opens the bundled macOS app, curls the bundled index.html, and verifies the HTML.
+EOF
+}
+
+build=false
+run=false
+test=false
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --build)
+ build=true
+ shift
+ ;;
+ --run)
+ run=true
+ shift
+ ;;
+ --test)
+ test=true
+ shift
+ ;;
+ -h|--help)
+ usage
+ exit 0
+ ;;
+ *)
+ echo "Unknown argument: $1" >&2
+ usage >&2
+ exit 1
+ ;;
+ esac
+done
+
+if [[ "$build" == false && "$run" == false && "$test" == false ]]; then
+ usage
+ exit 1
+fi
+
+root="$(repo_root)"
+cd "$root"
+
+bundle_root="$(cargo_target_dir)/release/bundle"
+bundle_name="bundle-wry"
+bundle_app_path() {
+ find "$bundle_root" -type d -name "${bundle_name}.app" -print -quit
+}
+
+build_bundle() {
+ ensure_wasm_target
+ ensure_cargo_bundle
+ ensure_trunk
+
+ trunk build --release --dist dist
+ cargo bundle --bin bundle-wry --release
+}
+
+ensure_bundle() {
+ if [[ -z "$(bundle_app_path)" ]]; then
+ build_bundle
+ fi
+}
+
+run_bundle() {
+ local app_path
+ app_path="$(bundle_app_path)"
+ if [[ -z "$app_path" ]]; then
+ echo "Missing bundled app; run scripts/bundle.sh --build first." >&2
+ exit 1
+ fi
+
+ open "$app_path"
+}
+
+test_bundle() {
+ local app_path
+ local html
+ local app_pid=""
+ local url="http://127.0.0.1:8080"
+
+ app_path="$(bundle_app_path)"
+ if [[ -z "$app_path" ]]; then
+ echo "Missing bundled app; run scripts/bundle.sh --build first." >&2
+ exit 1
+ fi
+
+ if lsof -nP -iTCP:8080 -sTCP:LISTEN >/dev/null 2>&1; then
+ echo "Port 8080 is already in use; stop the existing listener first." >&2
+ exit 1
+ fi
+
+ cleanup() {
+ if [[ -n "$app_pid" ]] && kill -0 "$app_pid" >/dev/null 2>&1; then
+ kill "$app_pid" >/dev/null 2>&1 || true
+ wait "$app_pid" >/dev/null 2>&1 || true
+ fi
+ }
+
+ trap cleanup EXIT
+
+ open "$app_path"
+
+ for _ in $(seq 1 60); do
+ app_pid="$(pgrep -n -f "${bundle_name}.app/Contents/MacOS/${bundle_name}" || true)"
+ if [[ -n "$app_pid" ]]; then
+ break
+ fi
+ sleep 1
+ done
+
+ if [[ -z "$app_pid" ]]; then
+ echo "Timed out waiting for bundled app process to start." >&2
+ exit 1
+ fi
+
+ for _ in $(seq 1 60); do
+ if html="$(curl -fsS "$url" 2>/dev/null)"; then
+ break
+ fi
+ sleep 1
+ done
+
+ if [[ -z "${html:-}" ]]; then
+ echo "Timed out waiting for $url" >&2
+ exit 1
+ fi
+
+ printf '%s' "$html" | grep -F 'Ratzilla Demo'
+ js_path="$(printf '%s' "$html" | grep -oE "./[^']+\.js" | head -n 1)"
+ wasm_path="$(printf '%s' "$html" | grep -oE "./[^']+_bg\.wasm" | head -n 1)"
+ curl -fsS "$url/${js_path#./}" >/dev/null
+ curl -fsS "$url/${wasm_path#./}" >/dev/null
+
+ cleanup
+ trap - EXIT
+ printf 'Verified %s\n' "$url"
+}
+
+if [[ "$build" == true ]]; then
+ build_bundle
+fi
+
+if [[ "$run" == true ]]; then
+ ensure_bundle
+ run_bundle
+fi
+
+if [[ "$test" == true ]]; then
+ ensure_bundle
+ test_bundle
+fi
diff --git a/examples/bundle-wry/scripts/common.sh b/examples/bundle-wry/scripts/common.sh
new file mode 100755
index 0000000..71e02b4
--- /dev/null
+++ b/examples/bundle-wry/scripts/common.sh
@@ -0,0 +1,100 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+repo_root() {
+ cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd
+}
+
+ensure_wasm_target() {
+ if rustup target list --installed | grep -qx 'wasm32-unknown-unknown'; then
+ return
+ fi
+
+ rustup target add wasm32-unknown-unknown
+}
+
+ensure_trunk() {
+ if command -v trunk >/dev/null 2>&1; then
+ return
+ fi
+
+ cargo install trunk --locked
+}
+
+ensure_cargo_bundle() {
+ if command -v cargo-bundle >/dev/null 2>&1; then
+ return
+ fi
+
+ cargo install cargo-bundle --locked
+}
+
+cargo_target_dir() {
+ local target_dir
+ target_dir="$(
+ cargo metadata --format-version 1 --no-deps |
+ sed -n 's/.*"target_directory":"\([^"]*\)".*/\1/p'
+ )"
+
+ if [[ -z "$target_dir" ]]; then
+ echo "Unable to determine Cargo target directory." >&2
+ return 1
+ fi
+
+ printf '%s\n' "$target_dir"
+}
+
+host_triple() {
+ local line
+ while IFS= read -r line; do
+ case "$line" in
+ host:*)
+ printf '%s\n' "${line#host: }"
+ return
+ ;;
+ esac
+ done < <(rustc -vV)
+
+ return 1
+}
+
+bundle_artifact_path() {
+ local binary_name="$1"
+ local bundle_root="${2:-$(cargo_target_dir)/release/bundle}"
+ local os_name
+ os_name="$(uname -s)"
+
+ case "$os_name" in
+ Darwin)
+ local artifact
+ artifact="$(find "$bundle_root" -type f -name "${binary_name}.dmg" -print -quit)"
+ if [[ -n "$artifact" ]]; then
+ printf '%s\n' "$artifact"
+ return
+ fi
+ find "$bundle_root" -type d -name "${binary_name}.app" -print -quit
+ ;;
+ Linux)
+ local artifact
+ artifact="$(find "$bundle_root" -type f -name "${binary_name}.deb" -print -quit)"
+ if [[ -n "$artifact" ]]; then
+ printf '%s\n' "$artifact"
+ return
+ fi
+ find "$bundle_root" -type f -name "${binary_name}.AppImage" -print -quit
+ ;;
+ MINGW*|MSYS*|CYGWIN*|Windows_NT)
+ local artifact
+ artifact="$(find "$bundle_root" -type f -name "${binary_name}.msi" -print -quit)"
+ if [[ -n "$artifact" ]]; then
+ printf '%s\n' "$artifact"
+ return
+ fi
+ find "$bundle_root" -type f -name "${binary_name}.exe" -print -quit
+ ;;
+ *)
+ find "$bundle_root" -type f -name "${binary_name}*" -print -quit
+ ;;
+ esac
+}
diff --git a/examples/bundle-wry/scripts/deploy.sh b/examples/bundle-wry/scripts/deploy.sh
new file mode 100755
index 0000000..52fd0c5
--- /dev/null
+++ b/examples/bundle-wry/scripts/deploy.sh
@@ -0,0 +1,101 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+source "$(dirname "${BASH_SOURCE[0]}")/common.sh"
+
+usage() {
+ cat <<'EOF'
+Usage: scripts/deploy.sh [--out-dir DIR] [--tag TAG] [--skip-build]
+
+Builds the desktop wrapper and wasm app, then stages release artifacts under
+the chosen output directory. If --tag is provided, the archives are uploaded to
+the matching GitHub release with the gh CLI.
+EOF
+}
+
+out_dir="dist/releases"
+tag=""
+skip_build=false
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --out-dir)
+ out_dir="$2"
+ shift 2
+ ;;
+ --tag)
+ tag="$2"
+ shift 2
+ ;;
+ --skip-build)
+ skip_build=true
+ shift
+ ;;
+ -h|--help)
+ usage
+ exit 0
+ ;;
+ *)
+ echo "Unknown argument: $1" >&2
+ usage >&2
+ exit 1
+ ;;
+ esac
+done
+
+root="$(repo_root)"
+cd "$root"
+mkdir -p "$out_dir"
+
+if [[ "$skip_build" == false ]]; then
+ "$root/scripts/bundle.sh" --build
+fi
+
+host="$(host_triple)"
+release_dir="$out_dir/$host"
+bundle_root="$(cargo_target_dir)/release/bundle"
+wasm_dist="dist"
+
+bundle_artifact="$(bundle_artifact_path bundle-wry "$bundle_root")"
+
+if [[ -z "$bundle_artifact" ]]; then
+ echo "Missing bundle-wry bundle in $bundle_root; omit --skip-build to build it." >&2
+ exit 1
+fi
+
+if [[ ! -d "$wasm_dist" ]]; then
+ echo "Missing $wasm_dist; omit --skip-build to build it." >&2
+ exit 1
+fi
+
+rm -rf "$release_dir"
+mkdir -p "$release_dir"
+
+bundle_name="$(basename "$bundle_artifact")"
+
+cp -R "$bundle_artifact" "$release_dir/$bundle_name"
+cp -R "$wasm_dist" "$release_dir/bundle-wry-web"
+
+bundle_archive="$out_dir/bundle-wry-$host.tar.gz"
+web_archive="$out_dir/bundle-wry-web-$host.tar.gz"
+
+tar -C "$release_dir" -czf "$bundle_archive" "$bundle_name"
+tar -C "$release_dir" -czf "$web_archive" bundle-wry-web
+
+printf 'Staged %s\n' "$release_dir"
+printf 'Packed %s\n' "$bundle_archive"
+printf 'Packed %s\n' "$web_archive"
+
+if [[ -n "$tag" ]]; then
+ if ! command -v gh >/dev/null 2>&1; then
+ echo "gh is required to upload release artifacts." >&2
+ exit 1
+ fi
+
+ if ! gh release view "$tag" >/dev/null 2>&1; then
+ gh release create "$tag" --title "$tag" --notes "Automated release for $tag"
+ fi
+
+ gh release upload "$tag" "$bundle_archive" "$web_archive" --clobber
+fi
diff --git a/examples/bundle-wry/src/app.rs b/examples/bundle-wry/src/app.rs
new file mode 100644
index 0000000..6191ed9
--- /dev/null
+++ b/examples/bundle-wry/src/app.rs
@@ -0,0 +1,377 @@
+use crate::effects;
+use rand::{
+ distr::{Distribution, Uniform},
+ rngs::SmallRng,
+ SeedableRng,
+};
+use ratzilla::ratatui::widgets::ListState;
+use tachyonfx::{Duration, EffectManager};
+
+const TASKS: [&str; 24] = [
+ "Item1", "Item2", "Item3", "Item4", "Item5", "Item6", "Item7", "Item8", "Item9", "Item10",
+ "Item11", "Item12", "Item13", "Item14", "Item15", "Item16", "Item17", "Item18", "Item19",
+ "Item20", "Item21", "Item22", "Item23", "Item24",
+];
+
+const LOGS: [(&str, &str); 26] = [
+ ("Event1", "INFO"),
+ ("Event2", "INFO"),
+ ("Event3", "CRITICAL"),
+ ("Event4", "ERROR"),
+ ("Event5", "INFO"),
+ ("Event6", "INFO"),
+ ("Event7", "WARNING"),
+ ("Event8", "INFO"),
+ ("Event9", "INFO"),
+ ("Event10", "INFO"),
+ ("Event11", "CRITICAL"),
+ ("Event12", "INFO"),
+ ("Event13", "INFO"),
+ ("Event14", "INFO"),
+ ("Event15", "INFO"),
+ ("Event16", "INFO"),
+ ("Event17", "ERROR"),
+ ("Event18", "ERROR"),
+ ("Event19", "INFO"),
+ ("Event20", "INFO"),
+ ("Event21", "WARNING"),
+ ("Event22", "INFO"),
+ ("Event23", "INFO"),
+ ("Event24", "WARNING"),
+ ("Event25", "INFO"),
+ ("Event26", "INFO"),
+];
+
+const EVENTS: [(&str, u64); 24] = [
+ ("B1", 9),
+ ("B2", 12),
+ ("B3", 5),
+ ("B4", 8),
+ ("B5", 2),
+ ("B6", 4),
+ ("B7", 5),
+ ("B8", 9),
+ ("B9", 14),
+ ("B10", 15),
+ ("B11", 1),
+ ("B12", 0),
+ ("B13", 4),
+ ("B14", 6),
+ ("B15", 4),
+ ("B16", 6),
+ ("B17", 4),
+ ("B18", 7),
+ ("B19", 13),
+ ("B20", 8),
+ ("B21", 11),
+ ("B22", 9),
+ ("B23", 3),
+ ("B24", 5),
+];
+
+#[derive(Clone)]
+pub struct RandomSignal {
+ distribution: Uniform,
+ rng: SmallRng,
+}
+
+impl RandomSignal {
+ pub fn new(lower: u64, upper: u64) -> Self {
+ Self {
+ distribution: Uniform::new(lower, upper).unwrap(),
+ rng: SmallRng::seed_from_u64(0),
+ }
+ }
+}
+
+impl Iterator for RandomSignal {
+ type Item = u64;
+ fn next(&mut self) -> Option {
+ Some(self.distribution.sample(&mut self.rng))
+ }
+}
+
+#[derive(Clone)]
+pub struct SinSignal {
+ x: f64,
+ interval: f64,
+ period: f64,
+ scale: f64,
+}
+
+impl SinSignal {
+ pub const fn new(interval: f64, period: f64, scale: f64) -> Self {
+ Self {
+ x: 0.0,
+ interval,
+ period,
+ scale,
+ }
+ }
+}
+
+impl Iterator for SinSignal {
+ type Item = (f64, f64);
+ fn next(&mut self) -> Option {
+ let point = (self.x, (self.x * 1.0 / self.period).sin() * self.scale);
+ self.x += self.interval;
+ Some(point)
+ }
+}
+
+pub struct TabsState<'a> {
+ pub titles: Vec<&'a str>,
+ pub index: usize,
+}
+
+impl<'a> TabsState<'a> {
+ pub const fn new(titles: Vec<&'a str>) -> Self {
+ Self { titles, index: 0 }
+ }
+ pub fn next(&mut self) {
+ self.index = (self.index + 1) % self.titles.len();
+ }
+
+ pub fn previous(&mut self) {
+ if self.index > 0 {
+ self.index -= 1;
+ } else {
+ self.index = self.titles.len() - 1;
+ }
+ }
+}
+
+pub struct StatefulList {
+ pub state: ListState,
+ pub items: Vec,
+}
+
+impl StatefulList {
+ pub fn with_items(items: Vec) -> Self {
+ Self {
+ state: ListState::default(),
+ items,
+ }
+ }
+
+ pub fn next(&mut self) {
+ let i = match self.state.selected() {
+ Some(i) => {
+ if i >= self.items.len() - 1 {
+ 0
+ } else {
+ i + 1
+ }
+ }
+ None => 0,
+ };
+ self.state.select(Some(i));
+ }
+
+ pub fn previous(&mut self) {
+ let i = match self.state.selected() {
+ Some(i) => {
+ if i == 0 {
+ self.items.len() - 1
+ } else {
+ i - 1
+ }
+ }
+ None => 0,
+ };
+ self.state.select(Some(i));
+ }
+}
+
+pub struct Signal {
+ source: S,
+ pub points: Vec,
+ tick_rate: usize,
+}
+
+impl Signal
+where
+ S: Iterator,
+{
+ fn on_tick(&mut self) {
+ self.points.drain(0..self.tick_rate);
+ self.points
+ .extend(self.source.by_ref().take(self.tick_rate));
+ }
+}
+
+pub struct Signals {
+ pub sin1: Signal,
+ pub sin2: Signal,
+ pub window: [f64; 2],
+}
+
+impl Signals {
+ fn on_tick(&mut self) {
+ self.sin1.on_tick();
+ self.sin2.on_tick();
+ self.window[0] += 1.0;
+ self.window[1] += 1.0;
+ }
+}
+
+pub struct Server<'a> {
+ pub name: &'a str,
+ pub location: &'a str,
+ pub coords: (f64, f64),
+ pub status: &'a str,
+}
+
+pub struct App<'a> {
+ pub title: &'a str,
+ pub should_quit: bool,
+ pub tabs: TabsState<'a>,
+ pub show_chart: bool,
+ pub progress: f64,
+ pub sparkline: Signal,
+ pub tasks: StatefulList<&'a str>,
+ pub logs: StatefulList<(&'a str, &'a str)>,
+ pub signals: Signals,
+ pub barchart: Vec<(&'a str, u64)>,
+ pub servers: Vec>,
+ pub enhanced_graphics: bool,
+ pub effects: EffectManager,
+ pub last_frame: web_time::Instant,
+}
+
+#[derive(Clone, Copy, Debug, Default, Ord, PartialOrd, Eq, PartialEq)]
+pub enum EffectKey {
+ #[default]
+ ChangeTab,
+}
+
+impl<'a> App<'a> {
+ pub fn new(title: &'a str, enhanced_graphics: bool) -> Self {
+ let mut rand_signal = RandomSignal::new(0, 100);
+ let sparkline_points = rand_signal.by_ref().take(300).collect();
+ let mut sin_signal = SinSignal::new(0.2, 3.0, 18.0);
+ let sin1_points = sin_signal.by_ref().take(100).collect();
+ let mut sin_signal2 = SinSignal::new(0.1, 2.0, 10.0);
+ let sin2_points = sin_signal2.by_ref().take(200).collect();
+
+ let mut effects = EffectManager::default();
+ effects.add_effect(effects::startup());
+ effects.add_effect(effects::pulsate_selected_tab());
+ App {
+ title,
+ should_quit: false,
+ tabs: TabsState::new(vec!["Home", "About", "Status", "Theme"]),
+ show_chart: true,
+ progress: 0.0,
+ sparkline: Signal {
+ source: rand_signal,
+ points: sparkline_points,
+ tick_rate: 1,
+ },
+ tasks: StatefulList::with_items(TASKS.to_vec()),
+ logs: StatefulList::with_items(LOGS.to_vec()),
+ signals: Signals {
+ sin1: Signal {
+ source: sin_signal,
+ points: sin1_points,
+ tick_rate: 5,
+ },
+ sin2: Signal {
+ source: sin_signal2,
+ points: sin2_points,
+ tick_rate: 10,
+ },
+ window: [0.0, 20.0],
+ },
+ barchart: EVENTS.to_vec(),
+ servers: vec![
+ Server {
+ name: "NorthAmerica-1",
+ location: "New York City",
+ coords: (40.71, -74.00),
+ status: "Up",
+ },
+ Server {
+ name: "Europe-1",
+ location: "Paris",
+ coords: (48.85, 2.35),
+ status: "Failure",
+ },
+ Server {
+ name: "SouthAmerica-1",
+ location: "São Paulo",
+ coords: (-23.54, -46.62),
+ status: "Up",
+ },
+ Server {
+ name: "Asia-1",
+ location: "Singapore",
+ coords: (1.35, 103.86),
+ status: "Up",
+ },
+ ],
+ enhanced_graphics,
+ effects,
+ last_frame: web_time::Instant::now(),
+ }
+ }
+
+ pub fn on_up(&mut self) {
+ self.tasks.previous();
+ }
+
+ pub fn on_down(&mut self) {
+ self.tasks.next();
+ }
+
+ pub fn on_right(&mut self) {
+ self.tabs.next();
+ self.add_transition_tab_effect();
+ }
+
+ pub fn on_left(&mut self) {
+ self.tabs.previous();
+ self.add_transition_tab_effect();
+ }
+
+ pub fn on_key(&mut self, c: char) {
+ match c {
+ 'q' => {
+ self.should_quit = true;
+ }
+ 't' => {
+ self.show_chart = !self.show_chart;
+ }
+ _ => {}
+ }
+ }
+
+ pub fn on_tick(&mut self) -> Duration {
+ // Update progress
+ self.progress += 0.001;
+ if self.progress > 1.0 {
+ self.progress = 0.0;
+ }
+
+ self.sparkline.on_tick();
+ self.signals.on_tick();
+
+ let log = self.logs.items.pop().unwrap();
+ self.logs.items.insert(0, log);
+
+ let event = self.barchart.pop().unwrap();
+ self.barchart.insert(0, event);
+
+ // calculate elapsed time since last frame
+ let now = web_time::Instant::now();
+ let elapsed = now.duration_since(self.last_frame).as_millis() as u32;
+ self.last_frame = now;
+
+ Duration::from_millis(elapsed)
+ }
+
+ fn add_transition_tab_effect(&mut self) {
+ let effect = effects::change_tab();
+ self.effects.add_unique_effect(EffectKey::ChangeTab, effect);
+ }
+}
diff --git a/examples/bundle-wry/src/backend.rs b/examples/bundle-wry/src/backend.rs
new file mode 100644
index 0000000..d3f17f2
--- /dev/null
+++ b/examples/bundle-wry/src/backend.rs
@@ -0,0 +1,569 @@
+use crate::{fps, utils::inject_backend_footer};
+use ratzilla::{
+ backend::{canvas::CanvasBackendOptions, dom::DomBackendOptions, webgl2::WebGl2BackendOptions},
+ error::Error,
+ event::{KeyEvent, MouseEvent},
+ ratatui::{backend::Backend, prelude::backend::ClearType, Terminal, TerminalOptions},
+ web_sys::window,
+ CanvasBackend, CellSized, DomBackend, WebEventHandler, WebGl2Backend,
+};
+use std::{convert::TryFrom, fmt, io};
+
+/// Available backend types
+#[derive(Debug, Clone, Copy, Default, PartialEq)]
+pub enum BackendType {
+ #[default]
+ Dom,
+ Canvas,
+ WebGl2,
+}
+
+impl BackendType {
+ /// Get the string representation
+ pub fn as_str(&self) -> &'static str {
+ match self {
+ BackendType::Dom => "dom",
+ BackendType::Canvas => "canvas",
+ BackendType::WebGl2 => "webgl2",
+ }
+ }
+}
+
+impl TryFrom for BackendType {
+ type Error = String;
+
+ fn try_from(s: String) -> Result {
+ match s.to_lowercase().as_str() {
+ "dom" => Ok(BackendType::Dom),
+ "canvas" => Ok(BackendType::Canvas),
+ "webgl2" => Ok(BackendType::WebGl2),
+ _ => Err(format!(
+ "Invalid backend type: '{s}'. Valid options are: dom, canvas, webgl2"
+ )),
+ }
+ }
+}
+
+impl fmt::Display for BackendType {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ write!(f, "{}", self.as_str())
+ }
+}
+
+/// Enum wrapper for different Ratzilla backends that implements the Ratatui Backend trait.
+///
+/// This enum allows switching between different rendering backends at runtime while
+/// providing a unified interface. All backend operations are delegated to the wrapped
+/// backend implementation.
+///
+/// # Backends
+///
+/// - `Dom`: HTML DOM-based rendering with accessibility features
+/// - `Canvas`: Canvas 2D API rendering with full Unicode support
+/// - `WebGl2`: GPU-accelerated rendering using WebGL2 and beamterm-renderer
+pub enum RatzillaBackend {
+ Dom(DomBackend),
+ Canvas(CanvasBackend),
+ WebGl2(WebGl2Backend),
+}
+
+impl RatzillaBackend {
+ /// Get the backend type for this backend instance.
+ pub fn backend_type(&self) -> BackendType {
+ match self {
+ RatzillaBackend::Dom(_) => BackendType::Dom,
+ RatzillaBackend::Canvas(_) => BackendType::Canvas,
+ RatzillaBackend::WebGl2(_) => BackendType::WebGl2,
+ }
+ }
+}
+
+impl CellSized for RatzillaBackend {
+ fn cell_size_px(&self) -> (f32, f32) {
+ match self {
+ RatzillaBackend::Dom(backend) => backend.cell_size_px(),
+ RatzillaBackend::Canvas(backend) => backend.cell_size_px(),
+ RatzillaBackend::WebGl2(backend) => backend.cell_size_px(),
+ }
+ }
+
+ fn cell_size_css_px(&self) -> (f32, f32) {
+ match self {
+ RatzillaBackend::Dom(backend) => backend.cell_size_css_px(),
+ RatzillaBackend::Canvas(backend) => backend.cell_size_css_px(),
+ RatzillaBackend::WebGl2(backend) => backend.cell_size_css_px(),
+ }
+ }
+}
+
+impl Backend for RatzillaBackend {
+ type Error = io::Error;
+
+ fn draw<'a, I>(&mut self, content: I) -> io::Result<()>
+ where
+ I: Iterator- ,
+ {
+ match self {
+ RatzillaBackend::Dom(backend) => backend.draw(content),
+ RatzillaBackend::Canvas(backend) => backend.draw(content),
+ RatzillaBackend::WebGl2(backend) => backend.draw(content),
+ }
+ }
+
+ fn flush(&mut self) -> io::Result<()> {
+ match self {
+ RatzillaBackend::Dom(backend) => backend.flush(),
+ RatzillaBackend::Canvas(backend) => backend.flush(),
+ RatzillaBackend::WebGl2(backend) => backend.flush(),
+ }
+ }
+
+ fn size(&self) -> io::Result {
+ match self {
+ RatzillaBackend::Dom(backend) => backend.size(),
+ RatzillaBackend::Canvas(backend) => backend.size(),
+ RatzillaBackend::WebGl2(backend) => backend.size(),
+ }
+ }
+
+ fn hide_cursor(&mut self) -> io::Result<()> {
+ match self {
+ RatzillaBackend::Dom(backend) => backend.hide_cursor(),
+ RatzillaBackend::Canvas(backend) => backend.hide_cursor(),
+ RatzillaBackend::WebGl2(backend) => backend.hide_cursor(),
+ }
+ }
+
+ fn show_cursor(&mut self) -> io::Result<()> {
+ match self {
+ RatzillaBackend::Dom(backend) => backend.show_cursor(),
+ RatzillaBackend::Canvas(backend) => backend.show_cursor(),
+ RatzillaBackend::WebGl2(backend) => backend.show_cursor(),
+ }
+ }
+
+ fn get_cursor_position(&mut self) -> io::Result {
+ match self {
+ RatzillaBackend::Dom(backend) => backend.get_cursor_position(),
+ RatzillaBackend::Canvas(backend) => backend.get_cursor_position(),
+ RatzillaBackend::WebGl2(backend) => backend.get_cursor_position(),
+ }
+ }
+
+ fn set_cursor_position>(
+ &mut self,
+ position: P,
+ ) -> io::Result<()> {
+ match self {
+ RatzillaBackend::Dom(backend) => backend.set_cursor_position(position),
+ RatzillaBackend::Canvas(backend) => backend.set_cursor_position(position),
+ RatzillaBackend::WebGl2(backend) => backend.set_cursor_position(position),
+ }
+ }
+
+ fn clear(&mut self) -> io::Result<()> {
+ match self {
+ RatzillaBackend::Dom(backend) => backend.clear(),
+ RatzillaBackend::Canvas(backend) => backend.clear(),
+ RatzillaBackend::WebGl2(backend) => backend.clear(),
+ }
+ }
+
+ fn append_lines(&mut self, n: u16) -> io::Result<()> {
+ match self {
+ RatzillaBackend::Dom(backend) => backend.append_lines(n),
+ RatzillaBackend::Canvas(backend) => backend.append_lines(n),
+ RatzillaBackend::WebGl2(backend) => backend.append_lines(n),
+ }
+ }
+
+ fn window_size(&mut self) -> io::Result {
+ match self {
+ RatzillaBackend::Dom(backend) => backend.window_size(),
+ RatzillaBackend::Canvas(backend) => backend.window_size(),
+ RatzillaBackend::WebGl2(backend) => backend.window_size(),
+ }
+ }
+
+ fn clear_region(&mut self, clear_type: ClearType) -> Result<(), Self::Error> {
+ match clear_type {
+ ClearType::All => self.clear(),
+ _ => Err(io::Error::other("unimplemented")),
+ }
+ }
+}
+
+impl WebEventHandler for RatzillaBackend {
+ fn on_mouse_event(&mut self, callback: F) -> Result<(), Error>
+ where
+ F: FnMut(MouseEvent) + 'static,
+ {
+ match self {
+ RatzillaBackend::Dom(backend) => backend.on_mouse_event(callback),
+ RatzillaBackend::Canvas(backend) => backend.on_mouse_event(callback),
+ RatzillaBackend::WebGl2(backend) => backend.on_mouse_event(callback),
+ }
+ }
+
+ fn clear_mouse_events(&mut self) {
+ match self {
+ RatzillaBackend::Dom(backend) => backend.clear_mouse_events(),
+ RatzillaBackend::Canvas(backend) => backend.clear_mouse_events(),
+ RatzillaBackend::WebGl2(backend) => backend.clear_mouse_events(),
+ }
+ }
+
+ fn on_key_event(&mut self, callback: F) -> Result<(), Error>
+ where
+ F: FnMut(KeyEvent) + 'static,
+ {
+ match self {
+ RatzillaBackend::Dom(backend) => backend.on_key_event(callback),
+ RatzillaBackend::Canvas(backend) => backend.on_key_event(callback),
+ RatzillaBackend::WebGl2(backend) => backend.on_key_event(callback),
+ }
+ }
+
+ fn clear_key_events(&mut self) {
+ match self {
+ RatzillaBackend::Dom(backend) => backend.clear_key_events(),
+ RatzillaBackend::Canvas(backend) => backend.clear_key_events(),
+ RatzillaBackend::WebGl2(backend) => backend.clear_key_events(),
+ }
+ }
+}
+
+/// Backend wrapper that automatically tracks FPS by recording frames on each flush.
+///
+/// This wrapper delegates all Backend trait methods to the inner RatzillaBackend
+/// while recording frame timing information when `flush()` is called successfully.
+/// The FPS data can be accessed through the `fps` module functions.
+pub struct FpsTrackingBackend {
+ inner: RatzillaBackend,
+}
+
+impl FpsTrackingBackend {
+ /// Create a new FPS tracking backend that wraps the given backend.
+ ///
+ /// Frame timing will be recorded automatically on each successful flush operation.
+ pub fn new(backend: RatzillaBackend) -> Self {
+ Self { inner: backend }
+ }
+
+ /// Get the backend type for the wrapped backend.
+ pub fn backend_type(&self) -> BackendType {
+ self.inner.backend_type()
+ }
+}
+
+impl From for FpsTrackingBackend {
+ fn from(backend: RatzillaBackend) -> Self {
+ Self::new(backend)
+ }
+}
+
+impl CellSized for FpsTrackingBackend {
+ fn cell_size_px(&self) -> (f32, f32) {
+ self.inner.cell_size_px()
+ }
+
+ fn cell_size_css_px(&self) -> (f32, f32) {
+ self.inner.cell_size_css_px()
+ }
+}
+
+impl Backend for FpsTrackingBackend {
+ type Error = io::Error;
+
+ fn draw<'a, I>(&mut self, content: I) -> io::Result<()>
+ where
+ I: Iterator
- ,
+ {
+ self.inner.draw(content)
+ }
+
+ fn flush(&mut self) -> io::Result<()> {
+ let result = self.inner.flush();
+ // Record frame after successful flush
+ if result.is_ok() {
+ fps::record_frame();
+ }
+ result
+ }
+
+ fn size(&self) -> io::Result {
+ self.inner.size()
+ }
+
+ fn hide_cursor(&mut self) -> io::Result<()> {
+ self.inner.hide_cursor()
+ }
+
+ fn show_cursor(&mut self) -> io::Result<()> {
+ self.inner.show_cursor()
+ }
+
+ fn get_cursor_position(&mut self) -> io::Result {
+ self.inner.get_cursor_position()
+ }
+
+ fn set_cursor_position>(
+ &mut self,
+ position: P,
+ ) -> io::Result<()> {
+ self.inner.set_cursor_position(position)
+ }
+
+ fn clear(&mut self) -> io::Result<()> {
+ self.inner.clear()
+ }
+
+ fn append_lines(&mut self, n: u16) -> io::Result<()> {
+ self.inner.append_lines(n)
+ }
+
+ fn window_size(&mut self) -> io::Result {
+ self.inner.window_size()
+ }
+
+ fn clear_region(&mut self, clear_type: ClearType) -> Result<(), Self::Error> {
+ match clear_type {
+ ClearType::All => self.clear(),
+ _ => Err(io::Error::other("unimplemented")),
+ }
+ }
+}
+
+impl WebEventHandler for FpsTrackingBackend {
+ fn on_mouse_event(&mut self, callback: F) -> Result<(), Error>
+ where
+ F: FnMut(MouseEvent) + 'static,
+ {
+ self.inner.on_mouse_event(callback)
+ }
+
+ fn clear_mouse_events(&mut self) {
+ self.inner.clear_mouse_events()
+ }
+
+ fn on_key_event(&mut self, callback: F) -> Result<(), Error>
+ where
+ F: FnMut(KeyEvent) + 'static,
+ {
+ self.inner.on_key_event(callback)
+ }
+
+ fn clear_key_events(&mut self) {
+ self.inner.clear_key_events()
+ }
+}
+
+/// Builder for creating terminals with different backend types and configuration options.
+///
+/// This builder provides a fluent API for configuring terminal and backend options
+/// before creating a terminal instance. It supports automatic backend selection
+/// from URL query parameters and includes FPS tracking by default.
+///
+/// # Backend Selection
+///
+/// The builder uses the following priority order for backend selection:
+/// 1. `?backend=` URL query parameter (dom, canvas, or webgl2)
+/// 2. Fallback backend specified in `with_fallback()`
+/// 3. Default backend (DOM)
+///
+/// # Example
+///
+/// ```no_run
+/// use bundle-wry::backend::{BackendType, MultiBackendBuilder};
+/// use ratzilla::backend::canvas::CanvasBackendOptions;
+/// use ratzilla::backend::webgl2::WebGl2BackendOptions;
+/// use ratzilla::ratatui::TerminalOptions;
+///
+/// fn main() -> Result<(), Box> {
+/// let terminal = MultiBackendBuilder::with_fallback(BackendType::Dom)
+/// .canvas_options(CanvasBackendOptions::new().grid_id("terminal-id"))
+/// .webgl2_options(WebGl2BackendOptions::new().size((1200, 800)))
+/// .build_terminal()?;
+///
+/// // Get backend type if needed
+/// let backend_type = terminal.backend().backend_type();
+/// let _ = backend_type;
+/// Ok(())
+/// }
+/// ```
+#[derive(Debug, Default)]
+pub struct MultiBackendBuilder {
+ default_backend: BackendType,
+
+ terminal_options: TerminalOptions,
+ canvas_options: CanvasBackendOptions,
+ dom_options: DomBackendOptions,
+ webgl2_options: WebGl2BackendOptions,
+}
+
+impl MultiBackendBuilder {
+ /// Create a new builder with the specified fallback backend type.
+ ///
+ /// The fallback backend will be used if no backend is specified in the URL query parameters.
+ pub fn with_fallback(default_backend: BackendType) -> Self {
+ Self {
+ default_backend,
+ ..Self::default()
+ }
+ }
+
+ /// Set terminal configuration options.
+ ///
+ /// These options control terminal behavior such as viewport behavior and drawing settings.
+ pub fn terminal_options(mut self, options: TerminalOptions) -> Self {
+ self.terminal_options = options;
+ self
+ }
+
+ /// Set options for the Canvas backend.
+ ///
+ /// These options control Canvas 2D rendering behavior such as font settings,
+ /// cursor appearance, and Unicode support.
+ pub fn canvas_options(mut self, options: CanvasBackendOptions) -> Self {
+ self.canvas_options = options;
+ self
+ }
+
+ /// Set options for the DOM backend.
+ ///
+ /// These options control DOM rendering behavior such as accessibility features,
+ /// element styling, and focus management.
+ pub fn dom_options(mut self, options: DomBackendOptions) -> Self {
+ self.dom_options = options;
+ self
+ }
+
+ /// Set options for the WebGL2 backend.
+ ///
+ /// These options control WebGL2 rendering behavior such as shader configuration,
+ /// GPU memory management, and performance settings.
+ pub fn webgl2_options(mut self, options: WebGl2BackendOptions) -> Self {
+ self.webgl2_options = options;
+ self
+ }
+
+ /// Build the terminal with the configured options and backend selection.
+ ///
+ /// This method:
+ /// 1. Determines the backend type from URL query parameters or fallback
+ /// 2. Creates the appropriate backend with the configured options
+ /// 3. Wraps the backend with FPS tracking
+ /// 4. Creates and returns the terminal with the selected backend
+ /// 5. Injects a backend footer into the DOM (best effort)
+ ///
+ /// # Returns
+ ///
+ /// The configured terminal instance. You can get the backend type using
+ /// `terminal.backend().backend_type()` if needed.
+ ///
+ /// # Errors
+ ///
+ /// Returns an error if backend creation or terminal initialization fails.
+ ///
+ /// # Example
+ ///
+ /// ```no_run
+ /// use bundle_wry::backend::{BackendType, MultiBackendBuilder};
+ ///
+ /// fn main() -> Result<(), Box> {
+ /// let terminal = MultiBackendBuilder::with_fallback(BackendType::Canvas)
+ /// .build_terminal()?;
+ ///
+ /// // Get backend type if needed
+ /// let backend_type = terminal.backend().backend_type();
+ /// println!("Using {backend_type} backend");
+ /// Ok(())
+ /// }
+ /// ```
+ pub fn build_terminal(self) -> io::Result> {
+ let backend_type = parse_backend_from_url(self.default_backend);
+ let backend = create_backend_with_options(
+ backend_type,
+ Some(self.dom_options),
+ Some(self.canvas_options),
+ Some(self.webgl2_options),
+ )?;
+
+ // Initialize FPS recorder
+ fps::init_fps_recorder();
+
+ // Wrap backend with FPS tracking
+ let fps_backend: FpsTrackingBackend = backend.into();
+ let terminal = Terminal::with_options(fps_backend, self.terminal_options)?;
+
+ // Inject footer (ignore errors)
+ let _ = inject_backend_footer(backend_type);
+
+ Ok(terminal)
+ }
+}
+
+impl From for MultiBackendBuilder {
+ fn from(backend_type: BackendType) -> Self {
+ MultiBackendBuilder::with_fallback(backend_type)
+ }
+}
+
+/// Parse the backend type from URL query parameters, with fallback to default.
+///
+/// Checks for a `?backend=` query parameter in the current page URL.
+/// Valid backend types are "dom", "canvas", and "webgl2" (case-insensitive).
+/// If no valid backend is found in the URL, returns the provided default.
+fn parse_backend_from_url(default: BackendType) -> BackendType {
+ window()
+ .and_then(|w| w.location().search().ok())
+ .and_then(|query| {
+ query
+ .trim_start_matches('?')
+ .split('&')
+ .find_map(|parameter| parameter.strip_prefix("backend="))
+ .map(str::to_owned)
+ })
+ .and_then(|backend| BackendType::try_from(backend).ok())
+ .unwrap_or(default)
+}
+
+/// Create a backend instance with the specified type and options.
+///
+/// Creates the appropriate backend variant (DOM, Canvas, or WebGL2) using the provided
+/// configuration options. Options default to `Default::default()` if `None` is provided.
+///
+/// # Arguments
+///
+/// * `backend_type` - The type of backend to create
+/// * `dom_options` - Configuration options for DOM backend (if applicable)
+/// * `canvas_options` - Configuration options for Canvas backend (if applicable)
+/// * `webgl2_options` - Configuration options for WebGL2 backend (if applicable)
+///
+/// # Returns
+///
+/// The created backend wrapped in a `RatzillaBackend` enum.
+///
+/// # Errors
+///
+/// Returns an error if the backend creation fails (e.g., WebGL2 not supported).
+fn create_backend_with_options(
+ backend_type: BackendType,
+ dom_options: Option,
+ canvas_options: Option,
+ webgl2_options: Option,
+) -> io::Result {
+ use RatzillaBackend::*;
+
+ match backend_type {
+ BackendType::Dom => Ok(Dom(DomBackend::new_with_options(
+ dom_options.unwrap_or_default(),
+ )?)),
+ BackendType::Canvas => Ok(Canvas(CanvasBackend::new_with_options(
+ canvas_options.unwrap_or_default(),
+ )?)),
+ BackendType::WebGl2 => Ok(WebGl2(WebGl2Backend::new_with_options(
+ webgl2_options.unwrap_or_default(),
+ )?)),
+ }
+}
diff --git a/examples/bundle-wry/src/effects.rs b/examples/bundle-wry/src/effects.rs
new file mode 100644
index 0000000..e64e723
--- /dev/null
+++ b/examples/bundle-wry/src/effects.rs
@@ -0,0 +1,78 @@
+use ratzilla::ratatui::{
+ layout::{Constraint, Layout},
+ prelude::Color,
+ style::Style,
+};
+use tachyonfx::{
+ fx::*, CellFilter, ColorSpace, Duration, Effect, EffectTimer, Interpolation::*, Motion,
+};
+
+pub fn startup() -> Effect {
+ let timer = EffectTimer::from_ms(3000, QuadIn);
+
+ parallel(&[
+ parallel(&[
+ sweep_in(Motion::LeftToRight, 100, 20, Color::Black, timer),
+ sweep_in(Motion::UpToDown, 100, 20, Color::Black, timer),
+ ]),
+ prolong_start(500, coalesce((2500, SineOut))),
+ ])
+}
+
+pub(super) fn pulsate_selected_tab() -> Effect {
+ let layout = Layout::vertical([Constraint::Length(3), Constraint::Min(0)]);
+ let highlighted_tab = CellFilter::AllOf(vec![
+ CellFilter::Layout(layout, 0),
+ CellFilter::FgColor(Color::LightYellow),
+ ]);
+
+ // never ends
+ repeating(hsl_shift_fg([-170.0, 25.0, 30.0], (1000, SineInOut))).with_filter(highlighted_tab)
+}
+
+pub(super) fn change_tab() -> Effect {
+ let layout = Layout::vertical([Constraint::Length(3), Constraint::Min(0)]);
+ let dissolved = Style::default().fg(Color::White).bg(BG_COLOR);
+
+ let flash_color = Color::from_u32(0x3232030);
+
+ sequence(&[
+ // close panel effect
+ with_duration(
+ Duration::from_millis(300),
+ parallel(&[
+ style_all_cells(),
+ never_complete(fade_to(flash_color, flash_color, (30, ExpoInOut))),
+ never_complete(dissolve_to(dissolved, (125, ExpoInOut))),
+ never_complete(fade_to_fg(BG_COLOR, (125, BounceOut))),
+ ])
+ .with_color_space(ColorSpace::Rgb),
+ ),
+ // init pane, after having closed the (not) "old" one
+ parallel(&[
+ style_all_cells(),
+ fade_from(BG_COLOR, BG_COLOR, (140, Linear)),
+ sweep_in(Motion::UpToDown, 40, 0, BG_COLOR, (140, Linear))
+ .with_color_space(ColorSpace::Hsl),
+ ]),
+ ])
+ .with_filter(CellFilter::Layout(layout, 1))
+}
+
+/// Style all cells have so that they have non-reset foreground and background colors.
+/// This ensures that color interpolation works correctly.
+fn style_all_cells() -> Effect {
+ never_complete(effect_fn((), 100_000, |_, _, cells| {
+ for (_, cell) in cells {
+ if cell.fg == Color::Reset {
+ cell.set_fg(Color::White);
+ }
+
+ if cell.bg == Color::Reset {
+ cell.set_bg(BG_COLOR);
+ }
+ }
+ }))
+}
+
+const BG_COLOR: Color = Color::from_u32(0x121212);
diff --git a/examples/bundle-wry/src/fps.rs b/examples/bundle-wry/src/fps.rs
new file mode 100644
index 0000000..0b64c08
--- /dev/null
+++ b/examples/bundle-wry/src/fps.rs
@@ -0,0 +1,107 @@
+use ratzilla::web_sys::window;
+use std::{cell::RefCell, thread_local};
+use wasm_bindgen::JsValue;
+use web_time::Instant;
+
+thread_local! {
+ /// Thread-local FPS recorder instance for shared use across examples
+ static FPS_RECORDER: RefCell