Skip to content

Visual effects (VisualEffect) ​

Declarative post-processing for EffectScope and hover passes on MouseRegion (hover_effect / hover_effects).

VisualEffect::Gradient ​

Uses ColorGradient stops (min -> optional center -> max) sampled along EffectAxis in scope-local normalized coordinates (nested scopes remap independently), then blended onto rendered fg/bg like RainbowWave. frequency repeats a sine-eased mirrored ramp (min -> max -> min) across the scope, avoiding hard wrap seams and sharp endpoint troughs; speed shifts the pattern using the renderer phase (0.0 = static).

Effects modify both color channels by default. Use .foreground_only(), .background_only(), or .channels(EffectChannels::...) to restrict any effect without changing its variant fields. A foreground-only gradient is useful for art rendered over an app-level fill because it leaves concrete cell backgrounds unchanged:

rust
VisualEffect::Gradient {
    gradient,
    blend: 0.9,
    frequency: 1.0,
    speed: 0.0,
    axis: EffectAxis::Horizontal,
}
.foreground_only()

VisualEffect::Ripple ​

Ripple uses origin: EffectOrigin, so the ring can be pinned to explicit scope-local cells or resolved from the current EffectScope bounds at render time. Its radius: RippleRadius is the animation knob: Fixed is static, Loop repeats from zero to max_radius, and Once plays a single burst from a captured renderer start_tick.

rust
VisualEffect::Ripple {
    origin: EffectOrigin::cell(12.0, 3.0),
    radius: RippleRadius::Fixed(4.0),
    ring_width: 1.5,
    tint: Color::Cyan,
    strength: 0.6,
}

VisualEffect::centered_ripple(4.0, 1.5, Color::Cyan, 0.6)

VisualEffect::centered_looping_ripple(18.0, 90, 1.5, Color::Cyan, 0.6)

let start_tick = ctx.effect_phase();
VisualEffect::centered_burst_ripple(18.0, 45, start_tick, 1.5, Color::Cyan, 0.6)

VisualEffect::Ripple {
    origin: EffectOrigin::aligned(EffectAlignment::TOP_RIGHT),
    radius: RippleRadius::Once {
        max_radius: 18.0,
        duration_ticks: 45,
        start_tick,
    },
    ring_width: 1.5,
    tint: Color::Cyan,
    strength: 0.6,
}

Loop and Once automatically mark the effect as animated so the runtime schedules repaint ticks. Radius growth uses ease-out (1 - (1 - t)^2); strength fades linearly by 1 - t. Once stops rendering outside its window, but callers should remove the effect or replace it with Fixed after completion so the animation ticker can go idle.

VisualEffect::Clipped ​

Restricts an inner effect to a sub-rectangle of the scope and/or a per-cell bitmask:

  • bounds: Option<Rect> - clip rect in scope-local coordinates (origin at the effect scope’s top-left). None means the full scope.
  • mask: Option<Arc<CellMask>> - optional bitmap; cells where the mask is false skip the inner effect. None means a solid rectangle (bounds, or the full scope when bounds is None).
  • inner - another VisualEffect (for example Ripple or Dim).

CellMask stores origin, w, h, and row-major packed bits in Arc<[u64]>. Use CellMask::test_scope_local for scope-local coordinates.

BigText::layout_glyphs builds the same raster as BigText::build_lines() for each line of text, splits it into per-character column bands, then derives each glyph’s ink Rect and CellMask. Exact letter boundaries only when FIGlet leaves at least one fully blank column between glyphs; when letters touch, bands use each character’s standalone FIGlet width (same font and style as the line), scaled to the full ink span - closer than equal slices, though smushed strings can still differ slightly from per-glyph truth. Blank lines advance the vertical offset by one row each; "A\n\nB" inserts a single empty row between blocks. Use a single MouseRegion over the BigText with hit_test / pointer move using those masks so coordinates stay in the shared scope. See the “Letter burst” tab in examples/burst_effects.rs.

Nested Clipped layers compose; each layer’s bounds / mask uses the same scope-local coordinate system as the enclosing EffectScope.

Custom Effects ​

VisualEffect::Custom(Arc<dyn CellEffect>) lets applications add their own per-cell post-processing pass without forking the renderer. The effect receives an EffectCell plus an EffectContext containing the absolute cell position, the absolute effect-scope bounds, the animation phase, the monotonic runtime clock elapsed, and the host terminal background when known.

phase counts effect ticks, so it slows down when frames are delayed. Evaluate time-based animation from elapsed (time since the runtime started, following the virtual clock in headless capture and TestBackend::advance) to keep its pace regardless of frame timing. elapsed is read per draw, so a partial repaint between two of the effect's ticks sees a later time than the cells it leaves alone; quantize to your animation_interval() if that matters. For a transform whose strength simply breathes, you do not need a custom effect at all: pass ctx.pulsing_amount(key, EffectPulse::new(from, to)) to EffectScope::dim_by / lighten_by / tint_by or any ColorTransform.

EffectContext and EffectPrepareContext are #[non_exhaustive]: the renderer builds them. To unit-test an effect, use EffectContext::new(x, y, bounds) / EffectPrepareContext::new(bounds) and the with_phase, with_elapsed, and with_terminal_bg setters.

For expensive effects, override prepare(&EffectPrepareContext) and return a PreparedCellEffect. Preparation runs once per effect scope, bounds, and render phase before the per-cell pass (EffectPrepareContext carries the same phase and elapsed), so you can cache light positions, palettes, masks, or other frame-constant state instead of recomputing it for every cell.

rust
use std::fmt;

use tui_lipan::prelude::*;

#[derive(Clone)]
struct Vortex {
    strength: f32,
}

impl fmt::Debug for Vortex {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("Vortex")
            .field("strength", &self.strength)
            .finish()
    }
}

impl CellEffect for Vortex {
    fn apply(&self, cell: &mut EffectCell, ctx: &EffectContext) {
        let lx = ctx.x as f32 - ctx.bounds.x as f32 + 0.5;
        let ly = ctx.y as f32 - ctx.bounds.y as f32 + 0.5;
        let cx = ctx.bounds.w as f32 * 0.5;
        let cy = ctx.bounds.h as f32 * 0.5;
        let dx = lx - cx;
        let dy = (ly - cy) * 2.0;
        let radius = (dx * dx + dy * dy).sqrt().max(1.0);
        let angle = dy.atan2(dx) + ctx.phase as f32 * 0.08;
        let wave = ((angle * 3.0 + radius * 0.25).sin() * 0.5 + 0.5) * self.strength;

        if wave > 0.6 {
            cell.set_fg(TerminalColor::Cyan);
        } else if wave > 0.3 {
            cell.set_fg(TerminalColor::Blue);
        }
    }

    fn is_animated(&self) -> bool {
        true
    }

    fn cache_key(&self) -> u64 {
        self.strength.to_bits() as u64
    }
}

#[derive(Debug)]
struct PreparedSpotlight {
    cx: f32,
    cy: f32,
}

impl PreparedCellEffect for PreparedSpotlight {
    fn apply(&self, cell: &mut EffectCell, ctx: &EffectContext) {
        let lx = ctx.x as f32 - ctx.bounds.x as f32 + 0.5;
        let ly = ctx.y as f32 - ctx.bounds.y as f32 + 0.5;
        let dx = lx - self.cx;
        let dy = (ly - self.cy) * 2.0;
        if dx * dx + dy * dy < 64.0 {
            cell.set_fg(TerminalColor::White);
        }
    }
}

#[derive(Clone, Debug)]
struct Spotlight;

impl CellEffect for Spotlight {
    fn apply(&self, cell: &mut EffectCell, ctx: &EffectContext) {
        PreparedSpotlight {
            cx: ctx.bounds.w as f32 * 0.5,
            cy: ctx.bounds.h as f32 * 0.5,
        }
        .apply(cell, ctx);
    }

    fn prepare(&self, ctx: &EffectPrepareContext) -> Option<Box<dyn PreparedCellEffect>> {
        Some(Box::new(PreparedSpotlight {
            cx: ctx.bounds.w as f32 * 0.5,
            cy: ctx.bounds.h as f32 * 0.5,
        }))
    }
}

let view = EffectScope::new()
    .custom_effect(Vortex { strength: 0.8 })
    .child(Text::new("custom effect target"));

Use is_animated() when the effect depends on ctx.phase so the runtime schedules redraws. Override animation_interval() when an animated custom effect should update below the default ~60 FPS cadence, for example a slow atmospheric glow that looks the same at 30 FPS. Override cache_key() when changing effect parameters should invalidate layout/render hashes; the default 0 is safe because custom effect hashes also include Arc identity. Custom effect equality uses Arc identity too, so two different Arcs with the same cache key are not equal.

Compositing over the backdrop ​

A custom effect normally post-processes what its scope painted: by the time it runs, anything that was beneath the scope has been painted over. Override uses_backdrop() to return true and the renderer keeps a copy of the cells under the scope, taken before its children paint, and calls apply_with_backdrop(cell, backdrop, ctx) instead of apply. Assign *cell = backdrop.clone() to let what was underneath show through at that cell; leave cell alone to keep the scope's content; or mix the two for a per-cell blend.

That makes an EffectScope a compositor between two layers, which is what reveals, wipes, irises, dissolves, and per-cell crossfades need. Put the outgoing content beneath the scope in a ZStack. When the outgoing content is leaving the tree, Animated::auto_exit keeps it painted underneath for the length of the transition. Its default exit animation fades to opacity 0, so keep the retained layer fully opaque and let the backdrop effect perform the transition:

rust
Animated::new(old_screen)
    .auto_exit(ExitAnimation::new(duration_ms).keep_opacity())
    .key(old_screen_key)
rust
/// Iris transition: the new content opens from the centre over the old.
#[derive(Debug)]
struct Iris {
    progress: f32,
}

impl CellEffect for Iris {
    fn apply(&self, _cell: &mut EffectCell, _ctx: &EffectContext) {}

    // Only while opening: at rest there is nothing to composite, so skip the per-frame copy.
    fn uses_backdrop(&self) -> bool {
        self.progress < 1.0
    }

    fn apply_with_backdrop(&self, cell: &mut EffectCell, backdrop: &EffectCell, ctx: &EffectContext) {
        let dx = (ctx.x - ctx.bounds.x) as f32 - ctx.bounds.w as f32 * 0.5;
        let dy = ((ctx.y - ctx.bounds.y) as f32 - ctx.bounds.h as f32 * 0.5) * 2.0;
        let reach = (ctx.bounds.w as f32).hypot(ctx.bounds.h as f32 * 2.0) * 0.5;
        if dx.hypot(dy) > self.progress * reach {
            *cell = backdrop.clone();
        }
    }
}

// `progress` runs 0.0 -> 1.0 over the transition, from an animation the view drives.
let view = ZStack::new()
    .child(old_screen)
    .child(EffectScope::new().custom_effect(Iris { progress }).child(new_screen));

The backdrop is copied once per scope cell per frame, so report false from uses_backdrop() once the effect settles. PreparedCellEffect::apply_with_backdrop is the prepared counterpart. Effects applied outside an EffectScope (through a Style, or as hover effects) have no pre-paint copy; there the backdrop is the cell as painted, so a compositing effect leaves the cell unchanged.

MPL-2.0