WIP essentials 13
This commit is contained in:
5 files changed
+301
No files matched your search
+193
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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", "文字过长,无法生成二维码"},
|
||||
})
|
||||
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
Reference in new issue
Block a user