Layout: components 001-010
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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"}}}) },
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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,不可配置。
|
||||
@@ -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()` 为零,组件只是一行普通标题。
|
||||
@@ -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` 中说明。
|
||||
@@ -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`。
|
||||
@@ -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 顺序即子项构建顺序。
|
||||
|
||||
## 限制
|
||||
|
||||
- 隐藏项不会被构建,其内部界面状态(如未提交的输入)随之丢弃;需要保留时把状态放在应用中。
|
||||
- 只有横向支持换行。
|
||||
@@ -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 按其规则扩展。
|
||||
@@ -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 会构建全部子项。
|
||||
@@ -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 没有公开的无障碍数值范围接口,当前位置通过描述文字提供。
|
||||
- 键盘或拖动产生的尺寸在下一帧生效(组件会立即请求下一帧)。
|
||||
@@ -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` 只能由宽推高,不能同时受宽高约束)。
|
||||
@@ -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`。
|
||||
- 纵向不支持文字。
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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") })
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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", "已折叠"},
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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}) },
|
||||
})
|
||||
})
|
||||
}
|
||||