WIP essentials 12

This commit is contained in:
ZacharyZhang-NY committed 2026-10-05 14:30:44 -04:00
1 parent beb4c02c61
commit d92ccca7d0
5 files changed
+393

No files matched your search

+2
View File
@@ -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", "文字过长,无法生成二维码"},
})
+256
View File
@@ -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)
}
})
}
+135
View File
@@ -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)
})
})
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB