Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
cd4a985
term: record BEL position and count; docs: M13 post-process shader spec
EDToaster Oct 22, 2026
0234fbb
examples: CRT + split-flap bell demo shader
EDToaster Oct 22, 2026
51e20b5
config: [shader] section; shader file loading and watching (M13)
EDToaster Oct 22, 2026
46dd3ee
shader_watcher: absolute targets, canonical parent dirs; keep io::Err…
EDToaster Oct 22, 2026
85b004f
Merge branch 'post-shaders-config' into post-shaders
EDToaster Oct 22, 2026
c720918
readme: post-processing shaders
EDToaster Oct 22, 2026
8e091b6
render: post-process shader compiler, analysis and GPU chain (M13)
EDToaster Oct 22, 2026
f2c6467
render: run post-process shader chains in render_term (M13)
EDToaster Oct 22, 2026
87b2138
Merge branch 'post-shaders-render' into post-shaders
EDToaster Oct 22, 2026
044f80a
main: load [shader] chains, hot-reload shader files, feed focus and p…
EDToaster Oct 22, 2026
3a7b940
examples: split-flap tiles riffle through borrowed characters; README…
EDToaster Oct 22, 2026
5cf15de
main: rustfmt
EDToaster Oct 22, 2026
3e3584d
docs: M13 duration window opens only on events the shader reads
EDToaster Oct 22, 2026
23f65ea
render(post): WGSL prelude after the user's code; clearer mainImage e…
EDToaster Oct 22, 2026
c903328
main: keep the blur link-hover clear, retry a missing shader watcher,…
EDToaster Oct 22, 2026
ac48877
render(post): review fixes for scheduling, iDate and full clears
EDToaster Oct 22, 2026
4b58150
render: headless test target; end-to-end render_term tests with post …
EDToaster Oct 22, 2026
5df7644
Merge branch 'post-shaders-render' into post-shaders
EDToaster Oct 22, 2026
11af18d
render(post): keep real errors, catch unclosed WGSL, keep dropped eve…
EDToaster Oct 22, 2026
b02b5a5
Merge branch 'post-shaders-render' into post-shaders
EDToaster Oct 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

45 changes: 41 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,40 @@ cargo run --release -p molecule-viewer # run it inside toastty
<img src="docs/screenshots/molecule.gif" alt="An animated 3D ball-and-stick model of caffeine rotating inside a terminal UI" width="720">
</p>

### ✨ 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:

<p align="center">
<img src="docs/screenshots/crt-splitflap.gif" alt="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" width="720">
</p>

```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.
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
16 changes: 16 additions & 0 deletions crates/toastty-config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ mod scroll_button;
mod scrollback;
mod security;
mod selection;
mod shader;
mod shell;
mod text;
mod theme;
Expand All @@ -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;
Expand Down Expand Up @@ -73,6 +75,7 @@ pub struct Config {
pub scroll_button: ScrollButtonConfig,
pub selection: SelectionConfig,
pub text: TextConfig,
pub shader: ShaderConfig,
}

impl Default for Config {
Expand All @@ -99,6 +102,7 @@ impl Config {
scroll_button: ScrollButtonConfig::defaults(),
selection: SelectionConfig::defaults(),
text: TextConfig::defaults(),
shader: ShaderConfig::defaults(),
}
}

Expand Down Expand Up @@ -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");
Expand Down
103 changes: 103 additions & 0 deletions crates/toastty-config/src/shader.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
/// 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::<ShaderConfig>("animation = \"sometimes\"\n").is_err());
assert!(toml::from_str::<ShaderConfig>("paths = \"a.glsl\"\n").is_err());
assert!(toml::from_str::<ShaderConfig>("bogus = 1\n").is_err());
}
}
4 changes: 4 additions & 0 deletions crates/toastty-config/tests/fixtures/full.toml
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,7 @@ word_chars = "_-./~:?&=%+#@"

[text]
blink = true

[shader]
paths = ["~/.config/toastty/crt.glsl", "bloom.wgsl"]
animation = "auto"
Loading
Loading