From b0d9000431ebb7e70f3e7d44d4065f4e220121b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 5 Oct 2026 14:13:45 -0400 Subject: [PATCH] WIP essentials 5 --- knob.go | 118 ++++++++++++++++++ knob_test.go | 98 +++++++++++++++ messages_essentials.go | 2 + testdata/snapshots/darwin/knob-dark.png | Bin 0 -> 10250 bytes testdata/snapshots/darwin/knob-light.png | Bin 0 -> 9974 bytes .../snapshots/darwin/vertical_slider-dark.png | Bin 0 -> 3107 bytes .../darwin/vertical_slider-light.png | Bin 0 -> 3058 bytes vertical_slider.go | 98 +++++++++++++++ vertical_slider_test.go | 83 ++++++++++++ 9 files changed, 399 insertions(+) create mode 100644 knob.go create mode 100644 knob_test.go create mode 100644 testdata/snapshots/darwin/knob-dark.png create mode 100644 testdata/snapshots/darwin/knob-light.png create mode 100644 testdata/snapshots/darwin/vertical_slider-dark.png create mode 100644 testdata/snapshots/darwin/vertical_slider-light.png create mode 100644 vertical_slider.go create mode 100644 vertical_slider_test.go diff --git a/knob.go b/knob.go new file mode 100644 index 0000000..b2d5cf7 --- /dev/null +++ b/knob.go @@ -0,0 +1,118 @@ +package mujica + +import ( + "cmp" + "fmt" + "log/slog" + "math" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// KnobOptions configure a Knob. Min and Max both zero mean 0 to 100. +type KnobOptions struct { + Min, Max float64 + // Step snaps the value; zero is continuous, moving 1% per arrow press. + Step float64 + // Ticks draws that many evenly spaced marks around the dial; zero draws none. + Ticks int + // Size is the dial diameter; zero is 56. + Size float32 + // Format writes the value under the dial; nil prints as many decimals as Min and Step need. + Format func(float64) string + Disabled bool + // Label names the knob and shows above it. + Label string +} + +// knobSweep is the dial's travel, centered on twelve o'clock, in turns. +const knobSweep = 0.75 + +// knobDragPixels is how far a vertical drag travels the whole range. +const knobDragPixels float32 = 160 + +// Knob sets *value by dragging up and down, the arrows, Home and End; the dial shows the value and ticks. +func Knob(c *ui.Context, value *float64, opts KnobOptions) ChoiceResult { + if value == nil { + panic("mujica: Knob needs a value") + } + if opts.Ticks < 0 || !(opts.Size >= 0) || math.IsInf(float64(opts.Size), 1) { + panic(fmt.Sprintf("mujica: Knob Ticks %d or Size %v invalid", opts.Ticks, opts.Size)) + } + g, _ := sliderSetup(opts.Min, opts.Max, opts.Step, nil, false, c) + format := sliderFormat(opts.Format, g) + size := cmp.Or(opts.Size, 56) + k := Tokens(c) + old := g.snap(*value) + *value = old + col := ui.Column(c).AlignItems(ui.Center).Gap(Density(c).Unit()).Disabled(opts.Disabled) + disabled := col.IsDisabled() + var s *ui.Element + col.Children(func() { + if opts.Label != "" { + ui.Text(c, opts.Label).TextColor(k.Text).FontSize(FontSize(c, theme.CaptionSize)) + } + var next float64 + var dragged bool + s, next, dragged = sliderControl(c, old, g, g.lo, g.hi) + want := ui.Local(s, "knob.want", func() float64 { return math.NaN() }) + if dragged { + if math.IsNaN(*want) { + *want = old + } + _, dy, _ := s.Dragged() + *want = max(g.lo, min(g.hi, *want-float64(dy)*(g.hi-g.lo)/float64(knobDragPixels))) + next = g.snap(*want) + } else { + *want = math.NaN() + } + if !disabled { + *value = next + } + s.Size(size, size).FocusRing(false).Cursor(ui.CursorResizeNS).Label(cmp.Or(opts.Label, msg(c, "knob.label"))) + hot := s.Hovered() || dragged + v := *value + s.Draw(func(p *ui.Painter, r ui.Rect) { knobPaint(p, r, k, g, v, opts.Ticks, hot, s.FocusVisible()) }) + ui.Text(c, format(*value)).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize)).FontFeatures("tnum") + }) + changed := *value != old + if changed { + slog.Debug("mujica: knob changed", "label", opts.Label, "value", *value) + } + return ChoiceResult{col, changed} +} + +// knobPaint draws the track arc, the value arc, ticks and the diamond pointer. +func knobPaint(p *ui.Painter, r ui.Rect, k theme.Tokens, g sliderGeom, v float64, ticks int, hot, focus bool) { + cx, cy := internal.Snap(p, r.X+r.W/2), internal.Snap(p, r.Y+r.H/2) + rad := r.W/2 - 6 + start := float32(-knobSweep / 2) + frac := float32((v - g.lo) / (g.hi - g.lo)) + p.StrokePath(progressArc(p, cx, cy, rad, start, start+knobSweep), 3, k.Border) + if frac > 0 { + p.StrokePath(progressArc(p, cx, cy, rad, start, start+knobSweep*frac), 3, k.AccentText) + } + for i := range ticks { + t := float64(start) + knobSweep*float64(i)/float64(max(ticks-1, 1)) + sin, cos := float32(math.Sin(2*math.Pi*t)), float32(math.Cos(2*math.Pi*t)) + p.Line(cx+sin*(rad+3), cy-cos*(rad+3), cx+sin*(rad+6), cy-cos*(rad+6), 1, k.TextMuted) + } + face := k.Surface + if hot { + face = k.SurfaceHover + } + var dial ui.Path + dial.Circle(cx, cy, rad-6) + p.FillPath(&dial, face) + p.StrokePath(&dial, 1, k.ControlBorder) + t := float64(start + knobSweep*frac) + px := internal.Snap(p, cx+float32(math.Sin(2*math.Pi*t))*(rad-12)) + py := internal.Snap(p, cy-float32(math.Cos(2*math.Pi*t))*(rad-12)) + p.FillPath(internal.Diamond(px, py, 7), k.AccentText) + if focus { + internal.FocusRing(p, r, r.W/2, k.Focus) + } +} diff --git a/knob_test.go b/knob_test.go new file mode 100644 index 0000000..79c3136 --- /dev/null +++ b/knob_test.go @@ -0,0 +1,98 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func knobTester(v *float64, opts *KnobOptions, n *int) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Row(c).Padding(40).Children(func() { + if Knob(c, v, *opts).Changed() { + *n++ + } + }) + }, 300, 300) +} + +func TestKnobKeysAndStepGrid(t *testing.T) { + v := 0.0 + opts := KnobOptions{Max: 1, Step: 0.1, Label: "Gain"} + n := 0 + tt := knobTester(&v, &opts, &n) + tt.Key(0, ui.KeyTab) + for range 3 { + tt.Key(0, ui.KeyUp) + } + if v != 0.3 || n != 3 || !tt.HasText("0.3") { + t.Fatalf("three Ups: %v (%d) %q", v, n, tt.Texts()) + } + tt.Key(0, ui.KeyLeft) + if v != 0.2 { + t.Fatalf("Left: %v", v) + } + tt.Key(0, ui.KeyEnd) + if v != 1 { + t.Fatalf("End: %v", v) + } + tt.Key(0, ui.KeyUp) + if v != 1 || n != 5 { + t.Fatalf("past the end: %v %d", v, n) + } + tt.Key(0, ui.KeyHome) + if v != 0 { + t.Fatalf("Home: %v", v) + } +} + +func TestKnobDragAndDisabled(t *testing.T) { + v := 50.0 + opts := KnobOptions{Step: 1, Label: "Volume"} + n := 0 + tt := knobTester(&v, &opts, &n) + // The dial's center: padding 40, caption and gap above it. + x, y := float32(68), float32(40+21+30) + tt.Press(x, y) + tt.Move(x, y-32) // a fifth of 160 DIPs: 20 of 100 + tt.Release(x, y-32) + if v != 70 { + t.Fatalf("drag up 32 DIPs: %v", v) + } + opts.Disabled = true + tt.Frame() + tt.Press(x, y) + tt.Move(x, y-32) + tt.Release(x, y-32) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyUp) + if v != 70 { + t.Fatalf("disabled knob moved: %v", v) + } +} + +func TestKnobRejects(t *testing.T) { + v := 0.0 + for name, o := range map[string]KnobOptions{"ticks": {Ticks: -1}, "size": {Size: -1}, "range": {Min: 5, Max: 1}} { + func() { + defer func() { + if recover() == nil { + t.Errorf("%s: no panic", name) + } + }() + ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); Knob(c, &v, o) }, 200, 200) + }() + } +} + +func TestKnobSnapshot(t *testing.T) { + a, b, d := 35.0, 0.8, 10.0 + snapshot(t, "knob", 320, 130, func(c *ui.Context) { + ui.Row(c).Padding(16).Gap(24).Children(func() { + Knob(c, &a, KnobOptions{Label: "Gain", Ticks: 11}) + Knob(c, &b, KnobOptions{Max: 1, Step: 0.1, Label: "Mix", Size: 72}) + Knob(c, &d, KnobOptions{Label: "Off", Disabled: true}) + }) + }) +} diff --git a/messages_essentials.go b/messages_essentials.go index fa7fc68..f4a4f68 100644 --- a/messages_essentials.go +++ b/messages_essentials.go @@ -16,6 +16,8 @@ func init() { "color_palette.custom": {"Custom color", "自定义颜色"}, + "knob.label": {"Knob", "旋钮"}, + "qr_code.label": {"QR code", "二维码"}, "qr_code.too_long": {"Text too long for a QR code", "文字过长,无法生成二维码"}, }) diff --git a/testdata/snapshots/darwin/knob-dark.png b/testdata/snapshots/darwin/knob-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..6ab48fb94a321520c2455fdc2dfd2a63603e3ce7 GIT binary patch literal 10250 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 literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/knob-light.png b/testdata/snapshots/darwin/knob-light.png new file mode 100644 index 0000000000000000000000000000000000000000..c8a69520300188a27099f2e6ffb3eaf17d3864fc GIT binary patch literal 9974 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 literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/vertical_slider-light.png b/testdata/snapshots/darwin/vertical_slider-light.png new file mode 100644 index 0000000000000000000000000000000000000000..98cab767886e069eeb74b8609c3e001ad3aa86cd GIT binary patch literal 3058 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 literal 0 HcmV?d00001 diff --git a/vertical_slider.go b/vertical_slider.go new file mode 100644 index 0000000..7e25cb9 --- /dev/null +++ b/vertical_slider.go @@ -0,0 +1,98 @@ +package mujica + +import ( + "cmp" + "log/slog" + "math" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// VerticalSliderOptions configure a VerticalSlider. Min and Max both zero mean 0 to 100. +type VerticalSliderOptions struct { + Min, Max float64 + // Step snaps the value; zero is continuous. + Step float64 + Marks []SliderMark + // Height is the track length; zero is 160. + Height float32 + // ShowValue shows the value beside the thumb while hovered, dragged or focused. + ShowValue bool + Format func(float64) string + Disabled bool + Label string +} + +// VerticalSlider is a Slider standing up: max at the top, Up and Right raise it. +func VerticalSlider(c *ui.Context, value *float64, opts VerticalSliderOptions) ChoiceResult { + if value == nil { + panic("mujica: VerticalSlider needs a value") + } + if !(opts.Height >= 0) || math.IsInf(float64(opts.Height), 1) { + panic("mujica: VerticalSlider Height must be finite and >= 0") + } + g, _ := sliderSetup(opts.Min, opts.Max, opts.Step, opts.Marks, false, c) + format := sliderFormat(opts.Format, g) + k := Tokens(c) + size := FontSize(c, theme.CaptionSize) + h := cmp.Or(opts.Height, 160) + old := g.snap(*value) + *value = old + var s *ui.Element + var next float64 + var held bool + wrap := ui.Column(c).Disabled(opts.Disabled) + wrap.Children(func() { s, next, held = sliderControl(c, old, g, g.lo, g.hi) }) + // The base maps the pointer across; here the height decides. + y := func(r ui.Rect, v float64) float32 { + return r.Y + r.H - sliderThumb/2 - (r.H-sliderThumb)*float32((v-g.lo)/(g.hi-g.lo)) + } + if held { + if _, py, _ := s.PointerPosition(); s.Bounds().H > sliderThumb { + f := 1 - float64((py-sliderThumb/2)/(s.Bounds().H-sliderThumb)) + next = g.snap(g.lo + max(0, min(1, f))*(g.hi-g.lo)) + } + } + w := Density(c).ControlHeight() + for _, m := range opts.Marks { + if m.Label != "" { + w += 40 + break + } + } + s.Size(w, h).PaddingY(sliderThumb / 2).FocusRing(false).Cursor(ui.CursorPointer) + if opts.Label != "" { + s.Label(opts.Label) + } + if !wrap.IsDisabled() { + *value = next + } + changed := *value != old + if changed { + slog.Debug("mujica: vertical_slider changed", "label", opts.Label, "value", *value) + } + hot := s.Hovered() || held + v := *value + s.Draw(func(p *ui.Painter, r ui.Rect) { + x := r.X + Density(c).ControlHeight()/2 + top, bot := y(r, g.hi), y(r, g.lo) + p.Fill(ui.Rect{X: x - sliderTrack/2, Y: top, W: sliderTrack, H: bot - top}, k.Border, 0) + p.Stroke(ui.Rect{X: x - sliderTrack/2, Y: top, W: sliderTrack, H: bot - top}, k.ControlBorder, 0, 1) + p.Fill(ui.Rect{X: x - sliderTrack/2, Y: y(r, v), W: sliderTrack, H: bot - y(r, v)}, k.AccentText, 0) + for _, m := range opts.Marks { + my := y(r, m.Value) + p.Fill(ui.Rect{X: x + sliderTrack/2 + 2, Y: my - 0.5, W: 5, H: 1}, k.TextMuted, 0) + if m.Label != "" { + _, th := p.MeasureText(1000, ui.Span{Text: m.Label, Size: size}) + p.Text(x+sliderTrack/2+10, my-th/2, m.Label, size, k.TextMuted) + } + } + sliderThumbPaint(p, k, x, y(r, v), hot, s.FocusVisible()) + if opts.ShowValue && (hot || s.Focused()) { + sliderBubblePaint(p, k, x, y(r, v), format(v), size) + } + }) + return ChoiceResult{wrap, changed} +} diff --git a/vertical_slider_test.go b/vertical_slider_test.go new file mode 100644 index 0000000..45d0347 --- /dev/null +++ b/vertical_slider_test.go @@ -0,0 +1,83 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func verticalTester(v *float64, opts *VerticalSliderOptions, n *int) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Row(c).Padding(20).Children(func() { + if VerticalSlider(c, v, *opts).Changed() { + *n++ + } + }) + }, 300, 300) +} + +func TestVerticalSliderKeysAndPointer(t *testing.T) { + v := 0.0 + opts := VerticalSliderOptions{Step: 10, Label: "Level", Height: 216} + n := 0 + tt := verticalTester(&v, &opts, &n) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyUp) + tt.Key(0, ui.KeyRight) + if v != 20 || n != 2 { + t.Fatalf("Up, Right: %v %d", v, n) + } + tt.Key(0, ui.KeyDown) + if v != 10 { + t.Fatalf("Down: %v", v) + } + tt.Key(0, ui.KeyEnd) + if v != 100 { + t.Fatalf("End: %v", v) + } + r, _ := tt.Find("Level") + // Track runs from y+8 (100) to y+208 (0): a quarter up is 25, snapped to 30 or 20. + tt.Press(r.X+r.W/2, r.Y+8+150) + tt.Release(r.X+r.W/2, r.Y+8+150) + if v != 30 && v != 20 { + t.Fatalf("press at a quarter height: %v", v) + } + tt.Press(r.X+r.W/2, r.Y+8+150) + tt.Move(r.X+r.W/2, r.Y) + tt.Release(r.X+r.W/2, r.Y) + if v != 100 { + t.Fatalf("drag to the top: %v", v) + } + opts.Disabled = true + tt.Frame() + tt.Press(r.X+r.W/2, r.Y+200) + tt.Release(r.X+r.W/2, r.Y+200) + if v != 100 { + t.Fatalf("disabled slider moved: %v", v) + } +} + +func TestVerticalSliderRejects(t *testing.T) { + v := 0.0 + for name, o := range map[string]VerticalSliderOptions{"height": {Height: -1}, "step": {Step: -1}} { + func() { + defer func() { + if recover() == nil { + t.Errorf("%s: no panic", name) + } + }() + ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); VerticalSlider(c, &v, o) }, 200, 200) + }() + } +} + +func TestVerticalSliderSnapshot(t *testing.T) { + a, b := 40.0, 70.0 + snapshot(t, "vertical_slider", 260, 220, func(c *ui.Context) { + ui.Row(c).Padding(16).Gap(24).Children(func() { + VerticalSlider(c, &a, VerticalSliderOptions{Marks: []SliderMark{{Value: 0, Label: "0"}, {Value: 50, Label: "50"}, {Value: 100, Label: "100"}}}) + VerticalSlider(c, &b, VerticalSliderOptions{Disabled: true}) + }) + }) +}