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).
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.
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).Nonemeans the full scope.mask: Option<Arc<CellMask>>- optional bitmap; cells where the mask is false skip the inner effect.Nonemeans a solid rectangle (bounds, or the full scope whenboundsisNone).inner- anotherVisualEffect(for exampleRippleorDim).
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, and the host terminal background when known.
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
}
}
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 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.