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 a41f523027..079ac867bf 100644 --- a/crates/ui/examples/new-project-fixture.rs +++ b/crates/ui/examples/new-project-fixture.rs @@ -19,6 +19,13 @@ 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()); + } if let Some(folder) = std::env::var_os("ZERON_FIXTURE_WALLPAPER_FOLDER") { settings.wallpaper_folder = Some(folder.into()); } @@ -47,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() { 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/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/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..9734d72c9a --- /dev/null +++ b/crates/ui/src/new_thread_generative.rs @@ -0,0 +1,314 @@ +//! 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 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; + +/// 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(MAX_DENSITY); + let mut pixels = (size.0 * density, size.1 * density); + 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, + 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 efcc724053..bff6f17044 100644 --- a/crates/ui/src/settings.rs +++ b/crates/ui/src/settings.rs @@ -23,7 +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; @@ -118,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 { @@ -484,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()); @@ -542,6 +569,51 @@ 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_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, @@ -1002,6 +1074,21 @@ 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")] + 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 +1192,10 @@ 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, codex_voice: None, @@ -1750,6 +1841,10 @@ 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, codex_voice, @@ -2058,6 +2153,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 +2183,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 +2949,10 @@ 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, legacy_accent_color: None, diff --git a/crates/ui/src/settings/appearance.rs b/crates/ui/src/settings/appearance.rs index 967cb173b2..c78c64de5f 100644 --- a/crates/ui/src/settings/appearance.rs +++ b/crates/ui/src/settings/appearance.rs @@ -317,6 +317,9 @@ pub struct AppearancePage { review_entry: Option, library_error: Option, background_error: Option, + background_source_select: widgets::SelectState, + home_visual: Entity, + generative_art: Entity, } impl AppearancePage { @@ -581,6 +584,19 @@ impl AppearancePage { review_entry: None, library_error: None, background_error: None, + 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, + ) + }), } } @@ -3555,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()); @@ -3734,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", @@ -4037,6 +4109,18 @@ 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", + self.home_visual.clone(), + )) .child(widgets::section( &theme, "Motion", 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 new file mode 100644 index 0000000000..39fe6a92e3 --- /dev/null +++ b/crates/ui/src/settings/home_visual.rs @@ -0,0 +1,28 @@ +//! 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 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 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 b40546df36..75fa8e754b 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -1373,6 +1373,28 @@ 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; +/// 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 +1990,15 @@ 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 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, + 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 +2556,10 @@ 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, + generative_background: None, + home_composer_bounds: Default::default(), + home_visual_origin: Default::default(), archived_open: true, pinned_open: true, sessions_open: true, @@ -11011,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); } @@ -11212,6 +11246,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 +11292,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 +11419,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 +11458,180 @@ 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 + /// 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/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 56e5446d3a..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, @@ -180,12 +165,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 +220,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 +257,7 @@ impl VisualFrame { link_ui: None, local: Rc::default(), started: Instant::now(), - _tasks: tasks, + _tasks: Vec::new(), } } @@ -552,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 } }); @@ -570,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 { @@ -1653,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()); @@ -1696,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]