From d92ccca7d0889be6d21ade918251441475b1def4 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:30:44 -0400 Subject: [PATCH] WIP essentials 12 --- messages_essentials.go | 2 + resizable_panel_group.go | 256 ++++++++++++++++++ resizable_panel_group_test.go | 135 +++++++++ .../darwin/resizable_panel_group-dark.png | Bin 0 -> 6459 bytes .../darwin/resizable_panel_group-light.png | Bin 0 -> 6121 bytes 5 files changed, 393 insertions(+) create mode 100644 resizable_panel_group.go create mode 100644 resizable_panel_group_test.go create mode 100644 testdata/snapshots/darwin/resizable_panel_group-dark.png create mode 100644 testdata/snapshots/darwin/resizable_panel_group-light.png diff --git a/messages_essentials.go b/messages_essentials.go index 7955b6e..618816a 100644 --- a/messages_essentials.go +++ b/messages_essentials.go @@ -28,6 +28,8 @@ func init() { "undo_toast.undo": {"Undo", "撤销"}, + "resizable_panel_group.divider": {"Resize %s and %s", "调整 %s 与 %s 的大小"}, + "qr_code.label": {"QR code", "二维码"}, "qr_code.too_long": {"Text too long for a QR code", "文字过长,无法生成二维码"}, }) diff --git a/resizable_panel_group.go b/resizable_panel_group.go new file mode 100644 index 0000000..8e273b6 --- /dev/null +++ b/resizable_panel_group.go @@ -0,0 +1,256 @@ +package mujica + +import ( + "cmp" + "fmt" + "log/slog" + "math" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// ResizablePanel is one panel of a ResizablePanelGroup. +type ResizablePanel struct { + // Label names the panel; dividers read it out. + Label string + // Min is the smallest size in DIPs; zero is 10 spacing units. + Min float32 + Collapsible bool + Build func() +} + +// ResizablePanelGroupOptions configure a ResizablePanelGroup. +type ResizablePanelGroupOptions struct { + // Vertical stacks the panels top to bottom. + Vertical bool + Disabled bool +} + +// ResizablePanelGroupResult reports a group's changes: Resized while moving, Committed when a change is done, to save. +type ResizablePanelGroupResult struct { + Element *ui.Element + Resized bool + Committed bool +} + +// resizablePanelState keeps drags and the sizes collapsed panels return to. +type resizablePanelState struct { + held int // divider being dragged, -1 for none + restored map[int]float32 +} + +// ResizablePanelGroup lays panels in a row with dividers; *sizes holds their fractions, summing to 1, for the caller to save. +func ResizablePanelGroup(c *ui.Context, sizes *[]float32, panels []ResizablePanel, opts ResizablePanelGroupOptions) ResizablePanelGroupResult { + if sizes == nil || len(panels) < 2 { + panic("mujica: ResizablePanelGroup needs sizes and two panels or more") + } + n := len(panels) + if len(*sizes) == 0 { + *sizes = make([]float32, n) + for i := range *sizes { + (*sizes)[i] = 1 / float32(n) + } + } + if len(*sizes) != n { + panic(fmt.Sprintf("mujica: ResizablePanelGroup has %d sizes for %d panels", len(*sizes), n)) + } + var sum float32 + for i, f := range *sizes { + if !(f >= 0) || f == 0 && !panels[i].Collapsible || panels[i].Label == "" || panels[i].Build == nil || panels[i].Min < 0 { + panic(fmt.Sprintf("mujica: ResizablePanelGroup panel %d is invalid", i)) + } + sum += f + } + if math.Abs(float64(sum-1)) > 1e-3 { + panic(fmt.Sprintf("mujica: ResizablePanelGroup sizes sum to %v, not 1", sum)) + } + u := Density(c).Unit() + k := Tokens(c) + var res ResizablePanelGroupResult + box := ui.Row(c).AlignItems(ui.Stretch).Disabled(opts.Disabled) + if opts.Vertical { + box.Column() + } + disabled := box.IsDisabled() + st := *ui.Local(box, "resizable_panel_group", func() *resizablePanelState { return &resizablePanelState{held: -1, restored: map[int]float32{}} }) + was := box.Bounds() + internal.Remeasure(c, box, was.W, was.H) + total := was.W + if opts.Vertical { + total = was.H + } + avail := max(0, total-float32(n-1)*theme.BorderWidth) + mins := make([]float32, n) + for i, p := range panels { + mins[i] = cmp.Or(p.Min, 10*u) + } + along := func(e *ui.Element, v float32) *ui.Element { + if opts.Vertical { + return e.Height(v).FillWidth() + } + return e.Width(v).FillHeight() + } + box.Children(func() { + var grips []func() + defer func() { + // Grips go last, above the panels they overlap. + for _, g := range grips { + g() + } + }() + for i, p := range panels { + if i > 0 { + grips = append(grips, resizablePanelDivider(c, k, u, st, sizes, panels, mins, avail, i-1, opts.Vertical, disabled, &res)) + } + if (*sizes)[i] == 0 { + continue + } + pane := ui.Box(c).Key(i).Shrink(0).Clip().Role(ui.RoleGroup).Label(p.Label) + if avail > 0 { + along(pane, (*sizes)[i]*avail) + } else { + pane.Grow((*sizes)[i]) + } + pane.Children(p.Build) + } + }) + if res.Resized || res.Committed { + c.Invalidate() + } + res.Element = box + return res +} + +// resizablePanelDivider builds the divider after panel i, stepped with arrows, Enter collapsing; it returns the grip's builder. +func resizablePanelDivider(c *ui.Context, k theme.Tokens, u float32, st *resizablePanelState, sizes *[]float32, panels []ResizablePanel, mins []float32, avail float32, i int, vertical, disabled bool, res *ResizablePanelGroupResult) func() { + s := *sizes + div := ui.Box(c).Key(fmt.Sprint("div", i)).Shrink(0).Focusable().FocusRing(false).Role(ui.RoleSplitter). + Label(fmt.Sprintf(msg(c, "resizable_panel_group.divider"), panels[i].Label, panels[i+1].Label)) + if vertical { + div.Height(theme.BorderWidth).FillWidth() + } else { + div.Width(theme.BorderWidth).FillHeight() + } + // Moving the boundary by d DIPs trades size between panels i and i+1 within their minimums. + move := func(d float32) bool { + if avail <= 0 { + return false + } + a, b := s[i]*avail, s[i+1]*avail + pair := a + b + na := max(0, min(pair, a+d)) + collapse := func(size, min float32, ok bool) bool { return ok && size < min/2 } + switch { + case collapse(na, mins[i], panels[i].Collapsible): + na = 0 + case collapse(pair-na, mins[i+1], panels[i+1].Collapsible): + na = pair + default: + na = max(min(mins[i], pair-mins[i+1]), min(na, pair-mins[i+1])) + } + if math.Abs(float64(na-a)) < 0.01 { + return false + } + s[i], s[i+1] = na/avail, (pair-na)/avail + return true + } + toggle := func() { + j := i + 1 + if !panels[j].Collapsible { + j = i + } + if !panels[j].Collapsible { + return + } + other := i + i + 1 - j + if s[j] > 0 { + st.restored[j] = s[j] + s[other] += s[j] + s[j] = 0 + } else { + back := min(cmp.Or(st.restored[j], mins[j]/max(avail, 1)), s[other]-mins[other]/max(avail, 1)) + s[j], s[other] = back, s[other]-back + } + res.Committed = true + slog.Debug("mujica: resizable_panel_group toggled", "panel", panels[j].Label, "size", s[j]) + } + if !disabled { + back, forth := ui.KeyLeft, ui.KeyRight + if vertical { + back, forth = ui.KeyUp, ui.KeyDown + } + switch { + case div.Shortcut(0, back): + res.Committed = move(-4 * u) + case div.Shortcut(0, forth): + res.Committed = move(4 * u) + case div.Shortcut(0, ui.KeyHome): + res.Committed = move(-s[i] * avail) + case div.Shortcut(0, ui.KeyEnd): + res.Committed = move(s[i+1] * avail) + case div.Shortcut(0, ui.KeyEnter): + toggle() + } + } + if s[i] > 0 && s[i+1] > 0 { + div.Description(fmt.Sprintf("%.0f%%", 100*s[i]/(s[i]+s[i+1]))) + } else { + div.Description(msg(c, "split_pane.collapsed")) + } + return func() { + grip := ui.Box(c).Key(fmt.Sprint("grip", i)).Absolute().Cursor(ui.CursorResizeEW) + if vertical { + grip.Cursor(ui.CursorResizeNS) + } + dx, dy, held := grip.Dragged() + held = held && !disabled + switch { + case held: + if st.held != i { + st.held = i + div.Focus() + } + d := dx + if vertical { + d = dy + } + if d != 0 && move(d) { + res.Resized = true + } + case st.held == i: + st.held = -1 + res.Committed = true + slog.Debug("mujica: resizable_panel_group resized", "divider", i, "sizes", s) + } + resizablePanelGrip(c, k, u, s, avail, i, vertical, grip, div, st.held == i) + } +} + +// resizablePanelGrip places the grip over divider i and draws the divider. +func resizablePanelGrip(c *ui.Context, k theme.Tokens, u float32, s []float32, avail float32, i int, vertical bool, grip, div *ui.Element, held bool) { + // The grip is wider than the 1 DIP line, centered on the boundary. + var at float32 + for _, f := range s[:i+1] { + at += f * avail + } + at += float32(i) * theme.BorderWidth + if vertical { + grip.Left(0).Right(0).Top(at - 1.5*u).Height(3 * u) + } else { + grip.Top(0).Bottom(0).Left(at - 1.5*u).Width(3 * u) + } + active := held || grip.Hovered() + div.Draw(func(p *ui.Painter, r ui.Rect) { + line := k.ControlBorder + if active { + line = k.AccentText + } + p.Fill(r, line, 0) + if div.FocusVisible() { + internal.FocusRing(p, r, 0, k.Focus) + } + }) +} diff --git a/resizable_panel_group_test.go b/resizable_panel_group_test.go new file mode 100644 index 0000000..7deb347 --- /dev/null +++ b/resizable_panel_group_test.go @@ -0,0 +1,135 @@ +package mujica + +import ( + "math" + "testing" + + "github.com/egoist/mygo/ui" +) + +func panelTester(sizes *[]float32, opts *ResizablePanelGroupOptions, commits *int) *ui.Tester { + panels := []ResizablePanel{ + {Label: "Files", Min: 100, Collapsible: true, Build: func() {}}, + {Label: "Editor", Min: 150, Build: func() {}}, + {Label: "Outline", Min: 80, Build: func() {}}, + } + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + r := ResizablePanelGroup(c, sizes, panels, *opts) + r.Element.Size(602, 300) + if r.Committed { + *commits++ + } + }, 700, 400) +} + +func near(a, b float32) bool { return math.Abs(float64(a-b)) < 0.002 } + +func TestResizablePanelGroupKeyboard(t *testing.T) { + var sizes []float32 + opts := ResizablePanelGroupOptions{} + commits := 0 + tt := panelTester(&sizes, &opts, &commits) + tt.Frame() + if len(sizes) != 3 || !near(sizes[0], 1.0/3) { + t.Fatalf("default sizes %v", sizes) + } + tt.Key(0, ui.KeyTab) + if !tt.Focused("Resize Files and Editor") { + t.Fatal("first divider not focused") + } + tt.Key(0, ui.KeyRight) // 16 DIPs of 600 + if !near(sizes[0], (200.0+16)/600) || !near(sizes[1], (200.0-16)/600) || commits != 1 { + t.Fatalf("Right: %v (%d)", sizes, commits) + } + tt.Key(0, ui.KeyEnd) // Editor keeps its 150 minimum + if !near(sizes[1], 150.0/600) { + t.Fatalf("End: %v", sizes) + } + tt.Key(0, ui.KeyEnter) + if sizes[0] != 0 || !near(sizes[1], 1-sizes[2]) { + t.Fatalf("collapse: %v", sizes) + } + tt.Key(0, ui.KeyEnter) + if !near(sizes[0], 250.0/600) { + t.Fatalf("restore: %v", sizes) + } + opts.Disabled = true + tt.Frame() + before := sizes[0] + tt.Key(0, ui.KeyLeft) + if sizes[0] != before { + t.Fatal("disabled group resized") + } +} + +func TestResizablePanelGroupDrag(t *testing.T) { + sizes := []float32{0.5, 0.25, 0.25} + opts := ResizablePanelGroupOptions{} + commits := 0 + tt := panelTester(&sizes, &opts, &commits) + tt.Frame() + x := float32(300) // the first boundary + tt.Press(x, 100) + tt.Move(x-60, 100) + if commits != 0 || !near(sizes[0], 240.0/600) { + t.Fatalf("while dragging: %v (%d)", sizes, commits) + } + tt.Release(x-60, 100) + if commits != 1 { + t.Fatalf("release did not commit: %d", commits) + } + tt.Press(x-60, 100) + tt.Move(x-60-200, 100) // past half of Files' minimum: it collapses + tt.Release(x-60-200, 100) + if sizes[0] != 0 { + t.Fatalf("drag collapse: %v", sizes) + } +} + +func TestResizablePanelGroupRejects(t *testing.T) { + ok := ResizablePanel{Label: "A", Build: func() {}} + for name, tc := range map[string]struct { + sizes []float32 + panels []ResizablePanel + }{ + "one panel": {nil, []ResizablePanel{ok}}, + "count": {[]float32{1}, []ResizablePanel{ok, ok}}, + "sum": {[]float32{0.5, 0.2}, []ResizablePanel{ok, ok}}, + "zero fixed": {[]float32{0, 1}, []ResizablePanel{ok, ok}}, + "no label": {nil, []ResizablePanel{ok, {Build: func() {}}}}, + } { + func() { + defer func() { + if recover() == nil { + t.Errorf("%s: no panic", name) + } + }() + s := tc.sizes + ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); ResizablePanelGroup(c, &s, tc.panels, ResizablePanelGroupOptions{}) }, 300, 200) + }() + } +} + +func TestResizablePanelGroupSnapshot(t *testing.T) { + sizes := []float32{0.25, 0.5, 0.25} + vs := []float32{0.6, 0.4} + snapshot(t, "resizable_panel_group", 560, 260, func(c *ui.Context) { + k := Tokens(c) + pane := func(s string) func() { + return func() { + ui.Column(c).Fill().Padding(12).Background(k.Surface).Children(func() { ui.Text(c, s) }) + } + } + ui.Row(c).Padding(16).Children(func() { + ResizablePanelGroup(c, &sizes, []ResizablePanel{ + {Label: "Files", Build: pane("Files")}, + {Label: "Editor", Build: func() { + ResizablePanelGroup(c, &vs, []ResizablePanel{{Label: "Code", Build: pane("Code")}, {Label: "Terminal", Build: pane("Terminal")}}, + ResizablePanelGroupOptions{Vertical: true}).Element.Fill() + }}, + {Label: "Outline", Build: pane("Outline")}, + }, ResizablePanelGroupOptions{}).Element.Size(528, 228) + }) + }) +} diff --git a/testdata/snapshots/darwin/resizable_panel_group-dark.png b/testdata/snapshots/darwin/resizable_panel_group-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..a7a9b6389de1de1ae398ce0598123133518bbd60 GIT binary patch literal 6459 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

WMhMhR!zk372#lFt!MpL+5?adET<>tFFlvaS$7 b=p->L;6g71Wr|LzKP(Uj_aH$zAMa literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/resizable_panel_group-light.png b/testdata/snapshots/darwin/resizable_panel_group-light.png new file mode 100644 index 0000000000000000000000000000000000000000..70099f432e3ac8fd7d8e44727dffb9b44814f317 GIT binary patch literal 6121 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