diff --git a/chart_brush.go b/chart_brush.go index edaad4f..08070e0 100644 --- a/chart_brush.go +++ b/chart_brush.go @@ -75,20 +75,21 @@ func ChartBrush(c *ui.Context, value *[2]float64, opts ChartBrushOptions) Choice 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]) - window := ui.Box(c).Absolute().Left(x0).Top(0).Width(max(x1-x0, 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 := [2]*ui.Element{} - for i, x := range []float32{x0, x1} { - end := ui.Box(c).Absolute().Left(x - theme.MinHitSize/2).Top(0).Size(theme.MinHitSize, h).Focusable(). + 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(end, k.Focus, theme.ControlRadius) - end.Draw(func(p *ui.Painter, r ui.Rect) { + 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) }) - ends[i] = end } + // 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 } diff --git a/chart_brush_test.go b/chart_brush_test.go new file mode 100644 index 0000000..17c9490 --- /dev/null +++ b/chart_brush_test.go @@ -0,0 +1,123 @@ +package mujica + +import ( + "fmt" + "testing" + + "github.com/egoist/mygo/ui" +) + +type brushHarness struct { + value [2]float64 + changes int + disabled bool + el *ui.Element +} + +func (h *brushHarness) view(c *ui.Context) { + Use(c, Settings{Mode: Light}) + ui.Column(c).Padding(10).Width(420).Children(func() { + r := ChartBrush(c, &h.value, ChartBrushOptions{Label: "Months", Min: 0, Max: 100, Step: 1, Disabled: h.disabled}) + h.el = r.Element + if r.Changed() { + h.changes++ + } + }) +} + +func TestChartBrushKeyboard(t *testing.T) { + h := &brushHarness{value: [2]float64{20, 40}} + tt := ui.NewTester(h.view, 460, 200) + tabTo(t, tt, "Months: 20 to 40") + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyRight) + if h.value != [2]float64{22, 42} || h.changes != 2 { + t.Fatalf("window moved to %v (%d)", h.value, h.changes) + } + tt.Key(0, ui.KeyTab) + if !tt.Focused("Range end 42") { + t.Fatal("Tab did not reach the end handle") + } + tt.Key(0, ui.KeyPageUp) + tt.Key(0, ui.KeyEnd) + if h.value != [2]float64{22, 100} { + t.Fatalf("end handle: %v", h.value) + } + tt.Key(ui.Shift, ui.KeyTab) + tt.Key(ui.Shift, ui.KeyTab) + tt.Key(0, ui.KeyEnd) // the start cannot pass the end + if h.value != [2]float64{99, 100} || !tt.HasText("99 – 100") { + t.Fatalf("start to End: %v %q", h.value, tt.Texts()) + } +} + +func TestChartBrushDrag(t *testing.T) { + h := &brushHarness{value: [2]float64{20, 40}} + tt := ui.NewTester(h.view, 460, 200) + b := h.el.Bounds() + x := func(v float32) float32 { return b.X + 400*v/100 } + y := b.Y + 24 + tt.Press(x(30), y) // inside the window + tt.Move(x(40), y) + tt.Move(x(50), y) + tt.Release(x(50), y) + if h.value != [2]float64{40, 60} { + t.Fatalf("window drag: %v", h.value) + } + tt.Press(x(60), y) // the end handle + tt.Move(x(70), y) + tt.Move(x(75), y) + tt.Release(x(75), y) + if h.value != [2]float64{40, 75} { + t.Fatalf("end drag: %v", h.value) + } + tt.Press(x(85), y) // outside: a new range + tt.Move(x(90), y) + tt.Move(x(95), y) + tt.Release(x(95), y) + if h.value != [2]float64{85, 95} { + t.Fatalf("new range: %v", h.value) + } +} + +func TestChartBrushDisabledAndBad(t *testing.T) { + h := &brushHarness{value: [2]float64{20, 40}, disabled: true} + tt := ui.NewTester(h.view, 460, 200) + b := h.el.Bounds() + tt.Press(b.X+120, b.Y+24) + tt.Move(b.X+200, b.Y+24) + tt.Release(b.X+200, b.Y+24) + for range 3 { + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyRight) + } + if h.value != [2]float64{20, 40} || h.changes != 0 { + t.Fatalf("disabled brush moved: %v", h.value) + } + for _, v := range [][2]float64{{50, 40}, {-1, 10}} { + func() { + defer func() { + if recover() == nil { + t.Errorf("%v: no panic", v) + } + }() + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ChartBrush(c, &v, ChartBrushOptions{Label: "x", Max: 100}) + }, 300, 100) + }() + } +} + +func TestChartBrushSnapshot(t *testing.T) { + vals := make([]float64, 60) + for i := range vals { + vals[i] = float64((i*7)%13) + float64(i)/6 + } + snapshot(t, "chart_brush", 460, 110, func(c *ui.Context) { + v := [2]float64{18, 42} + ui.Column(c).Padding(16).Children(func() { + ChartBrush(c, &v, ChartBrushOptions{Label: "Months", Min: 0, Max: 59, Step: 1, Values: vals, Format: func(f float64) string { return fmt.Sprintf("week %.0f", f) }}) + }) + }) +} diff --git a/chart_empty_state_test.go b/chart_empty_state_test.go new file mode 100644 index 0000000..2a94b95 --- /dev/null +++ b/chart_empty_state_test.go @@ -0,0 +1,61 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestChartEmptyStateKeepsSizeAndRetries(t *testing.T) { + status := DataReady + retries, disabled := 0, false + var heights []float32 + var el *ui.Element + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Width(400).Disabled(disabled).Children(func() { + r := ChartEmptyState(c, status, ChartEmptyStateOptions{Height: 180, Empty: "No hymns this week", Error: "The choir did not answer"}) + el = r.Element + if r.Changed() { + retries++ + } + }) + }, 440, 300) + heights = append(heights, el.Bounds().H) + if !tt.HasText("No hymns this week") { + t.Fatalf("empty: %q", tt.Texts()) + } + status = DataLoading + tt.Frame() + heights = append(heights, el.Bounds().H) + if !tt.HasText("Loading") { + t.Fatalf("loading: %q", tt.Texts()) + } + status = DataFailed + tt.Frame() + heights = append(heights, el.Bounds().H) + if err := tt.Click("Retry"); err != nil || retries != 1 || !tt.HasText("The choir did not answer") { + t.Fatalf("retry: %v %d", err, retries) + } + if heights[0] != 180 || heights[1] != 180 || heights[2] != 180 { + t.Fatalf("heights %v", heights) + } + disabled = true + tt.Frame() + _ = tt.Click("Retry") + if retries != 1 { + t.Fatal("disabled retry fired") + } +} + +func TestChartEmptyStateSnapshot(t *testing.T) { + snapshot(t, "chart_empty_state", 640, 220, func(c *ui.Context) { + ui.Row(c).Padding(16).Gap(16).Children(func() { + for _, st := range []DataStatus{DataReady, DataLoading, DataFailed} { + ui.Column(c).Width(192).Border(1, Tokens(c).Border).Children(func() { + ChartEmptyState(c, st, ChartEmptyStateOptions{Height: 180}) + }) + } + }) + }) +} diff --git a/chart_export_test.go b/chart_export_test.go new file mode 100644 index 0000000..14eef3f --- /dev/null +++ b/chart_export_test.go @@ -0,0 +1,73 @@ +package mujica + +import ( + "bytes" + "image/color" + "image/png" + "testing" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +func TestChartExportPNGInTheme(t *testing.T) { + for _, mode := range []Mode{Light, Dark} { + var out []byte + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{Mode: mode}) + if out != nil { + return + } + b, err := ChartExport(c, func(c *ui.Context) { + BarChart(c, barCats, barSeries(), BarChartOptions{Frame: ChartFrame{Label: "Tithes", Height: 160}}) + }, ChartExportOptions{Width: 300, Height: 220, Scale: 1.5}) + if err != nil { + t.Fatal(err) + } + out = b + }, 100, 100) + img, err := png.Decode(bytes.NewReader(out)) + if err != nil { + t.Fatal(err) + } + if b := img.Bounds(); b.Dx() != 450 || b.Dy() != 330 { + t.Fatalf("%v: size %v", mode, b) + } + k := theme.Light() + if mode == Dark { + k = theme.Dark() + } + if got := color.RGBAModel.Convert(img.At(3, 3)).(color.RGBA); got != toRGBA(k.Surface) { + t.Fatalf("%v: corner %v, want the theme's Surface", mode, got) + } + } +} + +func TestChartExportBad(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic for a zero size") + } + }() + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + _, _ = ChartExport(c, func(*ui.Context) {}, ChartExportOptions{}) + }, 100, 100) +} + +func TestChartExportSnapshot(t *testing.T) { + snapshot(t, "chart_export", 340, 300, func(c *ui.Context) { + b, err := ChartExport(c, func(c *ui.Context) { + PieChart(c, pieSlices, PieChartOptions{Frame: ChartFrame{Label: "Stores", Height: 150}, Donut: true, MinAngle: 20}) + }, ChartExportOptions{Width: 300, Height: 260, Scale: 1}) + if err != nil { + t.Fatal(err) + } + bmp, err := ui.DecodeBitmap(b) + if err != nil { + t.Fatal(err) + } + ui.Column(c).Padding(20).Children(func() { ui.Image(c, bmp).Size(300, 260) }) + }) +} diff --git a/pie_chart.go b/pie_chart.go index dba6d1d..ae5b479 100644 --- a/pie_chart.go +++ b/pie_chart.go @@ -105,7 +105,10 @@ func PieChart(c *ui.Context, slices []ChartSlice, opts PieChartOptions) ChartRes mx, my := internal.Polar(cx, cy, (r0+r1)/2+float32(chartBool(r0 == 0))*r1*0.12, (a0+a1)/2) ink := chartInk(k, keys[i].Color) internal.Marker(p, i, mx, my-8, 9, ink) - chartText(p, share(i), mx, my+2, size, ink, 0.5, 0) + // The percent shows where the ring is wide enough to hold it. + if w, _ := p.MeasureText(0, ui.Span{Text: share(i), Size: size}); r1-r0 >= w+6 { + chartText(p, share(i), mx, my+2, size, ink, 0.5, 0) + } } } if opts.Donut { diff --git a/testdata/snapshots/darwin/chart_brush-dark.png b/testdata/snapshots/darwin/chart_brush-dark.png new file mode 100644 index 0000000..37a558e Binary files /dev/null and b/testdata/snapshots/darwin/chart_brush-dark.png differ diff --git a/testdata/snapshots/darwin/chart_brush-light.png b/testdata/snapshots/darwin/chart_brush-light.png new file mode 100644 index 0000000..b29cac6 Binary files /dev/null and b/testdata/snapshots/darwin/chart_brush-light.png differ diff --git a/testdata/snapshots/darwin/chart_empty_state-dark.png b/testdata/snapshots/darwin/chart_empty_state-dark.png new file mode 100644 index 0000000..719b5e1 Binary files /dev/null and b/testdata/snapshots/darwin/chart_empty_state-dark.png differ diff --git a/testdata/snapshots/darwin/chart_empty_state-light.png b/testdata/snapshots/darwin/chart_empty_state-light.png new file mode 100644 index 0000000..c8c2add Binary files /dev/null and b/testdata/snapshots/darwin/chart_empty_state-light.png differ diff --git a/testdata/snapshots/darwin/chart_export-dark.png b/testdata/snapshots/darwin/chart_export-dark.png new file mode 100644 index 0000000..6173296 Binary files /dev/null and b/testdata/snapshots/darwin/chart_export-dark.png differ diff --git a/testdata/snapshots/darwin/chart_export-light.png b/testdata/snapshots/darwin/chart_export-light.png new file mode 100644 index 0000000..1bb86bf Binary files /dev/null and b/testdata/snapshots/darwin/chart_export-light.png differ