package mujica import ( "fmt" "math" "strconv" "github.com/egoist/mygo/ui" "mujicaui/internal" "mujicaui/theme" ) // ProgressShape is how a progress indicator draws. The zero value is ProgressLinear. type ProgressShape int const ( ProgressLinear ProgressShape = iota ProgressRing ) // ProgressOptions configure a Progress. type ProgressOptions struct { Shape ProgressShape // Indeterminate shows work of unknown length; the value is ignored. Indeterminate bool // Label names the indicator and shows beside it; empty reads "Loading". Label string ShowValue bool // Size is the ring diameter (48 DIP when zero) or the bar width (fills when zero). Size float32 } // Progress shows work done, value 0 to 1; indeterminate ones hold still under reduced motion. func Progress(c *ui.Context, value float64, opts ProgressOptions) *ui.Element { if opts.Shape != ProgressLinear && opts.Shape != ProgressRing { panic(fmt.Sprintf("mujica: unknown ProgressShape %d", int(opts.Shape))) } if !opts.Indeterminate && (math.IsNaN(value) || value < 0 || value > 1) { panic(fmt.Sprintf("mujica: Progress value %v outside 0..1", value)) } if !(opts.Size >= 0) || math.IsInf(float64(opts.Size), 1) { panic(fmt.Sprintf("mujica: Progress Size %v invalid", opts.Size)) } k := Tokens(c) u := Density(c).Unit() still := c.Preferences().ReduceMotion name := opts.Label if name == "" { name = msg(c, "loading") } acc := value if opts.Indeterminate { acc = -1 } caption := "" switch { case opts.Indeterminate && still: caption = msg(c, "progress.indeterminate") case !opts.Indeterminate && opts.ShowValue: caption = strconv.Itoa(int(math.Round(value*100))) + "%" } if opts.Shape == ProgressRing { return progressRing(c, k, u, acc, name, caption, opts, still) } col := ui.Column(c).Gap(u * 1.5) if opts.Size > 0 { col.Width(opts.Size) } else { col.FillWidth() } col.Children(func() { if opts.Label != "" || caption != "" { ui.Row(c).Gap(u * 2).Children(func() { ui.Text(c, opts.Label).TextColor(k.Text).Grow(1).Shrink(1) ui.Text(c, caption).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize)).FontFeatures("tnum").SingleLine() }) } bar := ui.Progress(c, acc).Label(name).FillWidth().Height(u * 1.5).Radius(theme.ControlRadius).Background(k.Border) shown := float32(value) if !opts.Indeterminate && !still { shown = bar.Animate("progress.value", shown, theme.StateDuration) } bar.Draw(func(p *ui.Painter, r ui.Rect) { switch { case !opts.Indeterminate: p.Fill(ui.Rect{X: r.X, Y: r.Y, W: r.W * shown, H: r.H}, k.AccentText, theme.ControlRadius) case still: p.Fill(r, k.AccentText, theme.ControlRadius) default: p.AnimationFrame() phase := float32(p.Now().UnixMilli()%1400) / 1400 w := r.W * 0.3 x := r.X - w + (r.W+w)*phase p.Clip(r, theme.ControlRadius, func() { p.Fill(ui.Rect{X: x, Y: r.Y, W: w, H: r.H}, k.AccentText, theme.ControlRadius) }) } }) }) return col } func progressRing(c *ui.Context, k theme.Tokens, u float32, acc float64, name, caption string, opts ProgressOptions, still bool) *ui.Element { d := opts.Size if d == 0 { d = 48 } row := ui.Row(c).AlignItems(ui.Center).Gap(u * 2) row.Children(func() { ring := ui.Progress(c, acc).Label(name).Size(d, d).Radius(0).Background(ui.Transparent) shown := float32(acc) if !opts.Indeterminate && !still { shown = ring.Animate("progress.value", shown, theme.StateDuration) } stroke := max(3, d/10) ring.Draw(func(p *ui.Painter, r ui.Rect) { cx, cy, rad := internal.Snap(p, r.X+r.W/2), internal.Snap(p, r.Y+r.H/2), internal.Snap(p, (r.W-stroke)/2) var track ui.Path track.Circle(cx, cy, rad) p.StrokePath(&track, stroke, k.Border) switch { case !opts.Indeterminate: if shown > 0 { p.StrokePath(progressArc(p, cx, cy, rad, 0, shown), stroke, k.AccentText) } case still: p.StrokePath(&track, stroke, k.AccentText) default: p.AnimationFrame() start := float32(p.Now().UnixMilli()%1200) / 1200 p.StrokePath(progressArc(p, cx, cy, rad, start, start+0.25), stroke, k.AccentText) } }) if caption != "" && d >= 44 && !opts.Indeterminate { ring.Children(func() { ui.Text(c, caption).FontSize(FontSize(c, theme.CaptionSize)).FontFeatures("tnum").TextColor(k.Text) }) ring.Center() } if opts.Label != "" || (caption != "" && (d < 44 || opts.Indeterminate)) { ui.Column(c).Children(func() { if opts.Label != "" { ui.Text(c, opts.Label).TextColor(k.Text) } if caption != "" && (d < 44 || opts.Indeterminate) { ui.Text(c, caption).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize)) } }) } }) return row } // progressArc is the arc from turn a to turn b, clockwise from twelve o'clock. func progressArc(pt *ui.Painter, cx, cy, r float32, a, b float32) *ui.Path { var p ui.Path n := max(2, int(math.Ceil(float64((b-a)*64)))) for i := 0; i <= n; i++ { t := float64(a + (b-a)*float32(i)/float32(n)) x := cx + r*float32(math.Sin(2*math.Pi*t)) y := cy - r*float32(math.Cos(2*math.Pi*t)) if i == 0 { p.MoveTo(internal.Snap(pt, x), internal.Snap(pt, y)) } else { p.LineTo(internal.Snap(pt, x), internal.Snap(pt, y)) } } return &p } // MeterOptions configure a Meter. type MeterOptions struct { // Min and Max bound the value; both zero mean 0 to 1. Min, Max float64 // Levels are the High and Critical thresholds; Critical below Warning flags low values. Levels *ui.MeterLevels Label string ShowValue bool // Width is the bar width; zero fills. Width float32 } // meterLevel is 0 normal, 1 warning, 2 critical. func meterLevel(v float64, l *ui.MeterLevels) int { if l == nil || l.Warning == l.Critical { return 0 } low := l.Critical < l.Warning switch { case !low && v >= l.Critical, low && v <= l.Critical: return 2 case !low && v >= l.Warning, low && v <= l.Warning: return 1 } return 0 } // Meter shows a level within a range with threshold ticks and a level word, not color alone. func Meter(c *ui.Context, value float64, opts MeterOptions) *ui.Element { lo, hi := opts.Min, opts.Max if lo == 0 && hi == 0 { hi = 1 } if !finite(lo) || !finite(hi) || !(hi > lo) { panic(fmt.Sprintf("mujica: Meter range %v..%v is empty", lo, hi)) } if math.IsNaN(value) || value < lo || value > hi { panic(fmt.Sprintf("mujica: Meter value %v outside %v..%v", value, lo, hi)) } if l := opts.Levels; l != nil && !(l.Warning >= lo && l.Warning <= hi && l.Critical >= lo && l.Critical <= hi) { panic(fmt.Sprintf("mujica: Meter levels %+v outside %v..%v", *l, lo, hi)) } k := Tokens(c) u := Density(c).Unit() level := meterLevel(value, opts.Levels) tone := []ui.Color{k.Success, k.Warning, k.Danger}[level] var word string if opts.Levels != nil { low := opts.Levels.Critical < opts.Levels.Warning word = msg(c, "meter.normal") switch { case level == 2: word = msg(c, "meter.critical") case level == 1 && low: word = msg(c, "meter.low_warning") case level == 1: word = msg(c, "meter.warning") } } frac := func(v float64) float32 { return float32((v - lo) / (hi - lo)) } col := ui.Column(c).Gap(u * 1.5) if opts.Width > 0 { col.Width(opts.Width) } else { col.FillWidth() } col.Children(func() { caption := word if opts.ShowValue { caption = strconv.FormatFloat(value, 'f', -1, 64) + " / " + strconv.FormatFloat(hi, 'f', -1, 64) if word != "" { caption += " ยท " + word } } if opts.Label != "" || caption != "" { ui.Row(c).Gap(u * 2).Children(func() { ui.Text(c, opts.Label).TextColor(k.Text).Grow(1).Shrink(1) ui.Text(c, caption).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize)).FontFeatures("tnum").SingleLine() }) } name := opts.Label if word != "" { name += " (" + word + ")" } bar := ui.Meter(c, value, lo, hi, opts.Levels).Label(name).FillWidth().Height(u * 2). Radius(theme.ControlRadius).Background(k.Border) bar.Draw(func(p *ui.Painter, r ui.Rect) { p.Fill(ui.Rect{X: r.X, Y: r.Y, W: r.W * frac(value), H: r.H}, tone, theme.ControlRadius) }) if l := opts.Levels; l != nil { // Ticks at the thresholds, over the fill. bar.DrawOver(func(p *ui.Painter, r ui.Rect) { for _, v := range []float64{l.Warning, l.Critical} { x := float32(math.Round(float64(r.X + r.W*frac(v)))) p.Fill(ui.Rect{X: x - 1, Y: r.Y, W: 2, H: r.H}, k.Text.Alpha(0.6), 0) } }) } }) return col } func finite(v float64) bool { return !math.IsNaN(v) && !math.IsInf(v, 0) }