Split into category packages; module github.com/ZacharyZhang-NY/MujicaUI

This commit is contained in:
ZacharyZhang-NY committed 2026-10-05 16:18:48 -04:00
1 parent 5a7e921c7e
commit 22b53e9e9e
1822 files changed
+24568 -21314

No files matched your search

+110
View File
@@ -0,0 +1,110 @@
package layout
import (
"log/slog"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// AppShellOptions are the slots of an AppShell. Content is required; the others are optional.
type AppShellOptions struct {
// TitleBar goes across the top, as a TitleBar.
TitleBar func()
// Sidebar scrolls on its own beside the content and collapses to a rail.
Sidebar func()
// Content scrolls on its own.
Content func()
// StatusBar goes across the bottom, as a StatusBar.
StatusBar func()
// SidebarWidth is the open sidebar's width in DIPs; zero is 240.
SidebarWidth float32
// ContentScroll keeps the content's offset; nil keeps it by the shell's place.
ContentScroll *ui.ScrollState
}
// AppShellResult is an AppShell and its event in this frame.
type AppShellResult struct {
Element *ui.Element
// Toggled reports the sidebar opened or collapsed.
Toggled bool
}
// AppShell lays out title bar, sidebar, content and status bar; *collapsed folds the sidebar.
func AppShell(c *ui.Context, collapsed *bool, opts AppShellOptions) AppShellResult {
if collapsed == nil {
panic("mujica: AppShell needs a collapsed state")
}
if opts.Content == nil {
panic("mujica: AppShell needs Content")
}
if opts.SidebarWidth < 0 {
panic("mujica: AppShell SidebarWidth must not be negative")
}
k := core.Tokens(c)
var res AppShellResult
toggle := func() {
*collapsed = !*collapsed
res.Toggled = true
slog.Debug("mujica: app_shell toggled", "collapsed", *collapsed)
}
if opts.Sidebar != nil && c.Shortcut(ui.Cmd, ui.KeyB) {
toggle()
}
e := ui.Column(c).Fill().AlignItems(ui.Stretch).Background(k.Background)
e.Children(func() {
if opts.TitleBar != nil {
opts.TitleBar()
}
ui.Row(c).Grow(1).MinHeight(0).AlignItems(ui.Stretch).Children(func() {
if opts.Sidebar != nil && appShellSidebar(c, *collapsed, opts) {
toggle()
}
ScrollArea(c, opts.ContentScroll, ScrollAreaOptions{}, opts.Content).Grow(1).MinWidth(0)
})
if opts.StatusBar != nil {
opts.StatusBar()
}
})
res.Element = e
return res
}
// appShellSidebar builds the sidebar or its rail and reports whether its toggle was pressed.
func appShellSidebar(c *ui.Context, collapsed bool, opts AppShellOptions) bool {
k := core.Tokens(c)
u := core.Density(c).Unit()
open := opts.SidebarWidth
if open == 0 {
open = 240
}
rail := theme.MinHitSize + 4*u
target := open
if collapsed {
target = rail
}
side := ui.Column(c).Shrink(0).Clip().AlignItems(ui.Stretch).Background(k.Surface).
BorderWidth(0, theme.BorderWidth, 0, 0).BorderColor(k.Border)
side.Width(side.AnimateWith("app_shell.width", target, core.Motion(c, theme.StateDuration), ui.EaseOut))
var pressed bool
side.Children(func() {
head := ui.Row(c).Shrink(0).Padding(u * 2).Justify(ui.End)
if collapsed {
head.Justify(ui.Center)
}
head.Children(func() {
label := core.Msg(c, "app_shell.collapse")
if collapsed {
label = core.Msg(c, "app_shell.expand")
}
pressed = core.IconAction(c, icons.Must("panel-left"), label).Tooltip(label).Clicked()
})
if !collapsed {
ScrollArea(c, nil, ScrollAreaOptions{}, opts.Sidebar).Grow(1).MinHeight(0)
}
})
return pressed
}
+150
View File
@@ -0,0 +1,150 @@
package layout
import (
"fmt"
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
type appShellProbe struct {
collapsed bool
toggled int
content ui.ScrollState
}
func (p *appShellProbe) view(c *ui.Context) {
core.Use(c, core.Settings{})
r := AppShell(c, &p.collapsed, AppShellOptions{
TitleBar: func() { TitleBar(c, "Court Records", TitleBarOptions{}) },
Sidebar: func() {
for i := range 30 {
ui.Text(c, fmt.Sprintf("Nav %d", i)).Height(24)
}
},
Content: func() {
for i := range 40 {
ui.Text(c, fmt.Sprintf("Entry %d", i)).Height(24)
}
},
StatusBar: func() { StatusBar(c, StatusBarOptions{Left: []StatusItem{{ID: "s", Text: "Ready"}}}) },
ContentScroll: &p.content,
})
if r.Toggled {
p.toggled++
}
}
func appShellTester(p *appShellProbe) *ui.Tester {
tt := ui.NewTester(p.view, 640, 400)
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
tt.Frame()
return tt
}
func TestAppShellSlots(t *testing.T) {
tt := appShellTester(&appShellProbe{})
title, _ := tt.Find("Court Records")
nav, _ := tt.Find("Nav 0")
entry, _ := tt.Find("Entry 0")
ready, _ := tt.Find("Ready")
if title.Y > nav.Y || nav.X >= entry.X || ready.Y < 360 {
t.Fatalf("slots out of place: title %+v nav %+v entry %+v status %+v", title, nav, entry, ready)
}
if entry.X < 240 {
t.Fatalf("content at %v overlaps the 240 DIP sidebar", entry.X)
}
}
func TestAppShellCollapseByButton(t *testing.T) {
p := &appShellProbe{}
tt := appShellTester(p)
if err := tt.Click("Collapse sidebar"); err != nil {
t.Fatal(err)
}
if !p.collapsed || p.toggled != 1 || tt.HasText("Nav 0") {
t.Fatalf("after collapse: collapsed=%v toggled=%d nav shown=%v", p.collapsed, p.toggled, tt.HasText("Nav 0"))
}
if entry, _ := tt.Find("Entry 0"); entry.X != 44 {
t.Fatalf("content at %v beside the rail, want 44", entry.X)
}
if err := tt.Click("Expand sidebar"); err != nil {
t.Fatal(err)
}
if p.collapsed || !tt.HasText("Nav 0") || p.toggled != 2 {
t.Fatal("Expand did not reopen the sidebar")
}
}
func TestAppShellCollapseByShortcut(t *testing.T) {
p := &appShellProbe{}
tt := appShellTester(p)
tt.Key(ui.Cmd, ui.KeyB)
if !p.collapsed {
t.Fatal("Cmd+B did not collapse")
}
tt.Key(ui.Cmd, ui.KeyB)
if p.collapsed || p.toggled != 2 {
t.Fatalf("Cmd+B twice: collapsed=%v toggled=%d", p.collapsed, p.toggled)
}
}
func TestAppShellContentScrollsAlone(t *testing.T) {
p := &appShellProbe{}
tt := appShellTester(p)
nav, _ := tt.Find("Nav 0")
tt.Scroll(400, 200, 0, 96)
if p.content.Y != 96 {
t.Fatalf("content offset %v, want 96", p.content.Y)
}
if r, _ := tt.Find("Nav 0"); r.Y != nav.Y {
t.Fatalf("sidebar moved from %v to %v with the content", nav.Y, r.Y)
}
if r, _ := tt.Find("Court Records"); r.Y > 20 {
t.Fatalf("title bar scrolled to %v", r.Y)
}
// Collapsing keeps the content's place.
tt.Key(ui.Cmd, ui.KeyB)
if p.content.Y != 96 {
t.Fatalf("collapse reset the content offset to %v", p.content.Y)
}
}
func TestAppShellNeedsContent(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("missing Content did not panic")
}
}()
var collapsed bool
ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
AppShell(c, &collapsed, AppShellOptions{})
}, 100, 100)
}
func TestAppShellSnapshot(t *testing.T) {
snapshot(t, "app_shell", 560, 260, func(c *ui.Context) {
collapsed := ui.Local(c.Root(), "snap", func() bool { return false })
AppShell(c, collapsed, AppShellOptions{
TitleBar: func() { TitleBar(c, "Court Records", TitleBarOptions{}) },
Sidebar: func() {
for _, s := range []string{"Chronicles", "Letters", "Decrees"} {
ui.Text(c, s).Padding(6, 12)
}
},
Content: func() {
Container(c, ContainerOptions{}, func() {
Stack(c, StackOptions{Gap: SpaceM}, func() {
ui.Text(c, "The Grand Hall").FontSize(18)
Divider(c, DividerOptions{Ornament: true})
ui.Text(c, "Content scrolls on its own, apart from the sidebar.")
})
})
},
StatusBar: func() { StatusBar(c, StatusBarOptions{Left: []StatusItem{{ID: "s", Text: "Ready"}}}) },
})
})
}
+49
View File
@@ -0,0 +1,49 @@
package layout
import (
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
)
// AspectRatioOptions configure an AspectRatio.
type AspectRatioOptions struct {
// Ratio is width over height; zero is 1.
Ratio float32
}
// AspectRatio centers the largest Ratio frame that fits; the child is centered, not stretched.
func AspectRatio(c *ui.Context, opts AspectRatioOptions, child func()) *ui.Element {
if opts.Ratio < 0 {
panic("mujica: AspectRatio Ratio must be positive")
}
if child == nil {
panic("mujica: AspectRatio needs a child")
}
r := opts.Ratio
if r == 0 {
r = 1
}
outer := ui.Box(c).Fill().Center()
room := outer.Bounds()
internal.Remeasure(c, outer, room.W, room.H)
return outer.Children(func() {
frame := ui.Box(c).AspectRatio(r).Shrink(0).Center().Clip()
was := frame.Bounds()
w := room.W
// A room whose height differs from the frame's, or a frame narrower than it, has a set height.
if room.H-was.H > 0.5 || was.H-room.H > 0.5 || was.W < room.W-0.5 {
w = min(w, room.H*r)
}
if w <= 0 {
// Not laid out yet: Remeasure rebuilds once the room is known.
frame.WidthPercent(100)
} else {
frame.Width(w)
if w-was.W > 0.5 || was.W-w > 0.5 {
c.Invalidate()
}
}
frame.Children(child)
})
}
+113
View File
@@ -0,0 +1,113 @@
package layout
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
// aspectRatioBounds returns the frame (via a filling child) and a fixed 40x20 child.
func aspectRatioBounds(t *testing.T, ratio float32, w, h int) (box, child ui.Rect) {
t.Helper()
ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
ui.Column(c).Size(float32(w), float32(h)).Children(func() {
AspectRatio(c, AspectRatioOptions{Ratio: ratio}, func() {
box = ui.Box(c).Fill().Absolute().Bounds()
child = ui.Box(c).Size(40, 20).Bounds()
})
})
}, 600, 600).Frame()
return box, child
}
func TestAspectRatioWidthBound(t *testing.T) {
box, _ := aspectRatioBounds(t, 16.0/9, 320, 400)
if box.W != 320 || box.H != 180 {
t.Fatalf("box %vx%v, want 320x180", box.W, box.H)
}
}
func TestAspectRatioHeightBound(t *testing.T) {
box, _ := aspectRatioBounds(t, 2, 400, 100)
if box.W != 200 || box.H != 100 {
t.Fatalf("box %vx%v, want 200x100", box.W, box.H)
}
}
func TestAspectRatioDefaultSquareAndUnstretchedChild(t *testing.T) {
box, child := aspectRatioBounds(t, 0, 200, 400)
if box.W != 200 || box.H != 200 {
t.Fatalf("box %vx%v, want 200x200", box.W, box.H)
}
if child.W != 40 || child.H != 20 {
t.Fatalf("child stretched to %vx%v", child.W, child.H)
}
if child.X != box.X+80 || child.Y != box.Y+90 {
t.Fatalf("child at %v,%v not centered in %+v", child.X, child.Y, box)
}
}
func TestAspectRatioFollowsWindowResize(t *testing.T) {
var box ui.Rect
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
ui.Column(c).Fill().Children(func() {
AspectRatio(c, AspectRatioOptions{Ratio: 2}, func() {
box = ui.Box(c).Fill().Absolute().Bounds()
})
})
}, 400, 100)
if box.W != 200 || box.H != 100 {
t.Fatalf("short window: %vx%v, want 200x100", box.W, box.H)
}
tt.SetSize(400, 400)
if box.W != 400 || box.H != 200 {
t.Fatalf("tall window: %vx%v, want 400x200", box.W, box.H)
}
tt.SetSize(300, 400)
if box.W != 300 || box.H != 150 {
t.Fatalf("narrow window: %vx%v, want 300x150", box.W, box.H)
}
}
func TestAspectRatioRejectsNegative(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("negative ratio did not panic")
}
}()
ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
AspectRatio(c, AspectRatioOptions{Ratio: -1}, func() {})
}, 100, 100)
}
func TestAspectRatioSnapshot(t *testing.T) {
snapshot(t, "aspect_ratio", 420, 160, func(c *ui.Context) {
k := core.Tokens(c)
ui.Row(c).Padding(16).Gap(16).Height(160).AlignItems(ui.Stretch).Children(func() {
for _, r := range []float32{1, 16.0 / 9, 3.0 / 4} {
ui.Box(c).Width(120).Children(func() {
AspectRatio(c, AspectRatioOptions{Ratio: r}, func() {
ui.Box(c).Fill().Center().Background(k.Surface).Border(1, k.Border).Radius(4).Children(func() {
ui.Text(c, aspectRatioLabel(r)).TextColor(k.TextMuted)
})
})
})
}
})
})
}
func aspectRatioLabel(r float32) string {
switch r {
case 1:
return "1 : 1"
case 3.0 / 4:
return "3 : 4"
}
return "16 : 9"
}
+86
View File
@@ -0,0 +1,86 @@
package layout
import (
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// Space is a spacing step scaled by the window's density. Zero picks the component's default.
type Space int
const (
SpaceDefault Space = iota
SpaceNone
SpaceXS // 1 unit, 4 DIPs at default density
SpaceS // 2 units
SpaceM // 3 units
SpaceL // 4 units
SpaceXL // 6 units
SpaceXXL // 8 units
)
// containerSpace returns s in DIPs for density d, using def when s is SpaceDefault.
func containerSpace(d theme.Density, s, def Space) float32 {
if s == SpaceDefault {
s = def
}
var u float32
switch s {
case SpaceNone:
case SpaceXS:
u = 1
case SpaceS:
u = 2
case SpaceM:
u = 3
case SpaceL:
u = 4
case SpaceXL:
u = 6
case SpaceXXL:
u = 8
default:
panic("mujica: invalid Space value")
}
return u * d.Unit()
}
// ContainerOptions configure a Container.
type ContainerOptions struct {
// Padding defaults to SpaceL.
Padding Space
// MaxWidth caps the width in DIPs; zero leaves it unbounded.
MaxWidth float32
// Center places the container in the middle of its parent's width.
Center bool
// Fill takes the parent's whole content box.
Fill bool
// Clip cuts content that overflows the padding box.
Clip bool
}
// Container pads its children, caps their width and can center, fill and clip.
func Container(c *ui.Context, opts ContainerOptions, children func()) *ui.Element {
if opts.MaxWidth < 0 {
panic("mujica: Container MaxWidth must not be negative")
}
if children == nil {
panic("mujica: Container needs children")
}
e := ui.Column(c).Padding(containerSpace(core.Density(c), opts.Padding, SpaceL))
if opts.MaxWidth > 0 {
e.MaxWidth(opts.MaxWidth)
}
if opts.Fill {
e.Fill()
}
if opts.Center {
e.FillWidth().Margin(0, ui.Auto)
}
if opts.Clip {
e.Clip()
}
return e.Children(children)
}
+98
View File
@@ -0,0 +1,98 @@
package layout
import (
"image/color"
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
func TestContainerPaddingByDensity(t *testing.T) {
for _, tc := range []struct {
density theme.Density
pad Space
want float32
}{{theme.Default, SpaceDefault, 16}, {theme.Compact, SpaceDefault, 12}, {theme.Comfortable, SpaceS, 10}, {theme.Default, SpaceNone, 0}} {
var outer, inner ui.Rect
ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{Density: tc.density})
outer = Container(c, ContainerOptions{Padding: tc.pad}, func() {
inner = ui.Box(c).Size(10, 10).Bounds()
}).Bounds()
}, 400, 300).Frame()
if inner.X-outer.X != tc.want || inner.Y-outer.Y != tc.want {
t.Errorf("%v %v: inset %v,%v, want %v", tc.density, tc.pad, inner.X-outer.X, inner.Y-outer.Y, tc.want)
}
}
}
func TestContainerMaxWidthCentered(t *testing.T) {
var r ui.Rect
ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
ui.Column(c).Width(600).Children(func() {
r = Container(c, ContainerOptions{MaxWidth: 300, Center: true}, func() { ui.Text(c, "Body") }).Bounds()
})
}, 600, 300).Frame()
if r.X != 150 || r.W != 300 {
t.Fatalf("container at x=%v w=%v, want 150 and 300", r.X, r.W)
}
}
func TestContainerFill(t *testing.T) {
var r ui.Rect
ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
ui.Row(c).Size(320, 200).Children(func() {
r = Container(c, ContainerOptions{Fill: true}, func() { ui.Text(c, "x") }).Bounds()
})
}, 400, 300).Frame()
if r.W != 320 || r.H != 200 {
t.Fatalf("filled %vx%v, want 320x200", r.W, r.H)
}
}
func TestContainerClip(t *testing.T) {
for _, clip := range []bool{false, true} {
var bg ui.Color
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{Mode: core.Light})
bg = core.Tokens(c).Background
Container(c, ContainerOptions{Padding: SpaceNone, Clip: clip}, func() {
ui.Box(c).Size(200, 200).Background(ui.RGB(255, 0, 0))
}).Size(100, 100)
}, 300, 300)
got := tt.Image().At(150, 150)
clipped := got == color.RGBA{bg.R, bg.G, bg.B, 255}
if clipped != clip {
t.Errorf("Clip %v: pixel outside %v", clip, got)
}
}
}
func TestContainerInvalidSpacePanics(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("invalid Space did not panic")
}
}()
ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
Container(c, ContainerOptions{Padding: Space(99)}, func() {})
}, 100, 100)
}
func TestContainerSnapshot(t *testing.T) {
snapshot(t, "container", 420, 150, func(c *ui.Context) {
k := core.Tokens(c)
ui.Column(c).Padding(16).Children(func() {
Container(c, ContainerOptions{MaxWidth: 280, Center: true}, func() {
ui.Text(c, "Of the Court").Font(theme.SerifFont).FontSize(theme.H3Size)
ui.Text(c, "A centered container, 280 DIPs at most, padded by four units.").TextColor(k.TextMuted)
}).Background(k.Surface).Border(1, k.Border).Radius(theme.CardRadius)
})
})
}
+72
View File
@@ -0,0 +1,72 @@
package layout
import (
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// dividerDiamond is the ornament's size in DIPs.
const dividerDiamond float32 = 7
// DividerOptions configure a Divider.
type DividerOptions struct {
// Vertical draws a line down a row instead of across a column.
Vertical bool
// Label sits in the middle of a horizontal divider.
Label string
// Ornament draws the line in the ornament color with a single diamond in the middle; a Label replaces the diamond.
Ornament bool
}
// Divider is a thin separating line. It never takes focus and its ornament is not read out.
func Divider(c *ui.Context, opts DividerOptions) *ui.Element {
if opts.Vertical && opts.Label != "" {
panic("mujica: a vertical Divider cannot have a Label")
}
k := core.Tokens(c)
line := k.Border
if opts.Ornament {
line = k.Ornament
}
if opts.Label == "" && !opts.Ornament {
e := ui.Box(c).Background(line).Shrink(0).AlignSelf(ui.Stretch)
if opts.Vertical {
return e.Width(theme.BorderWidth)
}
return e.Height(theme.BorderWidth)
}
if opts.Label == "" && !opts.Vertical {
gap := core.Density(c).Unit()*2 + dividerDiamond/2
return ui.Box(c).Height(dividerDiamond).Shrink(0).AlignSelf(ui.Stretch).Draw(func(p *ui.Painter, r ui.Rect) {
internal.RuleDiamond(p, r, gap, dividerDiamond, line)
})
}
e := ui.Row(c).Gap(core.Density(c).Unit() * 2).Shrink(0).AlignSelf(ui.Stretch)
if opts.Vertical {
e.Column().AlignItems(ui.Center)
}
segment := func() {
s := ui.Box(c).Grow(1).Background(line)
if opts.Vertical {
s.Width(theme.BorderWidth)
} else {
s.Height(theme.BorderWidth)
}
}
return e.Children(func() {
segment()
if opts.Label != "" {
ui.Text(c, opts.Label).Font(theme.SerifFont).FontSize(core.FontSize(c, theme.CaptionSize)).
TextColor(k.TextMuted).SingleLine().Shrink(0)
} else {
ui.Box(c).Size(dividerDiamond, dividerDiamond).Shrink(0).Role(ui.RoleNone).
Draw(func(p *ui.Painter, r ui.Rect) {
p.FillPath(internal.Diamond(r.X+r.W/2, r.Y+r.H/2, r.W), k.Ornament)
})
}
segment()
})
}
+123
View File
@@ -0,0 +1,123 @@
package layout
import (
"image/color"
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/input"
)
func TestDividerSizes(t *testing.T) {
var h, v ui.Rect
ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
ui.Column(c).Width(300).Children(func() {
h = Divider(c, DividerOptions{}).Bounds()
ui.Row(c).Height(80).Children(func() {
v = Divider(c, DividerOptions{Vertical: true}).Bounds()
})
})
}, 400, 300).Frame()
if h.W != 300 || h.H != 1 {
t.Fatalf("horizontal %vx%v, want 300x1", h.W, h.H)
}
if v.W != 1 || v.H != 80 {
t.Fatalf("vertical %vx%v, want 1x80", v.W, v.H)
}
}
func TestDividerNeverFocused(t *testing.T) {
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
ui.Column(c).Children(func() {
Divider(c, DividerOptions{Label: "Section", Ornament: true})
Divider(c, DividerOptions{Ornament: true})
input.Button(c, "OK", input.ButtonOptions{})
})
}, 400, 300)
tt.Key(0, ui.KeyTab)
if !tt.Focused("OK") {
t.Fatal("first Tab did not skip the dividers to the button")
}
if tt.Focused("Section") {
t.Fatal("divider label took the focus")
}
}
func TestDividerLabelCentered(t *testing.T) {
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
ui.Column(c).Width(400).Children(func() {
Divider(c, DividerOptions{Label: "Chapter II"})
})
}, 400, 100)
r, ok := tt.Find("Chapter II")
if !ok {
t.Fatal("label missing")
}
if mid := r.X + r.W/2; mid < 199 || mid > 201 {
t.Fatalf("label centered at %v, want 200", mid)
}
}
func TestDividerOrnamentColor(t *testing.T) {
var want ui.Color
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{Mode: core.Light})
want = core.Tokens(c).Ornament
ui.Column(c).Width(201).Padding(10, 0).Children(func() {
Divider(c, DividerOptions{Ornament: true})
})
}, 201, 40)
// The diamond's center sits on the line's middle.
got := tt.Image().At(100, 13)
if got != (color.RGBA{want.R, want.G, want.B, 255}) {
t.Fatalf("diamond center %v, want ornament %v", got, want)
}
}
func TestDividerVerticalLabelPanics(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("vertical label did not panic")
}
}()
ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
Divider(c, DividerOptions{Vertical: true, Label: "x"})
}, 100, 100)
}
func TestDividerSnapshot(t *testing.T) {
snapshot(t, "divider", 420, 170, func(c *ui.Context) {
ui.Column(c).Padding(20).Gap(20).Children(func() {
Divider(c, DividerOptions{})
Divider(c, DividerOptions{Ornament: true})
Divider(c, DividerOptions{Label: "Chapter II"})
Divider(c, DividerOptions{Label: "第二章", Ornament: true})
ui.Row(c).Height(28).Gap(12).Children(func() {
ui.Text(c, "Left")
Divider(c, DividerOptions{Vertical: true})
ui.Text(c, "Middle")
Divider(c, DividerOptions{Vertical: true, Ornament: true})
ui.Text(c, "Right")
})
})
})
}
func TestDividerLabelTextScale(t *testing.T) {
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
ui.Column(c).Width(300).Children(func() { Divider(c, DividerOptions{Label: "Chapter II"}) })
}, 400, 200)
plain, _ := tt.Find("Chapter II")
tt.SetPreferences(ui.Preferences{TextScale: 2})
tt.Frame()
if big, _ := tt.Find("Chapter II"); big.H < 1.8*plain.H {
t.Fatalf("label height %v at 200%%, was %v", big.H, plain.H)
}
}
+2
View File
@@ -0,0 +1,2 @@
// Package layout holds MujicaUI app shells, panes, toolbars and other desktop layout.
package layout
+133
View File
@@ -0,0 +1,133 @@
package layout_test
import (
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/input"
"github.com/ZacharyZhang-NY/MujicaUI/layout"
)
func ExampleAppShell() {
var collapsed bool
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
layout.AppShell(c, &collapsed, layout.AppShellOptions{
TitleBar: func() { layout.TitleBar(c, "Court Records", layout.TitleBarOptions{}) },
Sidebar: func() { ui.Text(c, "Chronicles") },
Content: func() { ui.Text(c, "The Grand Hall") },
})
}
ui.Render(view, 400, 300, 1)
}
func ExampleTitleBar() {
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
layout.TitleBar(c, "Court Records", layout.TitleBarOptions{
Trailing: func() { input.Button(c, "Share", input.ButtonOptions{Variant: input.Ghost}) },
})
}
ui.Render(view, 400, 300, 1)
}
func ExampleStatusBar() {
opened := 0
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
bar := layout.StatusBar(c, layout.StatusBarOptions{
Left: []layout.StatusItem{{ID: "sync", Text: "Syncing", ShowProgress: true, Progress: 0.4}},
Right: []layout.StatusItem{{ID: "bell", Label: "Notifications", Icon: icons.Must("bell"), Action: true}},
})
if bar.Clicked == "bell" {
opened++
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleContainer() {
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
layout.Container(c, layout.ContainerOptions{MaxWidth: 320, Center: true}, func() {
ui.Text(c, "A centered column of prose.")
})
}
ui.Render(view, 400, 300, 1)
}
func ExampleStack() {
showDraft := false
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
layout.Stack(c, layout.StackOptions{Horizontal: true, Gap: layout.SpaceM}, func() {
input.Button(c, "Save", input.ButtonOptions{})
if showDraft {
input.Button(c, "Draft", input.ButtonOptions{Variant: input.Secondary})
}
input.Button(c, "Cancel", input.ButtonOptions{Variant: input.Ghost})
})
}
ui.Render(view, 400, 300, 1)
}
func ExampleGrid() {
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
layout.Grid(c, layout.GridOptions{Columns: []ui.Track{ui.Fixed(96), ui.Fr(1)}}, func() {
layout.GridCell(c, layout.GridCellOptions{ColumnSpan: -1}, func() { ui.Text(c, "Header") })
ui.Text(c, "Name")
ui.Text(c, "Ada")
})
}
ui.Render(view, 400, 300, 1)
}
func ExampleScrollArea() {
var offset ui.ScrollState
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
layout.ScrollArea(c, &offset, layout.ScrollAreaOptions{Label: "Log"}, func() {
for range 50 {
ui.Text(c, "A line of the log")
}
}).Height(200)
}
ui.Render(view, 400, 300, 1)
}
func ExampleSplitPane() {
split := layout.SplitPaneState{Size: 160}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
layout.SplitPane(c, &split, layout.SplitPaneOptions{MinFirst: 120, Collapsible: layout.SplitPaneFirst},
func() { ui.Text(c, "Files") },
func() { ui.Text(c, "Editor") },
).Element.Fill()
}
ui.Render(view, 400, 300, 1)
}
func ExampleAspectRatio() {
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
ui.Box(c).Size(320, 240).Children(func() {
layout.AspectRatio(c, layout.AspectRatioOptions{Ratio: 16.0 / 9}, func() {
ui.Text(c, "16 : 9")
})
})
}
ui.Render(view, 400, 300, 1)
}
func ExampleDivider() {
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
ui.Column(c).Padding(16).Gap(12).Children(func() {
layout.Divider(c, layout.DividerOptions{Ornament: true})
layout.Divider(c, layout.DividerOptions{Label: "Chapter II"})
})
}
ui.Render(view, 400, 300, 1)
}
+76
View File
@@ -0,0 +1,76 @@
package layout
import (
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
// GridOptions configure a Grid.
type GridOptions struct {
// Columns size each column with ui.Fixed, ui.Fr or ui.FitContent; nil is one Fr(1) column.
Columns []ui.Track
// Rows size the first rows; rows added after fit their content.
Rows []ui.Track
// Gap defaults to SpaceM.
Gap Space
// RowGap defaults to Gap.
RowGap Space
}
// Grid lays children out in columns and rows; GridCell places and spans them.
func Grid(c *ui.Context, opts GridOptions, children func()) *ui.Element {
if children == nil {
panic("mujica: Grid needs children")
}
d := core.Density(c)
gap := containerSpace(d, opts.Gap, SpaceM)
rowGap := gap
if opts.RowGap != SpaceDefault {
rowGap = containerSpace(d, opts.RowGap, SpaceM)
}
cols := opts.Columns
if len(cols) == 0 {
cols = []ui.Track{ui.Fr(1)}
}
e := ui.Grid(c).ColumnTracks(cols...).GapX(gap).GapY(rowGap)
if len(opts.Rows) > 0 {
e.RowTracks(opts.Rows...)
}
return e.Children(children)
}
// GridCellOptions place one cell. Lines count from 1; zero places the cell automatically.
type GridCellOptions struct {
Column, Row int
// ColumnSpan and RowSpan default to 1; -1 spans to the last track.
ColumnSpan, RowSpan int
}
// GridCell is one child of a Grid, placed and spanned by opts; build makes one element, stretched over the cell.
func GridCell(c *ui.Context, opts GridCellOptions, build func()) *ui.Element {
if build == nil {
panic("mujica: GridCell needs build")
}
if opts.Column < 0 || opts.Row < 0 {
panic("mujica: GridCell Column and Row count from 1")
}
if opts.ColumnSpan < -1 || opts.RowSpan < -1 {
panic("mujica: GridCell spans must be positive, or -1 for the rest")
}
// A one-track grid stretches its child over the whole cell, as the grid does its own children.
e := ui.Grid(c).Columns(1).Rows(1)
if opts.Column > 0 {
e.ColumnStart(opts.Column)
}
if opts.Row > 0 {
e.RowStart(opts.Row)
}
if opts.ColumnSpan != 0 {
e.ColumnSpan(opts.ColumnSpan)
}
if opts.RowSpan != 0 {
e.RowSpan(opts.RowSpan)
}
return e.Children(build)
}
+95
View File
@@ -0,0 +1,95 @@
package layout
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func TestGridFixedAndFrTracks(t *testing.T) {
var a, b ui.Rect
ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
Grid(c, GridOptions{Columns: []ui.Track{ui.Fixed(100), ui.Fr(1)}}, func() {
a = ui.Box(c).Height(20).Bounds()
b = ui.Box(c).Height(20).Bounds()
}).Width(400)
}, 400, 300).Frame()
if a.W != 100 || b.X != 112 || b.W != 288 {
t.Fatalf("tracks %v and %v at %v, want 100, 288 at 112", a.W, b.W, b.X)
}
}
func TestGridSpans(t *testing.T) {
var head, tall, side ui.Rect
ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
Grid(c, GridOptions{Columns: []ui.Track{ui.Fr(1), ui.Fr(1), ui.Fr(1)}, Gap: SpaceS, RowGap: SpaceL}, func() {
head = GridCell(c, GridCellOptions{ColumnSpan: -1}, func() { ui.Box(c).Height(20) }).Bounds()
tall = GridCell(c, GridCellOptions{RowSpan: 2}, func() { ui.Box(c).Height(10) }).Bounds()
side = GridCell(c, GridCellOptions{Column: 3, Row: 3}, func() { ui.Box(c).Height(30) }).Bounds()
GridCell(c, GridCellOptions{Column: 2, Row: 2}, func() { ui.Box(c).Height(30) })
}).Width(316)
}, 400, 300).Frame()
if head.W != 316 {
t.Fatalf("header width %v, want 316", head.W)
}
// Rows of 30 with a 16 gap: the tall cell spans 30+16+30.
if tall.H != 76 {
t.Fatalf("row-spanning cell height %v, want 76", tall.H)
}
if side.X != 216 || side.Y != tall.Y+46 {
t.Fatalf("placed cell at %v,%v, want 216,%v", side.X, side.Y, tall.Y+46)
}
}
func TestGridRowTracksShareHeight(t *testing.T) {
var a, b ui.Rect
ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
Grid(c, GridOptions{Rows: []ui.Track{ui.Fixed(40), ui.Fr(1)}, Gap: SpaceNone}, func() {
a = ui.Box(c).Bounds()
b = ui.Box(c).Bounds()
}).Height(200)
}, 300, 300).Frame()
if a.H != 40 || b.H != 160 {
t.Fatalf("rows %v and %v, want 40 and 160", a.H, b.H)
}
}
func TestGridCellRejectsBadPlacement(t *testing.T) {
for _, o := range []GridCellOptions{{Column: -1}, {RowSpan: -2}} {
func() {
defer func() {
if recover() == nil {
t.Errorf("%+v did not panic", o)
}
}()
ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
Grid(c, GridOptions{}, func() { GridCell(c, o, func() {}) })
}, 100, 100)
}()
}
}
func TestGridSnapshot(t *testing.T) {
snapshot(t, "grid", 420, 200, func(c *ui.Context) {
k := core.Tokens(c)
cell := func(s string) {
ui.Box(c).Center().Padding(8).Background(k.Surface).Border(1, k.Border).Radius(4).
Children(func() { ui.Text(c, s).TextColor(k.TextMuted) })
}
ui.Column(c).Padding(16).Children(func() {
Grid(c, GridOptions{Columns: []ui.Track{ui.Fixed(96), ui.Fr(1), ui.Fr(1)}}, func() {
GridCell(c, GridCellOptions{RowSpan: 2}, func() { cell("Fixed 96") })
GridCell(c, GridCellOptions{ColumnSpan: 2}, func() { cell("Span 2") })
cell("Fr 1")
cell("Fr 1")
GridCell(c, GridCellOptions{ColumnSpan: -1}, func() { cell("Across") })
})
})
})
}
+24
View File
@@ -0,0 +1,24 @@
package layout
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/internal/snaptest"
)
// snapshot compares view in both themes with testdata/snapshots/<os>/<name>-<mode>.png.
func snapshot(t *testing.T, name string, w, h int, view func(c *ui.Context)) {
t.Helper()
snaptest.Run(t, name, w, h, snapshotTheme, view, nil)
}
func snapshotTheme(c *ui.Context, mode string) {
m := core.Light
if mode == "dark" {
m = core.Dark
}
core.Use(c, core.Settings{Mode: m})
}
+66
View File
@@ -0,0 +1,66 @@
package layout
import (
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// ScrollDirection is the way a ScrollArea scrolls. The zero value is vertical.
type ScrollDirection int
const (
ScrollVertical ScrollDirection = iota
ScrollHorizontal
ScrollBoth
)
// ScrollAreaOptions configure a ScrollArea.
type ScrollAreaOptions struct {
Direction ScrollDirection
// Label names the region for assistive technology.
Label string
}
// ScrollArea scrolls children; state keeps and sets the offset, nil keeps it by place.
func ScrollArea(c *ui.Context, state *ui.ScrollState, opts ScrollAreaOptions, children func()) *ui.Element {
if children == nil {
panic("mujica: ScrollArea needs children")
}
var e *ui.Element
switch opts.Direction {
case ScrollVertical:
e = ui.Scroll(c)
case ScrollHorizontal:
e = ui.ScrollHorizontal(c)
case ScrollBoth:
e = ui.ScrollBoth(c)
default:
panic("mujica: invalid ScrollDirection")
}
if state == nil {
state = ui.Local(e, "scroll_area.state", func() ui.ScrollState { return ui.ScrollState{} })
}
e.TrackScroll(state)
overflow := state.MaxX > 0 || state.MaxY > 0
if overflow {
e.Focusable()
}
focus := core.Tokens(c).Focus
// The ring goes inside, past the focus gap: areas often fill a clipped pane.
e.FocusRing(false).DrawOver(func(p *ui.Painter, r ui.Rect) {
if overflow != (state.MaxX > 0 || state.MaxY > 0) {
// Layout changed the overflow: rebuild so the focus stop follows it.
c.Invalidate()
}
if e.FocusVisible() {
in := theme.FocusGap + theme.FocusWidth/2
p.Stroke(ui.Rect{X: r.X + in, Y: r.Y + in, W: r.W - 2*in, H: r.H - 2*in}, focus, theme.ControlRadius, theme.FocusWidth)
}
})
if opts.Label != "" {
e.Label(opts.Label)
}
return e.Children(children)
}
+139
View File
@@ -0,0 +1,139 @@
package layout
import (
"fmt"
"image/color"
"math"
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func scrollAreaView(st *ui.ScrollState, opts ScrollAreaOptions, rows int) func(c *ui.Context) {
return func(c *ui.Context) {
core.Use(c, core.Settings{})
ScrollArea(c, st, opts, func() {
for i := range rows {
ui.Text(c, fmt.Sprintf("Row %d", i)).Height(20).Width(float32(100 + 40*i))
}
}).Size(200, 100)
}
}
func TestScrollAreaScrollToEnd(t *testing.T) {
st := &ui.ScrollState{}
tt := ui.NewTester(scrollAreaView(st, ScrollAreaOptions{}, 20), 300, 300)
if st.MaxY != 300 {
t.Fatalf("MaxY %v, want 300", st.MaxY)
}
st.Y = math.MaxFloat32
tt.Frame()
if st.Y != 300 {
t.Fatalf("Y %v after scrolling to the end, want 300", st.Y)
}
st.Y = 40
tt.Frame()
if r, _ := tt.Find("Row 2"); r.Y != 0 {
t.Fatalf("Row 2 at %v after scrolling to 40, want 0", r.Y)
}
}
func TestScrollAreaWheelAndKeys(t *testing.T) {
st := &ui.ScrollState{}
tt := ui.NewTester(scrollAreaView(st, ScrollAreaOptions{Label: "Log"}, 20), 300, 300)
tt.Scroll(50, 50, 0, 60)
if st.Y != 60 {
t.Fatalf("wheel moved to %v, want 60", st.Y)
}
tt.Key(0, ui.KeyTab)
if !tt.Focused("Log") {
t.Fatal("overflowing area did not take the focus")
}
tt.Key(0, ui.KeyDown)
if st.Y != 100 {
t.Fatalf("Down moved to %v, want 100", st.Y)
}
tt.Key(0, ui.KeyEnd)
if st.Y != 300 {
t.Fatalf("End moved to %v, want 300", st.Y)
}
tt.Key(0, ui.KeyHome)
if st.Y != 0 {
t.Fatalf("Home moved to %v, want 0", st.Y)
}
}
func TestScrollAreaNoOverflowSkipsFocus(t *testing.T) {
tt := ui.NewTester(scrollAreaView(&ui.ScrollState{}, ScrollAreaOptions{Label: "Log"}, 2), 300, 300)
tt.Key(0, ui.KeyTab)
if tt.Focused("Log") {
t.Fatal("area without overflow took the focus")
}
}
func TestScrollAreaHorizontal(t *testing.T) {
st := &ui.ScrollState{}
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
ScrollArea(c, st, ScrollAreaOptions{Direction: ScrollHorizontal}, func() {
ui.Box(c).Size(500, 50)
}).Size(200, 60)
}, 300, 300)
tt.Scroll(50, 30, 120, 0)
if st.X != 120 || st.Y != 0 || st.MaxX != 300 {
t.Fatalf("offset %v,%v max %v, want 120,0 max 300", st.X, st.Y, st.MaxX)
}
}
func TestScrollAreaKeepsPlaceWithoutState(t *testing.T) {
tt := ui.NewTester(scrollAreaView(nil, ScrollAreaOptions{Direction: ScrollBoth}, 20), 300, 300)
tt.Scroll(50, 50, 0, 80)
tt.Frame()
tt.Frame()
if r, _ := tt.Find("Row 4"); r.Y != 0 {
t.Fatalf("Row 4 at %v after frames, want 0", r.Y)
}
}
func TestScrollAreaInvalidDirectionPanics(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("invalid direction did not panic")
}
}()
ui.NewTester(scrollAreaView(nil, ScrollAreaOptions{Direction: 7}, 1), 100, 100)
}
func TestScrollAreaSnapshot(t *testing.T) {
snapshot(t, "scroll_area", 300, 160, func(c *ui.Context) {
k := core.Tokens(c)
st := ui.Local(c.Root(), "snap", func() ui.ScrollState { return ui.ScrollState{Y: 30} })
ui.Column(c).Padding(16).Children(func() {
ScrollArea(c, st, ScrollAreaOptions{}, func() {
for i := range 12 {
ui.Text(c, fmt.Sprintf("Verse %d", i+1)).Padding(4, 12)
}
}).Height(128).Background(k.Surface).Border(1, k.Border).Radius(4)
})
})
}
func TestScrollAreaFocusRingKeepsGap(t *testing.T) {
var focus, bg ui.Color
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{Mode: core.Light})
focus, bg = core.Tokens(c).Focus, core.Tokens(c).Background
ScrollArea(c, nil, ScrollAreaOptions{Label: "Log"}, func() { ui.Box(c).Height(400).Shrink(0) }).Size(100, 100)
}, 200, 200)
tt.Key(0, ui.KeyTab)
img := tt.Image()
// The edge's first 2 DIPs are the gap; the ring follows inside it.
if got := img.RGBAAt(50, 1); got != (color.RGBA{bg.R, bg.G, bg.B, 255}) {
t.Fatalf("gap pixel %v, want background %v", got, bg)
}
if got := img.RGBAAt(50, 3); got != (color.RGBA{focus.R, focus.G, focus.B, 255}) {
t.Fatalf("ring pixel %v, want focus %v", got, focus)
}
}
+275
View File
@@ -0,0 +1,275 @@
package layout
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// SplitPaneSide names a pane of a SplitPane.
type SplitPaneSide int
const (
SplitPaneNeither SplitPaneSide = iota
SplitPaneFirst
SplitPaneSecond
)
// SplitPaneState is the app-held layout of a SplitPane.
type SplitPaneState struct {
// Size is the first pane's width, or height when vertical, in DIPs; zero starts at half.
Size float32
// Collapsed hides the Collapsible pane; Size is kept for when it opens again.
Collapsed bool
}
// SplitPaneOptions configure a SplitPane.
type SplitPaneOptions struct {
// Vertical puts the first pane above the second.
Vertical bool
// MinFirst and MinSecond bound the panes in DIPs; zero is 10 spacing units.
MinFirst, MinSecond float32
// Collapsible names the pane that may collapse.
Collapsible SplitPaneSide
}
// SplitPaneResult is a SplitPane and its events in this frame.
type SplitPaneResult struct {
Element *ui.Element
Resized bool
Toggled bool
}
type splitPaneDrag struct {
held bool
// want is where the pointer asks the divider to be, before clamping.
want float32
}
// SplitPane lays two panes side by side with a divider the pointer drags and the arrow keys move.
func SplitPane(c *ui.Context, state *SplitPaneState, opts SplitPaneOptions, first, second func()) SplitPaneResult {
if state == nil || first == nil || second == nil {
panic("mujica: SplitPane needs a state and two panes")
}
if opts.MinFirst < 0 || opts.MinSecond < 0 {
panic("mujica: SplitPane minimums must not be negative")
}
if opts.Collapsible < SplitPaneNeither || opts.Collapsible > SplitPaneSecond {
panic("mujica: invalid SplitPane Collapsible")
}
if state.Collapsed && opts.Collapsible == SplitPaneNeither {
panic("mujica: SplitPane collapsed without a Collapsible pane")
}
u := core.Density(c).Unit()
lo, minSecond := opts.MinFirst, opts.MinSecond
if lo == 0 {
lo = 10 * u
}
if minSecond == 0 {
minSecond = 10 * u
}
e := ui.Row(c).AlignItems(ui.Stretch)
if opts.Vertical {
e.Column()
}
var res SplitPaneResult
e.Children(func() {
// Sizes come from this inner box: the caller's border and padding stay outside it.
in := ui.Row(c).Key("in").Grow(1).MinWidth(0).AlignItems(ui.Stretch)
if opts.Vertical {
in.Column().MinHeight(0)
}
splitPaneBuild(c, in, state, opts, lo, minSecond, &res, first, second)
})
if res.Resized || res.Toggled {
c.Invalidate()
}
res.Element = e
return res
}
// splitPaneBuild lays the panes, divider and drag handle out in the inner box in.
func splitPaneBuild(c *ui.Context, in *ui.Element, state *SplitPaneState, opts SplitPaneOptions, lo, minSecond float32, res *SplitPaneResult, first, second func()) {
k := core.Tokens(c)
u := core.Density(c).Unit()
was := in.Bounds()
internal.Remeasure(c, in, was.W, was.H)
total := was.W
if opts.Vertical {
total = was.H
}
hi := max(lo, total-theme.BorderWidth-minSecond)
clamp := func(v float32) float32 { return max(lo, min(v, hi)) }
if state.Size <= 0 && total > 0 {
state.Size = clamp((total - theme.BorderWidth) / 2)
} else if total > 0 {
state.Size = clamp(state.Size)
}
resize := func(v float32) {
if v = clamp(v); v != state.Size {
state.Size = v
res.Resized = true
}
}
toggle := func() {
state.Collapsed = !state.Collapsed
res.Toggled = true
slog.Debug("mujica: split_pane toggled", "collapsed", state.Collapsed)
}
firstShown := !(state.Collapsed && opts.Collapsible == SplitPaneFirst)
secondShown := !(state.Collapsed && opts.Collapsible == SplitPaneSecond)
along := func(el *ui.Element, v float32) {
if opts.Vertical {
el.Height(v)
} else {
el.Width(v)
}
}
in.Children(func() {
if firstShown {
a := ui.Box(c).Key("first").Shrink(0).Clip()
switch {
case !secondShown:
a.Grow(1).Shrink(1)
case state.Size <= 0 && opts.Vertical:
a.HeightPercent(50)
case state.Size <= 0:
a.WidthPercent(50)
default:
along(a, state.Size)
}
a.Children(first)
}
div := ui.Box(c).Key("divider").Shrink(0).Focusable().FocusRing(false).Role(ui.RoleSplitter).Label(core.Msg(c, "split_pane.divider"))
along(div, theme.BorderWidth)
if state.Collapsed {
div.Description(core.Msg(c, "split_pane.collapsed"))
} else if total > 0 {
div.Description(fmt.Sprintf("%.0f%%", 100*state.Size/total))
}
splitPaneKeys(c, div, state, opts, lo, hi, u, resize, toggle)
if secondShown {
b := ui.Box(c).Key("second").Grow(1).Shrink(1).Clip()
if opts.Vertical {
b.MinHeight(0)
} else {
b.MinWidth(0)
}
b.Children(second)
}
at := state.Size
switch {
case !firstShown:
at = 0
case !secondShown:
at = total - theme.BorderWidth
}
grip := 1.5 * u
handle := ui.Box(c).Key("handle").Absolute()
if opts.Vertical {
handle.Left(0).Right(0).Top(at - (grip-1)/2).Height(grip).Cursor(ui.CursorResizeNS)
} else {
handle.Top(0).Bottom(0).Left(at - (grip-1)/2).Width(grip).Cursor(ui.CursorResizeEW)
}
drag := ui.Local(handle, "split_pane.drag", func() splitPaneDrag { return splitPaneDrag{} })
dx, dy, held := handle.Dragged()
if held {
div.Focus()
if !drag.held {
drag.want = at
}
delta := dx
if opts.Vertical {
delta = dy
}
if delta != 0 {
drag.want += delta
splitPaneFollow(state, opts, drag.want, lo, minSecond, total, resize, toggle)
}
}
drag.held = held
if opts.Collapsible != SplitPaneNeither && handle.DoubleClicked() {
toggle()
}
active := held || handle.Hovered()
div.Draw(func(p *ui.Painter, r ui.Rect) {
line := k.ControlBorder
if active {
line = k.AccentText
}
p.Fill(r, line, 0)
if state.Collapsed {
// A short tab marks the edge a collapsed pane opens from.
if opts.Vertical {
p.Fill(ui.Rect{X: r.X + r.W/2 - 12, Y: r.Y - 1, W: 24, H: 3}, line, 1.5)
} else {
p.Fill(ui.Rect{X: r.X - 1, Y: r.Y + r.H/2 - 12, W: 3, H: 24}, line, 1.5)
}
}
})
handle.Draw(func(p *ui.Painter, r ui.Rect) {
if div.FocusVisible() {
internal.FocusRing(p, r, 0, k.Focus)
}
})
})
}
// splitPaneFollow moves the divider where the pointer wants it; past half its minimum, a collapsible pane closes.
func splitPaneFollow(state *SplitPaneState, opts SplitPaneOptions, want, lo, minSecond, total float32, resize func(float32), toggle func()) {
var past bool
switch opts.Collapsible {
case SplitPaneFirst:
past = want < lo/2
case SplitPaneSecond:
past = total-theme.BorderWidth-want < minSecond/2
}
if past != state.Collapsed {
toggle()
}
if !state.Collapsed {
resize(want)
}
}
// splitPaneKeys moves the divider with the arrows, Home and End, and toggles the collapsible pane with Enter.
func splitPaneKeys(c *ui.Context, div *ui.Element, state *SplitPaneState, opts SplitPaneOptions, lo, hi, u float32, resize func(float32), toggle func()) {
back, forth := ui.KeyLeft, ui.KeyRight
if opts.Vertical {
back, forth = ui.KeyUp, ui.KeyDown
}
step := 4 * u
switch {
case div.Shortcut(0, back):
switch {
case state.Collapsed && opts.Collapsible == SplitPaneSecond:
toggle()
case state.Collapsed:
case opts.Collapsible == SplitPaneFirst && state.Size <= lo:
toggle()
default:
resize(state.Size - step)
}
case div.Shortcut(0, forth):
switch {
case state.Collapsed && opts.Collapsible == SplitPaneFirst:
toggle()
case state.Collapsed:
case opts.Collapsible == SplitPaneSecond && state.Size >= hi:
toggle()
default:
resize(state.Size + step)
}
case div.Shortcut(0, ui.KeyHome) && !state.Collapsed:
resize(lo)
case div.Shortcut(0, ui.KeyEnd) && !state.Collapsed:
resize(hi)
case opts.Collapsible != SplitPaneNeither && div.Shortcut(0, ui.KeyEnter):
toggle()
}
}
+228
View File
@@ -0,0 +1,228 @@
package layout
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
type splitPaneProbe struct {
resized, toggled int
}
func splitPaneTester(st *SplitPaneState, opts SplitPaneOptions, p *splitPaneProbe) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
r := SplitPane(c, st, opts, func() { ui.Text(c, "Files") }, func() { ui.Text(c, "Editor") })
r.Element.Fill()
if r.Resized {
p.resized++
}
if r.Toggled {
p.toggled++
}
}, 401, 200)
}
func TestSplitPaneStartsAtHalf(t *testing.T) {
st := &SplitPaneState{}
tt := splitPaneTester(st, SplitPaneOptions{}, &splitPaneProbe{})
if st.Size != 200 {
t.Fatalf("size %v, want 200", st.Size)
}
if r, _ := tt.Find("Editor"); r.X != 201 {
t.Fatalf("second pane at %v, want 201", r.X)
}
}
func TestSplitPaneKeyboard(t *testing.T) {
st := &SplitPaneState{Size: 150}
p := &splitPaneProbe{}
tt := splitPaneTester(st, SplitPaneOptions{MinFirst: 100, MinSecond: 120}, p)
tt.Key(0, ui.KeyTab)
if !tt.Focused("Divider") {
t.Fatal("Tab did not focus the divider")
}
tt.Key(0, ui.KeyRight)
if st.Size != 166 {
t.Fatalf("Right moved to %v, want 166", st.Size)
}
tt.Key(0, ui.KeyHome)
if st.Size != 100 {
t.Fatalf("Home moved to %v, want the minimum 100", st.Size)
}
tt.Key(0, ui.KeyLeft)
if st.Size != 100 || st.Collapsed {
t.Fatalf("Left past the minimum gave %v collapsed=%v", st.Size, st.Collapsed)
}
tt.Key(0, ui.KeyEnd)
if st.Size != 401-1-120 {
t.Fatalf("End moved to %v, want 280", st.Size)
}
tt.Key(0, ui.KeyEnter)
if st.Collapsed || p.toggled != 0 {
t.Fatal("Enter collapsed a pane that is not collapsible")
}
if p.resized != 3 {
t.Fatalf("resized %d times, want 3", p.resized)
}
}
func TestSplitPaneDragClampsToMinimum(t *testing.T) {
st := &SplitPaneState{Size: 200}
tt := splitPaneTester(st, SplitPaneOptions{MinFirst: 80}, &splitPaneProbe{})
tt.Press(200, 100)
tt.Move(240, 100)
if st.Size != 240 {
t.Fatalf("dragged to %v, want 240", st.Size)
}
tt.Move(10, 100)
tt.Release(10, 100)
if st.Size != 80 || st.Collapsed {
t.Fatalf("dragged past the minimum to %v collapsed=%v, want 80", st.Size, st.Collapsed)
}
if !tt.Focused("Divider") {
t.Fatal("drag did not focus the divider")
}
}
func TestSplitPaneCollapseFirst(t *testing.T) {
st := &SplitPaneState{Size: 200}
p := &splitPaneProbe{}
tt := splitPaneTester(st, SplitPaneOptions{MinFirst: 100, Collapsible: SplitPaneFirst}, p)
// Dragging under half the minimum collapses; back over it opens again.
tt.Press(200, 100)
tt.Move(120, 100)
if st.Collapsed || st.Size != 120 {
t.Fatalf("at 120: size %v collapsed=%v", st.Size, st.Collapsed)
}
tt.Move(40, 100)
if !st.Collapsed || tt.HasText("Files") {
t.Fatalf("under half the minimum: collapsed=%v files shown=%v", st.Collapsed, tt.HasText("Files"))
}
if r, _ := tt.Find("Editor"); r.X != 1 {
t.Fatalf("second pane at %v, want the whole width from 1", r.X)
}
tt.Move(160, 100)
tt.Release(160, 100)
if st.Collapsed || st.Size != 160 {
t.Fatalf("dragged back: size %v collapsed=%v", st.Size, st.Collapsed)
}
// Keyboard: Left at the minimum collapses, Right opens, Enter toggles.
tt.Key(0, ui.KeyHome)
tt.Key(0, ui.KeyLeft)
if !st.Collapsed {
t.Fatal("Left at the minimum did not collapse")
}
tt.Key(0, ui.KeyRight)
if st.Collapsed || st.Size != 100 {
t.Fatalf("Right did not reopen at 100: size %v collapsed=%v", st.Size, st.Collapsed)
}
tt.Key(0, ui.KeyEnter)
if !st.Collapsed {
t.Fatal("Enter did not collapse")
}
tt.Key(0, ui.KeyEnter)
if st.Collapsed || !tt.HasText("Files") {
t.Fatal("Enter did not reopen")
}
if p.toggled != 6 {
t.Fatalf("toggled %d times, want 6", p.toggled)
}
}
func TestSplitPaneCollapseSecondVertical(t *testing.T) {
st := &SplitPaneState{Size: 100}
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
SplitPane(c, st, SplitPaneOptions{Vertical: true, MinSecond: 60, Collapsible: SplitPaneSecond},
func() { ui.Text(c, "Editor") }, func() { ui.Text(c, "Console") }).Element.Fill()
}, 300, 201)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyDown)
if st.Size != 116 {
t.Fatalf("Down moved to %v, want 116", st.Size)
}
tt.Key(0, ui.KeyEnd)
tt.Key(0, ui.KeyDown)
if !st.Collapsed || tt.HasText("Console") {
t.Fatal("Down past the second minimum did not collapse it")
}
tt.Key(0, ui.KeyUp)
if st.Collapsed || st.Size != 140 {
t.Fatalf("Up did not reopen at 140: size %v collapsed=%v", st.Size, st.Collapsed)
}
}
func TestSplitPaneDoubleClickToggles(t *testing.T) {
st := &SplitPaneState{Size: 200}
tt := splitPaneTester(st, SplitPaneOptions{Collapsible: SplitPaneFirst}, &splitPaneProbe{})
tt.ClickAt(200, 100)
tt.ClickAt(200, 100)
if !st.Collapsed || st.Size != 200 {
t.Fatalf("double click: collapsed=%v size %v, want collapsed keeping 200", st.Collapsed, st.Size)
}
}
func TestSplitPaneRejectsCollapsedWithoutSide(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("collapsed state without a Collapsible pane did not panic")
}
}()
splitPaneTester(&SplitPaneState{Collapsed: true}, SplitPaneOptions{}, &splitPaneProbe{})
}
func TestSplitPaneSnapshot(t *testing.T) {
snapshot(t, "split_pane", 420, 160, func(c *ui.Context) {
k := core.Tokens(c)
st := ui.Local(c.Root(), "snap", func() SplitPaneState { return SplitPaneState{Size: 140} })
pane := func(s string, bg ui.Color) func() {
return func() {
ui.Box(c).Fill().Padding(12).Background(bg).Children(func() { ui.Text(c, s) })
}
}
ui.Column(c).Padding(16).Children(func() {
SplitPane(c, st, SplitPaneOptions{}, pane("Files", k.Surface), pane("Editor", k.Background)).
Element.Height(128).Border(1, k.Border).Radius(4).Clip()
})
})
}
func TestSplitPaneSizesInsideBorder(t *testing.T) {
st := &SplitPaneState{Size: 150}
var second ui.Rect
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
SplitPane(c, st, SplitPaneOptions{MinSecond: 120}, func() { ui.Text(c, "Files") }, func() {
second = ui.Box(c).Fill().Absolute().Bounds()
}).Element.Fill().Border(1, ui.RGB(0, 0, 0))
}, 401, 200)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnd)
// 399 DIPs inside the border: 278 + 1 divider + 120.
if st.Size != 278 || second.W != 120 {
t.Fatalf("End gave size %v and second pane %v, want 278 and 120", st.Size, second.W)
}
tt.Press(1+278, 100)
tt.Move(1+200, 100)
tt.Release(1+200, 100)
if st.Size != 200 {
t.Fatalf("drag to x=201 gave %v, want 200", st.Size)
}
}
func TestSplitPaneClampsOnResizeAlone(t *testing.T) {
st := &SplitPaneState{Size: 299}
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
SplitPane(c, st, SplitPaneOptions{}, func() { ui.Text(c, "Files") }, func() { ui.Text(c, "Editor") }).Element.Fill()
}, 400, 200)
tt.SetSize(250, 200)
// 250 less the divider and the 40 DIP second minimum.
if r, _ := tt.Find("Editor"); st.Size != 209 || r.X != 210 {
t.Fatalf("after resize: size %v, second pane at %v; want 209 and 210", st.Size, r.X)
}
}
+63
View File
@@ -0,0 +1,63 @@
package layout
import (
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
// StackAlign places children across a Stack's direction. Zero stretches a vertical stack and centers a horizontal one.
type StackAlign int
const (
StackAlignAuto StackAlign = iota
StackAlignStart
StackAlignCenter
StackAlignEnd
StackAlignStretch
)
// StackOptions configure a Stack.
type StackOptions struct {
// Horizontal lays children left to right; otherwise top to bottom.
Horizontal bool
// Gap defaults to SpaceS.
Gap Space
Align StackAlign
// Justify places children along the direction; zero is ui.Start.
Justify ui.Align
// Wrap moves children of a horizontal stack onto more lines.
Wrap bool
}
// Stack arranges children in a row or column. A child not built takes no room and no gap.
func Stack(c *ui.Context, opts StackOptions, children func()) *ui.Element {
if children == nil {
panic("mujica: Stack needs children")
}
if opts.Wrap && !opts.Horizontal {
panic("mujica: Stack Wrap needs Horizontal")
}
e := ui.Column(c)
if opts.Horizontal {
e.Row()
}
e.Gap(containerSpace(core.Density(c), opts.Gap, SpaceS)).Justify(opts.Justify)
switch opts.Align {
case StackAlignAuto:
case StackAlignStart:
e.AlignItems(ui.Start)
case StackAlignCenter:
e.AlignItems(ui.Center)
case StackAlignEnd:
e.AlignItems(ui.End)
case StackAlignStretch:
e.AlignItems(ui.Stretch)
default:
panic("mujica: invalid StackAlign")
}
if opts.Wrap {
e.Wrap()
}
return e.Children(children)
}
+117
View File
@@ -0,0 +1,117 @@
package layout
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func TestStackVerticalGap(t *testing.T) {
var a, b ui.Rect
ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
Stack(c, StackOptions{}, func() {
a = ui.Box(c).Height(20).Bounds()
b = ui.Box(c).Height(20).Bounds()
})
}, 300, 300).Frame()
if b.Y-(a.Y+a.H) != 8 {
t.Fatalf("gap %v, want 8", b.Y-(a.Y+a.H))
}
if a.W != 300 {
t.Fatalf("vertical stack did not stretch: width %v", a.W)
}
}
func TestStackHiddenItemTakesNoGap(t *testing.T) {
for _, hide := range []bool{false, true} {
var a, last ui.Rect
ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
Stack(c, StackOptions{Horizontal: true, Gap: SpaceL}, func() {
a = ui.Box(c).Size(30, 10).Bounds()
if !hide {
ui.Box(c).Size(30, 10)
}
last = ui.Box(c).Size(30, 10).Bounds()
})
}, 300, 300).Frame()
want := a.X + 30 + 16
if !hide {
want += 30 + 16
}
if last.X != want {
t.Errorf("hidden=%v: last at %v, want %v", hide, last.X, want)
}
}
}
func TestStackAlignEnd(t *testing.T) {
var r ui.Rect
ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
ui.Column(c).Width(200).Children(func() {
Stack(c, StackOptions{Align: StackAlignEnd}, func() {
r = ui.Box(c).Size(50, 10).Bounds()
})
})
}, 300, 300).Frame()
if r.X != 150 {
t.Fatalf("end-aligned child at %v, want 150", r.X)
}
}
func TestStackJustifyAndWrap(t *testing.T) {
var first, third ui.Rect
ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
ui.Column(c).Width(250).Children(func() {
Stack(c, StackOptions{Horizontal: true, Wrap: true, Gap: SpaceNone, Justify: ui.End}, func() {
first = ui.Box(c).Size(100, 20).Bounds()
ui.Box(c).Size(100, 20)
third = ui.Box(c).Size(100, 20).Bounds()
})
})
}, 300, 300).Frame()
if third.Y != first.Y+20 {
t.Fatalf("third at y=%v, want the next line at %v", third.Y, first.Y+20)
}
if first.X != 50 || third.X != 150 {
t.Fatalf("end-justified lines start at %v and %v, want 50 and 150", first.X, third.X)
}
}
func TestStackWrapNeedsHorizontal(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("vertical Wrap did not panic")
}
}()
ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
Stack(c, StackOptions{Wrap: true}, func() {})
}, 100, 100)
}
func TestStackSnapshot(t *testing.T) {
snapshot(t, "stack", 420, 150, func(c *ui.Context) {
k := core.Tokens(c)
chip := func(s string) {
ui.Box(c).Padding(6, 12).Background(k.Surface).Border(1, k.Border).Radius(2).Children(func() { ui.Text(c, s) })
}
ui.Column(c).Padding(16).Gap(16).Children(func() {
Stack(c, StackOptions{Horizontal: true}, func() {
for _, s := range []string{"Overture", "Aria", "Recitative"} {
chip(s)
}
})
Stack(c, StackOptions{Horizontal: true, Justify: ui.SpaceBetween}, func() {
chip("Start")
chip("Between")
chip("End")
})
})
})
}
+291
View File
@@ -0,0 +1,291 @@
package layout
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// StatusItemTone colors a status item's text and icon. The zero value is muted text.
type StatusItemTone int
const (
StatusItemNeutral StatusItemTone = iota
StatusItemSuccess
StatusItemWarning
StatusItemDanger
StatusItemInfo
)
// StatusItem is one entry of a StatusBar.
type StatusItem struct {
// ID is the item's stable key and the value StatusBarResult.Clicked reports.
ID string
Text string
Icon *ui.SVG
Tone StatusItemTone
// ShowProgress draws a short bar filled to Progress, from 0 to 1.
ShowProgress bool
Progress float32
// Action makes the item a button.
Action bool
// Label names an item without Text for assistive technology.
Label string
// Secondary items hide first, last first, when the bar is too narrow.
Secondary bool
}
// StatusBarOptions configure a StatusBar.
type StatusBarOptions struct {
Left, Right []StatusItem
}
// StatusBarResult is a StatusBar and its event in this frame.
type StatusBarResult struct {
Element *ui.Element
// Clicked is the ID of the action item clicked, or empty.
Clicked string
}
const (
statusBarProgressWidth float32 = 64
statusBarIcon float32 = 16
)
// StatusBar is a window's bottom strip with left and right groups of status items.
func StatusBar(c *ui.Context, opts StatusBarOptions) StatusBarResult {
k := core.Tokens(c)
d := core.Density(c)
statusBarCheck(opts)
gap := d.Unit() * 2
e := ui.Row(c).MinHeight(max(theme.MinHitSize, d.ControlHeight()-8)).Shrink(0).PaddingX(d.Unit()*2).Gap(gap).
Background(k.Surface).BorderWidth(theme.BorderWidth, 0, 0, 0).BorderColor(k.Border).
Label(core.Msg(c, "status_bar.label"))
was := e.Bounds()
internal.Remeasure(c, e, was.W, was.H)
hidden := statusBarHide(c, opts, was.W-d.Unit()*4, gap)
open := ui.Local(e, "status_bar.open", func() bool { return false })
*open = *open && len(hidden) > 0
// focused is the bar item that had the focus last frame, for moving it to "+N" when that item folds.
focused := ui.Local(e, "status_bar.focused", func() string { return "" })
wasFocused := *focused
*focused = ""
var res StatusBarResult
item := func(it StatusItem) bool {
clicked, hasFocus := statusBarItem(c, it)
if clicked {
res.Clicked = it.ID
*open = false
slog.Debug("mujica: status_bar clicked", "id", it.ID)
}
return hasFocus
}
group := func(key string, items []StatusItem) {
ui.Row(c).Key(key).Gap(gap).Shrink(0).Dividers(theme.BorderWidth, k.Border).Children(func() {
for _, it := range items {
if !hidden[it.ID] && item(it) {
*focused = it.ID
}
}
})
}
e.Children(func() {
group("status_bar.left", opts.Left)
ui.Spacer(c).Key("status_bar.spacer")
if len(hidden) > 0 {
more := statusBarMore(c, len(hidden))
if wasFocused != "" && hidden[wasFocused] {
more.Focus()
slog.Debug("mujica: status_bar focus folded", "id", wasFocused)
}
if more.Clicked() {
*open = !*open
slog.Debug("mujica: status_bar hidden items", "open", *open)
}
if *open {
ui.PopoverBase(c, more, open, func(panel *ui.Element) {
core.OverlayStyle(c, panel, core.OverlayEnter(c, panel))
panel.Margin(d.Unit(), 0, 0, 0).Padding(d.Unit()).Gap(d.Unit()).AlignItems(ui.Stretch)
for _, it := range statusBarAll(opts) {
if hidden[it.ID] {
item(it)
}
}
})
}
}
group("status_bar.right", opts.Right)
})
res.Element = e
return res
}
// statusBarMore is the "+N" button that opens the hidden items.
func statusBarMore(c *ui.Context, n int) *ui.Element {
k := core.Tokens(c)
b := ui.ButtonBase(c).Key("status_bar.more").MinHeight(theme.MinHitSize).MinWidth(theme.MinHitSize).
PaddingX(core.Density(c).Unit()).Radius(theme.ControlRadius).TextColor(k.Text).
Label(fmt.Sprintf(core.Msg(c, "status_bar.hidden"), n))
internal.Ring(b, k.Focus, theme.ControlRadius)
if b.Pressed() {
b.Background(k.Selection)
} else if b.Hovered() {
b.Background(k.SurfaceHover)
}
return b.Children(func() {
ui.Text(c, fmt.Sprintf("+%d", n)).FontSize(core.FontSize(c, theme.CaptionSize)).SingleLine()
})
}
func statusBarAll(opts StatusBarOptions) []StatusItem {
return append(append([]StatusItem{}, opts.Left...), opts.Right...)
}
func statusBarCheck(opts StatusBarOptions) {
seen := map[string]bool{}
for _, it := range statusBarAll(opts) {
switch {
case it.ID == "":
panic("mujica: StatusItem needs an ID")
case seen[it.ID]:
panic("mujica: duplicate StatusItem ID " + it.ID)
case it.Progress < 0 || it.Progress > 1:
panic("mujica: StatusItem Progress must be within 0 and 1")
case it.Text == "" && it.Label == "":
panic("mujica: StatusItem " + it.ID + " needs Text or Label")
}
seen[it.ID] = true
}
}
// statusBarHide returns the secondary items to hide, last first, so the rest fit in room DIPs.
func statusBarHide(c *ui.Context, opts StatusBarOptions, room, gap float32) map[string]bool {
hidden := map[string]bool{}
if room <= 0 {
// Not laid out yet: show all; Remeasure rebuilds once measured.
return hidden
}
u := core.Density(c).Unit()
group := func(items []StatusItem) float32 {
var w float32
n := 0
for _, it := range items {
if !hidden[it.ID] {
w += statusBarWidth(c, it)
n++
}
}
return w + gap*float32(max(n-1, 0))
}
// The bar's children: left group, spacer, the "+N" button when shown, right group.
need := func() float32 {
w := group(opts.Left) + group(opts.Right) + 2*gap
if len(hidden) > 0 {
tw, _ := c.MeasureText(0, ui.Span{Text: fmt.Sprintf("+%d", len(hidden)), Size: core.FontSize(c, theme.CaptionSize)})
w += max(theme.MinHitSize, tw+2*u) + gap
}
return w
}
all := statusBarAll(opts)
for i := len(all) - 1; i >= 0 && need() > room; i-- {
if all[i].Secondary {
hidden[all[i].ID] = true
}
}
return hidden
}
// statusBarWidth is the width an item takes, as statusBarItem lays it out.
func statusBarWidth(c *ui.Context, it StatusItem) float32 {
u := core.Density(c).Unit()
var parts []float32
if it.Icon != nil {
parts = append(parts, statusBarIcon)
}
if it.Text != "" {
tw, _ := c.MeasureText(0, ui.Span{Text: it.Text, Size: core.FontSize(c, theme.CaptionSize)})
parts = append(parts, tw)
}
if it.ShowProgress {
parts = append(parts, statusBarProgressWidth)
}
w := 2 * u
for i, p := range parts {
w += p
if i > 0 {
w += u
}
}
if it.Action {
w = max(w, theme.MinHitSize)
}
return w
}
// statusBarItem builds one item and reports whether its action was clicked and whether it has the focus.
func statusBarItem(c *ui.Context, it StatusItem) (clicked, focused bool) {
k := core.Tokens(c)
d := core.Density(c)
fg := k.TextMuted
if it.Action {
fg = k.Text
}
switch it.Tone {
case StatusItemNeutral:
case StatusItemSuccess:
fg = k.Success
case StatusItemWarning:
fg = k.Warning
case StatusItemDanger:
fg = k.Danger
case StatusItemInfo:
fg = k.Info
default:
panic("mujica: invalid StatusItemTone")
}
var e *ui.Element
if it.Action {
e = ui.ButtonBase(c).Key(it.ID).MinHeight(theme.MinHitSize).MinWidth(theme.MinHitSize).Radius(theme.ControlRadius)
internal.Ring(e, k.Focus, theme.ControlRadius)
if e.Pressed() {
e.Background(k.Selection)
} else if e.Hovered() {
e.Background(k.SurfaceHover)
}
} else {
e = ui.Row(c).Key(it.ID).MinHeight(theme.MinHitSize)
}
e.PaddingX(d.Unit()).Gap(d.Unit()).TextColor(fg).Shrink(0)
if it.Label != "" {
e.Label(it.Label)
}
e.Children(func() {
if it.Icon != nil {
ui.Icon(c, it.Icon).Size(statusBarIcon, statusBarIcon)
}
if it.Text != "" {
ui.Text(c, it.Text).FontSize(core.FontSize(c, theme.CaptionSize)).SingleLine()
}
if it.ShowProgress {
statusBarProgress(c, it.Progress)
}
})
return it.Action && e.Clicked(), e.FocusWithin()
}
func statusBarProgress(c *ui.Context, v float32) {
k := core.Tokens(c)
ui.Box(c).Size(statusBarProgressWidth, 4).Shrink(0).Role(ui.RoleProgress).
Description(fmt.Sprintf("%.0f%%", v*100)).
Draw(func(p *ui.Painter, r ui.Rect) {
p.Fill(r, k.Border, 2)
if v > 0 {
p.Fill(ui.Rect{X: r.X, Y: r.Y, W: max(r.W*v, r.H), H: r.H}, k.AccentText, 2)
}
})
}
+325
View File
@@ -0,0 +1,325 @@
package layout
import (
"fmt"
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
func statusBarSample() StatusBarOptions {
return StatusBarOptions{
Left: []StatusItem{
{ID: "branch", Text: "main", Icon: icons.Must("folder")},
{ID: "sync", Text: "Syncing", ShowProgress: true, Progress: 0.4},
{ID: "lint", Text: "2 warnings", Tone: StatusItemWarning, Secondary: true},
},
Right: []StatusItem{
{ID: "enc", Text: "UTF-8", Secondary: true},
{ID: "pos", Text: "Ln 12, Col 4", Secondary: true},
{ID: "bell", Label: "Notifications", Icon: icons.Must("bell"), Action: true},
},
}
}
func TestStatusBarActionClick(t *testing.T) {
var clicked []string
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
if r := StatusBar(c, statusBarSample()); r.Clicked != "" {
clicked = append(clicked, r.Clicked)
}
}, 800, 100)
if err := tt.Click("Notifications"); err != nil {
t.Fatal(err)
}
_ = tt.Click("main")
if len(clicked) != 1 || clicked[0] != "bell" {
t.Fatalf("clicked %v, want [bell]", clicked)
}
}
func TestStatusBarActionKeyboard(t *testing.T) {
n := 0
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
if StatusBar(c, statusBarSample()).Clicked == "bell" {
n++
}
}, 800, 100)
tt.Key(0, ui.KeyTab)
if !tt.Focused("Notifications") {
t.Fatal("Tab did not reach the action")
}
tt.Key(0, ui.KeyEnter)
tt.Key(0, ui.KeySpace)
if n != 2 {
t.Fatalf("keyboard activations %d, want 2", n)
}
}
func TestStatusBarCollapsesSecondary(t *testing.T) {
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
StatusBar(c, statusBarSample())
}, 800, 100)
for _, s := range []string{"main", "Syncing", "2 warnings", "UTF-8", "Ln 12, Col 4"} {
if !tt.HasText(s) {
t.Fatalf("wide bar lost %q", s)
}
}
tt.SetSize(330, 100)
for _, s := range []string{"Ln 12, Col 4", "UTF-8", "2 warnings"} {
if tt.HasText(s) {
t.Fatalf("narrow bar still shows secondary %q; texts %q", s, tt.Texts())
}
}
if !tt.HasText("main") || !tt.HasText("Syncing") {
t.Fatalf("narrow bar hid primary items: %q", tt.Texts())
}
if !tt.HasText("3 more items") {
t.Fatalf("no hidden count named: %q", tt.Texts())
}
// Medium width drops only the last secondary items.
tt.SetSize(400, 100)
if !tt.HasText("2 warnings") || tt.HasText("Ln 12, Col 4") {
t.Fatalf("medium bar hid the wrong items: %q", tt.Texts())
}
// At every width the visible items stay inside the bar.
for w := 320; w <= 800; w += 10 {
tt.SetSize(w, 100)
bell, ok := tt.Find("Notifications")
if !ok || bell.X+bell.W > float32(w)-8+0.5 || bell.W < 28 {
t.Fatalf("width %d: action at %+v", w, bell)
}
}
tt.SetSize(800, 100)
if !tt.HasText("UTF-8") {
t.Fatal("secondary items did not come back when wide")
}
}
func TestStatusBarManyActionsKeepMinimumWidth(t *testing.T) {
var o StatusBarOptions
for i := range 16 {
o.Right = append(o.Right, StatusItem{ID: fmt.Sprint(i), Label: fmt.Sprintf("Action %d", i),
Icon: icons.Must("bell"), Action: true, Secondary: i >= 8})
}
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
StatusBar(c, o)
}, 560, 100)
shown := 0
for i := range 16 {
r, ok := tt.Find(fmt.Sprintf("Action %d", i))
if !ok {
continue
}
shown++
if r.W < 28 || r.X+r.W > 560-8+0.5 {
t.Fatalf("Action %d at %+v: narrower than 28 or past the bar", i, r)
}
}
if shown == 16 {
t.Fatal("no secondary action folded")
}
if _, ok := tt.Find(fmt.Sprintf("%d more items", 16-shown)); !ok {
t.Fatalf("no button for the %d hidden actions", 16-shown)
}
}
func TestStatusBarHiddenActionsByKeyboard(t *testing.T) {
var clicked []string
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
if id := StatusBar(c, StatusBarOptions{
Left: []StatusItem{{ID: "main", Text: "A long primary status text that fills the bar"}},
Right: []StatusItem{{ID: "fmt", Text: "Format", Action: true, Secondary: true}},
}).Clicked; id != "" {
clicked = append(clicked, id)
}
}, 300, 300)
if tt.HasText("Format") {
t.Fatal("secondary action not folded")
}
tt.Key(0, ui.KeyTab)
if !tt.Focused("1 more items") {
t.Fatal("Tab did not reach the hidden-items button")
}
tt.Key(0, ui.KeyEnter)
if !tt.HasText("Format") {
t.Fatal("Enter did not open the hidden items")
}
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
if len(clicked) != 1 || clicked[0] != "fmt" {
t.Fatalf("clicked %v, want [fmt]", clicked)
}
if tt.HasText("Format") {
t.Fatal("running a hidden action did not close the list")
}
if !tt.Focused("1 more items") {
t.Fatal("focus did not return to the hidden-items button")
}
tt.Key(0, ui.KeyEnter)
tt.Key(0, ui.KeyEscape)
if tt.HasText("Format") || !tt.Focused("1 more items") {
t.Fatal("Escape did not close the list back to its button")
}
}
func TestStatusBarIconSize(t *testing.T) {
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
StatusBar(c, StatusBarOptions{Left: []StatusItem{{ID: "i", Label: "Online", Icon: icons.Must("wifi-off")}}})
}, 400, 100)
// Padding of one unit each side around a 16 DIP icon.
if r, _ := tt.Find("Online"); r.W != 24 {
t.Fatalf("icon item width %v, want 24", r.W)
}
}
func TestStatusBarTextScale(t *testing.T) {
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
StatusBar(c, StatusBarOptions{Left: []StatusItem{{ID: "r", Text: "Ready"}}})
}, 400, 200)
plain, _ := tt.Find("Ready")
tt.SetPreferences(ui.Preferences{TextScale: 2})
tt.Frame()
big, _ := tt.Find("Ready")
bar, _ := tt.Find("Status bar")
if big.H < 1.8*plain.H {
t.Fatalf("text height %v at 200%%, was %v", big.H, plain.H)
}
if bar.H < big.H {
t.Fatalf("bar %v shorter than its text %v", bar.H, big.H)
}
}
func TestStatusBarRejectsBadItems(t *testing.T) {
for _, o := range []StatusBarOptions{
{Left: []StatusItem{{Text: "no id"}}},
{Left: []StatusItem{{ID: "a", Text: "a"}}, Right: []StatusItem{{ID: "a", Text: "b"}}},
{Left: []StatusItem{{ID: "p", Text: "p", ShowProgress: true, Progress: 1.5}}},
{Left: []StatusItem{{ID: "n", Icon: icons.Must("bell")}}},
} {
func() {
defer func() {
if recover() == nil {
t.Errorf("%+v did not panic", o)
}
}()
ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
StatusBar(c, o)
}, 400, 100)
}()
}
}
func TestStatusBarSnapshot(t *testing.T) {
snapshot(t, "status_bar", 560, 60, func(c *ui.Context) {
ui.Column(c).Fill().Justify(ui.End).Children(func() {
o := statusBarSample()
o.Left = append(o.Left, StatusItem{ID: "ok", Text: "Saved", Tone: StatusItemSuccess, Icon: icons.Must("check")})
StatusBar(c, o)
})
})
}
func TestStatusBarFoldsOnResizeAlone(t *testing.T) {
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
StatusBar(c, statusBarSample())
}, 400, 100)
tt.SetSize(160, 100)
for _, s := range []string{"2 warnings", "UTF-8", "Ln 12, Col 4"} {
if tt.HasText(s) {
t.Fatalf("160 DIP bar still shows %q", s)
}
}
if !tt.HasText("3 more items") {
t.Fatalf("no hidden count: %q", tt.Texts())
}
}
func TestStatusBarKeepsFocusAcrossDensity(t *testing.T) {
density := theme.Default
n := 0
// At 155 DIP the secondary item fits at Default and folds into "+1" at Comfortable.
o := StatusBarOptions{
Left: []StatusItem{{ID: "main", Text: "main"}, {ID: "enc", Text: "UTF-8", Secondary: true}},
Right: []StatusItem{{ID: "bell", Label: "Notifications", Icon: icons.Must("bell"), Action: true}},
}
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{Density: density})
if StatusBar(c, o).Clicked == "bell" {
n++
}
}, 155, 100)
if !tt.HasText("UTF-8") {
t.Fatal("secondary item folded at Default density")
}
for range 4 {
if tt.Focused("Notifications") {
break
}
tt.Key(0, ui.KeyTab)
}
if !tt.Focused("Notifications") {
t.Fatal("Tab never reached Notifications")
}
density = theme.Comfortable
tt.Frame()
if tt.HasText("UTF-8") || !tt.HasText("1 more items") {
t.Fatalf("Comfortable did not fold the secondary item: %q", tt.Texts())
}
if !tt.Focused("Notifications") {
t.Fatal("density switch lost the focus")
}
tt.Key(0, ui.KeyEnter)
if n != 1 {
t.Fatalf("Enter after the switch activated %d times, want 1", n)
}
}
func TestStatusBarFocusMovesToMoreWhenFocusedItemFolds(t *testing.T) {
density := theme.Default
var clicked []string
o := StatusBarOptions{
Left: []StatusItem{{ID: "main", Text: "main"}, {ID: "fmt", Text: "Format", Action: true, Secondary: true}},
Right: []StatusItem{{ID: "bell", Label: "Notifications", Icon: icons.Must("bell"), Action: true}},
}
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{Density: density})
if id := StatusBar(c, o).Clicked; id != "" {
clicked = append(clicked, id)
}
}, 152, 300)
tt.Key(0, ui.KeyTab)
if !tt.Focused("Format") {
t.Fatal("Tab did not focus Format at Default density")
}
density = theme.Comfortable
tt.Frame()
if tt.HasText("Format") {
t.Fatal("Format did not fold at Comfortable density")
}
if !tt.Focused("1 more items") {
t.Fatal("focus did not move to the +N button that holds Format")
}
tt.Key(0, ui.KeyEnter)
if !tt.HasText("Format") {
t.Fatal("Enter on +N did not open the list with Format")
}
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
if len(clicked) != 1 || clicked[0] != "fmt" {
t.Fatalf("clicked %v, want [fmt]", clicked)
}
}
+38
View File
@@ -0,0 +1,38 @@
package layout
import (
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// TitleBarOptions configure a TitleBar.
type TitleBarOptions struct {
// Leading holds tools before the title, such as a sidebar toggle.
Leading func()
// Trailing holds tools after the title.
Trailing func()
}
// TitleBar is a window title row that drags the window and clears the system buttons.
func TitleBar(c *ui.Context, title string, opts TitleBarOptions) *ui.Element {
k := core.Tokens(c)
d := core.Density(c)
bar := c.TitleBar()
pad := d.Unit() * 3
e := ui.Row(c).MinHeight(max(bar.Height, d.ControlHeight()+d.Unit()*2)).Shrink(0).
Padding(0, bar.Right+pad, 0, bar.Left+pad).Gap(d.Unit()*2).
Background(k.Surface).BorderWidth(0, 0, theme.BorderWidth, 0).BorderColor(k.Border).
DragWindow()
return e.Children(func() {
if opts.Leading != nil {
ui.Row(c).Gap(d.Unit()).Shrink(0).Children(opts.Leading)
}
ui.Text(c, title).Font(theme.SerifFont).FontSize(core.FontSize(c, theme.BodySize+1)).FontWeight(600).
SingleLine().Grow(1).Shrink(1).MinWidth(0)
if opts.Trailing != nil {
ui.Row(c).Gap(d.Unit()).Shrink(0).Children(opts.Trailing)
}
})
}
+103
View File
@@ -0,0 +1,103 @@
package layout
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/input"
)
func TestTitleBarDefaultHeight(t *testing.T) {
var r ui.Rect
ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
r = TitleBar(c, "Archive", TitleBarOptions{}).Bounds()
}, 400, 300).Frame()
if r.H != 44 || r.W != 400 {
t.Fatalf("bar %vx%v, want 400x44", r.W, r.H)
}
}
func TestTitleBarClearsWindowControls(t *testing.T) {
var r ui.Rect
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
r = TitleBar(c, "Archive", TitleBarOptions{
Trailing: func() { input.Button(c, "Share", input.ButtonOptions{Variant: input.Ghost}) },
}).Bounds()
}, 400, 300)
tt.SetTitleBar(ui.TitleBar{Height: 52, Left: 78, Right: 40})
tt.Frame()
if r.H != 52 {
t.Fatalf("bar height %v, want the controls' 52", r.H)
}
title, _ := tt.Find("Archive")
if title.X != 78+12 {
t.Fatalf("title at %v, want 90 past the controls", title.X)
}
share, _ := tt.Find("Share")
if share.X+share.W > 400-40-12+0.5 {
t.Fatalf("trailing tool ends at %v, inside the right controls", share.X+share.W)
}
}
func TestTitleBarSlotsStayClickable(t *testing.T) {
n := 0
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
TitleBar(c, "Archive", TitleBarOptions{
Leading: func() {
if input.Button(c, "Menu", input.ButtonOptions{Variant: input.Ghost}).Clicked() {
n++
}
},
})
}, 400, 300)
if err := tt.Click("Menu"); err != nil {
t.Fatal(err)
}
if n != 1 {
t.Fatalf("leading tool clicked %d times, want 1", n)
}
}
func TestTitleBarLongTitleKeepsTools(t *testing.T) {
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
TitleBar(c, "A very long window title that cannot possibly fit in the bar", TitleBarOptions{
Trailing: func() { input.Button(c, "Share", input.ButtonOptions{Variant: input.Ghost}) },
})
}, 240, 100)
share, ok := tt.Find("Share")
if !ok || share.X+share.W > 240-12+0.5 {
t.Fatalf("tool pushed out: %+v", share)
}
}
func TestTitleBarSnapshot(t *testing.T) {
snapshot(t, "title_bar", 420, 60, func(c *ui.Context) {
TitleBar(c, "Chronicle of the Court", TitleBarOptions{
Trailing: func() { input.Button(c, "Share", input.ButtonOptions{Variant: input.Ghost}) },
})
})
}
func TestTitleBarTextScale(t *testing.T) {
var bar ui.Rect
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
bar = TitleBar(c, "Archive", TitleBarOptions{}).Bounds()
}, 400, 300)
plain, _ := tt.Find("Archive")
tt.SetPreferences(ui.Preferences{TextScale: 2})
tt.Frame()
big, _ := tt.Find("Archive")
if big.H < 1.8*plain.H {
t.Fatalf("title height %v at 200%%, was %v", big.H, plain.H)
}
if bar.H < big.H {
t.Fatalf("bar %v shorter than its title %v", bar.H, big.H)
}
}