From f7d1b82139b9d5481fff62fe2c5fd493aa3adf2d Mon Sep 17 00:00:00 2001 From: marshiyar <57055687+marshiyar@users.noreply.github.com> Date: Fri, 30 Oct 2026 19:51:00 -0700 Subject: [PATCH] Web: the browser tab title follows the open document --- apps/effectcraft-web/src/app.rs | 16 +++++++++++++++- crates/ui-egui/src/app.rs | 7 +++++++ crates/ui-egui/tests/it/main.rs | 1 + crates/ui-egui/tests/it/ui_window_title.rs | 21 +++++++++++++++++++++ 4 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 crates/ui-egui/tests/it/ui_window_title.rs diff --git a/apps/effectcraft-web/src/app.rs b/apps/effectcraft-web/src/app.rs index 737f59f6a..d7e6e783d 100644 --- a/apps/effectcraft-web/src/app.rs +++ b/apps/effectcraft-web/src/app.rs @@ -90,6 +90,8 @@ pub struct WebApp { /// Started on the demo by the config: the snapshot stays off, so the demo never replaces the /// last visit's project, until the user opens a project from a file or edits the demo. pub demo_start: bool, + /// The last title written to `document.title`. + pub document_title: String, } impl eframe::App for WebApp { @@ -119,6 +121,11 @@ impl eframe::App for WebApp { f(&mut self.app); } self.app.tick(ctx, frame); + // eframe on the web ignores `ViewportCommand::Title`: the tab title follows the open document here. + if self.app.window_title() != self.document_title { + self.document_title = self.app.window_title().to_owned(); + set_document_title(&self.document_title); + } api::poll_replies(); // Render Queue outputs written this frame (here or by a worker) → downloads. files::flush_downloads(); @@ -146,6 +153,13 @@ impl eframe::App for WebApp { } } +/// The browser tab's title. +fn set_document_title(title: &str) { + if let Some(doc) = web_sys::window().and_then(|w| w.document()) { + doc.set_title(title); + } +} + fn query() -> Option { web_sys::window().and_then(|w| w.location().search().ok()).and_then(|s| web_sys::UrlSearchParams::new_with_str(&s).ok()) } @@ -305,7 +319,7 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> { app = app.with_control(rx); let backend = cc.wgpu_render_state.as_ref().map(|rs| format!("{:?}", rs.adapter.get_info().backend)); api::set_info("backend", json!(backend)); - Ok(Box::new(WebApp { app, persist: keep_session, demo_start: !empty && !keep_session })) + Ok(Box::new(WebApp { app, persist: keep_session, demo_start: !empty && !keep_session, document_title: String::new() })) }), ) .await?; diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 79c077b2a..a7f43a704 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -20,6 +20,13 @@ pub use gpu::{GPU_PREPARING, GpuBuild}; pub(crate) use screenshot::{MAX_PENDING_SHOTS, SCREENSHOT_SETTLE_PASSES, SCREENSHOT_SETTLE_S, SCREENSHOT_TIMEOUT_S}; impl EffectcraftApp { + /// The window title as of the last tick (`EffectCraft - `, ` *` while modified). The + /// app sends it as `ViewportCommand::Title`, which eframe on the web ignores: that host writes + /// it to `document.title`. + pub fn window_title(&self) -> &str { + &self.window_title + } + /// Build the app around `session`. /// Loads and applies the workspaces saved in the session's config directory (a failed load is reported in /// `ui.status`), initializes the default UI, playback and render state, and registers the frontend's commands as diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 36a5aa485..b09146812 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -106,6 +106,7 @@ mod ui_visual_review; mod ui_web_gpu_storage; mod ui_web_job_gpu; mod ui_web_offload; +mod ui_window_title; mod ui_workflow_selection; mod ui_workspace_focus; mod viewer_geometry; diff --git a/crates/ui-egui/tests/it/ui_window_title.rs b/crates/ui-egui/tests/it/ui_window_title.rs new file mode 100644 index 000000000..66ef62235 --- /dev/null +++ b/crates/ui-egui/tests/it/ui_window_title.rs @@ -0,0 +1,21 @@ +//! The window title the shared app reports (project name, ` *` while modified), which hosts that +//! cannot use `ViewportCommand::Title` (the browser tab) read through `window_title`. + +use effectcraft_engine::Session; +use effectcraft_ui_egui::EffectcraftApp; +use egui::vec2; +use egui_kittest::Harness; +use serde_json::json; + +#[test] +fn the_reported_title_follows_the_open_document() { + let mut h = Harness::builder().with_size(vec2(800.0, 600.0)).build_eframe(|_| EffectcraftApp::new(Session::default())); + h.run_steps(3); + let clean = h.state().window_title().to_owned(); + assert!(clean.starts_with("EffectCraft - ") && !clean.ends_with(" *"), "{clean}"); + + h.state_mut().session.execute("comp.new", json!({"name": "Edited", "width": 64, "height": 64, "duration": 1})).unwrap(); + h.run_steps(3); + let dirty = h.state().window_title().to_owned(); + assert!(dirty.starts_with("EffectCraft - ") && dirty.ends_with(" *"), "{dirty}"); +}