diff --git a/Cargo.lock b/Cargo.lock index 01f58e7e..29da5ce0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4942,6 +4942,7 @@ dependencies = [ "notify", "pollster", "pprof", + "tempfile", "thiserror 2.0.18", "toastty-config", "toastty-graphics", diff --git a/README.md b/README.md index 25ff77df..8ca45e78 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,40 @@ cargo run --release -p molecule-viewer # run it inside toastty An animated 3D ball-and-stick model of caffeine rotating inside a terminal UI

+### ✨ Post-processing shaders + +Run your own fragment shaders over the whole terminal: CRT glass, bloom, +colour grading, cursor trails. Shaders are written in +**ShaderToy-style GLSL** (`mainImage`) or **WGSL**, and use the same +uniforms as Ghostty's custom shaders, so Ghostty shaders work unchanged. +Several shaders can be chained, and saving a shader file reloads it. A +broken edit shows its error in the window and the last working shader +keeps running. + +toastty adds a few uniforms of its own. `iBells` and `iTimeBell` fire on +every BEL, with the cell it rang at, and `iCellSize` / `iGridRect` +describe the cell grid. The bundled +[`crt-splitflap.glsl`](examples/shaders/crt-splitflap.glsl) uses them for +a CRT look where each bell sends a split-flap wave across the screen: + +

+ toastty with a curved, scanlined CRT shader; after a bell, the character cells flip like split-flap display tiles in a wave spreading out from the cursor +

+ +```toml +[shader] +paths = ["~/.config/toastty/shaders/crt-splitflap.glsl"] # run in order +animation = "auto" # "auto" | "always" | "never" +``` + +Shaders that don't use time cost nothing when the screen is idle. +toastty reads which uniforms a shader uses and only draws frames when +they can change. A shader whose animation is triggered by events can +declare `const float TOASTTY_EVENT_DURATION = 1.5;` to animate for that +long after a bell, cursor move or focus change, then go back to idle. +The full contract is in +[docs/milestones/m13-user-shaders.md](docs/milestones/m13-user-shaders.md). + ### 🔤 Text that looks right - **Ligatures** and full font fallback with cosmic-text shaping. @@ -145,7 +179,8 @@ The full matrix is in [docs/protocols.md](docs/protocols.md), and - **Transparent backgrounds**: give the theme background an alpha, e.g. `bg = "#1e1e2ecc"`, and only the background goes see-through. Text stays opaque. -- **Config hot reload**: save `config.toml` and the window updates. +- **Config hot reload**: save `config.toml` (or a shader file) and the + window updates. - **Close confirmation** only when something other than the shell is running. - **Damage-tracked redraws**: frames are only drawn when something changed. @@ -222,7 +257,8 @@ left = 12 Other tables cover the cursor, shell, scrollback and smooth scrolling, window size and background extension, the scroll button, selection word -characters, and the `[security]` switches for OSC 52 and Markdown. +characters, post-processing `[shader]`s, and the `[security]` switches for +OSC 52 and Markdown. ## Keyboard shortcuts @@ -317,10 +353,11 @@ Cargo workspace under `crates/`: | `toastty-protocols` | One module per protocol handler | | `toastty-graphics` | Kitty graphics, Sixel, RGP decoders | | `toastty-markdown` | Markdown → laid-out block (maths, Mermaid, syntax highlighting) | -| `toastty-render` | wgpu renderer; cosmic-text shaping; image and 3D passes | +| `toastty-render` | wgpu renderer; cosmic-text shaping; image, 3D and post-process shader passes | | `toastty-config` | TOML config loading | -Demos live under `demos/` (`molecule-viewer`). +Demos live under `demos/` (`molecule-viewer`), example shaders under +`examples/shaders/`. ### Development diff --git a/crates/toastty-config/src/lib.rs b/crates/toastty-config/src/lib.rs index f01d4493..126121f3 100644 --- a/crates/toastty-config/src/lib.rs +++ b/crates/toastty-config/src/lib.rs @@ -29,6 +29,7 @@ mod scroll_button; mod scrollback; mod security; mod selection; +mod shader; mod shell; mod text; mod theme; @@ -45,6 +46,7 @@ pub use scroll_button::{ScrollButtonConfig, ScrollButtonPosition}; pub use scrollback::{ScrollbackConfig, SmoothingFunction}; pub use security::SecurityConfig; pub use selection::{DEFAULT_WORD_CHARS, SelectionConfig}; +pub use shader::{ShaderAnimation, ShaderConfig}; pub use shell::ShellConfig; pub use text::TextConfig; pub use theme::ThemeConfig; @@ -73,6 +75,7 @@ pub struct Config { pub scroll_button: ScrollButtonConfig, pub selection: SelectionConfig, pub text: TextConfig, + pub shader: ShaderConfig, } impl Default for Config { @@ -99,6 +102,7 @@ impl Config { scroll_button: ScrollButtonConfig::defaults(), selection: SelectionConfig::defaults(), text: TextConfig::defaults(), + shader: ShaderConfig::defaults(), } } @@ -246,6 +250,18 @@ mod tests { assert!(Config::parse_str("[text]\nnope = 1\n").is_err()); } + #[test] + fn shader_parses_and_defaults() { + let d = Config::defaults(); + assert!(d.shader.paths.is_empty()); + assert_eq!(d.shader.animation, ShaderAnimation::Auto); + let cfg = + Config::parse_str("[shader]\npaths = [\"a.glsl\"]\nanimation = \"always\"\n").unwrap(); + assert_eq!(cfg.shader.paths, vec!["a.glsl".to_string()]); + assert_eq!(cfg.shader.animation, ShaderAnimation::Always); + assert!(Config::parse_str("[shader]\nanimation = \"maybe\"\n").is_err()); + } + #[test] fn fully_populated_toml_parses() { let toml_src = include_str!("../tests/fixtures/full.toml"); diff --git a/crates/toastty-config/src/shader.rs b/crates/toastty-config/src/shader.rs new file mode 100644 index 00000000..9cf1fc10 --- /dev/null +++ b/crates/toastty-config/src/shader.rs @@ -0,0 +1,103 @@ +//! `[shader]` table — user post-process shaders (M13). +//! +//! ```toml +//! [shader] +//! # Post-process shaders, run in order. Empty turns post-processing off. +//! # `~` expands; relative paths are relative to the config file's directory. +//! # .glsl / .frag = GLSL (ShaderToy / Ghostty dialect), .wgsl = WGSL. +//! paths = [] +//! # "auto" (detect from the shader), "always" (~60 Hz), "never" (events only). +//! animation = "auto" +//! ``` +//! +//! This crate only stores the raw strings; path resolution and file +//! loading live in the binary (`toastty::shader_files`). See +//! `docs/milestones/m13-user-shaders.md`. + +use serde::{Deserialize, Serialize}; + +/// When post-process shaders get time-driven redraws. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "kebab-case")] +pub enum ShaderAnimation { + /// Detect from the shader which inputs it reads (the default). + #[default] + Auto, + /// Redraw at ~60 Hz all the time, even unfocused. + Always, + /// Redraw only on terminal updates and shader-input events. + Never, +} + +/// Post-process shader config. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct ShaderConfig { + /// Shader files, run in order. Empty disables post-processing. + pub paths: Vec, + /// Animation mode. + pub animation: ShaderAnimation, +} + +impl ShaderConfig { + /// Schema defaults — no shaders, `auto` animation. + #[must_use] + pub fn defaults() -> Self { + Self { + paths: Vec::new(), + animation: ShaderAnimation::Auto, + } + } +} + +impl Default for ShaderConfig { + fn default() -> Self { + Self::defaults() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_are_off_and_auto() { + let d = ShaderConfig::defaults(); + assert_eq!(d, ShaderConfig::default()); + assert!(d.paths.is_empty()); + assert_eq!(d.animation, ShaderAnimation::Auto); + } + + #[test] + fn round_trip_and_partial_table() { + let c = ShaderConfig { + paths: vec!["~/a.glsl".into(), "b.wgsl".into()], + animation: ShaderAnimation::Never, + }; + let t = toml::to_string(&c).unwrap(); + let p: ShaderConfig = toml::from_str(&t).unwrap(); + assert_eq!(p, c); + let only_paths: ShaderConfig = toml::from_str("paths = [\"x.glsl\"]\n").unwrap(); + assert_eq!(only_paths.paths, vec!["x.glsl".to_string()]); + assert_eq!(only_paths.animation, ShaderAnimation::Auto); + } + + #[test] + fn animation_parses_each_variant() { + for (s, want) in [ + ("auto", ShaderAnimation::Auto), + ("always", ShaderAnimation::Always), + ("never", ShaderAnimation::Never), + ] { + let c: ShaderConfig = toml::from_str(&format!("animation = \"{s}\"\n")).unwrap(); + assert_eq!(c.animation, want); + } + } + + #[test] + fn invalid_values_rejected() { + assert!(toml::from_str::("animation = \"sometimes\"\n").is_err()); + assert!(toml::from_str::("paths = \"a.glsl\"\n").is_err()); + assert!(toml::from_str::("bogus = 1\n").is_err()); + } +} diff --git a/crates/toastty-config/tests/fixtures/full.toml b/crates/toastty-config/tests/fixtures/full.toml index f4fef3f9..0ae833d0 100644 --- a/crates/toastty-config/tests/fixtures/full.toml +++ b/crates/toastty-config/tests/fixtures/full.toml @@ -62,3 +62,7 @@ word_chars = "_-./~:?&=%+#@" [text] blink = true + +[shader] +paths = ["~/.config/toastty/crt.glsl", "bloom.wgsl"] +animation = "auto" diff --git a/crates/toastty-render/examples/post_shader.rs b/crates/toastty-render/examples/post_shader.rs new file mode 100644 index 00000000..9380e3c4 --- /dev/null +++ b/crates/toastty-render/examples/post_shader.rs @@ -0,0 +1,263 @@ +//! Demo: user post-process shaders (M13) over a terminal frame. +//! +//! ```sh +//! cargo run -p toastty-render --example post_shader -- crt.glsl [more.wgsl ...] +//! ``` +//! +//! The shaders run in order, as `[shader] paths` would. Keys: +//! +//! - `b`: ring the bell (BEL at the cursor) +//! - arrows: move the cursor +//! - `r`: reload the shader files (errors go to the error banner) +//! - `o`: toggle the debug overlay, `e`: toggle an error banner (both are +//! UI, drawn over the shaded image) +//! - `a`: cycle `[shader] animation` (auto → always → never) +//! - Esc: exit +//! +//! With no shader arguments the frame renders exactly as without +//! post-processing. + +use std::path::PathBuf; +use std::time::Duration; + +use pollster::block_on; +use toastty_parser::Parser; +use toastty_render::{PostAnimation, PostShaderSource, RenderOutcome, Renderer}; +use toastty_term::Term; +use toastty_window::{ + App, ControlSignal, Event, KeyState, LogicalKey, MouseButton, NamedKey, ToasttyWindow, + WindowHandle, WindowOptions, run, +}; + +const COLS: u16 = 80; +const ROWS: u16 = 24; + +struct Demo { + renderer: Option, + term: Term, + parser: Parser, + paths: Vec, + overlay: bool, + banner: bool, + button1: bool, + animation: PostAnimation, +} + +impl Demo { + fn feed(&mut self, bytes: &[u8]) { + self.parser.advance(&mut self.term, bytes); + } + + /// Read and install the shader files; returns the banner text on + /// failure. + fn load_shaders(&mut self) -> Option { + let mut sources = Vec::new(); + for p in &self.paths { + match std::fs::read_to_string(p) { + Ok(source) => sources.push(PostShaderSource { + path: p.clone(), + source, + }), + Err(e) => return Some(format!("{}: {e}", p.display())), + } + } + let r = self.renderer.as_mut()?; + match r.set_post_shaders(&sources) { + Ok(()) => None, + Err(errors) => Some( + errors + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n"), + ), + } + } + + fn next(&self) -> ControlSignal { + match self + .renderer + .as_ref() + .and_then(|r| r.next_redraw_deadline(&self.term)) + { + Some(d) => ControlSignal::RedrawIn(d), + None => ControlSignal::Continue, + } + } +} + +impl App for Demo { + fn init(&mut self, window: ToasttyWindow, _handle: WindowHandle) { + let size = window.physical_size(); + let mut renderer = + block_on(Renderer::new(window.clone(), size, true, false)).expect("renderer init"); + renderer.with_font(None, toastty_render::DEFAULT_FONT_SIZE_PX); + renderer.set_padding(8, 8, 8, 8); + self.renderer = Some(renderer); + if let Some(err) = self.load_shaders() { + eprintln!("{err}"); + if let Some(r) = self.renderer.as_mut() { + r.set_error_banner(Some(&err)); + } + } + } + + fn event(&mut self, event: Event) -> ControlSignal { + match event { + Event::Close + | Event::Key { + logical: LogicalKey::Named(NamedKey::Escape), + state: KeyState::Pressed, + .. + } => return ControlSignal::Exit, + Event::Key { + logical, + state: KeyState::Pressed, + .. + } => match logical { + LogicalKey::Named(NamedKey::ArrowLeft) => self.feed(b"\x1b[D"), + LogicalKey::Named(NamedKey::ArrowRight) => self.feed(b"\x1b[C"), + LogicalKey::Named(NamedKey::ArrowUp) => self.feed(b"\x1b[A"), + LogicalKey::Named(NamedKey::ArrowDown) => self.feed(b"\x1b[B"), + LogicalKey::Character(c) => match c.as_str() { + "b" => self.feed(b"\x07"), + "r" => { + let err = self.load_shaders(); + if let Some(r) = self.renderer.as_mut() { + r.set_error_banner(err.as_deref()); + } + } + "o" => { + self.overlay = !self.overlay; + if let Some(r) = self.renderer.as_mut() { + r.set_debug_overlay(self.overlay.then_some(" debug overlay ")); + } + } + "e" => { + self.banner = !self.banner; + if let Some(r) = self.renderer.as_mut() { + r.set_error_banner( + self.banner + .then_some("example.glsl:3:7: an error banner (UI, unshaded)"), + ); + } + } + "a" => { + self.animation = match self.animation { + PostAnimation::Auto => PostAnimation::Always, + PostAnimation::Always => PostAnimation::Never, + PostAnimation::Never => PostAnimation::Auto, + }; + eprintln!("animation: {:?}", self.animation); + if let Some(r) = self.renderer.as_mut() { + r.set_post_animation(self.animation); + } + } + _ => {} + }, + _ => {} + }, + Event::Mouse { + button: MouseButton::Left, + state, + position, + .. + } => { + self.button1 = state == KeyState::Pressed; + if let Some(r) = self.renderer.as_mut() { + #[allow(clippy::cast_possible_truncation)] + r.set_pointer((position.0 as f32, position.1 as f32), self.button1); + } + } + Event::MouseMotion { position, .. } => { + if let Some(r) = self.renderer.as_mut() { + #[allow(clippy::cast_possible_truncation)] + r.set_pointer((position.0 as f32, position.1 as f32), self.button1); + } + } + Event::Focus(focused) => { + if let Some(r) = self.renderer.as_mut() { + r.set_focused(focused); + } + } + Event::Resize { width, height, .. } => { + if let Some(r) = self.renderer.as_mut() { + r.resize(width, height); + } + } + Event::Redraw => { + if let Some(r) = self.renderer.as_mut() { + match r.render_term(&mut self.term) { + Ok(RenderOutcome::Rendered) => self.term.clear_damage(), + Ok(RenderOutcome::Skipped) => {} + Err(e) => eprintln!("render_term error: {e}"), + } + } + return self.next(); + } + _ => return self.next(), + } + // Anything handled above may have changed what's on screen. + ControlSignal::RedrawIn(Duration::ZERO) + } +} + +fn main() { + let mut paths = Vec::new(); + for a in std::env::args().skip(1) { + paths.push(PathBuf::from(a)); + } + + let mut term = Term::new(ROWS, COLS, 0); + let mut parser = Parser::new(); + parser.advance( + &mut term, + b"\x1b[1mtoastty post-process shader demo\x1b[0m\r\n\r\n", + ); + for i in 0..16u8 { + let s = format!("\x1b[48;5;{i}m {i:>2} \x1b[0m"); + parser.advance(&mut term, s.as_bytes()); + if i == 7 { + parser.advance(&mut term, b"\r\n"); + } + } + parser.advance(&mut term, b"\r\n\r\n"); + parser.advance( + &mut term, + b"\x1b[31mred\x1b[0m \x1b[32mgreen\x1b[0m \x1b[33myellow\x1b[0m \x1b[34mblue\x1b[0m \x1b[35mmagenta\x1b[0m \x1b[36mcyan\x1b[0m\r\n", + ); + parser.advance( + &mut term, + b"the quick brown fox jumps over the lazy dog\r\n", + ); + parser.advance( + &mut term, + b"THE QUICK BROWN FOX JUMPS OVER THE LAZY DOG\r\n", + ); + parser.advance(&mut term, b"0123456789 !@#$%^&*() {}[]<>/?\r\n\r\n"); + parser.advance( + &mut term, + b"b: bell arrows: cursor r: reload o/e: overlays a: animation\r\n\r\n$ ", + ); + + let demo = Demo { + renderer: None, + term, + parser, + paths, + overlay: false, + banner: false, + button1: false, + animation: PostAnimation::Auto, + }; + let opts = WindowOptions { + title: "toastty — post_shader demo".into(), + size: (960, 560), + ime: false, + transparent: false, + }; + if let Err(e) = run(opts, demo) { + eprintln!("window error: {e}"); + std::process::exit(1); + } +} diff --git a/crates/toastty-render/shaders/post.wgsl b/crates/toastty-render/shaders/post.wgsl new file mode 100644 index 00000000..094dfb18 --- /dev/null +++ b/crates/toastty-render/shaders/post.wgsl @@ -0,0 +1,13 @@ +// Vertex stage for the user post-process passes (`crate::post`). +// +// Build-time validated by `build.rs`. One triangle that covers the +// target; the user's fragment shader (a separate module, compiled at +// runtime) reads `@builtin(position)` only, so nothing else is passed +// down. + +@vertex +fn vs_main(@builtin(vertex_index) vid: u32) -> @builtin(position) vec4 { + let x = f32((vid << 1u) & 2u); + let y = f32(vid & 2u); + return vec4(x * 2.0 - 1.0, 1.0 - y * 2.0, 0.0, 1.0); +} diff --git a/crates/toastty-render/src/color.rs b/crates/toastty-render/src/color.rs index 48d5d16c..5f05f891 100644 --- a/crates/toastty-render/src/color.rs +++ b/crates/toastty-render/src/color.rs @@ -11,13 +11,19 @@ /// linear shader output → sRGB-encoded bytes in the swapchain image. #[must_use] pub fn linear_to_srgb_u8(linear: f32) -> u8 { + (linear_to_srgb(linear) * 255.0 + 0.5) as u8 +} + +/// Convert a single linear-light channel in `[0, 1]` to its sRGB-encoded +/// value in `[0, 1]` (clamped). +#[must_use] +pub fn linear_to_srgb(linear: f32) -> f32 { let l = linear.clamp(0.0, 1.0); - let enc = if l <= 0.003_130_8 { + if l <= 0.003_130_8 { l * 12.92 } else { 1.055 * l.powf(1.0 / 2.4) - 0.055 - }; - (enc * 255.0 + 0.5) as u8 + } } /// Convert a linear-light RGBA tuple in `[0, 1]` to a 4-byte BGRA u8 diff --git a/crates/toastty-render/src/lib.rs b/crates/toastty-render/src/lib.rs index 0261a31d..8dd478df 100644 --- a/crates/toastty-render/src/lib.rs +++ b/crates/toastty-render/src/lib.rs @@ -27,6 +27,9 @@ pub mod chip; pub mod color; pub mod dialog; pub mod image; +pub mod post; +#[cfg(test)] +mod render_tests; pub mod rgp; pub mod surface_format; #[cfg(test)] @@ -54,6 +57,8 @@ use crate::image::instance::{ ImageInstance, ImageView, Layer, build_image_instances, classify_layer, split_layers, }; use crate::image::pipeline::ImagePipeline; +pub use crate::post::{PostAnimation, PostShaderSource, ShaderError}; +use crate::post::{PostChain, PostState}; use crate::rgp::{GpuAssetCache, Rgp3dPipeline}; use crate::text::glyph_rasterizer::{DEFAULT_LINE_HEIGHT_RATIO, GlyphRasterizer}; use crate::text::instance::{CellInstance, CellRenderOpts, Theme}; @@ -466,7 +471,9 @@ impl GpuContext { pub struct Renderer { device: Device, queue: Queue, - surface: Surface<'static>, + /// Where frames go (the window's swapchain; an offscreen texture in + /// this crate's tests). + target: Target, config: SurfaceConfiguration, clear_color: [f32; 4], /// Text pipeline lives lazily — `with_font` initializes it; `render` @@ -666,6 +673,27 @@ pub struct Renderer { /// `col_fills` slice handed to the builders. Empty unless /// `extend_background.vertical == SolidLine`. solid_cols_prev: Vec, + // ----- M13: user post-process shaders ([`crate::post`]) ----- + /// The installed shader chain. `None` (the default) means no + /// post-processing, and then the frame path is exactly the one + /// without shaders: none of the post fields below are touched per + /// frame. While `Some`, the content renders into the scratch texture + /// (never direct), the chain runs from scratch into the swapchain, and + /// the UI overlays draw on top in a pass of their own. + post: Option, + /// Clock, pointer, focus, bell and cursor history behind the post + /// uniforms, plus the animation schedule. Kept across chain reloads. + post_state: PostState, + /// `[shader] animation`. + post_animation: PostAnimation, + /// The surface can be viewed through a non-sRGB format + /// (`DownlevelFlags::SURFACE_VIEW_FORMATS`). Without it the last post + /// pass renders into a texture that is copied to the swapchain. + surface_view_formats: bool, + /// sRGB-encoded `iPalette`, and the (term palette revision, theme + /// ANSI colours) it was built for. + post_palette: Box<[[f32; 3]; 256]>, + post_palette_key: Option<(u32, [[f32; 4]; 16])>, } /// Which corner the scroll-to-bottom button is anchored to. Render-side @@ -830,12 +858,28 @@ fn premultiplied_color(c: [f32; 4]) -> wgpu::Color { } } +/// The scratch framebuffer. With `post` it is also sampled by the post +/// passes, through a non-sRGB view (see [`crate::post`]). fn create_scratch( device: &Device, width: u32, height: u32, format: TextureFormat, + post: bool, ) -> (wgpu::Texture, wgpu::TextureView) { + let (usage, view_formats) = if post { + ( + TextureUsages::RENDER_ATTACHMENT + | TextureUsages::COPY_SRC + | TextureUsages::TEXTURE_BINDING, + post::linear_view_formats(format), + ) + } else { + ( + TextureUsages::RENDER_ATTACHMENT | TextureUsages::COPY_SRC, + Vec::new(), + ) + }; let texture = device.create_texture(&wgpu::TextureDescriptor { label: Some("toastty-render scratch framebuffer"), size: wgpu::Extent3d { @@ -847,8 +891,8 @@ fn create_scratch( sample_count: 1, dimension: wgpu::TextureDimension::D2, format, - usage: TextureUsages::RENDER_ATTACHMENT | TextureUsages::COPY_SRC, - view_formats: &[], + usage, + view_formats: &view_formats, }); let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); (texture, view) @@ -879,6 +923,138 @@ fn create_scratch_depth( (texture, view) } +/// Where the renderer's frames go. +#[derive(Debug)] +enum Target { + /// The window's swapchain. + Surface(Surface<'static>), + /// A texture standing in for the swapchain (this crate's tests). + #[cfg(test)] + Offscreen(wgpu::Texture), +} + +/// One frame being drawn: a swapchain image, or the offscreen target. +enum Frame { + Surface(wgpu::SurfaceTexture), + #[cfg(test)] + Offscreen(wgpu::Texture), +} + +impl Frame { + fn texture(&self) -> &wgpu::Texture { + match self { + Self::Surface(f) => &f.texture, + #[cfg(test)] + Self::Offscreen(t) => t, + } + } + + fn present(self) { + match self { + Self::Surface(f) => f.present(), + #[cfg(test)] + Self::Offscreen(_) => {} + } + } +} + +/// The test target for `config`: what the swapchain would hand out +/// (format, view formats), readable back. +#[cfg(test)] +fn offscreen_target(device: &Device, config: &SurfaceConfiguration) -> wgpu::Texture { + device.create_texture(&wgpu::TextureDescriptor { + label: Some("toastty-render offscreen target"), + size: wgpu::Extent3d { + width: config.width, + height: config.height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: config.format, + usage: config.usage | TextureUsages::COPY_SRC, + view_formats: &config.view_formats, + }) +} + +/// sRGB-encode a linear-light colour's RGB (post-shader colour uniforms). +fn srgb3(c: [f32; 4]) -> [f32; 3] { + [ + crate::color::linear_to_srgb(c[0]), + crate::color::linear_to_srgb(c[1]), + crate::color::linear_to_srgb(c[2]), + ] +} + +/// The bell cell (screen row/col at BEL time, clamped to the grid) as a +/// post `iBellCursor` rect: `(left, bottom, width, height)` in surface px. +/// Mapped the way [`crate::text::instance::cursor_pixel_rect`] maps the +/// cursor, plus the content origin. +fn bell_cell_rect(term: &Term, cell: (f32, f32), origin: [f32; 2]) -> [f32; 4] { + let (rows, cols) = term.size(); + let (row, col) = term.last_bell_cell(); + let row = row.min(rows.saturating_sub(1)); + let col = col.min(cols.saturating_sub(1)); + let view_pixel = term.view_offset_pixel(); + let y_translate = if view_pixel > 0.0 { + view_pixel - cell.1 + } else { + 0.0 + }; + let x0 = origin[0] + f32::from(col) * cell.0; + let y0 = origin[1] + f32::from(row) * cell.1 + y_translate; + [x0, y0 + cell.1, cell.0, cell.1] +} + +/// Everything the post uniforms take from the terminal and theme, except +/// the palette. Colours are sRGB-encoded; positions are surface px. +fn post_frame_inputs<'a>( + term: &Term, + theme: &Theme, + cell: (f32, f32), + origin: [f32; 2], + surface: (u32, u32), + cursor_visible: bool, +) -> post::FrameInputs<'a> { + let r = crate::text::instance::cursor_pixel_rect(term, cell); + let (rows, cols) = term.size(); + let cursor = srgb3(theme.cursor); + // The glyph under the cursor: black or white, as `fs_glyph` picks. + let lum = 0.2126 * theme.cursor[0] + 0.7152 * theme.cursor[1] + 0.0722 * theme.cursor[2]; + let cursor_text = if lum > 0.5 { [0.0; 3] } else { [1.0; 3] }; + let style = match term.cursor_shape() { + toastty_term::CursorShape::Block => 0, + toastty_term::CursorShape::Bar => 2, + toastty_term::CursorShape::Underline => 3, + }; + #[allow(clippy::cast_precision_loss)] + post::FrameInputs { + resolution: [surface.0 as f32, surface.1 as f32], + cursor: post::CursorSnap { + rect: [r[0] + origin[0], r[3] + origin[1], r[2] - r[0], r[3] - r[1]], + color: [cursor[0], cursor[1], cursor[2], theme.cursor[3]], + style, + }, + cursor_visible, + cell_size: [cell.0, cell.1], + grid_rect: [ + origin[0], + origin[1], + f32::from(cols) * cell.0, + f32::from(rows) * cell.1, + ], + background: srgb3(theme.bg), + foreground: srgb3(theme.fg), + cursor_color: cursor, + cursor_text, + selection_background: srgb3(theme.selection_bg), + // Selected cells keep their own foreground. + selection_foreground: srgb3(theme.fg), + palette: None, + } +} + /// Default cursor blink half-cycle (matches gnome-terminal / kitty). pub const DEFAULT_CURSOR_BLINK_INTERVAL: Duration = Duration::from_millis(530); @@ -1207,16 +1383,38 @@ impl Renderer { }; surface.configure(&device, &config); + let surface_view_formats = adapter + .get_downlevel_capabilities() + .flags + .contains(wgpu::DownlevelFlags::SURFACE_VIEW_FORMATS); + Ok(Self::from_parts( + device, + queue, + Target::Surface(surface), + config, + surface_view_formats, + )) + } + + /// The renderer around a configured `target`: everything else starts + /// in its initial state. + fn from_parts( + device: Device, + queue: Queue, + target: Target, + config: SurfaceConfiguration, + surface_view_formats: bool, + ) -> Self { let (scratch_texture, scratch_view) = - create_scratch(&device, config.width, config.height, format); + create_scratch(&device, config.width, config.height, config.format, false); let (scratch_depth_texture, scratch_depth_view) = create_scratch_depth(&device, config.width, config.height); - Ok(Self { + Self { device, queue, - surface, + target, config, clear_color: [0.07, 0.07, 0.09, 1.0], text: None, @@ -1281,7 +1479,68 @@ impl Renderer { solid_rows_prev: Vec::new(), solid_cols_prev: Vec::new(), grid_align: GridAlign::TopLeft, - }) + post: None, + post_state: PostState::default(), + post_animation: PostAnimation::default(), + surface_view_formats, + post_palette: Box::new([[0.0; 3]; 256]), + post_palette_key: None, + } + } + + /// A renderer drawing into an offscreen texture shaped like the + /// swapchain (`Bgra8UnormSrgb`), on the shared test device, so tests + /// can drive [`Self::render_term`]. `surface_view_formats: false` + /// exercises the post chain's copy fallback. `None` without a GPU. + #[cfg(test)] + pub(crate) fn headless(width: u32, height: u32, surface_view_formats: bool) -> Option { + let (device, queue) = crate::test_gpu::try_device_and_queue()?; + let config = SurfaceConfiguration { + usage: TextureUsages::RENDER_ATTACHMENT | TextureUsages::COPY_DST, + format: TextureFormat::Bgra8UnormSrgb, + width: width.max(1), + height: height.max(1), + present_mode: PresentMode::AutoVsync, + desired_maximum_frame_latency: 2, + alpha_mode: CompositeAlphaMode::Opaque, + view_formats: vec![], + }; + let target = Target::Offscreen(offscreen_target(&device, &config)); + Some(Self::from_parts( + device, + queue, + target, + config, + surface_view_formats, + )) + } + + /// The offscreen target's current contents (BGRA rows; tests only). + #[cfg(test)] + pub(crate) fn read_offscreen(&self) -> Vec { + let Target::Offscreen(t) = &self.target else { + panic!("not a headless renderer"); + }; + crate::test_gpu::read_texture(&self.device, &self.queue, t) + } + + /// The window surface, unless this is a headless test renderer. + fn surface(&self) -> Option<&Surface<'static>> { + match &self.target { + Target::Surface(s) => Some(s), + #[cfg(test)] + Target::Offscreen(_) => None, + } + } + + /// Apply `config` to the target (configure the surface, or recreate + /// the offscreen texture). + fn configure_target(&mut self) { + match &mut self.target { + Target::Surface(s) => s.configure(&self.device, &self.config), + #[cfg(test)] + Target::Offscreen(t) => *t = offscreen_target(&self.device, &self.config), + } } /// Read-only view of the cached linear-light extended palette @@ -1533,8 +1792,19 @@ impl Renderer { let text_blink = self.text_blink.next_deadline(Instant::now()); // Deferred image mip work: finish it over the next frames. let mips = self.mip_work_pending.then_some(Duration::from_millis(16)); + // Post shaders: time-driven frames, or zero for a pending event. + // None while synchronized output (mode 2026) holds frames back: + // `render_term` skips them, so a due tick would spin until ESU. + let post = self + .post + .as_ref() + .filter(|_| !term.pause_rendering()) + .and_then(|chain| { + self.post_state + .next_frame_in(Instant::now(), self.post_animation, chain.inputs()) + }); // Whichever fires first wins. All are `Option`. - [blink, rgp, chip, text_blink, mips] + [blink, rgp, chip, text_blink, mips, post] .into_iter() .flatten() .min() @@ -1919,6 +2189,141 @@ impl Renderer { } } + /// Append the IME preedit overlay for `preedit` to `instances`. + /// + /// The in-progress composition string is + /// drawn inline starting at the terminal cursor cell, advancing + /// cell-by-cell to the right and overlaying whatever grid content + /// sits underneath. Rendered underlined (and with the theme's + /// cursor color as fg) so it reads as distinct, half-formed text + /// until the IME commits it into the grid (or clears it). + /// + /// We reuse the same cursor-cell anchor the cursor block uses: + /// `cursor_pixel_rect` clamps `term.cursor()` into the grid and + /// applies the sub-pixel scroll y-translate. We take its left edge + /// (x0) for the start column; the cell-top y is recomputed from the + /// same clamped row so the underline shapes/underlines align with + /// the grid regardless of cursor shape (Underline shifts the rect's + /// own y down). + /// + /// Associated (not `&mut self`) for the same borrow reason as + /// [`Self::draw_banner`]. + #[allow(clippy::too_many_arguments)] + fn push_preedit( + queue: &Queue, + text: &mut TextState, + instances: &mut Vec, + preedit: &str, + term: &Term, + cell_size: (f32, f32), + theme: &Theme, + ) { + let (rows, cols) = term.size(); + let cell_w = cell_size.0; + let cell_h = cell_size.1; + let cursor_rect = crate::text::instance::cursor_pixel_rect(term, cell_size); + // Cell-top y for the cursor row, mirroring cursor_pixel_rect's + // cell_pos: clamp the cursor row into the grid and apply the + // same sub-pixel scroll y-translate. + let view_pixel = term.view_offset_pixel(); + let y_translate = if view_pixel > 0.0 { + view_pixel - cell_h + } else { + 0.0 + }; + let cur_row = u16::min(term.cursor().row, rows.saturating_sub(1)); + let y0 = f32::from(cur_row) * cell_h + y_translate; + // Start column from the cursor rect's left edge (already + // clamped + cell-aligned by cursor_pixel_rect). + let start_x = cursor_rect[0]; + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + let start_col = (start_x / cell_w).round() as u16; + + let lg = text + .rasterizer + .shape_line(queue, preedit, term.grapheme_cluster_mode()); + let mode_2027 = term.grapheme_cluster_mode(); + let preedit_fg = theme.cursor; + // Walk the composition string char-by-char, advancing the + // overlay column by each char's display width so wide CJK + // glyphs occupy two cells (matching the shaper's column + // assignment, which leaves the continuation column empty). + let mut col: u16 = start_col; + let mut buf = [0u8; 4]; + for ch in preedit.chars() { + let ch_str = ch.encode_utf8(&mut buf); + let width = u16::from(cluster_cell_width(ch_str, mode_2027)); + // Clip at the right edge: stop emitting once the glyph + // (or its trailing continuation cell) would run past the + // last column. Don't wrap. + if col >= cols || col + width > cols { + break; + } + let pos = [f32::from(col) * cell_w, y0]; + let span = [f32::from(width) * cell_w, cell_h]; + // Cover the grid cell underneath so the half-formed glyph + // reads cleanly, not mixed with whatever was already + // there. Two covers, mirroring the error banner: a bg-pass + // quad (FLAG_NO_GLYPH) repaints the cell bg, and a + // glyph-pass cover (FLAG_UNDERLINE | FLAG_NO_GLYPH emits a + // solid bg) overpaints the term's own glyph for this cell + // — which was already emitted earlier under LoadOp::Load. + instances.push(CellInstance { + pos, + size: span, + uv_min: [0.0, 0.0], + uv_max: [0.0, 0.0], + fg: preedit_fg, + bg: theme.bg, + flags: crate::text::instance::FLAG_NO_GLYPH, + pad: [0; 3], + }); + instances.push(CellInstance { + pos, + size: span, + uv_min: [0.0, 0.0], + uv_max: [0.0, 0.0], + fg: preedit_fg, + bg: theme.bg, + flags: crate::text::instance::FLAG_UNDERLINE | crate::text::instance::FLAG_NO_GLYPH, + pad: [0; 3], + }); + // Underline strip spanning the char's full cell width, + // reusing the same machinery a normal underlined cell + // emits (FLAG_UNDERLINE, fg-as-bg). This is the preedit's + // visual distinction. + instances.push(crate::text::instance::underline_instance( + pos, span, preedit_fg, + )); + // The glyph, looked up at this column. `lg` was shaped from + // the preedit string alone, so its columns are 0-based + // relative to the composition start — subtract `start_col` + // to map this absolute grid column back into that space. + // (Continuation cells for wide chars are left empty by the + // shaper, same as the grid.) + if !ch.is_whitespace() + && let Some(slot) = lg.get(col - start_col, ch) + { + let flags = if slot.is_color { + crate::text::instance::FLAG_COLOR_GLYPH + } else { + 0 + }; + instances.push(CellInstance { + pos: [pos[0] + slot.glyph_offset[0], pos[1] + slot.glyph_offset[1]], + size: slot.glyph_size, + uv_min: slot.uv_min, + uv_max: slot.uv_max, + fg: preedit_fg, + bg: theme.bg, + flags, + pad: [0; 3], + }); + } + col += width; + } + } + /// Rich blocks: sync GPU textures for the blocks on screen and /// return `(render_row, draw)` for every rendered row that will show /// an image slice (block image resident and the row's slice present). @@ -2126,6 +2531,104 @@ impl Renderer { self.preedit.is_some() } + /// Compile and install a post-process shader chain (`[shader] paths`, + /// in order). All-or-nothing: if any shader fails, returns every error + /// and keeps the current chain. An empty slice turns post-processing + /// off. + /// + /// Switching the chain on or off reconfigures the surface (it needs a + /// non-sRGB view format while on) and recreates the scratch texture; + /// any change forces a full clear. + pub fn set_post_shaders( + &mut self, + shaders: &[PostShaderSource], + ) -> Result<(), Vec> { + if shaders.is_empty() { + if self.post.take().is_some() { + self.set_post_resources(false); + } + return Ok(()); + } + let compiled = post::compile_all(shaders)?; + let mut chain = PostChain::new( + &self.device, + &compiled, + self.config.format, + self.surface_view_formats, + )?; + if self.post.is_none() { + self.set_post_resources(true); + } + chain.bind_source(&self.device, &self.scratch_texture); + if chain.inputs().reads(post::date_mask()) { + self.post_state.prepare_date(); + } + self.post = Some(chain); + // The new chain's uniform buffer has no palette yet. + self.post_palette_key = None; + self.post_state.request_frame(); + self.needs_full_clear = true; + Ok(()) + } + + /// Reconfigure the surface and recreate the scratch texture for + /// post-processing on (`on`) or off. + fn set_post_resources(&mut self, on: bool) { + self.config.view_formats = if on && self.surface_view_formats { + post::linear_view_formats(self.config.format) + } else { + Vec::new() + }; + self.configure_target(); + let (tex, view) = create_scratch( + &self.device, + self.config.width, + self.config.height, + self.config.format, + on, + ); + self.scratch_texture = tex; + self.scratch_view = view; + self.scratch_stale = true; + self.needs_full_clear = true; + if !on { + // Bells aren't observed while off; don't replay them later. + self.post_state.reset_bell_tracking(); + } + } + + /// True while a post-process chain is installed. + #[must_use] + pub fn has_post_shaders(&self) -> bool { + self.post.is_some() + } + + /// Set when post shaders get time-driven frames (`[shader] + /// animation`). + pub fn set_post_animation(&mut self, mode: PostAnimation) { + if mode != self.post_animation { + self.post_animation = mode; + self.post_state.request_frame(); + } + } + + /// Window focus, for `iFocus` / `iTimeFocus` and the `auto` animation + /// rule (time-reading shaders idle while unfocused). After a change + /// that a shader reads, [`Self::next_redraw_deadline`] returns zero. + pub fn set_focused(&mut self, focused: bool) { + let inputs = self.post.as_ref().map(|c| *c.inputs()).unwrap_or_default(); + self.post_state + .set_focused(focused, Instant::now(), &inputs, self.post_animation); + } + + /// Pointer position in physical px (surface space) and button-1 state, + /// for `iMouse`. After a change that a shader reads, + /// [`Self::next_redraw_deadline`] returns zero. + pub fn set_pointer(&mut self, pos: (f32, f32), button1_down: bool) { + let inputs = self.post.as_ref().map(|c| *c.inputs()).unwrap_or_default(); + self.post_state.set_pointer(pos, button1_down, &inputs); + } + /// Current theme. pub fn theme(&self) -> Theme { self.base_theme @@ -2139,7 +2642,7 @@ impl Renderer { pub fn resize(&mut self, width: u32, height: u32) { self.config.width = width.max(1); self.config.height = height.max(1); - self.surface.configure(&self.device, &self.config); + self.configure_target(); // Scratch must match the swapchain dims; recreate. Its contents // become undefined → force a full clear on the next frame. let (tex, view) = create_scratch( @@ -2147,9 +2650,13 @@ impl Renderer { self.config.width, self.config.height, self.config.format, + self.post.is_some(), ); self.scratch_texture = tex; self.scratch_view = view; + if let Some(chain) = self.post.as_mut() { + chain.bind_source(&self.device, &self.scratch_texture); + } // Depth attachment shares dims with the color scratch; recreate // it for the same reason. let (dtex, dview) = @@ -2347,6 +2854,22 @@ impl Renderer { let chip_alpha = self.chip_fade.value(now); let chip_changed = self.chip_fade.animating(now) || (chip_alpha > 0.0) != self.chip_drawn_last; + // Post shaders: note a new BEL, then ask whether the chain wants a + // frame (a time-driven tick, or an event it reads). + let post_due = if let Some(chain) = self.post.as_ref() { + let cell = self.cell_size(); + let origin = self.content_origin(); + self.post_state.observe_bell( + term.bell_revision(), + || bell_cell_rect(term, cell, origin), + now, + chain.inputs(), + ); + self.post_state + .frame_due(now, self.post_animation, chain.inputs()) + } else { + false + }; if term.damage().is_empty() && !cursor_animation_due && !text_blink_due @@ -2359,6 +2882,11 @@ impl Renderer { && self.close_prompt.is_none() && self.preedit.is_none() { + // Nothing new in the content: at most re-run the post passes + // (and the UI on top) over the scratch texture as it is. + if post_due { + return self.render_post_only(term, now); + } return Ok(RenderOutcome::Skipped); } @@ -2369,7 +2897,9 @@ impl Renderer { self.needs_full_clear = true; term.mark_all_dirty(); } - if self.rich_chip.is_some() { + // (With post shaders the chips and the dialog are drawn in the UI + // pass, over a fresh post output, so none of this applies.) + if self.rich_chip.is_some() && self.post.is_none() { self.needs_full_clear = true; term.mark_all_dirty(); } @@ -2390,7 +2920,7 @@ impl Renderer { // The chip is translucent: compositing it over a LoadOp::Load // partial frame would stack on last frame's chip. While it is // (or just was) on screen, repaint everything underneath. - if chip_alpha > 0.0 || self.chip_drawn_last { + if (chip_alpha > 0.0 || self.chip_drawn_last) && self.post.is_none() { self.needs_full_clear = true; term.mark_all_dirty(); } @@ -2399,7 +2929,7 @@ impl Renderer { // The close dialog's scrim is translucent and covers the whole // window: repaint everything under it each frame it's up (its // removal already forces a full clear in `set_close_prompt`). - if self.close_prompt.is_some() { + if self.close_prompt.is_some() && self.post.is_none() { self.needs_full_clear = true; term.mark_all_dirty(); } @@ -2680,6 +3210,24 @@ impl Renderer { tracing::info!(target: "render_trace", "build_instances n={} took={ms:.3}ms", instances.len()); } + // Without post shaders the overlays below go into the same draws as + // the cells, in this order. With them, everything from `ui_start` + // on is UI, drawn after the post passes and never shaded; the IME + // preedit is content, so it moves ahead of the overlays. + let post_on = self.post.is_some(); + if post_on && let Some(preedit) = self.preedit.as_deref() { + Self::push_preedit( + &self.queue, + text, + &mut instances, + preedit, + term, + cell_size, + &theme, + ); + } + let ui_start = instances.len(); + // Debug overlay (TOASTTY_DEBUG FPS counter, etc). Shape the // string fresh each frame and append cell instances aligned to // the top-right corner of the viewport. The per-char glyph cache @@ -2771,126 +3319,16 @@ impl Renderer { ); } - // IME preedit overlay. The in-progress composition string is - // drawn inline starting at the terminal cursor cell, advancing - // cell-by-cell to the right and overlaying whatever grid content - // sits underneath. Rendered underlined (and with the theme's - // cursor color as fg) so it reads as distinct, half-formed text - // until the IME commits it into the grid (or clears it). - // - // We reuse the same cursor-cell anchor the cursor block uses: - // `cursor_pixel_rect` clamps `term.cursor()` into the grid and - // applies the sub-pixel scroll y-translate. We take its left edge - // (x0) for the start column; the cell-top y is recomputed from the - // same clamped row so the underline shapes/underlines align with - // the grid regardless of cursor shape (Underline shifts the rect's - // own y down). - if let Some(preedit) = self.preedit.as_deref() { - let (rows, cols) = term.size(); - let cell_w = cell_size.0; - let cell_h = cell_size.1; - let cursor_rect = crate::text::instance::cursor_pixel_rect(term, cell_size); - // Cell-top y for the cursor row, mirroring cursor_pixel_rect's - // cell_pos: clamp the cursor row into the grid and apply the - // same sub-pixel scroll y-translate. - let view_pixel = term.view_offset_pixel(); - let y_translate = if view_pixel > 0.0 { - view_pixel - cell_h - } else { - 0.0 - }; - let cur_row = u16::min(term.cursor().row, rows.saturating_sub(1)); - let y0 = f32::from(cur_row) * cell_h + y_translate; - // Start column from the cursor rect's left edge (already - // clamped + cell-aligned by cursor_pixel_rect). - let start_x = cursor_rect[0]; - #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] - let start_col = (start_x / cell_w).round() as u16; - - let lg = text - .rasterizer - .shape_line(&self.queue, preedit, term.grapheme_cluster_mode()); - let mode_2027 = term.grapheme_cluster_mode(); - let preedit_fg = theme.cursor; - // Walk the composition string char-by-char, advancing the - // overlay column by each char's display width so wide CJK - // glyphs occupy two cells (matching the shaper's column - // assignment, which leaves the continuation column empty). - let mut col: u16 = start_col; - let mut buf = [0u8; 4]; - for ch in preedit.chars() { - let ch_str = ch.encode_utf8(&mut buf); - let width = u16::from(cluster_cell_width(ch_str, mode_2027)); - // Clip at the right edge: stop emitting once the glyph - // (or its trailing continuation cell) would run past the - // last column. Don't wrap. - if col >= cols || col + width > cols { - break; - } - let pos = [f32::from(col) * cell_w, y0]; - let span = [f32::from(width) * cell_w, cell_h]; - // Cover the grid cell underneath so the half-formed glyph - // reads cleanly, not mixed with whatever was already - // there. Two covers, mirroring the error banner: a bg-pass - // quad (FLAG_NO_GLYPH) repaints the cell bg, and a - // glyph-pass cover (FLAG_UNDERLINE | FLAG_NO_GLYPH emits a - // solid bg) overpaints the term's own glyph for this cell - // — which was already emitted earlier under LoadOp::Load. - instances.push(CellInstance { - pos, - size: span, - uv_min: [0.0, 0.0], - uv_max: [0.0, 0.0], - fg: preedit_fg, - bg: theme.bg, - flags: crate::text::instance::FLAG_NO_GLYPH, - pad: [0; 3], - }); - instances.push(CellInstance { - pos, - size: span, - uv_min: [0.0, 0.0], - uv_max: [0.0, 0.0], - fg: preedit_fg, - bg: theme.bg, - flags: crate::text::instance::FLAG_UNDERLINE - | crate::text::instance::FLAG_NO_GLYPH, - pad: [0; 3], - }); - // Underline strip spanning the char's full cell width, - // reusing the same machinery a normal underlined cell - // emits (FLAG_UNDERLINE, fg-as-bg). This is the preedit's - // visual distinction. - instances.push(crate::text::instance::underline_instance( - pos, span, preedit_fg, - )); - // The glyph, looked up at this column. `lg` was shaped from - // the preedit string alone, so its columns are 0-based - // relative to the composition start — subtract `start_col` - // to map this absolute grid column back into that space. - // (Continuation cells for wide chars are left empty by the - // shaper, same as the grid.) - if !ch.is_whitespace() - && let Some(slot) = lg.get(col - start_col, ch) - { - let flags = if slot.is_color { - crate::text::instance::FLAG_COLOR_GLYPH - } else { - 0 - }; - instances.push(CellInstance { - pos: [pos[0] + slot.glyph_offset[0], pos[1] + slot.glyph_offset[1]], - size: slot.glyph_size, - uv_min: slot.uv_min, - uv_max: slot.uv_max, - fg: preedit_fg, - bg: theme.bg, - flags, - pad: [0; 3], - }); - } - col += width; - } + if !post_on && let Some(preedit) = self.preedit.as_deref() { + Self::push_preedit( + &self.queue, + text, + &mut instances, + preedit, + term, + cell_size, + &theme, + ); } // Close dialog text, last so it can be drawn on its own after the @@ -3044,34 +3482,10 @@ impl Renderer { } else { None }; - let frame = match self.surface.get_current_texture() { - wgpu::CurrentSurfaceTexture::Success(t) - | wgpu::CurrentSurfaceTexture::Suboptimal(t) => t, - wgpu::CurrentSurfaceTexture::Outdated => { - self.surface.configure(&self.device, &self.config); - // Followup C2: the reconfigured back-buffer has - // undefined contents (same invariant as `resize`). - // Without flagging a full clear here, the next frame - // may pick `LoadOp::Load` and read garbage. The cost - // is one extra clear on recovery — cheap. - self.needs_full_clear = true; - // No frame went out — caller must not clear damage / BSU - // force-flushed flag, so report Skipped. - return Ok(RenderOutcome::Skipped); - } - wgpu::CurrentSurfaceTexture::Lost | wgpu::CurrentSurfaceTexture::Validation => { - return Err(RenderError::SurfaceLost); - } - wgpu::CurrentSurfaceTexture::Timeout | wgpu::CurrentSurfaceTexture::Occluded => { - // Followup C2: defensively force the next frame to - // clear. Timeout means the driver missed its - // deadline; Occluded means the window isn't visible. - // In both cases the back-buffer's prior contents may - // be stale — flagging a full clear avoids any chance - // of a LoadOp::Load reading garbage on recovery. - self.needs_full_clear = true; - return Ok(RenderOutcome::Skipped); - } + let Some(frame) = self.acquire_frame(now)? else { + // No frame went out — caller must not clear damage / BSU + // force-flushed flag, so report Skipped. + return Ok(RenderOutcome::Skipped); }; if let Some(t) = t_acq { let ms = t.elapsed().as_secs_f64() * 1000.0; @@ -3169,11 +3583,14 @@ impl Renderer { // rotation isn't a problem. Partial-redraw frames still target // scratch + blit because LoadOp::Load needs the stable // previous-frame pixels scratch provides. - let render_direct = self.needs_full_clear; + // + // With post shaders the content always goes to scratch: the post + // passes sample it. + let render_direct = self.needs_full_clear && !post_on; let frame_view = if render_direct { Some( frame - .texture + .texture() .create_view(&wgpu::TextureViewDescriptor::default()), ) } else { @@ -3261,8 +3678,13 @@ impl Renderer { } let text_ref = self.text.as_ref().expect("text init checked above"); // The close dialog's glyphs (if any) sit at the end and are - // drawn after its panel, below. - let inst_count = dialog_glyphs_start; + // drawn after its panel, below. With post shaders, the UI + // instances (from `ui_start`) are drawn in the UI pass instead. + let inst_count = if post_on { + ui_start + } else { + dialog_glyphs_start + }; text_ref .pipeline .render_bg(&mut rp, &text_ref.bind_group, inst_count); @@ -3326,7 +3748,8 @@ impl Renderer { } // 6. scroll-to-bottom chip, on top of everything. - if chip_alpha > 0.0 + if !post_on + && chip_alpha > 0.0 && let Some(chip_pipe) = self.chip_pipeline.as_ref() && let Some(geom) = self.scroll_chip_geometry() { @@ -3336,7 +3759,8 @@ impl Renderer { } // 7. rich block "Copy Markdown" chip. - if self.close_prompt.is_none() + if !post_on + && self.close_prompt.is_none() && let Some(chip_pipe) = self.rich_chip_pipeline.as_ref() && let Some(geom) = self.rich_chip_geometry(term) { @@ -3348,7 +3772,8 @@ impl Renderer { // 8. close dialog, topmost: scrim, shadow, panel, buttons and // icon, then its text on the panel. - if let Some((layout, colors)) = close_dialog.as_ref() + if !post_on + && let Some((layout, colors)) = close_dialog.as_ref() && let Some(ui_pipe) = self.ui_pipeline.as_mut() { let rects = dialog::dialog_rects(layout, colors); @@ -3361,10 +3786,23 @@ impl Renderer { } } - // Blit scratch → swapchain only on partial-redraw frames; the - // direct path rendered straight into the back-buffer and - // doesn't need (and would clobber) the copy. - if !render_direct { + if post_on { + // Post passes (scratch → swapchain), then the UI on top. + self.encode_post(&mut encoder, frame.texture(), term, cursor_visible, now); + self.encode_ui_pass( + &mut encoder, + frame.texture(), + term, + chip_alpha, + ui_start..dialog_glyphs_start, + close_dialog + .as_ref() + .map(|d| (d, dialog_glyphs_start..instances.len())), + ); + } else if !render_direct { + // Blit scratch → swapchain only on partial-redraw frames; the + // direct path rendered straight into the back-buffer and + // doesn't need (and would clobber) the copy. encoder.copy_texture_to_texture( wgpu::TexelCopyTextureInfo { texture: &self.scratch_texture, @@ -3373,7 +3811,7 @@ impl Renderer { aspect: wgpu::TextureAspect::All, }, wgpu::TexelCopyTextureInfo { - texture: &frame.texture, + texture: frame.texture(), mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All, @@ -3397,6 +3835,9 @@ impl Renderer { }; self.queue.submit(std::iter::once(encoder.finish())); frame.present(); + if post_on { + self.post_state.end_frame(now); + } if let Some(t) = t_sub { let ms = t.elapsed().as_secs_f64() * 1000.0; tracing::info!(target: "render_trace", "submit+present took={ms:.3}ms"); @@ -3423,14 +3864,214 @@ impl Renderer { Ok(RenderOutcome::Rendered) } + /// A frame where the content hasn't changed but the post chain wants + /// one (an animation tick or an input event it reads): re-run the post + /// passes over the scratch texture as it is, then the UI pass. Only the + /// chips can be up here; every other overlay forces a full frame. + fn render_post_only( + &mut self, + term: &Term, + now: Instant, + ) -> Result { + let Some(frame) = self.acquire_frame(now)? else { + return Ok(RenderOutcome::Skipped); + }; + let mut encoder = self + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("toastty-render encoder (post only)"), + }); + let cursor_visible = + self.blink.visible && term.cursor_visible() && !term.is_view_scrolled_back(); + self.encode_post(&mut encoder, frame.texture(), term, cursor_visible, now); + let chip_alpha = self.chip_fade.value(now); + self.encode_ui_pass(&mut encoder, frame.texture(), term, chip_alpha, 0..0, None); + self.queue.submit(std::iter::once(encoder.finish())); + frame.present(); + self.post_state.end_frame(now); + Ok(RenderOutcome::Rendered) + } + + /// Upload this frame's post uniforms and record the post passes, from + /// the scratch texture into `frame`. + fn encode_post( + &mut self, + encoder: &mut wgpu::CommandEncoder, + frame: &wgpu::Texture, + term: &Term, + cursor_visible: bool, + now: Instant, + ) { + let Some(chain) = self.post.as_ref() else { + return; + }; + let cell = self.cell_size(); + let origin = self.content_origin(); + let theme = self.theme; + // `iPalette` is what cells are drawn with: the theme's ANSI 16, + // then the term's (OSC 4) 256-colour table. + let key = (term.palette_revision(), theme.palette); + let palette_changed = self.post_palette_key != Some(key); + if palette_changed { + for (i, dst) in self.post_palette.iter_mut().enumerate() { + #[allow(clippy::cast_possible_truncation)] + let c = theme.resolve_indexed256(i as u8, Some(&self.ext_palette)); + *dst = srgb3(c); + } + self.post_palette_key = Some(key); + } + let mut f = post_frame_inputs( + term, + &theme, + cell, + origin, + (self.config.width, self.config.height), + cursor_visible, + ); + f.palette = palette_changed.then_some(&*self.post_palette); + let needs_date = chain.inputs().reads(post::date_mask()); + let u = self.post_state.begin_frame(now, &f, needs_date); + chain.write_uniforms(&self.queue, u, palette_changed); + chain.encode(encoder, frame); + } + + /// The UI pass of a post-processed frame: overlays drawn unshaded on + /// the swapchain image after the post passes. `overlay` is the debug + /// overlay / error banner instance range; `dialog` the close dialog + /// and the instance range of its glyphs. + fn encode_ui_pass( + &mut self, + encoder: &mut wgpu::CommandEncoder, + frame: &wgpu::Texture, + term: &Term, + chip_alpha: f32, + overlay: std::ops::Range, + dialog: Option<( + &(dialog::DialogLayout, dialog::DialogColors), + std::ops::Range, + )>, + ) { + let view = frame.create_view(&wgpu::TextureViewDescriptor::default()); + #[allow(clippy::cast_precision_loss)] // viewport in 16k-px range. + let viewport = (self.config.width as f32, self.config.height as f32); + let scroll_chip = if chip_alpha > 0.0 { + self.scroll_chip_geometry() + } else { + None + }; + let rich_chip = if self.close_prompt.is_none() { + self.rich_chip_geometry(term) + } else { + None + }; + let mut rp = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("toastty-render UI pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &view, + depth_slice: None, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }, + })], + // The overlay pipelines share the content pass's depth format, + // and depth-test against what it left, as they did there. + depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { + view: &self.scratch_depth_view, + depth_ops: Some(wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }), + stencil_ops: None, + }), + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }); + if let Some(text) = self.text.as_ref() { + text.pipeline + .render_bg_range(&mut rp, &text.bind_group, overlay.clone()); + text.pipeline + .render_glyph_range(&mut rp, &text.bind_group, overlay); + } + if let (Some(chip_pipe), Some(geom)) = (self.chip_pipeline.as_ref(), scroll_chip) { + let colors = chip::chip_colors(self.theme.bg, self.theme.fg, self.chip_hover); + let uniform = ChipUniform::new(viewport, &geom, self.ui_scale, chip_alpha, &colors); + chip_pipe.render(&self.queue, &mut rp, &uniform); + } + if let (Some(chip_pipe), Some(geom)) = (self.rich_chip_pipeline.as_ref(), rich_chip) { + let colors = chip::chip_colors(self.theme.bg, self.theme.fg, self.rich_chip_hover); + let uniform = ChipUniform::new(viewport, &geom, self.ui_scale, 1.0, &colors) + .with_icon(chip::pipeline::ICON_COPY); + chip_pipe.render(&self.queue, &mut rp, &uniform); + } + if let Some(((layout, colors), glyphs)) = dialog + && let Some(ui_pipe) = self.ui_pipeline.as_mut() + { + let rects = dialog::dialog_rects(layout, colors); + ui_pipe.render(&self.device, &self.queue, &mut rp, &rects, viewport); + if let Some(text) = self.text.as_ref() { + text.pipeline + .render_glyph_range(&mut rp, &text.bind_group, glyphs); + } + } + } + + /// Acquire the swapchain frame for `render_term`. `Ok(None)` when no + /// frame can be drawn now (the caller reports `Skipped`). + /// + /// Without post shaders a failed acquire forces the next frame to be + /// a full clear. With them it doesn't: the scratch texture wasn't + /// touched, and the post passes rewrite the whole swapchain image + /// anyway. The post schedule counts the frame as dropped instead + /// (slowing time-driven frames while occluded). + fn acquire_frame(&mut self, now: Instant) -> Result, RenderError> { + #[cfg(test)] + if let Target::Offscreen(t) = &self.target { + return Ok(Some(Frame::Offscreen(t.clone()))); + } + let Some(surface) = self.surface() else { + return Ok(None); + }; + let occluded = match surface.get_current_texture() { + wgpu::CurrentSurfaceTexture::Success(t) + | wgpu::CurrentSurfaceTexture::Suboptimal(t) => return Ok(Some(Frame::Surface(t))), + wgpu::CurrentSurfaceTexture::Lost | wgpu::CurrentSurfaceTexture::Validation => { + return Err(RenderError::SurfaceLost); + } + wgpu::CurrentSurfaceTexture::Outdated => { + self.configure_target(); + false + } + wgpu::CurrentSurfaceTexture::Timeout => false, + wgpu::CurrentSurfaceTexture::Occluded => true, + }; + if self.post.is_some() { + self.post_state.frame_dropped(now, occluded); + } else { + // Followup C2: after Outdated the reconfigured back-buffer has + // undefined contents (same invariant as `resize`); Timeout + // means the driver missed its deadline and Occluded that the + // window isn't visible. In all three the back-buffer's prior + // contents may be stale, so the next frame must not pick + // `LoadOp::Load`. The cost is one extra clear on recovery. + self.needs_full_clear = true; + } + Ok(None) + } + /// Render one frame. M4a: just a clear-color pass. pub fn render(&mut self) -> Result<(), RenderError> { - let frame = match self.surface.get_current_texture() { + let Some(surface) = self.surface() else { + return Ok(()); + }; + let frame = match surface.get_current_texture() { wgpu::CurrentSurfaceTexture::Success(t) | wgpu::CurrentSurfaceTexture::Suboptimal(t) => t, wgpu::CurrentSurfaceTexture::Outdated => { // Surface size mismatch — reconfigure and try next frame. - self.surface.configure(&self.device, &self.config); + surface.configure(&self.device, &self.config); return Ok(()); } wgpu::CurrentSurfaceTexture::Lost | wgpu::CurrentSurfaceTexture::Validation => { diff --git a/crates/toastty-render/src/post/analysis.rs b/crates/toastty-render/src/post/analysis.rs new file mode 100644 index 00000000..bf684dab --- /dev/null +++ b/crates/toastty-render/src/post/analysis.rs @@ -0,0 +1,142 @@ +//! What a compiled post shader reads, found by walking its naga module. +//! +//! The animation policy depends on it: a shader that never reads a time +//! uniform can't change between events, so it needs no time-driven +//! frames, and a shader that ignores `iMouse` needs no frame when the +//! pointer moves. + +use naga::{Block, Expression, Function, Handle, Literal, Module, Statement}; + +/// Name of the optional event-duration constant (see the module docs of +/// [`crate::post`]). +pub(crate) const EVENT_DURATION_NAME: &str = "TOASTTY_EVENT_DURATION"; + +/// Bit mask (over [`super::uniforms::MEMBERS`] indexes) of the uniform +/// block members read by `entry` and every function it calls. +/// +/// A member counts as read when an `AccessIndex` on the block global +/// selects it. Any other use of the block global (copying the whole +/// struct, passing it by pointer) counts as reading every member. +/// Expressions in unreachable branches still count: the result errs on +/// the side of rendering. +pub(crate) fn uniforms_read( + module: &Module, + block: Handle, + entry: &str, +) -> u64 { + let Some(ep) = module.entry_points.iter().find(|ep| ep.name == entry) else { + return u64::MAX; + }; + let mut used = 0u64; + let mut visited = vec![false; module.functions.len()]; + let mut stack: Vec<&Function> = vec![&ep.function]; + let mut calls = Vec::new(); + while let Some(func) = stack.pop() { + used |= members_read_in(func, block); + calls.clear(); + collect_calls(&func.body, &mut calls); + for &(callee, whole) in &calls { + if whole_block_arg(func, block, whole) { + return u64::MAX; + } + let seen = &mut visited[callee.index()]; + if !*seen { + *seen = true; + stack.push(&module.functions[callee]); + } + } + } + used +} + +/// [`uniforms_read`] for one function body, ignoring its callees. +fn members_read_in(func: &Function, block: Handle) -> u64 { + let mut used = 0u64; + for (_, expr) in func.expressions.iter() { + match *expr { + Expression::AccessIndex { base, index } if is_block(func, block, base) => { + used |= 1u64.checked_shl(index).unwrap_or(0); + } + // The whole struct copied out. + Expression::Load { pointer } if is_block(func, block, pointer) => return u64::MAX, + _ => {} + } + } + used +} + +fn is_block(func: &Function, block: Handle, h: Handle) -> bool { + matches!(func.expressions[h], Expression::GlobalVariable(g) if g == block) +} + +/// True when one of a call's arguments is the block global itself (a +/// pointer to the whole struct). +fn whole_block_arg( + func: &Function, + block: Handle, + args: &[Handle], +) -> bool { + args.iter().any(|&a| is_block(func, block, a)) +} + +/// Every `Call` in `block` and its nested blocks: the callee and the +/// call's arguments. +fn collect_calls<'a>( + block: &'a Block, + out: &mut Vec<(Handle, &'a [Handle])>, +) { + for stmt in block { + match stmt { + Statement::Call { + function, + arguments, + .. + } => out.push((*function, arguments)), + Statement::Block(b) => collect_calls(b, out), + Statement::If { accept, reject, .. } => { + collect_calls(accept, out); + collect_calls(reject, out); + } + Statement::Switch { cases, .. } => { + for case in cases { + collect_calls(&case.body, out); + } + } + Statement::Loop { + body, continuing, .. + } => { + collect_calls(body, out); + collect_calls(continuing, out); + } + _ => {} + } + } +} + +/// Longest `TOASTTY_EVENT_DURATION` honoured (a day); larger values are +/// clamped so the schedule's `Instant` arithmetic can't overflow. +pub(crate) const MAX_EVENT_DURATION: f32 = 86_400.0; + +/// The value of a module-scope `TOASTTY_EVENT_DURATION` constant, if the +/// shader declares one with a positive number (clamped to +/// [`MAX_EVENT_DURATION`]; infinity clamps too, NaN is ignored). +pub(crate) fn event_duration(module: &Module) -> Option { + let (_, constant) = module + .constants + .iter() + .find(|(_, c)| c.name.as_deref() == Some(EVENT_DURATION_NAME))?; + #[allow(clippy::cast_precision_loss)] + let value = match module.global_expressions[constant.init] { + Expression::Literal(lit) => match lit { + Literal::F32(v) => v, + Literal::F64(v) | Literal::AbstractFloat(v) => v as f32, + Literal::I32(v) => v as f32, + Literal::U32(v) => v as f32, + Literal::I64(v) | Literal::AbstractInt(v) => v as f32, + Literal::U64(v) => v as f32, + _ => return None, + }, + _ => return None, + }; + (value > 0.0).then(|| value.min(MAX_EVENT_DURATION)) +} diff --git a/crates/toastty-render/src/post/compile.rs b/crates/toastty-render/src/post/compile.rs new file mode 100644 index 00000000..ecbc2d6f --- /dev/null +++ b/crates/toastty-render/src/post/compile.rs @@ -0,0 +1,890 @@ +//! User shader source → validated WGSL plus what it reads. +//! +//! GPU-free, so the prelude, the error mapping and the analysis are unit +//! tested without a device. The GPU side ([`super::PostChain`]) takes the +//! WGSL from here. +//! +//! GLSL gets a prelude before the user's file (`#version` and the +//! declarations must come first), so a line in the wrapped source maps +//! back to the user's file by subtracting the prelude's line count, and +//! an epilogue after it (its `main()`, which must follow `mainImage`: +//! naga wants callees declared before their callers, so a forward +//! prototype in the prelude doesn't work once `mainImage` calls helpers). +//! WGSL gets everything after the user's file: module-scope order doesn't +//! matter there, and directives (`enable`, `requires`, `diagnostic`) must +//! come before any declaration. Errors outside the user's file have no +//! user line. + +use std::fmt::{self, Write as _}; +use std::path::{Path, PathBuf}; + +use naga::valid::{Capabilities, ValidationFlags, Validator}; +use naga::{Module, ShaderStage, SourceLocation, Span}; + +use super::analysis; +use super::uniforms::MEMBERS; + +/// A post-process shader to compile: its path (picks the language by +/// extension and names it in errors) and its source text. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PostShaderSource { + /// Where the shader came from. `.glsl` / `.frag` are GLSL, `.wgsl` + /// is WGSL. + pub path: PathBuf, + /// The file's contents. + pub source: String, +} + +/// A shader that failed to compile. Locations are in the user's file +/// (1-based), not in the prelude-wrapped source. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ShaderError { + /// The shader's path, as given in [`PostShaderSource::path`]. + pub path: PathBuf, + /// Line in the user's file, when the error points into it. + pub line: Option, + /// Column (1-based, in bytes) on `line`. + pub column: Option, + /// What went wrong. + pub message: String, +} + +impl fmt::Display for ShaderError { + /// `path:line:col: message`, dropping the parts that are unknown. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.path.display())?; + if let Some(line) = self.line { + write!(f, ":{line}")?; + if let Some(col) = self.column { + write!(f, ":{col}")?; + } + } + write!(f, ": {}", self.message) + } +} + +impl std::error::Error for ShaderError {} + +/// Shader language, from the file extension. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Language { + Glsl, + Wgsl, +} + +impl Language { + pub(crate) fn from_path(path: &Path) -> Option { + let ext = path.extension()?.to_str()?.to_ascii_lowercase(); + match ext.as_str() { + "glsl" | "frag" => Some(Self::Glsl), + "wgsl" => Some(Self::Wgsl), + _ => None, + } + } +} + +/// What a compiled shader reads, for the animation policy. +#[derive(Debug, Clone, Copy, Default, PartialEq)] +pub(crate) struct ShaderInputs { + /// Bit mask over [`MEMBERS`] of the uniforms the shader reads. + pub used: u64, + /// `TOASTTY_EVENT_DURATION`, in seconds. + pub event_duration: Option, +} + +impl ShaderInputs { + /// True when the shader reads any of the members in `mask`. + pub(crate) fn reads(&self, mask: u64) -> bool { + self.used & mask != 0 + } + + /// The union of two shaders' needs (a chain needs what any of its + /// shaders needs; the longest duration wins). + pub(crate) fn union(self, other: Self) -> Self { + Self { + used: self.used | other.used, + event_duration: match (self.event_duration, other.event_duration) { + (Some(a), Some(b)) => Some(a.max(b)), + (a, b) => a.or(b), + }, + } + } +} + +/// A shader ready for `wgpu`: WGSL source with a fragment entry point. +#[derive(Debug, Clone)] +pub(crate) struct CompiledShader { + pub path: PathBuf, + pub wgsl: String, + pub entry_point: String, + pub inputs: ShaderInputs, +} + +/// The user's source between the prelude and the epilogue. +#[derive(Debug)] +pub(crate) struct Wrapped { + pub text: String, + /// Lines before the user's first line. + pub prelude_lines: u32, + /// Lines in the user's file. + pub user_lines: u32, + /// Byte offset where the epilogue starts. + pub epilogue_at: usize, +} + +impl Wrapped { + fn new(prelude: &str, user: &str, epilogue: &str) -> Self { + debug_assert!(prelude.is_empty() || prelude.ends_with('\n')); + let prelude_lines = count_lines(prelude); + let mut text = String::with_capacity(prelude.len() + user.len() + epilogue.len() + 1); + text.push_str(prelude); + text.push_str(user); + let mut user_lines = count_lines(user); + if !user.ends_with('\n') { + text.push('\n'); + user_lines += 1; + } + let epilogue_at = text.len(); + text.push_str(epilogue); + Self { + text, + prelude_lines, + user_lines, + epilogue_at, + } + } + + /// Map a location in the wrapped source to `(line, column)` in the + /// user's file. `None` when it falls in the prelude. + pub(crate) fn user_location(&self, loc: SourceLocation) -> Option<(u32, u32)> { + let line = loc.line_number.checked_sub(self.prelude_lines)?; + (1..=self.user_lines) + .contains(&line) + .then_some((line, loc.line_position)) + } + + /// True when `span` starts in the epilogue (after the user's file). + fn in_epilogue(&self, span: Span) -> bool { + span.to_range().is_some_and(|r| r.start >= self.epilogue_at) + } + + fn span_location(&self, span: Span) -> Option<(u32, u32)> { + if !span.is_defined() || span.to_range()?.end > self.text.len() { + return None; + } + self.user_location(span.location(&self.text)) + } +} + +fn count_lines(s: &str) -> u32 { + u32::try_from(s.bytes().filter(|&b| b == b'\n').count()).unwrap_or(u32::MAX) +} + +/// The GLSL prelude: Shadertoy / Ghostty globals as an unnamed uniform +/// block (so `iTime` names the member directly and nothing is copied per +/// fragment) and `iChannel0` as a texture + sampler pair. +pub(crate) fn glsl_prelude() -> String { + let mut s = String::from("#version 450 core\n"); + s.push_str("layout(set = 0, binding = 0, std140) uniform ToasttyUniforms {\n"); + for m in MEMBERS { + let _ = writeln!(s, " {} {}{};", m.glsl, m.name, m.glsl_array); + } + s.push_str("};\n"); + s.push_str( + "layout(set = 0, binding = 1) uniform texture2D iChannel0_tex;\n\ + layout(set = 0, binding = 2) uniform sampler iChannel0_smp;\n\ + #define iChannel0 sampler2D(iChannel0_tex, iChannel0_smp)\n\ + #define texture2D texture\n\ + #define CURSORSTYLE_BLOCK 0\n\ + #define CURSORSTYLE_BLOCK_HOLLOW 1\n\ + #define CURSORSTYLE_BAR 2\n\ + #define CURSORSTYLE_UNDERLINE 3\n\ + #define CURSORSTYLE_LOCK 4\n\ + layout(location = 0) out vec4 toastty_frag_color;\n", + ); + s +} + +/// The GLSL epilogue: `main()` hands `gl_FragCoord.xy` (y down, pixel +/// centres at .5) to `mainImage` unchanged. +const GLSL_EPILOGUE: &str = "void main() {\n\ + \x20 vec4 color = vec4(0.0);\n\ + \x20 mainImage(color, gl_FragCoord.xy);\n\ + \x20 toastty_frag_color = color;\n\ + }\n"; + +/// The WGSL prelude (appended after the user's file): the same block as +/// `var toastty`, the `iChannel0` texture and sampler, the +/// cursor-style constants and the fragment entry point. +pub(crate) fn wgsl_prelude() -> String { + let mut s = String::from("struct ToasttyUniforms {\n"); + for m in MEMBERS { + let _ = writeln!(s, " {}: {},", m.name, m.wgsl); + } + s.push_str("};\n"); + s.push_str( + "@group(0) @binding(0) var toastty: ToasttyUniforms;\n\ + @group(0) @binding(1) var iChannel0: texture_2d;\n\ + @group(0) @binding(2) var iChannel0Sampler: sampler;\n\ + const CURSORSTYLE_BLOCK: i32 = 0;\n\ + const CURSORSTYLE_BLOCK_HOLLOW: i32 = 1;\n\ + const CURSORSTYLE_BAR: i32 = 2;\n\ + const CURSORSTYLE_UNDERLINE: i32 = 3;\n\ + const CURSORSTYLE_LOCK: i32 = 4;\n\ + @fragment\n\ + fn toastty_fs_main(@builtin(position) frag_coord: vec4f) -> @location(0) vec4f {\n\ + \x20 return mainImage(frag_coord.xy);\n\ + }\n", + ); + s +} + +/// WGSL entry point name the prelude declares. +const WGSL_ENTRY: &str = "toastty_fs_main"; + +/// Blank every `#version` line (the prelude has its own), keeping the +/// line itself so line numbers stay put. +pub(crate) fn blank_version_lines(src: &str) -> String { + let mut out = String::with_capacity(src.len()); + for line in src.split_inclusive('\n') { + if line.trim_start().starts_with("#version") { + // Keep the line break (`\n` or `\r\n`). + let body = line.trim_end_matches(['\r', '\n']); + out.push_str(&line[body.len()..]); + } else { + out.push_str(line); + } + } + out +} + +/// Wrap a GLSL user file. +pub(crate) fn wrap_glsl(user: &str) -> Wrapped { + Wrapped::new(&glsl_prelude(), &blank_version_lines(user), GLSL_EPILOGUE) +} + +/// Wrap a WGSL user file. +pub(crate) fn wrap_wgsl(user: &str) -> Wrapped { + Wrapped::new("", user, &wgsl_prelude()) +} + +/// Compile one shader: parse, validate, analyse, and (for GLSL) emit +/// WGSL. Every error found is returned. +pub(crate) fn compile(src: &PostShaderSource) -> Result> { + let err = |line: Option<(u32, u32)>, message: String| ShaderError { + path: src.path.clone(), + line: line.map(|l| l.0), + column: line.map(|l| l.1), + message, + }; + let Some(lang) = Language::from_path(&src.path) else { + return Err(vec![err( + None, + "unknown shader type: use a .glsl, .frag or .wgsl file".to_owned(), + )]); + }; + let wrapped = match lang { + Language::Glsl => wrap_glsl(&src.source), + Language::Wgsl => wrap_wgsl(&src.source), + }; + let module = match lang { + Language::Glsl => { + let mut frontend = naga::front::glsl::Frontend::default(); + frontend + .parse( + &naga::front::glsl::Options::from(ShaderStage::Fragment), + &wrapped.text, + ) + .map_err(|errors| { + errors + .errors + .iter() + .map(|e| { + let loc = wrapped.span_location(e.meta); + let message = e.kind.to_string(); + // The epilogue's `main()` can't call `mainImage`. + let message = if loc.is_none() && message.contains("mainImage") { + GLSL_SIGNATURE.to_owned() + } else { + hint(&message) + }; + err(loc, message) + }) + .collect::>() + })? + } + Language::Wgsl => naga::front::wgsl::parse_str(&wrapped.text).map_err(|e| { + let (loc, message) = wgsl_parse_error(&e, &wrapped); + vec![err(loc, message)] + })?, + }; + // A file ending inside `/*` comments the whole prelude out, which can + // still parse. + if lang == Language::Wgsl && !module.entry_points.iter().any(|ep| ep.name == WGSL_ENTRY) { + return Err(vec![err( + Some((wrapped.user_lines, 1)), + UNCLOSED.to_owned(), + )]); + } + let info = Validator::new(ValidationFlags::all(), Capabilities::default()) + .validate(&module) + .map_err(|e| { + let loc = pick_location(&wrapped, e.spans().map(|(span, _)| span)); + let message = if lang == Language::Wgsl && is_main_image_call_error(e.as_inner()) { + WGSL_SIGNATURE.to_owned() + } else { + hint(&error_chain(e.as_inner())) + }; + vec![err(loc, message)] + })?; + let entry_point = match lang { + Language::Glsl => module + .entry_points + .iter() + .find(|ep| ep.stage == ShaderStage::Fragment) + .map_or_else(|| "main".to_owned(), |ep| ep.name.clone()), + Language::Wgsl => WGSL_ENTRY.to_owned(), + }; + let inputs = inputs_of(&module, &entry_point); + let wgsl = match lang { + Language::Glsl => { + naga::back::wgsl::write_string(&module, &info, naga::back::wgsl::WriterFlags::empty()) + .map_err(|e| vec![err(None, format!("internal: GLSL → WGSL failed: {e}"))])? + } + Language::Wgsl => wrapped.text, + }; + Ok(CompiledShader { + path: src.path.clone(), + wgsl, + entry_point, + inputs, + }) +} + +/// [`ShaderInputs`] of a validated module. +/// +/// The analysis runs on a compacted copy: naga's GLSL frontend leaves a +/// dead `AccessIndex` for every block member in every function, and +/// compaction drops expressions no statement uses (and functions nothing +/// calls). +pub(crate) fn inputs_of(module: &Module, entry: &str) -> ShaderInputs { + let mut module = module.clone(); + naga::compact::compact(&mut module, naga::compact::KeepUnused::No); + let module = &module; + let block = module.global_variables.iter().find_map(|(h, g)| { + g.binding + .as_ref() + .is_some_and(|b| b.group == 0 && b.binding == 0) + .then_some(h) + }); + ShaderInputs { + used: block.map_or(0, |b| analysis::uniforms_read(module, b, entry)), + event_duration: analysis::event_duration(module), + } +} + +/// An error and its `source()` chain, joined with `: ` (naga's +/// validation errors nest: function → expression → cause). +fn error_chain(e: &dyn std::error::Error) -> String { + let mut s = e.to_string(); + let mut cur = e.source(); + while let Some(inner) = cur { + let part = inner.to_string(); + if !s.contains(&part) { + s.push_str(": "); + s.push_str(&part); + } + cur = inner.source(); + } + s +} + +/// What the error says when `mainImage` is missing or has the wrong +/// signature. +const GLSL_SIGNATURE: &str = + "the shader must define `void mainImage(out vec4 fragColor, in vec2 fragCoord)`"; +const WGSL_SIGNATURE: &str = "the shader must define `fn mainImage(fragCoord: vec2f) -> vec4f`"; + +/// Parse errors that ran past the end of the user's WGSL file. +const UNEXPECTED_EOF: &str = "unexpected end of file (unclosed `{` or comment?)"; +const UNCLOSED: &str = "file ends inside a comment or an unclosed block"; + +/// Location and message for a WGSL parse error. +fn wgsl_parse_error( + e: &naga::front::wgsl::ParseError, + wrapped: &Wrapped, +) -> (Option<(u32, u32)>, String) { + let loc = e.labels().find_map(|(span, _)| wrapped.span_location(span)); + let mut message = e.message().to_owned(); + if let Some((_, label)) = e.labels().next() + && !label.is_empty() + && label != message + { + message = format!("{message} ({label})"); + } + if loc.is_none() && message.contains("mainImage") { + // The prelude's entry point can't find `mainImage`. + return (None, WGSL_SIGNATURE.to_owned()); + } + if e.labels() + .next() + .is_some_and(|(span, _)| wrapped.in_epilogue(span)) + { + // The parser ran on into the prelude: the user's file left a + // block or comment open. + return (Some((wrapped.user_lines, 1)), UNEXPECTED_EOF.to_owned()); + } + (loc, hint(&message)) +} + +/// Add a hint to messages that commonly need one. +fn hint(message: &str) -> String { + if ["iChannel1", "iChannel2", "iChannel3"] + .iter() + .any(|c| message.contains(c)) + { + return format!("{message} (only iChannel0, the terminal image, is available)"); + } + message.to_owned() +} + +/// True when a WGSL validation error is about the prelude's entry point +/// calling `mainImage` with the wrong signature (argument count or type, +/// or a return value that isn't a `vec4f`), not about what `mainImage` +/// does. +fn is_main_image_call_error(e: &naga::valid::ValidationError) -> bool { + use naga::valid::{CallError, EntryPointError, FunctionError, ValidationError}; + let ValidationError::EntryPoint { + name, + source: EntryPointError::Function(f), + .. + } = e + else { + return false; + }; + name == WGSL_ENTRY + && matches!( + f, + FunctionError::InvalidReturnType { .. } + | FunctionError::InvalidCall { + error: CallError::ArgumentCount { .. } + | CallError::ArgumentType { .. } + | CallError::Argument { .. }, + .. + } + ) +} + +/// The location to report for an error with these spans: the narrowest +/// span in the user's file (the others are the enclosing function and the +/// like, or the prelude). `None` when no span is in the user's file. +fn pick_location<'a>( + wrapped: &Wrapped, + spans: impl Iterator, +) -> Option<(u32, u32)> { + spans + .filter_map(|span| { + let loc = wrapped.span_location(*span)?; + Some((span.to_range()?.len(), loc)) + }) + .min_by_key(|(len, _)| *len) + .map(|(_, loc)| loc) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::post::uniforms::{bell_mask, focus_mask, mask_of, mouse_mask, time_mask}; + + fn src(name: &str, source: &str) -> PostShaderSource { + PostShaderSource { + path: PathBuf::from(name), + source: source.to_owned(), + } + } + + fn compile_ok(name: &str, source: &str) -> CompiledShader { + match compile(&src(name, source)) { + Ok(c) => c, + Err(errs) => panic!("{name} failed: {errs:?}"), + } + } + + fn compile_err(name: &str, source: &str) -> Vec { + compile(&src(name, source)).expect_err("should fail") + } + + const GLSL_PASS: &str = "void mainImage(out vec4 fragColor, in vec2 fragCoord) {\n\ + \x20 fragColor = texture(iChannel0, fragCoord / iResolution.xy);\n\ + }\n"; + + const WGSL_PASS: &str = "fn mainImage(fragCoord: vec2f) -> vec4f {\n\ + \x20 return textureSample(iChannel0, iChannel0Sampler, fragCoord / toastty.iResolution.xy);\n\ + }\n"; + + /// naga's offsets for the block (std140 from the GLSL prelude, the + /// uniform address-space layout from the WGSL one) match + /// [`crate::post::uniforms::PostUniforms`]. + #[test] + fn uniform_layout_matches_both_preludes() { + for (name, source) in [("l.glsl", GLSL_PASS), ("l.wgsl", WGSL_PASS)] { + let w = match Language::from_path(Path::new(name)).unwrap() { + Language::Glsl => wrap_glsl(source), + Language::Wgsl => wrap_wgsl(source), + }; + let module = if name.ends_with("glsl") { + naga::front::glsl::Frontend::default() + .parse( + &naga::front::glsl::Options::from(ShaderStage::Fragment), + &w.text, + ) + .unwrap() + } else { + naga::front::wgsl::parse_str(&w.text).unwrap() + }; + let (_, block) = module + .global_variables + .iter() + .find(|(_, g)| g.binding.as_ref().is_some_and(|b| b.binding == 0)) + .unwrap(); + let naga::TypeInner::Struct { members, span } = &module.types[block.ty].inner else { + panic!("block is not a struct"); + }; + assert_eq!(members.len(), MEMBERS.len(), "{name}"); + for (m, expected) in members.iter().zip(MEMBERS) { + assert_eq!(m.name.as_deref(), Some(expected.name), "{name}"); + assert_eq!( + m.offset as usize, expected.offset, + "{name}: {}", + expected.name + ); + } + assert_eq!( + *span as usize, + size_of::(), + "{name}" + ); + } + } + + #[test] + fn language_from_extension() { + assert_eq!( + Language::from_path(Path::new("a.glsl")), + Some(Language::Glsl) + ); + assert_eq!( + Language::from_path(Path::new("a.FRAG")), + Some(Language::Glsl) + ); + assert_eq!( + Language::from_path(Path::new("a.wgsl")), + Some(Language::Wgsl) + ); + assert_eq!(Language::from_path(Path::new("a.txt")), None); + assert_eq!(Language::from_path(Path::new("glsl")), None); + } + + #[test] + fn display_formats_known_parts() { + let mut e = ShaderError { + path: PathBuf::from("/s/crt.glsl"), + line: Some(3), + column: Some(7), + message: "bad".to_owned(), + }; + assert_eq!(e.to_string(), "/s/crt.glsl:3:7: bad"); + e.column = None; + assert_eq!(e.to_string(), "/s/crt.glsl:3: bad"); + e.line = None; + assert_eq!(e.to_string(), "/s/crt.glsl: bad"); + } + + #[test] + fn prelude_keeps_user_line_numbers() { + let user = "// one\n// two\nvoid mainImage(out vec4 c, in vec2 p) { c = vec4(1.0); }\n"; + let w = wrap_glsl(user); + let line3 = w.text.lines().nth(w.prelude_lines as usize + 2).unwrap(); + assert!(line3.starts_with("void mainImage"), "{line3}"); + let w = wrap_wgsl(user); + assert_eq!(w.prelude_lines, 0); + assert!(w.text.starts_with(user)); + } + + #[test] + fn version_line_is_blanked_in_place() { + let user = "#version 300 es\nfoo\n #version 450\r\nbar"; + let out = blank_version_lines(user); + assert_eq!(out, "\nfoo\n\r\nbar"); + // And a shader with its own #version still compiles. + compile_ok("v.glsl", &format!("#version 330\n{GLSL_PASS}")); + } + + #[test] + fn glsl_error_points_at_the_user_line() { + let errs = compile_err( + "bad.glsl", + "// comment\n\nvoid mainImage(out vec4 c, in vec2 p) {\n c = vec4(nope);\n}\n", + ); + let e = &errs[0]; + assert_eq!(e.line, Some(4), "{e}"); + assert!(e.column.is_some()); + assert!(e.message.contains("nope"), "{e}"); + assert!(e.to_string().starts_with("bad.glsl:4:"), "{e}"); + } + + #[test] + fn wgsl_error_points_at_the_user_line() { + let errs = compile_err( + "bad.wgsl", + "\nfn mainImage(p: vec2f) -> vec4f {\n return vec4f(nope);\n}\n", + ); + let e = &errs[0]; + assert_eq!(e.line, Some(3), "{e}"); + assert!(e.message.contains("nope"), "{e}"); + } + + #[test] + fn wgsl_validation_error_points_at_the_user_line() { + // Parses, but fails validation (returns the wrong type). + let errs = compile_err( + "bad.wgsl", + "fn mainImage(p: vec2f) -> vec4f {\n let unused = p;\n return 1.0;\n}\n", + ); + let e = &errs[0]; + assert_eq!(e.line, Some(3), "{e}"); + } + + #[test] + fn ichannel1_gives_a_clean_error() { + let errs = compile_err( + "multi.glsl", + "void mainImage(out vec4 c, in vec2 p) {\n c = texture(iChannel1, p);\n}\n", + ); + let e = &errs[0]; + assert_eq!(e.line, Some(2), "{e}"); + assert!(e.message.contains("iChannel1"), "{e}"); + assert!(e.message.contains("only iChannel0"), "{e}"); + } + + #[test] + fn missing_or_mistyped_main_image_is_explained() { + for (name, source) in [ + ("missing.wgsl", "fn other() {}\n"), + ( + "ret.wgsl", + "fn mainImage(p: vec2f) -> vec3f { return vec3f(1.0); }\n", + ), + ( + "args.wgsl", + "fn mainImage() -> vec4f { return vec4f(1.0); }\n", + ), + ] { + let errs = compile_err(name, source); + assert_eq!(errs[0].line, None, "{name}"); + assert_eq!(errs[0].message, WGSL_SIGNATURE, "{name}"); + } + for (name, source) in [ + ("missing.glsl", "void other() {}\n"), + ( + "args.glsl", + "void mainImage(out vec4 c) { c = vec4(1.0); }\n", + ), + ("ret.glsl", "vec4 mainImage(vec2 p) { return vec4(1.0); }\n"), + ] { + let errs = compile_err(name, source); + assert_eq!(errs[0].line, None, "{name}"); + assert_eq!(errs[0].message, GLSL_SIGNATURE, "{name}"); + } + } + + #[test] + fn real_errors_through_the_entry_point_are_kept() { + // `workgroupBarrier` is forbidden in a fragment shader: naga + // reports it on the prelude's entry point, but it is not a + // signature problem. + let errs = compile_err( + "barrier.wgsl", + "fn mainImage(p: vec2f) -> vec4f {\n workgroupBarrier();\n return vec4f(1.0);\n}\n", + ); + assert_ne!(errs[0].message, WGSL_SIGNATURE); + assert!(errs[0].message.contains("forbidden"), "{}", errs[0]); + } + + #[test] + fn wgsl_left_open_at_the_end_is_reported_at_the_last_line() { + // Unclosed comment: swallows the prelude, which still parses. + let errs = compile_err( + "comment.wgsl", + "fn mainImage(p: vec2f) -> vec4f {\n return vec4f(1.0);\n}\n/* trailing\n", + ); + assert_eq!(errs[0].message, UNCLOSED); + assert_eq!(errs[0].line, Some(4)); + // Unclosed block: the parser trips over the prelude. + let errs = compile_err( + "brace.wgsl", + "fn mainImage(p: vec2f) -> vec4f {\n return vec4f(1.0);\n", + ); + assert_eq!(errs[0].message, UNEXPECTED_EOF); + assert_eq!(errs[0].line, Some(2)); + } + + #[test] + fn wgsl_directives_compile() { + compile_ok("enable.wgsl", &format!("enable f16;\n{WGSL_PASS}")); + compile_ok( + "requires.wgsl", + &format!( + "requires pointer_composite_access;\ndiagnostic(off, derivative_uniformity);\n{WGSL_PASS}" + ), + ); + // Lines still map one to one. + let errs = compile_err( + "enable.wgsl", + "enable f16;\n\nfn mainImage(p: vec2f) -> vec4f {\n return nope;\n}\n", + ); + assert_eq!(errs[0].line, Some(4), "{}", errs[0]); + } + + #[test] + fn location_prefers_the_narrowest_span_in_the_user_file() { + // User line 2 holds a function header and a narrower expression; + // the narrowest span of all is in the WGSL prelude (after the + // user's lines). + let user = "// x\nfn mainImage(p: vec2f) -> vec4f { return vec4f(1.0); }\n"; + let w = wrap_wgsl(user); + let expr_at = user.find("vec4f(1.0)").unwrap(); + let header = Span::from(5..user.len() - 1); + let expr = Span::from(expr_at..user.find("; }").unwrap()); + let prelude = Span::from(user.len() + 3..user.len() + 4); + let col = u32::try_from(expr_at - 5).unwrap() + 1; + assert_eq!( + pick_location(&w, [header, prelude, expr].iter()), + Some((2, col)) + ); + assert_eq!(pick_location(&w, [prelude].iter()), None); + } + + #[test] + fn unknown_extension_is_an_error() { + let errs = compile_err("x.hlsl", GLSL_PASS); + assert!(errs[0].message.contains("unknown shader type")); + } + + #[test] + fn pass_through_compiles_in_both_languages() { + let g = compile_ok("p.glsl", GLSL_PASS); + assert_eq!(g.inputs.used, mask_of(&["iResolution"])); + assert!(g.wgsl.contains("@fragment")); + let w = compile_ok("p.wgsl", WGSL_PASS); + assert_eq!(w.inputs.used, mask_of(&["iResolution"])); + assert_eq!(w.entry_point, WGSL_ENTRY); + } + + #[test] + fn static_shader_is_static() { + let shader = "vec3 tint(vec3 c) { return c * iBackgroundColor; }\n\ + void mainImage(out vec4 c, in vec2 p) {\n\ + \x20 vec4 s = texture(iChannel0, p / iResolution.xy);\n\ + \x20 c = vec4(tint(s.rgb) + iPalette[int(p.x) % 16], 1.0);\n\ + }\n"; + let c = compile_ok("s.glsl", shader); + assert!(!c.inputs.reads(time_mask())); + assert!(!c.inputs.reads(mouse_mask() | focus_mask() | bell_mask())); + assert!(c.inputs.reads(mask_of(&["iPalette"]))); + assert!(c.inputs.reads(mask_of(&["iBackgroundColor"]))); + assert_eq!(c.inputs.event_duration, None); + } + + #[test] + fn time_read_in_a_nested_helper_is_found() { + let shader = "float wave(float x) { return sin(x + iTime); }\n\ + float outer(float x) { if (x > 0.0) { return wave(x); } return 0.0; }\n\ + void mainImage(out vec4 c, in vec2 p) { c = vec4(outer(p.x)); }\n"; + let c = compile_ok("t.glsl", shader); + assert!(c.inputs.reads(time_mask())); + let shader = "fn wave(x: f32) -> f32 { return sin(x + toastty.iTime); }\n\ + fn outer(x: f32) -> f32 { for (var i = 0; i < 2; i++) { if x > 0.0 { return wave(x); } } return 0.0; }\n\ + fn mainImage(p: vec2f) -> vec4f { return vec4f(outer(p.x)); }\n"; + let c = compile_ok("t.wgsl", shader); + assert!(c.inputs.reads(time_mask())); + } + + #[test] + fn unused_helper_does_not_count() { + let shader = "float unused() { return iTime; }\n\ + void mainImage(out vec4 c, in vec2 p) { c = vec4(iMouse.zw, 0.0, 1.0); }\n"; + let c = compile_ok("u.glsl", shader); + assert!(!c.inputs.reads(time_mask())); + assert!(c.inputs.reads(mouse_mask())); + } + + #[test] + fn bell_array_counts_as_time_and_bell() { + let shader = "void mainImage(out vec4 c, in vec2 p) { c = vec4(iBells[1].w); }\n"; + let c = compile_ok("b.glsl", shader); + assert!(c.inputs.reads(time_mask())); + assert!(c.inputs.reads(bell_mask())); + assert!(!c.inputs.reads(focus_mask())); + } + + #[test] + fn duration_constant_is_read_in_both_languages() { + let g = compile_ok( + "d.glsl", + &format!("const float TOASTTY_EVENT_DURATION = 1.5;\n{GLSL_PASS}"), + ); + assert_eq!(g.inputs.event_duration, Some(1.5)); + let w = compile_ok( + "d.wgsl", + &format!("const TOASTTY_EVENT_DURATION: f32 = 2.25;\n{WGSL_PASS}"), + ); + assert_eq!(w.inputs.event_duration, Some(2.25)); + // Huge values clamp instead of overflowing the schedule. + let g = compile_ok( + "d.glsl", + &format!("const float TOASTTY_EVENT_DURATION = 1e30;\n{GLSL_PASS}"), + ); + assert_eq!( + g.inputs.event_duration, + Some(crate::post::analysis::MAX_EVENT_DURATION) + ); + } + + #[test] + fn palette_index_does_not_copy_the_array() { + // A dynamic index into iPalette must index the uniform in place, + // not load all 256 entries into a local first. + let shader = "void mainImage(out vec4 c, in vec2 p) {\n\ + \x20 int i = int(p.x) & 255;\n\ + \x20 c = vec4(iPalette[i], 1.0);\n\ + }\n"; + let c = compile_ok("pal.glsl", shader); + assert!( + !c.wgsl.contains("array, 256>") + || c.wgsl.matches("array, 256>").count() == 1, + "palette copied:\n{}", + c.wgsl + ); + } + + #[test] + fn ghostty_style_shader_compiles() { + // Uses the Ghostty-only names, the cursor macros and texture2D. + let shader = "#version 330 core\n\ + void mainImage(out vec4 fragColor, in vec2 fragCoord) {\n\ + \x20 vec2 uv = fragCoord / iResolution.xy;\n\ + \x20 vec4 col = texture2D(iChannel0, uv);\n\ + \x20 float d = distance(fragCoord, iCurrentCursor.xy);\n\ + \x20 if (iCurrentCursorStyle == CURSORSTYLE_BAR) { col.r += 0.1; }\n\ + \x20 col.rgb += iCurrentCursorColor.rgb * exp(-d * 0.05) * step(iTimeCursorChange, iTime);\n\ + \x20 col.rgb += iPreviousCursorColor.rgb * float(iFocus) * iTimeDelta * iFrameRate;\n\ + \x20 col.rgb *= vec3(float(iFrame % 2), iDate.w, iChannelTime[0] + iChannelResolution[0].x);\n\ + \x20 fragColor = col + iMouse * 0.0;\n\ + }\n"; + let c = compile_ok("ghostty.glsl", shader); + assert!(c.inputs.reads(time_mask())); + assert!(c.inputs.reads(mouse_mask())); + assert!(c.inputs.reads(focus_mask())); + } +} diff --git a/crates/toastty-render/src/post/gpu_tests.rs b/crates/toastty-render/src/post/gpu_tests.rs new file mode 100644 index 00000000..1b8e5436 --- /dev/null +++ b/crates/toastty-render/src/post/gpu_tests.rs @@ -0,0 +1,333 @@ +//! GPU tests for [`PostChain`]: frames go through real wgpu passes and +//! are read back. +//! +//! The "swapchain" here is an `Rgba8UnormSrgb` texture with a non-sRGB +//! view format, like the configured surface, so both output paths (direct +//! non-sRGB view, and ping-pong + copy) are covered. + +use std::path::PathBuf; + +use wgpu::{Device, Queue, TextureFormat}; + +use super::*; + +const FORMAT: TextureFormat = TextureFormat::Rgba8UnormSrgb; + +fn shader(name: &str, source: &str) -> PostShaderSource { + PostShaderSource { + path: PathBuf::from(name), + source: source.to_owned(), + } +} + +const GLSL_PASS: &str = "void mainImage(out vec4 fragColor, in vec2 fragCoord) {\n\ + \x20 fragColor = texture(iChannel0, fragCoord / iResolution.xy);\n\ + }\n"; + +const WGSL_PASS: &str = "fn mainImage(fragCoord: vec2f) -> vec4f {\n\ + \x20 return textureSample(iChannel0, iChannel0Sampler, fragCoord / toastty.iResolution.xy);\n\ + }\n"; + +const GLSL_INVERT: &str = "void mainImage(out vec4 fragColor, in vec2 fragCoord) {\n\ + \x20 vec4 c = texture(iChannel0, fragCoord / iResolution.xy);\n\ + \x20 fragColor = vec4(vec3(1.0) - c.rgb, c.a);\n\ + }\n"; + +const WGSL_SWAP_RB: &str = "fn mainImage(fragCoord: vec2f) -> vec4f {\n\ + \x20 let c = textureSample(iChannel0, iChannel0Sampler, fragCoord / toastty.iResolution.xy);\n\ + \x20 return c.bgra;\n\ + }\n"; + +fn texture(device: &Device, w: u32, h: u32) -> wgpu::Texture { + device.create_texture(&wgpu::TextureDescriptor { + label: Some("post test texture"), + size: wgpu::Extent3d { + width: w, + height: h, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: FORMAT, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT + | wgpu::TextureUsages::TEXTURE_BINDING + | wgpu::TextureUsages::COPY_SRC + | wgpu::TextureUsages::COPY_DST, + view_formats: &linear_view_formats(FORMAT), + }) +} + +fn upload(queue: &Queue, t: &wgpu::Texture, rgba: &[u8]) { + queue.write_texture( + t.as_image_copy(), + rgba, + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(t.width() * 4), + rows_per_image: Some(t.height()), + }, + t.size(), + ); +} + +fn read_back(device: &Device, queue: &Queue, t: &wgpu::Texture) -> Vec { + let (w, h) = (t.width(), t.height()); + let row = (w * 4).div_ceil(256) * 256; + let buf = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("post test readback"), + size: u64::from(row * h), + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor::default()); + encoder.copy_texture_to_buffer( + t.as_image_copy(), + wgpu::TexelCopyBufferInfo { + buffer: &buf, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(row), + rows_per_image: Some(h), + }, + }, + t.size(), + ); + queue.submit(std::iter::once(encoder.finish())); + buf.slice(..).map_async(wgpu::MapMode::Read, |_| {}); + device + .poll(wgpu::PollType::wait_indefinitely()) + .expect("poll"); + let data = buf.slice(..).get_mapped_range(); + let mut out = Vec::with_capacity((w * h * 4) as usize); + for y in 0..h { + let start = (y * row) as usize; + out.extend_from_slice(&data[start..start + (w * 4) as usize]); + } + out +} + +/// Every byte value in every channel, with alpha varying too. +fn pattern(w: u32, h: u32) -> Vec { + let mut v = Vec::with_capacity((w * h * 4) as usize); + for y in 0..h { + for x in 0..w { + v.extend_from_slice(&[ + (x % 256) as u8, + (255 - x % 256) as u8, + ((x * 7 + y * 13) % 256) as u8, + (255 - (y * 31) % 128) as u8, + ]); + } + } + v +} + +/// Run `shaders` over `input` (`w × h` RGBA) and read the result back. +#[allow(clippy::too_many_arguments)] +fn run( + device: &Device, + queue: &Queue, + shaders: &[PostShaderSource], + direct: bool, + w: u32, + h: u32, + input: &[u8], + uniforms: &PostUniforms, +) -> Vec { + let compiled = compile_all(shaders).expect("compiles"); + let mut chain = PostChain::new(device, &compiled, FORMAT, direct).expect("pipelines"); + let source = texture(device, w, h); + let frame = texture(device, w, h); + upload(queue, &source, input); + chain.bind_source(device, &source); + chain.write_uniforms(queue, uniforms, true); + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor::default()); + chain.encode(&mut encoder, &frame); + queue.submit(std::iter::once(encoder.finish())); + read_back(device, queue, &frame) +} + +fn uniforms_for(w: u32, h: u32) -> PostUniforms { + let mut u = PostUniforms::default(); + #[allow(clippy::cast_precision_loss)] + { + u.i_resolution = [w as f32, h as f32, 1.0]; + } + u +} + +fn gpu() -> Option<(Device, Queue)> { + crate::test_gpu::try_device_and_queue() +} + +#[test] +fn pass_through_reproduces_the_frame_exactly() { + let Some((device, queue)) = gpu() else { return }; + let (w, h) = (256, 8); + let input = pattern(w, h); + let u = uniforms_for(w, h); + for direct in [true, false] { + for s in [shader("p.glsl", GLSL_PASS), shader("p.wgsl", WGSL_PASS)] { + let out = run( + &device, + &queue, + std::slice::from_ref(&s), + direct, + w, + h, + &input, + &u, + ); + assert!( + out == input, + "{} (direct={direct}) changed pixels", + s.path.display() + ); + } + } +} + +#[test] +fn pass_through_reproduces_a_terminal_frame() { + // The hello-world golden is a real cell-pass frame. + let Some((device, queue)) = gpu() else { return }; + let path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/snapshots/text_hello.png" + ); + let img = image::open(path).expect("golden").to_rgba8(); + let (w, h) = img.dimensions(); + let input = img.into_raw(); + let u = uniforms_for(w, h); + let out = run( + &device, + &queue, + &[shader("p.glsl", GLSL_PASS)], + true, + w, + h, + &input, + &u, + ); + assert!(out == input, "pass-through changed the terminal frame"); +} + +#[test] +fn invert_inverts() { + let Some((device, queue)) = gpu() else { return }; + let (w, h) = (256, 4); + let input = pattern(w, h); + let out = run( + &device, + &queue, + &[shader("inv.glsl", GLSL_INVERT)], + true, + w, + h, + &input, + &uniforms_for(w, h), + ); + for (i, (o, p)) in out.chunks(4).zip(input.chunks(4)).enumerate() { + assert_eq!(o, [255 - p[0], 255 - p[1], 255 - p[2], p[3]], "pixel {i}"); + } +} + +#[test] +fn chain_applies_every_shader_in_order() { + let Some((device, queue)) = gpu() else { return }; + let (w, h) = (256, 4); + let input = pattern(w, h); + let u = uniforms_for(w, h); + let expected: Vec = input + .chunks(4) + .flat_map(|p| [255 - p[2], 255 - p[1], 255 - p[0], p[3]]) + .collect(); + for direct in [true, false] { + let out = run( + &device, + &queue, + &[ + shader("inv.glsl", GLSL_INVERT), + shader("swap.wgsl", WGSL_SWAP_RB), + ], + direct, + w, + h, + &input, + &u, + ); + assert!(out == expected, "two-pass chain (direct={direct})"); + } + // Three passes reuse the two ping-pong textures; inverting twice + // cancels out. + let out = run( + &device, + &queue, + &[ + shader("a.glsl", GLSL_INVERT), + shader("b.glsl", GLSL_INVERT), + shader("c.wgsl", WGSL_SWAP_RB), + ], + false, + w, + h, + &input, + &u, + ); + let swapped: Vec = input + .chunks(4) + .flat_map(|p| [p[2], p[1], p[0], p[3]]) + .collect(); + assert!(out == swapped, "three-pass chain"); +} + +#[test] +fn uniforms_reach_both_languages() { + // Reads members from the start, middle and end of the block, + // including an array element, an int and the palette. + let Some((device, queue)) = gpu() else { return }; + let (w, h) = (4, 4); + let mut u = uniforms_for(w, h); + u.i_time = 0.25; + u.i_frame = 51; + u.i_bells[2] = [0.0, 0.0, 0.0, 0.6]; + u.i_palette[200] = [0.0, 0.0, 0.75, 0.0]; + let glsl = "void mainImage(out vec4 c, in vec2 p) {\n\ + \x20 c = vec4(iTime, float(iFrame) / 255.0, iPalette[200].b, iBells[2].w + iResolution.x / 8.0 - 0.5);\n\ + }\n"; + let wgsl = "fn mainImage(p: vec2f) -> vec4f {\n\ + \x20 let u = toastty;\n\ + \x20 return vec4f(u.iTime, f32(u.iFrame) / 255.0, u.iPalette[200].b, u.iBells[2].w + u.iResolution.x / 8.0 - 0.5);\n\ + }\n"; + let input = vec![0u8; (w * h * 4) as usize]; + for s in [shader("u.glsl", glsl), shader("u.wgsl", wgsl)] { + let out = run( + &device, + &queue, + std::slice::from_ref(&s), + true, + w, + h, + &input, + &u, + ); + assert_eq!(&out[..4], &[64, 51, 191, 153], "{}", s.path.display()); + } +} + +#[test] +fn backend_rejection_is_an_error_not_a_panic() { + // Valid for naga, but wgpu refuses the pipeline: the fragment output + // is an integer while the target is a float format. + let Some((device, _)) = gpu() else { return }; + let bad = CompiledShader { + path: PathBuf::from("bad.wgsl"), + wgsl: "@fragment fn main() -> @location(0) vec4 { return vec4(1u); }".to_owned(), + entry_point: "main".to_owned(), + inputs: ShaderInputs::default(), + }; + let errs = PostChain::new(&device, &[bad], FORMAT, true).expect_err("rejected"); + assert_eq!(errs[0].path, PathBuf::from("bad.wgsl")); + assert!(errs[0].message.contains("rejected"), "{}", errs[0]); +} diff --git a/crates/toastty-render/src/post/mod.rs b/crates/toastty-render/src/post/mod.rs new file mode 100644 index 00000000..01c54b88 --- /dev/null +++ b/crates/toastty-render/src/post/mod.rs @@ -0,0 +1,417 @@ +//! User post-process shaders (M13): Shadertoy / Ghostty-style fragment +//! shaders run over the terminal image. +//! +//! See [`docs/milestones/m13-user-shaders.md`](../../../docs/milestones/m13-user-shaders.md) +//! for the user-facing contract (languages, uniforms, coordinates, +//! animation rules). The pieces: +//! +//! - [`compile`]: prelude wrapping, naga parse + validate, error +//! mapping back to the user's file, GLSL → WGSL. +//! - [`analysis`]: which uniforms a shader reads, and its +//! `TOASTTY_EVENT_DURATION`. +//! - [`uniforms`]: the uniform block layout shared by both languages. +//! - [`state`]: the clock, pointer, focus, bell and cursor history behind +//! the uniforms, and the animation schedule. +//! - [`PostChain`] (here): the GPU passes. +//! +//! Colour handling: the scratch texture and the swapchain are sRGB +//! formats, but every post pass reads and writes them through a non-sRGB +//! view, so shaders see (and write) sRGB-encoded values and a +//! pass-through shader reproduces the frame bit for bit. Where the +//! surface can't be viewed as non-sRGB (no `SURFACE_VIEW_FORMATS`), the +//! last pass writes a texture that is then copied into the swapchain. + +mod analysis; +mod compile; +#[cfg(test)] +mod gpu_tests; +mod state; +mod uniforms; + +use std::num::NonZeroU64; +use std::task::{Context, Poll, Waker}; + +use wgpu::TextureFormat; + +pub(crate) use compile::ShaderInputs; +pub use compile::{PostShaderSource, ShaderError}; +pub use state::PostAnimation; +pub(crate) use state::{CursorSnap, FrameInputs, PostState}; +pub(crate) use uniforms::{PostUniforms, date_mask}; + +use compile::CompiledShader; +use uniforms::PALETTE_OFFSET; + +/// Compile every shader. All-or-nothing: any failure returns every +/// error from every shader. +pub(crate) fn compile_all( + shaders: &[PostShaderSource], +) -> Result, Vec> { + let mut ok = Vec::with_capacity(shaders.len()); + let mut errors = Vec::new(); + for s in shaders { + match compile::compile(s) { + Ok(c) => ok.push(c), + Err(e) => errors.extend(e), + } + } + if errors.is_empty() { + Ok(ok) + } else { + Err(errors) + } +} + +/// The format post passes read and write: `format` without sRGB. +pub(crate) fn linear_format(format: TextureFormat) -> TextureFormat { + format.remove_srgb_suffix() +} + +/// `view_formats` for a texture of `format` that post passes access +/// through [`linear_format`]. +pub(crate) fn linear_view_formats(format: TextureFormat) -> Vec { + let linear = linear_format(format); + if linear == format { + Vec::new() + } else { + vec![linear] + } +} + +/// `t` viewed through [`linear_format`]. +fn linear_view(t: &wgpu::Texture) -> wgpu::TextureView { + t.create_view(&wgpu::TextureViewDescriptor { + label: Some("toastty-post linear view"), + format: Some(linear_format(t.format())), + ..Default::default() + }) +} + +/// Size-dependent resources: ping-pong textures and one bind group per +/// pass. +#[derive(Debug)] +struct Targets { + size: (u32, u32), + textures: Vec, + views: Vec, + bind_groups: Vec, +} + +/// A compiled, installed chain of post passes. +#[derive(Debug)] +pub(crate) struct PostChain { + passes: Vec, + inputs: ShaderInputs, + layout: wgpu::BindGroupLayout, + uniform_buf: wgpu::Buffer, + sampler: wgpu::Sampler, + /// Format of the scratch texture and the swapchain. + format: TextureFormat, + /// The last pass renders into the swapchain through a non-sRGB view + /// (vs. into a ping-pong texture that is then copied over). + direct: bool, + targets: Option, +} + +impl PostChain { + /// Build the GPU pipelines for `shaders` (already compiled by + /// [`compile_all`]) rendering for a `format` surface. `direct`: the + /// swapchain can be viewed as [`linear_format`]. Fails with an error + /// per shader wgpu rejects. + pub(crate) fn new( + device: &wgpu::Device, + shaders: &[CompiledShader], + format: TextureFormat, + direct: bool, + ) -> Result> { + let linear = linear_format(format); + let layout = bind_group_layout(device); + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("toastty-post pipeline layout"), + bind_group_layouts: &[Some(&layout)], + immediate_size: 0, + }); + let vs = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("toastty-post vertex shader"), + source: wgpu::ShaderSource::Wgsl(include_str!("../../shaders/post.wgsl").into()), + }); + + let mut passes = Vec::with_capacity(shaders.len()); + let mut errors = Vec::new(); + let mut inputs = ShaderInputs::default(); + for shader in shaders { + inputs = inputs.union(shader.inputs); + match build_pass(device, &pipeline_layout, &vs, shader, linear) { + Ok(p) => passes.push(p), + Err(e) => errors.push(e), + } + } + if !errors.is_empty() { + return Err(errors); + } + + let uniform_buf = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("toastty-post uniforms"), + size: size_of::() as u64, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let sampler = device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("toastty-post sampler"), + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + address_mode_u: wgpu::AddressMode::ClampToEdge, + address_mode_v: wgpu::AddressMode::ClampToEdge, + address_mode_w: wgpu::AddressMode::ClampToEdge, + ..Default::default() + }); + Ok(Self { + passes, + inputs, + layout, + uniform_buf, + sampler, + format, + direct: direct || linear == format, + targets: None, + }) + } + + /// What the chain's shaders read (the union over its passes). + pub(crate) fn inputs(&self) -> &ShaderInputs { + &self.inputs + } + + /// Bind `source` (the content texture, same size as the swapchain, + /// created with [`linear_view_formats`] and `TEXTURE_BINDING`) and + /// (re)create the ping-pong textures for its size. Call after + /// creating the chain and whenever `source` is recreated. + pub(crate) fn bind_source(&mut self, device: &wgpu::Device, source: &wgpu::Texture) { + let size = (source.width(), source.height()); + let n = self.passes.len(); + // Pass outputs that aren't the swapchain; two textures cover any + // chain length. + let off_screen = n - 1 + usize::from(!self.direct); + let (textures, views) = match self.targets.take() { + Some(t) if t.size == size && t.textures.len() == off_screen.min(2) => { + (t.textures, t.views) + } + _ => { + let view_formats = linear_view_formats(self.format); + let textures: Vec = (0..off_screen.min(2)) + .map(|_| { + device.create_texture(&wgpu::TextureDescriptor { + label: Some("toastty-post ping-pong"), + size: wgpu::Extent3d { + width: size.0, + height: size.1, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: self.format, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT + | wgpu::TextureUsages::TEXTURE_BINDING + | wgpu::TextureUsages::COPY_SRC, + view_formats: &view_formats, + }) + }) + .collect(); + let views = textures.iter().map(linear_view).collect(); + (textures, views) + } + }; + let source_view = linear_view(source); + let bind_groups = (0..n) + .map(|i| { + let input = if i == 0 { + &source_view + } else { + &views[(i - 1) % 2] + }; + device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("toastty-post bind group"), + layout: &self.layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: self.uniform_buf.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(input), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::Sampler(&self.sampler), + }, + ], + }) + }) + .collect(); + self.targets = Some(Targets { + size, + textures, + views, + bind_groups, + }); + } + + /// Upload the uniforms: the block up to the palette always, the + /// palette when `palette` is set. + pub(crate) fn write_uniforms(&self, queue: &wgpu::Queue, u: &PostUniforms, palette: bool) { + let bytes = bytemuck::bytes_of(u); + let end = if palette { bytes.len() } else { PALETTE_OFFSET }; + queue.write_buffer(&self.uniform_buf, 0, &bytes[..end]); + } + + /// Record the passes: the bound source through every shader, into + /// `frame` (the swapchain texture, or any texture of the chain's + /// format with [`linear_view_formats`] and `COPY_DST`). + pub(crate) fn encode(&self, encoder: &mut wgpu::CommandEncoder, frame: &wgpu::Texture) { + let Some(t) = self.targets.as_ref() else { + debug_assert!(false, "PostChain::encode before bind_source"); + return; + }; + let n = self.passes.len(); + let frame_view = self.direct.then(|| linear_view(frame)); + for (i, pipeline) in self.passes.iter().enumerate() { + let view = match frame_view.as_ref() { + Some(v) if i + 1 == n => v, + _ => &t.views[i % 2], + }; + let mut rp = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("toastty-post pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view, + depth_slice: None, + resolve_target: None, + ops: wgpu::Operations { + // The triangle covers every pixel. + load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }); + rp.set_pipeline(pipeline); + rp.set_bind_group(0, &t.bind_groups[i], &[]); + rp.draw(0..3, 0..1); + } + if !self.direct { + encoder.copy_texture_to_texture( + t.textures[(n - 1) % 2].as_image_copy(), + frame.as_image_copy(), + wgpu::Extent3d { + width: t.size.0, + height: t.size.1, + depth_or_array_layers: 1, + }, + ); + } + } +} + +/// Uniform block at 0, `iChannel0` texture at 1, its sampler at 2. +fn bind_group_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout { + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("toastty-post bind group layout"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: NonZeroU64::new(size_of::() as u64), + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + ], + }) +} + +/// One post pass's pipeline, created inside wgpu error scopes so a +/// shader that naga accepted but wgpu rejects comes back as an error +/// instead of reaching the uncaptured-error handler. +fn build_pass( + device: &wgpu::Device, + layout: &wgpu::PipelineLayout, + vs: &wgpu::ShaderModule, + shader: &CompiledShader, + format: TextureFormat, +) -> Result { + let validation = device.push_error_scope(wgpu::ErrorFilter::Validation); + let internal = device.push_error_scope(wgpu::ErrorFilter::Internal); + let label = shader.path.display().to_string(); + let fs = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some(&label), + source: wgpu::ShaderSource::Wgsl(shader.wgsl.as_str().into()), + }); + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some(&label), + layout: Some(layout), + vertex: wgpu::VertexState { + module: vs, + entry_point: Some("vs_main"), + compilation_options: wgpu::PipelineCompilationOptions::default(), + buffers: &[], + }, + fragment: Some(wgpu::FragmentState { + module: &fs, + entry_point: Some(&shader.entry_point), + compilation_options: wgpu::PipelineCompilationOptions::default(), + targets: &[Some(wgpu::ColorTargetState { + format, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + })], + }), + primitive: wgpu::PrimitiveState::default(), + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + multiview_mask: None, + cache: None, + }); + // Pop in reverse push order. + match poll_error(internal.pop()).or_else(|| poll_error(validation.pop())) { + Some(e) => Err(ShaderError { + path: shader.path.clone(), + line: None, + column: None, + message: format!("rejected by the GPU backend: {e}"), + }), + None => Ok(pipeline), + } +} + +/// Poll an error-scope future once. wgpu's native ones are ready +/// immediately; a pending one reports no error (there is nothing we +/// could wait on in the render loop anyway). +fn poll_error(f: impl Future>) -> Option { + let mut f = std::pin::pin!(f); + match f.as_mut().poll(&mut Context::from_waker(Waker::noop())) { + Poll::Ready(e) => e, + Poll::Pending => None, + } +} diff --git a/crates/toastty-render/src/post/state.rs b/crates/toastty-render/src/post/state.rs new file mode 100644 index 00000000..1ac4b991 --- /dev/null +++ b/crates/toastty-render/src/post/state.rs @@ -0,0 +1,994 @@ +//! CPU-side state behind the post uniforms: the shader clock, pointer, +//! focus, bells and cursor history, and the animation schedule. +//! +//! No GPU and no `Term` here (the renderer gathers a [`FrameInputs`] +//! from those), so the timing rules are unit tested with synthetic +//! instants. + +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU64, Ordering}; +use std::time::{Duration, Instant, SystemTime}; + +use super::compile::ShaderInputs; +use super::uniforms::{ + EMPTY_BELL, NEVER, PostUniforms, bell_mask, cursor_mask, focus_mask, mouse_mask, time_mask, +}; + +/// Interval between time-driven frames (~60 Hz). +pub(crate) const FRAME_INTERVAL: Duration = Duration::from_millis(16); + +/// Interval between time-driven frames while the window is occluded: slow +/// polling, so animation resumes on its own once it's visible again. +pub(crate) const OCCLUDED_INTERVAL: Duration = Duration::from_millis(250); + +/// Which events open a `TOASTTY_EVENT_DURATION` window for these inputs: +/// `(bell, cursor change, focus gain)`. Each event counts only if the +/// chain reads its uniforms; a chain that reads none of the three groups +/// (a generic "animate after anything" shader) gets all three. +pub(crate) fn window_events(inputs: &ShaderInputs) -> (bool, bool, bool) { + let bell = inputs.reads(bell_mask()); + let cursor = inputs.reads(cursor_mask()); + let focus = inputs.reads(focus_mask()); + if bell || cursor || focus { + (bell, cursor, focus) + } else { + (true, true, true) + } +} + +/// When post shaders get time-driven frames (`[shader] animation`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum PostAnimation { + /// Decide from what the shaders read: shaders that read time animate + /// while focused, or only for `TOASTTY_EVENT_DURATION` seconds after + /// an event when they declare it; shaders that don't read time never + /// get time-driven frames. + #[default] + Auto, + /// Redraw at ~60 Hz all the time, even unfocused. + Always, + /// Redraw only on terminal updates and shader-input events. + Never, +} + +/// Cursor as the shaders see it. +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) struct CursorSnap { + /// `(left, bottom, width, height)` in surface px. + pub rect: [f32; 4], + /// sRGB RGBA. + pub color: [f32; 4], + /// `CURSORSTYLE_*`. + pub style: i32, +} + +/// Per-frame inputs the renderer gathers from the terminal and theme. +/// Colours are sRGB-encoded. +#[derive(Debug, Clone, Copy)] +pub(crate) struct FrameInputs<'a> { + pub resolution: [f32; 2], + pub cursor: CursorSnap, + pub cursor_visible: bool, + pub cell_size: [f32; 2], + pub grid_rect: [f32; 4], + pub background: [f32; 3], + pub foreground: [f32; 3], + pub cursor_color: [f32; 3], + pub cursor_text: [f32; 3], + pub selection_background: [f32; 3], + pub selection_foreground: [f32; 3], + /// The palette, when it changed since the last frame. + pub palette: Option<&'a [[f32; 3]; 256]>, +} + +#[derive(Debug, Clone, Copy)] +struct Bell { + at: Instant, + /// Centre of the bell cell, surface px. + center: [f32; 2], +} + +/// `iMouse`, Shadertoy semantics. +#[derive(Debug, Clone, Copy, Default, PartialEq)] +struct Mouse { + /// Pointer position while button 1 is held; the last held position + /// otherwise. + pos: [f32; 2], + /// Where button 1 was last pressed. + click: [f32; 2], + down: bool, + /// The press hasn't been shown in a frame yet. + pressed: bool, +} + +impl Mouse { + fn uniform(&self) -> [f32; 4] { + [ + self.pos[0], + self.pos[1], + if self.down { + self.click[0] + } else { + -self.click[0] + }, + if self.pressed { + self.click[1] + } else { + -self.click[1] + }, + ] + } +} + +/// The local zone's offset from UTC for `iDate`, looked up off the +/// render thread: `date +%z` runs on a helper thread and publishes the +/// result through atomics, refreshed at the next local hour boundary (DST +/// changes happen on the hour). Until the first lookup lands, and when +/// `date` is missing, the offset is 0 (UTC). +/// +/// `date` rather than `localtime_r` because the crate forbids `unsafe`, +/// and the `time` crate refuses to read the offset in a multi-threaded +/// process. +#[derive(Debug, Default, Clone)] +struct LocalOffset(Arc); + +#[derive(Debug, Default)] +struct OffsetCell { + seconds: AtomicI64, + /// Unix time of the next lookup (0: now). + refresh_at: AtomicU64, + in_flight: AtomicBool, +} + +impl LocalOffset { + /// The offset in seconds east of UTC, starting a lookup when one is + /// due. Never blocks. + fn get(&self, unix_secs: u64) -> i64 { + let cell = &self.0; + if unix_secs >= cell.refresh_at.load(Ordering::Relaxed) + && !cell.in_flight.swap(true, Ordering::AcqRel) + { + let shared = Arc::clone(cell); + let spawned = std::thread::Builder::new() + .name("toastty-utc-offset".into()) + .spawn(move || { + let seconds = local_utc_offset().unwrap_or_else(|| { + static LOGGED: AtomicBool = AtomicBool::new(false); + if !LOGGED.swap(true, Ordering::Relaxed) { + tracing::debug!("iDate: `date +%z` failed; using UTC"); + } + 0 + }); + shared.seconds.store(seconds, Ordering::Relaxed); + let now = unix_now().as_secs(); + shared + .refresh_at + .store(next_local_hour(now, seconds), Ordering::Relaxed); + shared.in_flight.store(false, Ordering::Release); + }); + if spawned.is_err() { + cell.refresh_at + .store(next_local_hour(unix_secs, 0), Ordering::Relaxed); + cell.in_flight.store(false, Ordering::Release); + } + } + cell.seconds.load(Ordering::Relaxed) + } +} + +/// Unix time of the first local hour boundary after `unix_secs` in a zone +/// `offset` seconds east of UTC. +fn next_local_hour(unix_secs: u64, offset: i64) -> u64 { + let local = i64::try_from(unix_secs).unwrap_or(i64::MAX - 7200) + offset; + let next = (local.div_euclid(3600) + 1) * 3600 - offset; + u64::try_from(next).unwrap_or(0) +} + +fn unix_now() -> Duration { + SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap_or_default() +} + +/// The post-process state that outlives a chain (the clock keeps running +/// across hot reloads). +#[derive(Debug)] +pub(crate) struct PostState { + /// First post-processed frame: `iTime` zero. + start: Option, + last_frame: Option, + frame: i32, + frame_rate: f32, + focused: bool, + focus_gained_at: Option, + mouse: Mouse, + /// A shader-input event the chain reads happened since the last frame. + event_pending: bool, + /// `Term::bell_revision` last seen; `None` until the first frame. + bell_seen: Option, + /// Newest first. + bells: [Option; 4], + bell_count: u32, + bell_cursor: [f32; 4], + cursor: Option, + previous_cursor: Option, + cursor_changed_at: Option, + local_offset: LocalOffset, + /// The last frame couldn't get a swapchain image because the window + /// is occluded: time-driven frames slow to [`OCCLUDED_INTERVAL`]. + occluded: bool, + uniforms: PostUniforms, +} + +impl Default for PostState { + fn default() -> Self { + Self { + start: None, + last_frame: None, + frame: 0, + frame_rate: 0.0, + focused: true, + focus_gained_at: None, + mouse: Mouse::default(), + event_pending: false, + bell_seen: None, + bells: [None; 4], + bell_count: 0, + bell_cursor: [0.0; 4], + cursor: None, + previous_cursor: None, + cursor_changed_at: None, + local_offset: LocalOffset::default(), + occluded: false, + uniforms: PostUniforms::default(), + } + } +} + +impl PostState { + /// `iTime` at `t`. + fn time_at(&self, t: Instant) -> f32 { + self.start + .map_or(0.0, |s| t.saturating_duration_since(s).as_secs_f32()) + } + + /// Window focus changed (or was re-reported). + pub(crate) fn set_focused( + &mut self, + focused: bool, + now: Instant, + inputs: &ShaderInputs, + mode: PostAnimation, + ) { + if focused == self.focused { + return; + } + self.focused = focused; + if focused { + self.focus_gained_at = Some(now); + } + // Under `auto`, focus also switches time-driven frames on and off + // (no duration), or opens a duration window (if focus counts). + let schedule = mode == PostAnimation::Auto + && inputs.reads(time_mask()) + && (inputs.event_duration.is_none() || (focused && window_events(inputs).2)); + if inputs.reads(focus_mask()) || schedule { + self.event_pending = true; + } + } + + /// Pointer moved, or button 1 changed. + pub(crate) fn set_pointer(&mut self, pos: (f32, f32), down: bool, inputs: &ShaderInputs) { + let before = self.mouse.uniform(); + let p = [pos.0, pos.1]; + if down { + if !self.mouse.down { + self.mouse.click = p; + self.mouse.pressed = true; + } + self.mouse.pos = p; + } + self.mouse.down = down; + #[allow(clippy::float_cmp)] // Any change at all is a new value. + let changed = self.mouse.uniform() != before; + if inputs.reads(mouse_mask()) && changed { + self.event_pending = true; + } + } + + /// Feed the terminal's bell counter. `cell` gives the bell cell as + /// `(left, bottom, width, height)` px (only called when there is a + /// new bell). Returns `true` on a new bell. + pub(crate) fn observe_bell( + &mut self, + revision: u32, + cell: impl FnOnce() -> [f32; 4], + now: Instant, + inputs: &ShaderInputs, + ) -> bool { + let Some(seen) = self.bell_seen.replace(revision) else { + // First look: bells from before post-processing don't count. + return false; + }; + if seen == revision { + return false; + } + self.bell_count = self.bell_count.wrapping_add(revision.wrapping_sub(seen)); + let rect = cell(); + self.bell_cursor = rect; + self.bells.rotate_right(1); + self.bells[0] = Some(Bell { + at: now, + center: [rect[0] + rect[2] * 0.5, rect[1] - rect[3] * 0.5], + }); + if inputs.reads(super::uniforms::bell_mask()) { + self.event_pending = true; + } + true + } + + /// Forget the bell counter (the chain was switched off, so bells + /// stop being observed). + pub(crate) fn reset_bell_tracking(&mut self) { + self.bell_seen = None; + } + + /// Force the next frame through (a mode or chain change). + pub(crate) fn request_frame(&mut self) { + self.event_pending = true; + } + + /// Latest event that starts an event-duration animation for a chain + /// with these inputs (see [`window_events`]). + fn last_event(&self, inputs: &ShaderInputs) -> Option { + let (bell, cursor, focus) = window_events(inputs); + [ + self.bells[0].map(|b| b.at).filter(|_| bell), + self.cursor_changed_at.filter(|_| cursor), + self.focus_gained_at.filter(|_| focus), + ] + .into_iter() + .flatten() + .max() + } + + /// Time until the chain wants its next frame, or `None` when it + /// needs none (idle). Zero means now. + pub(crate) fn next_frame_in( + &self, + now: Instant, + mode: PostAnimation, + inputs: &ShaderInputs, + ) -> Option { + if self.event_pending { + return Some(Duration::ZERO); + } + let interval = if self.occluded { + OCCLUDED_INTERVAL + } else { + FRAME_INTERVAL + }; + let tick = || { + self.last_frame.map_or(Duration::ZERO, |l| { + interval.saturating_sub(now.saturating_duration_since(l)) + }) + }; + match mode { + PostAnimation::Never => None, + PostAnimation::Always => Some(tick()), + PostAnimation::Auto if !inputs.reads(time_mask()) => None, + PostAnimation::Auto => match inputs.event_duration { + None => self.focused.then(tick), + Some(d) => { + let start = self.last_event(inputs)?; + let Some(end) = Duration::try_from_secs_f32(d) + .ok() + .and_then(|d| start.checked_add(d)) + else { + // Past what `Instant` can hold: never ends. + return Some(tick()); + }; + if now < end { + Some(tick().min(end - now)) + } else if self.last_frame.is_none_or(|l| l < end) { + // One frame at (or after) the end, so the shader + // shows its settled state. + Some(Duration::ZERO) + } else { + None + } + } + }, + } + } + + /// True when the chain wants a frame now. + pub(crate) fn frame_due( + &self, + now: Instant, + mode: PostAnimation, + inputs: &ShaderInputs, + ) -> bool { + self.next_frame_in(now, mode, inputs) == Some(Duration::ZERO) + } + + /// Update the uniforms for a frame at `now` and return them. + /// `needs_date` skips the wall-clock work when no shader reads + /// `iDate`. + pub(crate) fn begin_frame( + &mut self, + now: Instant, + f: &FrameInputs<'_>, + needs_date: bool, + ) -> &PostUniforms { + let start = *self.start.get_or_insert(now); + let delta = self + .last_frame + .map_or(0.0, |l| now.saturating_duration_since(l).as_secs_f32()); + // Idle gaps aren't a frame rate: only smooth over short deltas. + if delta > 0.0 && delta < 1.0 { + let rate = 1.0 / delta; + self.frame_rate = if self.frame_rate > 0.0 { + self.frame_rate * 0.9 + rate * 0.1 + } else { + rate + }; + } + + // Cursor history. + match self.cursor { + Some(cur) if cur != f.cursor => { + self.previous_cursor = Some(cur); + self.cursor = Some(f.cursor); + self.cursor_changed_at = Some(now); + } + None => self.cursor = Some(f.cursor), + Some(_) => {} + } + let previous = self.previous_cursor.unwrap_or(f.cursor); + + let time = now.saturating_duration_since(start).as_secs_f32(); + let date = if needs_date { self.date() } else { [0.0; 4] }; + let mut bells = [EMPTY_BELL; 4]; + for (slot, bell) in bells.iter_mut().zip(&self.bells) { + if let Some(b) = bell { + #[allow(clippy::cast_possible_truncation)] + let ago = now.saturating_duration_since(b.at).as_secs_f64() as f32; + *slot = [b.center[0], b.center[1], self.time_at(b.at), ago]; + } + } + let time_bell = self.bells[0].map_or(NEVER, |b| self.time_at(b.at)); + let time_cursor_change = self.cursor_changed_at.map_or(NEVER, |t| self.time_at(t)); + let time_focus = self.focus_gained_at.map_or(0.0, |t| self.time_at(t)); + let mouse = self.mouse.uniform(); + let focus = i32::from(self.focused); + let frame = self.frame; + let rgb = |c: [f32; 3]| [c[0], c[1], c[2], 0.0]; + + let u = &mut self.uniforms; + u.i_resolution = [f.resolution[0], f.resolution[1], 1.0]; + u.i_time = time; + u.i_time_delta = delta; + u.i_frame_rate = self.frame_rate; + u.i_frame = frame; + u.i_channel_time = [0.0; 4]; + u.i_channel_resolution = [[0.0; 4]; 4]; + u.i_channel_resolution[0] = [f.resolution[0], f.resolution[1], 1.0, 0.0]; + u.i_mouse = mouse; + u.i_date = date; + u.i_sample_rate = 0.0; + u.i_current_cursor = f.cursor.rect; + u.i_previous_cursor = previous.rect; + u.i_current_cursor_color = f.cursor.color; + u.i_previous_cursor_color = previous.color; + u.i_current_cursor_style = f.cursor.style; + u.i_previous_cursor_style = previous.style; + u.i_cursor_visible = i32::from(f.cursor_visible); + u.i_time_cursor_change = time_cursor_change; + u.i_time_focus = time_focus; + u.i_focus = focus; + u.i_time_bell = time_bell; + u.i_bell_cursor = self.bell_cursor; + u.i_bell_count = self.bell_count as i32; + u.i_bells = bells; + u.i_cell_size = f.cell_size; + u.i_grid_rect = f.grid_rect; + u.i_background_color = rgb(f.background); + u.i_foreground_color = rgb(f.foreground); + u.i_cursor_color = rgb(f.cursor_color); + u.i_cursor_text = rgb(f.cursor_text); + u.i_selection_background_color = rgb(f.selection_background); + u.i_selection_foreground_color = rgb(f.selection_foreground); + if let Some(p) = f.palette { + for (dst, src) in u.i_palette.iter_mut().zip(p) { + *dst = rgb(*src); + } + } + &self.uniforms + } + + /// A post-processed frame at `now` was submitted. + pub(crate) fn end_frame(&mut self, now: Instant) { + self.last_frame = Some(now); + self.frame = self.frame.wrapping_add(1); + self.event_pending = false; + self.mouse.pressed = false; + self.occluded = false; + } + + /// A post frame at `now` was wanted but couldn't be drawn (no + /// swapchain image: occluded, outdated, timed out). Counts as shown for + /// the schedule, so an animating chain doesn't spin on a hidden window; + /// the next frame that draws shows the current state anyway. While + /// `occluded`, time-driven frames slow to [`OCCLUDED_INTERVAL`] and a + /// pending event frame is dropped (the window is hidden). Otherwise + /// (timeout, outdated surface) the event frame stays pending, to be + /// drawn on the next try: those don't repeat without blocking, so + /// this can't spin. + pub(crate) fn frame_dropped(&mut self, now: Instant, occluded: bool) { + self.last_frame = Some(now); + if occluded { + self.event_pending = false; + } + self.occluded = occluded; + } + + /// Start the local UTC offset lookup now (a chain that reads `iDate` + /// was installed), so the first frames aren't shown in UTC. + pub(crate) fn prepare_date(&self) { + self.local_offset.get(unix_now().as_secs()); + } + + /// `iDate`: `(year, month - 1, day, seconds since local midnight)`. + fn date(&self) -> [f32; 4] { + let since_epoch = unix_now(); + date_uniform(since_epoch, self.local_offset.get(since_epoch.as_secs())) + } +} + +/// `iDate` for a time `since_epoch` (UTC) in a zone `offset` seconds east +/// of UTC. +#[allow(clippy::cast_precision_loss)] +pub(crate) fn date_uniform(since_epoch: Duration, offset: i64) -> [f32; 4] { + let local = since_epoch.as_secs_f64() + offset as f64; + let days = (local / 86_400.0).floor(); + let secs = local - days * 86_400.0; + #[allow(clippy::cast_possible_truncation)] + let (y, m, d) = civil_from_days(days as i64); + #[allow(clippy::cast_possible_truncation)] + [y as f32, (m - 1) as f32, d as f32, secs as f32] +} + +/// Proleptic Gregorian `(year, month 1..=12, day 1..=31)` for days since +/// 1970-01-01 (Howard Hinnant's `civil_from_days`). +fn civil_from_days(z: i64) -> (i64, u32, u32) { + let z = z + 719_468; + let era = z.div_euclid(146_097); + let doe = z.rem_euclid(146_097); + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = (doy - (153 * mp + 2) / 5 + 1) as u32; + let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; + (if m <= 2 { y + 1 } else { y }, m, d) +} + +/// The local zone's current offset from UTC in seconds, from `date +%z` +/// (blocks; runs on [`LocalOffset`]'s helper thread). +fn local_utc_offset() -> Option { + let out = std::process::Command::new("date") + .arg("+%z") + .stdin(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .output() + .ok()?; + parse_utc_offset(std::str::from_utf8(&out.stdout).ok()?) +} + +/// Parse `+hhmm` / `-hhmm`. +fn parse_utc_offset(s: &str) -> Option { + let s = s.trim(); + let (sign, digits) = match s.as_bytes().first()? { + b'+' => (1, &s[1..]), + b'-' => (-1, &s[1..]), + _ => return None, + }; + if digits.len() != 4 || !digits.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + let h: i64 = digits[..2].parse().ok()?; + let m: i64 = digits[2..].parse().ok()?; + Some(sign * (h * 3600 + m * 60)) +} + +#[cfg(test)] +#[allow(clippy::float_cmp, clippy::cast_precision_loss)] // exact test values +mod tests { + use super::*; + use crate::post::uniforms::{bell_mask, mask_of}; + + fn reads(names: &[&str], duration: Option) -> ShaderInputs { + ShaderInputs { + used: mask_of(names), + event_duration: duration, + } + } + + fn frame_inputs(cursor_x: f32) -> FrameInputs<'static> { + FrameInputs { + resolution: [800.0, 600.0], + cursor: CursorSnap { + rect: [cursor_x, 20.0, 10.0, 20.0], + color: [1.0, 1.0, 0.0, 1.0], + style: 0, + }, + cursor_visible: true, + cell_size: [10.0, 20.0], + grid_rect: [0.0, 0.0, 800.0, 600.0], + background: [0.0; 3], + foreground: [1.0; 3], + cursor_color: [1.0, 1.0, 0.0], + cursor_text: [0.0; 3], + selection_background: [0.5; 3], + selection_foreground: [1.0; 3], + palette: None, + } + } + + fn ms(n: u64) -> Duration { + Duration::from_millis(n) + } + + #[test] + fn static_shader_never_animates_in_auto() { + let s = PostState::default(); + let now = Instant::now(); + let inputs = reads(&["iResolution", "iPalette"], None); + assert_eq!(s.next_frame_in(now, PostAnimation::Auto, &inputs), None); + assert_eq!(s.next_frame_in(now, PostAnimation::Never, &inputs), None); + assert!( + s.next_frame_in(now, PostAnimation::Always, &inputs) + .is_some() + ); + } + + #[test] + fn time_shader_animates_while_focused() { + let mut s = PostState::default(); + let t0 = Instant::now(); + let inputs = reads(&["iTime"], None); + s.begin_frame(t0, &frame_inputs(0.0), false); + s.end_frame(t0); + assert_eq!( + s.next_frame_in(t0 + ms(4), PostAnimation::Auto, &inputs), + Some(ms(12)) + ); + assert!(s.frame_due(t0 + ms(16), PostAnimation::Auto, &inputs)); + s.set_focused(false, t0 + ms(5), &inputs, PostAnimation::Auto); + // The focus change itself is one frame... + assert!(s.frame_due(t0 + ms(5), PostAnimation::Auto, &inputs)); + s.end_frame(t0 + ms(5)); + // ...then idle while unfocused. + assert_eq!( + s.next_frame_in(t0 + ms(50), PostAnimation::Auto, &inputs), + None + ); + // `always` keeps going unfocused. + assert!(s.frame_due(t0 + ms(50), PostAnimation::Always, &inputs)); + } + + #[test] + fn duration_shader_animates_only_after_events() { + let mut s = PostState::default(); + let t0 = Instant::now(); + let inputs = reads(&["iTime", "iBells"], Some(1.0)); + s.begin_frame(t0, &frame_inputs(0.0), false); + s.end_frame(t0); + // Nothing happened yet: idle. + assert_eq!( + s.next_frame_in(t0 + ms(100), PostAnimation::Auto, &inputs), + None + ); + // First observation only primes the counter. + assert!(!s.observe_bell(0, || [0.0; 4], t0, &inputs)); + let bell_at = t0 + ms(200); + assert!(s.observe_bell(1, || [10.0, 40.0, 10.0, 20.0], bell_at, &inputs)); + assert!(s.frame_due(bell_at, PostAnimation::Auto, &inputs)); + s.begin_frame(bell_at, &frame_inputs(0.0), false); + s.end_frame(bell_at + ms(985)); + // Ticking inside the window, capped by its end. + assert_eq!( + s.next_frame_in(bell_at + ms(990), PostAnimation::Auto, &inputs), + Some(ms(10)) + ); + // One settling frame past the end, then idle. + assert!(s.frame_due(bell_at + ms(1000), PostAnimation::Auto, &inputs)); + s.end_frame(bell_at + ms(1001)); + assert_eq!( + s.next_frame_in(bell_at + ms(1100), PostAnimation::Auto, &inputs), + None + ); + // `never` gives the bell frame but no animation. + assert!(s.observe_bell(2, || [0.0; 4], bell_at + ms(2000), &inputs)); + assert!(s.frame_due(bell_at + ms(2000), PostAnimation::Never, &inputs)); + s.end_frame(bell_at + ms(2000)); + assert_eq!( + s.next_frame_in(bell_at + ms(2010), PostAnimation::Never, &inputs), + None + ); + } + + #[test] + fn cursor_change_opens_the_duration_window() { + let mut s = PostState::default(); + let t0 = Instant::now(); + let inputs = reads(&["iTime", "iTimeCursorChange"], Some(0.5)); + let u = s.begin_frame(t0, &frame_inputs(0.0), false); + assert_eq!(u.i_time_cursor_change, NEVER); + s.end_frame(t0); + assert_eq!( + s.next_frame_in(t0 + ms(20), PostAnimation::Auto, &inputs), + None + ); + let t1 = t0 + ms(100); + let u = s.begin_frame(t1, &frame_inputs(10.0), false); + assert!((u.i_time_cursor_change - 0.1).abs() < 1e-4); + assert_eq!(u.i_previous_cursor[0], 0.0); + assert_eq!(u.i_current_cursor[0], 10.0); + s.end_frame(t1); + assert!(s.frame_due(t1 + ms(20), PostAnimation::Auto, &inputs)); + } + + #[test] + fn events_only_wake_shaders_that_read_them() { + let mut s = PostState::default(); + let t0 = Instant::now(); + let none = reads(&["iResolution"], None); + s.set_pointer((5.0, 5.0), true, &none); + s.set_focused(false, t0, &none, PostAnimation::Auto); + s.observe_bell(0, || [0.0; 4], t0, &none); + s.observe_bell(1, || [0.0; 4], t0, &none); + assert!(!s.frame_due(t0, PostAnimation::Auto, &none)); + + let mouse = reads(&["iMouse"], None); + let mut s = PostState::default(); + // Moving without the button doesn't change iMouse. + s.set_pointer((5.0, 5.0), false, &mouse); + assert!(!s.frame_due(t0, PostAnimation::Auto, &mouse)); + s.set_pointer((5.0, 5.0), true, &mouse); + assert!(s.frame_due(t0, PostAnimation::Auto, &mouse)); + + let bell = ShaderInputs { + used: bell_mask(), + event_duration: None, + }; + let mut s = PostState::default(); + s.observe_bell(7, || [0.0; 4], t0, &bell); + s.observe_bell(9, || [0.0; 4], t0, &bell); + assert!(s.frame_due(t0, PostAnimation::Never, &bell)); + let u = s.begin_frame(t0, &frame_inputs(0.0), false); + assert_eq!(u.i_bell_count, 2); + } + + #[test] + fn mouse_follows_shadertoy_semantics() { + let mut s = PostState::default(); + let t0 = Instant::now(); + let inputs = reads(&["iMouse"], None); + s.set_pointer((10.0, 20.0), true, &inputs); + let u = s.begin_frame(t0, &frame_inputs(0.0), false); + assert_eq!(u.i_mouse, [10.0, 20.0, 10.0, 20.0]); + s.end_frame(t0); + s.set_pointer((30.0, 40.0), true, &inputs); + let u = s.begin_frame(t0, &frame_inputs(0.0), false); + assert_eq!(u.i_mouse, [30.0, 40.0, 10.0, -20.0]); + s.end_frame(t0); + s.set_pointer((50.0, 60.0), false, &inputs); + let u = s.begin_frame(t0, &frame_inputs(0.0), false); + assert_eq!(u.i_mouse, [30.0, 40.0, -10.0, -20.0]); + } + + #[test] + fn bells_are_newest_first_with_exact_age() { + let mut s = PostState::default(); + let t0 = Instant::now(); + let inputs = reads(&["iBells"], None); + s.begin_frame(t0, &frame_inputs(0.0), false); + s.end_frame(t0); + s.observe_bell(0, || [0.0; 4], t0, &inputs); + for i in 1..=5u32 { + let x = i as f32 * 10.0; + s.observe_bell( + i, + || [x, 40.0, 10.0, 20.0], + t0 + ms(u64::from(i) * 100), + &inputs, + ); + } + let u = *s.begin_frame(t0 + ms(1000), &frame_inputs(0.0), false); + // Newest (5th) first; centre of the cell. + assert_eq!(u.i_bells[0][0], 55.0); + assert_eq!(u.i_bells[0][1], 30.0); + assert!((u.i_bells[0][2] - 0.5).abs() < 1e-5); + assert!((u.i_bells[0][3] - 0.5).abs() < 1e-5); + assert_eq!(u.i_bells[3][0], 25.0); + assert!((u.i_time_bell - 0.5).abs() < 1e-5); + assert_eq!(u.i_bell_cursor, [50.0, 40.0, 10.0, 20.0]); + assert_eq!(u.i_bell_count, 5); + // Fewer bells than slots: the rest are empty. + let mut s = PostState::default(); + s.observe_bell(0, || [0.0; 4], t0, &inputs); + s.observe_bell(1, || [0.0; 4], t0, &inputs); + let u = s.begin_frame(t0, &frame_inputs(0.0), false); + assert_eq!(u.i_bells[1], EMPTY_BELL); + } + + #[test] + fn clock_and_frame_counter() { + let mut s = PostState::default(); + let t0 = Instant::now(); + let u = *s.begin_frame(t0, &frame_inputs(0.0), false); + assert_eq!((u.i_time, u.i_time_delta, u.i_frame), (0.0, 0.0, 0)); + assert_eq!(u.i_resolution, [800.0, 600.0, 1.0]); + assert_eq!(u.i_channel_resolution[0], [800.0, 600.0, 1.0, 0.0]); + assert_eq!(u.i_time_bell, NEVER); + s.end_frame(t0); + let u = *s.begin_frame(t0 + ms(20), &frame_inputs(0.0), false); + assert!((u.i_time - 0.02).abs() < 1e-6); + assert!((u.i_time_delta - 0.02).abs() < 1e-6); + assert!((u.i_frame_rate - 50.0).abs() < 0.01); + assert_eq!(u.i_frame, 1); + assert_eq!(u.i_focus, 1); + } + + #[test] + fn date_uniform_splits_civil_time() { + // 2024-02-29 13:30:15 UTC. + let t = Duration::from_secs(1_709_213_415); + assert_eq!(date_uniform(t, 0), [2024.0, 1.0, 29.0, 48_615.0]); + // Two hours east: 15:30:15 the same day. + assert_eq!(date_uniform(t, 7200), [2024.0, 1.0, 29.0, 55_815.0]); + // Fourteen hours west: the previous day. + assert_eq!( + date_uniform(t, -14 * 3600), + [2024.0, 1.0, 28.0, 48_615.0 - 14.0 * 3600.0 + 86_400.0] + ); + assert_eq!(civil_from_days(0), (1970, 1, 1)); + } + + #[test] + fn huge_duration_does_not_overflow() { + let mut s = PostState::default(); + let t0 = Instant::now(); + let inputs = reads(&["iTime", "iBells"], Some(f32::MAX)); + s.observe_bell(0, || [0.0; 4], t0, &inputs); + s.observe_bell(1, || [0.0; 4], t0, &inputs); + s.end_frame(t0); + assert!( + s.next_frame_in(t0 + ms(1), PostAnimation::Auto, &inputs) + .is_some() + ); + } + + #[test] + fn events_open_windows_only_for_what_the_chain_reads() { + let t0 = Instant::now(); + // Reads the bells only: a cursor move or focus gain doesn't + // animate. + let bells = reads(&["iTime", "iBells"], Some(1.0)); + let mut s = PostState::default(); + s.begin_frame(t0, &frame_inputs(0.0), false); + s.end_frame(t0); + s.begin_frame(t0 + ms(10), &frame_inputs(10.0), false); // cursor moved + s.end_frame(t0 + ms(10)); + s.set_focused(false, t0 + ms(20), &bells, PostAnimation::Auto); + s.set_focused(true, t0 + ms(30), &bells, PostAnimation::Auto); + assert_eq!( + s.next_frame_in(t0 + ms(100), PostAnimation::Auto, &bells), + None + ); + s.observe_bell(0, || [0.0; 4], t0, &bells); + s.observe_bell(1, || [0.0; 4], t0 + ms(200), &bells); + s.end_frame(t0 + ms(200)); + assert!( + s.next_frame_in(t0 + ms(210), PostAnimation::Auto, &bells) + .is_some() + ); + + // Reads the cursor only: a bell doesn't animate, a move does. + let cursor = reads(&["iTime", "iTimeCursorChange"], Some(1.0)); + let mut s = PostState::default(); + s.begin_frame(t0, &frame_inputs(0.0), false); + s.end_frame(t0); + s.observe_bell(0, || [0.0; 4], t0, &cursor); + s.observe_bell(1, || [0.0; 4], t0 + ms(10), &cursor); + assert_eq!( + s.next_frame_in(t0 + ms(20), PostAnimation::Auto, &cursor), + None + ); + s.begin_frame(t0 + ms(30), &frame_inputs(10.0), false); + s.end_frame(t0 + ms(30)); + assert!( + s.next_frame_in(t0 + ms(40), PostAnimation::Auto, &cursor) + .is_some() + ); + + // Reads none of the event groups: every event counts. + let any = reads(&["iTime"], Some(1.0)); + let mut s = PostState::default(); + s.observe_bell(0, || [0.0; 4], t0, &any); + s.observe_bell(1, || [0.0; 4], t0, &any); + s.end_frame(t0); + assert!( + s.next_frame_in(t0 + ms(10), PostAnimation::Auto, &any) + .is_some() + ); + let mut s = PostState::default(); + s.end_frame(t0); + s.set_focused(false, t0, &any, PostAnimation::Auto); + s.set_focused(true, t0 + ms(5), &any, PostAnimation::Auto); + assert!(s.frame_due(t0 + ms(5), PostAnimation::Auto, &any)); + } + + #[test] + fn dropped_event_frame_is_kept_unless_occluded() { + let t0 = Instant::now(); + let inputs = reads(&["iMouse"], None); + let mut s = PostState::default(); + s.set_pointer((1.0, 1.0), true, &inputs); + s.frame_dropped(t0, false); + assert!(s.frame_due(t0, PostAnimation::Auto, &inputs)); + s.frame_dropped(t0, true); + assert!(!s.frame_due(t0, PostAnimation::Auto, &inputs)); + } + + #[test] + fn prepare_date_starts_the_offset_lookup() { + let s = PostState::default(); + s.prepare_date(); + // The helper thread schedules the next lookup when it's done + // (whether or not `date` exists). + let deadline = Instant::now() + Duration::from_secs(5); + while s.local_offset.0.refresh_at.load(Ordering::Relaxed) == 0 { + assert!(Instant::now() < deadline, "lookup never finished"); + std::thread::sleep(ms(5)); + } + } + + #[test] + fn occluded_window_polls_slowly() { + assert_eq!(OCCLUDED_INTERVAL, ms(250)); + let mut s = PostState::default(); + let t0 = Instant::now(); + let inputs = reads(&["iTime"], None); + s.frame_dropped(t0, true); + assert_eq!( + s.next_frame_in(t0 + ms(50), PostAnimation::Always, &inputs), + Some(ms(200)) + ); + s.end_frame(t0 + ms(300)); + assert_eq!( + s.next_frame_in(t0 + ms(304), PostAnimation::Always, &inputs), + Some(ms(12)) + ); + } + + #[test] + fn next_local_hour_follows_the_zone() { + // 2024-02-29 13:30:15 UTC. + let t = 1_709_213_415; + assert_eq!(next_local_hour(t, 0), 1_709_215_200); // 14:00 UTC + // +05:30: local 19:00:15, next local hour 20:00 = 14:30 UTC. + assert_eq!(next_local_hour(t, 5 * 3600 + 1800), 1_709_217_000); + assert_eq!(next_local_hour(1_709_215_200, 0), 1_709_215_200 + 3600); + } + + #[test] + fn utc_offset_parses() { + assert_eq!(parse_utc_offset("+0530\n"), Some(5 * 3600 + 30 * 60)); + assert_eq!(parse_utc_offset("-0700"), Some(-7 * 3600)); + assert_eq!(parse_utc_offset("Z"), None); + assert_eq!(parse_utc_offset("+5"), None); + } +} diff --git a/crates/toastty-render/src/post/uniforms.rs b/crates/toastty-render/src/post/uniforms.rs new file mode 100644 index 00000000..d45633dd --- /dev/null +++ b/crates/toastty-render/src/post/uniforms.rs @@ -0,0 +1,216 @@ +//! The post-process uniform block: its Rust layout and the member table +//! the GLSL and WGSL preludes are generated from. +//! +//! One byte layout serves both languages. GLSL declares the block +//! `std140`; WGSL's uniform address-space rules give the same offsets for +//! these member types (`vec3` aligns to 16 and a scalar may follow it in +//! the same 16 bytes, array elements stride 16). A test parses both +//! preludes and checks naga's offsets against [`PostUniforms`]. +//! +//! `iChannelTime` is a `vec4`, not Ghostty's `float[4]`: a `float[4]` in +//! std140 has a 16-byte stride that WGSL can't express, so the module +//! wouldn't survive the GLSL → WGSL step. Indexing (`iChannelTime[i]`) +//! reads the same either way. + +use bytemuck::{Pod, Zeroable}; + +/// Seconds-valued uniforms use this for "never happened" (`iTimeBell`, +/// `iTimeCursorChange`, unused `iBells` slots). +pub(crate) const NEVER: f32 = -1e6; + +/// The uniform block, as uploaded. `vec3` members are stored as +/// `[f32; 4]` (the fourth float is std140 padding). +#[repr(C)] +#[derive(Debug, Clone, Copy, Pod, Zeroable)] +pub(crate) struct PostUniforms { + pub i_resolution: [f32; 3], + pub i_time: f32, + pub i_mouse: [f32; 4], + pub i_date: [f32; 4], + pub i_channel_time: [f32; 4], + pub i_channel_resolution: [[f32; 4]; 4], + pub i_current_cursor: [f32; 4], + pub i_previous_cursor: [f32; 4], + pub i_current_cursor_color: [f32; 4], + pub i_previous_cursor_color: [f32; 4], + pub i_bell_cursor: [f32; 4], + pub i_bells: [[f32; 4]; 4], + pub i_grid_rect: [f32; 4], + pub i_cell_size: [f32; 2], + pub i_time_delta: f32, + pub i_frame_rate: f32, + pub i_frame: i32, + pub i_sample_rate: f32, + pub i_current_cursor_style: i32, + pub i_previous_cursor_style: i32, + pub i_cursor_visible: i32, + pub i_time_cursor_change: f32, + pub i_time_focus: f32, + pub i_focus: i32, + pub i_time_bell: f32, + pub i_bell_count: i32, + pub _pad0: [u32; 2], + pub i_background_color: [f32; 4], + pub i_foreground_color: [f32; 4], + pub i_cursor_color: [f32; 4], + pub i_cursor_text: [f32; 4], + pub i_selection_background_color: [f32; 4], + pub i_selection_foreground_color: [f32; 4], + pub i_palette: [[f32; 4]; 256], +} + +/// Byte offset of `iPalette`. Everything before it is rewritten every +/// post-processed frame; the palette only when it changes. +pub(crate) const PALETTE_OFFSET: usize = core::mem::offset_of!(PostUniforms, i_palette); + +impl Default for PostUniforms { + fn default() -> Self { + let mut u = Self::zeroed(); + u.i_time_cursor_change = NEVER; + u.i_time_bell = NEVER; + u.i_bells = [EMPTY_BELL; 4]; + u + } +} + +/// An unused `iBells` slot: at the origin, rung "never", infinitely long +/// ago. +pub(crate) const EMPTY_BELL: [f32; 4] = [0.0, 0.0, NEVER, 1e6]; + +/// One uniform-block member: its name (same in both languages), its +/// GLSL type and array suffix, its WGSL type, and its offset in +/// [`PostUniforms`]. Index in [`MEMBERS`] = member index in the naga +/// struct. +#[derive(Debug, Clone, Copy)] +pub(crate) struct Member { + pub name: &'static str, + pub glsl: &'static str, + pub glsl_array: &'static str, + pub wgsl: &'static str, + /// Only read by the layout test. + #[cfg_attr(not(test), allow(dead_code))] + pub offset: usize, +} + +macro_rules! members { + ($( $name:literal $glsl:literal $arr:literal $wgsl:literal => $field:ident ),* $(,)?) => { + &[$( Member { + name: $name, + glsl: $glsl, + glsl_array: $arr, + wgsl: $wgsl, + offset: core::mem::offset_of!(PostUniforms, $field), + } ),*] + }; +} + +/// The uniform block, in declaration order. +pub(crate) const MEMBERS: &[Member] = members![ + "iResolution" "vec3" "" "vec3f" => i_resolution, + "iTime" "float" "" "f32" => i_time, + "iMouse" "vec4" "" "vec4f" => i_mouse, + "iDate" "vec4" "" "vec4f" => i_date, + "iChannelTime" "vec4" "" "vec4f" => i_channel_time, + "iChannelResolution" "vec3" "[4]" "array" => i_channel_resolution, + "iCurrentCursor" "vec4" "" "vec4f" => i_current_cursor, + "iPreviousCursor" "vec4" "" "vec4f" => i_previous_cursor, + "iCurrentCursorColor" "vec4" "" "vec4f" => i_current_cursor_color, + "iPreviousCursorColor" "vec4" "" "vec4f" => i_previous_cursor_color, + "iBellCursor" "vec4" "" "vec4f" => i_bell_cursor, + "iBells" "vec4" "[4]" "array" => i_bells, + "iGridRect" "vec4" "" "vec4f" => i_grid_rect, + "iCellSize" "vec2" "" "vec2f" => i_cell_size, + "iTimeDelta" "float" "" "f32" => i_time_delta, + "iFrameRate" "float" "" "f32" => i_frame_rate, + "iFrame" "int" "" "i32" => i_frame, + "iSampleRate" "float" "" "f32" => i_sample_rate, + "iCurrentCursorStyle" "int" "" "i32" => i_current_cursor_style, + "iPreviousCursorStyle" "int" "" "i32" => i_previous_cursor_style, + "iCursorVisible" "int" "" "i32" => i_cursor_visible, + "iTimeCursorChange" "float" "" "f32" => i_time_cursor_change, + "iTimeFocus" "float" "" "f32" => i_time_focus, + "iFocus" "int" "" "i32" => i_focus, + "iTimeBell" "float" "" "f32" => i_time_bell, + "iBellCount" "int" "" "i32" => i_bell_count, + "iBackgroundColor" "vec3" "" "vec3f" => i_background_color, + "iForegroundColor" "vec3" "" "vec3f" => i_foreground_color, + "iCursorColor" "vec3" "" "vec3f" => i_cursor_color, + "iCursorText" "vec3" "" "vec3f" => i_cursor_text, + "iSelectionBackgroundColor" "vec3" "" "vec3f" => i_selection_background_color, + "iSelectionForegroundColor" "vec3" "" "vec3f" => i_selection_foreground_color, + "iPalette" "vec3" "[256]" "array" => i_palette, +]; + +/// Bit mask over [`MEMBERS`] indexes for the named members. +pub(crate) fn mask_of(names: &[&str]) -> u64 { + MEMBERS + .iter() + .enumerate() + .filter(|(_, m)| names.contains(&m.name)) + .fold(0, |acc, (i, _)| acc | (1u64 << i)) +} + +/// Uniforms that move with wall-clock time alone. +pub(crate) fn time_mask() -> u64 { + mask_of(&["iTime", "iTimeDelta", "iFrame", "iDate", "iBells"]) +} + +/// `iDate` (the wall clock is only read for shaders that use it). +pub(crate) fn date_mask() -> u64 { + mask_of(&["iDate"]) +} + +/// `iMouse`. +pub(crate) fn mouse_mask() -> u64 { + mask_of(&["iMouse"]) +} + +/// Uniforms that change when the window gains or loses focus. +pub(crate) fn focus_mask() -> u64 { + mask_of(&["iFocus", "iTimeFocus"]) +} + +/// Uniforms that change when the cursor moves or changes colour/style. +pub(crate) fn cursor_mask() -> u64 { + mask_of(&[ + "iCurrentCursor", + "iPreviousCursor", + "iCurrentCursorColor", + "iPreviousCursorColor", + "iCurrentCursorStyle", + "iPreviousCursorStyle", + "iCursorVisible", + "iTimeCursorChange", + ]) +} + +/// Uniforms that change on a BEL. +pub(crate) fn bell_mask() -> u64 { + mask_of(&["iTimeBell", "iBellCursor", "iBellCount", "iBells"]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn members_fit_the_mask() { + assert!(MEMBERS.len() <= 64); + } + + #[test] + fn block_size_is_a_multiple_of_16() { + assert_eq!(size_of::() % 16, 0); + assert_eq!(PALETTE_OFFSET % 16, 0); + assert_eq!(size_of::(), PALETTE_OFFSET + 256 * 16); + } + + #[test] + fn masks_name_real_members() { + assert_eq!(time_mask().count_ones(), 5); + assert_eq!(mouse_mask().count_ones(), 1); + assert_eq!(focus_mask().count_ones(), 2); + assert_eq!(bell_mask().count_ones(), 4); + assert_eq!(cursor_mask().count_ones(), 8); + } +} diff --git a/crates/toastty-render/src/render_tests.rs b/crates/toastty-render/src/render_tests.rs new file mode 100644 index 00000000..09abd4c7 --- /dev/null +++ b/crates/toastty-render/src/render_tests.rs @@ -0,0 +1,221 @@ +//! End-to-end tests through [`Renderer::render_term`] on the headless +//! target (an offscreen `Bgra8UnormSrgb` texture standing in for the +//! swapchain): the real frame path, with and without post shaders. + +use std::path::PathBuf; + +use toastty_parser::Parser; +use toastty_term::Term; + +use crate::{DEFAULT_FONT_SIZE_PX, PostShaderSource, RenderOutcome, Renderer}; + +const W: u32 = 400; +const H: u32 = 240; + +/// Pass-through: samples `iChannel0` at the fragment's own pixel. +const PASS: &str = "void mainImage(out vec4 fragColor, in vec2 fragCoord) {\n\ + \x20 fragColor = texture(iChannel0, fragCoord / iResolution.xy);\n\ + }\n"; + +const INVERT: &str = "void mainImage(out vec4 fragColor, in vec2 fragCoord) {\n\ + \x20 vec4 c = texture(iChannel0, fragCoord / iResolution.xy);\n\ + \x20 fragColor = vec4(1.0 - c.rgb, c.a);\n\ + }\n"; + +/// White while button 1 is held, pass-through otherwise. +const MOUSE: &str = "void mainImage(out vec4 fragColor, in vec2 fragCoord) {\n\ + \x20 vec4 c = texture(iChannel0, fragCoord / iResolution.xy);\n\ + \x20 fragColor = iMouse.z > 0.0 ? vec4(1.0) : c;\n\ + }\n"; + +fn shader(name: &str, source: &str) -> Vec { + vec![PostShaderSource { + path: PathBuf::from(name), + source: source.to_owned(), + }] +} + +fn renderer(surface_view_formats: bool) -> Option { + let mut r = Renderer::headless(W, H, surface_view_formats)?; + r.with_font(None, DEFAULT_FONT_SIZE_PX); + Some(r) +} + +/// A term with some coloured text in the top rows (the bottom rows stay +/// blank, under where the error banner goes) and a steady cursor, so +/// blink timing can't differ between two renderers. +fn term(r: &Renderer) -> Term { + let (cw, ch) = r.cell_size(); + #[allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss + )] + let (rows, cols) = ((H as f32 / ch) as u16, (W as f32 / cw) as u16); + let mut t = Term::new(rows, cols, 0); + Parser::new().advance( + &mut t, + b"\x1b[2 qhello \x1b[31mred \x1b[42mgreen bg\x1b[0m\r\n\x1b[1mbold\x1b[0m $ ", + ); + t +} + +fn feed(t: &mut Term, bytes: &[u8]) { + Parser::new().advance(t, bytes); +} + +/// Render a frame; clear the damage the way the binary does. +fn frame(r: &mut Renderer, t: &mut Term) -> (RenderOutcome, Vec) { + let outcome = r.render_term(t).expect("render_term"); + if outcome == RenderOutcome::Rendered { + t.clear_damage(); + } + (outcome, r.read_offscreen()) +} + +fn px(img: &[u8], x: u32, y: u32) -> [u8; 4] { + let i = ((y * W + x) * 4) as usize; + [img[i], img[i + 1], img[i + 2], img[i + 3]] +} + +#[test] +fn pass_through_chain_matches_the_plain_frame_path() { + for view_formats in [true, false] { + let (Some(mut plain), Some(mut post)) = (renderer(view_formats), renderer(view_formats)) + else { + return; + }; + post.set_post_shaders(&shader("p.glsl", PASS)) + .expect("compiles"); + let (mut ta, mut tb) = (term(&plain), term(&post)); + // Full frame, then a partial (LoadOp::Load) frame. + let a = frame(&mut plain, &mut ta).1; + let colours: std::collections::HashSet<&[u8]> = a.chunks(4).collect(); + assert!(colours.len() > 3, "the frame should show text"); + assert!(a == frame(&mut post, &mut tb).1, "full frame differs"); + feed(&mut ta, b"more\r\n"); + feed(&mut tb, b"more\r\n"); + let (oa, a) = frame(&mut plain, &mut ta); + let (ob, b) = frame(&mut post, &mut tb); + assert_eq!((oa, ob), (RenderOutcome::Rendered, RenderOutcome::Rendered)); + assert!( + a == b, + "partial frame differs (view_formats={view_formats})" + ); + } +} + +#[test] +fn chain_shades_the_content_but_not_the_ui() { + let (Some(mut plain), Some(mut post)) = (renderer(true), renderer(true)) else { + return; + }; + post.set_post_shaders(&shader("inv.glsl", INVERT)) + .expect("compiles"); + for r in [&mut plain, &mut post] { + r.set_error_banner(Some("banner")); + r.set_debug_overlay(Some(" fps ")); + } + let (mut ta, mut tb) = (term(&plain), term(&post)); + let a = frame(&mut plain, &mut ta).1; + let b = frame(&mut post, &mut tb).1; + // Content (a text row and the blank grid): inverted, alpha kept. + for (x, y) in [(3, 8), (200, 120), (W - 5, H / 2)] { + let (pa, pb) = (px(&a, x, y), px(&b, x, y)); + assert_eq!( + pb, + [255 - pa[0], 255 - pa[1], 255 - pa[2], pa[3]], + "({x}, {y})" + ); + } + // Error banner (bottom row) and debug overlay (top right): as drawn + // without shaders. + let ch = plain.cell_size().1; + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + let banner_y = H - (ch as u32) / 2; + for (x, y) in [(2, banner_y), (100, banner_y), (W - 3, 4)] { + assert_eq!(px(&b, x, y), px(&a, x, y), "UI pixel ({x}, {y})"); + } +} + +#[test] +fn post_only_frame_runs_on_an_event_without_damage() { + let (Some(mut plain), Some(mut post)) = (renderer(true), renderer(true)) else { + return; + }; + post.set_post_shaders(&shader("m.glsl", MOUSE)) + .expect("compiles"); + let (mut ta, mut tb) = (term(&plain), term(&post)); + let first = frame(&mut post, &mut tb).1; + let _ = frame(&mut plain, &mut ta); + // Idle: nothing to draw. + assert_eq!(frame(&mut post, &mut tb).0, RenderOutcome::Skipped); + assert_eq!(post.next_redraw_deadline(&tb), None); + // A press the shader reads: a frame, though nothing is damaged. + post.set_pointer((10.0, 10.0), true); + plain.set_pointer((10.0, 10.0), true); + assert_eq!( + post.next_redraw_deadline(&tb), + Some(std::time::Duration::ZERO) + ); + let (outcome, pressed) = frame(&mut post, &mut tb); + assert_eq!(outcome, RenderOutcome::Rendered); + assert_eq!(px(&pressed, 50, 100), [255; 4]); + assert_eq!(frame(&mut plain, &mut ta).0, RenderOutcome::Skipped); + // Release: back to the terminal image. + post.set_pointer((10.0, 10.0), false); + let released = frame(&mut post, &mut tb).1; + assert!(released == first); +} + +#[test] +fn no_post_deadline_while_sync_output_pauses_rendering() { + let Some(mut post) = renderer(true) else { + return; + }; + post.set_post_shaders(&shader("m.glsl", MOUSE)) + .expect("compiles"); + let mut t = term(&post); + let _ = frame(&mut post, &mut t); + post.set_pointer((1.0, 1.0), true); + feed(&mut t, b"\x1b[?2026h"); + assert!(t.pause_rendering()); + assert_eq!(post.next_redraw_deadline(&t), None); + feed(&mut t, b"\x1b[?2026l"); + assert_eq!( + post.next_redraw_deadline(&t), + Some(std::time::Duration::ZERO) + ); +} + +#[test] +fn turning_the_chain_off_restores_the_plain_frame() { + let (Some(mut plain), Some(mut post)) = (renderer(true), renderer(true)) else { + return; + }; + post.set_post_shaders(&shader("inv.glsl", INVERT)) + .expect("compiles"); + let (mut ta, mut tb) = (term(&plain), term(&post)); + let a = frame(&mut plain, &mut ta).1; + let inverted = frame(&mut post, &mut tb).1; + assert!(inverted != a); + // A failed reload keeps the inverting chain. + let bad = shader( + "bad.glsl", + "void mainImage(out vec4 c, in vec2 p) { c = nope; }\n", + ); + let errs = post.set_post_shaders(&bad).expect_err("fails"); + assert_eq!(errs[0].line, Some(1)); + assert!(post.has_post_shaders()); + feed(&mut tb, b"\x1b[H"); + feed(&mut ta, b"\x1b[H"); + let a = frame(&mut plain, &mut ta).1; + assert_eq!( + px(&frame(&mut post, &mut tb).1, 200, 120)[0], + 255 - px(&a, 200, 120)[0] + ); + // Off: the plain path again, pixel for pixel. + post.set_post_shaders(&[]).expect("off"); + assert!(!post.has_post_shaders()); + assert!(frame(&mut post, &mut tb).1 == a); +} diff --git a/crates/toastty-render/src/test_gpu.rs b/crates/toastty-render/src/test_gpu.rs index 91e8bb2e..cbef3a18 100644 --- a/crates/toastty-render/src/test_gpu.rs +++ b/crates/toastty-render/src/test_gpu.rs @@ -49,3 +49,40 @@ pub(crate) fn try_device_and_queue() -> Option<(Device, Queue)> { pub(crate) fn device_and_queue() -> (Device, Queue) { try_device_and_queue().expect("no GPU adapter for test") } + +/// Copy `t` (4 bytes per texel) back to the CPU, tightly packed rows. +pub(crate) fn read_texture(device: &Device, queue: &Queue, t: &wgpu::Texture) -> Vec { + let (w, h) = (t.width(), t.height()); + let row = (w * 4).div_ceil(256) * 256; + let buf = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("test readback"), + size: u64::from(row * h), + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor::default()); + encoder.copy_texture_to_buffer( + t.as_image_copy(), + wgpu::TexelCopyBufferInfo { + buffer: &buf, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(row), + rows_per_image: Some(h), + }, + }, + t.size(), + ); + queue.submit(std::iter::once(encoder.finish())); + buf.slice(..).map_async(wgpu::MapMode::Read, |_| {}); + device + .poll(wgpu::PollType::wait_indefinitely()) + .expect("poll"); + let data = buf.slice(..).get_mapped_range(); + let mut out = Vec::with_capacity((w * h * 4) as usize); + for y in 0..h { + let start = (y * row) as usize; + out.extend_from_slice(&data[start..start + (w * 4) as usize]); + } + out +} diff --git a/crates/toastty-render/src/text/pipeline.rs b/crates/toastty-render/src/text/pipeline.rs index 79cd2781..9d3a8eff 100644 --- a/crates/toastty-render/src/text/pipeline.rs +++ b/crates/toastty-render/src/text/pipeline.rs @@ -235,13 +235,24 @@ impl TextPipeline { bind_group: &'pass BindGroup, instance_count: usize, ) { - if instance_count == 0 { + self.render_bg_range(pass, bind_group, 0..instance_count); + } + + /// [`Self::render_bg`] for the instances in `range` only (UI overlays + /// drawn after the post-process passes). + pub fn render_bg_range<'pass>( + &'pass self, + pass: &mut wgpu::RenderPass<'pass>, + bind_group: &'pass BindGroup, + range: std::ops::Range, + ) { + if range.is_empty() { return; } pass.set_pipeline(&self.bg_pipeline); pass.set_bind_group(0, bind_group, &[]); pass.set_vertex_buffer(0, self.instance_buffer.slice(..)); - pass.draw(0..4, 0..instance_count as u32); + pass.draw(0..4, range.start as u32..range.end as u32); } /// Record the glyph-pass draw. Runs after the RGP 3D pass; glyphs diff --git a/crates/toastty-term/src/term.rs b/crates/toastty-term/src/term.rs index 5e912cf3..7bc03ff1 100644 --- a/crates/toastty-term/src/term.rs +++ b/crates/toastty-term/src/term.rs @@ -286,6 +286,15 @@ pub struct Term { /// renderer keeps a cached linear-light palette and compares /// revisions to decide when to rebuild. palette_revision: u32, + /// Bump-counter incremented on every BEL (0x07) the program prints. + /// The renderer compares revisions to spot new bells (user shaders + /// animate on them). An OSC terminated by BEL never reaches + /// `execute`, so it doesn't count. + bell_revision: u32, + /// Cursor cell `(row, col)` in screen coordinates when the last BEL + /// arrived. Captured here because the cursor has usually moved on by + /// the time the next frame is drawn. + last_bell_cell: (u16, u16), /// Intern table for OSC 8 hyperlink URLs. The cell stores a /// `NonZeroU16` index into this `Vec`; closing the hyperlink /// (`OSC 8 ; ; ST`) clears `current_hyperlink` without touching @@ -592,6 +601,8 @@ impl Term { pty_replies: Vec::new(), palette_overrides: Box::new([None; 256]), palette_revision: 0, + bell_revision: 0, + last_bell_cell: (0, 0), hyperlinks: Vec::new(), current_hyperlink: None, extras: ExtraTable::default(), @@ -966,6 +977,19 @@ impl Term { self.palette_revision } + /// Bump-counter incremented on every BEL. See [`Self::last_bell_cell`]. + #[must_use] + pub fn bell_revision(&self) -> u32 { + self.bell_revision + } + + /// Cursor cell `(row, col)` in screen coordinates at the most recent + /// BEL. `(0, 0)` before the first one; check [`Self::bell_revision`]. + #[must_use] + pub fn last_bell_cell(&self) -> (u16, u16) { + self.last_bell_cell + } + /// Set palette override for `idx` and bump the revision. Marks every /// row dirty so the new color shows immediately under partial /// redraw (since the resolved color of any existing cell drawn at @@ -4400,7 +4424,14 @@ impl Perform for Term { // HT: advance to the next tab stop (HTS / TBC editable), // clamped at the right margin. Same damage-gap fix as BS. b'\t' => self.tab_forward(1), - // BEL and everything else are no-ops for M3. + // BEL: record where it rang. Nothing on screen changes, so no + // damage; the renderer watches `bell_revision`. + 0x07 => { + let col = self.cursor.col.min(self.cols.saturating_sub(1)); + self.last_bell_cell = (self.cursor.row, col); + self.bell_revision = self.bell_revision.wrapping_add(1); + } + // Everything else is a no-op. _ => {} } } @@ -9334,6 +9365,25 @@ mod tests { assert_eq!(&t.drain_pty_replies()[..], b"\x1b[?2;5R"); } + #[test] + fn bel_records_cursor_cell_and_bumps_revision() { + let mut t = Term::new(5, 10, 0); + assert_eq!(t.bell_revision(), 0); + feed(&mut t, b"\x1b[3;7H\x07ab"); + assert_eq!(t.bell_revision(), 1); + assert_eq!(t.last_bell_cell(), (2, 6)); + feed(&mut t, b"\x07"); + assert_eq!(t.bell_revision(), 2); + assert_eq!(t.last_bell_cell(), (2, 8)); + } + + #[test] + fn bel_terminated_osc_is_not_a_bell() { + let mut t = Term::new(2, 4, 0); + feed(&mut t, b"\x1b]0;title\x07"); + assert_eq!(t.bell_revision(), 0); + } + #[test] fn osc4_set_records_override_and_bumps_revision() { let mut t = Term::new(2, 4, 0); diff --git a/crates/toastty/Cargo.toml b/crates/toastty/Cargo.toml index d763dd35..b8c9476a 100644 --- a/crates/toastty/Cargo.toml +++ b/crates/toastty/Cargo.toml @@ -54,6 +54,7 @@ libc = "0.2" pprof = ["dep:pprof"] [dev-dependencies] +tempfile = "3" toastty-graphics.workspace = true [lints] diff --git a/crates/toastty/src/cli.rs b/crates/toastty/src/cli.rs index 0cf0fa1c..516d8067 100644 --- a/crates/toastty/src/cli.rs +++ b/crates/toastty/src/cli.rs @@ -627,6 +627,7 @@ mod tests { "[shell]", "[scrollback]", "[window]", + "[shader]", ] { assert!(s.contains(section), "missing section {section} in:\n{s}"); } diff --git a/crates/toastty/src/config_watcher.rs b/crates/toastty/src/config_watcher.rs index d1b1455a..ad9e9d2e 100644 --- a/crates/toastty/src/config_watcher.rs +++ b/crates/toastty/src/config_watcher.rs @@ -107,7 +107,8 @@ fn matches_target(ev: ¬ify::Event, target: &Path) -> bool { /// Filter out access / metadata-only notifications so we don't fire a /// reload for every `stat` the editor does. Create / Modify(Data) / /// Modify(Name) (atomic rename) / Remove are the interesting kinds. -fn is_payload_event(kind: EventKind) -> bool { +/// Shared with [`crate::shader_watcher`]. +pub(crate) fn is_payload_event(kind: EventKind) -> bool { use notify::event::ModifyKind; matches!( kind, diff --git a/crates/toastty/src/lib.rs b/crates/toastty/src/lib.rs index 75e9b60c..7b5dac7d 100644 --- a/crates/toastty/src/lib.rs +++ b/crates/toastty/src/lib.rs @@ -28,5 +28,7 @@ pub mod paste; pub mod perf_stats; pub mod preload; pub mod pty_log; +pub mod shader_files; +pub mod shader_watcher; pub mod shell; pub mod theme_bridge; diff --git a/crates/toastty/src/main.rs b/crates/toastty/src/main.rs index 604d649d..af34a37c 100644 --- a/crates/toastty/src/main.rs +++ b/crates/toastty/src/main.rs @@ -17,12 +17,14 @@ use std::time::{Duration, Instant}; use anyhow::{Context, Result}; use pollster::block_on; -use toastty_config::{Config, ConfigError, ConfigSource, ConfirmClose}; +use toastty_config::{Config, ConfigError, ConfigSource, ConfirmClose, ShaderAnimation}; use toastty_parser::Parser; use toastty_protocols::resize_inband::encode_resize_report; use toastty_protocols::synchronized::{BSU_TIMEOUT, should_force_flush}; use toastty_pty::{Pty, PtySpec, WinSize}; -use toastty_render::{ClosePrompt, DialogButton, RenderOutcome, Renderer}; +use toastty_render::{ + ClosePrompt, DialogButton, PostAnimation, PostShaderSource, RenderOutcome, Renderer, +}; use toastty_term::{ ClipboardRequest, Pos, SecurityFlags, Selection, SelectionMode, Smoothing, Term, }; @@ -52,6 +54,8 @@ use toastty::paste::wrap_for_paste; use toastty::perf_stats::PerfStats; use toastty::preload::Preload; use toastty::pty_log::{Direction, PtyLogger}; +use toastty::shader_files; +use toastty::shader_watcher::ShaderWatcher; use toastty::shell::resolve_command; use toastty::theme_bridge::{ extend_background, extend_background_when, grid_align, scroll_button_corner, theme_from_config, @@ -411,9 +415,22 @@ struct Toastty { /// File-watcher driving hot-reload. `None` if the parent directory /// is missing or the watcher couldn't be installed. config_watcher: Option, - /// Currently-displayed error banner string. Tracked so we only call + /// Currently-displayed error banner string: `config_banner` and + /// `shader_banner` joined. Tracked so we only call /// `Renderer::set_error_banner` when the contents actually change. banner_text: Option, + /// Config load / reload error shown in the banner. + config_banner: Option, + /// `[shader]` load / compile errors shown in the banner. + shader_banner: Option, + /// Resolved `[shader] paths` the watcher was built for. + shader_paths: Vec, + /// Hot-reload watcher for the shader files. Rebuilt when the + /// resolved paths change. + shader_watcher: Option, + /// Kept from `init_impl` so a config reload can rebuild + /// `shader_watcher`. + event_proxy: Option>, /// Most recent config-load error from startup (if any). Promoted to /// `banner_text` once the renderer is available in `init_impl`. pending_load_error: Option, @@ -557,6 +574,11 @@ impl Toastty { config_path: Config::default_path(), config_watcher: None, banner_text: None, + config_banner: None, + shader_banner: None, + shader_paths: Vec::new(), + shader_watcher: None, + event_proxy: None, pending_load_error: load_err, close_pending: false, close_prompt_press: None, @@ -1351,6 +1373,8 @@ impl Toastty { if let Some(path) = self.config_path.clone() { self.config_watcher = ConfigWatcher::spawn(path, handle.event_loop_proxy()); } + self.event_proxy = Some(handle.event_loop_proxy()); + self.apply_shaders(false); // Promote any startup config load error to the in-window // banner now that the renderer exists. @@ -1404,6 +1428,7 @@ impl Toastty { .set_cursor_default(self.config.cursor.shape, self.config.cursor.blink); self.term.set_security(security_flags(self.config.security)); self.push_rich_env(); + self.apply_shaders(false); // Padding / extend_background / grid_align are safe to live-apply // (render + grid only, no surface reconfigure). Push them before the // grid sync so the recomputed grid reflects any padding change. @@ -1423,10 +1448,10 @@ impl Toastty { /// Re-parse the watched config file and either apply it (clearing /// any banner) or surface the error in the banner without - /// touching live state. - fn reload_config(&mut self) { + /// touching live state. Returns whether the new config was applied. + fn reload_config(&mut self) -> bool { let Some(path) = self.config_path.clone() else { - return; + return false; }; if !path.exists() { // File was deleted: keep running with current settings, @@ -1436,7 +1461,7 @@ impl Toastty { "config file missing: {}\n(continuing with previous settings; recreate or restart)", path.display() ))); - return; + return false; } match Config::load_from_path(&path) { Ok(cfg) => { @@ -1452,10 +1477,12 @@ impl Toastty { self.apply_config_to_runtime(); self.set_banner(None); info!("config reloaded from {}", path.display()); + true } Err(e) => { warn!("config reload failed: {e}"); self.set_banner_from_error(&e); + false } } } @@ -1474,7 +1501,20 @@ impl Toastty { self.set_banner(Some(msg)); } + /// Set the config part of the error banner. fn set_banner(&mut self, text: Option) { + self.config_banner = text; + self.refresh_banner(); + } + + /// Show `config_banner` and `shader_banner` together (either can be + /// absent). + fn refresh_banner(&mut self) { + let text = match (self.config_banner.as_deref(), self.shader_banner.as_deref()) { + (Some(c), Some(s)) => Some(format!("{c}\n\n{s}")), + (Some(t), None) | (None, Some(t)) => Some(t.to_owned()), + (None, None) => None, + }; if self.banner_text == text { return; } @@ -1487,6 +1527,75 @@ impl Toastty { } } + /// Load, compile and install the `[shader]` chain, and point the + /// shader watcher at its files. Called at startup, on every config + /// reload, and (`from_watcher`) when a shader file changes. A failure + /// keeps the previous chain running and shows the errors in the + /// banner. On a watcher reload a missing file is skipped quietly: + /// editors that save by rename briefly remove it, and the + /// re-create fires another reload. + fn apply_shaders(&mut self, from_watcher: bool) { + let base = self + .config_path + .as_deref() + .and_then(std::path::Path::parent); + let paths = shader_files::resolve_paths(&self.config.shader.paths, base); + // Also retry a watcher that couldn't be installed (e.g. the + // shader directory didn't exist yet). + if paths != self.shader_paths || (self.shader_watcher.is_none() && !paths.is_empty()) { + self.shader_watcher = self + .event_proxy + .clone() + .and_then(|proxy| ShaderWatcher::spawn(&paths, proxy)); + self.shader_paths.clone_from(&paths); + } + let Some(r) = self.renderer.as_mut() else { + return; + }; + r.set_post_animation(match self.config.shader.animation { + ShaderAnimation::Auto => PostAnimation::Auto, + ShaderAnimation::Always => PostAnimation::Always, + ShaderAnimation::Never => PostAnimation::Never, + }); + let errors: Vec = match shader_files::load(&paths) { + Ok(files) => { + let sources: Vec = files + .into_iter() + .map(|f| PostShaderSource { + path: f.path, + source: f.source, + }) + .collect(); + match r.set_post_shaders(&sources) { + Ok(()) => Vec::new(), + Err(errs) => errs.iter().map(ToString::to_string).collect(), + } + } + Err(errs) => { + if from_watcher && errs.iter().all(|e| e.kind == std::io::ErrorKind::NotFound) { + debug!("shader file missing during reload; waiting for it to reappear"); + return; + } + errs.iter().map(ToString::to_string).collect() + } + }; + self.shader_banner = if errors.is_empty() { + None + } else { + for e in &errors { + warn!("shader error: {e}"); + } + Some(format!( + "shader error\n{}\n(the previous shader keeps running; fix the file and save)", + errors.join("\n") + )) + }; + self.refresh_banner(); + if let Some(w) = self.window.as_ref() { + w.request_redraw(); + } + } + /// Tear down the PTY and signal the event loop to exit. The single /// place a close actually happens — both the immediate path and the /// confirmed-prompt path funnel through here. @@ -2919,11 +3028,34 @@ impl App for Toastty { if let Event::Mouse { button, state, .. } = &event { self.track_button_down(*button, *state); } + // Shaders can read the pointer (`iMouse`). + let pointer = match &event { + Event::Mouse { position, .. } | Event::MouseMotion { position, .. } => Some(*position), + _ => None, + }; + if let Some(pos) = pointer + && let Some(r) = self.renderer.as_mut() + && r.has_post_shaders() + { + #[allow(clippy::cast_possible_truncation)] + r.set_pointer( + (pos.0 as f32, pos.1 as f32), + self.buttons_down.contains(&MouseButton::Left), + ); + } // A resize, scale change or config / font reload re-centres the // dialog, which can move a button under (or out from under) the // still pointer. let relayout = matches!(event, Event::Resize { .. } | Event::User); let signal = self.dispatch_event(event); + let signal = if pointer.is_some() + && signal == ControlSignal::Continue + && self.post_frame_pending() + { + ControlSignal::RedrawIn(Duration::ZERO) + } else { + signal + }; if relayout && self.close_pending && self.refresh_close_dialog_hover() @@ -2937,6 +3069,14 @@ impl App for Toastty { impl Toastty { /// Record a mouse button going down or up (see `buttons_down`). + /// The shader chain has an input change (pointer, focus) it reads + /// and wants a frame for now. + fn post_frame_pending(&self) -> bool { + self.renderer.as_ref().is_some_and(|r| { + r.has_post_shaders() && r.next_redraw_deadline(&self.term) == Some(Duration::ZERO) + }) + } + fn track_button_down(&mut self, button: MouseButton, state: KeyState) { match state { KeyState::Pressed => { @@ -3213,9 +3353,15 @@ impl Toastty { if let Some(bytes) = encode_focus(focused, self.term.report_focus()) { self.write_pty(bytes); } + // Shaders can read focus (`iFocus`, `iTimeFocus`) and + // `animation = "auto"` pauses on blur. + if let Some(r) = self.renderer.as_mut() { + r.set_focused(focused); + } // Modifier state is unreliable across a focus change // (the release may go to another window). - if !focused && self.clear_link_hover() { + let hover_cleared = !focused && self.clear_link_hover(); + if hover_cleared || self.post_frame_pending() { return ControlSignal::RedrawIn(Duration::ZERO); } ControlSignal::Continue @@ -3295,8 +3441,18 @@ impl Toastty { .config_watcher .as_ref() .is_some_and(ConfigWatcher::take_pending); - if should_reload { - self.reload_config(); + let shaders_changed = self + .shader_watcher + .as_ref() + .is_some_and(ShaderWatcher::take_pending); + // An applied config reload re-reads the shaders too; a + // failed one doesn't, so the shader change still needs + // its own reload. + let applied = should_reload && self.reload_config(); + if shaders_changed && !applied { + self.apply_shaders(true); + } + if should_reload || shaders_changed { ControlSignal::RedrawIn(Duration::ZERO) } else { ControlSignal::Continue diff --git a/crates/toastty/src/shader_files.rs b/crates/toastty/src/shader_files.rs new file mode 100644 index 00000000..c0a41045 --- /dev/null +++ b/crates/toastty/src/shader_files.rs @@ -0,0 +1,234 @@ +//! Resolve and read the `[shader] paths` files (M13). +//! +//! Renderer-agnostic: the binary turns each [`ShaderFile`] into a +//! `toastty_render::PostShaderSource`. Resolution rules: +//! +//! - `~` and `~/…` expand to `$HOME` (`~user` is not supported and is +//! taken literally). +//! - Absolute paths are used as is. +//! - Relative paths are relative to the directory of the config file in +//! use, or the XDG config dir (`$XDG_CONFIG_HOME/toastty`, +//! `~/.config/toastty`) when running on defaults. See [`base_dir`]. + +use std::path::{Path, PathBuf}; + +use toastty_config::{Config, ConfigSource}; + +/// One shader file that was read successfully. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ShaderFile { + /// Resolved path (what the user sees in error messages). + pub path: PathBuf, + /// File contents. + pub source: String, +} + +/// A shader file that couldn't be read. Displays as `path: message`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ShaderFileError { + pub path: PathBuf, + /// The I/O error kind. On a watcher-triggered reload the caller can + /// skip `NotFound`: editors that save by rename briefly remove the + /// file. + pub kind: std::io::ErrorKind, + pub message: String, +} + +impl std::fmt::Display for ShaderFileError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}: {}", self.path.display(), self.message) + } +} + +/// Directory that relative shader paths are resolved against, for a +/// config loaded from `source`. `None` when there is no config file and +/// no XDG/HOME to derive one from (relative paths then stay relative to +/// the CWD). +#[must_use] +pub fn base_dir(source: &ConfigSource) -> Option { + base_dir_with(source, Config::default_path().as_deref()) +} + +/// [`base_dir`] with the default config path passed in (testable +/// without touching the process environment). +fn base_dir_with(source: &ConfigSource, default_path: Option<&Path>) -> Option { + let file = match source { + ConfigSource::File(p) => p.as_path(), + ConfigSource::Defaults => default_path?, + }; + file.parent().map(Path::to_path_buf) +} + +/// Resolve every configured path, in order. `base` comes from +/// [`base_dir`]; `$HOME` is read from the environment. +#[must_use] +pub fn resolve_paths(raw: &[String], base: Option<&Path>) -> Vec { + let home = std::env::var_os("HOME").map(PathBuf::from); + raw.iter() + .map(|r| resolve_path(r, base, home.as_deref())) + .collect() +} + +/// Resolve one configured path. See the module docs for the rules. +#[must_use] +pub fn resolve_path(raw: &str, base: Option<&Path>, home: Option<&Path>) -> PathBuf { + if let Some(home) = home.filter(|h| !h.as_os_str().is_empty()) { + if raw == "~" { + return home.to_path_buf(); + } + if let Some(rest) = raw.strip_prefix("~/") { + return home.join(rest); + } + } + let p = Path::new(raw); + match base { + Some(base) if p.is_relative() => base.join(p), + _ => p.to_path_buf(), + } +} + +/// Read every file in `paths`, in order. All-or-nothing: if any file +/// can't be read, returns one error per failing file. +pub fn load(paths: &[PathBuf]) -> Result, Vec> { + let mut files = Vec::with_capacity(paths.len()); + let mut errors = Vec::new(); + for path in paths { + match std::fs::read_to_string(path) { + Ok(source) => files.push(ShaderFile { + path: path.clone(), + source, + }), + Err(e) => errors.push(ShaderFileError { + path: path.clone(), + kind: e.kind(), + message: e.to_string(), + }), + } + } + if errors.is_empty() { + Ok(files) + } else { + Err(errors) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tilde_expands_to_home() { + let home = Path::new("/h"); + let base = Path::new("/cfg"); + assert_eq!( + resolve_path("~/s/crt.glsl", Some(base), Some(home)), + PathBuf::from("/h/s/crt.glsl") + ); + assert_eq!( + resolve_path("~", Some(base), Some(home)), + PathBuf::from("/h") + ); + // `~user` isn't expanded; it's a relative path. + assert_eq!( + resolve_path("~bob/x.glsl", Some(base), Some(home)), + PathBuf::from("/cfg/~bob/x.glsl") + ); + // No HOME: `~/` stays literal (relative to the base). + assert_eq!( + resolve_path("~/x.glsl", Some(base), None), + PathBuf::from("/cfg/~/x.glsl") + ); + } + + #[test] + fn relative_joins_base_absolute_kept() { + let base = Path::new("/cfg/toastty"); + assert_eq!( + resolve_path("shaders/a.wgsl", Some(base), None), + PathBuf::from("/cfg/toastty/shaders/a.wgsl") + ); + assert_eq!( + resolve_path("/abs/a.glsl", Some(base), None), + PathBuf::from("/abs/a.glsl") + ); + assert_eq!(resolve_path("a.glsl", None, None), PathBuf::from("a.glsl")); + } + + #[test] + fn base_dir_from_file_or_default() { + let file = ConfigSource::File(PathBuf::from("/etc/t/my.toml")); + assert_eq!( + base_dir_with(&file, Some(Path::new("/x/toastty/config.toml"))), + Some(PathBuf::from("/etc/t")) + ); + assert_eq!( + base_dir_with( + &ConfigSource::Defaults, + Some(Path::new("/x/toastty/config.toml")) + ), + Some(PathBuf::from("/x/toastty")) + ); + assert_eq!(base_dir_with(&ConfigSource::Defaults, None), None); + // The env-reading wrapper runs. + let _ = base_dir(&ConfigSource::Defaults); + } + + #[test] + fn resolve_paths_keeps_order() { + let base = Path::new("/cfg"); + let raw = vec!["b.glsl".to_string(), "/a.wgsl".to_string()]; + assert_eq!( + resolve_paths(&raw, Some(base)), + vec![PathBuf::from("/cfg/b.glsl"), PathBuf::from("/a.wgsl")] + ); + } + + #[test] + fn load_reads_files_in_order() { + let dir = tempfile::tempdir().unwrap(); + let a = dir.path().join("a.glsl"); + let b = dir.path().join("b.wgsl"); + std::fs::write(&a, "void mainImage() {}").unwrap(); + std::fs::write(&b, "fn mainImage() {}").unwrap(); + let files = load(&[b.clone(), a.clone()]).unwrap(); + assert_eq!( + files, + vec![ + ShaderFile { + path: b, + source: "fn mainImage() {}".into() + }, + ShaderFile { + path: a, + source: "void mainImage() {}".into() + }, + ] + ); + assert_eq!(load(&[]).unwrap(), Vec::new()); + } + + #[test] + fn load_reports_every_failing_file() { + let dir = tempfile::tempdir().unwrap(); + let ok = dir.path().join("ok.glsl"); + std::fs::write(&ok, "x").unwrap(); + let missing = dir.path().join("missing.glsl"); + let not_utf8 = dir.path().join("bin.glsl"); + std::fs::write(¬_utf8, [0xff, 0xfe, 0x00]).unwrap(); + + let errs = load(&[missing.clone(), ok, not_utf8.clone()]).unwrap_err(); + assert_eq!(errs.len(), 2); + assert_eq!(errs[0].path, missing); + assert_eq!(errs[0].kind, std::io::ErrorKind::NotFound); + let shown = errs[0].to_string(); + assert!( + shown.starts_with(&format!("{}: No such file", missing.display())), + "{shown}" + ); + assert_eq!(errs[1].path, not_utf8); + assert_eq!(errs[1].kind, std::io::ErrorKind::InvalidData); + // A directory is not a shader file either. + let errs = load(&[dir.path().to_path_buf()]).unwrap_err(); + assert_eq!(errs.len(), 1); + } +} diff --git a/crates/toastty/src/shader_watcher.rs b/crates/toastty/src/shader_watcher.rs new file mode 100644 index 00000000..31c03100 --- /dev/null +++ b/crates/toastty/src/shader_watcher.rs @@ -0,0 +1,280 @@ +//! Hot-reload of the `[shader] paths` files (M13). +//! +//! Same design as [`crate::config_watcher`]: each distinct parent +//! directory is watched non-recursively (so editor save-via-rename and +//! files created later are seen), events are filtered to the target +//! files and to payload kinds, and a hit sets a shared pending flag and +//! sends `UserEvent::Wake`. The main thread drains the flag with +//! [`ShaderWatcher::take_pending`] and reloads the whole chain. +//! +//! When the path list changes (config reload), the binary drops the old +//! watcher and spawns a new one; that's one inotify instance, so cheap. +//! +//! Each parent directory is canonicalized before watching: inotify gives +//! two spellings of one directory (a stowed `~/.config/toastty` symlink, +//! `a/../b`) the same watch, and notify then reports every event under +//! whichever spelling was added last. The file name itself is kept as +//! is, so a symlinked shader is reloaded when the link changes, not when +//! its target is edited in another directory. + +use std::collections::{BTreeSet, HashSet}; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +use notify::{RecommendedWatcher, RecursiveMode, Watcher}; +use toastty_window::EventLoopProxy; +use tracing::{debug, warn}; + +use crate::config_watcher::is_payload_event; + +/// Handle returned to the binary. Drop it to stop watching. +pub struct ShaderWatcher { + /// Kept alive so the watcher thread keeps running. + #[allow(dead_code)] + inner: RecommendedWatcher, + pending: Arc, +} + +impl std::fmt::Debug for ShaderWatcher { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ShaderWatcher") + .field("pending", &self.pending.load(Ordering::Relaxed)) + .finish_non_exhaustive() + } +} + +impl ShaderWatcher { + /// Watch `paths` (resolved shader files). Returns `None` when there + /// is nothing to watch (empty list, or no parent directory exists) + /// or the watcher can't be installed. Non-fatal: shaders still load + /// once, just without hot-reload. + pub fn spawn( + paths: &[PathBuf], + proxy: EventLoopProxy, + ) -> Option { + let WatchSet { dirs, targets } = watch_set(paths); + if dirs.is_empty() { + return None; + } + let pending = Arc::new(AtomicBool::new(false)); + let pending_for_cb = Arc::clone(&pending); + + let mut watcher = + match notify::recommended_watcher(move |res: notify::Result| match res { + Ok(ev) => { + if !is_relevant(&ev, &targets) { + return; + } + pending_for_cb.store(true, Ordering::SeqCst); + // Err means the loop has exited; nothing to do. + let _ = proxy.send_event(toastty_window::UserEvent::Wake); + } + Err(e) => warn!("shader watcher error: {e}"), + }) { + Ok(w) => w, + Err(e) => { + warn!("shader watcher init failed: {e}"); + return None; + } + }; + + let mut n_watched = 0; + for dir in &dirs { + match watcher.watch(dir, RecursiveMode::NonRecursive) { + Ok(()) => n_watched += 1, + Err(e) => warn!("shader watch() failed for {}: {e}", dir.display()), + } + } + if n_watched == 0 { + return None; + } + + debug!(?paths, "shader watcher installed"); + Some(Self { + inner: watcher, + pending, + }) + } + + /// Atomically clear and return whether a reload is pending. + pub fn take_pending(&self) -> bool { + self.pending.swap(false, Ordering::SeqCst) + } +} + +/// What to watch and what to match, spelled the way notify reports +/// events: absolute, with the parent directory canonicalized. +#[derive(Debug, Default, PartialEq, Eq)] +struct WatchSet { + /// Distinct canonical parent directories. + dirs: BTreeSet, + /// `canonical parent / file name` for each path. + targets: HashSet, +} + +/// Build the [`WatchSet`] for `paths`. Relative paths are made absolute +/// against the CWD (notify does the same for the watch). Paths whose +/// parent directory doesn't exist are skipped. +fn watch_set(paths: &[PathBuf]) -> WatchSet { + let mut set = WatchSet::default(); + for path in paths { + let Ok(abs) = std::path::absolute(path) else { + continue; + }; + let (Some(parent), Some(name)) = (abs.parent(), abs.file_name()) else { + continue; + }; + match std::fs::canonicalize(parent) { + Ok(dir) => { + set.targets.insert(dir.join(name)); + set.dirs.insert(dir); + } + Err(e) => debug!(dir = ?parent, "shader dir not watchable: {e}"), + } + } + set +} + +/// True when `ev` is a payload event (see +/// [`crate::config_watcher`]) touching one of `targets`. Siblings in a +/// watched directory are ignored. +fn is_relevant(ev: ¬ify::Event, targets: &HashSet) -> bool { + is_payload_event(ev.kind) && ev.paths.iter().any(|p| targets.contains(p)) +} + +#[cfg(test)] +mod tests { + use super::*; + use notify::EventKind; + use notify::event::{AccessKind, CreateKind, DataChange, ModifyKind, RenameMode}; + + fn ev(kind: EventKind, paths: &[&str]) -> notify::Event { + notify::Event { + kind, + paths: paths.iter().map(PathBuf::from).collect(), + attrs: notify::event::EventAttributes::default(), + } + } + + fn targets(paths: &[&str]) -> HashSet { + paths.iter().map(PathBuf::from).collect() + } + + #[cfg(unix)] + #[test] + fn watch_set_canonicalizes_parent_and_dedups() { + let tmp = tempfile::tempdir().unwrap(); + let real = std::fs::canonicalize(tmp.path()).unwrap().join("real"); + std::fs::create_dir(&real).unwrap(); + let link = real.parent().unwrap().join("link"); + std::os::unix::fs::symlink(&real, &link).unwrap(); + let paths = vec![ + real.join("a.glsl"), + link.join("b.glsl"), + real.join("sub/../c.glsl"), + real.join("missing/d.glsl"), + ]; + std::fs::create_dir(real.join("sub")).unwrap(); + let set = watch_set(&paths); + assert_eq!(set.dirs.into_iter().collect::>(), vec![real.clone()]); + assert_eq!( + set.targets, + ["a.glsl", "b.glsl", "c.glsl"] + .iter() + .map(|n| real.join(n)) + .collect() + ); + assert_eq!(watch_set(&[]), WatchSet::default()); + } + + #[test] + fn watch_set_makes_relative_paths_absolute() { + let cwd = std::fs::canonicalize(std::env::current_dir().unwrap()).unwrap(); + let set = watch_set(&[PathBuf::from("x.glsl")]); + assert_eq!(set.dirs.into_iter().collect::>(), vec![cwd.clone()]); + assert!(set.targets.contains(&cwd.join("x.glsl"))); + } + + /// Real notify watcher: writes through a symlinked spelling of the + /// directory must match the canonical targets. + #[cfg(unix)] + #[test] + fn real_events_match_targets() { + use std::sync::mpsc; + use std::time::{Duration, Instant}; + + let tmp = tempfile::tempdir().unwrap(); + let real = std::fs::canonicalize(tmp.path()).unwrap().join("real"); + std::fs::create_dir(&real).unwrap(); + let link = real.parent().unwrap().join("link"); + std::os::unix::fs::symlink(&real, &link).unwrap(); + let target = link.join("x.glsl"); + let set = watch_set(&[target.clone(), real.join("y.glsl")]); + + let (tx, rx) = mpsc::channel(); + let mut w = notify::recommended_watcher(move |res| { + let _ = tx.send(res); + }) + .unwrap(); + for dir in &set.dirs { + w.watch(dir, RecursiveMode::NonRecursive).unwrap(); + } + std::fs::write(link.join("sibling.txt"), "x").unwrap(); + std::fs::write(&target, "void mainImage() {}").unwrap(); + + let deadline = Instant::now() + Duration::from_secs(5); + let mut saw_sibling = false; + loop { + let left = deadline.saturating_duration_since(Instant::now()); + let ev = rx.recv_timeout(left).expect("no event for target").unwrap(); + if ev.paths.iter().any(|p| p.ends_with("sibling.txt")) { + saw_sibling = true; + assert!(!is_relevant(&ev, &set.targets), "{ev:?}"); + } else if is_relevant(&ev, &set.targets) { + break; + } + } + assert!(saw_sibling, "sibling write should arrive first"); + } + + #[test] + fn data_write_to_any_target_is_relevant() { + let t = targets(&["/a/x.glsl", "/b/z.glsl"]); + let data = EventKind::Modify(ModifyKind::Data(DataChange::Any)); + assert!(is_relevant(&ev(data, &["/a/x.glsl"]), &t)); + assert!(is_relevant(&ev(data, &["/b/z.glsl"]), &t)); + } + + #[test] + fn sibling_files_are_ignored() { + let t = targets(&["/a/x.glsl"]); + let data = EventKind::Modify(ModifyKind::Data(DataChange::Any)); + assert!(!is_relevant(&ev(data, &["/a/x.glsl.swp"]), &t)); + assert!(!is_relevant(&ev(data, &["/a/other.glsl"]), &t)); + } + + #[test] + fn rename_onto_target_is_relevant() { + // Editor save: write a temp file, rename it over the target. + let t = targets(&["/a/x.glsl"]); + let rename = EventKind::Modify(ModifyKind::Name(RenameMode::Both)); + assert!(is_relevant( + &ev(rename, &["/a/.x.glsl.tmp", "/a/x.glsl"]), + &t + )); + assert!(is_relevant( + &ev(EventKind::Create(CreateKind::File), &["/a/x.glsl"]), + &t + )); + } + + #[test] + fn access_events_are_ignored() { + let t = targets(&["/a/x.glsl"]); + assert!(!is_relevant( + &ev(EventKind::Access(AccessKind::Read), &["/a/x.glsl"]), + &t + )); + } +} diff --git a/docs/milestones/m13-user-shaders.md b/docs/milestones/m13-user-shaders.md index 4480bb43..ec49b937 100644 --- a/docs/milestones/m13-user-shaders.md +++ b/docs/milestones/m13-user-shaders.md @@ -1,17 +1,247 @@ # M13 — User shaders and post-process -**Goal.** Power-user differentiator. ShaderToy port compatibility was measured at 100% on a 14-shader corpus in decision #8 — bring that to users. +**Goal.** Let users run ShaderToy/Ghostty-style fragment shaders over the +terminal image. Ghostty's shaders should paste in unchanged, and a shader +that doesn't animate must cost nothing at idle. -**Scope.** Post-process fragment shader stage after the cell pass, slotted into the existing render graph as pass 4. User supplies a shader file path in config (or via a CLI arg for live experimentation). +**Status.** Spec agreed 2026-10-22. It supersedes parts of +[decision #8](../decisions/shader-pipeline.md): GLSL is the main +language in practice, the y axis points down, and there is no +`iCursor`. The rest of #8 (naga, the shim, last-good on error) stands. -**Dual-path shader pipeline.** WGSL primary — written by the user as `fn main(...) -> @location(0) vec4`. GLSL secondary via `naga`'s GLSL frontend — accepts ShaderToy-style `void mainImage(out vec4 fragColor, in vec2 fragCoord)`, runs through a ~50-LoC shim (`glsl_shim.rs` from decision #8's prototype is the reference) that synthesizes an entry point, translates via `naga` at runtime to whatever the backend wants (SPIR-V/MSL/HLSL). +## Config -**Uniforms.** Mirror ShaderToy conventions where sensible: `iTime` (seconds), `iFrame` (counter), `iResolution` (`vec3` with width/height/aspect), `iCursor` (cell coords). Plus a previous-framebuffer texture binding so effects can read the pre-shader output. +```toml +[shader] +# Post-process shaders, run in order. Each one's output is the next one's +# iChannel0. Empty (the default) turns post-processing off. +# `~` expands; relative paths are relative to the config file's directory. +# The extension picks the language: .glsl / .frag = GLSL, .wgsl = WGSL. +paths = [] +# "auto" detect from the shader (below) +# "always" redraw at ~60 Hz all the time, even unfocused +# "never" redraw only on terminal updates and shader-input events +animation = "auto" +``` -**Hot reload.** `notify` crate watches the shader file. On change, recompile via `naga`. On compile failure, surface diagnostics in the status line (or stderr) and **keep the last-good pipeline live** so the renderer never crashes from a bad edit. The Ghostty/Kitty contract: a typo should never take the terminal down. +Shader files are watched. On a change they are recompiled. If any shader +in the chain fails, the error goes to the in-window error banner +(`path:line:col: message`, line numbers in the user's file, not the +prelude-wrapped source) and the previous working chain stays live. A +config reload that changes `[shader]` behaves the same way. -**Sandbox.** None needed — fragment shaders can't escape the GPU. Resource limits via wgpu's validation. +## Languages -**The shim's known gotcha.** From decision #8: `#define`-based ShaderToy uniform aliases break naga's swizzle parsing (`#define iMouse (vec4(...))` then `iMouse.zw` fails). The shim declares `iMouse` as a top-level `vec4` and copies from the UBO inside the synthesized `main()` — already worked out in the prototype. +### GLSL (ShaderToy / Ghostty dialect) -**Out of scope.** A UI for shader selection (just a file path in config). Multi-pass shaders. Compute-shader effects. +The user file contains `void mainImage(out vec4 fragColor, in vec2 +fragCoord)` plus any helpers, and nothing else. We prepend a prelude and +append a `main()`; naga's GLSL frontend parses the result. The prelude: + +- `#version 450 core`. A `#version` line in the user file is blanked + (kept as an empty line so line numbers stay right). +- An unnamed std140 uniform block (`set = 0, binding = 0`) whose members + are the uniforms below, so the user writes `iTime` directly. naga turns + each access into `AccessIndex(GlobalVariable(block), i)`. +- `texture2D iChannel0_tex` and `sampler iChannel0_smp` at bindings 1 and + 2, plus `#define iChannel0 sampler2D(iChannel0_tex, iChannel0_smp)`. +- `#define texture2D texture` and the `CURSORSTYLE_*` macros (Ghostty + compatibility). +- `main()` passes `gl_FragCoord.xy` straight through as `fragCoord`, + without flipping it (see Coordinates). + +`iChannel1..3` are not declared. Using one is a compile error that names +the line. + +### WGSL + +The user file contains `fn mainImage(fragCoord: vec2f) -> vec4f`. The +prelude declares `var toastty: ToasttyUniforms` (same member +names as GLSL, so `toastty.iTime`), `iChannel0: texture_2d` and +`iChannel0Sampler: sampler` at the same bindings, the +`CURSORSTYLE_*` constants, and an entry point that calls `mainImage`. + +## Coordinates and colour + +- **The y axis points down** with the origin at the top left: wgpu's + native convention, and the one Ghostty uses on macOS. So `fragCoord` is + `gl_FragCoord.xy` (pixel centres at .5), and + `texture(iChannel0, fragCoord / iResolution.xy)` samples the same pixel. + Nothing is flipped anywhere. ShaderToy shaders that draw relative to + the bottom of the screen come out upside down, as they do in Ghostty on + macOS. +- `iResolution` is the full surface, padding included. +- **Colours are sRGB-encoded** (display values in 0–1), as on ShaderToy. + `iChannel0` is sampled through a non-sRGB view of the scratch texture, + and the last pass writes through a non-sRGB view of the swapchain + image. A pass-through shader therefore reproduces the frame exactly. + Colour uniforms (`iPalette`, `iBackgroundColor`, …) are sRGB-encoded + too, converted from the renderer's linear theme values. +- **Alpha is premultiplied.** `iChannel0` holds what the cell pass wrote + (premultiplied; alpha 1 unless the theme bg is transparent). The output + is presented as is, so a shader that writes `alpha = 1` makes a + transparent window opaque. This is documented, not handled for v1. +- The sampler is linear-filtered and clamps to the edge. + +## Uniforms + +Names and types match Ghostty's `shadertoy_prefix.glsl` +(ghostty-org/ghostty `f54340ad`) so its shaders compile unchanged. + +| Uniform | Type | Value | +|---|---|---| +| `iResolution` | `vec3` | `(width, height, 1)` px | +| `iTime` | `float` | Seconds since the first post-processed frame | +| `iTimeDelta` | `float` | Seconds since the previous frame | +| `iFrameRate` | `float` | `1 / iTimeDelta`, smoothed | +| `iFrame` | `int` | Frames rendered with post-processing | +| `iChannelTime[4]` | `float[4]` | 0 | +| `iChannelResolution[4]` | `vec3[4]` | `[0]` = `iResolution`, the rest 0 | +| `iMouse` | `vec4` | ShaderToy semantics, y-down px: `xy` = pointer position while button 1 is held (last held position otherwise); `zw` = position of the last press, `z` negative once released, `w` negative after the press frame | +| `iDate` | `vec4` | `(year, month - 1, day, seconds since local midnight)` | +| `iSampleRate` | `float` | 0 | +| `iCurrentCursor` | `vec4` | Drawn cursor rect (a bar cursor is thin): `xy` = `(left, top + height)`, the left and **bottom** edge (Ghostty's y-down convention: the −x, +y corner), `zw` = width, height | +| `iPreviousCursor` | `vec4` | Value before the last change | +| `iCurrentCursorColor`, `iPreviousCursorColor` | `vec4` | sRGB RGBA | +| `iCurrentCursorStyle`, `iPreviousCursorStyle` | `int` | `CURSORSTYLE_BLOCK` 0, `_BLOCK_HOLLOW` 1, `_BAR` 2, `_UNDERLINE` 3, `_LOCK` 4 | +| `iCursorVisible` | `int` | 0 / 1 | +| `iTimeCursorChange` | `float` | `iTime` when the cursor rect or colour last changed; `-1e6` until the first change | +| `iTimeFocus` | `float` | `iTime` when the window last gained focus (0 at start) | +| `iFocus` | `int` | 0 / 1 | +| `iPalette[256]` | `vec3[256]` | Live palette (OSC 4 included) | +| `iBackgroundColor`, `iForegroundColor` | `vec3` | Current theme (after reverse video) | +| `iCursorColor`, `iCursorText` | `vec3` | | +| `iSelectionBackgroundColor`, `iSelectionForegroundColor` | `vec3` | | + +Toastty extensions: + +| Uniform | Type | Value | +|---|---|---| +| `iTimeBell` | `float` | `iTime` at the last BEL; `-1e6` before the first | +| `iBellCursor` | `vec4` | The cell the cursor was in when the BEL arrived, same layout as `iCurrentCursor` | +| `iBellCount` | `int` | BELs since start | +| `iBells[4]` | `vec4[4]` | The last four BELs, newest first: `xy` = centre of the bell cell (px), `z` = `iTime` at the bell, `w` = seconds since the bell, computed on the CPU in f64 (exact even after days of uptime). Unused slots: `(0, 0, -1e6, 1e6)` | +| `iCellSize` | `vec2` | Cell width, height (px) | +| `iGridRect` | `vec4` | The cell grid: `xy` = top-left (px), `zw` = size (px) | + +The bell position comes from `Term::last_bell_cell()` (screen row/col +captured when BEL is parsed) and `Term::bell_revision()`, mapped to +pixels the way `cursor_pixel_rect` maps the cursor (view offset +included). A bell in an OSC terminator is not a bell. + +## Animation + +After compiling, walk the naga module: collect the uniform-block members +read by `mainImage` and everything it calls (the `AccessIndex` on the +block global, through the `Call` graph). The chain's needs are the union +over its shaders. + +- **Time inputs:** `iTime`, `iTimeDelta`, `iFrame`, `iDate`, `iBells` + (its `w`). A shader that reads none of them can't change between events, + so it never needs time-driven frames. +- **Event inputs:** `iMouse` (pointer move / press), `iFocus` and + `iTimeFocus` (focus change), the bell uniforms (BEL). When one of these + changes and the shader reads it, render a frame even if nothing is + damaged. Cursor and colour uniforms change only with damage or a full + clear, which already render. +- **Event duration hint:** a shader may declare + `const float TOASTTY_EVENT_DURATION = 1.5;` (WGSL: + `const TOASTTY_EVENT_DURATION: f32 = 1.5;`), read from the module's + named constants. It means "my time-driven animation only lasts this long + after an event". It is valid but unused in Ghostty. + +Per `[shader] animation`: + +| Mode | Shader reads time, declares a duration | Reads time, no duration | No time inputs | +|---|---|---|---| +| `auto` | ~60 Hz for `duration` after an event the shader reads (below), focused or not; else idle | ~60 Hz while focused; idle while unfocused | Idle | +| `always` | ~60 Hz | ~60 Hz | ~60 Hz | +| `never` | Event frames only | Event frames only | Event frames only | + +The events that open the `duration` window are a bell, a cursor change +and a focus gain, but only those whose uniforms the chain reads (bell: +`iTimeBell`, `iBellCursor`, `iBellCount`, `iBells`; cursor: the +`iCurrentCursor*` / `iPreviousCursor*` / `iTimeCursorChange` family; focus: +`iFocus`, `iTimeFocus`). A chain that reads none of them is opened by all +three. So a bell-only shader stays idle while you type. + +"Idle" means the existing skip-submit gate applies. The chain's +duration is the max over its shaders. Time-driven frames are scheduled +through `Renderer::next_redraw_deadline`, like cursor blink. + +## Render path + +With no shaders configured, the frame path is **unchanged and +pixel-identical** (the existing snapshot tests guard this). + +With a chain: + +1. **Content pass** into `scratch_texture`, never directly into the + swapchain (`render_direct` is off). It draws everything it draws today + except the UI overlays, and keeps the partial-redraw and `LoadOp` logic. + The IME preedit stays here, because it is part of the content. +2. **Post passes**, one fullscreen triangle per shader. Pass 0 reads + scratch. Each later pass reads the previous pass's output (two + ping-pong textures in the non-sRGB surface format, created only when + the chain has more than one shader). The last pass writes the + swapchain image through its non-sRGB view. This replaces the + `copy_texture_to_texture` blit. +3. **UI pass** on the swapchain image (sRGB view, `LoadOp::Load`), drawn + every post-processed frame and never shaded: debug overlay, error + banner, close dialog with its scrim, scroll-to-bottom chip, + "Copy Markdown" chip. + +Scratch needs `TEXTURE_BINDING` and a non-sRGB entry in `view_formats`. +The surface config needs a non-sRGB `view_formats` entry. Switching the +chain on or off forces a full clear. + +A frame with no damage but a due post animation (or an event) skips the +content pass's draws and runs only steps 2 and 3. + +## Renderer API (`toastty-render`) + +```rust +pub struct PostShaderSource { pub path: PathBuf, pub source: String } +pub struct ShaderError { pub path: PathBuf, pub line: Option, pub column: Option, pub message: String } // Display: "path:line:col: message" +pub enum PostAnimation { Auto, Always, Never } + +impl Renderer { + /// Compile and install a chain. All-or-nothing: if any shader fails, + /// returns every error and keeps the current chain. An empty slice + /// turns post-processing off. + pub fn set_post_shaders(&mut self, shaders: &[PostShaderSource]) -> Result<(), Vec>; + pub fn set_post_animation(&mut self, mode: PostAnimation); + pub fn set_focused(&mut self, focused: bool); + /// Pointer position in physical px (surface space) and button-1 state. + pub fn set_pointer(&mut self, pos: (f32, f32), button1_down: bool); +} +``` + +The renderer reads the bell from `Term` itself in `render_term`, so a +BEL with no other output must still produce a `render_term` call (the +binary requests a redraw after any PTY input). + +## Testing + +- Unit: the prelude keeps user line numbers; `#version` is blanked; error + locations map back to the user file; analysis finds time inputs through + helper calls; a static shader is static; the duration constant is read + in both languages; `iChannel1` gives a clean error. +- GPU (via `test_gpu`): a pass-through shader reproduces the no-shader + frame; an invert shader inverts; a two-shader chain applies both; with + no shader, existing snapshots are unchanged. +- App, on Xvfb: screenshots of the demo shader, static and mid-ripple. + +## Demo + +`examples/shaders/crt-splitflap.glsl`: a static CRT look (barrel +curvature, scanlines, aperture mask, vignette) plus, on each BEL, a +split-flap wave. Starting at the bell's cell, cells flip like a +departure-board tile in a ring that spreads outward. Overlapping bells +come from `iBells`, and `TOASTTY_EVENT_DURATION` keeps it idle between +bells. + +## Out of scope + +UI for choosing shaders, multi-buffer ShaderToys (`iChannel1+`, feedback), +compute effects, HLSL/Slang/SPIR-V input, visual/audible bell features. diff --git a/docs/screenshots/crt-splitflap.gif b/docs/screenshots/crt-splitflap.gif new file mode 100644 index 00000000..55a3e22a Binary files /dev/null and b/docs/screenshots/crt-splitflap.gif differ diff --git a/examples/shaders/crt-splitflap.glsl b/examples/shaders/crt-splitflap.glsl new file mode 100644 index 00000000..6041ec5d --- /dev/null +++ b/examples/shaders/crt-splitflap.glsl @@ -0,0 +1,204 @@ +// CRT look with a split-flap wave on every bell. +// +// The CRT part is static: barrel curvature, scanlines, an aperture-grille +// mask, slight colour fringing and a vignette. It reads no time input, so +// between bells toastty draws nothing at all. +// +// On each BEL (try `printf '\a'`), the cells flip like the tiles of a +// split-flap departure board: each tile riffles through two characters +// borrowed from elsewhere on screen, then lands back on its own. The wave +// starts at the cell where the bell rang and spreads outward until it has +// crossed the window. Up to four overlapping bells (`iBells`) each run +// their own wave. +// +// Enable it with: +// +// [shader] +// paths = ["~/src/toastty/examples/shaders/crt-splitflap.glsl"] + +// toastty: animate for this long after an event (bell, cursor move, focus), +// then stop drawing. Must cover SWEEP_SECONDS + FLIPS * FLIP_SECONDS. +const float TOASTTY_EVENT_DURATION = 1.6; + +// ---- CRT ---- +const float CURVATURE = 0.06; // barrel distortion, 0 = flat +const float SCANLINES = 0.22; // scanline darkening, 0..1 +const float SCANLINE_PX = 3.0; // scanline period in pixels +const float MASK = 0.10; // aperture-grille strength, 0..1 +const float FRINGE_PX = 0.6; // red/blue offset at the edges, px +const float VIGNETTE = 0.30; // corner darkening, 0..1 +const float BRIGHTNESS = 1.12; // makes up for scanline/mask loss + +// ---- split-flap ---- +const float SWEEP_SECONDS = 1.2; // time for the wave to cross the window +const int FLIPS = 3; // flips per tile: two random characters, then home +const float FLIP_SECONDS = 0.1; // time for one flip +const float FLAP_SHADE = 0.55; // darkest the flap gets edge-on +const float SHADOW = 0.45; // shadow the flap casts on the card behind it +const float TILE_LIFT = 0.07; // flipping tiles are drawn as lighter cards + +const float PI = 3.14159265; + +// Barrel distortion in 0..1 uv space. +vec2 curve(vec2 uv) { + vec2 c = uv * 2.0 - 1.0; + c *= 1.0 + CURVATURE * (c.yx * c.yx); + return c * 0.5 + 0.5; +} + +// Dave Hoskins' hash: no `sin`, so it stays random for large inputs. +float hash(vec2 p) { + vec3 p3 = fract(vec3(p.xyx) * 0.1031); + p3 += dot(p3, p3.yzx + 33.33); + return fract((p3.x + p3.y) * p3.z); +} + +// How far the tile centred at `center` is through its flips (0..FLIPS), +// or -1 when it is at rest. `y` is a per-bell seed. The newest bell that +// is flipping this tile wins. +vec2 flip_progress(vec2 center) { + float diag = length(iResolution.xy); + for (int i = 0; i < 4; i++) { + vec4 bell = iBells[i]; + float delay = distance(center, bell.xy) / diag * SWEEP_SECONDS; + float p = (bell.w - delay) / FLIP_SECONDS; + if (p > 0.0 && p < float(FLIPS)) { + return vec2(p, mod(bell.z * 61.0, 997.0)); + } + } + return vec2(-1.0, 0.0); +} + +// How much a cell differs from the background: a few taps across it. +float ink(vec2 cell) { + vec2 o = iGridRect.xy + cell * iCellSize; + float sum = 0.0; + for (int i = 0; i < 3; i++) { + vec2 at = o + iCellSize * vec2(0.5, 0.3 + 0.2 * float(i)); + sum += dot(abs(texture(iChannel0, at / iResolution.xy).rgb - iBackgroundColor), vec3(1.0)); + } + return sum; +} + +// The cell whose character card `k` of tile `cell` shows. Card 0 is the +// tile's own character and so is the last one, so every tile comes home. +// The cards in between borrow a random character from elsewhere on +// screen, skipping (up to a few) blank ones. +vec2 card(vec2 cell, int k, float seed) { + if (k <= 0 || k >= FLIPS) { + return cell; + } + vec2 cells = floor(iGridRect.zw / iCellSize); + vec2 cand = cell; + for (int j = 0; j < 6; j++) { + // Retries step far apart: with `+ j`, tile x's second try would be + // tile x+1's first, and neighbours would settle on the same cell. + vec2 h = vec2(float(k * 6 + j) * 211.0, seed); + cand = floor(vec2(hash(cell + h), hash(cell.yx + h.yx + 17.0)) * cells); + if (ink(cand) > 0.25) { + break; + } + } + return cand; +} + +// Where to sample for pixel `p` (px): `xy` = source pixel, `z` = shade, +// `w` = 1 inside a flipping tile. A flip is the physical one: the top +// flap of the old card falls toward the viewer over the hinge in the +// middle, uncovering the new card's top half, and its back (the new +// card's bottom half) unfolds over the old bottom half. +vec4 split_flap(vec2 p) { + vec2 grid = p - iGridRect.xy; + if (any(lessThan(grid, vec2(0.0))) || any(greaterThanEqual(grid, iGridRect.zw))) { + return vec4(p, 1.0, 0.0); + } + vec2 cell = floor(grid / iCellSize); + vec2 origin = iGridRect.xy + cell * iCellSize; + vec2 progress = flip_progress(origin + 0.5 * iCellSize); + if (progress.x < 0.0) { + return vec4(p, 1.0, 0.0); + } + + int k = int(progress.x); + float t = fract(progress.x); + vec2 old_card = card(cell, k, progress.y); + vec2 new_card = card(cell, k + 1, progress.y); + + vec2 local = (p - origin) / iCellSize; // 0..1, y down + float fold = cos(PI * t); // 1 -> 0 -> -1 + float half_h = 0.5 * abs(fold); // projected height of the flap + float light = mix(FLAP_SHADE, 1.0, abs(fold)); + vec2 from = new_card; + float y = local.y; + float shade = 1.0; + + if (fold > 0.0) { + if (y < 0.5 - half_h) { + shade = 1.0 - SHADOW * abs(fold); // new top, in the flap's shadow + } else if (y < 0.5) { + from = old_card; // the falling flap + y = 0.5 - (0.5 - y) / abs(fold); + shade = light; + } else { + from = old_card; // old bottom, not covered yet + } + } else { + if (y < 0.5) { + // new top, settled + } else if (y < 0.5 + half_h) { + y = 0.5 + (y - 0.5) / abs(fold); // the flap's back unfolding + shade = light; + } else { + from = old_card; // old bottom, in shadow + shade = 1.0 - SHADOW * (1.0 - abs(fold)); + } + } + + // The hinge and the gaps between tiles. + float hinge = abs(local.y - 0.5) * iCellSize.y; + vec2 edge = min(local, 1.0 - local) * iCellSize; + if (hinge < 0.75 || min(edge.x, edge.y) < 0.75) { + shade *= 0.3; + } + + vec2 src = iGridRect.xy + (from + vec2(local.x, y)) * iCellSize; + return vec4(src, shade, 1.0); +} + +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + vec2 uv = curve(fragCoord / iResolution.xy); + if (any(lessThan(uv, vec2(0.0))) || any(greaterThan(uv, vec2(1.0)))) { + fragColor = vec4(0.0, 0.0, 0.0, 1.0); // outside the tube + return; + } + + vec4 flap = split_flap(uv * iResolution.xy); + vec2 src = flap.xy / iResolution.xy; + + // Colour fringing grows toward the edges. + vec2 fringe = (uv - 0.5) * 2.0 * FRINGE_PX / iResolution.xy; + vec4 base = texture(iChannel0, src); + vec3 color = vec3( + texture(iChannel0, src + fringe).r, + base.g, + texture(iChannel0, src - fringe).b + ); + color = (color + flap.w * TILE_LIFT) * flap.z; + + // Scanlines follow the curved surface, so they bend with it. + float scan = 0.5 + 0.5 * cos(2.0 * PI * uv.y * iResolution.y / SCANLINE_PX); + color *= 1.0 - SCANLINES * scan; + + // Aperture grille: one bright channel per pixel column, in screen space. + int column = int(mod(floor(fragCoord.x), 3.0)); + vec3 mask = vec3(1.0 - MASK); + mask[column] = 1.0; + color *= mask; + + float v = 16.0 * uv.x * uv.y * (1.0 - uv.x) * (1.0 - uv.y); + color *= mix(1.0 - VIGNETTE, 1.0, pow(v, 0.3)); + + // `base.a` keeps a transparent background transparent; the colour is + // only ever darkened, so it stays a valid premultiplied value. + fragColor = vec4(min(color * BRIGHTNESS, vec3(base.a)), base.a); +}