motion

package
v0.0.0-...-64e189b Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 3, 2026 License: MIT Imports: 5 Imported by: 0

Documentation

Overview

Package motion provides a small, caller-side reduced-motion preference.

Two designs were considered for reduced-motion support in this repo (spec #18):

  1. Thread an explicit `reduced bool` through every animated widget's Start()/constructor (spinner.Model, streamtext.Model, skeleton.Model), consistent with how theme.Theme is threaded explicitly everywhere in this codebase.
  2. A small standalone `motion` package exposing a Preference value that the caller reads once, deciding whether to call Start() at all (or to call streamtext's Skip() for instant reveal) — without touching any existing animated widget's signature.

Decision #11 selected design (2): spinner.Model and skeleton.Model already render a valid static single frame even when Start() is never called (their View() works pre-Start), and streamtext.Model already has Skip() (which jumps shown to total() for an instant reveal). A caller checking a motion.Preference before deciding Start() vs. Skip()/no-op fully satisfies reduced motion with zero changes to spinner.go, streamtext.go, or skeleton.go. See decision #11 for the full rationale.

The package also has easing functions (Ease, from Linear to OutBack) and Tween, which interpolates a value over time and skips straight to the end value under a Reduced preference.

Index

Examples

Constants

View Source
const DefaultFPS = 60

DefaultFPS is the frame rate the program renders non-input Msgs at, and so the fastest rate a frame-synced Clock ticks.

Variables

This section is empty.

Functions

func After

func After[T any](d time.Duration, fn func(time.Time) T) func(context.Context) T

After returns a context-carrying Cmd that waits d and then produces fn's Msg, the one-shot timer behind blink, dismiss and timeout Cmds: wrap it with tui.FromCtx. The wait ends when ctx is cancelled (Run returned), and fn is then not called and no Msg is sent. T is the program's Msg type, so tui.FromCtx(After(d, fn)) is a tui.Cmd when fn returns tui.Msg.

func AfterOn

func AfterOn[T any](c *Clock, d time.Duration, fn func(time.Time) T) func(context.Context) T

AfterOn is After on c's time. When c has a Source (a test's tuitest.FakeClock), the wait is the first tick of a Source ticker of period d, created when the Cmd runs and stopped when it returns, and fn gets that tick's time; advancing the fake past d ends the wait without touching the wall clock. A nil c, or one without a Source, makes it After. The Source is read when AfterOn is called, as a Clock is driven from the Update loop.

func FrameInterval

func FrameInterval(fps int) time.Duration

FrameInterval is the interval between frames at fps; fps <= 0 means DefaultFPS. Pass the same fps given to tui.WithMaxFPS so animation ticks line up with the renderer's frame budget.

func InCubic

func InCubic(t float64) float64

InCubic is InQuad with a steeper start (t cubed).

func InOutCubic

func InOutCubic(t float64) float64

InOutCubic is InOutQuad with steeper acceleration.

func InOutQuad

func InOutQuad(t float64) float64

InOutQuad speeds up, then slows down.

func InQuad

func InQuad(t float64) float64

InQuad starts slowly and speeds up (t squared).

func Linear

func Linear(t float64) float64

Linear is constant speed.

func OutBack

func OutBack(t float64) float64

OutBack overshoots the target slightly, then settles on it. It is the one Ease that is not monotonic: it exceeds 1 before returning to 1.

func OutCubic

func OutCubic(t float64) float64

OutCubic is OutQuad with a longer, softer stop.

func OutQuad

func OutQuad(t float64) float64

OutQuad starts fast and slows down.

Types

type Clock

type Clock struct {
	Interval time.Duration
	// Motion is the reduced-motion preference. With Reduced, Start and
	// Update schedule nothing and Frame stays 0.
	Motion Preference
	// Source, when set, supplies time and ticks instead of the wall clock,
	// so a test drives the Clock by advancing a fake. Set it before Start.
	Source Source
	// contains filtered or unexported fields
}

Clock is one shared animation tick. Widgets that opt in (spinner, skeleton and loadingbar have a Clock field) render from Frame instead of scheduling their own tick chain, so every widget on one Clock shows the same frame at every tick and a program issues one tick Cmd per interval however many widgets animate.

A Clock is used by pointer and is not safe for concurrent use; drive it from the Update loop: return Start's Cmd once, and pass every Msg to Update, returning the Cmd it produces.

func NewClock

func NewClock(interval time.Duration) *Clock

NewClock returns a stopped Clock ticking every interval.

func NewFrameClock

func NewFrameClock(fps int) *Clock

NewFrameClock returns a stopped Clock ticking once per frame at fps. Animating widgets Acquire and Release it, so it ticks only while something animates.

func (*Clock) Acquire

func (c *Clock) Acquire() func(context.Context) any

Acquire registers one animator and returns the Cmd that starts the Clock if this is the first, else nil: however many widgets animate there is one tick chain, so at most one tick per frame interval. Pair every Acquire with a Release.

func (*Clock) Frame

func (c *Clock) Frame() int

Frame reports how many ticks the Clock has delivered. A nil Clock is at frame 0.

func (*Clock) Now

func (c *Clock) Now() time.Time

Now reports the Clock's time: Source.Now when a Source is set, else the wall clock. A nil Clock reads the wall clock.

func (*Clock) Release

func (c *Clock) Release()

Release drops one animator and stops the Clock when none remain, so an idle program schedules no timer. Extra Releases are ignored.

func (*Clock) Running

func (c *Clock) Running() bool

Running reports whether the Clock is ticking.

func (*Clock) Start

func (c *Clock) Start() func(context.Context) any

Start begins ticking and returns the first tick as a context-carrying Cmd: wrap it with tui.FromCtx. The pending wait ends when ctx is cancelled (Run returned). It returns nil under reduced motion or on a nil Clock, and starting a running Clock returns nil so a second Start cannot double the tick chain.

func (*Clock) Stop

func (c *Clock) Stop()

Stop ends ticking; the pending tick, if any, is absorbed by Update.

func (*Clock) Update

func (c *Clock) Update(msg any) func(context.Context) any

Update advances the frame on this Clock's TickMsg and returns the next tick as a context-carrying Cmd (see Start). Any other Msg, or a tick after Stop, is a no-op.

type ColorKeyframe

type ColorKeyframe struct {
	At    float64
	Color RGB
	// Ease shapes the segment that ends at this keyframe; nil is Linear.
	Ease Ease
}

ColorKeyframe is a Color at position At in [0, 1].

type ColorKeyframes

type ColorKeyframes []ColorKeyframe

ColorKeyframes interpolates a colour across ordered keyframes, channel by channel in RGB.

func (ColorKeyframes) At

func (k ColorKeyframes) At(t float64) RGB

At returns the interpolated colour at t. Empty ColorKeyframes yield black.

type Ease

type Ease func(t float64) float64

Ease maps animation progress t in [0, 1] to eased progress. Every Ease in this package returns 0 at 0 and 1 at 1, clamps t outside [0, 1], and is monotonic non-decreasing except OutBack, which overshoots 1 on the way.

type Keyframe

type Keyframe struct {
	At    float64
	Value float64
	// Ease shapes the segment that ends at this keyframe; nil is Linear.
	Ease Ease
}

Keyframe is a Value at position At in [0, 1].

type Keyframes

type Keyframes []Keyframe

Keyframes interpolates a value across ordered keyframes. Positions outside the first and last keyframe hold their values.

func (Keyframes) At

func (k Keyframes) At(t float64) float64

At returns the interpolated value at t. Empty Keyframes yield 0.

type Preference

type Preference int

Preference reports whether the caller should prefer reduced motion (e.g. skip animation and render a static frame) or normal motion (animate as usual).

const (
	// Normal indicates animations should run as usual.
	Normal Preference = iota
	// Reduced indicates the caller should avoid animation: e.g. never call
	// an animated widget's Start(), or call streamtext.Model.Skip() for an
	// instant reveal instead of animating.
	Reduced
)

func Detect

func Detect() Preference

Detect returns Reduced if the NO_ANIMATION or REDUCE_MOTION environment variable is set to a non-empty value, and Normal otherwise.

func (Preference) Reduced

func (p Preference) Reduced() bool

Reduced reports whether p represents a reduced-motion preference.

type RGB

type RGB struct{ R, G, B uint8 }

RGB is a 24-bit colour.

type Source

type Source interface {
	Now() time.Time
	// NewTicker returns a channel that receives the time every d and a
	// func that stops it.
	NewTicker(d time.Duration) (<-chan time.Time, func())
}

Source is an injectable time source. tuitest.FakeClock and any tui.Clock that also reports Now implement it; the wall clock is used when a Clock has none.

type Spring

type Spring struct {
	Stiffness, Damping float64
	// Epsilon is how close to Target, in both distance and speed, counts as
	// settled. 0 means 0.001.
	Epsilon float64
	Motion  Preference

	Value, Velocity float64
	// contains filtered or unexported fields
}

Spring moves Value toward Target as a damped harmonic oscillator of unit mass: Stiffness is the spring constant, Damping the friction. Drive it from a tick: call Step with the time since the last tick and render Value. Step solves the motion in closed form, so the result does not depend on the step size and never blows up on a long frame.

Damping of 2*sqrt(Stiffness) is critical damping: the fastest approach that does not overshoot. Less oscillates around Target, more is sluggish. Changing Target mid-flight (SetTarget) keeps Value and Velocity, so the motion stays continuous.

A Spring honours reduced motion: with Motion set to Reduced it jumps to Target at once, with no intermediate frames. The zero Spring has no stiffness and also jumps to Target.

func NewSpring

func NewSpring(value, stiffness float64) *Spring

NewSpring returns a critically damped Spring at rest at value, with the given stiffness (larger is faster).

func (*Spring) Done

func (s *Spring) Done() bool

Done reports whether the Spring has settled on Target: within Epsilon in both distance and speed, or always under reduced motion.

func (*Spring) SetTarget

func (s *Spring) SetTarget(target float64)

SetTarget retargets the Spring. Value and Velocity are kept, so a change mid-flight continues from where the Spring is, at the speed it has. Under reduced motion it jumps to the new target instead.

func (*Spring) Step

func (s *Spring) Step(dt time.Duration) float64

Step advances the Spring by dt and returns the new Value. Once settled it snaps to Target exactly and stays there. A non-positive dt changes nothing. Under reduced motion, or without positive Stiffness, it jumps to Target.

func (*Spring) Target

func (s *Spring) Target() float64

Target reports the value the Spring is moving toward.

type TickMsg

type TickMsg struct {
	Clock *Clock
	// At is when the tick was scheduled to fire.
	At time.Time
	// contains filtered or unexported fields
}

TickMsg is the Msg a Clock sends once per interval.

type Timeline

type Timeline struct {
	// Motion is the reduced-motion preference. With Reduced, the first
	// Update applies every tween's final value and reports done. Tweens
	// carry their own Motion too.
	Motion Preference
	// contains filtered or unexported fields
}

Timeline sequences and overlaps Tweens (each with an apply func that receives its value) against a time read from a Clock (or a FakeClock in tests): it holds no timer, so it is deterministic.

tl.Then(a).Then(b)           // b starts when a ends
tl.Then(a).With(b)           // b starts when a starts

A Timeline is built, then Started and Updated from the Update loop; it is not safe for concurrent use.

func (*Timeline) At

func (tl *Timeline) At(offset time.Duration, tw Tween, apply func(float64)) *Timeline

At adds tw (and its apply) to start offset after the Timeline starts.

func (*Timeline) Duration

func (tl *Timeline) Duration() time.Duration

Duration is the Timeline's total length.

func (*Timeline) Start

func (tl *Timeline) Start(now time.Time)

Start begins (or restarts) the Timeline at now.

func (*Timeline) Then

func (tl *Timeline) Then(tw Tween, apply func(float64)) *Timeline

Then adds tw, whose value goes to apply, to start when everything added so far has ended.

func (*Timeline) Update

func (tl *Timeline) Update(now time.Time) bool

Update applies every tween that is active at now, in the order added, and reports whether the Timeline is complete. A tween that has begun applies its From value on the Update that reaches its start. An Update before Start, or after completion, does nothing.

func (*Timeline) With

func (tl *Timeline) With(tw Tween, apply func(float64)) *Timeline

With adds tw (and its apply) to start together with the tween added last.

type Transition

type Transition struct {
	// contains filtered or unexported fields
}

Transition is an interruptible move to a target: a critically damped Spring sized by duration rather than stiffness. Retarget it with To at any time; it carries on from the current Value and Velocity, so an interrupted transition never jumps or stops dead. Under reduced motion To jumps to the target. The zero Transition is not usable; build one with NewTransition.

func NewTransition

func NewTransition(value float64, duration time.Duration, motion Preference) *Transition

NewTransition returns a Transition at rest at value that settles on a target in about duration. A non-positive duration jumps at once. motion is the reduced-motion preference.

func (*Transition) Done

func (tr *Transition) Done() bool

Done reports whether the Transition has settled on its target.

func (*Transition) Step

func (tr *Transition) Step(dt time.Duration) float64

Step advances the Transition by dt and returns the new Value.

func (*Transition) Target

func (tr *Transition) Target() float64

Target reports the value the Transition is moving toward.

func (*Transition) To

func (tr *Transition) To(target float64)

To retargets the Transition from its current Value and Velocity.

func (*Transition) Value

func (tr *Transition) Value() float64

Value reports the current value.

type Tween

type Tween struct {
	From, To float64
	Duration time.Duration
	Ease     Ease
	Motion   Preference
}

Tween interpolates a value from From to To over Duration, shaped by Ease. Drive it from a motion.Clock: keep the start time, and on each tick call At(time.Since(start)). The zero Ease is Linear.

A Tween honours reduced motion: with Motion set to Reduced (for example motion.Detect() or the Program's ReducedMotion) it skips the animation and always reports To, so the app shows the end state at once.

Example

A Tween turns elapsed time into a value. Drive it from a tui.Tick: keep the start time in the model and call At(time.Since(start)) on each tick. Under reduced motion it reports the end value straight away.

package main

import (
	"fmt"
	"time"

	"github.com/ows4444/tui/motion"
)

func main() {
	slide := motion.Tween{From: 0, To: 20, Duration: 200 * time.Millisecond, Ease: motion.OutQuad}
	for _, ms := range []int{0, 50, 100, 200} {
		fmt.Printf("%3dms: %5.2f\n", ms, slide.At(time.Duration(ms)*time.Millisecond))
	}

	slide.Motion = motion.Reduced
	fmt.Printf("reduced at 0ms: %.0f\n", slide.At(0))
}
Output:
0ms:  0.00
 50ms:  8.75
100ms: 15.00
200ms: 20.00
reduced at 0ms: 20

func (Tween) At

func (tw Tween) At(elapsed time.Duration) float64

At returns the value elapsed after the start: From at or before 0, To at or after Duration (or when Duration is not positive, or under reduced motion), and From to To shaped by Ease in between.

func (Tween) Done

func (tw Tween) Done(elapsed time.Duration) bool

Done reports whether the tween has reached To: always under reduced motion, otherwise once elapsed reaches Duration.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL