diff --git a/dock_panel.go b/dock_panel.go new file mode 100644 index 0000000..8d34e49 --- /dev/null +++ b/dock_panel.go @@ -0,0 +1,193 @@ +package mujica + +import ( + "fmt" + "log/slog" + "slices" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// DockZone is an area of a DockPanel. +type DockZone int + +const ( + DockCenter DockZone = iota + DockLeft + DockRight + DockBottom +) + +// DockLayout is the caller-held arrangement: panel IDs per zone in tab order, and each zone's shown tab. +type DockLayout struct { + Zones [4][]string + Active [4]string +} + +// DockItem is one dockable panel. +type DockItem struct { + ID, Title string + Build func() +} + +// DockPanelOptions configure a DockPanel. +type DockPanelOptions struct { + Disabled bool +} + +// dockPanelDrag is a tab being dragged. +type dockPanelDrag struct{ id string } + +// DockPanel arranges panels in left, center, right and bottom zones; tabs drag to an edge or zone to dock there. +func DockPanel(c *ui.Context, layout *DockLayout, items []DockItem, opts DockPanelOptions) ChoiceResult { + if layout == nil || len(items) == 0 { + panic("mujica: DockPanel needs a layout and items") + } + byID := map[string]DockItem{} + for _, it := range items { + if it.ID == "" || it.Title == "" || it.Build == nil { + panic("mujica: DockPanel item needs an ID, Title and Build") + } + if _, dup := byID[it.ID]; dup { + panic("mujica: DockPanel item ID repeated: " + it.ID) + } + byID[it.ID] = it + } + placed := map[string]bool{} + for z, ids := range layout.Zones { + for _, id := range ids { + if _, ok := byID[id]; !ok || placed[id] { + panic(fmt.Sprintf("mujica: DockPanel zone %d holds unknown or repeated %q", z, id)) + } + placed[id] = true + } + } + changed := false + for _, it := range items { + if !placed[it.ID] { + layout.Zones[DockCenter] = append(layout.Zones[DockCenter], it.ID) + changed = true + } + } + for z, ids := range layout.Zones { + if len(ids) > 0 && !slices.Contains(ids, layout.Active[z]) { + layout.Active[z] = ids[0] + } + } + k := Tokens(c) + u := Density(c).Unit() + root := ui.Box(c).Disabled(opts.Disabled).MinHeight(0) + disabled := root.IsDisabled() + // follow is the tab that moved; it keeps the focus in its new zone. + follow := ui.Local(root, "dock_panel.follow", func() string { return "" }) + move := func(id string, to DockZone) { + *follow = id + for z := range layout.Zones { + layout.Zones[z] = slices.DeleteFunc(slices.Clone(layout.Zones[z]), func(s string) bool { return s == id }) + } + layout.Zones[to] = append(layout.Zones[to], id) + layout.Active[to] = id + changed = true + slog.Debug("mujica: dock_panel docked", "panel", id, "zone", to) + } + dragging := false + root.Children(func() { + ui.Column(c).Fill().Children(func() { + ui.Row(c).Grow(1).MinHeight(0).AlignItems(ui.Stretch).Children(func() { + for _, z := range []DockZone{DockLeft, DockCenter, DockRight} { + if dockPanelZone(c, k, u, layout, byID, z, disabled, move, &changed, follow) { + dragging = true + } + } + }) + if dockPanelZone(c, k, u, layout, byID, DockBottom, disabled, move, &changed, follow) { + dragging = true + } + }) + // Edges stay one frame after the drag, when the drop arrives. + was := ui.Local(root, "dock_panel.drag", func() bool { return false }) + if (dragging || *was) && !disabled { + dockPanelEdges(c, k, u, move) + } + *was = dragging + }) + return ChoiceResult{root, changed} +} + +// dockPanelZone builds zone z's tabs and shown panel; it reports a tab of it being dragged. +func dockPanelZone(c *ui.Context, k theme.Tokens, u float32, layout *DockLayout, byID map[string]DockItem, z DockZone, disabled bool, move func(string, DockZone), changed *bool, follow *string) bool { + ids := layout.Zones[z] + if len(ids) == 0 { + return false + } + box := ui.Column(c).Key(z).MinWidth(0).MinHeight(0).Background(k.Surface).Clip().Role(ui.RoleGroup). + Label(msg(c, fmt.Sprintf("dock_panel.zone%d", z))) + switch z { + case DockLeft, DockRight: + box.Width(220).Shrink(0).Border(theme.BorderWidth, k.Border) + case DockBottom: + box.Height(160).Shrink(0).Border(theme.BorderWidth, k.Border) + default: + box.Grow(1) + } + if d, ok := ui.DragOver[dockPanelDrag](box); ok && !slices.Contains(ids, d.id) { + box.Border(2, k.AccentText) + } + if d, ok := ui.Drop[dockPanelDrag](box); ok && !disabled { + move(d.id, z) + } + dragging := false + box.Children(func() { + at := slices.Index(ids, layout.Active[z]) + base := ui.TabsBase(c, &at, len(ids)) + base.List.Gap(u).Padding(theme.FocusGap+theme.FocusWidth, u*2, 0, u*2).Children(func() { + for i, id := range ids { + tab := base.Tab(i).Drag(dockPanelDrag{id}) + tabsStyle(c, tab, Tab{Label: byID[id].Title}, i == at, false) + tab.Description(msg(c, "dock_panel.hint")) + if *follow == id { + tab.Focus() + *follow = "" + } + dragging = dragging || tab.Dragging() + for key, to := range map[ui.Key]DockZone{ui.KeyLeft: DockLeft, ui.KeyRight: DockRight, ui.KeyDown: DockBottom, ui.KeyUp: DockCenter} { + if tab.Shortcut(ui.Shift|ui.Alt, key) && !disabled && to != z { + move(id, to) + } + } + } + }) + if base.List.Changed() && !disabled && ids[at] != layout.Active[z] { + layout.Active[z], *changed = ids[at], true + } + ui.Box(c).FillWidth().Height(theme.BorderWidth).Background(k.Border).Role(ui.RoleNone) + ui.Scroll(c).Grow(1).MinHeight(0).Padding(u * 3).Children(byID[layout.Active[z]].Build) + }) + return dragging +} + +// dockPanelEdges shows drop strips on the four edges while a tab is dragged. +func dockPanelEdges(c *ui.Context, k theme.Tokens, u float32, move func(string, DockZone)) { + for _, z := range []DockZone{DockLeft, DockRight, DockBottom, DockCenter} { + e := ui.Box(c).Key(fmt.Sprint("edge", z)).Absolute().Role(ui.RoleNone) + switch z { + case DockLeft: + e.Left(0).Top(0).Bottom(0).Width(12 * u) + case DockRight: + e.Right(0).Top(0).Bottom(0).Width(12 * u) + case DockBottom: + e.Left(12 * u).Right(12 * u).Bottom(0).Height(10 * u) + default: + e.Left(0).Right(0).Top(0).Height(4 * u) + } + _, over := ui.DragOver[dockPanelDrag](e) + if over { + e.Background(k.Selection).Border(2, k.AccentText) + } + if d, ok := ui.Drop[dockPanelDrag](e); ok { + move(d.id, z) + } + } +} diff --git a/dock_panel_test.go b/dock_panel_test.go new file mode 100644 index 0000000..20b5aa0 --- /dev/null +++ b/dock_panel_test.go @@ -0,0 +1,102 @@ +package mujica + +import ( + "slices" + "testing" + + "github.com/egoist/mygo/ui" +) + +func dockItems(c *ui.Context) []DockItem { + var out []DockItem + for _, id := range []string{"files", "editor", "terminal"} { + out = append(out, DockItem{ID: id, Title: "Panel " + id, Build: func() { ui.Text(c, "Body of "+id) }}) + } + return out +} + +func dockTester(l *DockLayout, dis *bool, n *int) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + r := DockPanel(c, l, dockItems(c), DockPanelOptions{Disabled: *dis}) + r.Element.Size(800, 500) + if r.Changed() { + *n++ + } + }, 820, 520) +} + +func TestDockPanelTabsAndKeyboardDock(t *testing.T) { + var l DockLayout + dis, n := false, 0 + tt := dockTester(&l, &dis, &n) + if !slices.Equal(l.Zones[DockCenter], []string{"files", "editor", "terminal"}) || l.Active[DockCenter] != "files" || !tt.HasText("Body of files") { + t.Fatalf("default layout %+v", l) + } + if err := tt.Click("Panel editor"); err != nil { + t.Fatal(err) + } + if l.Active[DockCenter] != "editor" || !tt.HasText("Body of editor") { + t.Fatalf("tab click: %+v", l) + } + tt.Key(ui.Shift|ui.Alt, ui.KeyLeft) + if !slices.Equal(l.Zones[DockLeft], []string{"editor"}) || slices.Contains(l.Zones[DockCenter], "editor") { + t.Fatalf("Shift+Alt+Left: %+v", l) + } + if _, ok := tt.Find("Left dock"); !ok { + t.Fatal("left zone not shown") + } + tt.Key(ui.Shift|ui.Alt, ui.KeyUp) + if len(l.Zones[DockLeft]) != 0 || l.Active[DockCenter] != "editor" { + t.Fatalf("back to center: %+v", l) + } + before := n + dis = true + tt.Frame() + tt.Key(ui.Shift|ui.Alt, ui.KeyDown) + if len(l.Zones[DockBottom]) != 0 || n != before { + t.Fatalf("disabled docked: %+v", l) + } +} + +func TestDockPanelDragToEdge(t *testing.T) { + var l DockLayout + dis, n := false, 0 + tt := dockTester(&l, &dis, &n) + r, _ := tt.Find("Panel terminal") + x, y := r.X+r.W/2, r.Y+r.H/2 + tt.Press(x, y) + for _, px := range []float32{x + 10, x + 200, 600, 790} { + tt.Move(px, 250) + } + tt.Release(790, 250) + tt.Frame() + if !slices.Equal(l.Zones[DockRight], []string{"terminal"}) || n == 0 { + t.Fatalf("drag to the right edge: %+v", l) + } +} + +func TestDockPanelRejects(t *testing.T) { + for name, l := range map[string]DockLayout{ + "unknown": {Zones: [4][]string{{"nope"}}}, + "repeated": {Zones: [4][]string{{"files"}, {"files"}}}, + } { + func() { + defer func() { + if recover() == nil { + t.Errorf("%s: no panic", name) + } + }() + ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); DockPanel(c, &l, dockItems(c), DockPanelOptions{}) }, 300, 200) + }() + } +} + +func TestDockPanelSnapshot(t *testing.T) { + l := DockLayout{Zones: [4][]string{{"editor"}, {"files"}, nil, {"terminal"}}} + snapshot(t, "dock_panel", 640, 360, func(c *ui.Context) { + ui.Column(c).Padding(12).Children(func() { + DockPanel(c, &l, dockItems(c), DockPanelOptions{}).Element.Size(616, 336) + }) + }) +} diff --git a/messages_essentials.go b/messages_essentials.go index 618816a..5c9e9d2 100644 --- a/messages_essentials.go +++ b/messages_essentials.go @@ -30,6 +30,12 @@ func init() { "resizable_panel_group.divider": {"Resize %s and %s", "调整 %s 与 %s 的大小"}, + "dock_panel.zone0": {"Center dock", "中央停靠区"}, + "dock_panel.zone1": {"Left dock", "左侧停靠区"}, + "dock_panel.zone2": {"Right dock", "右侧停靠区"}, + "dock_panel.zone3": {"Bottom dock", "底部停靠区"}, + "dock_panel.hint": {"Drag to an edge, or Shift+Alt+arrow, to dock", "拖到边缘,或按 Shift+Alt+方向键停靠"}, + "qr_code.label": {"QR code", "二维码"}, "qr_code.too_long": {"Text too long for a QR code", "文字过长,无法生成二维码"}, }) diff --git a/testdata/snapshots/darwin/dock_panel-dark.png b/testdata/snapshots/darwin/dock_panel-dark.png new file mode 100644 index 0000000..cf54f97 Binary files /dev/null and b/testdata/snapshots/darwin/dock_panel-dark.png differ diff --git a/testdata/snapshots/darwin/dock_panel-light.png b/testdata/snapshots/darwin/dock_panel-light.png new file mode 100644 index 0000000..0cab0c0 Binary files /dev/null and b/testdata/snapshots/darwin/dock_panel-light.png differ