From fe806037c7857974a441b727ecd80083832a43cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 5 Oct 2026 12:18:49 -0400 Subject: [PATCH] Layout: components 001-010 --- app_shell.go | 120 +++++ app_shell_test.go | 149 ++++++ aspect_ratio.go | 49 ++ aspect_ratio_test.go | 113 ++++ container.go | 85 +++ container_test.go | 97 ++++ divider.go | 65 +++ divider_test.go | 107 ++++ docs/components/001-app-shell.md | 53 ++ docs/components/002-title-bar.md | 43 ++ docs/components/003-status-bar.md | 63 +++ docs/components/004-container.md | 45 ++ docs/components/005-stack.md | 49 ++ docs/components/006-grid.md | 57 ++ docs/components/007-scroll-area.md | 49 ++ docs/components/008-split-pane.md | 64 +++ docs/components/009-aspect-ratio.md | 41 ++ docs/components/010-divider.md | 43 ++ example_layout_test.go | 129 +++++ examples/gallery/pages_layout.go | 498 +++++++++++++++++- grid.go | 72 +++ grid_test.go | 93 ++++ messages_layout.go | 12 + scroll_area.go | 62 +++ scroll_area_test.go | 118 +++++ split_pane.go | 264 ++++++++++ split_pane_test.go | 190 +++++++ stack.go | 69 +++ stack_test.go | 114 ++++ status_bar.go | 237 +++++++++ status_bar_test.go | 130 +++++ testdata/snapshots/darwin/app_shell-dark.png | Bin 0 -> 17599 bytes testdata/snapshots/darwin/app_shell-light.png | Bin 0 -> 16677 bytes .../snapshots/darwin/aspect_ratio-dark.png | Bin 0 -> 3442 bytes .../snapshots/darwin/aspect_ratio-light.png | Bin 0 -> 3363 bytes testdata/snapshots/darwin/container-dark.png | Bin 0 -> 10207 bytes testdata/snapshots/darwin/container-light.png | Bin 0 -> 9861 bytes testdata/snapshots/darwin/divider-dark.png | Bin 0 -> 6227 bytes testdata/snapshots/darwin/divider-light.png | Bin 0 -> 5932 bytes testdata/snapshots/darwin/grid-dark.png | Bin 0 -> 6551 bytes testdata/snapshots/darwin/grid-light.png | Bin 0 -> 6367 bytes .../snapshots/darwin/scroll_area-dark.png | Bin 0 -> 4054 bytes .../snapshots/darwin/scroll_area-light.png | Bin 0 -> 3613 bytes testdata/snapshots/darwin/split_pane-dark.png | Bin 0 -> 3072 bytes .../snapshots/darwin/split_pane-light.png | Bin 0 -> 3049 bytes testdata/snapshots/darwin/stack-dark.png | Bin 0 -> 6786 bytes testdata/snapshots/darwin/stack-light.png | Bin 0 -> 6604 bytes testdata/snapshots/darwin/status_bar-dark.png | Bin 0 -> 6276 bytes .../snapshots/darwin/status_bar-light.png | Bin 0 -> 6200 bytes testdata/snapshots/darwin/title_bar-dark.png | Bin 0 -> 4559 bytes testdata/snapshots/darwin/title_bar-light.png | Bin 0 -> 4463 bytes title_bar.go | 38 ++ title_bar_test.go | 82 +++ 53 files changed, 3399 insertions(+), 1 deletion(-) create mode 100644 app_shell.go create mode 100644 app_shell_test.go create mode 100644 aspect_ratio.go create mode 100644 aspect_ratio_test.go create mode 100644 container.go create mode 100644 container_test.go create mode 100644 divider.go create mode 100644 divider_test.go create mode 100644 docs/components/001-app-shell.md create mode 100644 docs/components/002-title-bar.md create mode 100644 docs/components/003-status-bar.md create mode 100644 docs/components/004-container.md create mode 100644 docs/components/005-stack.md create mode 100644 docs/components/006-grid.md create mode 100644 docs/components/007-scroll-area.md create mode 100644 docs/components/008-split-pane.md create mode 100644 docs/components/009-aspect-ratio.md create mode 100644 docs/components/010-divider.md create mode 100644 example_layout_test.go create mode 100644 grid.go create mode 100644 grid_test.go create mode 100644 messages_layout.go create mode 100644 scroll_area.go create mode 100644 scroll_area_test.go create mode 100644 split_pane.go create mode 100644 split_pane_test.go create mode 100644 stack.go create mode 100644 stack_test.go create mode 100644 status_bar.go create mode 100644 status_bar_test.go create mode 100644 testdata/snapshots/darwin/app_shell-dark.png create mode 100644 testdata/snapshots/darwin/app_shell-light.png create mode 100644 testdata/snapshots/darwin/aspect_ratio-dark.png create mode 100644 testdata/snapshots/darwin/aspect_ratio-light.png create mode 100644 testdata/snapshots/darwin/container-dark.png create mode 100644 testdata/snapshots/darwin/container-light.png create mode 100644 testdata/snapshots/darwin/divider-dark.png create mode 100644 testdata/snapshots/darwin/divider-light.png create mode 100644 testdata/snapshots/darwin/grid-dark.png create mode 100644 testdata/snapshots/darwin/grid-light.png create mode 100644 testdata/snapshots/darwin/scroll_area-dark.png create mode 100644 testdata/snapshots/darwin/scroll_area-light.png create mode 100644 testdata/snapshots/darwin/split_pane-dark.png create mode 100644 testdata/snapshots/darwin/split_pane-light.png create mode 100644 testdata/snapshots/darwin/stack-dark.png create mode 100644 testdata/snapshots/darwin/stack-light.png create mode 100644 testdata/snapshots/darwin/status_bar-dark.png create mode 100644 testdata/snapshots/darwin/status_bar-light.png create mode 100644 testdata/snapshots/darwin/title_bar-dark.png create mode 100644 testdata/snapshots/darwin/title_bar-light.png create mode 100644 title_bar.go create mode 100644 title_bar_test.go 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 0000000000000000000000000000000000000000..d646dbecfd813afc6b18d1547ad8e2a48e3a8ed6 GIT binary patch literal 17599 zcmeIaWmFyA)+V~K;O_2j3GVI$2yVgMgF6Jb;O@a4vT-N4LvVL@ciFwq_n!Bh^P|VT zeS38G=pT1gjRN+7s#SAMdFEWtB;t#rG%^AK0ssI&mX(oE0RSLB0ss)uaFF0nTH?}4 z004CthbqUFuE#UwRjR~W+OU1)Jjy3u zUT$4~RWF}2}Fu00*5$)!~F_Y;LkiigvYJ?AwrQZnQ3d5iIksZGq~`)(cIT-8R$HY5BWm3dGjPyFlA_IOS!!?CjONrmH1bE zN=iybMr%}Rwf$OSNwdT_3dq@rgP@7FWPmlR>I*blyv**{#81<(1Y$Wix}`kR2N`*{ zc=U2#t6Y@Bjgyj|{P=*bWMfJJj(_+ii`J%XTH-wf81r$zLW4OAyNr;J zB1C-ygCHYh3Yfe6-njTbdrxVT*o{R() zdQ_vmXH!cVuixke^Rv}PDWFb4?iw=Av(2YUc?k}uzPLlHouI%yACe1w<>4l2rSoOqpA8Ln$*!Ys zZ>AU#T2yX|3W7Y~DNSVa`)1X@jZzM(m&XMc-L!^;WqdAvenCDZ*3J+i3r`m^+WfAM zj$Yo{`oh;C#ND*na7X&oLpQiG#IHY2wz3Z^4DGbY>|%J*tE6H$pP!vZ1+7K zOPMdr=Sra{_}^~Kw|2{J2nbwEq)?TV-e&tH6I{2POoQd{nNo+_Gcz+|iG_))5!647 zwhj4&A1=o>Wo11wsE2oT*-lhiDGO3kV_f~pgR0hlyXp?Yk)JT)FgG`U`^J^oVa`vE zdYj4igm07D`TX3rzi&>V!hl`YmXL!t`>=cDOzKy!&hlV@CG35kbtJ)IHq9PE+JQdD z?sSuqbegpq0Oi$HYv1yPE{&UezlvKwGW-j*ot*xCPb-<^?*vKUh5c7~35&_S;Lr~t z!E~)dJ~;i8DDcW29?gRiN~Z+HtH86~!2*iW5eAKF{hFDhrOa9%YNqTqIzKlLkHf6y zy*9Uthm-A-Qzp}Y*`zBGRmiNi^?-d*|*zpC(j zI~ggN-_TxS8n0G=48yWDO5Xk8Ac$tv=;+)!lc*h-?guI=DoP{)K|@m}B|`)S(#Y

~vkZWVEr~;Y9>uVL#Z$exaqU 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 Q1{Xk9Qc%rNl30Zsi4)Bpeg literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/app_shell-light.png b/testdata/snapshots/darwin/app_shell-light.png new file mode 100644 index 0000000000000000000000000000000000000000..e6834ac799385377db4efa8e12ceab7ac0b3d87c GIT binary patch literal 16677 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

0UR|nzK*IE8^Km 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 literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/aspect_ratio-light.png b/testdata/snapshots/darwin/aspect_ratio-light.png new file mode 100644 index 0000000000000000000000000000000000000000..97bf95886d99e07587593d64f1b03aff0ab1b6db GIT binary patch literal 3363 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 literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/container-light.png b/testdata/snapshots/darwin/container-light.png new file mode 100644 index 0000000000000000000000000000000000000000..604f644cde40bd238fda91c7545b3d0818861b60 GIT binary patch literal 9861 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 Dwdz~J literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/divider-dark.png b/testdata/snapshots/darwin/divider-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..52e118718f67d9c1d88a81ea8bd9fa4e9c72fb46 GIT binary patch literal 6227 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 literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/divider-light.png b/testdata/snapshots/darwin/divider-light.png new file mode 100644 index 0000000000000000000000000000000000000000..edf9be8be0fe653d210ee81cf146a7c787f3c9dd GIT binary patch literal 5932 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 literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/grid-dark.png b/testdata/snapshots/darwin/grid-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..122e08b8746236fb09717b2efeb673a2181ff3ee GIT binary patch literal 6551 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 s3}*lTNWcHTU550wsG%>W$qzXuHA_@r| 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 literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/scroll_area-dark.png b/testdata/snapshots/darwin/scroll_area-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..7dce19f2a56f8ac0219b9e59e8447d18f7ac2102 GIT binary patch literal 4054 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 literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/scroll_area-light.png b/testdata/snapshots/darwin/scroll_area-light.png new file mode 100644 index 0000000000000000000000000000000000000000..551db14adf34774845571ad3b853a7d205550c02 GIT binary patch literal 3613 zcmb_fXEdDK8YWRb(MGh<4JT2f2O)_Vogfh{5=8Gr8xduKFN8rxgorK}?cj)uAh<>s zC5#?3B3g`IMzrD1Ie+e-v+g=S?%MC#>s{|!d%f>|o@YO6?f82pdd!!2FHuoZF&pS> zn^93wOH=ZE-~wg8!P|YEQeQOC)-->Xy^asN_rOY!emn4@JcFbmdI?s6w_u}r=>mBG z*`a>3Yc@6^;`^Z4sJPkJT!bdv@47jhyIC<9W4Iw{7_mNfsiuroTescv+t83XS2K={ zhSP}sDvV7F4o6(b{x~o*^syQClN`CF@hdy^3t3DE+34mNxk)!D5H0*_4O<{~2LS(% zF{Jg=#59(zwr|X3EF|O>aaEqZSu}`N)0!J2yZKlUx&EnUL2Q@iLemc=&^5I$e+{al z`IAo-pY+8AI%YOzRzGRh+%oA|10~tSw05LEW1XD;gAKt$TDS_lY>f)KpgAQNJO4EF zG?K$sm;98Cr8zk`<`omZx~|pPR0b)b z2?RoJuAo|guM+QIWi_AfI)VaMAU7Cv|Q^#jR!?rS5BB}v}EMwV&#}}0u6WTIc%iU1M{*Q z{r8BOzmEqT9bho7O%z&sdBL-@$UW^q3ky#*%_`f9t@ZT*>zYK!2eY!;iQluhTfQgp zduVAn&8dY}los7-Exgkz`g}9Gzd!J5s)yHn)u$td8hMn8PygiVelj;(z={bDlN36P z;%faQxwhEXHmack$*NQl7Jjl;f101aYG=n@TRX`H_r)Kp+tR~@otfcq{qEiUVU4!3 z5%-h0a>-zK_ufPv9FbVHvf`7Yu(Wct`#8rWul9_UTs`8cE-5u!S5Nr%X(uFb8EhdR z*L`um}b!78n-!HvvPW5v>%(?F_^TsoGWnu zeo8VmEO@d?k%^s48C50PTZ%eChT@Uv-~6#vwqfH$#>)ojea=ncM}}42qOe6-p|?Fl zuS31&vOsia=an7CbW5vAXV+G3VCR5K@S5zxL4;^Y%?`rdUEh<)2%<|%^&{-Bd=6R- z2n##ud%*p<HOg!M0rm6qR5 z(f?RpQEe@K%fq8DqN(-$qN0J!+?m!!6aJ93hX>XUQ*7K<_ct(z*SX~4*`J_;`q3pT z&737LgRE>;wouHC#^F_MNv10_!S0HBqgmibsldn(&y{pVPR3W zu&~1T(hkv#cP8e3UzOlcGvt9ScD=Ktqo-#MS3P?t+u3qVocaD;IL*-ZVd&=f_Kvl{ zNU2l<m-nS;xPOD@q4k;MW))b@M0s;ScC+}xJ+QG8R= zTJebhYcNv>P22zzQ?~TGm4g=0y-wh!;A=a1XpV^x4-f3J&NuIREH;O;$F$#h6#Y=K zqQXnrk09qd0PQ)iAQGRSPH9&|U*Ua+hrdnChX3k}FKlhqD8x-=w0AR|D7*3_b2<#{ z)1R+T#X0pnm$u`dQ_(Yf(CIc@p3HxPFg6x`Jf`Qr$3B$Tre6&m_)=m2FpD(PFh>YI z+-{EZnY;B1RR*FD4^^`&Lr}l}QBbhcog{1N?A*WCz}lFI{_(@C?ilOUmU7eGtD^e+ z_tvx8=D}-0Y-x}*v`gC8op>eDjqhPwVq&NhFK(FgMzvzyO!7x9Ar6VR;kRW^NWm4B zF8kaD&r=qrtUj0g@x;wAfUZNX)|Hl(1c}ba3kaWT3O<4^!3gk{A-3qR#sM}A+Nk!cR^G| zB{?pRJ_q`${mEiL;|1zG6XD*{-VIr=JjPrty~4uFXWCbdcQGXznL7(TBRJU3KEJ6c z+u|yV+i$+n7wMCQG+sb}sQ+K&If62NfRl1oR@T-B7!xlxAC}0-iwz3NYUsTriq$bN z!clhQY~|rEBnzc=UIZxP4HVwvK$6DiSkeSa+aeo_wEd6w-zOwI2x6m{+;zZq91;t; zEFeJIj3WE{%iq3zyHCPVLu2mb*CtL!md=!XeU7x}Omckw)5tiCvpb`~4)JHcIP zjH_D$(yE-C==c=9XEOW^w4hNhgrLy;d*WVR)wiF0MI1Pdzke;sEqio+>Z>6Mlbpv{ zG1!hmmQ9OKE-@l}4SV&^;4rj^7p}C2b}j2@LkFl-TwSYx+}KV z?g^)W3~b$rMw&4fqoQP|?|~`Qw(k=GDCfXp7q<*hn)(8`s44oJa78j-Z-(>^*H>?k zCx#u&>>r^D_A<&&4Yer6!e3=(@(BnCxDBVfxwN3)DCp$UQqIyjoH9Cu-<~TnV6P~jwXoQH zs&Q8sq8hobO-x)Brg3l-;rDnc$H+a@M)cRSdsOe8d-G_dLNH|c1MM!yUpEV@))#2Z z#OQEhIkG5`5@xJO91-^X{)lg*@Mvy^#5rXQ3Z_MntI=}h zkmbkIM@>z03JS~KcuUo=`dg0y+zgQ&Zf@D6ar|s$wAko2-YWbkpsu`pcqp&IZ8(ly z%mm=Z@@pVQ7aEm=iB3b;D;O{BF1ZnsMvICtLmBE@JdOX3;Sn5wlG+m~tQ#8-!e4EC zLpZx#n``xn>j^Y&?J(&zaWBfb*8_*@+^@1fcUdau^qN$YeSA2-G5BaWTOxwEI=ve~ z;Z6=9NrHX78GA$8=enabN)$qCXwAjih=1p3&3O_b+KZ z0T%3mLJynV!YAa|;1qf|iNuV<7^iLRJ&C1P)O{{jXau`!W;VLd^IGWEu0=3uI4Q|k zW^z@^Dze{h_rp)*Wp$W(fjb+s~BjT&YV6U(BP0H<5lQaNw5 z%t_5`tj4iyyyn32t8O4EaaW zt|%QrHu#y9F7wV-iYom-bilts=D!{q{)z2Ok!mfhb{1*`K~uhcR0cXG+Ewr;(fVgezLMAL+e zk{#`pCF_(~>&`2qs6}Kuj~<-2PT1eI>XexZ``}1RY|)UTn*It*oaEnbciKUH?o5 zw&^FXKbj%9;xz=zAYeb4hg{!zdvio8uky^O|fV zHDi6KwGG5Zl+4^KdzH^FG@=}Cl(8uOO{Kp=n{cM0^tpWUQ?T?&sG=Cvc~Wl@BnCdX zG@Vq}&`gBA%3ImsZFmUA+V4Zd^x(}d(;I7d#9!R;0)Ev6sbVeh%rg08#lBofYg@Oq z@{8$OPro54f87iemjzT0`IuG=c+OoU&#h}fIgvx7N_|?#AUN5c_~9-O3!u1wvH3YD zA4tGBOTp{!70*@yw5LcmFt31M^pT&GX^ht$9bKal{Z*2Lvu72y{op1JUQ=7LcS$i$ zi(x(jvvj&)%j$J6)~CqvA^&0&vjaX!FeX-QU3 z?^}WTj6d>;#$iWjjEf_b1iKciu`9G<34@7DGtUQ}@IR55a(sh zdiE#|s^$H(s+HIZI}*>?5j){VE0;IBSul)0pON9+)NB588y(@oYN%$nt1bmiHgHmS^Lhuoi$JisKVplDH&fJUdF?v z07An4!NIq+FD36Cn0o$3R5-W8*wfov+cUZ0-J)TxDF6U(`&l!y@e`pd1%RR=?Ch*U zcNm$6lvdm1cfm;^?q9~zNltO`ZO5a7u&1-IkCG_OjS^dX8i&0R2vq3Jk=d;0_4WNm zGcCuPVo7)3`R`Vy>SW4-!e({ytCqnK-VgP5gE>r*1Hr*Z;qdeEdFywYCn#)T{N>Bq zfSoOy+py@xPVD<}#7NKj2a}uX075?rUASlGHc9UvYQ!_SCsQ!GQ*1l`;#=>kEMv={ z2@OGv-Fp?<^rCB3cuHsA?q+1~3$r_nvOl&srdOu9{qD^Y5mM zumdc{gQ**Xz#4ZgeXWN28HnY%)V%!bEHr_VD8prXg_eCiBz1gzZ}$p~oSL=R7aFMg zVT>*t@*rJENszPRA}yzbs!mxq8Z7y=X;h+bW5Eyd@C&C#{h1#XC2F9m5+O(4ir9xJ zH-?54QopEA7qwtYDvM@W=0f7>oe2!5^o?*sl{Q%T%CqeeC*Y!@k1`B)8?ellqh&vP zb~_@@PaGT`4mg3z5|IKJBu^(!G*+@S3#XJZo=hi^6n=vWlB`}h8=+(%&Mkki9{h`0 zLfv|+|3sodoMHFcw}+5J@l*6TR7Z9nCmNTZfBnD#gH<4hBqb$q!TDI*Ui5mToTg9Z z>yWr_^)`wh6N?_W)Le`-eK{OD!%?&O{+*9lu@EFQnWW7I| zBWPW&c`?9<9HxIVgjMWpzTJjEOB6W96=DF#4slke=~|yZ^wO$K&f!e5>DG2D2H^Xh zrJ3u!cRNduc5fQBt4cUaygy2yE%T zH%MAE>rVCBfSNfh`aBz)%q>M51 zvE`XFdT^YBS+wFzsa>GEm93F+YZv*g*QIU^492?UWvP$=-(WLh+4L0F{3QdkYJ$D@ zGR~kq#OcY$TV(|1L79wEt8)3q9caf6=HB7JYgOtkKaJSIjJ4Ru%;mdj4@+O9J-n(c zfixBV_sy5nQ@aCfjnd$L8IrCH%(PfY1JkqRC;vbGI|{(vPzBReLdw{|JBmEr)kG`g zwIF_M%>Kgh+&_g_U0@Hw*?i=oxP3RdcguqOAZphURBehp(?jro6;6+O04Wi%d1d%iX YdHf)IV#NLvga6!bWd=Uz%$7U z@e%|cftvYXZ^iy&Mu`wa7^eB8{YU5zBZ53x?;I^sUk`#X@*$4 z_hQI2@?e@ru}y$?5ZV8TlFsPr1@#vjI%B((H(dL(upyy(=5G`&C?I}pSQ|AXJ!c785 zH#f=RFh+XH;4^D{WF!pbB6;CiY}PNQMMlDo*&^AbK^Zavm*9mad9kG95M?3ZQcT2HgkH5(VZzrhUnEZI(0Y4I zkd-PQ~j{LBry@~UXOzu0WWnmtO*VnGZG^DX<2q-8HZLEKshl&dx^RRU`@!wN?0F zNQ@0!?Kj5qjs&)giaK>+cWBZLbwBkkYkVY!y5r&^uV=;Xb>&Q;(XE8ig4Nnq)QO9+TCropd#SxSIo9*qw9d~}_PUYsn4o#r{J{1b zdVV7x&_167(H#H5!`qAjQLXzpS$TO!_?LMQBu0)L*DGv>s|&(8|JN7?xa zPfhPOg%Swb(OJ(ul5S8dtB4l8mXq0;;tb=>(LW?r`z04jm5VjbDAzxA8ttE;ZWGMR zlOs(gA#~aWb>Nb+q3hZk>oFYai}+oV;j#Rh8Vk?;B#a&sOeh+5DBRui1MCk~py^zp=5+%p2Y+Wk4M2G%Zv`98G0!pHoept4dEtprW8U#&7jiF@HAg z7ldE6uRnoMI#_Hf#~_J`vkMqTh~9%(yk{f+$7!j|ULWm_z}11{8LG!DEG@0hl-Jad zOm=|Da$|wzaa2Z5e4$7BjiS=h;65BR#Q!9ytPJYw=hrL40f`%g#Y8!CH#W!9=5wdZ z;xX6Z@(@1Y5$#&f%9S|-o4L*Q#>OmnLP>VLBYAv0?V@IIklldq$e}~KSS(gja&~*k zORE_y2xIF7&G9Q6vWK42%Um5setXtXQU(;~#(ykaU}oG&cxYYyd5TIsZWoPyOqzcf zYhxQ9lZ=;9RQr&gX}Of1sTh}X5l(pdGK#yOyt!!-x3>DVLu4V4h{YK@e5v%k^CCfA|%XUI@ry+M3OLxXF1p3m;(T5LLY zrxP5Y*4WSxZRlZZ%R>sc`WVD*cw?K;>a&PId>cFp?A4@xs?IwV1LNCgFL~f1`{5F3 zW&4_Yl%eUJFb+&kZW)b2CNQLl@vF#bdr3*Ui)Nw{DV$C(2%R40G`-iPeR*=;#KkV5 z_3?#I_w~A4e0t|z56>K6O@t(!h9O*5k6L@YiRWMW$^wTO#bY%a-gsDEyue5_*jem8X- zmNm3UrBYi2qPW_ao)0}|+o;3|I-Rn(&Lt7gDt`w?U0hsRPtm%@!h(Z|i|f=wX0KQU zb2IRdQhIVLWi<1JrJwu|+(@0>RuoH3wt)W-FJu?B=V++his|N_?F>jqz@9RVAtDD9 zDgMvTDJ{>Ntq5E8I`{&3lK$w9eySNJqXKFDKmT_Sz;*%}*p>kO#(F+pdCLHeQ#yw& z-eeKD$(y{^Ou?%;vP`KIYHbqKdWh9sq9q)Y{-ik>1b7KTCf|v{{%6%aS->Zo@r%{d zAbzL{9q^&tVW?Y07DtMC#{vz%a|YhboC#V9+e2H|&zvKM*`c;r7a8ZQA}2so7I-yG z?qDaE##LB1gTj`rCiQDkNa4yH4V!oXFBb@t? zX@enjbK@wCj59Zf_Y^anYuf9a$o_8n%WJ-^`dA4w+H1?W!V)SUTl`Yr6|snKDuVwq z{42M-7s-^;GJVK>7#+Y-AGWRg{{sisWY3>%%yZW?SFl=F(){n?wX(2*Hko-n{tK2# B$3_4E literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/stack-dark.png b/testdata/snapshots/darwin/stack-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..fbe69a34ca48f6eeac842f5997319bf171220ab2 GIT binary patch literal 6786 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 N$Vn?pRX|LF{tJqE;z9rb literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/stack-light.png b/testdata/snapshots/darwin/stack-light.png new file mode 100644 index 0000000000000000000000000000000000000000..01bc2506484786531f266b1a5beaefab19f8ff9c GIT binary patch literal 6604 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 DMF4&p literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/status_bar-dark.png b/testdata/snapshots/darwin/status_bar-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..4c7aceec6677b700ebe1d9064586edd252424c6e GIT binary patch literal 6276 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 YpF?Di6S*UX`dt9zq~TH(5+*_a1z?cAM*si- literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/status_bar-light.png b/testdata/snapshots/darwin/status_bar-light.png new file mode 100644 index 0000000000000000000000000000000000000000..169e081fc748873639aac6a17425a95fe30f549c GIT binary patch literal 6200 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 JYLyqU{{;`{&&mJ* literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/title_bar-dark.png b/testdata/snapshots/darwin/title_bar-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..cfae176c3fb6e50bf213f58f845727691ece53c5 GIT binary patch literal 4559 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 literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/title_bar-light.png b/testdata/snapshots/darwin/title_bar-light.png new file mode 100644 index 0000000000000000000000000000000000000000..e516140413f1279a7f07049d801de9896e2f792a GIT binary patch literal 4463 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 literal 0 HcmV?d00001 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}) }, + }) + }) +}