209 lines
6.9 KiB
Go
209 lines
6.9 KiB
Go
package mujica
|
||
|
||
import (
|
||
"fmt"
|
||
"log/slog"
|
||
"math"
|
||
|
||
"github.com/egoist/mygo/ui"
|
||
|
||
"mujicaui/internal"
|
||
"mujicaui/theme"
|
||
)
|
||
|
||
// ChartBrushOptions configure a ChartBrush.
|
||
type ChartBrushOptions struct {
|
||
// Label names the brush for assistive technology; required.
|
||
Label string
|
||
// Min and Max span the track; Min < Max.
|
||
Min, Max float64
|
||
// Step is the grid both ends snap to; 0 means a hundredth of the span.
|
||
Step float64
|
||
// Values draws a preview line across the track.
|
||
Values []float64
|
||
// Height is the track height; 0 means 48 DIPs.
|
||
Height float32
|
||
Format func(v float64) string
|
||
Disabled bool
|
||
}
|
||
|
||
type brushDrag struct {
|
||
part int
|
||
grab float64
|
||
start float64
|
||
}
|
||
|
||
// ChartBrush selects the range *value on a track: drag the window or its ends, or use the keys.
|
||
func ChartBrush(c *ui.Context, value *[2]float64, opts ChartBrushOptions) ChoiceResult {
|
||
if opts.Label == "" || !(opts.Max > opts.Min) || opts.Step < 0 || opts.Height < 0 {
|
||
panic("mujica: ChartBrush needs a Label, Min < Max, Step ≥ 0 and Height ≥ 0")
|
||
}
|
||
step := opts.Step
|
||
if step == 0 {
|
||
step = (opts.Max - opts.Min) / 100
|
||
}
|
||
spec := numberInputResolve(NumberInputOptions{Min: opts.Min, Max: opts.Max, Step: step})
|
||
if value[0] < opts.Min || value[1] > opts.Max || !(value[1] > value[0]) {
|
||
panic("mujica: ChartBrush value must be an ascending range inside Min..Max")
|
||
}
|
||
h := opts.Height
|
||
if h == 0 {
|
||
h = 48
|
||
}
|
||
k := Tokens(c)
|
||
u := Density(c).Unit()
|
||
val := func(v float64) string { return chartValue(c, v, opts.Format) }
|
||
snap := func(v float64) float64 {
|
||
return max(spec.lo, min(spec.hi, spec.round(spec.lo+math.Round((v-spec.lo)/spec.step)*spec.step)))
|
||
}
|
||
before := *value
|
||
var res ChoiceResult
|
||
res.Element = ui.Column(c).Gap(u).MinWidth(0).Disabled(opts.Disabled)
|
||
res.Element.Children(func() {
|
||
track := ui.Box(c).Height(h).FillWidth().MinWidth(0).Radius(theme.ControlRadius).Background(k.SurfaceHover)
|
||
b := track.Bounds()
|
||
internal.Remeasure(c, track, b.W, b.H)
|
||
at := func(v float64) float32 { return float32((v - opts.Min) / (opts.Max - opts.Min)) * b.W }
|
||
from := func(px float32) float64 { return opts.Min + float64(px/max(b.W, 1))*(opts.Max-opts.Min) }
|
||
drag := ui.Local(track, "mujica.brush.drag", func() brushDrag { return brushDrag{part: -1} })
|
||
set := func(lo, hi float64) {
|
||
lo, hi = snap(lo), snap(hi)
|
||
if hi-lo >= spec.step-1e-9 {
|
||
*value = [2]float64{lo, hi}
|
||
}
|
||
}
|
||
track.Draw(func(p *ui.Painter, r ui.Rect) { brushPaint(p, r, *value, opts, k) })
|
||
track.Children(func() {
|
||
x0, x1 := at(value[0]), at(value[1])
|
||
end := func(i int, x float32) *ui.Element {
|
||
e := ui.Box(c).Absolute().Left(x - theme.MinHitSize/2).Top(0).Size(theme.MinHitSize, h).Focusable().
|
||
Role(ui.RoleSlider).Label(fmt.Sprintf(msg(c, []string{"chart_brush.start", "chart_brush.end"}[i]), val(value[i])))
|
||
internal.Ring(e, k.Focus, theme.ControlRadius)
|
||
return e.Draw(func(p *ui.Painter, r ui.Rect) {
|
||
cx := internal.Snap(p, r.X+r.W/2)
|
||
p.Fill(ui.Rect{X: cx - 3, Y: r.Y + r.H/2 - 10, W: 6, H: 20}, k.Text, 2)
|
||
})
|
||
}
|
||
// Tab order runs start, window, end.
|
||
ends := [2]*ui.Element{end(0, x0)}
|
||
window := ui.Box(c).Absolute().Left(x0 + theme.MinHitSize/2).Top(0).Width(max(x1-x0-theme.MinHitSize, 1)).Height(h).Focusable().
|
||
Role(ui.RoleGroup).Label(fmt.Sprintf(msg(c, "chart_brush.window"), opts.Label, val(value[0]), val(value[1])))
|
||
internal.Ring(window, k.Focus, theme.ControlRadius)
|
||
ends[1] = end(1, x1)
|
||
if res.Element.IsDisabled() {
|
||
return
|
||
}
|
||
for i, end := range ends {
|
||
v, ok := brushKey(end, value[i], spec)
|
||
switch {
|
||
case !ok:
|
||
case i == 0:
|
||
set(min(v, value[1]-spec.step), value[1])
|
||
default:
|
||
set(value[0], max(v, value[0]+spec.step))
|
||
}
|
||
}
|
||
w := value[1] - value[0]
|
||
switch {
|
||
case window.Shortcut(0, ui.KeyLeft):
|
||
lo := max(spec.lo, spec.stepBy(value[0], -1))
|
||
set(lo, lo+w)
|
||
case window.Shortcut(0, ui.KeyRight):
|
||
hi := min(spec.hi, spec.stepBy(value[1], 1))
|
||
set(hi-w, hi)
|
||
case window.Shortcut(0, ui.KeyHome):
|
||
set(spec.lo, spec.lo+w)
|
||
case window.Shortcut(0, ui.KeyEnd):
|
||
set(spec.hi-w, spec.hi)
|
||
}
|
||
brushPointer(track, window, ends, drag, value, from, set)
|
||
})
|
||
ui.Text(c, val(value[0])+" – "+val(value[1])).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).Role(ui.RoleStatus)
|
||
})
|
||
res.changed = *value != before
|
||
if res.changed {
|
||
slog.Debug("mujica: chart_brush range", "from", value[0], "to", value[1])
|
||
}
|
||
return res
|
||
}
|
||
|
||
// brushKey steps an end for the arrow, Page, Home and End keys.
|
||
func brushKey(e *ui.Element, v float64, spec numberInputSpec) (float64, bool) {
|
||
switch {
|
||
case e.Shortcut(0, ui.KeyLeft), e.Shortcut(0, ui.KeyDown):
|
||
return spec.stepBy(v, -1), true
|
||
case e.Shortcut(0, ui.KeyRight), e.Shortcut(0, ui.KeyUp):
|
||
return spec.stepBy(v, 1), true
|
||
case e.Shortcut(0, ui.KeyPageDown):
|
||
return spec.stepBy(v, -10), true
|
||
case e.Shortcut(0, ui.KeyPageUp):
|
||
return spec.stepBy(v, 10), true
|
||
case e.Shortcut(0, ui.KeyHome):
|
||
return spec.lo, true
|
||
case e.Shortcut(0, ui.KeyEnd):
|
||
return spec.hi, true
|
||
}
|
||
return v, false
|
||
}
|
||
|
||
// brushPointer drags an end, the window, or a new range across the track.
|
||
func brushPointer(track, window *ui.Element, ends [2]*ui.Element, drag *brushDrag, value *[2]float64, from func(float32) float64, set func(lo, hi float64)) {
|
||
px, _, _ := track.PointerPosition()
|
||
at := from(px)
|
||
parts := []*ui.Element{ends[0], ends[1], window, track}
|
||
held := -1
|
||
for i, e := range parts {
|
||
if _, _, ok := e.Dragged(); ok && held < 0 {
|
||
held = i
|
||
}
|
||
}
|
||
if held < 0 {
|
||
drag.part = -1
|
||
return
|
||
}
|
||
if drag.part < 0 {
|
||
*drag = brushDrag{part: held, grab: at - value[0], start: at}
|
||
}
|
||
w := value[1] - value[0]
|
||
switch drag.part {
|
||
case 0:
|
||
set(min(at, value[1]), value[1])
|
||
case 1:
|
||
set(value[0], max(at, value[0]))
|
||
case 2:
|
||
set(at-drag.grab, at-drag.grab+w)
|
||
default:
|
||
set(min(drag.start, at), max(drag.start, at))
|
||
}
|
||
}
|
||
|
||
// brushPaint draws the preview line, dims outside the range and frames the window.
|
||
func brushPaint(p *ui.Painter, r ui.Rect, value [2]float64, opts ChartBrushOptions, k theme.Tokens) {
|
||
lo, hi := math.Inf(1), math.Inf(-1)
|
||
for _, v := range opts.Values {
|
||
if !math.IsNaN(v) {
|
||
lo, hi = min(lo, v), max(hi, v)
|
||
}
|
||
}
|
||
if n := len(opts.Values); n > 1 && hi > lo {
|
||
x := func(i int) float32 { return r.X + r.W*float32(i)/float32(n-1) }
|
||
chartRuns(opts.Values, x, func(idx []int) {
|
||
var line ui.Path
|
||
for j, i := range idx {
|
||
y := r.Y + 4 + (r.H-8)*float32(1-(opts.Values[i]-lo)/(hi-lo))
|
||
if j == 0 {
|
||
line.MoveTo(x(i), y)
|
||
} else {
|
||
line.LineTo(x(i), y)
|
||
}
|
||
}
|
||
p.StrokePath(&line, 1.25, k.TextMuted)
|
||
})
|
||
}
|
||
x0 := r.X + r.W*float32((value[0]-opts.Min)/(opts.Max-opts.Min))
|
||
x1 := r.X + r.W*float32((value[1]-opts.Min)/(opts.Max-opts.Min))
|
||
p.Fill(ui.Rect{X: r.X, Y: r.Y, W: x0 - r.X, H: r.H}, k.Background.Alpha(0.6), 0)
|
||
p.Fill(ui.Rect{X: x1, Y: r.Y, W: r.X + r.W - x1, H: r.H}, k.Background.Alpha(0.6), 0)
|
||
p.Stroke(ui.Rect{X: x0, Y: r.Y, W: x1 - x0, H: r.H}, k.AccentText, 0, 1.5)
|
||
}
|