From 000271d7969dc8f135b44bf3d31316d7e1336db4 Mon Sep 17 00:00:00 2001 From: randymcmillan Date: Tue, 14 Jul 2026 13:04:27 -0400 Subject: [PATCH] feature(examples): bundle-wry 00-00000271 --- examples/Cargo.toml | 1 + examples/bundle-wry/.gitignore | 1 + examples/bundle-wry/Cargo.toml | 45 ++ examples/bundle-wry/Trunk.toml | 59 +++ examples/bundle-wry/icons/icon.png | Bin 0 -> 1081 bytes examples/bundle-wry/index.html | 43 ++ examples/bundle-wry/scripts/bundle.sh | 163 ++++++++ examples/bundle-wry/scripts/common.sh | 100 +++++ examples/bundle-wry/scripts/deploy.sh | 101 +++++ examples/bundle-wry/src/app.rs | 377 +++++++++++++++++ examples/bundle-wry/src/backend.rs | 569 ++++++++++++++++++++++++++ examples/bundle-wry/src/effects.rs | 78 ++++ examples/bundle-wry/src/fps.rs | 107 +++++ examples/bundle-wry/src/lib.rs | 6 + examples/bundle-wry/src/main.rs | 91 ++++ examples/bundle-wry/src/ui.rs | 434 ++++++++++++++++++++ examples/bundle-wry/src/utils.rs | 72 ++++ examples/bundle-wry/src/wry_app.rs | 349 ++++++++++++++++ 18 files changed, 2596 insertions(+) create mode 100644 examples/bundle-wry/.gitignore create mode 100644 examples/bundle-wry/Cargo.toml create mode 100644 examples/bundle-wry/Trunk.toml create mode 100644 examples/bundle-wry/icons/icon.png create mode 100644 examples/bundle-wry/index.html create mode 100755 examples/bundle-wry/scripts/bundle.sh create mode 100755 examples/bundle-wry/scripts/common.sh create mode 100755 examples/bundle-wry/scripts/deploy.sh create mode 100644 examples/bundle-wry/src/app.rs create mode 100644 examples/bundle-wry/src/backend.rs create mode 100644 examples/bundle-wry/src/effects.rs create mode 100644 examples/bundle-wry/src/fps.rs create mode 100644 examples/bundle-wry/src/lib.rs create mode 100644 examples/bundle-wry/src/main.rs create mode 100644 examples/bundle-wry/src/ui.rs create mode 100644 examples/bundle-wry/src/utils.rs create mode 100644 examples/bundle-wry/src/wry_app.rs 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 0000000000000000000000000000000000000000..d1947414596a05c52a5a1da61f7fe5b01a07ce4f GIT binary patch literal 1081 zcmeAS@N?(olHy`uVBq!ia0vp^CqS5k2}mkgS)OEIU}5ldaSW-L^X6_~(PB4=){7QT z&z|@l-@X0uo>NaBIJNQ5dN$|!5&`2~U(c^yc6E=vTz~zKcb`8OPk&ugQ}d>1uJ*6* zZ=@6anp~YWcu-#6Om!6C+ZQ0ZgcH(r^yC*n%e)j(Xu2$j5x8^5f2 zJTpRtrMo3z;*4#{FGHWhR2zmKzqI;TV$wtb9ib-IN1tT9*GoP?Nz1LY%WE zdG4CH%U0R`vtjluw7ypSe8d00%P0Q#^8vX7|?n1}y$MDYAMAOy%v;HRl?4#xRk%`%c@6pS=Tn6-~@$7;p@^h zrg5>aXMMGNuo`Ar#PM4<<6g&A$8O$MavtX2yt^CS-svevf3CRgz+=K?}@(rYvsGp^DygU>SVLe z-kewdI@-N#r7FyRy_*}al`MPyKi&KOu17ONl?|Xa6wd71`c$^wyEc1$^#gTbP@DoI z&$)b!?)x9nQM+%g&i`l)6FvO4^8V|)C#z%scV&kwm$<{ko!0=9&FX7w0weERwZR;A z`P#;=ud8&g&w2U1Q@O+irnY@u^40T<+bc?IUu)IA)`20w_yp(W1i@| zt@*VlC;ooA{@(UI!m}2F1AJrjajBn{36A%F&4pRyEBiWcZ`R2*wcf{*EFiIc2N>3& z%5(M`1mC~3QKAp*rPWr~PV9YkGvm8u)cvZj#icrJ%HXt?bX;of`aM}&ey{%>_R-o` zw)-I@;lF*;cCqTD*kaq++UJ@jepbVh>c?+izkc2SKk-TZ{`5_$wW}?J>({@Y4NayJ zk7`@X&0be7hxu#Y+vBrdMn1eccWc?!>*+Ah%_uwn+o!8MR=@C^wX-lNjV7(yEd6f5 zkJ_Y-(i1cVaEK>XJGjMf17AVrBTS8J-{M? N!PC{xWt~$(69BZ7{UiVY literal 0 HcmV?d00001 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> = RefCell::new(None); +} + +/// Records and calculates frames per second. +/// +/// `FpsRecorder` keeps track of frame timings in a ring buffer and +/// provides methods to calculate the current frames per second. +pub struct FpsRecorder { + /// Current position in the ring buffer + tail: usize, + /// Ring buffer of frame timestamps. Length is a power of 2 for + /// fast modulus operations. + recorded_frame: [Instant; 16], +} + +impl FpsRecorder { + /// Creates a new FPS recorder. + pub fn new() -> Self { + let recorder = Self { + tail: 0, + recorded_frame: [Instant::now(); 16], + }; + + debug_assert!( + recorder.recorded_frame.len().is_power_of_two(), + "recorded_frame length must be a power of two" + ); + + recorder + } + + /// Records a new frame timestamp. + pub fn record(&mut self) { + self.recorded_frame[self.tail] = Instant::now(); + self.tail = (self.tail + 1) & (self.recorded_frame.len() - 1); + } + + /// Calculates the current frames per second. + pub fn fps(&self) -> f32 { + // Find the newest recorded timestamp (the one just before tail) + let newest_idx = if self.tail == 0 { + self.recorded_frame.len() - 1 + } else { + self.tail - 1 + }; + + let elapsed = self.recorded_frame[newest_idx] + .duration_since(self.recorded_frame[self.tail]) + .as_secs_f32() + .max(0.001); // avoid division by zero + + // We have 16 frames, so there are 15 intervals between them + (self.recorded_frame.len() - 1) as f32 / elapsed + } +} + +/// Initialize the global FPS recorder +pub fn init_fps_recorder() { + FPS_RECORDER.with(|recorder| { + *recorder.borrow_mut() = Some(FpsRecorder::new()); + }); +} + +/// Record a frame for FPS calculation +pub fn record_frame() { + FPS_RECORDER.with(|recorder| { + if let Some(ref mut fps_recorder) = *recorder.borrow_mut() { + fps_recorder.record(); + // Update the footer FPS display + let fps = fps_recorder.fps(); + update_fps_display(fps); + } + }); +} + +/// Get the current FPS value +pub fn get_current_fps() -> f32 { + FPS_RECORDER.with(|recorder| { + if let Some(ref fps_recorder) = *recorder.borrow() { + fps_recorder.fps() + } else { + 0.0 + } + }) +} + +/// Update the FPS display in the footer +fn update_fps_display(fps: f32) { + let _ = (|| -> Result<(), JsValue> { + let fps_element = window() + .and_then(|w| w.document()) + .and_then(|d| d.get_element_by_id("ratzilla-fps")); + + if let Some(element) = fps_element { + element.set_text_content(Some(&format!("{:.1}", fps))); + } + + Ok(()) + })(); +} diff --git a/examples/bundle-wry/src/lib.rs b/examples/bundle-wry/src/lib.rs new file mode 100644 index 0000000..9c0e0e3 --- /dev/null +++ b/examples/bundle-wry/src/lib.rs @@ -0,0 +1,6 @@ +pub mod app; +pub mod backend; +pub mod effects; +pub mod fps; +pub mod ui; +pub mod utils; diff --git a/examples/bundle-wry/src/main.rs b/examples/bundle-wry/src/main.rs new file mode 100644 index 0000000..9fa7a9c --- /dev/null +++ b/examples/bundle-wry/src/main.rs @@ -0,0 +1,91 @@ +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +//! # [Ratatui] Ratzilla Bundle Wry Example +//! + +#[cfg(feature = "bundle-wry")] +mod wry_app; + +#[cfg(any( + feature = "bundle-wry", + all(not(feature = "bundle-wry"), target_arch = "wasm32") +))] +use std::error::Error; + +#[cfg(all(not(feature = "bundle-wry"), target_arch = "wasm32"))] +use std::{cell::RefCell, rc::Rc}; + +#[cfg(all(not(feature = "bundle-wry"), target_arch = "wasm32"))] +use bundle_wry::app::App; +#[cfg(all(not(feature = "bundle-wry"), target_arch = "wasm32"))] +use bundle_wry::backend::{BackendType, MultiBackendBuilder}; +#[cfg(all(not(feature = "bundle-wry"), target_arch = "wasm32"))] +use ratzilla::backend::webgl2::WebGl2BackendOptions; +#[cfg(all(not(feature = "bundle-wry"), target_arch = "wasm32"))] +use ratzilla::event::KeyCode; +#[cfg(all(not(feature = "bundle-wry"), target_arch = "wasm32"))] +use ratzilla::WebRenderer; + +#[cfg(all(not(feature = "bundle-wry"), target_arch = "wasm32"))] +fn main() -> Result<(), Box> { + browser_main() +} + +#[cfg(all(not(feature = "bundle-wry"), target_arch = "wasm32"))] +fn browser_main() -> Result<(), Box> { + let app_state = Rc::new(RefCell::new(App::new("Demo", true))); + + let webgl2_options = WebGl2BackendOptions::new() + .measure_performance(true) + .enable_console_debug_api() + .enable_mouse_selection() + .disable_auto_css_resize(); // canvas size managed by css in index.html + + let mut terminal = MultiBackendBuilder::with_fallback(BackendType::WebGl2) + .webgl2_options(webgl2_options) + .build_terminal()?; + + terminal.on_key_event({ + let app_state_cloned = app_state.clone(); + move |event| { + let mut app_state = app_state_cloned.borrow_mut(); + match event.code { + KeyCode::Right => { + app_state.on_right(); + } + KeyCode::Left => { + app_state.on_left(); + } + KeyCode::Up => { + app_state.on_up(); + } + KeyCode::Down => { + app_state.on_down(); + } + KeyCode::Char(c) => app_state.on_key(c), + _ => {} + } + } + })?; + + terminal.draw_web(move |f| { + let mut app_state = app_state.borrow_mut(); + let elapsed = app_state.on_tick(); + bundle_wry::ui::draw(elapsed, f, &mut app_state); + }); + + Ok(()) +} + +#[cfg(feature = "bundle-wry")] +fn main() -> Result<(), Box> { + wry_app::run() +} + +#[cfg(all(not(feature = "bundle-wry"), not(target_arch = "wasm32")))] +fn main() { + println!( + "bundle-wry's browser entrypoint is for wasm32.\n\ + Run `trunk serve` or `trunk build` for the Ratzilla web app." + ); +} diff --git a/examples/bundle-wry/src/ui.rs b/examples/bundle-wry/src/ui.rs new file mode 100644 index 0000000..d32595b --- /dev/null +++ b/examples/bundle-wry/src/ui.rs @@ -0,0 +1,434 @@ +use ratzilla::ratatui::{ + layout::{Constraint, Layout, Rect}, + style::{Color, Modifier, Style}, + symbols, + text::{self, Span}, + widgets::{ + canvas::{self, Canvas, Circle, Map, MapResolution, Rectangle}, + Axis, BarChart, Block, Cell, Chart, Dataset, Gauge, LineGauge, List, ListItem, Paragraph, + Row, Sparkline, Table, Tabs, Wrap, + }, + Frame, +}; +use tachyonfx::Duration; +// use tui_big_text::{BigText, PixelSize}; + +use crate::app::App; + +pub fn draw(elapsed: Duration, frame: &mut Frame, app: &mut App) { + let chunks = Layout::vertical([Constraint::Length(3), Constraint::Min(0)]).split(frame.area()); + let tabs = app + .tabs + .titles + .iter() + .map(|t| text::Line::from(Span::styled(*t, Style::default().fg(Color::LightGreen)))) + .collect::() + .block(Block::bordered().title(app.title)) + .highlight_style(Style::default().fg(Color::LightYellow)) + .select(app.tabs.index); + frame.render_widget(tabs, chunks[0]); + match app.tabs.index { + 0 => draw_first_tab(frame, app, chunks[1]), + 1 => draw_about_tab(frame, chunks[1]), + 2 => draw_second_tab(frame, app, chunks[1]), + 3 => draw_third_tab(frame, app, chunks[1]), + _ => {} + }; + // let big_text = BigText::builder() + // .pixel_size(PixelSize::Quadrant) + // .lines(vec!["RATZILLA".white().into()]) + // .build(); + // frame.render_widget( + // big_text, + // frame.area().inner(Margin { + // horizontal: frame.area().width / 2 - 15, + // vertical: 0, + // }), + // ); + let area = frame.area(); + app.effects + .process_effects(elapsed, frame.buffer_mut(), area); +} + +fn draw_first_tab(frame: &mut Frame, app: &mut App, area: Rect) { + let chunks = Layout::vertical([ + Constraint::Length(9), + Constraint::Min(8), + Constraint::Length(7), + ]) + .split(area); + draw_gauges(frame, app, chunks[0]); + draw_charts(frame, app, chunks[1]); + draw_text(frame, chunks[2]); +} + +fn draw_about_tab(frame: &mut Frame, area: Rect) { + let block = Block::bordered().title("About"); + let text = vec![ + text::Line::from("bundle-wry is a Ratzilla demo app with a tabbed menu bar."), + text::Line::from(""), + text::Line::from( + "Use the left and right arrow keys to move between Home, About, Status, and Theme.", + ), + text::Line::from(""), + text::Line::from( + "This view is where the app description, links, and quick start notes can live.", + ), + ]; + let paragraph = Paragraph::new(text).block(block).wrap(Wrap { trim: true }); + frame.render_widget(paragraph, area); +} + +fn draw_gauges(frame: &mut Frame, app: &mut App, area: Rect) { + let chunks = Layout::vertical([ + Constraint::Length(2), + Constraint::Length(3), + Constraint::Length(2), + ]) + .margin(1) + .split(area); + let block = Block::bordered().title("Graphs"); + frame.render_widget(block, area); + + let label = format!("{:.2}%", app.progress * 100.0); + let gauge = Gauge::default() + .block(Block::new().title("Gauge:")) + .gauge_style( + Style::default() + .fg(Color::LightMagenta) + .bg(Color::Black) + .add_modifier(Modifier::ITALIC | Modifier::BOLD), + ) + .use_unicode(app.enhanced_graphics) + .label(label) + .ratio(app.progress); + frame.render_widget(gauge, chunks[0]); + + let sparkline = Sparkline::default() + .block(Block::new().title("Sparkline:")) + .style(Style::default().fg(Color::LightGreen)) + .data(&app.sparkline.points) + .bar_set(if app.enhanced_graphics { + symbols::bar::NINE_LEVELS + } else { + symbols::bar::THREE_LEVELS + }); + frame.render_widget(sparkline, chunks[1]); + + let line_gauge = LineGauge::default() + .block(Block::new().title("LineGauge:")) + .filled_style(Style::default().fg(Color::LightMagenta)) + .filled_symbol(if app.enhanced_graphics { + symbols::line::THICK.horizontal + } else { + symbols::line::NORMAL.horizontal + }) + .ratio(app.progress); + frame.render_widget(line_gauge, chunks[2]); +} + +#[allow(clippy::too_many_lines)] +fn draw_charts(frame: &mut Frame, app: &mut App, area: Rect) { + let constraints = if app.show_chart { + vec![Constraint::Percentage(50), Constraint::Percentage(50)] + } else { + vec![Constraint::Percentage(100)] + }; + let chunks = Layout::horizontal(constraints).split(area); + { + let chunks = Layout::vertical([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(chunks[0]); + { + let chunks = + Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(chunks[0]); + + // Draw tasks + let tasks: Vec = app + .tasks + .items + .iter() + .map(|i| ListItem::new(vec![text::Line::from(Span::raw(*i))])) + .collect(); + let tasks = List::new(tasks) + .block(Block::bordered().title("List")) + .highlight_style(Style::default().add_modifier(Modifier::BOLD)) + .highlight_symbol("> "); + frame.render_stateful_widget(tasks, chunks[0], &mut app.tasks.state); + + // Draw logs + let info_style = Style::default().fg(Color::Green); + let warning_style = Style::default().fg(Color::LightYellow); + let error_style = Style::default().fg(Color::LightMagenta); + let critical_style = Style::default().fg(Color::LightRed); + let logs: Vec = app + .logs + .items + .iter() + .map(|&(evt, level)| { + let s = match level { + "ERROR" => error_style, + "CRITICAL" => critical_style, + "WARNING" => warning_style, + _ => info_style, + }; + let content = vec![text::Line::from(vec![ + Span::styled(format!("{level:<9}"), s), + Span::raw(evt), + ])]; + ListItem::new(content) + }) + .collect(); + let logs = List::new(logs).block(Block::bordered().title("List")); + frame.render_stateful_widget(logs, chunks[1], &mut app.logs.state); + } + + let barchart = BarChart::default() + .block(Block::bordered().title("Bar Chart")) + .data(&app.barchart) + .bar_width(3) + .bar_gap(2) + .bar_set(if app.enhanced_graphics { + symbols::bar::NINE_LEVELS + } else { + symbols::bar::THREE_LEVELS + }) + .value_style( + Style::default() + .fg(Color::Black) + .bg(Color::LightGreen) + .add_modifier(Modifier::ITALIC), + ) + .label_style(Style::default().fg(Color::Yellow)) + .bar_style(Style::default().fg(Color::LightGreen)); + frame.render_widget(barchart, chunks[1]); + } + if app.show_chart { + let x_labels = vec![ + Span::styled( + format!("{}", app.signals.window[0]), + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(format!( + "{}", + (app.signals.window[0] + app.signals.window[1]) / 2.0 + )), + Span::styled( + format!("{}", app.signals.window[1]), + Style::default().add_modifier(Modifier::BOLD), + ), + ]; + let datasets = vec![ + Dataset::default() + .name("data2") + .marker(symbols::Marker::Dot) + .style(Style::default().fg(Color::White)) + .data(&app.signals.sin1.points), + Dataset::default() + .name("data3") + .marker(if app.enhanced_graphics { + symbols::Marker::Braille + } else { + symbols::Marker::Dot + }) + .style(Style::default().fg(Color::LightCyan)) + .data(&app.signals.sin2.points), + ]; + let chart = Chart::new(datasets) + .block( + Block::bordered().title(Span::styled( + "Chart", + Style::default() + .fg(Color::LightCyan) + .add_modifier(Modifier::BOLD), + )), + ) + .x_axis( + Axis::default() + .title("X Axis") + .style(Style::default().fg(Color::Gray)) + .bounds(app.signals.window) + .labels(x_labels), + ) + .y_axis( + Axis::default() + .title("Y Axis") + .style(Style::default().fg(Color::Gray)) + .bounds([-20.0, 20.0]) + .labels([ + Span::styled("-20", Style::default().add_modifier(Modifier::BOLD)), + Span::raw("0"), + Span::styled("20", Style::default().add_modifier(Modifier::BOLD)), + ]), + ); + frame.render_widget(chart, chunks[1]); + } +} + +fn draw_text(frame: &mut Frame, area: Rect) { + let text = vec![ + text::Line::from("This is a paragraph with several lines. You can change style your text the way you want"), + text::Line::from(""), + text::Line::from(vec![ + Span::from("For example: "), + Span::styled("under", Style::default().fg(Color::LightRed)), + Span::raw(" "), + Span::styled("the", Style::default().fg(Color::LightGreen)), + Span::raw(" "), + Span::styled("rainbow", Style::default().fg(Color::LightCyan)), + Span::raw("."), + ]), + text::Line::from(vec![ + Span::raw("Oh and if you didn't "), + Span::styled("notice", Style::default().add_modifier(Modifier::ITALIC)), + Span::raw(" you can "), + Span::styled("automatically", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(" "), + Span::styled("wrap", Style::default().add_modifier(Modifier::REVERSED)), + Span::raw(" your "), + Span::styled("text", Style::default().add_modifier(Modifier::UNDERLINED)), + Span::raw(".") + ]), + text::Line::from( + "One more thing is that it should display unicode characters: 10€" + ), + ]; + let block = Block::bordered().title(Span::styled( + "Footer", + Style::default() + .fg(Color::LightMagenta) + .add_modifier(Modifier::BOLD), + )); + let paragraph = Paragraph::new(text).block(block).wrap(Wrap { trim: true }); + frame.render_widget(paragraph, area); +} + +fn draw_second_tab(frame: &mut Frame, app: &mut App, area: Rect) { + let chunks = + Layout::horizontal([Constraint::Percentage(30), Constraint::Percentage(70)]).split(area); + let up_style = Style::default().fg(Color::LightGreen); + let failure_style = Style::default() + .fg(Color::Red) + .add_modifier(Modifier::RAPID_BLINK | Modifier::CROSSED_OUT); + let rows = app.servers.iter().map(|s| { + let style = if s.status == "Up" { + up_style + } else { + failure_style + }; + Row::new(vec![s.name, s.location, s.status]).style(style) + }); + let table = Table::new( + rows, + [ + Constraint::Length(15), + Constraint::Length(15), + Constraint::Length(10), + ], + ) + .header( + Row::new(vec!["Server", "Location", "Status"]) + .style(Style::default().fg(Color::Yellow)) + .bottom_margin(1), + ) + .block(Block::bordered().title("Servers")); + frame.render_widget(table, chunks[0]); + + let map = Canvas::default() + .block(Block::bordered().title("World")) + .paint(|ctx| { + ctx.draw(&Map { + color: Color::White, + resolution: MapResolution::High, + }); + ctx.layer(); + ctx.draw(&Rectangle { + x: 0.0, + y: 30.0, + width: 10.0, + height: 10.0, + color: Color::Yellow, + }); + ctx.draw(&Circle { + x: app.servers[2].coords.1, + y: app.servers[2].coords.0, + radius: 10.0, + color: Color::LightGreen, + }); + for (i, s1) in app.servers.iter().enumerate() { + for s2 in &app.servers[i + 1..] { + ctx.draw(&canvas::Line { + x1: s1.coords.1, + y1: s1.coords.0, + y2: s2.coords.0, + x2: s2.coords.1, + color: Color::Yellow, + }); + } + } + for server in &app.servers { + let color = if server.status == "Up" { + Color::LightGreen + } else { + Color::Red + }; + ctx.print( + server.coords.1, + server.coords.0, + Span::styled("X", Style::default().fg(color)), + ); + } + }) + .marker(if app.enhanced_graphics { + symbols::Marker::Braille + } else { + symbols::Marker::Dot + }) + .x_bounds([-180.0, 180.0]) + .y_bounds([-90.0, 90.0]); + frame.render_widget(map, chunks[1]); +} + +fn draw_third_tab(frame: &mut Frame, _app: &mut App, area: Rect) { + let chunks = Layout::horizontal([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)]).split(area); + let colors = [ + Color::Reset, + Color::Black, + Color::Red, + Color::Green, + Color::Yellow, + Color::Blue, + Color::LightMagenta, + Color::Cyan, + Color::Gray, + Color::DarkGray, + Color::LightRed, + Color::LightGreen, + Color::LightYellow, + Color::LightBlue, + Color::LightMagenta, + Color::LightCyan, + Color::White, + ]; + let items: Vec = colors + .iter() + .map(|c| { + let cells = vec![ + Cell::from(Span::raw(format!("{c:?}: "))), + Cell::from(Span::styled("Foreground", Style::default().fg(*c))), + Cell::from(Span::styled("Background", Style::default().bg(*c))), + ]; + Row::new(cells) + }) + .collect(); + let table = Table::new( + items, + [ + Constraint::Ratio(1, 3), + Constraint::Ratio(1, 3), + Constraint::Ratio(1, 3), + ], + ) + .block(Block::bordered().title("Colors")); + frame.render_widget(table, chunks[0]); +} diff --git a/examples/bundle-wry/src/utils.rs b/examples/bundle-wry/src/utils.rs new file mode 100644 index 0000000..970c9e4 --- /dev/null +++ b/examples/bundle-wry/src/utils.rs @@ -0,0 +1,72 @@ +use crate::backend::BackendType; +use ratzilla::web_sys; +use wasm_bindgen::JsValue; + +/// Inject HTML footer with backend switching links +pub(crate) fn inject_backend_footer(current_backend: BackendType) -> Result<(), JsValue> { + let window = web_sys::window().ok_or("No window")?; + let document = window.document().ok_or("No document")?; + + // Remove existing footer if present + if let Some(existing) = document.get_element_by_id("ratzilla-backend-footer") { + existing.remove(); + } + + // Create footer element + let footer = document.create_element("div")?; + footer.set_id("ratzilla-backend-footer"); + + // Set footer styles + footer.set_attribute( + "style", + "position: fixed; bottom: 0; left: 0; right: 0; \ + background: rgba(0,0,0,0.8); color: white; \ + padding: 8px 16px; font-family: monospace; font-size: 12px; \ + display: flex; justify-content: center; gap: 16px; \ + border-top: 1px solid #333; z-index: 1000;", + )?; + + // Get current URL without backend param - use relative URL to avoid protocol issues + let location = window.location(); + let base_url = location.pathname().unwrap_or_default(); + + let backends = [BackendType::Dom, BackendType::Canvas, BackendType::WebGl2]; + let mut links = Vec::new(); + + for backend in backends { + let is_current = backend == current_backend; + let style = if is_current { + "color: #4ade80; font-weight: bold; text-decoration: none;" + } else { + "color: #94a3b8; text-decoration: none; cursor: pointer;" + }; + + let link = if is_current { + format!("● {backend}", style,) + } else { + format!( + "{backend}", + base_url, + backend.as_str(), + style, + ) + }; + + links.push(link); + } + + let footer_html = format!( + "Backend: {} | \ + FPS: \ + --", + links.join(" | ") + ); + + footer.set_inner_html(&footer_html); + + // Append to body + let body = document.body().ok_or("No body")?; + body.append_child(&footer)?; + + Ok(()) +} diff --git a/examples/bundle-wry/src/wry_app.rs b/examples/bundle-wry/src/wry_app.rs new file mode 100644 index 0000000..67b0ead --- /dev/null +++ b/examples/bundle-wry/src/wry_app.rs @@ -0,0 +1,349 @@ +use std::{ + error::Error, + fs, io, + io::{Read, Write}, + net::{SocketAddr, TcpStream}, + path::{Path, PathBuf}, + process::{Child, Command, Stdio}, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Mutex, + }, + thread::{self, JoinHandle}, + time::Duration, +}; + +use muda::{Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu}; +#[cfg(any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "openbsd", + target_os = "netbsd" +))] +use tao::platform::unix::WindowExtUnix; +#[cfg(target_os = "windows")] +use tao::platform::windows::{EventLoopBuilderExtWindows, WindowExtWindows}; +use tao::{ + event::{Event, WindowEvent}, + event_loop::{ControlFlow, EventLoopBuilder}, + window::{Window, WindowBuilder}, +}; +use wry::WebViewBuilder; +#[cfg(any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "openbsd", + target_os = "netbsd" +))] +use wry::WebViewBuilderExtUnix; + +const HOST: &str = "127.0.0.1"; +const PORT: u16 = 8080; + +enum UserEvent { + MenuEvent(MenuEvent), +} + +pub fn run() -> Result<(), Box> { + let app_source = app_source()?; + + let mut event_loop_builder = EventLoopBuilder::::with_user_event(); + + let event_loop = event_loop_builder.build(); + let proxy = event_loop.create_proxy(); + MenuEvent::set_event_handler(Some(move |event| { + let _ = proxy.send_event(UserEvent::MenuEvent(event)); + })); + + let menu_bar = Menu::new(); + let app_menu = Submenu::new("bundle-wry", true); + let about_item = MenuItem::new("About bundle-wry", true, None); + let quit_item = MenuItem::new("Quit", true, None); + app_menu + .append_items(&[&about_item, &PredefinedMenuItem::separator(), &quit_item]) + .unwrap(); + menu_bar.append(&app_menu).unwrap(); + + let window = WindowBuilder::new() + .with_title("bundlle-wry") + .build(&event_loop)?; + + #[cfg(target_os = "windows")] + unsafe { + menu_bar.init_for_hwnd(window.hwnd() as _).unwrap(); + } + #[cfg(any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "openbsd", + target_os = "netbsd" + ))] + { + menu_bar + .init_for_gtk_window(window.gtk_window(), window.default_vbox()) + .unwrap(); + } + #[cfg(target_os = "macos")] + { + menu_bar.init_for_nsapp(); + } + + let _webview = create_webview(&window)? + .with_url(&app_source.url) + .build(&window)?; + + event_loop.run(move |event, _, control_flow| { + *control_flow = ControlFlow::Wait; + + match event { + Event::WindowEvent { + event: WindowEvent::CloseRequested, + .. + } => stop_server(&app_source.server, control_flow), + Event::UserEvent(UserEvent::MenuEvent(event)) => { + if event.id() == about_item.id() { + println!("bundle-wry: native About menu selected"); + } else if event.id() == quit_item.id() { + stop_server(&app_source.server, control_flow); + } + } + _ => {} + } + }); +} + +fn stop_server(server: &Arc>>, control_flow: &mut ControlFlow) { + if let Some(handle) = server.lock().ok().and_then(|mut slot| slot.take()) { + match handle { + ServerHandle::Trunk(mut child) => { + let _ = child.kill(); + let _ = child.wait(); + } + ServerHandle::Bundled(server) => { + server.stop(); + } + } + } + *control_flow = ControlFlow::Exit; +} + +fn spawn_trunk_serve() -> Result { + Command::new("trunk") + .args([ + "serve", + "--address", + HOST, + "--port", + "8080", + "--no-autoreload", + "true", + "--open", + "false", + ]) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .spawn() +} + +fn wait_for_server() -> Result<(), io::Error> { + let addr: SocketAddr = format!("{HOST}:{PORT}").parse().map_err(|err| { + io::Error::new( + io::ErrorKind::InvalidInput, + format!("invalid address: {err}"), + ) + })?; + + for _ in 0..120 { + if TcpStream::connect_timeout(&addr, Duration::from_millis(250)).is_ok() { + return Ok(()); + } + thread::sleep(Duration::from_millis(500)); + } + + Err(io::Error::new( + io::ErrorKind::TimedOut, + "trunk serve did not start on 127.0.0.1:8080", + )) +} + +enum ServerHandle { + Trunk(Child), + Bundled(BundledServer), +} + +struct BundledServer { + shutdown: Arc, + join: Mutex>>, +} + +impl BundledServer { + fn stop(self) { + self.shutdown.store(true, Ordering::SeqCst); + if let Some(join) = self.join.lock().ok().and_then(|mut slot| slot.take()) { + let _ = join.join(); + } + } +} + +struct AppSource { + url: String, + server: Arc>>, +} + +fn app_source() -> Result> { + if let Some(index) = bundled_index_path()? { + let server = spawn_bundled_server(index.parent().unwrap().to_path_buf())?; + return Ok(AppSource { + url: format!("http://{HOST}:{PORT}"), + server: Arc::new(Mutex::new(Some(ServerHandle::Bundled(server)))), + }); + } + + let server = Arc::new(Mutex::new(Some(ServerHandle::Trunk(spawn_trunk_serve()?)))); + wait_for_server()?; + + Ok(AppSource { + url: format!("http://{HOST}:{PORT}"), + server, + }) +} + +fn bundled_index_path() -> Result, io::Error> { + let exe = std::env::current_exe()?; + let Some(contents_dir) = exe.parent().and_then(|p| p.parent()) else { + return Ok(None); + }; + let index = contents_dir + .join("Resources") + .join("dist") + .join("index.html"); + Ok(index.exists().then_some(index)) +} + +fn spawn_bundled_server(dist_root: PathBuf) -> Result { + let listener = std::net::TcpListener::bind((HOST, PORT))?; + listener.set_nonblocking(true)?; + + let shutdown = Arc::new(AtomicBool::new(false)); + let shutdown_flag = Arc::clone(&shutdown); + let join = thread::spawn(move || { + while !shutdown_flag.load(Ordering::SeqCst) { + match listener.accept() { + Ok((stream, _)) => { + let _ = handle_http_request(stream, &dist_root); + } + Err(err) if err.kind() == io::ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(20)); + } + Err(_) => break, + } + } + }); + + Ok(BundledServer { + shutdown, + join: Mutex::new(Some(join)), + }) +} + +fn handle_http_request( + mut stream: std::net::TcpStream, + dist_root: &PathBuf, +) -> Result<(), io::Error> { + stream.set_nonblocking(false)?; + let mut buffer = [0_u8; 4096]; + let bytes_read = stream.read(&mut buffer)?; + let request = String::from_utf8_lossy(&buffer[..bytes_read]); + let path = request + .lines() + .next() + .and_then(|line| line.split_whitespace().nth(1)) + .unwrap_or("/"); + + let file_path = match path.split('?').next().unwrap_or("/") { + "/" => dist_root.join("index.html"), + other => safe_dist_path(dist_root, other), + }; + + if file_path.is_file() { + let body = fs::read(&file_path)?; + let content_type = content_type(&file_path); + write_response(&mut stream, "200 OK", content_type, &body)?; + } else { + write_response( + &mut stream, + "404 Not Found", + "text/plain; charset=utf-8", + b"Not Found", + )?; + } + + Ok(()) +} + +fn safe_dist_path(dist_root: &PathBuf, request_path: &str) -> PathBuf { + let mut path = PathBuf::new(); + for component in Path::new(request_path).components() { + use std::path::Component; + match component { + Component::Normal(part) => path.push(part), + Component::CurDir | Component::RootDir => {} + _ => return dist_root.join("__invalid__"), + } + } + dist_root.join(path) +} + +fn content_type(path: &PathBuf) -> &'static str { + match path.extension().and_then(|ext| ext.to_str()).unwrap_or("") { + "html" => "text/html; charset=utf-8", + "js" => "text/javascript; charset=utf-8", + "css" => "text/css; charset=utf-8", + "wasm" => "application/wasm", + "json" => "application/json; charset=utf-8", + "svg" => "image/svg+xml", + "png" => "image/png", + "ico" => "image/x-icon", + _ => "application/octet-stream", + } +} + +fn write_response( + stream: &mut std::net::TcpStream, + status: &str, + content_type: &str, + body: &[u8], +) -> Result<(), io::Error> { + write!( + stream, + "HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + )?; + stream.write_all(body)?; + Ok(()) +} + +#[cfg(any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "openbsd", + target_os = "netbsd" +))] +fn create_webview(_window: &Window) -> Result, wry::Error> { + Ok(WebViewBuilder::new_gtk(window.default_vbox().unwrap())) +} + +#[cfg(not(any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "openbsd", + target_os = "netbsd" +)))] +fn create_webview(_window: &Window) -> Result, wry::Error> { + Ok(WebViewBuilder::new()) +}