diff --git a/aspect_ratio.go b/aspect_ratio.go index 643cb3d..139b940 100644 --- a/aspect_ratio.go +++ b/aspect_ratio.go @@ -1,6 +1,10 @@ package mujica -import "github.com/egoist/mygo/ui" +import ( + "github.com/egoist/mygo/ui" + + "mujicaui/internal" +) // AspectRatioOptions configure an AspectRatio. type AspectRatioOptions struct { @@ -22,6 +26,7 @@ func AspectRatio(c *ui.Context, opts AspectRatioOptions, child func()) *ui.Eleme } outer := ui.Box(c).Fill().Center() room := outer.Bounds() + internal.Remeasure(c, outer, room.W, room.H) return outer.Children(func() { frame := ui.Box(c).AspectRatio(r).Shrink(0).Center().Clip() was := frame.Bounds() @@ -31,12 +36,8 @@ func AspectRatio(c *ui.Context, opts AspectRatioOptions, child func()) *ui.Eleme w = min(w, room.H*r) } if w <= 0 { - // Not laid out yet: lay out once, then size from the room. + // Not laid out yet: Remeasure rebuilds once the room is known. frame.WidthPercent(100) - if first := ui.Local(outer, "aspect_ratio.first", func() bool { return true }); *first { - *first = false - c.Invalidate() - } } else { frame.Width(w) if w-was.W > 0.5 || was.W-w > 0.5 { diff --git a/aspect_ratio_test.go b/aspect_ratio_test.go index e2f485f..8224068 100644 --- a/aspect_ratio_test.go +++ b/aspect_ratio_test.go @@ -62,12 +62,10 @@ func TestAspectRatioFollowsWindowResize(t *testing.T) { t.Fatalf("short window: %vx%v, want 200x100", box.W, box.H) } tt.SetSize(400, 400) - tt.Frame() if box.W != 400 || box.H != 200 { t.Fatalf("tall window: %vx%v, want 400x200", box.W, box.H) } tt.SetSize(300, 400) - tt.Frame() if box.W != 300 || box.H != 150 { t.Fatalf("narrow window: %vx%v, want 300x150", box.W, box.H) } diff --git a/internal/layout.go b/internal/layout.go new file mode 100644 index 0000000..ea1ed3e --- /dev/null +++ b/internal/layout.go @@ -0,0 +1,14 @@ +package internal + +import "github.com/egoist/mygo/ui" + +// Remeasure rebuilds the view when layout gives e another size than the w×h it was built for. +func Remeasure(c *ui.Context, e *ui.Element, w, h float32) *ui.Element { + return e.DrawOver(func(_ *ui.Painter, r ui.Rect) { + if abs(r.W-w) > 0.5 || abs(r.H-h) > 0.5 { + c.Invalidate() + } + }) +} + +func abs(v float32) float32 { return max(v, -v) } diff --git a/split_pane.go b/split_pane.go index cc4dc3f..3851056 100644 --- a/split_pane.go +++ b/split_pane.go @@ -96,9 +96,11 @@ func SplitPane(c *ui.Context, state *SplitPaneState, opts SplitPaneOptions, firs func splitPaneBuild(c *ui.Context, in *ui.Element, state *SplitPaneState, opts SplitPaneOptions, lo, minSecond float32, res *SplitPaneResult, first, second func()) { k := Tokens(c) u := Density(c).Unit() - total := in.Bounds().W + was := in.Bounds() + internal.Remeasure(c, in, was.W, was.H) + total := was.W if opts.Vertical { - total = in.Bounds().H + total = was.H } hi := max(lo, total-theme.BorderWidth-minSecond) clamp := func(v float32) float32 { return max(lo, min(v, hi)) } @@ -106,10 +108,6 @@ func splitPaneBuild(c *ui.Context, in *ui.Element, state *SplitPaneState, opts S state.Size = clamp((total - theme.BorderWidth) / 2) } else if total > 0 { state.Size = clamp(state.Size) - } else if once := ui.Local(in, "split_pane.first", func() bool { return true }); *once { - // Not laid out yet: measure, then size in the next frame. - *once = false - c.Invalidate() } resize := func(v float32) { if v = clamp(v); v != state.Size { diff --git a/split_pane_test.go b/split_pane_test.go index 37bf08e..47fb4a2 100644 --- a/split_pane_test.go +++ b/split_pane_test.go @@ -211,3 +211,16 @@ func TestSplitPaneSizesInsideBorder(t *testing.T) { t.Fatalf("drag to x=201 gave %v, want 200", st.Size) } } + +func TestSplitPaneClampsOnResizeAlone(t *testing.T) { + st := &SplitPaneState{Size: 299} + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + SplitPane(c, st, SplitPaneOptions{}, func() { ui.Text(c, "Files") }, func() { ui.Text(c, "Editor") }).Fill() + }, 400, 200) + tt.SetSize(250, 200) + // 250 less the divider and the 40 DIP second minimum. + if r, _ := tt.Find("Editor"); st.Size != 209 || r.X != 210 { + t.Fatalf("after resize: size %v, second pane at %v; want 209 and 210", st.Size, r.X) + } +} diff --git a/status_bar.go b/status_bar.go index d6fcf54..5786410 100644 --- a/status_bar.go +++ b/status_bar.go @@ -65,7 +65,9 @@ func StatusBar(c *ui.Context, opts StatusBarOptions) StatusBarResult { e := ui.Row(c).MinHeight(max(theme.MinHitSize, d.ControlHeight()-8)).Shrink(0).PaddingX(d.Unit()*2).Gap(gap). Background(k.Surface).BorderWidth(theme.BorderWidth, 0, 0, 0).BorderColor(k.Border). Label(msg(c, "status_bar.label")) - hidden := statusBarHide(c, e, opts, e.Bounds().W-d.Unit()*4, gap) + was := e.Bounds() + internal.Remeasure(c, e, was.W, was.H) + hidden := statusBarHide(c, opts, was.W-d.Unit()*4, gap) open := ui.Local(e, "status_bar.open", func() bool { return false }) *open = *open && len(hidden) > 0 var res StatusBarResult @@ -76,8 +78,8 @@ func StatusBar(c *ui.Context, opts StatusBarOptions) StatusBarResult { slog.Debug("mujica: status_bar clicked", "id", it.ID) } } - group := func(items []StatusItem) { - ui.Row(c).Gap(gap).Shrink(0).Dividers(theme.BorderWidth, k.Border).Children(func() { + group := func(key string, items []StatusItem) { + ui.Row(c).Key(key).Gap(gap).Shrink(0).Dividers(theme.BorderWidth, k.Border).Children(func() { for _, it := range items { if !hidden[it.ID] { item(it) @@ -86,8 +88,8 @@ func StatusBar(c *ui.Context, opts StatusBarOptions) StatusBarResult { }) } e.Children(func() { - group(opts.Left) - ui.Spacer(c) + group("status_bar.left", opts.Left) + ui.Spacer(c).Key("status_bar.spacer") if len(hidden) > 0 { more := statusBarMore(c, len(hidden)) if more.Clicked() { @@ -107,7 +109,7 @@ func StatusBar(c *ui.Context, opts StatusBarOptions) StatusBarResult { }) } } - group(opts.Right) + group("status_bar.right", opts.Right) }) res.Element = e return res @@ -152,14 +154,10 @@ func statusBarCheck(opts StatusBarOptions) { } // statusBarHide returns the secondary items to hide, last first, so the rest fit in room DIPs. -func statusBarHide(c *ui.Context, bar *ui.Element, opts StatusBarOptions, room, gap float32) map[string]bool { +func statusBarHide(c *ui.Context, opts StatusBarOptions, room, gap float32) map[string]bool { hidden := map[string]bool{} if room <= 0 { - // Not laid out yet: show all, and measure in the next frame. - if once := ui.Local(bar, "status_bar.first", func() bool { return true }); *once { - *once = false - c.Invalidate() - } + // Not laid out yet: show all; Remeasure rebuilds once measured. return hidden } u := Density(c).Unit() diff --git a/status_bar_test.go b/status_bar_test.go index 0afcfd4..6c2fc4e 100644 --- a/status_bar_test.go +++ b/status_bar_test.go @@ -7,6 +7,7 @@ import ( "github.com/egoist/mygo/ui" "mujicaui/icons" + "mujicaui/theme" ) func statusBarSample() StatusBarOptions { @@ -71,7 +72,6 @@ func TestStatusBarCollapsesSecondary(t *testing.T) { } } tt.SetSize(330, 100) - tt.Frame() for _, s := range []string{"Ln 12, Col 4", "UTF-8", "2 warnings"} { if tt.HasText(s) { t.Fatalf("narrow bar still shows secondary %q; texts %q", s, tt.Texts()) @@ -85,21 +85,18 @@ func TestStatusBarCollapsesSecondary(t *testing.T) { } // Medium width drops only the last secondary items. tt.SetSize(400, 100) - tt.Frame() if !tt.HasText("2 warnings") || tt.HasText("Ln 12, Col 4") { t.Fatalf("medium bar hid the wrong items: %q", tt.Texts()) } // At every width the visible items stay inside the bar. for w := 320; w <= 800; w += 10 { tt.SetSize(w, 100) - tt.Frame() bell, ok := tt.Find("Notifications") if !ok || bell.X+bell.W > float32(w)-8+0.5 || bell.W < 28 { t.Fatalf("width %d: action at %+v", w, bell) } } tt.SetSize(800, 100) - tt.Frame() if !tt.HasText("UTF-8") { t.Fatal("secondary items did not come back when wide") } @@ -233,3 +230,59 @@ func TestStatusBarSnapshot(t *testing.T) { }) }) } + +func TestStatusBarFoldsOnResizeAlone(t *testing.T) { + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + StatusBar(c, statusBarSample()) + }, 400, 100) + tt.SetSize(160, 100) + for _, s := range []string{"2 warnings", "UTF-8", "Ln 12, Col 4"} { + if tt.HasText(s) { + t.Fatalf("160 DIP bar still shows %q", s) + } + } + if !tt.HasText("3 more items") { + t.Fatalf("no hidden count: %q", tt.Texts()) + } +} + +func TestStatusBarKeepsFocusAcrossDensity(t *testing.T) { + density := theme.Default + n := 0 + // At 155 DIP the secondary item fits at Default and folds into "+1" at Comfortable. + o := StatusBarOptions{ + Left: []StatusItem{{ID: "main", Text: "main"}, {ID: "enc", Text: "UTF-8", Secondary: true}}, + Right: []StatusItem{{ID: "bell", Label: "Notifications", Icon: icons.Must("bell"), Action: true}}, + } + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{Density: density}) + if StatusBar(c, o).Clicked == "bell" { + n++ + } + }, 155, 100) + if !tt.HasText("UTF-8") { + t.Fatal("secondary item folded at Default density") + } + for range 4 { + if tt.Focused("Notifications") { + break + } + tt.Key(0, ui.KeyTab) + } + if !tt.Focused("Notifications") { + t.Fatal("Tab never reached Notifications") + } + density = theme.Comfortable + tt.Frame() + if tt.HasText("UTF-8") || !tt.HasText("1 more items") { + t.Fatalf("Comfortable did not fold the secondary item: %q", tt.Texts()) + } + if !tt.Focused("Notifications") { + t.Fatal("density switch lost the focus") + } + tt.Key(0, ui.KeyEnter) + if n != 1 { + t.Fatalf("Enter after the switch activated %d times, want 1", n) + } +}