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):
- 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.
- 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 ¶
- Constants
- func After[T any](d time.Duration, fn func(time.Time) T) func(context.Context) T
- func AfterOn[T any](c *Clock, d time.Duration, fn func(time.Time) T) func(context.Context) T
- func FrameInterval(fps int) time.Duration
- func InCubic(t float64) float64
- func InOutCubic(t float64) float64
- func InOutQuad(t float64) float64
- func InQuad(t float64) float64
- func Linear(t float64) float64
- func OutBack(t float64) float64
- func OutCubic(t float64) float64
- func OutQuad(t float64) float64
- type Clock
- type ColorKeyframe
- type ColorKeyframes
- type Ease
- type Keyframe
- type Keyframes
- type Preference
- type RGB
- type Source
- type Spring
- type TickMsg
- type Timeline
- func (tl *Timeline) At(offset time.Duration, tw Tween, apply func(float64)) *Timeline
- func (tl *Timeline) Duration() time.Duration
- func (tl *Timeline) Start(now time.Time)
- func (tl *Timeline) Then(tw Tween, apply func(float64)) *Timeline
- func (tl *Timeline) Update(now time.Time) bool
- func (tl *Timeline) With(tw Tween, apply func(float64)) *Timeline
- type Transition
- type Tween
Examples ¶
Constants ¶
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 ¶
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 ¶
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 ¶
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 InOutCubic ¶
InOutCubic is InOutQuad with steeper acceleration.
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 NewFrameClock ¶
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 ¶
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 ¶
Frame reports how many ticks the Clock has delivered. A nil Clock is at frame 0.
func (*Clock) Now ¶
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) Start ¶
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.
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 ¶
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.
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 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 ¶
NewSpring returns a critically damped Spring at rest at value, with the given stiffness (larger is faster).
func (*Spring) Done ¶
Done reports whether the Spring has settled on Target: within Epsilon in both distance and speed, or always under reduced motion.
func (*Spring) SetTarget ¶
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.
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) Then ¶
Then adds tw, whose value goes to apply, to start when everything added so far has ended.
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.
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