diff --git a/app_shell.go b/app_shell.go new file mode 100644 index 0000000..ee6850e --- /dev/null +++ b/app_shell.go @@ -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 +} diff --git a/app_shell_test.go b/app_shell_test.go new file mode 100644 index 0000000..8521cbd --- /dev/null +++ b/app_shell_test.go @@ -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"}}}) }, + }) + }) +} diff --git a/aspect_ratio.go b/aspect_ratio.go new file mode 100644 index 0000000..d886a64 --- /dev/null +++ b/aspect_ratio.go @@ -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) + }) +} diff --git a/aspect_ratio_test.go b/aspect_ratio_test.go new file mode 100644 index 0000000..e2f485f --- /dev/null +++ b/aspect_ratio_test.go @@ -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" +} diff --git a/container.go b/container.go new file mode 100644 index 0000000..7007853 --- /dev/null +++ b/container.go @@ -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) +} diff --git a/container_test.go b/container_test.go new file mode 100644 index 0000000..b3bceaa --- /dev/null +++ b/container_test.go @@ -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) + }) + }) +} diff --git a/divider.go b/divider.go new file mode 100644 index 0000000..3e4bdda --- /dev/null +++ b/divider.go @@ -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() + }) +} diff --git a/divider_test.go b/divider_test.go new file mode 100644 index 0000000..ab3db0c --- /dev/null +++ b/divider_test.go @@ -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") + }) + }) + }) +} diff --git a/docs/components/001-app-shell.md b/docs/components/001-app-shell.md new file mode 100644 index 0000000..f3ccaca --- /dev/null +++ b/docs/components/001-app-shell.md @@ -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,不可配置。 diff --git a/docs/components/002-title-bar.md b/docs/components/002-title-bar.md new file mode 100644 index 0000000..f7fc838 --- /dev/null +++ b/docs/components/002-title-bar.md @@ -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()` 为零,组件只是一行普通标题。 diff --git a/docs/components/003-status-bar.md b/docs/components/003-status-bar.md new file mode 100644 index 0000000..8e85639 --- /dev/null +++ b/docs/components/003-status-bar.md @@ -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` 中说明。 diff --git a/docs/components/004-container.md b/docs/components/004-container.md new file mode 100644 index 0000000..abe01ee --- /dev/null +++ b/docs/components/004-container.md @@ -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`。 diff --git a/docs/components/005-stack.md b/docs/components/005-stack.md new file mode 100644 index 0000000..bb2e7c9 --- /dev/null +++ b/docs/components/005-stack.md @@ -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 顺序即子项构建顺序。 + +## 限制 + +- 隐藏项不会被构建,其内部界面状态(如未提交的输入)随之丢弃;需要保留时把状态放在应用中。 +- 只有横向支持换行。 diff --git a/docs/components/006-grid.md b/docs/components/006-grid.md new file mode 100644 index 0000000..c22bac8 --- /dev/null +++ b/docs/components/006-grid.md @@ -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 按其规则扩展。 diff --git a/docs/components/007-scroll-area.md b/docs/components/007-scroll-area.md new file mode 100644 index 0000000..947ab19 --- /dev/null +++ b/docs/components/007-scroll-area.md @@ -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 会构建全部子项。 diff --git a/docs/components/008-split-pane.md b/docs/components/008-split-pane.md new file mode 100644 index 0000000..ea1e9b6 --- /dev/null +++ b/docs/components/008-split-pane.md @@ -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 没有公开的无障碍数值范围接口,当前位置通过描述文字提供。 +- 键盘或拖动产生的尺寸在下一帧生效(组件会立即请求下一帧)。 diff --git a/docs/components/009-aspect-ratio.md b/docs/components/009-aspect-ratio.md new file mode 100644 index 0000000..5992bd9 --- /dev/null +++ b/docs/components/009-aspect-ratio.md @@ -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` 只能由宽推高,不能同时受宽高约束)。 diff --git a/docs/components/010-divider.md b/docs/components/010-divider.md new file mode 100644 index 0000000..bc938df --- /dev/null +++ b/docs/components/010-divider.md @@ -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`。 +- 纵向不支持文字。 diff --git a/example_layout_test.go b/example_layout_test.go new file mode 100644 index 0000000..2926e55 --- /dev/null +++ b/example_layout_test.go @@ -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) +} diff --git a/examples/gallery/pages_layout.go b/examples/gallery/pages_layout.go index 9d315c6..cb295e1 100644 --- a/examples/gallery/pages_layout.go +++ b/examples/gallery/pages_layout.go @@ -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") + }) + }) +} diff --git a/grid.go b/grid.go new file mode 100644 index 0000000..9de4fe2 --- /dev/null +++ b/grid.go @@ -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) +} diff --git a/grid_test.go b/grid_test.go new file mode 100644 index 0000000..a04dce3 --- /dev/null +++ b/grid_test.go @@ -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") }) + }) + }) + }) +} diff --git a/messages_layout.go b/messages_layout.go new file mode 100644 index 0000000..5563c39 --- /dev/null +++ b/messages_layout.go @@ -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", "已折叠"}, + }) +} diff --git a/scroll_area.go b/scroll_area.go new file mode 100644 index 0000000..1802827 --- /dev/null +++ b/scroll_area.go @@ -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) +} diff --git a/scroll_area_test.go b/scroll_area_test.go new file mode 100644 index 0000000..4beb683 --- /dev/null +++ b/scroll_area_test.go @@ -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) + }) + }) +} diff --git a/split_pane.go b/split_pane.go new file mode 100644 index 0000000..5d0dc32 --- /dev/null +++ b/split_pane.go @@ -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() + } +} diff --git a/split_pane_test.go b/split_pane_test.go new file mode 100644 index 0000000..463c883 --- /dev/null +++ b/split_pane_test.go @@ -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() + }) + }) +} diff --git a/stack.go b/stack.go new file mode 100644 index 0000000..1914350 --- /dev/null +++ b/stack.go @@ -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() + } +} diff --git a/stack_test.go b/stack_test.go new file mode 100644 index 0000000..d9c2053 --- /dev/null +++ b/stack_test.go @@ -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") + }) + }) + }) +} diff --git a/status_bar.go b/status_bar.go new file mode 100644 index 0000000..c12f496 --- /dev/null +++ b/status_bar.go @@ -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) + } + }) +} diff --git a/status_bar_test.go b/status_bar_test.go new file mode 100644 index 0000000..7c5f1e3 --- /dev/null +++ b/status_bar_test.go @@ -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) + }) + }) +} diff --git a/testdata/snapshots/darwin/app_shell-dark.png b/testdata/snapshots/darwin/app_shell-dark.png new file mode 100644 index 0000000..d646dbe Binary files /dev/null and b/testdata/snapshots/darwin/app_shell-dark.png differ diff --git a/testdata/snapshots/darwin/app_shell-light.png b/testdata/snapshots/darwin/app_shell-light.png new file mode 100644 index 0000000..e6834ac Binary files /dev/null and b/testdata/snapshots/darwin/app_shell-light.png differ diff --git a/testdata/snapshots/darwin/aspect_ratio-dark.png b/testdata/snapshots/darwin/aspect_ratio-dark.png new file mode 100644 index 0000000..65015aa Binary files /dev/null and b/testdata/snapshots/darwin/aspect_ratio-dark.png differ diff --git a/testdata/snapshots/darwin/aspect_ratio-light.png b/testdata/snapshots/darwin/aspect_ratio-light.png new file mode 100644 index 0000000..97bf958 Binary files /dev/null and b/testdata/snapshots/darwin/aspect_ratio-light.png differ diff --git a/testdata/snapshots/darwin/container-dark.png b/testdata/snapshots/darwin/container-dark.png new file mode 100644 index 0000000..09215ad Binary files /dev/null and b/testdata/snapshots/darwin/container-dark.png differ diff --git a/testdata/snapshots/darwin/container-light.png b/testdata/snapshots/darwin/container-light.png new file mode 100644 index 0000000..604f644 Binary files /dev/null and b/testdata/snapshots/darwin/container-light.png differ diff --git a/testdata/snapshots/darwin/divider-dark.png b/testdata/snapshots/darwin/divider-dark.png new file mode 100644 index 0000000..52e1187 Binary files /dev/null and b/testdata/snapshots/darwin/divider-dark.png differ diff --git a/testdata/snapshots/darwin/divider-light.png b/testdata/snapshots/darwin/divider-light.png new file mode 100644 index 0000000..edf9be8 Binary files /dev/null and b/testdata/snapshots/darwin/divider-light.png differ diff --git a/testdata/snapshots/darwin/grid-dark.png b/testdata/snapshots/darwin/grid-dark.png new file mode 100644 index 0000000..122e08b Binary files /dev/null and b/testdata/snapshots/darwin/grid-dark.png differ diff --git a/testdata/snapshots/darwin/grid-light.png b/testdata/snapshots/darwin/grid-light.png new file mode 100644 index 0000000..d578fbe Binary files /dev/null and b/testdata/snapshots/darwin/grid-light.png differ diff --git a/testdata/snapshots/darwin/scroll_area-dark.png b/testdata/snapshots/darwin/scroll_area-dark.png new file mode 100644 index 0000000..7dce19f Binary files /dev/null and b/testdata/snapshots/darwin/scroll_area-dark.png differ diff --git a/testdata/snapshots/darwin/scroll_area-light.png b/testdata/snapshots/darwin/scroll_area-light.png new file mode 100644 index 0000000..551db14 Binary files /dev/null and b/testdata/snapshots/darwin/scroll_area-light.png differ diff --git a/testdata/snapshots/darwin/split_pane-dark.png b/testdata/snapshots/darwin/split_pane-dark.png new file mode 100644 index 0000000..b70c0f9 Binary files /dev/null and b/testdata/snapshots/darwin/split_pane-dark.png differ diff --git a/testdata/snapshots/darwin/split_pane-light.png b/testdata/snapshots/darwin/split_pane-light.png new file mode 100644 index 0000000..217d065 Binary files /dev/null and b/testdata/snapshots/darwin/split_pane-light.png differ diff --git a/testdata/snapshots/darwin/stack-dark.png b/testdata/snapshots/darwin/stack-dark.png new file mode 100644 index 0000000..fbe69a3 Binary files /dev/null and b/testdata/snapshots/darwin/stack-dark.png differ diff --git a/testdata/snapshots/darwin/stack-light.png b/testdata/snapshots/darwin/stack-light.png new file mode 100644 index 0000000..01bc250 Binary files /dev/null and b/testdata/snapshots/darwin/stack-light.png differ diff --git a/testdata/snapshots/darwin/status_bar-dark.png b/testdata/snapshots/darwin/status_bar-dark.png new file mode 100644 index 0000000..4c7acee Binary files /dev/null and b/testdata/snapshots/darwin/status_bar-dark.png differ diff --git a/testdata/snapshots/darwin/status_bar-light.png b/testdata/snapshots/darwin/status_bar-light.png new file mode 100644 index 0000000..169e081 Binary files /dev/null and b/testdata/snapshots/darwin/status_bar-light.png differ diff --git a/testdata/snapshots/darwin/title_bar-dark.png b/testdata/snapshots/darwin/title_bar-dark.png new file mode 100644 index 0000000..cfae176 Binary files /dev/null and b/testdata/snapshots/darwin/title_bar-dark.png differ diff --git a/testdata/snapshots/darwin/title_bar-light.png b/testdata/snapshots/darwin/title_bar-light.png new file mode 100644 index 0000000..e516140 Binary files /dev/null and b/testdata/snapshots/darwin/title_bar-light.png differ diff --git a/title_bar.go b/title_bar.go new file mode 100644 index 0000000..af6e1e5 --- /dev/null +++ b/title_bar.go @@ -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) + } + }) +} diff --git a/title_bar_test.go b/title_bar_test.go new file mode 100644 index 0000000..18202bb --- /dev/null +++ b/title_bar_test.go @@ -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}) }, + }) + }) +}