Layout: components 001-010

This commit is contained in:
ZacharyZhang-NY committed 2026-10-05 12:18:49 -04:00
1 parent 3be3cc1730
commit fe806037c7
53 files changed
+3399 -1

No files matched your search

+120
View File
@@ -0,0 +1,120 @@
package mujica
import (
"log/slog"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"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 {
*ui.Element
// Toggled reports the sidebar opened or collapsed.
Toggled bool
}
// AppShell lays out a window: title bar, sidebar, content and status bar.
// *collapsed folds the sidebar to a rail; its toggle button and Cmd/Ctrl+B flip it.
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 := 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 := Tokens(c)
u := 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, 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 := msg(c, "app_shell.collapse")
if collapsed {
label = msg(c, "app_shell.expand")
}
b := ui.ButtonBase(c).Size(theme.MinHitSize, theme.MinHitSize).Radius(theme.ControlRadius).
Label(label).Tooltip(label).TextColor(k.TextMuted)
internal.Ring(b, k.Focus, theme.ControlRadius)
if b.Pressed() {
b.Background(k.Selection).TextColor(k.Text)
} else if b.Hovered() {
b.Background(k.SurfaceHover).TextColor(k.Text)
}
b.Children(func() { ui.Icon(c, icons.Must("panel-left")).Size(16, 16) })
pressed = b.Clicked()
})
if !collapsed {
ScrollArea(c, nil, ScrollAreaOptions{}, opts.Sidebar).Grow(1).MinHeight(0)
}
})
return pressed
}
+149
View File
@@ -0,0 +1,149 @@
package mujica
import (
"fmt"
"testing"
"github.com/egoist/mygo/ui"
)
type appShellProbe struct {
collapsed bool
toggled int
content ui.ScrollState
side ui.Rect
}
func (p *appShellProbe) view(c *ui.Context) {
Use(c, 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) {
Use(c, 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 mujica
import "github.com/egoist/mygo/ui"
// AspectRatioOptions configure an AspectRatio.
type AspectRatioOptions struct {
// Ratio is width over height; zero is 1.
Ratio float32
}
// AspectRatio fills its parent's box and centers a frame of Ratio inside, as large as fits.
// The child is centered in the frame without stretching and clipped to it.
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()
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: lay out once, then size from the room.
frame.WidthPercent(100)
if first := ui.Local(outer, "aspect_ratio.first", func() bool { return true }); *first {
*first = false
c.Invalidate()
}
} 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 mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
// 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) {
Use(c, 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) {
Use(c, 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)
tt.Frame()
if box.W != 400 || box.H != 200 {
t.Fatalf("tall window: %vx%v, want 400x200", box.W, box.H)
}
tt.SetSize(300, 400)
tt.Frame()
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) {
Use(c, 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 := 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"
}
+85
View File
@@ -0,0 +1,85 @@
package mujica
import (
"github.com/egoist/mygo/ui"
"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(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)
}
+97
View File
@@ -0,0 +1,97 @@
package mujica
import (
"image/color"
"testing"
"github.com/egoist/mygo/ui"
"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) {
Use(c, 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) {
Use(c, 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) {
Use(c, 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) {
Use(c, Settings{Mode: Light})
bg = 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) {
Use(c, 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 := 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)
})
})
}
+65
View File
@@ -0,0 +1,65 @@
package mujica
import (
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"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 := 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)
}
e := ui.Row(c).Gap(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(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()
})
}
+107
View File
@@ -0,0 +1,107 @@
package mujica
import (
"image/color"
"testing"
"github.com/egoist/mygo/ui"
)
func TestDividerSizes(t *testing.T) {
var h, v ui.Rect
ui.NewTester(func(c *ui.Context) {
Use(c, 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) {
Use(c, Settings{})
ui.Column(c).Children(func() {
Divider(c, DividerOptions{Label: "Section", Ornament: true})
Divider(c, DividerOptions{Ornament: true})
Button(c, "OK", 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) {
Use(c, 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) {
Use(c, Settings{Mode: Light})
want = 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) {
Use(c, 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")
})
})
})
}
+53
View File
@@ -0,0 +1,53 @@
# 001 AppShell 应用外壳
## 用途
搭建桌面窗口的整体骨架:顶部标题栏、左侧侧栏、主内容区和底部状态栏四个插槽。侧栏可折叠为只含切换按钮的窄条;侧栏与主内容各自独立滚动。
## 最小示例
```go
var collapsed bool
mujica.AppShell(c, &collapsed, mujica.AppShellOptions{
TitleBar: func() { mujica.TitleBar(c, "Court Records", mujica.TitleBarOptions{}) },
Sidebar: func() { ui.Text(c, "Chronicles") },
Content: func() { ui.Text(c, "The Grand Hall") },
})
```
签名:`func AppShell(c *ui.Context, collapsed *bool, opts AppShellOptions) AppShellResult`
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `collapsed` | `*bool` | 必填 | 侧栏是否折叠,由应用持有;为 nil 时 panic。 |
| `TitleBar` | `func()` | nil | 顶部插槽,通常放 `TitleBar`。 |
| `Sidebar` | `func()` | nil | 侧栏内容,自带独立滚动;为 nil 时没有侧栏与折叠快捷键。 |
| `Content` | `func()` | 必填 | 主内容,自带独立滚动;为 nil 时 panic。 |
| `StatusBar` | `func()` | nil | 底部插槽,通常放 `StatusBar`。 |
| `SidebarWidth` | `float32` | 240 | 展开时侧栏宽度(DIP);负数 panic。 |
| `ContentScroll` | `*ui.ScrollState` | nil | 保存主内容滚动位置;nil 时按位置保留。 |
返回 `AppShellResult`,内嵌 `*ui.Element`。
## 状态
- 展开:侧栏宽 `SidebarWidth`,顶部右侧为收起按钮。
- 折叠:侧栏收为 `28 + 4 单位` 的窄条,只保留展开按钮。宽度变化 120 ms,减少动画时直接切换。
- 切换按钮有悬停、按下和键盘焦点环;折叠不会重置主内容滚动位置。
## 事件
- `Toggled`:本帧侧栏被展开或折叠(按钮或快捷键),只触发一次。
## 键盘操作
- `Cmd+B`(macOS)/ `Ctrl+B`(其他平台):切换侧栏。焦点控件先处理该键,富文本等若占用此键则不会触发。
- Tab 依次到达切换按钮、侧栏与内容中的控件;滚动区域内容溢出时可获得焦点,方向键、Page Up/Down、Home、End 滚动。
## 限制
- 窄窗口不会自动折叠,由应用根据窗口宽度设置 `*collapsed`。
- 折叠动画期间侧栏内容立即隐藏,只有宽度过渡。
- 快捷键固定为 Cmd/Ctrl+B,不可配置。
+43
View File
@@ -0,0 +1,43 @@
# 002 TitleBar 标题栏
## 用途
窗口顶部的标题行:衬线体应用标题、前后工具插槽。空白处可拖动窗口,并为系统窗口按钮留出空间。配合 `mygo.TitleBarHidden` 使用。
## 最小示例
```go
mujica.TitleBar(c, "Court Records", mujica.TitleBarOptions{
Trailing: func() { mujica.Button(c, "Share", mujica.ButtonOptions{Variant: mujica.Ghost}) },
})
```
签名:`func TitleBar(c *ui.Context, title string, opts TitleBarOptions) *ui.Element`
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `title` | `string` | 必填 | 标题文字,过长时单行省略。 |
| `Leading` | `func()` | nil | 标题前的工具,如侧栏开关。 |
| `Trailing` | `func()` | nil | 标题后的工具。 |
## 状态
- 高度取 `c.TitleBar().Height` 与 `控件高度 + 2 单位` 中较大者(默认密度 44 DIP)。
- 左右内边距在 `c.TitleBar().Left/Right` 之外再加 3 单位,不覆盖系统按钮;全屏时预留为 0。
- Surface 底色,底部 1 DIP Border 分隔线。
## 事件
TitleBar 本身无事件;插槽中控件照常报告 `Clicked` 等事件。
## 键盘操作
插槽控件照常接受 Tab、Enter、Space。标题栏本身不获取焦点。
## 限制
- 拖动、双击缩放由 MyGo `DragWindow` 实现,只作用于非交互区域;需真机验证,无窗口测试无法覆盖。
- 系统窗口按钮外观由系统决定,不换肤。
- 普通有标题栏的窗口中 `c.TitleBar()` 为零,组件只是一行普通标题。
+63
View File
@@ -0,0 +1,63 @@
# 003 StatusBar 状态栏
## 用途
窗口底部的状态条,分左右两组条目:状态文字、图标、进度与可点击操作。宽度不足时自动隐藏次要条目,并显示剩余数量。
## 最小示例
```go
bar := mujica.StatusBar(c, mujica.StatusBarOptions{
Left: []mujica.StatusItem{{ID: "sync", Text: "Syncing", ShowProgress: true, Progress: 0.4}},
Right: []mujica.StatusItem{{ID: "bell", Label: "Notifications", Icon: icons.Must("bell"), Action: true}},
})
if bar.Clicked == "bell" {
opened++
}
```
签名:`func StatusBar(c *ui.Context, opts StatusBarOptions) StatusBarResult`
## 参数
`StatusBarOptions`
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Left` | `[]StatusItem` | nil | 左侧条目,从左到右。 |
| `Right` | `[]StatusItem` | nil | 右侧条目,从左到右。 |
`StatusItem`
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `ID` | `string` | 必填 | 稳定键,也是 `Clicked` 的值;为空或重复 panic。 |
| `Text` | `string` | "" | 状态文字(12 DIP)。 |
| `Icon` | `*ui.SVG` | nil | 前置图标(14 DIP)。 |
| `Tone` | `StatusItemTone` | `StatusItemNeutral` | 语义色:Neutral、Success、Warning、Danger、Info。 |
| `ShowProgress` | `bool` | false | 显示 64 DIP 进度条。 |
| `Progress` | `float32` | 0 | 进度 0–1,超出 panic。 |
| `Action` | `bool` | false | 条目成为按钮。 |
| `Label` | `string` | "" | 无文字条目的无障碍名称;`Text` 与 `Label` 都为空时 panic。 |
| `Secondary` | `bool` | false | 空间不足时优先隐藏。 |
## 状态
- 默认:Surface 底色,顶部 Border 线,组内条目以细线分隔。
- 操作条目:悬停 SurfaceHover,按下 Selection,键盘焦点环。
- 折叠:按“右组末项 → 左组首项”的顺序隐藏 `Secondary` 条目,直到放得下;显示 `+N`,悬停提示列出被隐藏条目,无障碍名称为“另有 N 项”。主要条目永不隐藏。
- 进度:AccentText 填充,Border 轨道,描述为百分比。
## 事件
- `Clicked`:本帧被点击的操作条目 ID,没有则为空。一次点击只报告一次。
## 键盘操作
Tab 到达操作条目,Enter 或 Space 触发。纯文字条目不进入 Tab 序列。
## 限制
- 折叠依据上一帧的宽度和文字测量估算,窗口缩放后下一帧生效。
- 被隐藏条目只在 `+N` 的提示中列出,提示仅悬停可见,键盘用户通过无障碍名称得知数量。
- 不提供不确定进度动画;需要时在 `Text` 中说明。
+45
View File
@@ -0,0 +1,45 @@
# 004 Container 容器
## 用途
为一组内容提供内边距、最大宽度、水平居中、填充父级和裁剪,适合正文、设置页等需要可读行宽的区域。
## 最小示例
```go
mujica.Container(c, mujica.ContainerOptions{MaxWidth: 320, Center: true}, func() {
ui.Text(c, "A centered column of prose.")
})
```
签名:`func Container(c *ui.Context, opts ContainerOptions, children func()) *ui.Element`
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Padding` | `Space` | `SpaceL`(4 单位,默认密度 16 DIP) | 四周内边距,随密度缩放。 |
| `MaxWidth` | `float32` | 0(不限) | 最大宽度 DIP;负数 panic。 |
| `Center` | `bool` | false | 占满父级宽度后受 `MaxWidth` 限制并水平居中。 |
| `Fill` | `bool` | false | 占满父级内容区(宽和高)。 |
| `Clip` | `bool` | false | 裁剪超出的内容。 |
| `children` | `func()` | 必填 | 子内容,纵向排列;nil 时 panic。 |
`Space` 取值:`SpaceDefault`(组件默认)、`SpaceNone`、`SpaceXS`(1 单位)、`SpaceS`(2)、`SpaceM`(3)、`SpaceL`(4)、`SpaceXL`(6)、`SpaceXXL`(8)。单位为 Compact 3、Default 4、Comfortable 5 DIP。非法值 panic。
## 状态
布局组件,无交互状态。不绘制背景,可在返回的元素上调用 `Background`、`Border`。
## 事件
无。
## 键盘操作
不获取焦点。
## 限制
- 只做水平居中;垂直居中请用 `Stack` 的对齐或 MyGo `Center()`。
- 子内容固定纵向排列,需要横向时在内部放 `Stack`。
+49
View File
@@ -0,0 +1,49 @@
# 005 Stack 堆叠
## 用途
按横向或纵向排列子元素,统一间距、对齐与换行。用 `StackItem` 包裹可隐藏的子项,隐藏时不占位置也不留间距。
## 最小示例
```go
mujica.Stack(c, mujica.StackOptions{Horizontal: true, Gap: mujica.SpaceM}, func() {
mujica.Button(c, "Save", mujica.ButtonOptions{})
mujica.StackItem(!showDraft, func() { mujica.Button(c, "Draft", mujica.ButtonOptions{Variant: mujica.Secondary}) })
mujica.Button(c, "Cancel", mujica.ButtonOptions{Variant: mujica.Ghost})
})
```
签名:
- `func Stack(c *ui.Context, opts StackOptions, children func()) *ui.Element`
- `func StackItem(hidden bool, build func())`
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Horizontal` | `bool` | false | 横向排列;否则纵向。 |
| `Gap` | `Space` | `SpaceS`(2 单位) | 子项间距,随密度缩放。 |
| `Align` | `StackAlign` | `StackAlignAuto` | 交叉轴对齐:Auto(纵向拉伸、横向居中)、Start、Center、End、Stretch;非法值 panic。 |
| `Justify` | `ui.Align` | `ui.Start` | 主轴分布:Start、Center、End、SpaceBetween、SpaceAround、SpaceEvenly。 |
| `Wrap` | `bool` | false | 横向换行;纵向时设置会 panic。 |
`StackItem`:`hidden` 为 true 时不构建 `build`,因此既不占空间也不产生间距。`build` 为 nil 时 panic。
## 状态
布局组件,无交互状态。
## 事件
无。
## 键盘操作
不获取焦点;Tab 顺序即子项构建顺序。
## 限制
- 隐藏项不会被构建,其内部界面状态(如未提交的输入)随之丢弃;需要保留时把状态放在应用中。
- 只有横向支持换行。
+57
View File
@@ -0,0 +1,57 @@
# 006 Grid 网格
## 用途
按行列排布子元素:列宽可为固定尺寸、按内容或按比例分配剩余空间;子项可指定位置与跨行跨列。基于 MyGo 网格布局。
## 最小示例
```go
mujica.Grid(c, mujica.GridOptions{Columns: []ui.Track{ui.Fixed(96), ui.Fr(1)}}, func() {
mujica.GridCell(c, mujica.GridCellOptions{ColumnSpan: -1}, func() { ui.Text(c, "Header") })
ui.Text(c, "Name")
ui.Text(c, "Ada")
})
```
签名:
- `func Grid(c *ui.Context, opts GridOptions, children func()) *ui.Element`
- `func GridCell(c *ui.Context, opts GridCellOptions, build func()) *ui.Element`
## 参数
`GridOptions`
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Columns` | `[]ui.Track` | 一列 `Fr(1)` | 各列尺寸:`ui.Fixed(dip)`、`ui.Fr(n)`(分配剩余空间)、`ui.FitContent()`。 |
| `Rows` | `[]ui.Track` | nil | 前几行尺寸;之后自动添加的行按内容高度。 |
| `Gap` | `Space` | `SpaceM`(3 单位) | 列间距,也是默认行间距。 |
| `RowGap` | `Space` | 同 `Gap` | 行间距。 |
`GridCellOptions`
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Column`, `Row` | `int` | 0(自动放置) | 起始列、行,从 1 计;负数 panic。 |
| `ColumnSpan`, `RowSpan` | `int` | 1 | 跨越轨道数;-1 跨到最后一条;小于 -1 panic。 |
`GridCell` 内的单个子元素会拉伸铺满整个单元格,与网格直接子项一致。
## 状态
布局组件,无交互状态。
## 事件
无。
## 键盘操作
不获取焦点;Tab 顺序为构建顺序。
## 限制
- 网格会构建全部子项;大量条目请使用虚拟化列表。
- 不校验跨度是否超出列数,超出时由 MyGo 按其规则扩展。
+49
View File
@@ -0,0 +1,49 @@
# 007 ScrollArea 滚动区域
## 用途
纵向、横向或双向滚动内容。支持滚轮、触控板、滚动条拖动与键盘;可读取、设置并保留滚动位置。基于 MyGo `ui.Scroll`。
## 最小示例
```go
var offset ui.ScrollState
mujica.ScrollArea(c, &offset, mujica.ScrollAreaOptions{Label: "Log"}, func() {
for range 50 {
ui.Text(c, "A line of the log")
}
}).Height(200)
```
签名:`func ScrollArea(c *ui.Context, state *ui.ScrollState, opts ScrollAreaOptions, children func()) *ui.Element`
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `state` | `*ui.ScrollState` | nil | 滚动位置。设置 `X`/`Y` 即滚动到该处,`math.MaxFloat32` 表示末尾;组件写回 `MaxX`/`MaxY`。nil 时按区域在视图中的位置保留。 |
| `Direction` | `ScrollDirection` | `ScrollVertical` | `ScrollVertical`、`ScrollHorizontal`、`ScrollBoth`;非法值 panic。 |
| `Label` | `string` | "" | 区域的无障碍名称。 |
| `children` | `func()` | 必填 | 内容;nil 时 panic。 |
需给区域尺寸(`Height`、`Grow` 等)。
## 状态
- 内容溢出时区域可获得焦点,键盘焦点环画在区域内侧(2 DIP,Focus 色),避免被父级裁剪。
- 不溢出时不进入 Tab 序列。
- 滚动条为 MyGo 叠加式滑块,颜色取主题 Scrollbar(TextMuted 50%),指针悬停时显示,不占布局空间。
- 切换主题、折叠周围布局都不重置位置。
## 事件
无独立事件;比较 `state.X`/`state.Y` 的变化即可得知滚动。
## 键盘操作
焦点在区域或其内容上时:方向键滚动 40 DIP,Page Up/Down 与 Space/Shift+Space 翻页,Home/End 到顶部/底部(macOS 另有 Cmd+↑/↓)。文本输入等自行处理这些键的控件优先。
## 限制
- 滚动条只在悬停时显示,不提供常驻滚动条(MyGo 滚动条不占空间的设计)。
- 大量行请使用虚拟化列表,ScrollArea 会构建全部子项。
+64
View File
@@ -0,0 +1,64 @@
# 008 SplitPane 分栏
## 用途
将区域分为左右(或上下)两栏,中间分隔条可拖动、可用键盘调整;两栏有各自最小尺寸,可指定一栏可折叠。
## 最小示例
```go
split := mujica.SplitPaneState{Size: 160}
mujica.SplitPane(c, &split, mujica.SplitPaneOptions{MinFirst: 120, Collapsible: mujica.SplitPaneFirst},
func() { ui.Text(c, "Files") },
func() { ui.Text(c, "Editor") },
).Fill()
```
签名:`func SplitPane(c *ui.Context, state *SplitPaneState, opts SplitPaneOptions, first, second func()) SplitPaneResult`
## 参数
`SplitPaneState`(应用持有,nil 时 panic)
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Size` | `float32` | 0(首次布局后取一半) | 第一栏宽度(纵向时为高度),DIP。每帧被限制在最小尺寸之间。 |
| `Collapsed` | `bool` | false | 可折叠栏是否折叠;折叠时保留 `Size` 以便恢复。未设置 `Collapsible` 却为 true 时 panic。 |
`SplitPaneOptions`
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Vertical` | `bool` | false | 第一栏在上。 |
| `MinFirst`, `MinSecond` | `float32` | 10 单位(默认 40 DIP) | 两栏最小尺寸;负数 panic。 |
| `Collapsible` | `SplitPaneSide` | `SplitPaneNeither` | 可折叠的栏:`SplitPaneFirst` 或 `SplitPaneSecond`。 |
返回 `SplitPaneResult`,内嵌 `*ui.Element`。
## 状态
- 分隔条为 1 DIP ControlBorder 线,命中区域 1.5 单位宽;悬停或拖动时变为 AccentText;键盘焦点时在命中区域外画焦点环。
- 折叠时该栏不构建,分隔条贴边并显示一段短标记,提示可从此处拉开。
- 无障碍:角色为 splitter,名称“分隔条”,描述为第一栏所占百分比或“已折叠”。
## 事件
- `Resized`:本帧尺寸改变(拖动或键盘)。
- `Toggled`:本帧折叠或展开。
## 键盘操作
Tab 聚焦分隔条后:
- ←/→(纵向为 ↑/↓):移动 4 单位,到达最小尺寸后停止。
- 可折叠栏已在最小尺寸时继续朝它按方向键:折叠;折叠后按反方向键:展开。
- Home / End:移到最小 / 最大位置。
- Enter:切换可折叠栏(未设置 `Collapsible` 时不占用 Enter)。
指针:拖动分隔条;拖到可折叠栏最小尺寸的一半以内即折叠,拖回超过一半即展开;双击切换折叠。
## 限制
- 未直接使用 MyGo `ui.Split`:它的最小栏宽固定为 10 个间距单位(`ui/split.go:20`),没有折叠能力,且分隔条元素不对外暴露,无法挂接 Enter、Home、End。本组件用 MyGo 公开的 `Focusable`、`Dragged`、`Shortcut`、`RoleSplitter` 重新组合,行为与 MyGo 分隔条一致并补齐上述功能。
- MyGo 没有公开的无障碍数值范围接口,当前位置通过描述文字提供。
- 键盘或拖动产生的尺寸在下一帧生效(组件会立即请求下一帧)。
+41
View File
@@ -0,0 +1,41 @@
# 009 AspectRatio 宽高比
## 用途
在父容器的宽高约束内放置一个保持指定宽高比的框,尽可能大并居中;子内容在框内居中,不被拉伸,超出部分裁剪。适合预览、视频占位、封面图。
## 最小示例
```go
ui.Box(c).Size(320, 240).Children(func() {
mujica.AspectRatio(c, mujica.AspectRatioOptions{Ratio: 16.0 / 9}, func() {
ui.Text(c, "16 : 9")
})
})
```
签名:`func AspectRatio(c *ui.Context, opts AspectRatioOptions, child func()) *ui.Element`
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Ratio` | `float32` | 1 | 宽 / 高;负数 panic。 |
| `child` | `func()` | 必填 | 框内内容;nil 时 panic。需要铺满框时让子元素 `Fill()`。 |
## 状态
布局组件,无交互状态。返回的元素占满父级内容区,比例框位于其中央。
## 事件
无。
## 键盘操作
不获取焦点。
## 限制
- 父级高度不确定(随内容增长)时只按宽度计算。
- 框尺寸依据上一帧的父级尺寸计算,窗口缩放后组件请求下一帧完成调整(MyGo 布局的 `AspectRatio` 只能由宽推高,不能同时受宽高约束)。
+43
View File
@@ -0,0 +1,43 @@
# 010 Divider 分隔线
## 用途
横向或纵向的细分隔线;横向可在中间放文字;可选旧金色单个菱形装饰。永不获取焦点,装饰不进入无障碍树。
## 最小示例
```go
ui.Column(c).Padding(16).Gap(12).Children(func() {
mujica.Divider(c, mujica.DividerOptions{Ornament: true})
mujica.Divider(c, mujica.DividerOptions{Label: "Chapter II"})
})
```
签名:`func Divider(c *ui.Context, opts DividerOptions) *ui.Element`
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Vertical` | `bool` | false | 纵向,放在横向行中,高度随行拉伸。 |
| `Label` | `string` | "" | 横向分隔线中间的文字(衬线 12 DIP,TextMuted)。纵向带文字时 panic。 |
| `Ornament` | `bool` | false | 线条改用 Ornament 色,并在中间画一个 7 DIP 菱形;有 `Label` 时文字取代菱形。 |
## 状态
- 默认:1 DIP Border 色细线。
- 装饰:Ornament 色线与菱形。
- 无悬停、焦点等交互状态。
## 事件
无。
## 键盘操作
不获取焦点,Tab 直接跳过。
## 限制
- 方向不会根据父级自动判断,需要显式设置 `Vertical`。
- 纵向不支持文字。
+129
View File
@@ -0,0 +1,129 @@
package mujica_test
import (
"github.com/egoist/mygo/ui"
mujica "mujicaui"
"mujicaui/icons"
)
func ExampleAppShell() {
var collapsed bool
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.AppShell(c, &collapsed, mujica.AppShellOptions{
TitleBar: func() { mujica.TitleBar(c, "Court Records", mujica.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) {
mujica.Use(c, mujica.Settings{})
mujica.TitleBar(c, "Court Records", mujica.TitleBarOptions{
Trailing: func() { mujica.Button(c, "Share", mujica.ButtonOptions{Variant: mujica.Ghost}) },
})
}
ui.Render(view, 400, 300, 1)
}
func ExampleStatusBar() {
opened := 0
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
bar := mujica.StatusBar(c, mujica.StatusBarOptions{
Left: []mujica.StatusItem{{ID: "sync", Text: "Syncing", ShowProgress: true, Progress: 0.4}},
Right: []mujica.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) {
mujica.Use(c, mujica.Settings{})
mujica.Container(c, mujica.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) {
mujica.Use(c, mujica.Settings{})
mujica.Stack(c, mujica.StackOptions{Horizontal: true, Gap: mujica.SpaceM}, func() {
mujica.Button(c, "Save", mujica.ButtonOptions{})
mujica.StackItem(!showDraft, func() { mujica.Button(c, "Draft", mujica.ButtonOptions{Variant: mujica.Secondary}) })
mujica.Button(c, "Cancel", mujica.ButtonOptions{Variant: mujica.Ghost})
})
}
ui.Render(view, 400, 300, 1)
}
func ExampleGrid() {
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.Grid(c, mujica.GridOptions{Columns: []ui.Track{ui.Fixed(96), ui.Fr(1)}}, func() {
mujica.GridCell(c, mujica.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) {
mujica.Use(c, mujica.Settings{})
mujica.ScrollArea(c, &offset, mujica.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 := mujica.SplitPaneState{Size: 160}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.SplitPane(c, &split, mujica.SplitPaneOptions{MinFirst: 120, Collapsible: mujica.SplitPaneFirst},
func() { ui.Text(c, "Files") },
func() { ui.Text(c, "Editor") },
).Fill()
}
ui.Render(view, 400, 300, 1)
}
func ExampleAspectRatio() {
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
ui.Box(c).Size(320, 240).Children(func() {
mujica.AspectRatio(c, mujica.AspectRatioOptions{Ratio: 16.0 / 9}, func() {
ui.Text(c, "16 : 9")
})
})
}
ui.Render(view, 400, 300, 1)
}
func ExampleDivider() {
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
ui.Column(c).Padding(16).Gap(12).Children(func() {
mujica.Divider(c, mujica.DividerOptions{Ornament: true})
mujica.Divider(c, mujica.DividerOptions{Label: "Chapter II"})
})
}
ui.Render(view, 400, 300, 1)
}
+497 -1
View File
@@ -1,3 +1,499 @@
package main
var layoutPages = []page{}
import (
"fmt"
"math"
"slices"
"github.com/egoist/mygo/ui"
mujica "mujicaui"
"mujicaui/icons"
)
var layoutPages = []page{
{ID: "001", Name: "AppShell", ZhName: "应用外壳", Category: "Layout", Demo: layoutAppShellDemo, Code: layoutAppShellCode,
Keys: "Cmd/Ctrl+B or the toggle button folds the sidebar. Tab moves between the toggle, the sidebar and the content; arrows, Page Up/Down, Home and End scroll the focused region."},
{ID: "002", Name: "TitleBar", ZhName: "标题栏", Category: "Layout", Demo: layoutTitleBarDemo, Code: layoutTitleBarCode,
Keys: "Tools in the slots take Tab and Enter/Space as usual. Dragging the empty bar moves the window; double-click zooms it."},
{ID: "003", Name: "StatusBar", ZhName: "状态栏", Category: "Layout", Demo: layoutStatusBarDemo, Code: layoutStatusBarCode,
Keys: "Tab reaches action items; Enter or Space activates them. Plain status text is not a tab stop."},
{ID: "004", Name: "Container", ZhName: "容器", Category: "Layout", Demo: layoutContainerDemo, Code: layoutContainerCode,
Keys: "None: Container never takes the focus."},
{ID: "005", Name: "Stack", ZhName: "堆叠", Category: "Layout", Demo: layoutStackDemo, Code: layoutStackCode,
Keys: "None: Stack never takes the focus; Tab follows the children's order."},
{ID: "006", Name: "Grid", ZhName: "网格", Category: "Layout", Demo: layoutGridDemo, Code: layoutGridCode,
Keys: "None: Grid never takes the focus; Tab follows the build order."},
{ID: "007", Name: "ScrollArea", ZhName: "滚动区域", Category: "Layout", Demo: layoutScrollAreaDemo, Code: layoutScrollAreaCode,
Keys: "When the content overflows, Tab focuses the area; arrows scroll by a line, Page Up/Down and Space by a page, Home and End to the ends."},
{ID: "008", Name: "SplitPane", ZhName: "分栏", Category: "Layout", Demo: layoutSplitPaneDemo, Code: layoutSplitPaneCode,
Keys: "Tab focuses the divider. Left/Right (Up/Down when vertical) move it by 4 units, Home and End to the limits, Enter folds the collapsible pane; an arrow past its minimum folds it, the opposite arrow opens it."},
{ID: "009", Name: "AspectRatio", ZhName: "宽高比", Category: "Layout", Demo: layoutAspectRatioDemo, Code: layoutAspectRatioCode,
Keys: "None: AspectRatio never takes the focus."},
{ID: "010", Name: "Divider", ZhName: "分隔线", Category: "Layout", Demo: layoutDividerDemo, Code: layoutDividerCode,
Keys: "None: Divider and its ornament never take the focus."},
}
// layoutCheck is a check box that logs its changes.
func layoutCheck(c *ui.Context, v *bool, label string, log func(string)) {
was := *v
ui.Checkbox(c, v, label)
if *v != was {
log(fmt.Sprintf("%s: %v", label, *v))
}
}
// layoutChoice is a labeled drop-down that logs its changes.
func layoutChoice(c *ui.Context, v *string, label string, options []string, log func(string)) {
was := *v
ui.Row(c).Gap(8).Children(func() {
ui.Text(c, label)
ui.Select(c, v, options)
})
if *v != was {
log(fmt.Sprintf("%s: %s", label, *v))
}
}
// layoutSlider is a labeled slider that logs whole-number changes.
func layoutSlider(c *ui.Context, v *float64, label string, lo, hi float64, log func(string)) {
was := math.Round(*v)
ui.Row(c).Gap(8).Children(func() {
ui.Text(c, fmt.Sprintf("%s %.0f", label, *v))
ui.Slider(c, v, lo, hi).Width(140)
})
if math.Round(*v) != was {
log(fmt.Sprintf("%s: %.0f", label, *v))
}
}
func layoutControls(c *ui.Context, fn func()) { ui.Row(c).Wrap().Gap(12).Children(fn) }
func layoutFrame(c *ui.Context, h float32, fn func()) *ui.Element {
k := mujica.Tokens(c)
return ui.Column(c).Height(h).Border(1, k.Border).Radius(4).Clip().Children(fn)
}
var layoutSpaces = []string{"Default", "None", "XS", "S", "M", "L", "XL", "XXL"}
func layoutSpace(s string) mujica.Space { return mujica.Space(slices.Index(layoutSpaces, s)) }
const layoutAppShellCode = `var collapsed bool
mujica.AppShell(c, &collapsed, mujica.AppShellOptions{
TitleBar: func() { mujica.TitleBar(c, "Court Records", mujica.TitleBarOptions{}) },
Sidebar: func() { ui.Text(c, "Chronicles") },
Content: func() { ui.Text(c, "The Grand Hall") },
})`
func layoutAppShellDemo(c *ui.Context, log func(string)) {
type state struct {
collapsed, title, status bool
width float64
scroll ui.ScrollState
}
s := ui.Local(c.Root(), "001", func() state { return state{title: true, status: true, width: 220} })
layoutControls(c, func() {
layoutCheck(c, &s.title, "Title bar", log)
layoutCheck(c, &s.status, "Status bar", log)
layoutCheck(c, &s.collapsed, "Collapsed", log)
layoutSlider(c, &s.width, "Sidebar width", 160, 320, log)
})
opts := mujica.AppShellOptions{
SidebarWidth: float32(s.width),
ContentScroll: &s.scroll,
Sidebar: func() {
for _, n := range []string{"Chronicles", "Letters", "Decrees", "Portraits", "Ledgers"} {
ui.Text(c, n).Padding(6, 12)
}
},
Content: func() {
mujica.Container(c, mujica.ContainerOptions{}, func() {
for i := range 30 {
ui.Text(c, fmt.Sprintf("Entry %d of the court record", i+1)).Padding(4, 0)
}
})
},
}
if s.title {
opts.TitleBar = func() { mujica.TitleBar(c, "Court Records", mujica.TitleBarOptions{}) }
}
if s.status {
opts.StatusBar = func() {
mujica.StatusBar(c, mujica.StatusBarOptions{Left: []mujica.StatusItem{{ID: "pos", Text: fmt.Sprintf("Scrolled %.0f", s.scroll.Y)}}})
}
}
ui.Column(c).Margin(16, 0, 0, 0).Children(func() {
layoutFrame(c, 360, func() {
if mujica.AppShell(c, &s.collapsed, opts).Toggled {
log(fmt.Sprintf("sidebar collapsed: %v", s.collapsed))
}
})
})
}
const layoutTitleBarCode = `mujica.TitleBar(c, "Court Records", mujica.TitleBarOptions{
Trailing: func() { mujica.Button(c, "Share", mujica.ButtonOptions{Variant: mujica.Ghost}) },
})`
func layoutTitleBarDemo(c *ui.Context, log func(string)) {
type state struct {
leading, trailing bool
title string
}
s := ui.Local(c.Root(), "002", func() state { return state{trailing: true, title: "Court Records"} })
layoutControls(c, func() {
layoutCheck(c, &s.leading, "Leading tool", log)
layoutCheck(c, &s.trailing, "Trailing tool", log)
layoutChoice(c, &s.title, "Title", []string{"Court Records", "宫廷档案", "A very long title of the royal chronicle of the court"}, log)
})
var opts mujica.TitleBarOptions
if s.leading {
opts.Leading = func() {
if mujica.Button(c, "", mujica.ButtonOptions{Variant: mujica.Ghost, Icon: icons.Must("panel-left"), Label: "Sidebar"}).Clicked() {
log("leading tool clicked")
}
}
}
if s.trailing {
opts.Trailing = func() {
if mujica.Button(c, "Share", mujica.ButtonOptions{Variant: mujica.Ghost}).Clicked() {
log("Share clicked")
}
}
}
ui.Column(c).Margin(16, 0, 0, 0).Width(520).Children(func() {
layoutFrame(c, 90, func() { mujica.TitleBar(c, s.title, opts) })
})
bar := c.TitleBar()
ui.Text(c, fmt.Sprintf("Window controls reserve: left %.0f, right %.0f, height %.0f DIP", bar.Left, bar.Right, bar.Height)).
TextColor(mujica.Tokens(c).TextMuted).Margin(8, 0, 0, 0)
}
const layoutStatusBarCode = `bar := mujica.StatusBar(c, mujica.StatusBarOptions{
Left: []mujica.StatusItem{{ID: "sync", Text: "Syncing", ShowProgress: true, Progress: 0.4}},
Right: []mujica.StatusItem{{ID: "bell", Label: "Notifications", Icon: icons.Must("bell"), Action: true}},
})
if bar.Clicked == "bell" {
opened++
}`
func layoutStatusBarDemo(c *ui.Context, log func(string)) {
type state struct {
width, progress float64
showProgress bool
tone string
}
s := ui.Local(c.Root(), "003", func() state { return state{width: 640, progress: 40, showProgress: true, tone: "Warning"} })
layoutControls(c, func() {
layoutSlider(c, &s.width, "Width", 240, 720, log)
layoutCheck(c, &s.showProgress, "Progress", log)
layoutSlider(c, &s.progress, "Percent", 0, 100, log)
layoutChoice(c, &s.tone, "Tone", []string{"Neutral", "Success", "Warning", "Danger", "Info"}, log)
})
tones := map[string]mujica.StatusItemTone{"Neutral": mujica.StatusItemNeutral, "Success": mujica.StatusItemSuccess,
"Warning": mujica.StatusItemWarning, "Danger": mujica.StatusItemDanger, "Info": mujica.StatusItemInfo}
opts := mujica.StatusBarOptions{
Left: []mujica.StatusItem{
{ID: "branch", Text: "main", Icon: icons.Must("folder")},
{ID: "sync", Text: "Syncing", ShowProgress: s.showProgress, Progress: float32(s.progress / 100)},
{ID: "lint", Text: "2 warnings", Tone: tones[s.tone], Secondary: true, Action: true},
},
Right: []mujica.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},
},
}
ui.Column(c).Margin(16, 0, 0, 0).Width(float32(s.width)).Children(func() {
if id := mujica.StatusBar(c, opts).Clicked; id != "" {
log("clicked " + id)
}
})
ui.Text(c, "Narrow the width: secondary items fold into a count, last first.").
TextColor(mujica.Tokens(c).TextMuted).Margin(8, 0, 0, 0)
}
const layoutContainerCode = `mujica.Container(c, mujica.ContainerOptions{MaxWidth: 320, Center: true}, func() {
ui.Text(c, "A centered column of prose.")
})`
func layoutContainerDemo(c *ui.Context, log func(string)) {
type state struct {
maxWidth float64
padding string
center, fill, clip, wide bool
}
s := ui.Local(c.Root(), "004", func() state { return state{maxWidth: 360, padding: "Default", center: true} })
layoutControls(c, func() {
layoutSlider(c, &s.maxWidth, "Max width", 0, 600, log)
layoutChoice(c, &s.padding, "Padding", layoutSpaces, log)
layoutCheck(c, &s.center, "Center", log)
layoutCheck(c, &s.fill, "Fill", log)
layoutCheck(c, &s.clip, "Clip", log)
layoutCheck(c, &s.wide, "Overflowing child", log)
})
k := mujica.Tokens(c)
ui.Column(c).Margin(16, 0, 0, 0).Children(func() {
layoutFrame(c, 200, func() {
mujica.Container(c, mujica.ContainerOptions{MaxWidth: float32(s.maxWidth), Padding: layoutSpace(s.padding),
Center: s.center, Fill: s.fill, Clip: s.clip}, func() {
ui.Text(c, "Of the Court").FontSize(18)
ui.Text(c, "Prose keeps a readable measure inside a container.").TextColor(k.TextMuted)
if s.wide {
ui.Box(c).Size(900, 24).Background(k.Selection).Shrink(0)
}
}).Background(k.Surface).Border(1, k.Border)
})
})
}
const layoutStackCode = `mujica.Stack(c, mujica.StackOptions{Horizontal: true, Gap: mujica.SpaceM}, func() {
mujica.Button(c, "Save", mujica.ButtonOptions{})
mujica.StackItem(!showDraft, func() { mujica.Button(c, "Draft", mujica.ButtonOptions{Variant: mujica.Secondary}) })
mujica.Button(c, "Cancel", mujica.ButtonOptions{Variant: mujica.Ghost})
})`
func layoutStackDemo(c *ui.Context, log func(string)) {
type state struct {
horizontal, wrap, hide bool
gap, align, justify string
}
s := ui.Local(c.Root(), "005", func() state {
return state{horizontal: true, gap: "Default", align: "Auto", justify: "Start"}
})
aligns := []string{"Auto", "Start", "Center", "End", "Stretch"}
justifies := map[string]ui.Align{"Start": ui.Start, "Center": ui.Center, "End": ui.End, "SpaceBetween": ui.SpaceBetween}
layoutControls(c, func() {
layoutCheck(c, &s.horizontal, "Horizontal", log)
s.wrap = s.wrap && s.horizontal
layoutCheck(c, &s.wrap, "Wrap", log)
layoutCheck(c, &s.hide, "Hide Draft", log)
layoutChoice(c, &s.gap, "Gap", layoutSpaces, log)
layoutChoice(c, &s.align, "Align", aligns, log)
layoutChoice(c, &s.justify, "Justify", []string{"Start", "Center", "End", "SpaceBetween"}, log)
})
align := mujica.StackAlign(slices.Index(aligns, s.align))
ui.Column(c).Margin(16, 0, 0, 0).Width(420).Children(func() {
layoutFrame(c, 200, func() {
mujica.Stack(c, mujica.StackOptions{Horizontal: s.horizontal, Wrap: s.wrap, Gap: layoutSpace(s.gap),
Align: align, Justify: justifies[s.justify]}, func() {
for _, n := range []string{"Save", "Draft", "Publish", "Archive", "Cancel"} {
mujica.StackItem(s.hide && n == "Draft", func() {
if mujica.Button(c, n, mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() {
log(n + " clicked")
}
})
}
}).Grow(1).Padding(12)
})
})
}
const layoutGridCode = `mujica.Grid(c, mujica.GridOptions{Columns: []ui.Track{ui.Fixed(96), ui.Fr(1)}}, func() {
mujica.GridCell(c, mujica.GridCellOptions{ColumnSpan: -1}, func() { ui.Text(c, "Header") })
ui.Text(c, "Name")
ui.Text(c, "Ada")
})`
func layoutGridDemo(c *ui.Context, log func(string)) {
type state struct {
first, gap, rowGap string
header, tall bool
}
s := ui.Local(c.Root(), "006", func() state {
return state{first: "Fixed 120", gap: "Default", rowGap: "Default", header: true, tall: true}
})
tracks := map[string]ui.Track{"Fixed 120": ui.Fixed(120), "Fit content": ui.FitContent(), "Fr 1": ui.Fr(1), "Fr 2": ui.Fr(2)}
layoutControls(c, func() {
layoutChoice(c, &s.first, "First column", []string{"Fixed 120", "Fit content", "Fr 1", "Fr 2"}, log)
layoutChoice(c, &s.gap, "Gap", layoutSpaces, log)
layoutChoice(c, &s.rowGap, "Row gap", layoutSpaces, log)
layoutCheck(c, &s.header, "Header spans all", log)
layoutCheck(c, &s.tall, "First cell spans 2 rows", log)
})
k := mujica.Tokens(c)
cell := func(text string) {
ui.Box(c).Center().Padding(8).Background(k.Surface).Border(1, k.Border).Radius(4).
Children(func() { ui.Text(c, text).TextColor(k.TextMuted) })
}
ui.Column(c).Margin(16, 0, 0, 0).Width(520).Children(func() {
mujica.Grid(c, mujica.GridOptions{Columns: []ui.Track{tracks[s.first], ui.Fr(1), ui.Fr(1)},
Gap: layoutSpace(s.gap), RowGap: layoutSpace(s.rowGap)}, func() {
if s.header {
mujica.GridCell(c, mujica.GridCellOptions{ColumnSpan: -1}, func() { cell("Header, ColumnSpan -1") })
}
span := 1
if s.tall {
span = 2
}
mujica.GridCell(c, mujica.GridCellOptions{RowSpan: span}, func() { cell(s.first) })
for i := range 4 {
cell(fmt.Sprintf("Cell %d", i+1))
}
})
})
}
const layoutScrollAreaCode = `var offset ui.ScrollState
mujica.ScrollArea(c, &offset, mujica.ScrollAreaOptions{Label: "Log"}, func() {
for range 50 {
ui.Text(c, "A line of the log")
}
}).Height(200)`
func layoutScrollAreaDemo(c *ui.Context, log func(string)) {
type state struct {
direction string
offset ui.ScrollState
lastX, lastY float32
}
s := ui.Local(c.Root(), "007", func() state { return state{direction: "Vertical"} })
dirs := map[string]mujica.ScrollDirection{"Vertical": mujica.ScrollVertical, "Horizontal": mujica.ScrollHorizontal, "Both": mujica.ScrollBoth}
layoutControls(c, func() {
layoutChoice(c, &s.direction, "Direction", []string{"Vertical", "Horizontal", "Both"}, log)
if mujica.Button(c, "Top", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() {
s.offset.X, s.offset.Y = 0, 0
log("scroll to start")
}
if mujica.Button(c, "Middle", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() {
s.offset.X, s.offset.Y = s.offset.MaxX/2, s.offset.MaxY/2
log("scroll to middle")
}
if mujica.Button(c, "End", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() {
s.offset.X, s.offset.Y = math.MaxFloat32, math.MaxFloat32
log("scroll to end")
}
})
k := mujica.Tokens(c)
ui.Column(c).Margin(16, 0, 0, 0).Width(420).Children(func() {
mujica.ScrollArea(c, &s.offset, mujica.ScrollAreaOptions{Direction: dirs[s.direction], Label: "Ledger"}, func() {
for i := range 40 {
ui.Text(c, fmt.Sprintf("%02d Ledger line of the treasury, folio %d, carried over from the last quarter", i+1, i+7)).
SingleLine().Padding(4, 12)
}
}).Height(220).Background(k.Surface).Border(1, k.Border).Radius(4)
})
if s.offset.Y != s.lastY || s.offset.X != s.lastX {
s.lastX, s.lastY = s.offset.X, s.offset.Y
log(fmt.Sprintf("offset %.0f, %.0f", s.offset.X, s.offset.Y))
}
ui.Text(c, fmt.Sprintf("Offset %.0f, %.0f of %.0f, %.0f", s.offset.X, s.offset.Y, s.offset.MaxX, s.offset.MaxY)).
TextColor(k.TextMuted).Margin(8, 0, 0, 0)
}
const layoutSplitPaneCode = `split := mujica.SplitPaneState{Size: 160}
mujica.SplitPane(c, &split, mujica.SplitPaneOptions{MinFirst: 120, Collapsible: mujica.SplitPaneFirst},
func() { ui.Text(c, "Files") },
func() { ui.Text(c, "Editor") },
).Fill()`
func layoutSplitPaneDemo(c *ui.Context, log func(string)) {
type state struct {
split mujica.SplitPaneState
vertical bool
collapsible string
minFirst float64
}
s := ui.Local(c.Root(), "008", func() state {
return state{split: mujica.SplitPaneState{Size: 180}, collapsible: "First", minFirst: 120}
})
sides := map[string]mujica.SplitPaneSide{"Neither": mujica.SplitPaneNeither, "First": mujica.SplitPaneFirst, "Second": mujica.SplitPaneSecond}
layoutControls(c, func() {
layoutCheck(c, &s.vertical, "Vertical", log)
layoutChoice(c, &s.collapsible, "Collapsible", []string{"Neither", "First", "Second"}, log)
layoutSlider(c, &s.minFirst, "Min first", 40, 200, log)
})
s.split.Collapsed = s.split.Collapsed && s.collapsible != "Neither"
k := mujica.Tokens(c)
pane := func(title, body string, bg ui.Color) func() {
return func() {
ui.Column(c).Fill().Padding(12).Gap(6).Background(bg).Children(func() {
ui.Text(c, title).FontSize(16)
ui.Text(c, body).TextColor(k.TextMuted)
})
}
}
ui.Column(c).Margin(16, 0, 0, 0).Width(560).Children(func() {
layoutFrame(c, 280, func() {
r := mujica.SplitPane(c, &s.split, mujica.SplitPaneOptions{Vertical: s.vertical, MinFirst: float32(s.minFirst),
Collapsible: sides[s.collapsible]},
pane("Files", "Drag the divider, or focus it and use the arrows.", k.Surface),
pane("Editor", "Enter on the divider folds the collapsible pane.", k.Background))
r.Fill()
if r.Resized {
log(fmt.Sprintf("resized to %.0f", s.split.Size))
}
if r.Toggled {
log(fmt.Sprintf("collapsed: %v", s.split.Collapsed))
}
})
})
}
const layoutAspectRatioCode = `ui.Box(c).Size(320, 240).Children(func() {
mujica.AspectRatio(c, mujica.AspectRatioOptions{Ratio: 16.0 / 9}, func() {
ui.Text(c, "16 : 9")
})
})`
func layoutAspectRatioDemo(c *ui.Context, log func(string)) {
type state struct {
ratio string
width, height float64
}
s := ui.Local(c.Root(), "009", func() state { return state{ratio: "16 : 9", width: 360, height: 220} })
ratios := map[string]float32{"1 : 1": 1, "4 : 3": 4.0 / 3, "16 : 9": 16.0 / 9, "3 : 4": 3.0 / 4, "21 : 9": 21.0 / 9}
layoutControls(c, func() {
layoutChoice(c, &s.ratio, "Ratio", []string{"1 : 1", "4 : 3", "16 : 9", "3 : 4", "21 : 9"}, log)
layoutSlider(c, &s.width, "Parent width", 120, 560, log)
layoutSlider(c, &s.height, "Parent height", 80, 320, log)
})
k := mujica.Tokens(c)
ui.Box(c).Margin(16, 0, 0, 0).Size(float32(s.width), float32(s.height)).Border(1, k.Border).Children(func() {
mujica.AspectRatio(c, mujica.AspectRatioOptions{Ratio: ratios[s.ratio]}, func() {
ui.Box(c).Fill().Center().Background(k.Surface).Border(1, k.Ornament).Children(func() {
ui.Text(c, s.ratio).TextColor(k.TextMuted)
})
})
})
}
const layoutDividerCode = `ui.Column(c).Padding(16).Gap(12).Children(func() {
mujica.Divider(c, mujica.DividerOptions{Ornament: true})
mujica.Divider(c, mujica.DividerOptions{Label: "Chapter II"})
})`
func layoutDividerDemo(c *ui.Context, log func(string)) {
type state struct {
vertical, ornament bool
label string
}
s := ui.Local(c.Root(), "010", func() state { return state{ornament: true, label: "None"} })
layoutControls(c, func() {
layoutCheck(c, &s.vertical, "Vertical", log)
layoutCheck(c, &s.ornament, "Ornament", log)
if !s.vertical {
layoutChoice(c, &s.label, "Label", []string{"None", "Chapter II", "第二章"}, log)
}
})
label := s.label
if label == "None" || s.vertical {
label = ""
}
opts := mujica.DividerOptions{Vertical: s.vertical, Ornament: s.ornament, Label: label}
ui.Column(c).Margin(16, 0, 0, 0).Width(480).Children(func() {
if s.vertical {
ui.Row(c).Height(80).Gap(16).Children(func() {
ui.Text(c, "Overture")
mujica.Divider(c, opts)
ui.Text(c, "Aria")
})
return
}
ui.Column(c).Gap(16).Children(func() {
ui.Text(c, "Overture")
mujica.Divider(c, opts)
ui.Text(c, "Aria")
})
})
}
+72
View File
@@ -0,0 +1,72 @@
package mujica
import "github.com/egoist/mygo/ui"
// 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 := 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)
}
+93
View File
@@ -0,0 +1,93 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
func TestGridFixedAndFrTracks(t *testing.T) {
var a, b ui.Rect
ui.NewTester(func(c *ui.Context) {
Use(c, 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) {
Use(c, 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) {
Use(c, 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) {
Use(c, 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 := 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") })
})
})
})
}
+12
View File
@@ -0,0 +1,12 @@
package mujica
func init() {
addMessages(map[string][2]string{
"app_shell.collapse": {"Collapse sidebar", "收起侧栏"},
"app_shell.expand": {"Expand sidebar", "展开侧栏"},
"status_bar.label": {"Status bar", "状态栏"},
"status_bar.hidden": {"%d more items", "另有 %d 项"},
"split_pane.divider": {"Divider", "分隔条"},
"split_pane.collapsed": {"Collapsed", "已折叠"},
})
}
+62
View File
@@ -0,0 +1,62 @@
package mujica
import (
"github.com/egoist/mygo/ui"
"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 its children with the wheel, its scroll bars and the keyboard.
// state keeps the offset across frames: set X or Y to scroll there, math.MaxFloat32 for the end.
// A nil state keeps the offset by the area's place in the view.
// The area takes the focus while its content overflows, so the keys reach it.
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)
if state.MaxX > 0 || state.MaxY > 0 {
focus := Tokens(c).Focus
// The ring goes inside: areas often fill a clipped pane edge to edge.
e.Focusable().FocusRing(false).DrawOver(func(p *ui.Painter, r ui.Rect) {
if e.FocusVisible() {
in := 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)
}
+118
View File
@@ -0,0 +1,118 @@
package mujica
import (
"fmt"
"math"
"testing"
"github.com/egoist/mygo/ui"
)
func scrollAreaView(st *ui.ScrollState, opts ScrollAreaOptions, rows int) func(c *ui.Context) {
return func(c *ui.Context) {
Use(c, 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) {
Use(c, 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 := 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)
})
})
}
+264
View File
@@ -0,0 +1,264 @@
package mujica
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"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 lets that pane collapse: Enter on the divider, a double click,
// dragging it under half its minimum, or an arrow key past its minimum.
Collapsible SplitPaneSide
}
// SplitPaneResult is a SplitPane and its events in this frame.
type SplitPaneResult struct {
*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")
}
k := Tokens(c)
u := 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()
}
total := e.Bounds().W
if opts.Vertical {
total = e.Bounds().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)
} else if first := ui.Local(e, "split_pane.first", func() bool { return true }); *first {
// Not laid out yet: measure, then size in the next frame.
*first = false
c.Invalidate()
}
var res SplitPaneResult
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)
}
}
e.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(msg(c, "split_pane.divider"))
along(div, theme.BorderWidth)
if state.Collapsed {
div.Description(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)
}
})
})
if res.Resized || res.Toggled {
c.Invalidate()
}
res.Element = e
return res
}
// 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()
}
}
+190
View File
@@ -0,0 +1,190 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
type splitPaneProbe struct {
resized, toggled int
}
func splitPaneTester(st *SplitPaneState, opts SplitPaneOptions, p *splitPaneProbe) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
r := SplitPane(c, st, opts, func() { ui.Text(c, "Files") }, func() { ui.Text(c, "Editor") })
r.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) {
Use(c, Settings{})
SplitPane(c, st, SplitPaneOptions{Vertical: true, MinSecond: 60, Collapsible: SplitPaneSecond},
func() { ui.Text(c, "Editor") }, func() { ui.Text(c, "Console") }).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 := 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)).
Height(128).Border(1, k.Border).Radius(4).Clip()
})
})
}
+69
View File
@@ -0,0 +1,69 @@
package mujica
import "github.com/egoist/mygo/ui"
// 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. Build children with StackItem to hide them without leaving a 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(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)
}
// StackItem builds one child of a Stack unless hidden; a hidden item takes no room and no gap.
func StackItem(hidden bool, build func()) {
if build == nil {
panic("mujica: StackItem needs build")
}
if !hidden {
build()
}
}
+114
View File
@@ -0,0 +1,114 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
func TestStackVerticalGap(t *testing.T) {
var a, b ui.Rect
ui.NewTester(func(c *ui.Context) {
Use(c, 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) {
Use(c, Settings{})
Stack(c, StackOptions{Horizontal: true, Gap: SpaceL}, func() {
a = ui.Box(c).Size(30, 10).Bounds()
StackItem(hide, func() { 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) {
Use(c, 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) {
Use(c, 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) {
Use(c, Settings{})
Stack(c, StackOptions{Wrap: true}, func() {})
}, 100, 100)
}
func TestStackSnapshot(t *testing.T) {
snapshot(t, "stack", 420, 150, func(c *ui.Context) {
k := 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")
StackItem(true, func() { chip("Hidden") })
chip("Between")
chip("End")
})
})
})
}
+237
View File
@@ -0,0 +1,237 @@
package mujica
import (
"fmt"
"log/slog"
"strings"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"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 {
*ui.Element
// Clicked is the ID of the action item clicked, or empty.
Clicked string
}
const statusBarProgressWidth float32 = 64
// StatusBar is a window's bottom strip with left and right groups of status items.
func StatusBar(c *ui.Context, opts StatusBarOptions) StatusBarResult {
k := Tokens(c)
d := Density(c)
statusBarCheck(opts)
h := max(theme.MinHitSize, d.ControlHeight()-8)
gap := d.Unit() * 2
e := ui.Row(c).Height(h).Shrink(0).PaddingX(d.Unit()*2).Gap(gap).
Background(k.Surface).BorderWidth(theme.BorderWidth, 0, 0, 0).BorderColor(k.Border).
Label(msg(c, "status_bar.label"))
hidden := statusBarHide(c, e, opts, e.Bounds().W-d.Unit()*4, gap)
var res StatusBarResult
group := func(items []StatusItem) {
ui.Row(c).Gap(gap).Shrink(0).Dividers(theme.BorderWidth, k.Border).Children(func() {
for _, it := range items {
if hidden[it.ID] {
continue
}
if statusBarItem(c, it) {
res.Clicked = it.ID
slog.Debug("mujica: status_bar clicked", "id", it.ID)
}
}
})
}
e.Children(func() {
group(opts.Left)
ui.Spacer(c)
if len(hidden) > 0 {
var names []string
for _, it := range append(append([]StatusItem{}, opts.Left...), opts.Right...) {
if hidden[it.ID] {
names = append(names, statusBarName(it))
}
}
label := fmt.Sprintf(msg(c, "status_bar.hidden"), len(hidden))
ui.Text(c, fmt.Sprintf("+%d", len(hidden))).FontSize(theme.CaptionSize).TextColor(k.TextMuted).
Label(label).Tooltip(strings.Join(names, "\n")).Shrink(0)
}
group(opts.Right)
})
res.Element = e
return res
}
func statusBarCheck(opts StatusBarOptions) {
seen := map[string]bool{}
for _, it := range append(append([]StatusItem{}, opts.Left...), opts.Right...) {
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
}
}
func statusBarName(it StatusItem) string {
if it.Text != "" {
return it.Text
}
return it.Label
}
// statusBarHide returns the secondary items to hide so the rest fit in room DIPs.
func statusBarHide(c *ui.Context, bar *ui.Element, opts StatusBarOptions, room, gap float32) map[string]bool {
hidden := map[string]bool{}
if room <= 0 {
// Not laid out yet: show all, and measure in the next frame.
if first := ui.Local(bar, "status_bar.first", func() bool { return true }); *first {
*first = false
c.Invalidate()
}
return hidden
}
all := append(append([]StatusItem{}, opts.Left...), opts.Right...)
need := func() float32 {
var w float32
for _, it := range all {
if !hidden[it.ID] {
w += statusBarWidth(c, it) + gap
}
}
if len(hidden) > 0 {
mw, _ := c.MeasureText(0, ui.Span{Text: fmt.Sprintf("+%d", len(hidden)), Size: theme.CaptionSize})
w += mw + gap
}
return w
}
for i := len(all) - 1; i >= 0 && need() > room; i-- {
if all[i].Secondary {
hidden[all[i].ID] = true
}
}
return hidden
}
// statusBarWidth estimates the width an item takes.
func statusBarWidth(c *ui.Context, it StatusItem) float32 {
d := Density(c)
w := d.Unit() * 2
if it.Icon != nil {
w += 14 + d.Unit()
}
if it.Text != "" {
tw, _ := c.MeasureText(0, ui.Span{Text: it.Text, Size: theme.CaptionSize})
w += tw + d.Unit()
}
if it.ShowProgress {
w += statusBarProgressWidth + d.Unit()
}
return w
}
// statusBarItem builds one item and reports whether its action was clicked.
func statusBarItem(c *ui.Context, it StatusItem) bool {
k := Tokens(c)
d := 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(14, 14)
}
if it.Text != "" {
ui.Text(c, it.Text).FontSize(theme.CaptionSize).SingleLine()
}
if it.ShowProgress {
statusBarProgress(c, it.Progress)
}
})
return it.Action && e.Clicked()
}
func statusBarProgress(c *ui.Context, v float32) {
k := 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)
}
})
}
+130
View File
@@ -0,0 +1,130 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
)
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) {
Use(c, 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) {
Use(c, 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) {
Use(c, 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)
tt.Frame()
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(460, 100)
tt.Frame()
if !tt.HasText("2 warnings") || tt.HasText("Ln 12, Col 4") {
t.Fatalf("medium bar hid the wrong items: %q", tt.Texts())
}
if _, ok := tt.Find("Notifications"); !ok {
t.Fatal("primary action hidden")
}
tt.SetSize(800, 100)
tt.Frame()
if !tt.HasText("UTF-8") {
t.Fatal("secondary items did not come back when wide")
}
}
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) {
Use(c, 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)
})
})
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

+38
View File
@@ -0,0 +1,38 @@
package mujica
import (
"github.com/egoist/mygo/ui"
"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's title row. Its empty parts drag the window, and it keeps clear of the system window buttons
// reported by c.TitleBar() in a window with a hidden title bar. Controls in the slots stay clickable.
func TitleBar(c *ui.Context, title string, opts TitleBarOptions) *ui.Element {
k := Tokens(c)
d := Density(c)
bar := c.TitleBar()
pad := d.Unit() * 3
e := ui.Row(c).Height(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(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)
}
})
}
+82
View File
@@ -0,0 +1,82 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
func TestTitleBarDefaultHeight(t *testing.T) {
var r ui.Rect
ui.NewTester(func(c *ui.Context) {
Use(c, 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) {
Use(c, Settings{})
r = TitleBar(c, "Archive", TitleBarOptions{
Trailing: func() { Button(c, "Share", ButtonOptions{Variant: 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) {
Use(c, Settings{})
TitleBar(c, "Archive", TitleBarOptions{
Leading: func() {
if Button(c, "Menu", ButtonOptions{Variant: 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) {
Use(c, Settings{})
TitleBar(c, "A very long window title that cannot possibly fit in the bar", TitleBarOptions{
Trailing: func() { Button(c, "Share", ButtonOptions{Variant: 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() { Button(c, "Share", ButtonOptions{Variant: Ghost}) },
})
})
}