diff --git a/messages_devtools.go b/messages_devtools.go index ca629ba..9e2a822 100644 --- a/messages_devtools.go +++ b/messages_devtools.go @@ -47,6 +47,13 @@ func init() { "resource_gauge.high": {"High", "偏高"}, "resource_gauge.critical": {"Critical", "危险"}, + "refresh_interval_selector.off": {"Auto refresh off", "不自动刷新"}, + "refresh_interval_selector.refresh": {"Refresh", "刷新"}, + "refresh_interval_selector.refreshing": {"Refreshing", "正在刷新"}, + "refresh_interval_selector.seconds": {"Every %d s", "每 %d 秒"}, + "refresh_interval_selector.minutes": {"Every %d min", "每 %d 分钟"}, + "refresh_interval_selector.label": {"Refresh interval", "刷新间隔"}, + "alert_list.empty": {"No alerts", "没有告警"}, }) } diff --git a/refresh_interval_selector.go b/refresh_interval_selector.go new file mode 100644 index 0000000..f9a39e3 --- /dev/null +++ b/refresh_interval_selector.go @@ -0,0 +1,90 @@ +package mujica + +import ( + "fmt" + "log/slog" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" +) + +// RefreshIntervalSelectorOptions configure a RefreshIntervalSelector. +type RefreshIntervalSelectorOptions struct { + // Intervals to offer besides Off; nil uses 5 s, 10 s, 30 s, 1 m and 5 m. + Intervals []time.Duration + // Refreshing shows the refresh in progress and holds the timer. + Refreshing bool + Disabled bool +} + +// RefreshResult reports this frame's events. +type RefreshResult struct { + Element *ui.Element + // Refresh asks the caller to reload, by hand or because the interval elapsed. + Refresh bool + // Changed reports a new interval. + Changed bool +} + +type refreshState struct{ last time.Time } + +var refreshDefaults = []time.Duration{5 * time.Second, 10 * time.Second, 30 * time.Second, time.Minute, 5 * time.Minute} + +// RefreshIntervalSelector picks an auto-refresh interval, zero for off, and refreshes by hand. +func RefreshIntervalSelector(c *ui.Context, interval *time.Duration, opts RefreshIntervalSelectorOptions) RefreshResult { + if interval == nil { + panic("mujica: RefreshIntervalSelector needs an interval") + } + choices := opts.Intervals + if choices == nil { + choices = refreshDefaults + } + options := []SelectOption[time.Duration]{{Value: 0, Label: msg(c, "refresh_interval_selector.off")}} + known := *interval == 0 + for _, d := range choices { + if d < time.Second { + panic("mujica: RefreshIntervalSelector intervals must be at least a second") + } + known = known || d == *interval + label := fmt.Sprintf(msg(c, "refresh_interval_selector.seconds"), int(d/time.Second)) + if d%time.Minute == 0 { + label = fmt.Sprintf(msg(c, "refresh_interval_selector.minutes"), int(d/time.Minute)) + } + options = append(options, SelectOption[time.Duration]{Value: d, Label: label}) + } + if !known { + panic("mujica: RefreshIntervalSelector interval is not offered") + } + u := Density(c).Unit() + var r RefreshResult + r.Element = ui.Row(c).Shrink(0).Gap(u * 2).AlignItems(ui.Center).Disabled(opts.Disabled) + st := ui.Local(r.Element, "mujica.refresh", func() refreshState { return refreshState{last: c.Now()} }) + locked := r.Element.IsDisabled() + r.Element.Children(func() { + label := msg(c, "refresh_interval_selector.refresh") + if opts.Refreshing { + Spinner(c, SpinnerOptions{Size: 14, Label: msg(c, "refresh_interval_selector.refreshing")}) + } + if Button(c, label, ButtonOptions{Variant: Secondary, Icon: icons.Must("refresh-cw"), Disabled: locked || opts.Refreshing}).Clicked() && !locked { + r.Refresh = true + } + r.Changed = Select(c, interval, options, SelectOptions{Label: msg(c, "refresh_interval_selector.label"), Disabled: locked}).Changed() + }) + if r.Changed || opts.Refreshing { + st.last = c.Now() + } + if *interval > 0 && !locked && !opts.Refreshing && !r.Refresh { + if wait := *interval - c.Now().Sub(st.last); wait > 0 { + c.After(wait) + } else { + r.Refresh = true + } + } + if r.Refresh { + st.last = c.Now() + slog.Debug("mujica: refresh_interval_selector refresh", "interval", *interval) + } + return r +} diff --git a/refresh_interval_selector_test.go b/refresh_interval_selector_test.go new file mode 100644 index 0000000..c456eb6 --- /dev/null +++ b/refresh_interval_selector_test.go @@ -0,0 +1,86 @@ +package mujica + +import ( + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +func refreshTester(interval *time.Duration, opts *RefreshIntervalSelectorOptions, refreshes, changes *int) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + r := RefreshIntervalSelector(c, interval, *opts) + if r.Refresh { + *refreshes++ + } + if r.Changed { + *changes++ + } + }, 500, 300) +} + +func TestRefreshIntervalSelectorManualAndAuto(t *testing.T) { + interval := time.Duration(0) + opts := RefreshIntervalSelectorOptions{Intervals: []time.Duration{time.Second, time.Minute}} + refreshes, changes := 0, 0 + tt := refreshTester(&interval, &opts, &refreshes, &changes) + _ = tt.Click("Refresh") + if refreshes != 1 { + t.Fatalf("manual refreshes %d", refreshes) + } + time.Sleep(1100 * time.Millisecond) + tt.Frame() + if refreshes != 1 { + t.Fatal("refreshed while off") + } + _ = tt.Click("Refresh interval") + _ = tt.Click("Every 1 s") + if interval != time.Second || changes != 1 { + t.Fatalf("interval %v changes %d", interval, changes) + } + start := time.Now() + if !overlayUntil(tt, func() bool { return refreshes == 2 }) { + t.Fatal("no automatic refresh") + } + if time.Since(start) < 900*time.Millisecond { + t.Fatal("refreshed before the interval") + } +} + +func TestRefreshIntervalSelectorRefreshingAndDisabled(t *testing.T) { + interval := time.Second + opts := RefreshIntervalSelectorOptions{Intervals: []time.Duration{time.Second}, Refreshing: true} + refreshes, changes := 0, 0 + tt := refreshTester(&interval, &opts, &refreshes, &changes) + _ = tt.Click("Refresh") + time.Sleep(1100 * time.Millisecond) + tt.Frame() + if refreshes != 0 || !tt.HasText("Refresh") { + t.Fatalf("refreshing: %d", refreshes) + } + opts = RefreshIntervalSelectorOptions{Intervals: []time.Duration{time.Second}, Disabled: true} + tt.Frame() + time.Sleep(1100 * time.Millisecond) + tt.Frame() + _ = tt.Click("Refresh") + if refreshes != 0 { + t.Fatalf("disabled refreshed %d", refreshes) + } + defer func() { + if recover() == nil { + t.Fatal("unoffered interval did not panic") + } + }() + bad := 7 * time.Second + refreshTester(&bad, &RefreshIntervalSelectorOptions{}, &refreshes, &changes) +} + +func TestRefreshIntervalSelectorSnapshot(t *testing.T) { + snapshot(t, "refresh_interval_selector", 420, 80, func(c *ui.Context) { + interval := 30 * time.Second + ui.Column(c).Padding(16).Children(func() { + RefreshIntervalSelector(c, &interval, RefreshIntervalSelectorOptions{}) + }) + }) +} diff --git a/testdata/snapshots/darwin/refresh_interval_selector-dark.png b/testdata/snapshots/darwin/refresh_interval_selector-dark.png new file mode 100644 index 0000000..8d76da9 Binary files /dev/null and b/testdata/snapshots/darwin/refresh_interval_selector-dark.png differ diff --git a/testdata/snapshots/darwin/refresh_interval_selector-light.png b/testdata/snapshots/darwin/refresh_interval_selector-light.png new file mode 100644 index 0000000..586fba2 Binary files /dev/null and b/testdata/snapshots/darwin/refresh_interval_selector-light.png differ