From 00af05d8fc92b78a8061b27a6b0f38b69aeff01a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Thu, 15 Oct 2026 23:14:09 -0300 Subject: [PATCH 1/3] Home visual: draw the user's own Luau visual below the new-thread composer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings → Appearance → Home visual takes a Luau script in the format agents use with show_visual and draws it in the room between the blank new-thread composer and the window bottom. - The script is checked with zeron_visual::check off the main thread before it is saved, so a broken script never reaches the home view; its error comes back with the line. Clear removes it. - The setting (`new_thread_visual`) is device-local and omitted from the file when unset. - VisualFrame::from_script runs a script already in hand, with the same runtime as agent visuals (canvases, scenes, shaders). - The shell places it from last frame's composer and column bounds, fades it with the Home selectors, leaves room for the bottom notices, clips it at the terminal, and fades a visual taller than the room out at its bottom instead of cutting it off. - Code fields: a CodeInput key context where Enter breaks the line, and a monospace ComposerInput option. - new-project-fixture takes ZERON_FIXTURE_HOME_VISUAL=. --- crates/ui/examples/new-project-fixture.rs | 5 +- crates/ui/src/composer.rs | 20 ++- crates/ui/src/settings.rs | 32 ++++ crates/ui/src/settings/appearance.rs | 7 + crates/ui/src/settings/home_visual.rs | 170 ++++++++++++++++++++++ crates/ui/src/shell.rs | 149 +++++++++++++++++++ crates/ui/src/visuals.rs | 37 ++++- 7 files changed, 410 insertions(+), 10 deletions(-) create mode 100644 crates/ui/src/settings/home_visual.rs diff --git a/crates/ui/examples/new-project-fixture.rs b/crates/ui/examples/new-project-fixture.rs index a41f523027..ccb60ea11b 100644 --- a/crates/ui/examples/new-project-fixture.rs +++ b/crates/ui/examples/new-project-fixture.rs @@ -19,6 +19,9 @@ fn main() -> anyhow::Result<()> { adjustment: settings::NewThreadBackgroundAdjustment::default(), }); } + if let Ok(path) = std::env::var("ZERON_FIXTURE_HOME_VISUAL") { + settings.new_thread_visual = Some(std::fs::read_to_string(path).unwrap()); + } if let Some(folder) = std::env::var_os("ZERON_FIXTURE_WALLPAPER_FOLDER") { settings.wallpaper_folder = Some(folder.into()); } @@ -47,7 +50,7 @@ fn main() -> anyhow::Result<()> { {"id":"remote","name":"Build server","platform":"linux","lastSeenAt":null}, {"id":"laptop","name":"Travel laptop","platform":"macos","lastSeenAt":null} ])).unwrap(); - s.selected_chat = if std::env::var_os("ZERON_FIXTURE_BACKGROUND").is_some() { None } else { Some("browser-fixture".into()) }; s.selected_space = Some("project".into()); + s.selected_chat = if std::env::var_os("ZERON_FIXTURE_BACKGROUND").is_some() || std::env::var_os("ZERON_FIXTURE_HOME_VISUAL").is_some() { None } else { Some("browser-fixture".into()) }; s.selected_space = Some("project".into()); s.auto_selected = true; s.chats_synced = true; s.spaces_synced = true; s.spaces = vec![serde_json::from_value(serde_json::json!({"id":"project","deviceId":"local","path":"/tmp/fieldnotes","createdAt":"2026-09-08T00:00:00Z"})).unwrap()]; s.chats = vec![serde_json::from_value(serde_json::json!({"id":"browser-fixture","deviceId":"local","spaceId":"project","title":"Build the Fieldnotes workspace","archived":false,"createdAt":"2026-09-08T00:00:00Z","config":{"harness":"claude-code","model":"claude-sonnet-4-6","reasoning":null,"sandbox":"workspace-write"}})).unwrap()]; diff --git a/crates/ui/src/composer.rs b/crates/ui/src/composer.rs index 2a1af941f7..56265522cf 100644 --- a/crates/ui/src/composer.rs +++ b/crates/ui/src/composer.rs @@ -1530,6 +1530,8 @@ enum EditKind { const GENERIC_COMPOSER_CONTEXT: &str = "Composer"; pub(crate) const MESSAGE_COMPOSER_CONTEXT: &str = "MessageComposer"; const PALETTE_SEARCH_CONTEXT: &str = "PaletteSearch"; +/// Code fields: Enter breaks the line; nothing submits. +pub(crate) const CODE_INPUT_CONTEXT: &str = "CodeInput"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum MessageEnterBindingAction { @@ -1776,6 +1778,9 @@ pub fn init(cx: &mut App, send_behavior: ComposerSendBehavior) { } cx.bind_keys(palette_bindings); cx.bind_keys(generic_bindings); + let mut code_bindings = input_bindings(CODE_INPUT_CONTEXT); + code_bindings.push(KeyBinding::new("enter", Newline, Some(CODE_INPUT_CONTEXT))); + cx.bind_keys(code_bindings); cx.bind_keys(message_bindings); } @@ -1979,6 +1984,8 @@ pub struct ComposerInput { text_size: f32, configured_line_height: f32, single_line: bool, + /// Set in the monospace face (code fields). + monospace: bool, scroll_left: f32, // -- measured state (written during layout/paint) -- last_lines: Vec, @@ -2097,6 +2104,7 @@ impl ComposerInput { text_size: INPUT_TEXT_SIZE, configured_line_height: INPUT_LINE_HEIGHT, single_line: false, + monospace: false, scroll_left: 0.0, last_lines: Vec::new(), line_starts: vec![0], @@ -2161,6 +2169,12 @@ impl ComposerInput { self } + /// Set the text in the monospace face, for code. + pub(crate) fn with_monospace(mut self) -> Self { + self.monospace = true; + self + } + /// Join the window's Tab order, for a field the keyboard must reach. pub(crate) fn with_tab_stop(mut self) -> Self { self.focus_handle = self.focus_handle.tab_stop(true); @@ -5311,7 +5325,11 @@ impl Render for ComposerInput { .text_size(crate::typography::ui_rems(self.text_size)) .line_height(crate::typography::ui_rems(self.configured_line_height)) .text_color(text_color) - .font_family(theme.font_sans.clone()) + .font_family(if self.monospace { + theme.font_mono.clone() + } else { + theme.font_sans.clone() + }) .child({ let input = cx.entity(); let ascent = self.max_ascent; diff --git a/crates/ui/src/settings.rs b/crates/ui/src/settings.rs index efcc724053..1583e985ea 100644 --- a/crates/ui/src/settings.rs +++ b/crates/ui/src/settings.rs @@ -23,6 +23,7 @@ pub mod composer; pub mod devices; pub mod files; pub mod harnesses; +pub mod home_visual; pub mod notifications; pub mod shortcuts; pub mod thread_naming; @@ -542,6 +543,16 @@ pub fn set_new_thread_background_effect(effect: NewThreadBackgroundEffect, cx: & } } +/// Save (or clear, with `None`) the visual below the new-thread composer. +/// Callers check the script first; see `settings::home_visual`. +pub fn set_new_thread_visual(script: Option, cx: &mut App) { + if update(SavePolicy::Immediate, cx, |settings| { + settings.new_thread_visual = script; + }) { + cx.refresh_windows(); + } +} + pub fn set_new_thread_background_adjustment( adjustment: NewThreadBackgroundAdjustment, cx: &mut App, @@ -1002,6 +1013,10 @@ pub struct UiSettings { pub wallpaper_color: Option, /// Non-destructive treatment composited inside the artwork's fade mask. pub new_thread_background_effect: NewThreadBackgroundEffect, + /// The user's own Luau visual (the `show_visual` script format), drawn + /// below the blank new-thread composer. Checked before it is saved. + #[serde(skip_serializing_if = "Option::is_none")] + pub new_thread_visual: Option, /// Snap animations to rest. Defaults to following the OS. pub reduce_motion: crate::motion::ReduceMotion, /// Also snap animations while the main window is not focused. @@ -1105,6 +1120,7 @@ impl Default for UiSettings { wallpaper_theme_colors: false, wallpaper_color: None, new_thread_background_effect: NewThreadBackgroundEffect::None, + new_thread_visual: None, reduce_motion: crate::motion::ReduceMotion::System, pause_animations_in_background: false, codex_voice: None, @@ -1750,6 +1766,7 @@ impl UiSettings { wallpaper_theme_colors, wallpaper_color, new_thread_background_effect, + new_thread_visual, reduce_motion, pause_animations_in_background, codex_voice, @@ -2058,6 +2075,19 @@ mod tests { } } + #[test] + fn new_thread_visual_round_trips_and_is_omitted_when_unset() { + let settings = UiSettings { + new_thread_visual: Some("return function() return ui.text \"Hi\" end".into()), + ..Default::default() + }; + let loaded: UiSettings = + serde_json::from_str(&serde_json::to_string(&settings).unwrap()).unwrap(); + assert_eq!(loaded.new_thread_visual, settings.new_thread_visual); + let unset = serde_json::to_string(&UiSettings::default()).unwrap(); + assert!(!unset.contains("newThreadVisual")); + } + #[test] fn composer_send_behavior_is_opt_in_for_old_and_partial_settings() { let dir = tempfile::tempdir().unwrap(); @@ -2075,6 +2105,7 @@ mod tests { loaded.new_thread_background_effect, NewThreadBackgroundEffect::None ); + assert!(loaded.new_thread_visual.is_none()); assert_eq!(loaded.reduce_motion, crate::motion::ReduceMotion::System); assert!(!loaded.pause_animations_in_background); assert_eq!(loaded.sidebar_width, 300.0); @@ -2840,6 +2871,7 @@ mod tests { wallpaper_theme_colors: false, wallpaper_color: None, new_thread_background_effect: NewThreadBackgroundEffect::Ascii, + new_thread_visual: Some("return function() return ui.text \"Hi\" end".into()), reduce_motion: crate::motion::ReduceMotion::On, pause_animations_in_background: true, legacy_accent_color: None, diff --git a/crates/ui/src/settings/appearance.rs b/crates/ui/src/settings/appearance.rs index 967cb173b2..664f5561cd 100644 --- a/crates/ui/src/settings/appearance.rs +++ b/crates/ui/src/settings/appearance.rs @@ -317,6 +317,7 @@ pub struct AppearancePage { review_entry: Option, library_error: Option, background_error: Option, + home_visual: Entity, } impl AppearancePage { @@ -581,6 +582,7 @@ impl AppearancePage { review_entry: None, library_error: None, background_error: None, + home_visual: cx.new(crate::settings::home_visual::HomeVisualCard::new), } } @@ -4037,6 +4039,11 @@ impl Render for AppearancePage { "Material and background", widgets::section_card(&theme).mt_0().children(settings_rows), )) + .child(widgets::section( + &theme, + "Home visual", + self.home_visual.clone(), + )) .child(widgets::section( &theme, "Motion", diff --git a/crates/ui/src/settings/home_visual.rs b/crates/ui/src/settings/home_visual.rs new file mode 100644 index 0000000000..609a940d16 --- /dev/null +++ b/crates/ui/src/settings/home_visual.rs @@ -0,0 +1,170 @@ +//! Settings → Appearance → Home visual: the user's own Luau visual, drawn +//! below the blank new-thread composer. The script is the format agents +//! show with `show_visual`; it is checked the way the engine checks theirs +//! before it is saved, so a broken script never reaches the home view. + +use gpui::{Context, Entity, SharedString, Subscription, Task, div, prelude::*, px}; + +use crate::composer::{CODE_INPUT_CONTEXT, ComposerInput, ComposerInputEvent}; +use crate::popover; +use crate::settings::widgets; +use crate::theme::Theme; + +/// The visual's id, which also seeds its randomness: the check and the +/// home view see the same random data. +pub const HOME_VISUAL_ID: &str = "home-visual"; + +pub struct HomeVisualCard { + input: Entity, + error: Option, + checking: Option>, + _input_events: Subscription, +} + +impl HomeVisualCard { + pub fn new(cx: &mut Context) -> Self { + let saved = crate::settings::current(cx).new_thread_visual; + let input = cx.new(|cx| { + ComposerInput::with_context("-- Paste a Luau visual script", CODE_INPUT_CONTEXT, cx) + .with_monospace() + .with_text_metrics(12.0, 18.0) + .with_viewport_height(240.0) + .with_tab_stop() + }); + if let Some(saved) = saved { + input.update(cx, |input, cx| input.set_text(saved, cx)); + } + let input_events = cx.subscribe(&input, |card: &mut Self, _, event, cx| { + if matches!(event, ComposerInputEvent::Edited) { + card.error = None; + cx.notify(); + } + }); + Self { + input, + error: None, + checking: None, + _input_events: input_events, + } + } + + fn draft(&self, cx: &gpui::App) -> String { + self.input.read(cx).text().trim().to_owned() + } + + /// Check the draft off the main thread, then save it (an empty draft + /// clears the visual). + fn apply(&mut self, cx: &mut Context) { + let script = self.draft(cx); + if script.is_empty() { + self.clear(cx); + return; + } + let check = cx.background_executor().spawn({ + let script = script.clone(); + async move { + zeron_visual::check(&script, zeron_visual::seed_for(HOME_VISUAL_ID)).map(|_| ()) + } + }); + self.error = None; + self.checking = Some(cx.spawn(async move |this, cx| { + let checked = check.await; + this.update(cx, |card, cx| { + card.checking = None; + match checked { + Ok(()) => crate::settings::set_new_thread_visual(Some(script), cx), + Err(error) => card.error = Some(error.message.into()), + } + cx.notify(); + }) + .ok(); + })); + cx.notify(); + } + + fn clear(&mut self, cx: &mut Context) { + self.checking = None; + self.error = None; + self.input.update(cx, |input, cx| input.set_text("", cx)); + crate::settings::set_new_thread_visual(None, cx); + cx.notify(); + } +} + +impl Render for HomeVisualCard { + fn render(&mut self, _: &mut gpui::Window, cx: &mut Context) -> impl IntoElement { + let theme = Theme::of(cx).for_settings_surface(); + let saved = crate::settings::current(cx).new_thread_visual; + let draft = self.draft(cx); + let checking = self.checking.is_some(); + let applied = saved.as_deref().map(str::trim) == Some(draft.as_str()) + || (saved.is_none() && draft.is_empty()); + let status = if checking { + "Checking the script…" + } else if saved.is_some() && applied { + "Showing below the new-session composer." + } else if saved.is_some() { + "Unapplied changes; the saved visual is still showing." + } else { + "A Luau visual, in the format agents use with show_visual." + }; + let actions = div() + .flex_none() + .flex() + .flex_row() + .items_center() + .gap(px(8.0)) + .when(saved.is_some() || !draft.is_empty(), |row| { + row.child( + widgets::text_action(&theme, widgets::ActionTone::Quiet, "Clear") + .id("home-visual-clear") + .tab_index(0) + .role(gpui::Role::Button) + .aria_label("Remove the home visual") + .focus_visible(|s| s.border_2().border_color(theme.accent)) + .on_click(cx.listener(|card, _, _, cx| card.clear(cx))), + ) + }) + .child( + widgets::text_action( + &theme, + widgets::ActionTone::Solid, + if checking { "Checking…" } else { "Apply" }, + ) + .id("home-visual-apply") + .tab_index(0) + .role(gpui::Role::Button) + .aria_label("Check and apply the home visual") + .focus_visible(|s| s.border_2().border_color(theme.accent)) + .when(applied || checking, |button| button.opacity(0.5)) + .when(!applied && !checking, |button| { + button.on_click(cx.listener(|card, _, _, cx| card.apply(cx))) + }), + ); + widgets::section_card(&theme) + .mt_0() + .child( + widgets::card_row(&theme, true) + .child( + div() + .flex_1() + .min_w(px(160.0)) + .child(widgets::row_title(&theme, "Visual script")) + .child(widgets::meta_line( + &theme, + vec![div().child(status).into_any_element()], + )), + ) + .child(actions), + ) + .child( + div() + .mx(px(16.0)) + .mb(px(16.0)) + .child(popover::dialog_field(self.input.clone().into_any_element())) + .when_some(self.error.clone(), |block, error| { + block.child(widgets::error_strip(&theme, error).mt(px(12.0))) + }), + ) + } +} diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index b40546df36..f93b6fc4b2 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -1373,6 +1373,15 @@ fn bottom_stack_measurement_matches( measured_has_composer == expected_has_composer } +/// Space between the new-thread composer and the home visual, and below the +/// visual when no Home notice sits at the window bottom. +const HOME_VISUAL_GAP: f32 = 24.0; +/// Room left below the home visual for the bottom Home notices chip. +const HOME_VISUAL_NOTICE_CLEARANCE: f32 = 72.0; +/// Less room than this and the home visual stays hidden. +const HOME_VISUAL_MIN_HEIGHT: f32 = 48.0; +const HOME_VISUAL_FADE_BAND: f32 = 48.0; + fn new_thread_background_opacity(is_frost: bool) -> f32 { if is_frost { NEW_THREAD_BACKGROUND_FROSTED_OPACITY @@ -1968,6 +1977,13 @@ pub struct Shell { /// Shared route clock and measured prepaint geometry for the persistent composer. composer_dock: crate::composer_dock::SharedDock, new_thread_artwork_ready: crate::new_thread_background_effects::Readiness, + /// The user's Luau visual below the blank new-thread composer, and the + /// script it runs (rebuilt when Settings saves another). + home_visual: Option<(String, Entity)>, + /// The persistent composer, and the column the home visual is placed + /// in, as last painted (window coordinates). + home_composer_bounds: crate::new_thread_background_mask::SurfaceBounds, + home_visual_origin: crate::new_thread_background_mask::SurfaceBounds, /// Session-transient disclosure state, matching the Archived shelf. pub(super) pinned_open: bool, pub(super) sessions_open: bool, @@ -2525,6 +2541,9 @@ impl Shell { bottom_stack_has_composer: std::rc::Rc::new(std::cell::Cell::new(false)), composer_dock: Default::default(), new_thread_artwork_ready: Default::default(), + home_visual: None, + home_composer_bounds: Default::default(), + home_visual_origin: Default::default(), archived_open: true, pinned_open: true, sessions_open: true, @@ -11212,6 +11231,22 @@ impl Shell { } else { None }; + let home_shown = !has_selection || dock_frame.active; + let home_visual_layer = self.render_home_visual( + ui_settings.new_thread_visual.as_deref(), + if home_shown && (has_spaces || no_project || has_appshots) { + chip_opacity + } else { + 0.0 + }, + if harness_update_card.is_some() { + HOME_VISUAL_NOTICE_CLEARANCE + } else { + HOME_VISUAL_GAP + }, + terminal_geometry.clone(), + cx, + ); let status = div() .relative() .left(px(-session_reserve / 2.0)) @@ -11242,6 +11277,7 @@ impl Shell { // it must paint under the overlaid titlebar instead of becoming // fully transparent across the titlebar's inset band. .children(new_thread_background_layer) + .children(home_visual_layer) .child( // Full-height underlay: the transcript viewport spans the // whole column, scrolling UNDER the titlebar above and the @@ -11368,6 +11404,22 @@ impl Shell { .opacity(composer_opacity) .mx_auto() .child(self.composer.clone()) + .when(home_shown, |composer| { + let measured = self.home_composer_bounds.clone(); + composer.child( + gpui::canvas( + move |bounds, window, _| { + if measured.get() != Some(bounds) { + measured.set(Some(bounds)); + window.request_animation_frame(); + } + }, + |_, _, _, _| {}, + ) + .absolute() + .inset_0(), + ) + }) .children(if has_selection { self.render_jump_to_bottom(cx) } else { @@ -11391,6 +11443,103 @@ impl Shell { .into_any_element() } + /// The user's Luau visual (Settings → Appearance → Home visual) in the + /// room between the blank new-thread composer and the window bottom. + /// It is placed from last frame's composer and column bounds, like the + /// bottom-stack clearance, and fades with the Home selectors. + fn render_home_visual( + &mut self, + script: Option<&str>, + opacity: f32, + bottom_clearance: f32, + terminal_geometry: crate::terminal::dock::SharedGeometry, + cx: &mut Context, + ) -> Option { + let Some(script) = script else { + self.home_visual = None; + return None; + }; + if self + .home_visual + .as_ref() + .is_none_or(|(current, _)| current != script) + { + let frame = cx.new(|cx| { + crate::visuals::VisualFrame::from_script( + settings::home_visual::HOME_VISUAL_ID.into(), + "Home visual".into(), + script, + cx, + ) + }); + self.home_visual = Some((script.to_owned(), frame)); + } + if opacity <= 0.001 { + return None; + } + let frame = self.home_visual.as_ref()?.1.clone(); + let origin = self.home_visual_origin.clone(); + let placement = |column: gpui::Bounds, composer: gpui::Bounds| { + let top = f32::from(composer.bottom() - column.top()) + HOME_VISUAL_GAP; + let height = f32::from(column.size.height) - top - bottom_clearance; + (height >= HOME_VISUAL_MIN_HEIGHT).then(|| { + ( + f32::from(composer.left() - column.left()) + Theme::SPACE_LG, + top, + (f32::from(composer.size.width) - 2.0 * Theme::SPACE_LG).max(0.0), + height, + ) + }) + }; + let placement = origin + .get() + .zip(self.home_composer_bounds.get()) + .and_then(|(column, composer)| placement(column, composer)); + Some( + div() + .absolute() + .inset_0() + .child( + gpui::canvas( + move |bounds, window, _| { + if origin.get() != Some(bounds) { + origin.set(Some(bounds)); + window.request_animation_frame(); + } + }, + |_, _, _, _| {}, + ) + .absolute() + .inset_0(), + ) + .when_some(placement, |layer, (left, top, width, height)| { + // Clipped at the terminal's top edge, like the Home notices. + layer.child(crate::terminal::dock::above_terminal( + div().size_full().overflow_hidden().child( + div() + .absolute() + .left(px(left)) + .top(px(top)) + .w(px(width)) + .h(px(height)) + .overflow_hidden() + .opacity(opacity) + // A visual taller than the room fades out at + // its bottom instead of being cut off. + .child(crate::edge_fade::edge_faded( + HOME_VISUAL_FADE_BAND, + false, + true, + div().size_full().child(frame), + )), + ), + terminal_geometry, + )) + }) + .into_any_element(), + ) + } + /// The "↓ Scroll to bottom" pill (round-9 §3): a LABELED rounded-full /// chip — down-arrow glyph + 13px label on a near-opaque raised surface /// with a hairline — horizontally centered over the transcript column and diff --git a/crates/ui/src/visuals.rs b/crates/ui/src/visuals.rs index 56e5446d3a..1840f89478 100644 --- a/crates/ui/src/visuals.rs +++ b/crates/ui/src/visuals.rs @@ -180,12 +180,7 @@ impl VisualFrame { target_device_id: Option, cx: &mut Context, ) -> Self { - // Its drawings leave every window's sprite atlas with it. - cx.on_release(|this, cx| { - let images = this.local.borrow_mut().prune(None); - free_images(images, cx); - }) - .detach(); + let mut frame = Self::unloaded(id, title, cx); let mut tasks = Vec::new(); let load = match engine { Some(engine) => { @@ -240,10 +235,36 @@ impl VisualFrame { } None => Load::Failed("Visual unavailable".into()), }; + frame.load = load; + frame._tasks = tasks; + frame + } + + /// A visual from a script already in hand rather than an agent's reply + /// (the user's own visual below the new-thread composer). It has no + /// assets; its seed comes from `id`, as [`zeron_visual::seed_for`]. + pub fn from_script( + id: SharedString, + title: String, + script: &str, + cx: &mut Context, + ) -> Self { + let mut frame = Self::unloaded(id, title, cx); + frame.start(script, zeron_visual::Assets::default(), cx); + frame + } + + fn unloaded(id: SharedString, title: String, cx: &mut Context) -> Self { + // Its drawings leave every window's sprite atlas with it. + cx.on_release(|this, cx| { + let images = this.local.borrow_mut().prune(None); + free_images(images, cx); + }) + .detach(); Self { id, title: title.into(), - load, + load: Load::Loading, runtime: None, tree: None, generation: 0, @@ -251,7 +272,7 @@ impl VisualFrame { link_ui: None, local: Rc::default(), started: Instant::now(), - _tasks: tasks, + _tasks: Vec::new(), } } From 1f90d2d661f1a1f7e565228803da24cb1ead9d01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Thu, 15 Oct 2026 23:37:43 -0300 Subject: [PATCH 2/3] Generative background: the user's Luau shader behind the new-thread composer Appearance gains a background source, Image or Generative. Generative shows the user's Luau script, whose ui.shader renders at the background's size and paints through the same composer mask, fades, and crossfade as an image. It moves (time, spin, feedback buffers) but takes no input. Both the image and the script are kept, so switching loses neither. - Applying the script checks that it runs and returns a shader without assets, and samples one frame's color for "Match wallpaper colors". Choosing or shuffling an image switches back to it. - Frames render at half the display's density (at most 1600 px on a side), at most 30 per second, one in flight, and only while the background paints; reduced motion and background pause hold a still. Without a GPU the image shows instead. Effects and framing don't apply. - Readiness crossfades only between sources: a live source's frames replace each other in place, including mid-crossfade. - ShaderPlayer, extracted from VisualFrame, renders one shader's frames for agent visuals and the background alike. - ShaderSpec::fade_edges lets a host that masks the frame itself turn the edge fade off. - The script settings card is shared: ScriptCard with a ScriptSpec for the home visual and for the generative background. - The voice stage shows the same background. - new-project-fixture takes ZERON_FIXTURE_GENERATIVE=. --- crates/mobile/src/visual/mod.rs | 1 + crates/ui/examples/new-project-fixture.rs | 6 +- crates/ui/src/lib.rs | 2 + .../ui/src/new_thread_background_effects.rs | 45 +++ crates/ui/src/new_thread_generative.rs | 311 ++++++++++++++++++ crates/ui/src/settings.rs | 81 +++++ crates/ui/src/settings/appearance.rs | 83 ++++- crates/ui/src/settings/generative_art.rs | 27 ++ crates/ui/src/settings/home_visual.rs | 176 +--------- crates/ui/src/settings/script_card.rs | 189 +++++++++++ crates/ui/src/settings/wallpaper_colors.rs | 18 +- crates/ui/src/shader_player.rs | 147 +++++++++ crates/ui/src/shell.rs | 146 ++++++-- crates/ui/src/shell/voice_stage.rs | 26 +- crates/ui/src/visuals.rs | 141 +++----- crates/visual-gpu/examples/render_shader.rs | 1 + crates/visual-gpu/src/shader.rs | 20 +- 17 files changed, 1107 insertions(+), 313 deletions(-) create mode 100644 crates/ui/src/new_thread_generative.rs create mode 100644 crates/ui/src/settings/generative_art.rs create mode 100644 crates/ui/src/settings/script_card.rs create mode 100644 crates/ui/src/shader_player.rs diff --git a/crates/mobile/src/visual/mod.rs b/crates/mobile/src/visual/mod.rs index 199a492d51..7b80c3b439 100644 --- a/crates/mobile/src/visual/mod.rs +++ b/crates/mobile/src/visual/mod.rs @@ -1273,6 +1273,7 @@ impl Worker { blend: node.blend, bloom: node.bloom, textures: &textures, + fade_edges: true, }, &uniforms, size.0, diff --git a/crates/ui/examples/new-project-fixture.rs b/crates/ui/examples/new-project-fixture.rs index ccb60ea11b..079ac867bf 100644 --- a/crates/ui/examples/new-project-fixture.rs +++ b/crates/ui/examples/new-project-fixture.rs @@ -19,6 +19,10 @@ fn main() -> anyhow::Result<()> { adjustment: settings::NewThreadBackgroundAdjustment::default(), }); } + if let Ok(path) = std::env::var("ZERON_FIXTURE_GENERATIVE") { + settings.new_thread_generative_art = Some(std::fs::read_to_string(path).unwrap()); + settings.new_thread_background_source = settings::NewThreadBackgroundSource::Generative; + } if let Ok(path) = std::env::var("ZERON_FIXTURE_HOME_VISUAL") { settings.new_thread_visual = Some(std::fs::read_to_string(path).unwrap()); } @@ -50,7 +54,7 @@ fn main() -> anyhow::Result<()> { {"id":"remote","name":"Build server","platform":"linux","lastSeenAt":null}, {"id":"laptop","name":"Travel laptop","platform":"macos","lastSeenAt":null} ])).unwrap(); - s.selected_chat = if std::env::var_os("ZERON_FIXTURE_BACKGROUND").is_some() || std::env::var_os("ZERON_FIXTURE_HOME_VISUAL").is_some() { None } else { Some("browser-fixture".into()) }; s.selected_space = Some("project".into()); + s.selected_chat = if std::env::var_os("ZERON_FIXTURE_BACKGROUND").is_some() || std::env::var_os("ZERON_FIXTURE_HOME_VISUAL").is_some() || std::env::var_os("ZERON_FIXTURE_GENERATIVE").is_some() { None } else { Some("browser-fixture".into()) }; s.selected_space = Some("project".into()); s.auto_selected = true; s.chats_synced = true; s.spaces_synced = true; s.spaces = vec![serde_json::from_value(serde_json::json!({"id":"project","deviceId":"local","path":"/tmp/fieldnotes","createdAt":"2026-09-08T00:00:00Z"})).unwrap()]; s.chats = vec![serde_json::from_value(serde_json::json!({"id":"browser-fixture","deviceId":"local","spaceId":"project","title":"Build the Fieldnotes workspace","archived":false,"createdAt":"2026-09-08T00:00:00Z","config":{"harness":"claude-code","model":"claude-sonnet-4-6","reasoning":null,"sandbox":"workspace-write"}})).unwrap()]; diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index b9d1eca902..58d04b3d31 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -48,6 +48,7 @@ pub mod motion; mod new_thread_background_effects; mod new_thread_background_image; mod new_thread_background_mask; +mod new_thread_generative; mod notice; pub mod notify; pub mod orb; @@ -66,6 +67,7 @@ pub mod rail; mod roll_text; pub mod running_pill; pub mod settings; +mod shader_player; pub mod shell; pub mod sound; pub mod state; diff --git a/crates/ui/src/new_thread_background_effects.rs b/crates/ui/src/new_thread_background_effects.rs index bedcd0af4c..afdb857e29 100644 --- a/crates/ui/src/new_thread_background_effects.rs +++ b/crates/ui/src/new_thread_background_effects.rs @@ -35,6 +35,17 @@ fn crossfade_mix(start: Instant, now: Instant) -> f32 { } impl Readiness { + /// A live source (the generative background, keyed by `path`) replaces + /// its own frames in place, before [`Readiness::frame`] runs: only + /// switching to or from it crossfades, including mid-crossfade. + pub fn adopt_live_frame(&mut self, image: Option<&Arc>, path: &Path) { + if let Some(image) = image + && self.current_path.as_deref() == Some(path) + { + self.current = Some(image.clone()); + } + } + pub fn frame( &mut self, image: Option>, @@ -469,6 +480,40 @@ mod tests { assert!(removed.current.is_none() && removed.previous.is_none()); } + #[test] + fn live_frames_replace_in_place_and_only_switching_sources_crossfades() { + let image = artwork(); + let (first, second, third) = (artwork(), artwork(), artwork()); + let mut ready = Readiness::default(); + let now = Instant::now(); + let adjustment = NewThreadBackgroundAdjustment::default(); + let image_path = Path::new("background.png"); + let live = Path::new("generative:1"); + ready.frame(Some(image.clone()), Some(image_path), adjustment, true, true, now); + // Switching to the live source crossfades from the image. + ready.adopt_live_frame(Some(&first), live); + let start = ready.frame(Some(first.clone()), Some(live), adjustment, true, false, now); + assert_eq!(start.previous.unwrap().id, image.id); + assert_eq!(start.current.unwrap().id, first.id); + // Its next frame lands mid-crossfade: shown at once, still fading in. + ready.adopt_live_frame(Some(&second), live); + let mid = ready.frame(Some(second.clone()), Some(live), adjustment, true, false, now); + assert_eq!(mid.current.unwrap().id, second.id); + assert_eq!(mid.previous.unwrap().id, image.id); + assert!(mid.active); + // After it, frames replace each other without starting another. + let done = now + std::time::Duration::from_secs(1); + ready.frame(Some(second), Some(live), adjustment, true, false, done); + ready.adopt_live_frame(Some(&third), live); + let later = ready.frame(Some(third.clone()), Some(live), adjustment, true, false, done); + assert_eq!(later.current.unwrap().id, third.id); + assert!(later.previous.is_none() && !later.active); + // A frame of another live key doesn't hijack the current source. + ready.adopt_live_frame(Some(&image), Path::new("generative:2")); + let held = ready.frame(None, Some(live), adjustment, true, false, done); + assert_eq!(held.current.unwrap().id, third.id); + } + #[test] fn wallpaper_replacement_and_removal_keep_the_departing_crop() { let first = artwork(); diff --git a/crates/ui/src/new_thread_generative.rs b/crates/ui/src/new_thread_generative.rs new file mode 100644 index 0000000000..be87fd1ead --- /dev/null +++ b/crates/ui/src/new_thread_generative.rs @@ -0,0 +1,311 @@ +//! The generative new-thread background: the user's Luau `ui.shader`, +//! rendered at the background's size and painted through the same composer +//! mask, fades, and crossfade as an image. It moves (time, spin, feedback +//! buffers) but takes no input: it is art, not a control, so `iMouse` +//! stays at rest and nothing is clickable. + +use std::cell::Cell; +use std::rc::Rc; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use gpui::{App, Context, RenderImage, Task}; +use zeron_visual::{Node, NodeKind, ShaderNode}; + +use crate::shader_player::{ShaderPlayer, ShaderProgram, SharedShaderPlayer}; + +/// Seeds the script's randomness, so the check and the background agree. +pub(crate) const GENERATIVE_ID: &str = "new-thread-generative"; +/// Frames per second while the art moves. +const FPS: f64 = 30.0; +/// Frames render below the display's resolution: the art is soft and fades +/// out anyway, and every frame is read back from the GPU. +const RENDER_SCALE: f32 = 0.5; +/// The longest side of a frame, in pixels. +const MAX_SIDE: f32 = 1600.0; +/// When the art's color is sampled for "Match wallpaper colors". +const SAMPLE_TIME: f32 = 2.0; + +/// The shader a script shows as the background: the first in its tree. +pub(crate) fn first_shader(node: &Node) -> Option<&ShaderNode> { + if let NodeKind::Shader(shader) = &node.kind { + return Some(shader); + } + zeron_visual::children(node).find_map(first_shader) +} + +fn program(shader: &ShaderNode) -> ShaderProgram { + ShaderProgram { + code: shader.code.clone(), + buffers: shader.buffers.clone(), + inputs: shader.inputs.iter().map(|(name, _)| name.clone()).collect(), + blend: shader.blend, + bloom: shader.bloom, + // A background has no assets; the check rejects a script naming any. + textures: Vec::new(), + // The background's own mask fades it. + fade_edges: false, + } +} + +fn inputs(shader: &ShaderNode) -> [[f32; 4]; zeron_visual::shader::MAX_INPUTS / 4] { + let mut inputs = [[0.0f32; 4]; zeron_visual::shader::MAX_INPUTS / 4]; + for (i, (_, value)) in shader.inputs.iter().enumerate() { + inputs[i / 4][i % 4] = *value; + } + inputs +} + +fn uniforms( + shader: &ShaderNode, + size: (u32, u32), + density: f32, + time: f32, + delta: f32, + frame: u32, +) -> zeron_visual::shader::ShaderUniforms { + zeron_visual::shader::ShaderUniforms { + resolution: [size.0 as f32, size.1 as f32, density, 0.0], + // At rest: the background takes no pointer. + mouse: [0.0, 0.0, -1.0, -1.0], + camera: [ + shader.yaw + shader.spin * time, + shader.pitch, + shader.zoom, + 0.0, + ], + time: [time, delta, frame as f32, 0.0], + inputs: inputs(shader), + channels: Default::default(), + } +} + +/// Check a script for the generative background the way a visual is checked, +/// and require a shader to show. On success, the color of one of its frames +/// (none without a GPU). Blocking: run it off the main thread. +pub(crate) fn check(script: &str) -> Result, String> { + let seed = zeron_visual::seed_for(GENERATIVE_ID); + zeron_visual::check(script, seed).map_err(|error| error.message)?; + let tree = zeron_visual::Visual::load(script, seed) + .and_then(|mut visual| visual.render()) + .map_err(|error| error.message)?; + let shader = first_shader(&tree).ok_or_else(|| { + "a generative background is a ui.shader: return one from the view function".to_owned() + })?; + if !shader.textures.is_empty() { + return Err("a generative background has no assets: remove the shader's textures".into()); + } + let size = (96, 54); + let sample = zeron_visual_gpu::render_shader( + &zeron_visual_gpu::ShaderSpec { + image: &shader.code, + buffers: &shader.buffers, + inputs: &program(shader).inputs, + blend: shader.blend, + bloom: shader.bloom, + textures: &[], + fade_edges: false, + }, + &uniforms(shader, size, 1.0, SAMPLE_TIME, 0.0, 0), + size.0, + size.1, + &mut None, + ); + Ok(sample.ok().and_then(|frame| { + crate::settings::wallpaper_colors::extract( + frame.bgra.chunks_exact(4).map(|p| [p[2], p[1], p[0], p[3]]), + ) + })) +} + +/// A running generative background. +pub(crate) struct GenerativeBackground { + script: String, + /// Identifies this art to the background's crossfade. + key: std::path::PathBuf, + shader: Option>, + program: Option>, + player: SharedShaderPlayer, + started: Instant, + /// The art can't show here (no GPU, or a script that stopped loading); + /// the image shows instead. A failed frame is not retried every frame. + failed: Rc>, + /// Wakes the owner when the next frame is due. + wake: Option>, +} + +impl GenerativeBackground { + pub fn new(script: &str) -> Self { + let seed = zeron_visual::seed_for(GENERATIVE_ID); + let shader = zeron_visual::Visual::load(script, seed) + .and_then(|mut visual| visual.render()) + .map_err(|error| tracing::warn!(%error, "generative background failed to load")) + .ok() + .and_then(|tree| first_shader(&tree).cloned()) + .map(Arc::new); + let key = { + use std::hash::{Hash, Hasher}; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + script.hash(&mut hasher); + format!("generative:{:016x}", hasher.finish()).into() + }; + Self { + script: script.to_owned(), + key, + program: shader.as_deref().map(program).map(Arc::new), + failed: Rc::new(Cell::new(shader.is_none())), + shader, + player: Default::default(), + started: Instant::now(), + wake: None, + } + } + + pub fn script(&self) -> &str { + &self.script + } + + pub fn key(&self) -> &std::path::Path { + &self.key + } + + pub fn failed(&self) -> bool { + self.failed.get() + } + + /// The newest frame for a background `size` logical pixels big. Starts + /// the next frame when it's due, or wakes the owner (`cx`'s entity) when + /// it will be; the owner calls this again only while it paints the + /// background, so a hidden background stops rendering. `still` (reduced + /// motion, or paused in the background) holds the time last shown. + pub fn frame( + &mut self, + size: (f32, f32), + scale_factor: f32, + still: bool, + cx: &mut Context, + ) -> Option> { + self.wake = None; + let (Some(shader), Some(program)) = (self.shader.clone(), self.program.clone()) else { + return None; + }; + let density = scale_factor.min(2.0) * RENDER_SCALE; + let mut pixels = (size.0 * density, size.1 * density); + let longest = pixels.0.max(pixels.1); + if longest > MAX_SIDE { + pixels = (pixels.0 * MAX_SIDE / longest, pixels.1 * MAX_SIDE / longest); + } + let pixels = ( + pixels.0.round().max(1.0) as u32, + pixels.1.round().max(1.0) as u32, + ); + let moving = !still && (shader.animate || shader.spin != 0.0 || !shader.buffers.is_empty()); + let now = self.started.elapsed().as_secs_f64(); + let player = self.player.borrow(); + let shown = player.image(); + let time = if moving { + now as f32 + } else { + player.shown().map_or(now as f32, |(u, _)| u.time[0]) + }; + let current = player.shown().is_some_and(|(u, _)| { + u.resolution[..2] == [pixels.0 as f32, pixels.1 as f32] + && (!moving || u.time[0] == time) + }); + if self.failed.get() || player.rendering() || current { + return shown; + } + let first = player.frames() == 0; + let wait = if first { + 0.0 + } else { + 1.0 / FPS - (now - player.last_time()) + }; + if wait > 0.0 { + self.wake = Some(cx.spawn(async move |entity, cx| { + cx.background_executor() + .timer(Duration::from_secs_f64(wait)) + .await; + let _ = entity.update(cx, |_, cx| cx.notify()); + })); + return shown; + } + let delta = if first { + 0.0 + } else { + (now - player.last_time()) as f32 + }; + let uniforms = uniforms(&shader, pixels, density, time, delta, player.frames()); + drop(player); + let (entity, failed) = (cx.entity().downgrade(), self.failed.clone()); + ShaderPlayer::render( + &self.player, + program, + uniforms, + pixels, + now, + cx, + move |outcome, cx: &mut App| { + if let Err(error) = outcome { + tracing::warn!(%error, "generative background frame failed"); + failed.set(true); + } + let _ = entity.update(cx, |_, cx| cx.notify()); + }, + ); + shown + } + + /// Give up the shown frame, for the owner to free with the background. + pub fn take_image(&mut self) -> Option> { + self.wake = None; + self.player.borrow_mut().take_image() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SHADER: &str = + "void mainImage(out vec4 c, in vec2 p) { c = vec4(p / iResolution.xy, 0.5, 1.0); }"; + + #[test] + fn the_first_shader_in_the_tree_is_the_background() { + let script = format!( + "return function() return ui.column {{ ui.text \"Art\", \ + ui.shader {{ height = 80, code = [[{SHADER}]] }} }} end" + ); + let tree = zeron_visual::Visual::load(&script, 1) + .and_then(|mut visual| visual.render()) + .unwrap(); + assert_eq!(first_shader(&tree).unwrap().code, SHADER); + assert!(check(&script).is_ok()); + } + + #[test] + fn a_script_without_a_shader_is_not_a_background() { + let error = check("return function() return ui.text \"Hi\" end").unwrap_err(); + assert!(error.contains("ui.shader"), "{error}"); + let error = check("return function() return ui.nope {} end").unwrap_err(); + assert!(error.contains("visual:1"), "{error}"); + } + + #[test] + fn the_background_takes_no_pointer_and_spins_on_its_own() { + let tree = zeron_visual::Visual::load( + &format!( + "return function() return ui.shader {{ height = 80, spin = 0.5, \ + code = [[{SHADER}]] }} end" + ), + 1, + ) + .and_then(|mut visual| visual.render()) + .unwrap(); + let shader = first_shader(&tree).unwrap(); + let at = |time| uniforms(shader, (40, 20), 1.0, time, 0.0, 0); + assert_eq!(at(0.0).mouse, [0.0, 0.0, -1.0, -1.0]); + assert!((at(2.0).camera[0] - at(0.0).camera[0] - 1.0).abs() < 1e-6); + assert!(!program(shader).fade_edges); + } +} diff --git a/crates/ui/src/settings.rs b/crates/ui/src/settings.rs index 1583e985ea..bff6f17044 100644 --- a/crates/ui/src/settings.rs +++ b/crates/ui/src/settings.rs @@ -23,8 +23,10 @@ pub mod composer; pub mod devices; pub mod files; pub mod harnesses; +pub mod generative_art; pub mod home_visual; pub mod notifications; +pub mod script_card; pub mod shortcuts; pub mod thread_naming; pub mod wallpaper; @@ -119,6 +121,28 @@ impl Default for NewThreadBackgroundAdjustment { } } +/// What fills the new-thread background. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum NewThreadBackgroundSource { + /// The chosen image, with its framing and effect. + #[default] + Image, + /// The user's generative art (`new_thread_generative_art`). + Generative, +} + +impl NewThreadBackgroundSource { + pub const ALL: [Self; 2] = [Self::Image, Self::Generative]; + + pub fn label(self) -> &'static str { + match self { + Self::Image => "Image", + Self::Generative => "Generative", + } + } +} + #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum NewThreadBackgroundEffect { @@ -485,6 +509,8 @@ fn commit_background( wallpaper::remember(&mut next.wallpaper_history, source); next.wallpaper_source = Some(source.to_path_buf()); next.wallpaper_color = color; + // A newly chosen image is what the user wants to see. + next.new_thread_background_source = NewThreadBackgroundSource::Image; let previous = next .new_thread_composer_background .replace(replacement.clone()); @@ -553,6 +579,41 @@ pub fn set_new_thread_visual(script: Option, cx: &mut App) { } } +pub fn set_new_thread_background_source(source: NewThreadBackgroundSource, cx: &mut App) { + if update(SavePolicy::Immediate, cx, |settings| { + settings.new_thread_background_source = source; + }) { + if current(cx).wallpaper_theme_colors { + crate::appearance::apply(cx); + } + cx.refresh_windows(); + } +} + +/// Save the generative background with the color sampled from one of its +/// frames, and show it; `None` clears it and returns to the image. Callers +/// check the script first; see `settings::generative_art`. +pub fn set_new_thread_generative_art( + art: Option<(String, Option)>, + cx: &mut App, +) { + if update(SavePolicy::Immediate, cx, |settings| { + let (script, color) = art.map_or((None, None), |(script, color)| (Some(script), color)); + settings.new_thread_background_source = if script.is_some() { + NewThreadBackgroundSource::Generative + } else { + NewThreadBackgroundSource::Image + }; + settings.new_thread_generative_art = script; + settings.new_thread_generative_color = color; + }) { + if current(cx).wallpaper_theme_colors { + crate::appearance::apply(cx); + } + cx.refresh_windows(); + } +} + pub fn set_new_thread_background_adjustment( adjustment: NewThreadBackgroundAdjustment, cx: &mut App, @@ -1013,6 +1074,17 @@ pub struct UiSettings { pub wallpaper_color: Option, /// Non-destructive treatment composited inside the artwork's fade mask. pub new_thread_background_effect: NewThreadBackgroundEffect, + /// Which background shows behind the new-thread composer. The image and + /// the generative art are both kept, so switching loses neither. + pub new_thread_background_source: NewThreadBackgroundSource, + /// The user's generative background: a Luau script whose `ui.shader` + /// renders at the background's size. Checked before it is saved. + #[serde(skip_serializing_if = "Option::is_none")] + pub new_thread_generative_art: Option, + /// The generative art's dominant color, sampled from a frame when it + /// was saved, for "Match wallpaper colors". + #[serde(skip_serializing_if = "Option::is_none")] + pub new_thread_generative_color: Option, /// The user's own Luau visual (the `show_visual` script format), drawn /// below the blank new-thread composer. Checked before it is saved. #[serde(skip_serializing_if = "Option::is_none")] @@ -1120,6 +1192,9 @@ impl Default for UiSettings { wallpaper_theme_colors: false, wallpaper_color: None, new_thread_background_effect: NewThreadBackgroundEffect::None, + new_thread_background_source: NewThreadBackgroundSource::Image, + new_thread_generative_art: None, + new_thread_generative_color: None, new_thread_visual: None, reduce_motion: crate::motion::ReduceMotion::System, pause_animations_in_background: false, @@ -1766,6 +1841,9 @@ impl UiSettings { wallpaper_theme_colors, wallpaper_color, new_thread_background_effect, + new_thread_background_source, + new_thread_generative_art, + new_thread_generative_color, new_thread_visual, reduce_motion, pause_animations_in_background, @@ -2871,6 +2949,9 @@ mod tests { wallpaper_theme_colors: false, wallpaper_color: None, new_thread_background_effect: NewThreadBackgroundEffect::Ascii, + new_thread_background_source: NewThreadBackgroundSource::Generative, + new_thread_generative_art: Some("return function() return ui.shader { height = 80, code = [[void mainImage(out vec4 c, in vec2 p) { c = vec4(1.0); }]] } end".into()), + new_thread_generative_color: Some(zeron_theme::Color::rgb(12, 34, 56)), new_thread_visual: Some("return function() return ui.text \"Hi\" end".into()), reduce_motion: crate::motion::ReduceMotion::On, pause_animations_in_background: true, diff --git a/crates/ui/src/settings/appearance.rs b/crates/ui/src/settings/appearance.rs index 664f5561cd..c78c64de5f 100644 --- a/crates/ui/src/settings/appearance.rs +++ b/crates/ui/src/settings/appearance.rs @@ -317,7 +317,9 @@ pub struct AppearancePage { review_entry: Option, library_error: Option, background_error: Option, - home_visual: Entity, + background_source_select: widgets::SelectState, + home_visual: Entity, + generative_art: Entity, } impl AppearancePage { @@ -582,7 +584,19 @@ impl AppearancePage { review_entry: None, library_error: None, background_error: None, - home_visual: cx.new(crate::settings::home_visual::HomeVisualCard::new), + background_source_select: widgets::SelectState::default(), + home_visual: cx.new(|cx| { + crate::settings::script_card::ScriptCard::new( + &crate::settings::home_visual::SPEC, + cx, + ) + }), + generative_art: cx.new(|cx| { + crate::settings::script_card::ScriptCard::new( + &crate::settings::generative_art::SPEC, + cx, + ) + }), } } @@ -3557,6 +3571,61 @@ impl Render for AppearancePage { .child(surface_control) .into_any_element(), ); + let current_background_source = ui_settings.new_thread_background_source; + let generative = matches!( + current_background_source, + crate::settings::NewThreadBackgroundSource::Generative + ); + { + use crate::settings::NewThreadBackgroundSource; + let source_control = widgets::select( + "new-thread-background-source", + "Background source", + &theme, + |page: &mut Self| &mut page.background_source_select, + ) + .options( + NewThreadBackgroundSource::ALL + .into_iter() + .map(|source| widgets::SelectOption::new(source.label())), + NewThreadBackgroundSource::ALL + .into_iter() + .position(|source| source == current_background_source) + .unwrap_or_default(), + ) + .width(128.0) + .on_select(|_, ix, _, cx| { + crate::settings::set_new_thread_background_source( + NewThreadBackgroundSource::ALL[ix], + cx, + ); + cx.notify(); + }) + .render(&self.background_source_select, cx); + settings_rows.push( + widgets::card_row(&theme, false) + .child( + div() + .flex_1() + .min_w(px(160.0)) + .child(widgets::row_title(&theme, "Background source")) + .child(widgets::meta_line( + &theme, + vec![ + div() + .child(if generative { + "Generative art from a Luau shader, set below." + } else { + "The image below, with its framing and effect." + }) + .into_any_element(), + ], + )), + ) + .child(source_control) + .into_any_element(), + ); + } let background_available = current_background .as_ref() .is_some_and(|background| Path::new(&background.path).is_file()); @@ -3736,7 +3805,8 @@ impl Render for AppearancePage { ) .into_any_element(), ); - if background_available { + // Effects treat the image; the generative art shows as its script draws it. + if background_available && !generative { use crate::settings::NewThreadBackgroundEffect; let effect_control = widgets::select( "new-thread-background-effect", @@ -4039,6 +4109,13 @@ impl Render for AppearancePage { "Material and background", widgets::section_card(&theme).mt_0().children(settings_rows), )) + .when(generative, |page| { + page.child(widgets::section( + &theme, + "Generative background", + self.generative_art.clone(), + )) + }) .child(widgets::section( &theme, "Home visual", diff --git a/crates/ui/src/settings/generative_art.rs b/crates/ui/src/settings/generative_art.rs new file mode 100644 index 0000000000..a2d00a4b1d --- /dev/null +++ b/crates/ui/src/settings/generative_art.rs @@ -0,0 +1,27 @@ +//! Settings → Appearance → Generative background: the user's Luau +//! `ui.shader`, drawn behind the new-thread composer in place of the image. +//! Applying it checks that it runs and shows a shader, samples one frame's +//! color for "Match wallpaper colors", and makes it the background. + +use crate::settings::NewThreadBackgroundSource; +use crate::settings::script_card::{Commit, ScriptSpec}; + +pub static SPEC: ScriptSpec = ScriptSpec { + key: "generative-art", + title: "Shader script", + empty_status: "A Luau script returning a ui.shader. It moves, but takes no input.", + saved: |settings| settings.new_thread_generative_art.clone(), + saved_status: |settings| match settings.new_thread_background_source { + NewThreadBackgroundSource::Generative => "Showing behind the new-session composer.", + NewThreadBackgroundSource::Image => "Saved. Choose Generative above to show it.", + }, + check, + clear: |cx| crate::settings::set_new_thread_generative_art(None, cx), +}; + +fn check(script: String) -> Result { + let color = crate::new_thread_generative::check(&script)?; + Ok(Box::new(move |cx| { + crate::settings::set_new_thread_generative_art(Some((script, color)), cx) + })) +} diff --git a/crates/ui/src/settings/home_visual.rs b/crates/ui/src/settings/home_visual.rs index 609a940d16..39fe6a92e3 100644 --- a/crates/ui/src/settings/home_visual.rs +++ b/crates/ui/src/settings/home_visual.rs @@ -3,168 +3,26 @@ //! show with `show_visual`; it is checked the way the engine checks theirs //! before it is saved, so a broken script never reaches the home view. -use gpui::{Context, Entity, SharedString, Subscription, Task, div, prelude::*, px}; - -use crate::composer::{CODE_INPUT_CONTEXT, ComposerInput, ComposerInputEvent}; -use crate::popover; -use crate::settings::widgets; -use crate::theme::Theme; +use crate::settings::script_card::{Commit, ScriptSpec}; /// The visual's id, which also seeds its randomness: the check and the /// home view see the same random data. pub const HOME_VISUAL_ID: &str = "home-visual"; -pub struct HomeVisualCard { - input: Entity, - error: Option, - checking: Option>, - _input_events: Subscription, -} - -impl HomeVisualCard { - pub fn new(cx: &mut Context) -> Self { - let saved = crate::settings::current(cx).new_thread_visual; - let input = cx.new(|cx| { - ComposerInput::with_context("-- Paste a Luau visual script", CODE_INPUT_CONTEXT, cx) - .with_monospace() - .with_text_metrics(12.0, 18.0) - .with_viewport_height(240.0) - .with_tab_stop() - }); - if let Some(saved) = saved { - input.update(cx, |input, cx| input.set_text(saved, cx)); - } - let input_events = cx.subscribe(&input, |card: &mut Self, _, event, cx| { - if matches!(event, ComposerInputEvent::Edited) { - card.error = None; - cx.notify(); - } - }); - Self { - input, - error: None, - checking: None, - _input_events: input_events, - } - } - - fn draft(&self, cx: &gpui::App) -> String { - self.input.read(cx).text().trim().to_owned() - } - - /// Check the draft off the main thread, then save it (an empty draft - /// clears the visual). - fn apply(&mut self, cx: &mut Context) { - let script = self.draft(cx); - if script.is_empty() { - self.clear(cx); - return; - } - let check = cx.background_executor().spawn({ - let script = script.clone(); - async move { - zeron_visual::check(&script, zeron_visual::seed_for(HOME_VISUAL_ID)).map(|_| ()) - } - }); - self.error = None; - self.checking = Some(cx.spawn(async move |this, cx| { - let checked = check.await; - this.update(cx, |card, cx| { - card.checking = None; - match checked { - Ok(()) => crate::settings::set_new_thread_visual(Some(script), cx), - Err(error) => card.error = Some(error.message.into()), - } - cx.notify(); - }) - .ok(); - })); - cx.notify(); - } - - fn clear(&mut self, cx: &mut Context) { - self.checking = None; - self.error = None; - self.input.update(cx, |input, cx| input.set_text("", cx)); - crate::settings::set_new_thread_visual(None, cx); - cx.notify(); - } -} - -impl Render for HomeVisualCard { - fn render(&mut self, _: &mut gpui::Window, cx: &mut Context) -> impl IntoElement { - let theme = Theme::of(cx).for_settings_surface(); - let saved = crate::settings::current(cx).new_thread_visual; - let draft = self.draft(cx); - let checking = self.checking.is_some(); - let applied = saved.as_deref().map(str::trim) == Some(draft.as_str()) - || (saved.is_none() && draft.is_empty()); - let status = if checking { - "Checking the script…" - } else if saved.is_some() && applied { - "Showing below the new-session composer." - } else if saved.is_some() { - "Unapplied changes; the saved visual is still showing." - } else { - "A Luau visual, in the format agents use with show_visual." - }; - let actions = div() - .flex_none() - .flex() - .flex_row() - .items_center() - .gap(px(8.0)) - .when(saved.is_some() || !draft.is_empty(), |row| { - row.child( - widgets::text_action(&theme, widgets::ActionTone::Quiet, "Clear") - .id("home-visual-clear") - .tab_index(0) - .role(gpui::Role::Button) - .aria_label("Remove the home visual") - .focus_visible(|s| s.border_2().border_color(theme.accent)) - .on_click(cx.listener(|card, _, _, cx| card.clear(cx))), - ) - }) - .child( - widgets::text_action( - &theme, - widgets::ActionTone::Solid, - if checking { "Checking…" } else { "Apply" }, - ) - .id("home-visual-apply") - .tab_index(0) - .role(gpui::Role::Button) - .aria_label("Check and apply the home visual") - .focus_visible(|s| s.border_2().border_color(theme.accent)) - .when(applied || checking, |button| button.opacity(0.5)) - .when(!applied && !checking, |button| { - button.on_click(cx.listener(|card, _, _, cx| card.apply(cx))) - }), - ); - widgets::section_card(&theme) - .mt_0() - .child( - widgets::card_row(&theme, true) - .child( - div() - .flex_1() - .min_w(px(160.0)) - .child(widgets::row_title(&theme, "Visual script")) - .child(widgets::meta_line( - &theme, - vec![div().child(status).into_any_element()], - )), - ) - .child(actions), - ) - .child( - div() - .mx(px(16.0)) - .mb(px(16.0)) - .child(popover::dialog_field(self.input.clone().into_any_element())) - .when_some(self.error.clone(), |block, error| { - block.child(widgets::error_strip(&theme, error).mt(px(12.0))) - }), - ) - } +pub static SPEC: ScriptSpec = ScriptSpec { + key: "home-visual", + title: "Visual script", + empty_status: "A Luau visual, in the format agents use with show_visual.", + saved: |settings| settings.new_thread_visual.clone(), + saved_status: |_| "Showing below the new-session composer.", + check, + clear: |cx| crate::settings::set_new_thread_visual(None, cx), +}; + +fn check(script: String) -> Result { + zeron_visual::check(&script, zeron_visual::seed_for(HOME_VISUAL_ID)) + .map_err(|error| error.message)?; + Ok(Box::new(move |cx| { + crate::settings::set_new_thread_visual(Some(script), cx) + })) } diff --git a/crates/ui/src/settings/script_card.rs b/crates/ui/src/settings/script_card.rs new file mode 100644 index 0000000000..c68a4173b1 --- /dev/null +++ b/crates/ui/src/settings/script_card.rs @@ -0,0 +1,189 @@ +//! A settings card holding one Luau script: a code field, Apply, and Clear. +//! Apply checks the draft off the main thread the way its feature needs and +//! saves it only if it passes, so a broken script never reaches the view +//! that runs it; the error comes back with its line. The home visual and +//! the generative background each describe themselves with a +//! [`ScriptSpec`]. + +use gpui::{App, Context, Entity, SharedString, Subscription, Task, div, prelude::*, px}; + +use crate::composer::{CODE_INPUT_CONTEXT, ComposerInput, ComposerInputEvent}; +use crate::popover; +use crate::settings::{UiSettings, widgets}; +use crate::theme::Theme; + +/// Saves a script that passed its check (on the main thread). +pub type Commit = Box; + +/// One script setting. +pub struct ScriptSpec { + /// Element ids start with it. + pub key: &'static str, + pub title: &'static str, + /// What the script is, while none is saved. + pub empty_status: &'static str, + /// The saved script, if any. + pub saved: fn(&UiSettings) -> Option, + /// Where the saved script shows, or why it doesn't. + pub saved_status: fn(&UiSettings) -> &'static str, + /// Check a draft (blocking, off the main thread); on success, how to + /// save it. + pub check: fn(String) -> Result, + pub clear: fn(&mut App), +} + +pub struct ScriptCard { + spec: &'static ScriptSpec, + input: Entity, + error: Option, + checking: Option>, + _input_events: Subscription, +} + +impl ScriptCard { + pub fn new(spec: &'static ScriptSpec, cx: &mut Context) -> Self { + let saved = (spec.saved)(&crate::settings::current(cx)); + let input = cx.new(|cx| { + ComposerInput::with_context("-- Paste a Luau script", CODE_INPUT_CONTEXT, cx) + .with_monospace() + .with_text_metrics(12.0, 18.0) + .with_viewport_height(240.0) + .with_tab_stop() + }); + if let Some(saved) = saved { + input.update(cx, |input, cx| input.set_text(saved, cx)); + } + let input_events = cx.subscribe(&input, |card: &mut Self, _, event, cx| { + if matches!(event, ComposerInputEvent::Edited) { + card.error = None; + cx.notify(); + } + }); + Self { + spec, + input, + error: None, + checking: None, + _input_events: input_events, + } + } + + fn draft(&self, cx: &App) -> String { + self.input.read(cx).text().trim().to_owned() + } + + /// Check the draft off the main thread, then save it (an empty draft + /// clears the setting). + fn apply(&mut self, cx: &mut Context) { + let script = self.draft(cx); + if script.is_empty() { + self.clear(cx); + return; + } + let check = self.spec.check; + let checked = cx.background_executor().spawn(async move { check(script) }); + self.error = None; + self.checking = Some(cx.spawn(async move |this, cx| { + let checked = checked.await; + this.update(cx, |card, cx| { + card.checking = None; + match checked { + Ok(commit) => commit(cx), + Err(error) => card.error = Some(error.into()), + } + cx.notify(); + }) + .ok(); + })); + cx.notify(); + } + + fn clear(&mut self, cx: &mut Context) { + self.checking = None; + self.error = None; + self.input.update(cx, |input, cx| input.set_text("", cx)); + (self.spec.clear)(cx); + cx.notify(); + } +} + +impl Render for ScriptCard { + fn render(&mut self, _: &mut gpui::Window, cx: &mut Context) -> impl IntoElement { + let theme = Theme::of(cx).for_settings_surface(); + let spec = self.spec; + let settings = crate::settings::current(cx); + let saved = (spec.saved)(&settings); + let draft = self.draft(cx); + let checking = self.checking.is_some(); + let applied = saved.as_deref().map(str::trim) == Some(draft.as_str()) + || (saved.is_none() && draft.is_empty()); + let status = if checking { + "Checking the script…" + } else if saved.is_some() && applied { + (spec.saved_status)(&settings) + } else if saved.is_some() { + "Unapplied changes; the saved script is still in use." + } else { + spec.empty_status + }; + let id = |part: &str| SharedString::from(format!("{}-{part}", spec.key)); + let actions = div() + .flex_none() + .flex() + .flex_row() + .items_center() + .gap(px(8.0)) + .when(saved.is_some() || !draft.is_empty(), |row| { + row.child( + widgets::text_action(&theme, widgets::ActionTone::Quiet, "Clear") + .id(id("clear")) + .tab_index(0) + .role(gpui::Role::Button) + .aria_label(format!("Remove the {}", spec.title.to_lowercase())) + .focus_visible(|s| s.border_2().border_color(theme.accent)) + .on_click(cx.listener(|card, _, _, cx| card.clear(cx))), + ) + }) + .child( + widgets::text_action( + &theme, + widgets::ActionTone::Solid, + if checking { "Checking…" } else { "Apply" }, + ) + .id(id("apply")) + .tab_index(0) + .role(gpui::Role::Button) + .aria_label(format!("Check and apply the {}", spec.title.to_lowercase())) + .focus_visible(|s| s.border_2().border_color(theme.accent)) + .when(applied || checking, |button| button.opacity(0.5)) + .when(!applied && !checking, |button| { + button.on_click(cx.listener(|card, _, _, cx| card.apply(cx))) + }), + ); + widgets::section_card(&theme) + .mt_0() + .child( + widgets::card_row(&theme, true) + .child( + div() + .flex_1() + .min_w(px(160.0)) + .child(widgets::row_title(&theme, spec.title)) + .child(widgets::meta_line( + &theme, + vec![div().child(status).into_any_element()], + )), + ) + .child(actions), + ) + .child( + div() + .mx(px(16.0)) + .mb(px(16.0)) + .child(popover::dialog_field(self.input.clone().into_any_element())) + .when_some(self.error.clone(), |block, error| { + block.child(widgets::error_strip(&theme, error).mt(px(12.0))) + }), + ) + } +} diff --git a/crates/ui/src/settings/wallpaper_colors.rs b/crates/ui/src/settings/wallpaper_colors.rs index 6f5e092bf3..e19aab53eb 100644 --- a/crates/ui/src/settings/wallpaper_colors.rs +++ b/crates/ui/src/settings/wallpaper_colors.rs @@ -33,10 +33,20 @@ pub(crate) fn extract(pixels: impl IntoIterator) -> Option Option { let settings = super::current(cx); - if settings.wallpaper_theme_colors && settings.new_thread_composer_background.is_some() { - settings.wallpaper_color - } else { - None + if !settings.wallpaper_theme_colors { + return None; + } + match settings.new_thread_background_source { + super::NewThreadBackgroundSource::Image => settings + .new_thread_composer_background + .is_some() + .then_some(settings.wallpaper_color) + .flatten(), + super::NewThreadBackgroundSource::Generative => settings + .new_thread_generative_art + .is_some() + .then_some(settings.new_thread_generative_color) + .flatten(), } } diff --git a/crates/ui/src/shader_player.rs b/crates/ui/src/shader_player.rs new file mode 100644 index 0000000000..204e2ae40f --- /dev/null +++ b/crates/ui/src/shader_player.rs @@ -0,0 +1,147 @@ +//! One `ui.shader`'s frames: rendered one at a time on a background thread +//! with `zeron_visual_gpu`, the newest shown meanwhile. Agent visuals and +//! the generative new-thread background share it. + +use std::cell::RefCell; +use std::rc::Rc; +use std::sync::Arc; + +use gpui::{App, RenderImage}; +use zeron_visual::shader::{Blend, ShaderUniforms}; + +/// What a shader runs, apart from each frame's uniforms. +pub(crate) struct ShaderProgram { + pub code: String, + pub buffers: Vec, + /// Named float inputs, in slot order. + pub inputs: Vec, + pub blend: Blend, + pub bloom: f32, + pub textures: Vec, + pub fade_edges: bool, +} + +/// A shader's newest finished frame and the GPU state carried between +/// frames (feedback buffers, simulations). +#[derive(Default)] +pub(crate) struct ShaderPlayer { + /// The newest frame and the uniforms it was drawn with. + shown: Option<(ShaderUniforms, Arc)>, + /// Frames started, and the wall time the last one was started at. + frame: u32, + last_time: f64, + /// Away on the render thread while a frame renders. + gpu: Option, + rendering: bool, +} + +pub(crate) type SharedShaderPlayer = Rc>; + +impl ShaderPlayer { + pub fn shown(&self) -> Option<&(ShaderUniforms, Arc)> { + self.shown.as_ref() + } + + pub fn image(&self) -> Option> { + self.shown.as_ref().map(|(_, image)| image.clone()) + } + + /// Frames started so far (`iFrame` of the next one). + pub fn frames(&self) -> u32 { + self.frame + } + + /// When the last frame was started, for the next one's `iTimeDelta`. + pub fn last_time(&self) -> f64 { + self.last_time + } + + pub fn rendering(&self) -> bool { + self.rendering + } + + /// Give up the shown frame, for its owner to free. + pub fn take_image(&mut self) -> Option> { + self.shown.take().map(|(_, image)| image) + } + + /// Render a frame of `program` at `size` device pixels unless one is + /// already in flight (then this returns false). `time` is the wall time + /// the frame starts at. When it lands it replaces the shown frame (the + /// old one is freed) and `landed` runs with the outcome; a player + /// dropped meanwhile frees the frame instead and `landed` doesn't run. + pub fn render( + player: &SharedShaderPlayer, + program: Arc, + uniforms: ShaderUniforms, + size: (u32, u32), + time: f64, + cx: &mut App, + landed: impl FnOnce(Result<(), String>, &mut App) + 'static, + ) -> bool { + let mut gpu_state = { + let mut this = player.borrow_mut(); + if this.rendering { + return false; + } + this.rendering = true; + this.frame += 1; + this.last_time = time; + this.gpu.take() + }; + let render = cx.background_executor().spawn(async move { + let rendered = zeron_visual_gpu::render_shader( + &zeron_visual_gpu::ShaderSpec { + image: &program.code, + buffers: &program.buffers, + inputs: &program.inputs, + blend: program.blend, + bloom: program.bloom, + textures: &program.textures, + fade_edges: program.fade_edges, + }, + &uniforms, + size.0, + size.1, + &mut gpu_state, + ) + .and_then(|frame| { + crate::visuals::render_image(frame.width, frame.height, frame.bgra) + .ok_or_else(|| "the shader's frame was malformed".to_owned()) + }); + (rendered, gpu_state) + }); + let player = Rc::downgrade(player); + cx.spawn(async move |cx| { + let (rendered, gpu_state) = render.await; + cx.update(|cx| { + let Some(player) = player.upgrade() else { + if let Ok(image) = rendered { + crate::visuals::free_images(vec![image], cx); + } + return; + }; + let outcome = { + let mut this = player.borrow_mut(); + this.rendering = false; + this.gpu = gpu_state; + match rendered { + Ok(image) => { + let old = this.shown.replace((uniforms, image)); + drop(this); + crate::visuals::free_images( + old.map(|(_, image)| image).into_iter().collect(), + cx, + ); + Ok(()) + } + Err(error) => Err(error), + } + }; + landed(outcome, cx); + }) + }) + .detach(); + true + } +} diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index f93b6fc4b2..75fa8e754b 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -1373,6 +1373,19 @@ fn bottom_stack_measurement_matches( measured_has_composer == expected_has_composer } +/// What the new-thread background shows this frame. +pub(crate) struct NewThreadArtwork { + pub image: Option>, + /// Identifies the source across frames: the image's path, or the + /// generative art's key. + pub key: Option, + pub adjustment: settings::NewThreadBackgroundAdjustment, + /// A background is chosen (`image` None then means still loading). + pub enabled: bool, + /// Frames of one source replace each other (the generative art). + pub live: bool, +} + /// Space between the new-thread composer and the home visual, and below the /// visual when no Home notice sits at the window bottom. const HOME_VISUAL_GAP: f32 = 24.0; @@ -1980,6 +1993,8 @@ pub struct Shell { /// The user's Luau visual below the blank new-thread composer, and the /// script it runs (rebuilt when Settings saves another). home_visual: Option<(String, Entity)>, + /// The generative new-thread background while it is chosen. + generative_background: Option, /// The persistent composer, and the column the home visual is placed /// in, as last painted (window coordinates). home_composer_bounds: crate::new_thread_background_mask::SurfaceBounds, @@ -2542,6 +2557,7 @@ impl Shell { composer_dock: Default::default(), new_thread_artwork_ready: Default::default(), home_visual: None, + generative_background: None, home_composer_bounds: Default::default(), home_visual_origin: Default::default(), archived_open: true, @@ -11030,39 +11046,38 @@ impl Shell { (has_spaces || no_project || has_appshots) && has_selection, ); let ui_settings = settings::current(cx); - let new_thread_background_setting = ui_settings.new_thread_composer_background; - let new_thread_background_effect = ui_settings.new_thread_background_effect; - let new_thread_background_adjustment = new_thread_background_setting - .as_ref() - .map(|background| background.adjustment) - .unwrap_or_default(); let frame_time = self.render_time.unwrap_or_else(std::time::Instant::now); + let dock_frame = + self.composer_dock + .borrow_mut() + .tick(has_selection, self.reduced_motion, frame_time); // Prewarm even in an established thread. Decode/effect work is not - // contingent on a hero measurement or a navigation gesture. - let artwork = new_thread_background_setting - .as_ref() - .and_then(|background| { - crate::new_thread_background_effects::prepare( - new_thread_background_effect, - theme, - std::path::Path::new(&background.path), - cx, - ) - }); + // contingent on a hero measurement or a navigation gesture; only the + // generative art renders just while the hero shows. + let hero_size = ( + (self.viewport_width - self.sidebar_now()).max(0.0), + new_thread_background_height(self.viewport_height), + ); + let artwork = self.new_thread_artwork( + &ui_settings, + theme, + hero_size, + !has_selection || dock_frame.active, + window, + cx, + ); + if let (true, Some(key)) = (artwork.live, artwork.key.as_deref()) { + self.new_thread_artwork_ready + .adopt_live_frame(artwork.image.as_ref(), key); + } let artwork_frame = self.new_thread_artwork_ready.frame( - artwork, - new_thread_background_setting - .as_ref() - .map(|background| std::path::Path::new(&background.path)), - new_thread_background_adjustment, - new_thread_background_setting.is_some(), + artwork.image, + artwork.key.as_deref(), + artwork.adjustment, + artwork.enabled, self.reduced_motion, frame_time, ); - let dock_frame = - self.composer_dock - .borrow_mut() - .tick(has_selection, self.reduced_motion, frame_time); if dock_frame.active { self.motion_active.set(true); } @@ -11443,6 +11458,83 @@ impl Shell { .into_any_element() } + /// The new-thread background's source image this frame: the generative + /// art's newest frame while it is chosen and can run here, otherwise the + /// image with its effect. `painted` says the caller paints it this frame; + /// generative art renders only then, so a hidden background stops. + pub(crate) fn new_thread_artwork( + &mut self, + ui_settings: &settings::UiSettings, + theme: &Theme, + size: (f32, f32), + painted: bool, + window: &Window, + cx: &mut Context, + ) -> NewThreadArtwork { + let generative = matches!( + ui_settings.new_thread_background_source, + settings::NewThreadBackgroundSource::Generative + ); + let script = ui_settings + .new_thread_generative_art + .as_deref() + .filter(|_| generative); + if self.generative_background.as_ref().map(|art| art.script()) != script { + let images = self + .generative_background + .take() + .and_then(|mut art| art.take_image()); + crate::visuals::free_images(images.into_iter().collect(), cx); + self.generative_background = + script.map(crate::new_thread_generative::GenerativeBackground::new); + } + match self.generative_background.as_mut() { + // Without a GPU the image shows instead. + Some(art) if !art.failed() => { + let image = if painted { + art.frame(size, window.scale_factor(), self.reduced_motion, cx) + } else { + None + }; + return NewThreadArtwork { + image, + key: Some(art.key().to_path_buf()), + adjustment: Default::default(), + enabled: true, + live: true, + }; + } + // Generative chosen with no art yet: nothing shows. + None if generative => { + return NewThreadArtwork { + image: None, + key: None, + adjustment: Default::default(), + enabled: false, + live: false, + }; + } + _ => {} + } + let background = ui_settings.new_thread_composer_background.as_ref(); + NewThreadArtwork { + image: background.and_then(|background| { + crate::new_thread_background_effects::prepare( + ui_settings.new_thread_background_effect, + theme, + std::path::Path::new(&background.path), + cx, + ) + }), + key: background.map(|background| std::path::PathBuf::from(&background.path)), + adjustment: background + .map(|background| background.adjustment) + .unwrap_or_default(), + enabled: background.is_some(), + live: false, + } + } + /// The user's Luau visual (Settings → Appearance → Home visual) in the /// room between the blank new-thread composer and the window bottom. /// It is placed from last frame's composer and column bounds, like the diff --git a/crates/ui/src/shell/voice_stage.rs b/crates/ui/src/shell/voice_stage.rs index 5e1ed96de9..cd2ab40e38 100644 --- a/crates/ui/src/shell/voice_stage.rs +++ b/crates/ui/src/shell/voice_stage.rs @@ -408,22 +408,16 @@ impl Shell { }; let width = (f32::from(viewport.width) - left).max(0.0); let ui_settings = settings::current(cx); - let artwork = ui_settings - .new_thread_composer_background - .as_ref() - .and_then(|background| { - crate::new_thread_background_effects::prepare( - ui_settings.new_thread_background_effect, - &theme, - std::path::Path::new(&background.path), - cx, - ) - }); - let adjustment = ui_settings - .new_thread_composer_background - .as_ref() - .map(|background| background.adjustment) - .unwrap_or_default(); + let artwork = self.new_thread_artwork( + &ui_settings, + &theme, + (width, new_thread_background_height(f32::from(viewport.height))), + true, + window, + cx, + ); + let adjustment = artwork.adjustment; + let artwork = artwork.image; // The new-thread hero's artwork with only its soft bottom fade: no // cutout around the orb, so no light channel splits the picture. let hero = artwork.map(|artwork| { diff --git a/crates/ui/src/visuals.rs b/crates/ui/src/visuals.rs index 1840f89478..757bf1341f 100644 --- a/crates/ui/src/visuals.rs +++ b/crates/ui/src/visuals.rs @@ -74,25 +74,10 @@ struct Local { cameras: HashMap, canvases: HashMap, scenes: HashMap, - shaders: HashMap, + shaders: HashMap, draw_error: Option, } -/// A shader's newest finished frame, and the next one in the works. Frames -/// render on a background thread; the view shows the newest one meanwhile. -#[derive(Default)] -struct ShaderCache { - /// The newest frame and the uniforms it was drawn with. - shown: Option<(zeron_visual::shader::ShaderUniforms, Arc)>, - /// Frames started, and the wall time the last one was started at. - frame: u32, - last_time: f64, - /// Buffers carried between frames (feedback, simulations); away on the - /// render thread while a frame renders. - gpu: Option, - rendering: bool, -} - struct CanvasCache { generation: u64, size: (f32, f32), @@ -138,7 +123,7 @@ struct SceneImage { } /// A rendered frame as an image GPUI can paint (off the main thread). -fn render_image(width: u32, height: u32, bgra: Vec) -> Option> { +pub(crate) fn render_image(width: u32, height: u32, bgra: Vec) -> Option> { let buffer = image::RgbaImage::from_raw(width, height, bgra)?; Some(Arc::new(gpui::RenderImage::new([image::Frame::new( buffer, @@ -573,9 +558,9 @@ impl Local { ) -> Vec> { let kept = |key: &SharedString| keep.is_some_and(|keep| keep.contains(key)); let mut images = Vec::new(); - self.shaders.retain(|key, cache| { + self.shaders.retain(|key, player| { kept(key) || { - images.extend(cache.shown.as_ref().map(|(_, image)| image.clone())); + images.extend(player.borrow_mut().take_image()); false } }); @@ -591,7 +576,7 @@ impl Local { } } -fn free_images(images: Vec>, cx: &mut App) { +pub(crate) fn free_images(images: Vec>, cx: &mut App) { if !images.is_empty() { cx.defer(move |cx| { for image in images { @@ -1674,13 +1659,17 @@ impl VisualFrame { ) }) .collect(); - let (code, buffers, blend, bloom, zoom) = ( - node.code.clone(), - node.buffers.clone(), - node.blend, - node.bloom, - node.zoom, - ); + let zoom = node.zoom; + let buffers = node.buffers.clone(); + let program = Arc::new(crate::shader_player::ShaderProgram { + code: node.code.clone(), + buffers: node.buffers.clone(), + inputs: names, + blend: node.blend, + bloom: node.bloom, + textures, + fade_edges: true, + }); // Buffers evolve every frame, so a shader with any keeps running. let moving = moving || !buffers.is_empty() && !cx.reduce_motion(); let (local, time, entity) = (self.local.clone(), self.time(), cx.entity().downgrade()); @@ -1717,105 +1706,55 @@ impl VisualFrame { } _ => [0.0, 0.0, -1.0, -1.0], }; - let entry = state.shaders.entry(key.clone()).or_default(); + let player = state.shaders.entry(key.clone()).or_default().clone(); + drop(state); + let entry = player.borrow(); // A still shader keeps the time it first showed. let shown_time = if animate { time } else { - entry - .shown - .as_ref() - .map_or(0.0, |(u, _)| f64::from(u.time[0])) + entry.shown().map_or(0.0, |(u, _)| f64::from(u.time[0])) }; - let delta = if entry.frame == 0 { + let delta = if entry.frames() == 0 { 0.0 } else { - (time - entry.last_time).max(0.0) + (time - entry.last_time()).max(0.0) }; let uniforms = zeron_visual::shader::ShaderUniforms { resolution: [size.0 as f32, size.1 as f32, scale, 0.0], mouse, camera: [yaw, orbit.pitch, zoom, 0.0], - time: [shown_time as f32, delta as f32, entry.frame as f32, 0.0], + time: [shown_time as f32, delta as f32, entry.frames() as f32, 0.0], inputs, channels: Default::default(), }; let unchanged = buffers.is_empty() - && entry.shown.as_ref().is_some_and(|(p, _)| { + && entry.shown().is_some_and(|(p, _)| { p.resolution == uniforms.resolution && p.mouse == uniforms.mouse && p.camera == uniforms.camera && p.inputs == uniforms.inputs && (!animate || p.time[0] == uniforms.time[0]) }); - let shown = entry.shown.as_ref().map(|(_, image)| image.clone()); + let shown = entry.image(); + let rendering = entry.rendering(); + drop(entry); // One frame in flight at a time; when it lands the view // paints again and starts the next if anything moved. - if !unchanged && !entry.rendering { - entry.rendering = true; - entry.frame += 1; - entry.last_time = time; - let mut gpu_state = entry.gpu.take(); - let (code, buffers, names, textures) = ( - code.clone(), - buffers.clone(), - names.clone(), - textures.clone(), - ); - let render = cx.background_executor().spawn(async move { - let rendered = zeron_visual_gpu::render_shader( - &zeron_visual_gpu::ShaderSpec { - image: &code, - buffers: &buffers, - inputs: &names, - blend, - bloom, - textures: &textures, - }, - &uniforms, - size.0, - size.1, - &mut gpu_state, - ) - .and_then(|frame| { - render_image(frame.width, frame.height, frame.bgra) - .ok_or_else(|| "the shader's frame was malformed".to_owned()) - }); - (rendered, gpu_state) - }); - let (local, key, entity) = (local.clone(), key.clone(), entity.clone()); - cx.spawn(async move |cx| { - let (rendered, gpu_state) = render.await; - cx.update(|cx| { - let mut freed = Vec::new(); - let mut state = local.borrow_mut(); - match (state.shaders.get_mut(&key), rendered) { - (Some(entry), Ok(image)) => { - entry.rendering = false; - entry.gpu = gpu_state; - freed.extend( - entry - .shown - .replace((uniforms, image)) - .map(|(_, old)| old), - ); - state.draw_error = None; - } - (Some(entry), Err(error)) => { - entry.rendering = false; - entry.gpu = gpu_state; - state.draw_error = Some(error); - } - // The node left the tree meanwhile. - (None, Ok(image)) => freed.push(image), - (None, Err(_)) => {} - } - drop(state); - free_images(freed, cx); + if !unchanged && !rendering { + let (local, entity) = (local.clone(), entity.clone()); + crate::shader_player::ShaderPlayer::render( + &player, + program.clone(), + uniforms, + size, + time, + cx, + move |outcome, cx| { + local.borrow_mut().draw_error = outcome.err(); let _ = entity.update(cx, |_, cx| cx.notify()); - }) - }) - .detach(); + }, + ); } shown }, diff --git a/crates/visual-gpu/examples/render_shader.rs b/crates/visual-gpu/examples/render_shader.rs index e68d4383a7..756a722948 100644 --- a/crates/visual-gpu/examples/render_shader.rs +++ b/crates/visual-gpu/examples/render_shader.rs @@ -59,6 +59,7 @@ fn main() { blend, bloom, textures: &[], + fade_edges: true, }; let frame = render_shader(&spec, &uniforms, w, h, &mut None).expect("render"); let mut ppm = format!("P6\n{w} {h}\n255\n").into_bytes(); diff --git a/crates/visual-gpu/src/shader.rs b/crates/visual-gpu/src/shader.rs index 75542e2837..10db3b961a 100644 --- a/crates/visual-gpu/src/shader.rs +++ b/crates/visual-gpu/src/shader.rs @@ -24,6 +24,10 @@ pub struct ShaderSpec<'a> { pub bloom: f32, /// Images sampled as `iTexture0`-`3`. pub textures: &'a [zeron_visual::TextureData], + /// Fade non-opaque output out toward the frame's edges, as a visual on + /// the chat does. Off where the host masks the frame itself (the + /// generative new-thread background). + pub fade_edges: bool, } /// One shader's textures between frames: each buffer's previous and @@ -110,7 +114,7 @@ fn luma(c: vec3) -> f32 { return dot(c, vec3(0.2126, 0.7152, 0.0722)); if (post.y > 1.5) { return vec4(rgb, 1.0); } - let fade = edge_fade(i.clip.xy, vec2(textureDimensions(source))); + let fade = select(1.0, edge_fade(i.clip.xy, vec2(textureDimensions(source))), post.z > 0.5); if (post.y > 0.5) { let a = max(rgb.r, max(rgb.g, rgb.b)); if (a <= 0.0001) { return vec4(0.0); } @@ -657,7 +661,8 @@ pub fn render_shader( Blend::Light => 1.0, Blend::Opaque => 2.0, }; - let composite = post_uniform([spec.bloom, mode, 0.0, 0.0]); + let fade_edges = if spec.fade_edges { 1.0 } else { 0.0 }; + let composite = post_uniform([spec.bloom, mode, fade_edges, 0.0]); fullscreen( &mut encoder, &state.output, @@ -686,6 +691,7 @@ mod tests { blend, bloom: 0.0, textures: &[], + fade_edges: true, } } @@ -838,6 +844,16 @@ mod tests { assert!(px(&light, 199, 99)[3] < 3, "and the corners"); let opaque = render(Blend::Opaque); assert_eq!(px(&opaque, 0, 0)[3], 255, "an opaque shader keeps its box"); + let unfaded = ShaderSpec { + fade_edges: false, + ..spec(code, &[], Blend::Light) + }; + let unfaded = render_shader(&unfaded, &uniforms, 200, 100, &mut None).unwrap(); + assert_eq!( + px(&unfaded, 0, 0)[3], + 255, + "a host-masked frame keeps its edges" + ); } #[test] From f8ebdb39688a80f91a3dd8fb7a1fbd54ca53a4d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Fri, 16 Oct 2026 14:53:39 -0300 Subject: [PATCH 3/3] Generative background: render at one pixel per logical pixel Frames rendered at half the display's density and were capped at 1600 px on the longest side, so on a 1x display a large window stretched the art two to three times and fine detail (halftone dots, neon lettering) went soft. Render at the window's logical resolution instead, capped by area (about 4 MP) so a wide window scales down evenly. 2x displays render as before. --- crates/ui/src/new_thread_generative.rs | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/crates/ui/src/new_thread_generative.rs b/crates/ui/src/new_thread_generative.rs index be87fd1ead..9734d72c9a 100644 --- a/crates/ui/src/new_thread_generative.rs +++ b/crates/ui/src/new_thread_generative.rs @@ -18,11 +18,13 @@ use crate::shader_player::{ShaderPlayer, ShaderProgram, SharedShaderPlayer}; pub(crate) const GENERATIVE_ID: &str = "new-thread-generative"; /// Frames per second while the art moves. const FPS: f64 = 30.0; -/// Frames render below the display's resolution: the art is soft and fades -/// out anyway, and every frame is read back from the GPU. -const RENDER_SCALE: f32 = 0.5; -/// The longest side of a frame, in pixels. -const MAX_SIDE: f32 = 1600.0; +/// Frames render at one pixel per logical pixel: sharp on a 1x display, half +/// the device's on a 2x one, where the art's soft fades hide it, and every +/// frame is read back from the GPU. +const MAX_DENSITY: f32 = 1.0; +/// The most pixels in a frame (about 2560 by 1600); bigger backgrounds scale +/// down to it. +const MAX_PIXELS: f32 = 4_096_000.0; /// When the art's color is sampled for "Match wallpaper colors". const SAMPLE_TIME: f32 = 2.0; @@ -189,11 +191,12 @@ impl GenerativeBackground { let (Some(shader), Some(program)) = (self.shader.clone(), self.program.clone()) else { return None; }; - let density = scale_factor.min(2.0) * RENDER_SCALE; + let density = scale_factor.min(MAX_DENSITY); let mut pixels = (size.0 * density, size.1 * density); - let longest = pixels.0.max(pixels.1); - if longest > MAX_SIDE { - pixels = (pixels.0 * MAX_SIDE / longest, pixels.1 * MAX_SIDE / longest); + let area = pixels.0 * pixels.1; + if area > MAX_PIXELS { + let shrink = (MAX_PIXELS / area).sqrt(); + pixels = (pixels.0 * shrink, pixels.1 * shrink); } let pixels = ( pixels.0.round().max(1.0) as u32,