diff --git a/app_shell.go b/app_shell.go index ee6850e..36fdbb5 100644 --- a/app_shell.go +++ b/app_shell.go @@ -33,8 +33,7 @@ type AppShellResult struct { 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. +// AppShell lays out title bar, sidebar, content and status bar; *collapsed folds the sidebar. func AppShell(c *ui.Context, collapsed *bool, opts AppShellOptions) AppShellResult { if collapsed == nil { panic("mujica: AppShell needs a collapsed state") diff --git a/aspect_ratio.go b/aspect_ratio.go index d886a64..643cb3d 100644 --- a/aspect_ratio.go +++ b/aspect_ratio.go @@ -8,8 +8,7 @@ type AspectRatioOptions struct { 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. +// AspectRatio centers the largest Ratio frame that fits; the child is centered, not stretched. func AspectRatio(c *ui.Context, opts AspectRatioOptions, child func()) *ui.Element { if opts.Ratio < 0 { panic("mujica: AspectRatio Ratio must be positive") diff --git a/divider.go b/divider.go index 3e4bdda..b2443c5 100644 --- a/divider.go +++ b/divider.go @@ -52,7 +52,7 @@ func Divider(c *ui.Context, opts DividerOptions) *ui.Element { return e.Children(func() { segment() if opts.Label != "" { - ui.Text(c, opts.Label).Font(theme.SerifFont).FontSize(theme.CaptionSize). + ui.Text(c, opts.Label).Font(theme.SerifFont).FontSize(FontSize(c, theme.CaptionSize)). TextColor(k.TextMuted).SingleLine().Shrink(0) } else { ui.Box(c).Size(dividerDiamond, dividerDiamond).Shrink(0).Role(ui.RoleNone). diff --git a/divider_test.go b/divider_test.go index ab3db0c..709a78b 100644 --- a/divider_test.go +++ b/divider_test.go @@ -105,3 +105,16 @@ func TestDividerSnapshot(t *testing.T) { }) }) } + +func TestDividerLabelTextScale(t *testing.T) { + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Width(300).Children(func() { Divider(c, DividerOptions{Label: "Chapter II"}) }) + }, 400, 200) + plain, _ := tt.Find("Chapter II") + tt.SetPreferences(ui.Preferences{TextScale: 2}) + tt.Frame() + if big, _ := tt.Find("Chapter II"); big.H < 1.8*plain.H { + t.Fatalf("label height %v at 200%%, was %v", big.H, plain.H) + } +} diff --git a/docs/components/002-title-bar.md b/docs/components/002-title-bar.md index f7fc838..9b9dd1b 100644 --- a/docs/components/002-title-bar.md +++ b/docs/components/002-title-bar.md @@ -24,7 +24,7 @@ mujica.TitleBar(c, "Court Records", mujica.TitleBarOptions{ ## 状态 -- 高度取 `c.TitleBar().Height` 与 `控件高度 + 2 单位` 中较大者(默认密度 44 DIP)。 +- 最小高度取 `c.TitleBar().Height` 与 `控件高度 + 2 单位` 中较大者(默认密度 44 DIP);标题字号随系统文本缩放,放大后标题栏随之增高。 - 左右内边距在 `c.TitleBar().Left/Right` 之外再加 3 单位,不覆盖系统按钮;全屏时预留为 0。 - Surface 底色,底部 1 DIP Border 分隔线。 diff --git a/docs/components/003-status-bar.md b/docs/components/003-status-bar.md index 8e85639..620b910 100644 --- a/docs/components/003-status-bar.md +++ b/docs/components/003-status-bar.md @@ -32,8 +32,8 @@ if bar.Clicked == "bell" { | 字段 | 类型 | 默认值 | 含义 | | --- | --- | --- | --- | | `ID` | `string` | 必填 | 稳定键,也是 `Clicked` 的值;为空或重复 panic。 | -| `Text` | `string` | "" | 状态文字(12 DIP)。 | -| `Icon` | `*ui.SVG` | nil | 前置图标(14 DIP)。 | +| `Text` | `string` | "" | 状态文字(12 DIP,随系统文本缩放)。 | +| `Icon` | `*ui.SVG` | nil | 前置图标(16 DIP)。 | | `Tone` | `StatusItemTone` | `StatusItemNeutral` | 语义色:Neutral、Success、Warning、Danger、Info。 | | `ShowProgress` | `bool` | false | 显示 64 DIP 进度条。 | | `Progress` | `float32` | 0 | 进度 0–1,超出 panic。 | @@ -45,7 +45,8 @@ if bar.Clicked == "bell" { - 默认:Surface 底色,顶部 Border 线,组内条目以细线分隔。 - 操作条目:悬停 SurfaceHover,按下 Selection,键盘焦点环。 -- 折叠:按“右组末项 → 左组首项”的顺序隐藏 `Secondary` 条目,直到放得下;显示 `+N`,悬停提示列出被隐藏条目,无障碍名称为“另有 N 项”。主要条目永不隐藏。 +- 折叠:按“右组末项 → 左组首项”的顺序隐藏 `Secondary` 条目,直到放得下(按实际宽度计算,操作条目至少 28 DIP,含条目间距);显示 `+N` 按钮,无障碍名称为“另有 N 项”,点击或 Enter/Space 打开弹层,列出被隐藏的条目,其中的操作照常可用,执行后弹层关闭。主要条目永不隐藏。 +- 文本放大时状态栏随文字增高。 - 进度:AccentText 填充,Border 轨道,描述为百分比。 ## 事件 @@ -54,10 +55,9 @@ if bar.Clicked == "bell" { ## 键盘操作 -Tab 到达操作条目,Enter 或 Space 触发。纯文字条目不进入 Tab 序列。 +Tab 到达操作条目和 `+N` 按钮,Enter 或 Space 触发。`+N` 打开的弹层中 Tab 依次到达隐藏的操作,Escape 关闭弹层并把焦点还给 `+N`。纯文字条目不进入 Tab 序列。 ## 限制 - 折叠依据上一帧的宽度和文字测量估算,窗口缩放后下一帧生效。 -- 被隐藏条目只在 `+N` 的提示中列出,提示仅悬停可见,键盘用户通过无障碍名称得知数量。 - 不提供不确定进度动画;需要时在 `Text` 中说明。 diff --git a/docs/components/007-scroll-area.md b/docs/components/007-scroll-area.md index 947ab19..40f742e 100644 --- a/docs/components/007-scroll-area.md +++ b/docs/components/007-scroll-area.md @@ -30,7 +30,7 @@ mujica.ScrollArea(c, &offset, mujica.ScrollAreaOptions{Label: "Log"}, func() { ## 状态 -- 内容溢出时区域可获得焦点,键盘焦点环画在区域内侧(2 DIP,Focus 色),避免被父级裁剪。 +- 内容溢出时区域可获得焦点,键盘焦点环画在区域内侧:先留 2 DIP 间隔,再画 2 DIP Focus 色环线,避免被父级裁剪。溢出状态在布局后变化时组件立即重建,焦点停靠随之更新。 - 不溢出时不进入 Tab 序列。 - 滚动条为 MyGo 叠加式滑块,颜色取主题 Scrollbar(TextMuted 50%),指针悬停时显示,不占布局空间。 - 切换主题、折叠周围布局都不重置位置。 diff --git a/docs/components/009-aspect-ratio.md b/docs/components/009-aspect-ratio.md index 5992bd9..8837483 100644 --- a/docs/components/009-aspect-ratio.md +++ b/docs/components/009-aspect-ratio.md @@ -38,4 +38,4 @@ ui.Box(c).Size(320, 240).Children(func() { ## 限制 - 父级高度不确定(随内容增长)时只按宽度计算。 -- 框尺寸依据上一帧的父级尺寸计算,窗口缩放后组件请求下一帧完成调整(MyGo 布局的 `AspectRatio` 只能由宽推高,不能同时受宽高约束)。 +- 框尺寸依据上一帧的父级尺寸计算,窗口缩放后组件请求下一帧完成调整(MyGo 布局的 `AspectRatio` 由已定的宽推出高,或由固定的高推出宽,见 `ui/layout.go:345` 与 `:364`;但不会在父级宽高同时约束时取两者中较小的一边,因此组件自行计算)。 diff --git a/examples/gallery/pages_layout.go b/examples/gallery/pages_layout.go index cb295e1..a6ebd67 100644 --- a/examples/gallery/pages_layout.go +++ b/examples/gallery/pages_layout.go @@ -17,7 +17,7 @@ var layoutPages = []page{ {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."}, + Keys: "Tab reaches action items and the +N button; Enter or Space activates them. +N opens the folded items, Tab moves through them, Escape closes the list. 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, @@ -46,10 +46,7 @@ func layoutCheck(c *ui.Context, v *bool, label string, log func(string)) { // 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) - }) + 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)) } @@ -70,8 +67,7 @@ func layoutSlider(c *ui.Context, v *float64, label string, lo, hi float64, log f 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) + return ui.Column(c).Height(h).Border(1, mujica.Tokens(c).Border).Radius(4).Clip().Children(fn) } var layoutSpaces = []string{"Default", "None", "XS", "S", "M", "L", "XL", "XXL"} @@ -266,7 +262,9 @@ func layoutStackDemo(c *ui.Context, log func(string)) { layoutControls(c, func() { layoutCheck(c, &s.horizontal, "Horizontal", log) s.wrap = s.wrap && s.horizontal - layoutCheck(c, &s.wrap, "Wrap", log) + if 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) diff --git a/examples/gallery/pages_layout_test.go b/examples/gallery/pages_layout_test.go new file mode 100644 index 0000000..3e3a569 --- /dev/null +++ b/examples/gallery/pages_layout_test.go @@ -0,0 +1,48 @@ +package main + +import ( + "testing" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" +) + +func layoutPageTester(t *testing.T, id string, events *[]string) *ui.Tester { + t.Helper() + for _, p := range layoutPages { + if p.ID == id { + return ui.NewTester(func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + ui.Scroll(c).Fill().Children(func() { + p.Demo(c, func(s string) { *events = append(*events, s) }) + }) + }, 900, 700) + } + } + t.Fatalf("no layout page %s", id) + return nil +} + +func TestLayoutStackDemoVerticalHasNoWrap(t *testing.T) { + var events []string + tt := layoutPageTester(t, "005", &events) + if err := tt.Click("Wrap"); err != nil { + t.Fatal(err) + } + if err := tt.Click("Horizontal"); err != nil { + t.Fatal(err) + } + if tt.HasText("Wrap") { + t.Fatal("Wrap control offered for a vertical stack") + } + if err := tt.Click("Horizontal"); err != nil { + t.Fatal(err) + } + if !tt.HasText("Wrap") { + t.Fatal("Wrap control missing for a horizontal stack") + } + if len(events) != 3 || events[1] != "Horizontal: false" { + t.Fatalf("events %q", events) + } +} diff --git a/scroll_area.go b/scroll_area.go index 1802827..83046fd 100644 --- a/scroll_area.go +++ b/scroll_area.go @@ -22,10 +22,7 @@ type ScrollAreaOptions struct { 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. +// ScrollArea scrolls children; state keeps and sets the offset, nil keeps it by place. func ScrollArea(c *ui.Context, state *ui.ScrollState, opts ScrollAreaOptions, children func()) *ui.Element { if children == nil { panic("mujica: ScrollArea needs children") @@ -45,16 +42,22 @@ func ScrollArea(c *ui.Context, state *ui.ScrollState, opts ScrollAreaOptions, ch 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) - } - }) + overflow := state.MaxX > 0 || state.MaxY > 0 + if overflow { + e.Focusable() } + focus := Tokens(c).Focus + // The ring goes inside, past the focus gap: areas often fill a clipped pane. + e.FocusRing(false).DrawOver(func(p *ui.Painter, r ui.Rect) { + if overflow != (state.MaxX > 0 || state.MaxY > 0) { + // Layout changed the overflow: rebuild so the focus stop follows it. + c.Invalidate() + } + if e.FocusVisible() { + in := theme.FocusGap + theme.FocusWidth/2 + p.Stroke(ui.Rect{X: r.X + in, Y: r.Y + in, W: r.W - 2*in, H: r.H - 2*in}, focus, theme.ControlRadius, theme.FocusWidth) + } + }) if opts.Label != "" { e.Label(opts.Label) } diff --git a/scroll_area_test.go b/scroll_area_test.go index 4beb683..27e61e8 100644 --- a/scroll_area_test.go +++ b/scroll_area_test.go @@ -2,6 +2,7 @@ package mujica import ( "fmt" + "image/color" "math" "testing" @@ -116,3 +117,21 @@ func TestScrollAreaSnapshot(t *testing.T) { }) }) } + +func TestScrollAreaFocusRingKeepsGap(t *testing.T) { + var focus, bg ui.Color + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{Mode: Light}) + focus, bg = Tokens(c).Focus, Tokens(c).Background + ScrollArea(c, nil, ScrollAreaOptions{Label: "Log"}, func() { ui.Box(c).Height(400).Shrink(0) }).Size(100, 100) + }, 200, 200) + tt.Key(0, ui.KeyTab) + img := tt.Image() + // The edge's first 2 DIPs are the gap; the ring follows inside it. + if got := img.RGBAAt(50, 1); got != (color.RGBA{bg.R, bg.G, bg.B, 255}) { + t.Fatalf("gap pixel %v, want background %v", got, bg) + } + if got := img.RGBAAt(50, 3); got != (color.RGBA{focus.R, focus.G, focus.B, 255}) { + t.Fatalf("ring pixel %v, want focus %v", got, focus) + } +} diff --git a/split_pane.go b/split_pane.go index 5d0dc32..cc4dc3f 100644 --- a/split_pane.go +++ b/split_pane.go @@ -33,8 +33,7 @@ type SplitPaneOptions struct { 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 names the pane that may collapse. Collapsible SplitPaneSide } @@ -65,7 +64,6 @@ func SplitPane(c *ui.Context, state *SplitPaneState, opts SplitPaneOptions, firs 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 { @@ -78,9 +76,29 @@ func SplitPane(c *ui.Context, state *SplitPaneState, opts SplitPaneOptions, firs if opts.Vertical { e.Column() } - total := e.Bounds().W + var res SplitPaneResult + e.Children(func() { + // Sizes come from this inner box: the caller's border and padding stay outside it. + in := ui.Row(c).Key("in").Grow(1).MinWidth(0).AlignItems(ui.Stretch) + if opts.Vertical { + in.Column().MinHeight(0) + } + splitPaneBuild(c, in, state, opts, lo, minSecond, &res, first, second) + }) + if res.Resized || res.Toggled { + c.Invalidate() + } + res.Element = e + return res +} + +// splitPaneBuild lays the panes, divider and drag handle out in the inner box in. +func splitPaneBuild(c *ui.Context, in *ui.Element, state *SplitPaneState, opts SplitPaneOptions, lo, minSecond float32, res *SplitPaneResult, first, second func()) { + k := Tokens(c) + u := Density(c).Unit() + total := in.Bounds().W if opts.Vertical { - total = e.Bounds().H + total = in.Bounds().H } hi := max(lo, total-theme.BorderWidth-minSecond) clamp := func(v float32) float32 { return max(lo, min(v, hi)) } @@ -88,12 +106,11 @@ func SplitPane(c *ui.Context, state *SplitPaneState, opts SplitPaneOptions, firs 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 { + } else if once := ui.Local(in, "split_pane.first", func() bool { return true }); *once { // Not laid out yet: measure, then size in the next frame. - *first = false + *once = false c.Invalidate() } - var res SplitPaneResult resize := func(v float32) { if v = clamp(v); v != state.Size { state.Size = v @@ -114,7 +131,7 @@ func SplitPane(c *ui.Context, state *SplitPaneState, opts SplitPaneOptions, firs el.Width(v) } } - e.Children(func() { + in.Children(func() { if firstShown { a := ui.Box(c).Key("first").Shrink(0).Clip() switch { @@ -202,11 +219,6 @@ func SplitPane(c *ui.Context, state *SplitPaneState, opts SplitPaneOptions, firs } }) }) - 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. diff --git a/split_pane_test.go b/split_pane_test.go index 463c883..37bf08e 100644 --- a/split_pane_test.go +++ b/split_pane_test.go @@ -188,3 +188,26 @@ func TestSplitPaneSnapshot(t *testing.T) { }) }) } + +func TestSplitPaneSizesInsideBorder(t *testing.T) { + st := &SplitPaneState{Size: 150} + var second ui.Rect + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + SplitPane(c, st, SplitPaneOptions{MinSecond: 120}, func() { ui.Text(c, "Files") }, func() { + second = ui.Box(c).Fill().Absolute().Bounds() + }).Fill().Border(1, ui.RGB(0, 0, 0)) + }, 401, 200) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnd) + // 399 DIPs inside the border: 278 + 1 divider + 120. + if st.Size != 278 || second.W != 120 { + t.Fatalf("End gave size %v and second pane %v, want 278 and 120", st.Size, second.W) + } + tt.Press(1+278, 100) + tt.Move(1+200, 100) + tt.Release(1+200, 100) + if st.Size != 200 { + t.Fatalf("drag to x=201 gave %v, want 200", st.Size) + } +} diff --git a/status_bar.go b/status_bar.go index c12f496..d6fcf54 100644 --- a/status_bar.go +++ b/status_bar.go @@ -3,7 +3,6 @@ package mujica import ( "fmt" "log/slog" - "strings" "github.com/egoist/mygo/ui" @@ -52,29 +51,36 @@ type StatusBarResult struct { Clicked string } -const statusBarProgressWidth float32 = 64 +const ( + statusBarProgressWidth float32 = 64 + statusBarIcon float32 = 16 +) // StatusBar is a window's bottom strip with left and right groups of status items. func StatusBar(c *ui.Context, opts StatusBarOptions) StatusBarResult { k := 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). + e := ui.Row(c).MinHeight(max(theme.MinHitSize, d.ControlHeight()-8)).Shrink(0).PaddingX(d.Unit()*2).Gap(gap). Background(k.Surface).BorderWidth(theme.BorderWidth, 0, 0, 0).BorderColor(k.Border). Label(msg(c, "status_bar.label")) hidden := statusBarHide(c, e, opts, e.Bounds().W-d.Unit()*4, gap) + open := ui.Local(e, "status_bar.open", func() bool { return false }) + *open = *open && len(hidden) > 0 var res StatusBarResult + item := func(it StatusItem) { + if statusBarItem(c, it) { + res.Clicked = it.ID + *open = false + slog.Debug("mujica: status_bar clicked", "id", it.ID) + } + } 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) + if !hidden[it.ID] { + item(it) } } }) @@ -83,15 +89,23 @@ func StatusBar(c *ui.Context, opts StatusBarOptions) StatusBarResult { 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)) - } + more := statusBarMore(c, len(hidden)) + if more.Clicked() { + *open = !*open + slog.Debug("mujica: status_bar hidden items", "open", *open) + } + if *open { + ui.PopoverBase(c, more, open, func(panel *ui.Element) { + panel.Margin(d.Unit(), 0, 0, 0).Padding(d.Unit()).Gap(d.Unit()).AlignItems(ui.Stretch). + Radius(theme.PopupRadius).Background(k.Surface).Border(theme.BorderWidth, k.Border). + Shadow(0, 4, 12, 0, ui.RGBA(0, 0, 0, 0.16)) + for _, it := range statusBarAll(opts) { + if hidden[it.ID] { + item(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) }) @@ -99,9 +113,30 @@ func StatusBar(c *ui.Context, opts StatusBarOptions) StatusBarResult { return res } +// statusBarMore is the "+N" button that opens the hidden items. +func statusBarMore(c *ui.Context, n int) *ui.Element { + k := Tokens(c) + b := ui.ButtonBase(c).Key("status_bar.more").MinHeight(theme.MinHitSize).MinWidth(theme.MinHitSize). + PaddingX(Density(c).Unit()).Radius(theme.ControlRadius).TextColor(k.Text). + Label(fmt.Sprintf(msg(c, "status_bar.hidden"), n)) + internal.Ring(b, k.Focus, theme.ControlRadius) + if b.Pressed() { + b.Background(k.Selection) + } else if b.Hovered() { + b.Background(k.SurfaceHover) + } + return b.Children(func() { + ui.Text(c, fmt.Sprintf("+%d", n)).FontSize(FontSize(c, theme.CaptionSize)).SingleLine() + }) +} + +func statusBarAll(opts StatusBarOptions) []StatusItem { + return append(append([]StatusItem{}, opts.Left...), opts.Right...) +} + func statusBarCheck(opts StatusBarOptions) { seen := map[string]bool{} - for _, it := range append(append([]StatusItem{}, opts.Left...), opts.Right...) { + for _, it := range statusBarAll(opts) { switch { case it.ID == "": panic("mujica: StatusItem needs an ID") @@ -116,38 +151,39 @@ func statusBarCheck(opts StatusBarOptions) { } } -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. +// statusBarHide returns the secondary items to hide, last first, 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 + if once := ui.Local(bar, "status_bar.first", func() bool { return true }); *once { + *once = false c.Invalidate() } return hidden } - all := append(append([]StatusItem{}, opts.Left...), opts.Right...) - need := func() float32 { + u := Density(c).Unit() + group := func(items []StatusItem) float32 { var w float32 - for _, it := range all { + n := 0 + for _, it := range items { if !hidden[it.ID] { - w += statusBarWidth(c, it) + gap + w += statusBarWidth(c, it) + n++ } } + return w + gap*float32(max(n-1, 0)) + } + // The bar's children: left group, spacer, the "+N" button when shown, right group. + need := func() float32 { + w := group(opts.Left) + group(opts.Right) + 2*gap if len(hidden) > 0 { - mw, _ := c.MeasureText(0, ui.Span{Text: fmt.Sprintf("+%d", len(hidden)), Size: theme.CaptionSize}) - w += mw + gap + tw, _ := c.MeasureText(0, ui.Span{Text: fmt.Sprintf("+%d", len(hidden)), Size: FontSize(c, theme.CaptionSize)}) + w += max(theme.MinHitSize, tw+2*u) + gap } return w } + all := statusBarAll(opts) for i := len(all) - 1; i >= 0 && need() > room; i-- { if all[i].Secondary { hidden[all[i].ID] = true @@ -156,19 +192,29 @@ func statusBarHide(c *ui.Context, bar *ui.Element, opts StatusBarOptions, room, return hidden } -// statusBarWidth estimates the width an item takes. +// statusBarWidth is the width an item takes, as statusBarItem lays it out. func statusBarWidth(c *ui.Context, it StatusItem) float32 { - d := Density(c) - w := d.Unit() * 2 + u := Density(c).Unit() + var parts []float32 if it.Icon != nil { - w += 14 + d.Unit() + parts = append(parts, statusBarIcon) } if it.Text != "" { - tw, _ := c.MeasureText(0, ui.Span{Text: it.Text, Size: theme.CaptionSize}) - w += tw + d.Unit() + tw, _ := c.MeasureText(0, ui.Span{Text: it.Text, Size: FontSize(c, theme.CaptionSize)}) + parts = append(parts, tw) } if it.ShowProgress { - w += statusBarProgressWidth + d.Unit() + parts = append(parts, statusBarProgressWidth) + } + w := 2 * u + for i, p := range parts { + w += p + if i > 0 { + w += u + } + } + if it.Action { + w = max(w, theme.MinHitSize) } return w } @@ -212,10 +258,10 @@ func statusBarItem(c *ui.Context, it StatusItem) bool { } e.Children(func() { if it.Icon != nil { - ui.Icon(c, it.Icon).Size(14, 14) + ui.Icon(c, it.Icon).Size(statusBarIcon, statusBarIcon) } if it.Text != "" { - ui.Text(c, it.Text).FontSize(theme.CaptionSize).SingleLine() + ui.Text(c, it.Text).FontSize(FontSize(c, theme.CaptionSize)).SingleLine() } if it.ShowProgress { statusBarProgress(c, it.Progress) diff --git a/status_bar_test.go b/status_bar_test.go index 7c5f1e3..0afcfd4 100644 --- a/status_bar_test.go +++ b/status_bar_test.go @@ -1,6 +1,7 @@ package mujica import ( + "fmt" "testing" "github.com/egoist/mygo/ui" @@ -83,13 +84,19 @@ func TestStatusBarCollapsesSecondary(t *testing.T) { t.Fatalf("no hidden count named: %q", tt.Texts()) } // Medium width drops only the last secondary items. - tt.SetSize(460, 100) + tt.SetSize(400, 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") + // At every width the visible items stay inside the bar. + for w := 320; w <= 800; w += 10 { + tt.SetSize(w, 100) + tt.Frame() + bell, ok := tt.Find("Notifications") + if !ok || bell.X+bell.W > float32(w)-8+0.5 || bell.W < 28 { + t.Fatalf("width %d: action at %+v", w, bell) + } } tt.SetSize(800, 100) tt.Frame() @@ -98,6 +105,104 @@ func TestStatusBarCollapsesSecondary(t *testing.T) { } } +func TestStatusBarManyActionsKeepMinimumWidth(t *testing.T) { + var o StatusBarOptions + for i := range 16 { + o.Right = append(o.Right, StatusItem{ID: fmt.Sprint(i), Label: fmt.Sprintf("Action %d", i), + Icon: icons.Must("bell"), Action: true, Secondary: i >= 8}) + } + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + StatusBar(c, o) + }, 560, 100) + shown := 0 + for i := range 16 { + r, ok := tt.Find(fmt.Sprintf("Action %d", i)) + if !ok { + continue + } + shown++ + if r.W < 28 || r.X+r.W > 560-8+0.5 { + t.Fatalf("Action %d at %+v: narrower than 28 or past the bar", i, r) + } + } + if shown == 16 { + t.Fatal("no secondary action folded") + } + if _, ok := tt.Find(fmt.Sprintf("%d more items", 16-shown)); !ok { + t.Fatalf("no button for the %d hidden actions", 16-shown) + } +} + +func TestStatusBarHiddenActionsByKeyboard(t *testing.T) { + var clicked []string + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if id := StatusBar(c, StatusBarOptions{ + Left: []StatusItem{{ID: "main", Text: "A long primary status text that fills the bar"}}, + Right: []StatusItem{{ID: "fmt", Text: "Format", Action: true, Secondary: true}}, + }).Clicked; id != "" { + clicked = append(clicked, id) + } + }, 300, 300) + if tt.HasText("Format") { + t.Fatal("secondary action not folded") + } + tt.Key(0, ui.KeyTab) + if !tt.Focused("1 more items") { + t.Fatal("Tab did not reach the hidden-items button") + } + tt.Key(0, ui.KeyEnter) + if !tt.HasText("Format") { + t.Fatal("Enter did not open the hidden items") + } + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnter) + if len(clicked) != 1 || clicked[0] != "fmt" { + t.Fatalf("clicked %v, want [fmt]", clicked) + } + if tt.HasText("Format") { + t.Fatal("running a hidden action did not close the list") + } + if !tt.Focused("1 more items") { + t.Fatal("focus did not return to the hidden-items button") + } + tt.Key(0, ui.KeyEnter) + tt.Key(0, ui.KeyEscape) + if tt.HasText("Format") || !tt.Focused("1 more items") { + t.Fatal("Escape did not close the list back to its button") + } +} + +func TestStatusBarIconSize(t *testing.T) { + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + StatusBar(c, StatusBarOptions{Left: []StatusItem{{ID: "i", Label: "Online", Icon: icons.Must("wifi-off")}}}) + }, 400, 100) + // Padding of one unit each side around a 16 DIP icon. + if r, _ := tt.Find("Online"); r.W != 24 { + t.Fatalf("icon item width %v, want 24", r.W) + } +} + +func TestStatusBarTextScale(t *testing.T) { + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + StatusBar(c, StatusBarOptions{Left: []StatusItem{{ID: "r", Text: "Ready"}}}) + }, 400, 200) + plain, _ := tt.Find("Ready") + tt.SetPreferences(ui.Preferences{TextScale: 2}) + tt.Frame() + big, _ := tt.Find("Ready") + bar, _ := tt.Find("Status bar") + if big.H < 1.8*plain.H { + t.Fatalf("text height %v at 200%%, was %v", big.H, plain.H) + } + if bar.H < big.H { + t.Fatalf("bar %v shorter than its text %v", bar.H, big.H) + } +} + func TestStatusBarRejectsBadItems(t *testing.T) { for _, o := range []StatusBarOptions{ {Left: []StatusItem{{Text: "no id"}}}, diff --git a/testdata/snapshots/darwin/app_shell-dark.png b/testdata/snapshots/darwin/app_shell-dark.png index d646dbe..12f83cc 100644 Binary files a/testdata/snapshots/darwin/app_shell-dark.png and b/testdata/snapshots/darwin/app_shell-dark.png differ diff --git a/testdata/snapshots/darwin/app_shell-light.png b/testdata/snapshots/darwin/app_shell-light.png index e6834ac..716b444 100644 Binary files a/testdata/snapshots/darwin/app_shell-light.png and b/testdata/snapshots/darwin/app_shell-light.png differ diff --git a/testdata/snapshots/darwin/status_bar-dark.png b/testdata/snapshots/darwin/status_bar-dark.png index 4c7acee..8a0d68d 100644 Binary files a/testdata/snapshots/darwin/status_bar-dark.png and b/testdata/snapshots/darwin/status_bar-dark.png differ diff --git a/testdata/snapshots/darwin/status_bar-light.png b/testdata/snapshots/darwin/status_bar-light.png index 169e081..fc4e58c 100644 Binary files a/testdata/snapshots/darwin/status_bar-light.png and b/testdata/snapshots/darwin/status_bar-light.png differ diff --git a/title_bar.go b/title_bar.go index af6e1e5..ee83d64 100644 --- a/title_bar.go +++ b/title_bar.go @@ -14,14 +14,13 @@ type TitleBarOptions struct { 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. +// TitleBar is a window title row that drags the window and clears the system buttons. func TitleBar(c *ui.Context, title string, opts TitleBarOptions) *ui.Element { k := 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). + e := ui.Row(c).MinHeight(max(bar.Height, d.ControlHeight()+d.Unit()*2)).Shrink(0). Padding(0, bar.Right+pad, 0, bar.Left+pad).Gap(d.Unit()*2). Background(k.Surface).BorderWidth(0, 0, theme.BorderWidth, 0).BorderColor(k.Border). DragWindow() @@ -29,7 +28,7 @@ func TitleBar(c *ui.Context, title string, opts TitleBarOptions) *ui.Element { 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). + ui.Text(c, title).Font(theme.SerifFont).FontSize(FontSize(c, theme.BodySize+1)).FontWeight(600). SingleLine().Grow(1).Shrink(1).MinWidth(0) if opts.Trailing != nil { ui.Row(c).Gap(d.Unit()).Shrink(0).Children(opts.Trailing) diff --git a/title_bar_test.go b/title_bar_test.go index 18202bb..6268419 100644 --- a/title_bar_test.go +++ b/title_bar_test.go @@ -80,3 +80,21 @@ func TestTitleBarSnapshot(t *testing.T) { }) }) } + +func TestTitleBarTextScale(t *testing.T) { + var bar ui.Rect + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + bar = TitleBar(c, "Archive", TitleBarOptions{}).Bounds() + }, 400, 300) + plain, _ := tt.Find("Archive") + tt.SetPreferences(ui.Preferences{TextScale: 2}) + tt.Frame() + big, _ := tt.Find("Archive") + if big.H < 1.8*plain.H { + t.Fatalf("title height %v at 200%%, was %v", big.H, plain.H) + } + if bar.H < big.H { + t.Fatalf("bar %v shorter than its title %v", bar.H, big.H) + } +}