From 9903aff52b7cb0f58c40cf8bb58cc31427c30c5c 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:51:32 -0400 Subject: [PATCH] wip-devtools --- messages_devtools.go | 7 ++ refresh_interval_selector.go | 90 ++++++++++++++++++ refresh_interval_selector_test.go | 86 +++++++++++++++++ .../darwin/refresh_interval_selector-dark.png | Bin 0 -> 3892 bytes .../refresh_interval_selector-light.png | Bin 0 -> 3779 bytes 5 files changed, 183 insertions(+) create mode 100644 refresh_interval_selector.go create mode 100644 refresh_interval_selector_test.go create mode 100644 testdata/snapshots/darwin/refresh_interval_selector-dark.png create mode 100644 testdata/snapshots/darwin/refresh_interval_selector-light.png 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 0000000000000000000000000000000000000000..8d76da93afb44e48989444c0d6ea516786c4fcc1 GIT binary patch literal 3892 zcmc(ic{mi_8poAlBC^b&?8=_8CbDG=>QnED2He=t06k}f_zcE84`*u0#ep<(`GprdIXgeT)eRWsbqdftU%J}F(U7>#}MYEb>_OIOL7SU9(B zo+ELo>Pbn_Xssu`ILLCjoNafnvE6jn){3=RUoS56j8>VYuB~`Ms zgmM1ylaI?yJ*$73*7xm-D8l!wt|ie+qDwyuK<9{LS8RRFR zTS|(DuXZ1mmi7VOq^H*$L}fgC<`|WFNt<5~*x95V#`4b+U_{Bq<|S)Wb_38PTU%Rx zjbIFm+{xIN$2m@>TWEqkdI`%#^N&JJSH z9y17Ji~FOsX?pslIPv%XyHVWKtFWt)JZG2WAeyJ5%Y%o~@c{n$?TZ!`(r3OBq|Khi zJGJl4)(cq!LoC}j(F)4e^9;oC+vM-)jiH>-sJCUVJ~gfCe1n5h2E8R=>aUOyu?SaV zi&y1);_XA5dN|>ih_1|G3CS`e&dM8=bcQcP(BY#!FJM+$o_!JUpEW4G;9!uJ>(S1; z^T&G-2t!0ngWbAUN@xA``PdMu;B&-2!E~d&5KqX9Yg1UihTyZ6QYinoA(8BxD?ZIQ zHT^%|Gz3ziBJS?SCk~ATgg4V7+ijEz$;}jLk=VF6-d)Cq5Gnjd5=%wEBoqi|#~A_X z;gz=IyP-uh`hwe-_Pmg?&T%Rec@Bo5HO>#~U3{D9c&*vz5*IqZuah!(2N}RFz^M6O}HY& zp}8!fu#gU`(-)3!K6@SE=GKTDHWF#19Mng)GN%v>p)K`iKQ>dyQsIdOF3zmFZ_@Nh z1>2%E{L3Lhhk$picYIvyRgJtdze!JDf6kJvK|r3gg+n2*TiGf_M~kaFiRF!$rVo4E z8`ulmy4zMNm87MmlYGx7ptJtoUPh*b_;}b|z=Kw{rmv6R*KFO7N-bQ5jrECweAR|Ol@D>mk?79_ zi{+#n9baoj68T-4eZ=E*PD8pCawaB0APeJH>VZdY!bY$$)NbKG%mgt0pi!Vrm;>SR zywc=`7@{x{DI&Q!G`;uMK3LT@hUoy%@4tGKH*eZQ#F5BLtG9)~xmWI2wf|~}`0=y% zNx@@K_-Uk+l+{&g`#2s#ata$2w(h;(OT~%Zk3#5w+D@i@+JUOO3U<4j091^&5Ch093gSk)Fz$^d-k+Mq{ z&bx@(zTp%M4$jV-7jtuyfd8&wU@+VmPlse?%V_Qn;;vBze85Y5MsaQ#a=~y@`{3sHw(?#Ps>^ zjlLYX2-^oI5n5IQyRhC~5v2F`aRUY1xx8eXROD_oLDF*M{dKUsJvy8@)w2IVA~bUYpgt(?xC8OwCvQ?rh{mv`>6c(>|sS z7jEB1d!0?gBazY5j+^Jh2D?T&fPy>oy0Y;1(;|SmSEV&%!lk@2~hn3 zHDq+@2+CYpvGS)(uBqc9@+K#nQp-9%-oj!hsLE=(BTsE3^B+e~^)LiZnd^>g zo)s)w171#~k(&7B;ET)wV?Y!HHss5jb5-62mXcuX<+-RHva=H*eq{i9l}LPdjT=Yy ztk~LObh;m>WAO$qHs2{!`sg|d0|+>vdxQhI2d;gui+E@2>uU(};?vUCeqdy0hYwZ> zm_66FUaLC&d133$OFvvTsqIx`mH&2|M>!`w{<6<3+BK_r)}svA|ZzTC=%5(L|(iPA;u#S&?(-G%-wvAv`Fzm6$uChsOBgJL4u+1)i7QH8doGLW?xsj)y~(MhvDq`FG2no`G4~-HnGSD=D5nc1!-p9-PZPT>&)LYe~mb)9bjsbq&nh zT0OWJFgW{r*|>L8?L#;Uh}9`A4>3>*85Yp>cP>j&q*ZG+u5=k!{@C{V^?`@x_@$i4 znkzvG2Vt56 zXF5L@Y%2R7@DBG<)x!ING&ZZ(`|KD8Rds$x6|QQ~pdxJ*w+@LodaHOuZy!m!19sq2 z%&jyx8>gw}-S|73M4|(pHxR&5k}EOUA+LX7Ow7OOqkYl?>EonH#W1D# t3xnsfJExXt{?7>Zzb8Ry1i1hJ0FVAX zZ8HGiL;-7W0zAozv$n5h0RY}4eeJszPjYFw;r1v`W=}4`MQ3p7pj5};5pNY3@{g`M`Sbz;NL1o|sY&s5HyqDLQy3!-GDWbk&wixeIt94~Tj@|-}Djyp+Q&Uq(DJYuvq){1+7x8YL3i~m9(>7~ii zNANhDx(4Rwvc3THp6TQC`u&sq{O(8-TU!Ze@7&x;>r?;e-?+%xxVpOf?hZUKu#7+; z6un}HURycpF6n(){j z%UdAXnDF&^;t>3TcIwri7Sfr?YkVbmJU)M^J|?E=d+Y4Nh0x47UlGM+@sW=A)lnbTworzYxAzOn56mH#AQAPWtFp2#-ARh;>uZOC zOO_Vu%<8?qov-q{_feJpf8H}QQ&5F#$$}s6$o~rD>voGZ|45%Wu%j(NBs5r>alj*p zQi@19!ICjfi#9&-prfM$2ID2|5M@1z7Y!#Bbr8?{`uf&4hP8o1(>aA52-39XlkX-D z4*0sdWvzJLwdG~0YrS$0HmDmLxoPcOoVplH zczKgEGG08^5?7D3$9+(nKuoa9kPTc74Gq6` zZ*!A=@6TCMgqV(&A!RMmj!X5siTXPbK?Y=8Zq_>N8~Fl=`5Z^PeYDftc4uU2>Vdhr zFxc&(w=jPQ)pOq*Y2xYW34=M(j3wclLr|cOwgQS9v{0Ak?biZO*6~l-NKlRub!}A} zJNi+W9jije(u}Oe*OA876&<{{pDKu@yj2f3Q!=z(8`?DR^vo6z$Os*?9(30+sWD(n2kgzRonBl>7tmtHyV99G)hA zd+I|jDJ?xGj6ilbB)9hJVFhkM(C0_ca>=!|wHdph5V+Sairb&_V`0;gr_tV6p&_VI zHZ1#=9<%*@6<93A{a?vnJH;<|Y^zc~s{?A2`WLg}fcetyI ziho~AB0bUR*0-`+ZT8=*Sz1aj==y^<9v#*A1?u%CF|iVf{FQwxF*7ss@y@z}!nl2j z(}2VB#Kd!S#m6LCElb{ix1F zx4vQq*Eci)os~yIU{Y#=*3#$a=P9bVuUq8}?6kd2DwR+hSW<=)>zf+?EG8i#Ei)wC zB#PultuAde>l6lSzGSuD!om<7Qrr5vN0zvkSJ~(83d`%0lNRP?vJ#TTtTYZC@Z_P- zxGbEtd)fH%ih-Dz*t4^L&dtu|YM__vR|ar6pDPp@e-!s=z2`m=&djK9{N$0|CheH> zlyz`m`(%Ir(d*`}b}WJA6^e$-$%6$8}Fz-8?+r zm#Hc${1D9w zZEe}1y&<#x{C?gd8q$%+M-^-QQBqRIpJX&Iek8Uwgl_51I4$}!>Ku_0u-tdW#h1^u zPzDE)>Zw0qX_eL0Dj^Ex<>lo5G6uoa$jIWZix^tqEe5eNTb)$Ul@a8D2FWhHbrjNa z9Cn{g@09N;cWibv2~fV_Ls9QksqnuTb!2)P<-@aJZv=&^?>~!SrihLVY3AqWZwPt; zv8>r(Zhl@^=+a%M{Zv6ku9+?K1<-McMOpD$5$dvrxp`DAk@(wxuVr*JK^!)qB9xLg z) zii)fZ49dI_m?LkmgD64N+?7+UXfd|t1;i1@&04lg*?Or-0=2OCR?V6KtQ7sHm7boS zyLec5iouqK%#)q`?WqseUwZ~zIIi%RS?JX^V@KM0x8Gl$4RN|}ZEEUBgb8G^lgMuz z24+5?(Ct}|5+=^a8Tq3IkAE}uVFpu^<2D3mP5RC`3@j@vFTqgbPZ^n5xx-1|3u&W= zE`kFdf@kPyIRwJ@V!r`<2aML2J)!@H3wGhq?d{Eo#3$x;#mI1ZgO!NvLV2 mmGFO^hyE2T)8(QaD+BiS%<}J@ReQsNQ-Hn>OuHQF9P>Y$h(y}} literal 0 HcmV?d00001