CtrlK
BlogDocsLog inGet started
Tessl Logo

tessl/maven-com-williamcallahan--tui4j

Terminal User Interface framework for Java that ports the Charmbracelet ecosystem (Bubble Tea, Bubbles, Lipgloss, Harmonica) from Go, enabling developers to build interactive CLI applications using The Elm Architecture pattern.

Overview
Eval results
Files

animation.mddocs/

Animation

TUI4J includes Harmonica, a physics-based animation system that provides spring dynamics for smooth, natural motion in terminal user interfaces.

Spring Physics

Spring

The Spring class implements spring physics for creating smooth animations with configurable damping and frequency.

package com.williamcallahan.tui4j.compat.harmonica;

class Spring {
    // Factory
    static Spring newSpring(double deltaTime, double angularFrequency, double dampingRatio);
    static double fps(int n);

    // Update
    double update(double position, double velocity, double target);

    // Configuration
    void setDampingRatio(double ratio);
    void setAngularFrequency(double frequency);

    // State
    double velocity();
}

Parameters:

  • deltaTime - Time step between frames (use Spring.fps(n) to convert from FPS)
  • angularFrequency - Controls speed of spring oscillation (higher = faster)
  • dampingRatio - Controls oscillation damping:
    • < 1.0 - Underdamped (oscillates)
    • = 1.0 - Critically damped (no oscillation, fastest to target)
    • > 1.0 - Overdamped (slow, no oscillation)

Basic Usage:

// Create a spring for 60 FPS
Spring spring = Spring.newSpring(
    Spring.fps(60),      // Delta time (1/60 second)
    6.0,                 // Angular frequency
    1.0                  // Damping ratio (critically damped)
);

// Animation loop
double position = 0.0;
double target = 100.0;

while (Math.abs(position - target) > 0.01) {
    position = spring.update(position, spring.velocity(), target);
    // Render at position
    Thread.sleep(16); // ~60 FPS
}

FPS Conversion

Convert frames per second to delta time.

static double fps(int n);

Usage:

double deltaTime60 = Spring.fps(60);  // 0.0166... (1/60)
double deltaTime30 = Spring.fps(30);  // 0.0333... (1/30)

Physics Types

Point

3D point in space.

package com.williamcallahan.tui4j.compat.harmonica;

record Point(double x, double y, double z) { }

Vector

3D vector.

package com.williamcallahan.tui4j.compat.harmonica;

record Vector(double x, double y, double z) { }

Projectile

Projectile motion physics.

package com.williamcallahan.tui4j.compat.harmonica;

class Projectile {
    Projectile();

    Point update(double deltaTime);
    void setVelocity(Vector velocity);
    void setGravity(Vector gravity);
    void setPosition(Point position);

    Point position();
    Vector velocity();
}

Usage:

Projectile projectile = new Projectile();
projectile.setPosition(new Point(0, 0, 0));
projectile.setVelocity(new Vector(10, 20, 0));
projectile.setGravity(new Vector(0, -9.8, 0));

// Animation loop
while (projectile.position().y() >= 0) {
    Point pos = projectile.update(0.016);  // 60 FPS
    // Render projectile at pos
}

Animation Patterns

Smooth Scrolling

Use springs for smooth scrolling animations.

class ScrollModel implements Model {
    private final Spring spring;
    private double currentOffset;
    private double targetOffset;

    public ScrollModel() {
        this.spring = Spring.newSpring(
            Spring.fps(60),
            6.0,   // Fast response
            1.0    // No oscillation
        );
        this.currentOffset = 0.0;
        this.targetOffset = 0.0;
    }

    @Override
    public UpdateResult<? extends Model> update(Message msg) {
        if (msg instanceof KeyPressMessage keyMsg) {
            Key key = keyMsg.key();

            // Set target offset
            if (key.type() == KeyType.KeyDown) {
                ScrollModel updated = new ScrollModel(this);
                updated.targetOffset = targetOffset + 3;
                return UpdateResult.from(
                    updated,
                    Command.tick(Duration.ofMillis(16), id -> new AnimateMessage())
                );
            }
        }

        if (msg instanceof AnimateMessage) {
            // Update spring position
            currentOffset = spring.update(currentOffset, spring.velocity(), targetOffset);

            // Continue animation if not at target
            if (Math.abs(currentOffset - targetOffset) > 0.1) {
                return UpdateResult.from(
                    this,
                    Command.tick(Duration.ofMillis(16), id -> new AnimateMessage())
                );
            }
        }

        return UpdateResult.from(this);
    }

    @Override
    public String view() {
        int offset = (int) Math.round(currentOffset);
        // Render with offset
        return renderContent(offset);
    }
}

Smooth Value Transitions

Animate between numeric values.

class CounterModel implements Model {
    private final Spring spring;
    private double currentValue;
    private double targetValue;

    @Override
    public UpdateResult<? extends Model> update(Message msg) {
        if (msg instanceof IncrementMessage) {
            CounterModel updated = new CounterModel(this);
            updated.targetValue = targetValue + 10;
            return UpdateResult.from(
                updated,
                Command.tick(Duration.ofMillis(16), id -> new AnimateMessage())
            );
        }

        if (msg instanceof AnimateMessage) {
            currentValue = spring.update(currentValue, spring.velocity(), targetValue);

            if (Math.abs(currentValue - targetValue) > 0.01) {
                return UpdateResult.from(
                    this,
                    Command.tick(Duration.ofMillis(16), id -> new AnimateMessage())
                );
            }
        }

        return UpdateResult.from(this);
    }

    @Override
    public String view() {
        return String.format("Value: %.2f", currentValue);
    }
}

Progress Animation

Animate progress bar with spring physics.

class ProgressModel implements Model {
    private final Progress progress;
    private final Spring spring;
    private double currentPercent;
    private double targetPercent;

    @Override
    public UpdateResult<? extends Model> update(Message msg) {
        if (msg instanceof SetProgressMessage setMsg) {
            ProgressModel updated = new ProgressModel(this);
            updated.targetPercent = setMsg.percent();
            return UpdateResult.from(
                updated,
                Command.tick(Duration.ofMillis(16), id -> new AnimateMessage())
            );
        }

        if (msg instanceof AnimateMessage) {
            currentPercent = spring.update(
                currentPercent,
                spring.velocity(),
                targetPercent
            );

            Progress updated = progress;
            UpdateResult<Progress> result = updated.update(
                updated.setPercent(currentPercent)
            );

            ProgressModel newModel = new ProgressModel(result.model(), this);

            if (Math.abs(currentPercent - targetPercent) > 0.001) {
                return UpdateResult.from(
                    newModel,
                    Command.batch(
                        result.command(),
                        Command.tick(Duration.ofMillis(16), id -> new AnimateMessage())
                    )
                );
            }

            return UpdateResult.from(newModel, result.command());
        }

        // Delegate to progress component
        UpdateResult<Progress> result = progress.update(msg);
        return UpdateResult.from(
            new ProgressModel(result.model(), this),
            result.command()
        );
    }
}

Easing with Damping Ratios

Different damping ratios create different animation feels:

// Bouncy animation (underdamped)
Spring bouncy = Spring.newSpring(Spring.fps(60), 6.0, 0.5);

// Smooth animation (critically damped)
Spring smooth = Spring.newSpring(Spring.fps(60), 6.0, 1.0);

// Slow animation (overdamped)
Spring slow = Spring.newSpring(Spring.fps(60), 6.0, 1.5);

// Fast, responsive animation
Spring fast = Spring.newSpring(Spring.fps(60), 10.0, 1.0);

// Slow, gentle animation
Spring gentle = Spring.newSpring(Spring.fps(60), 3.0, 1.0);

Window Resize Animation

Smoothly animate layout changes on window resize.

class LayoutModel implements Model {
    private final Spring widthSpring;
    private double currentWidth;
    private double targetWidth;

    @Override
    public UpdateResult<? extends Model> update(Message msg) {
        if (msg instanceof WindowSizeMessage sizeMsg) {
            LayoutModel updated = new LayoutModel(this);
            updated.targetWidth = sizeMsg.width();
            return UpdateResult.from(
                updated,
                Command.tick(Duration.ofMillis(16), id -> new AnimateMessage())
            );
        }

        if (msg instanceof AnimateMessage) {
            currentWidth = widthSpring.update(
                currentWidth,
                widthSpring.velocity(),
                targetWidth
            );

            if (Math.abs(currentWidth - targetWidth) > 0.5) {
                return UpdateResult.from(
                    this,
                    Command.tick(Duration.ofMillis(16), id -> new AnimateMessage())
                );
            }
        }

        return UpdateResult.from(this);
    }

    @Override
    public String view() {
        int width = (int) Math.round(currentWidth);
        Style style = Style.newStyle().width(width);
        return style.render("Animated content");
    }
}

Best Practices

  1. Frame Rate: Use consistent frame rates (typically 30 or 60 FPS)

    Spring.fps(60)  // 16.67ms per frame
    Spring.fps(30)  // 33.33ms per frame
  2. Damping Ratios:

    • Use 1.0 for most UI animations (no oscillation)
    • Use 0.5-0.8 for playful, bouncy effects
    • Use 1.2-1.5 for slow, heavy objects
  3. Angular Frequency:

    • Higher values (8-10) for quick, responsive UI
    • Medium values (4-6) for most animations
    • Lower values (2-3) for slow, deliberate motion
  4. Threshold Detection: Stop animation when close to target

    if (Math.abs(current - target) < 0.01) {
        // Animation complete
    }
  5. Command Chaining: Use Command.tick() to schedule next animation frame

    Command.tick(Duration.ofMillis(16), id -> new AnimateMessage())
  6. State Management: Keep spring instances in model state

    class MyModel implements Model {
        private final Spring spring;
        // Spring persists across updates
    }
  7. Multiple Springs: Use separate springs for independent animations

    private final Spring xSpring;
    private final Spring ySpring;
    private final Spring scaleSpring;
  8. Performance: Only animate when needed

    if (Math.abs(current - target) > threshold) {
        // Continue animating
    } else {
        // Stop sending animation messages
    }

Install with Tessl CLI

npx tessl i tessl/maven-com-williamcallahan--tui4j@0.3.0

docs

animation.md

bubbles-components.md

bubbletea-core.md

index.md

input-handling.md

lipgloss-styling.md

utilities.md

tile.json