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 0000000..a7a9b63 Binary files /dev/null and b/testdata/snapshots/darwin/resizable_panel_group-dark.png differ 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 0000000..70099f4 Binary files /dev/null and b/testdata/snapshots/darwin/resizable_panel_group-light.png differ