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.
TUI4J includes Harmonica, a physics-based animation system that provides spring dynamics for smooth, natural motion in terminal user interfaces.
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
}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)3D point in space.
package com.williamcallahan.tui4j.compat.harmonica;
record Point(double x, double y, double z) { }3D vector.
package com.williamcallahan.tui4j.compat.harmonica;
record Vector(double x, double y, double z) { }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
}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);
}
}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);
}
}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()
);
}
}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);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");
}
}Frame Rate: Use consistent frame rates (typically 30 or 60 FPS)
Spring.fps(60) // 16.67ms per frame
Spring.fps(30) // 33.33ms per frameDamping Ratios:
1.0 for most UI animations (no oscillation)0.5-0.8 for playful, bouncy effects1.2-1.5 for slow, heavy objectsAngular Frequency:
Threshold Detection: Stop animation when close to target
if (Math.abs(current - target) < 0.01) {
// Animation complete
}Command Chaining: Use Command.tick() to schedule next animation frame
Command.tick(Duration.ofMillis(16), id -> new AnimateMessage())State Management: Keep spring instances in model state
class MyModel implements Model {
private final Spring spring;
// Spring persists across updates
}Multiple Springs: Use separate springs for independent animations
private final Spring xSpring;
private final Spring ySpring;
private final Spring scaleSpring;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