From a8412f4f9c7ab4837b4cf0b7cfb3544ba4a49f2e 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:32:10 -0400 Subject: [PATCH] Layout: review r1 fixes --- app_shell.go | 3 +- aspect_ratio.go | 3 +- divider.go | 2 +- divider_test.go | 13 ++ docs/components/002-title-bar.md | 2 +- docs/components/003-status-bar.md | 10 +- docs/components/007-scroll-area.md | 2 +- docs/components/009-aspect-ratio.md | 2 +- examples/gallery/pages_layout.go | 14 +- examples/gallery/pages_layout_test.go | 48 +++++++ scroll_area.go | 29 ++-- scroll_area_test.go | 19 +++ split_pane.go | 40 ++++-- split_pane_test.go | 23 +++ status_bar.go | 134 ++++++++++++------ status_bar_test.go | 111 ++++++++++++++- testdata/snapshots/darwin/app_shell-dark.png | Bin 17599 -> 17609 bytes testdata/snapshots/darwin/app_shell-light.png | Bin 16677 -> 16690 bytes testdata/snapshots/darwin/status_bar-dark.png | Bin 6276 -> 6321 bytes .../snapshots/darwin/status_bar-light.png | Bin 6200 -> 6264 bytes title_bar.go | 7 +- title_bar_test.go | 18 +++ 22 files changed, 381 insertions(+), 99 deletions(-) create mode 100644 examples/gallery/pages_layout_test.go 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 d646dbecfd813afc6b18d1547ad8e2a48e3a8ed6..12f83cc61b41ae4ead9a076feb10867f48c93af7 100644 GIT binary patch delta 1565 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 diff --git a/testdata/snapshots/darwin/app_shell-light.png b/testdata/snapshots/darwin/app_shell-light.png index e6834ac799385377db4efa8e12ceab7ac0b3d87c..716b44475fd7f7701f967f5486208f198a28e271 100644 GIT binary patch delta 1547 zcmZ45#JH)6ae@lt&yA`Zt?D;AD=|1|bTobb-LA;OtI_0=AaEv>Ve|5JhwPk`RA`Rs z>0k#wE9zRs+8>o<3EeT2(p*gxD4 ze4iC9cWdh6 zHMMuuDj~t+g?(!(vrk=*tcI+ zU3(|!@3nKXq4T7aZvC&@TgC=7&GDU6nzf?f#Df_IsA_o<*T)BM-i?5|v{{=EL-9p>NPR#v1u zmMO0*n9yM)TlVwVlk?2aJ6>d27@1hi*qhM!jm5`f#srvUna?<17DVqpn9$SWd~o7} zTc1i@p4iq_9&igtBNmtEKFy7d05UP;yLfchZMKuKn^Z18 ze2|+dZ~9I<_NPzR(M8u^ueLEQbG)Fp(9<(3Gpp{~&#S-ypQMqu`mUGDwBI_{kNvw* zn)zn>#+9+tTh{3Oy|XiYZ>@N(xLMAv6?5HziHuWL2$Jg3E-KnSD!Fy(a=-lFp2QhH z`BTefY}NdH>U-j)@9!?YKJ$H5N@Q55scG;0#h2&4c+qp;aVB58idXF1_!FD{l!k|^ zh+Nv&Ge__G!fV^ui;H^HCYSL*LWV)eVHT?g&taR`ZJVAyF@JdRqPW(z?ng(YqqDYO zt>STWj!Se*3c4ihubaQ-_T!aRzLS>xkexDR&YwqZUBa^UbLX8}WxTvfCi3$Z{l%^C z9<82u;>zpF8;4|~XC!kM=Op&&p4r&?)Be_>KgXV^99Ho$bqk({$@hjFQ4Z2j)-YXGut1nzv_GWML_oLlkeVb z3SGUxK+vSg?E;6?-N#aQ=W@R66Xt7;nC5FZdn+?6<{2*4GYT~+a5Vjy*J8bgf8s-9 g>6h`WFSj!?Y)P!vxc6w~AqF7uboFyt=akR{0KBD<`~Uy| delta 1536 zcmdng#JIGHae@ltn~ka)t?Ji1D=|1|a5a7Y-LA;OtI_0=5TMU|X2bGzhlGR_1!<1z z>0k#wE9zV{*NR!9as?bin+R@*idI?;>nU-u4;ObQyvPn%u@A^so5xgTCCUTaf3!qaY{p{ z-*cXv$rFyAHRMh0xXlrEkt0fbyZOXTAd9uw@#W{UbO5J|BTITZFik~OHf7kwF zxasf-K4t5kHwIwF?%O?YCSMfus$UZAC}sD)yXEk$e2yjs4x!UBZ`#{ zJ(h|5d~M45&7by^eLh$8=fuQ2fBvY?k6RY&;Oqp4vr3(VLbn!qf3>zQ52?)R$oSv> z^_DD)qX5fJ_L;fPN=nTa6TD1j3i+N6TeIT+*63-eZHc$+b6>vyI!m|y+0&&<&n1Zq zvi|vZt)Ksge*CtGwW+J)Pnwo)5m0fBo8sd~ufD3SYig)k|7T zpEq%%rQPLV5!dz4ckTbDW+lhV#dzb!BBmUYouJGH@y|sx+`nV7PQfVX6I;iAje2EbDfN?Tqyn^N-mt*Xt(z+U~-; z$)={?Oxxx6+h47GbNb>$!D-2lPphYzNccRhoa-lkJ5p8UgIV;SmrqK>&EKpG7Fl}# zb?l2{8~6@e@w_%zbGUxPJ3HBHcMUz>O;Vl7$DZ%~dr4vZ%Tv#Tbj0??#XnuSk-O=e z$gC+#rY(E+JU-^FUZhs%qiwg=x};PdD9U}aK6v`EI_cR~rJg5FT{=Iv`r7UCi{*)j zm-XJNeKTQ$^C3P?YZVY<)=|EfhRZKcn^*Veg*eaQGxaLFAHOgxOiKDwf9uD+xx3H( zxuAS{)2dY~GyllUedpqy{zr7m%L0!@SFZk?s&o2&S*fd&;@h()UKYt_|9YmX$C%fWk$KFV_c0mOcyVSI(aIEp5w%*cwxznZK&!`V5W>KC?I^ z<38gtN3l%ezx90v`wcBm8gK?nFJ9@sMqQI@`E~!_-fEsAyl#uj6uPxvRnz%O5| UA#E@rfB^_RUHx3vIVCg!00}5rG5`Po diff --git a/testdata/snapshots/darwin/status_bar-dark.png b/testdata/snapshots/darwin/status_bar-dark.png index 4c7aceec6677b700ebe1d9064586edd252424c6e..8a0d68db9b1d83e86de85e671fd4b5e08ceba1f6 100644 GIT binary patch literal 6321 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 YpF?Di6S*UX`dt9zq~TH(5+*_a1z?cAM*si- diff --git a/testdata/snapshots/darwin/status_bar-light.png b/testdata/snapshots/darwin/status_bar-light.png index 169e081fc748873639aac6a17425a95fe30f549c..fc4e58cf18674c85a2f4d55d7a061092e021f517 100644 GIT binary patch literal 6264 zcmd^ERZN^=v;J_36fI7X;_eF+cPUVcQ=G+J7MB)x*u{z!DDDo$-HWqWDbONY9E$tl zT%6mJ|Nc)VlX)kZWai29&P+1V@6{D?u_&+r0KipNlKTJv$UM)s7$)+wzM3Sp2LLij zWjSeWpX`HWIxJ*q;^E(#JAen4TRX}eH7&^6%11S=VQZ8!k zs|MG({p-Eg+|(GHsB+K4zT(S3VbfV=D5!F$@&o98#1{vDmuo*qt>vucywViICOOD+ z|I123{8mU?TdG(66>8b%PmSllt{NCB2sLHjUU02PO7czpn6w79pJn-4d+k=zzS`Or z|DHUCI0FDM-?DEW<=hh4gm#|PXJoV|c{zH`tszccUd8tqm^j%~%>t;2!`|t%aCc!% zN_sb8qp7t{0KlUu6#t&pcAmI?aqgcdPt5W@PpUUKp|;j^irM(~2Jt?3rT_qJh?42) zA1f^yS{#?7NOtX|*|<260mmf*d0Kn`u&5KZm)(j;t>AES93kd2#7a^EmmqO5SzDJD zgkpyGm1G!P2KsHDTqi94BXDupk7A&gSAGHU%(fTG(lWMmwu%-d9znuc^cYKp?+S|% zKp>>_bdpyyIY(R=r=M$#BpwIu#j6^x$wF0fAFts=a;Wfu!rB zh98d$mjf=xVF8?(6qKnt+SBuTC=tKgRt$9n1$`rkd2klpg( zeh@=EN$u>&5y@Omzeq$Z!bok+Z{x~D+eCX~`-1M>Dhn(n8}m9w;D7pgaDb#z0sx5Y zpD<~pz4=p=xo6*qX+eHnHP_xg@l>u@bfU<> zwmV(jQqo|ykUxo<7SY53=Kq2|@x$=nPxtx!4eSm-FT{@{e6kSKduzSpR+M25Igd6p zb@|_|50d;im4E%1N{_Q>YtAn^oE)4CifJkcP^EvW4>A@S4O!ITqu#XnqTi_vnYSyX zeGdp)`l?2{aJ-;B_t(+*DaFc4Oe~nm?PdjDiZUQ~JhK3=JN^lBzo3|Bw(|V+w2}Xn zu3<-sf<1cTg$dZsGQN1tV{;5Xj(~;9(0!I7+u1xk5<#7?!-%{*nk$@Bym|a2+J%GD z-vX;)%#jdZ9UitMn+>s%40iKbm%t1`3+O_O5cCIj%+%LQ;isJF1y~QsN+d=On&5jpsk~u`YrT=$T3^NZqI9z zilM*9efR4y8AtVg#Sto@Am1VgE0-YdcFhB^Tu^ui!i%VC;4#}tA)Z+8T9{l~ZK%P{ ze9A>p_yiUMfM2iu9E4nufz>XrPFil4mF(q_0ihQ!;5|HIATadn=g*B9_Uu$23{1ZI z(9^L946M%bAqL0)a(=AxU*YrgoDT5){838Fz0T$%aeItfkxG^#X2{FK<$a=!;{%~Q zS`fFrC74DL<4_|Yd4TAJ#%rs|Y{^7Bi~Hj5!c2a6(Q@|bR4S7i0SD$z)Q4>=)xMTS z%A;9dy_#+J#KjLCRaMsV%8p12g`hb3;Gn`52qdfjjS+g=ua3;h;%cFwy_DE!gvmC7 zoG;@UF}8}*l=QU6K;i1f!m#o(5$nos$kf+(TwJKb!b6)MW8&~ns@K1TmL7KCV7B-j zLHj}{ZvRFLU*C^_AuRf_h{#5uc8v^mEP+7`0QM8{K_oZ1O9ZvKRVqB(>wG#%7W|)8Y`YndfI5RCAi3l(= z?};-xf143CAcQ@pLZ5In9(ZOLsbISGc66}Vdt+#OF7l>rnXdcJbX2+3B}P{*E7#Pp z<8U5|kiQW(*fHNM*`I4d0^CLs>Z@%W_*f-4)bU4%c8u7;^5#`FIl>o_{S`s3Hp;hS ze6PtJ932^8O2s=n0Kl(`O%GjKS{jk^cpF++b@$G{s5+lO*yi%HaeL8~yhp=$-sc3a zNdO@7;E#b3w%6CINr%w>J(~EaW&Owy-S^C^M46LW{3LuA^q>cNlqXqomH#{5_}IQXb=H{n-S zRvxk;@OgNFkeuQ2$`4aQ!{M?{e|9t=2|xIqma0SVERQPX{&y4%s5$}aqMI=OKcwa6 z+PsaD8|mAKh?0bd_=T010=u5n;=jZ--#VLiYZ~hml@|~3N%sjWaOmm|^?TC^TWZY- zc{6XET{*3n6<5C|rm%3dZv)x4OVMteUdQ8J0Kn_?D;Duz13w&ZJ6d&KZI^K>o?rhi zh_#uDCp+YKnmlNn_aCW~s*)2uyB-`;QMZ}L6sfZ)tqV!c&7K+inbX6=vEk-Wp`sbt z!BY0&i4;F#knZ%io`y@T9J09OTLB_o+z4|wbt3FY6~#A&4TE_qYI0mNpnieV$i%)g$ed&ffn zqj}2#VoIJqpCC5ex;ezyfRz|ajg#{|Ms3}`h?^fRVUGVPJ>BD%JN!iQvk+lH_$hCl zErEfLk;&|+R@debL6k9nA`6MEukXz}lCw=;xinsWZf;YKh^`&XUH7pNIaFCYs}a?f1LqJgzLT{QmPV- z=4;K4kpX!6ODYG<-25uP-WbddWiD!Slf;gU?U^J$Nr9X|WH`m8-vn%bX6D4|e!1X* zX~i=Xe5*PO5(!{DO@H`!BAl()jifrP!TjgNOmDZUF5~;C!fqF%ErAZ|l_|*RkrHOe zl8eK0Z+l#95B731M`>YQ^5!Fn#l^*>dK^E%QoqQtNsM^=^2CiC2hcx|6tw+_!FYLB zU$qZXTGWS*QhLjs-rU2!(T`HFBY(C9$2DHRv)tTIuSrQu>8Pqm{;};zM{QfPXYa82 zascP-QddVyHn6I+teOs{_l%p15Ifg9#>9mvs!(}=e3|wmlaln z0p!ivB6msEtE}}IipHhNoq+#tAEy$mqtU&!;>0CN3}!L8;sPteXJx4mWqTYXx*%y# zonQpC;e9S~TGRL;zvhLY6%WJFS@#R?$)j1J#s#Yt7zRlWn^aAzt*U-hnb?(8c6~1-N^~x!; zr}rplUK*n+k9{_%+{@G=EBW3mG~IxWYr*$v5WoK;0H8_XWIFB)GBuQNE zwj@QBCWQFHGIaW_gE{z*rwixyg|n{$$zLk!7*ne?#Wz2;FL+!4EEkCJh39ofGWm2OQ`GV>Fzq&7riTv^Psu>2az`Swc}50-x)bF>mFU3 zJW>-o8Zm_q3X|asHPnn(4U^Z5sYbk|f6zkL(TY^iFls=l!l_f;^q6+8Jk? zOn!Cs%h2BUW%G#@Bjb|G1L&ruO`rS+P~T(_$4GL|O93g^L@BRBM+I=bCv&t+fH>?gturip&mfjLy+GUm6P z80ZQLFvfsDD(Xwe*gt1zS~e4MvEvpa0))|WdfY~yS5&anuWkn?P#q{`Pl-y4ez4wR z6#-?!fuz*je8879uH?J*tua#v2R4&nA2j@B^ z9|ntWXHgj>fnpN}7^Iy_;Ye{v)NvXr0(QNZ5CHI<#{}Mg(CIq#`j_jGPO-m7s*_^9 z(@nK65ywp^!&6vO6hHiX6E>A^)jJ);^}H=)`K(4|#3BK@3PUbg6ht4YMr#-`TX z|4k=^v-@S}JHLg1QySWLhWmv=eR|&c&6QKV!=D_rvD^q$YXAs2M+AroP`9NTvG1YqENlu zKa#*#gXGXEr@7Irv`=+u(9AEQC(?-f>FKL$Y3e|(sOnwr)Nx4e)bAzjl)RgM;f68g zNCb^-gpMwu{;Yq?Xtmwb>X9(~0C_wo#|r0r zj=)|gDup}l`j2F#Q7yxhwZ-!U9w8*}bIG~VF- zV`E{@AnPJd?2jJ!Ll>C+uqrkxHyid8Z&dG3L=p?diD0md?VL{kUr_tP5Ux*cZ9gSAlAdzZnt{kqZ~cK>qKvIf$84m)?ZDH`eRM|tc%reasgK_ z%8hMtl^y%L0Dqm~->Y42C_t^(4VJRM;J-*-*$BM1WSwtq-J*VM&7I6h#K*_qc`IOF z2arYFU3$YRZ@gTOOic{iEa$8x5^c=D#KPWQSq@#PDXGr_)c^!Qf}5_wf&E@`vl2ph zcvtDm2DW=NPoz81%!JOy?OY_$65dF^Pea8XL83i0kP&P?^^iQF@Xm@y1Q$-fARNxX zchgT>`Dx1ZY(M5xG!a8=w6YfsmpaQ+)cc8w(dP!C-(q#9ryqDy zbJH@+^TVmSgr#C!NB`yZ>X53UuBb2C9@(-h8`f1dnIIRKW_(C~G7z$8-b`l7!h+D= zpZyCXf3q9TMjT@vML*0_k>9GE@h$9O!-6O(iFP2eDbQO$Kp-lt&+Edo+m;6hG{0C; zUKaQ`bg;1ArdCm2x!UM=cw1WKByo-<{a!Fjt!QSgvv}jzZ%kC=4~Wd^Nh+Toz5RaT zNv%uuv-Lf@TJOEvp&;;c+1)5AZ%Rh9SC4pb(BI5$`kbCG&n)Rv8;g%(-*!c1+cS{+ znBGi$jU`+W|6cg@A`WL0zJG#^N5ibEz(rk)l8UTZKA2418eKsI`4B!jSk z+Lq(_&8_RpS5KtNkr0s1LbE#zY1H0PbmNsu=?Sgrc{$ zJdp5qJ{@Lo2LKpuwA7SMVep;boMdrIbn#=SPSTFS1j36XS&yuUTa{};j}WaSBH~yb z$8<)S6%lO5HILR%^;@FUXks1?0#Z#SLYn_K|0fRa#N{3+Iv1@E<9N)>eQb?%it7CF zLFsF73!#Y6RcXsXu$TOHzo(`&5A%n6gYizPUparLdi<|T$NZloSwfe}b}~# zhUMc-WL5de_gbOn?Z8*d2PI_R#2aMBrU@EV9#})HkunvHUWRqC?_57ke@8B>^u#z@ zX2)`f(*n7bPTFs-4i2(D9UV zTp>x`Mzr|do+ADR{BN=zERLax=huK0@t5W@j)U#oSK zcpAe2Onhi`yXV7=Kv)pFjL$Olt0Hp1vQM+Uy^i_o1p6*d&EE>YhFby^emll~Nh!d{ z1UFd^3TpAw=H}*DpM{@yPG^``?nS3+@?@uoI~faZfLL;vm`FT5E!~tReOuJeNi4Or z_LWjM@ZfCI_n#7|%hl3P|9#K}cd^{i_bl3dFy%v5U^Sh^(PA92{|F*UZ6d(3!tLiwcX3x$x7yI6w*jvFI4`WbDyC>1c+c#gnexEarCQELo%w9g#gbHG-z#jlWb2b8|h- zFjYrxM90$&X6hD$e4aj5S|D+)?n{S0t6C>9J;+`1lY~yTr+B%CVN*@pq6r_^VAqnn zDX;DWDH7E^y*f~R1~)W`6r^oAOIZeXYdvPQKxAX!_L?CabLd2(F6*yUZJzPGNWM4S=s$ zLKZ%>8y}b4#n)LZcZh8@QYU0C~(v^oEJG-^xP%m49j-Z1NYFtJ}M%k;_ zZP^eS`c0yK%Koj<&54)cv9h6elvCvd9}DNnVb`#aecwF>6mmS?hj~1r;C4uJ#Z9J! zUxwFxfA>Qa9w08ZePG8`M4iyz0)y#Nk@c*#c9@a*AByiOp5eAN(FZx9!l(W0(rJT# zlH{5kI|%O6M!vH&SD-mL3}AkOp;;qqEMbbaja6i&CZ z)r(NIu&96e(o@ha$n9gI;;7xR(LL)aoZ@~81dE_$>ZE3uLUNLyGAB|!rEtuhO7P=g ziTr1Wks<_?DEw+WT0R*Y_%+>E`7_fQb*e7=wTdq#XxDVJlPq@J z4a~v79HIJ3PjR0Zu+Z*X|L1o-iB2W}Fu?P*?dR7w5AO8!?M@MVLmv2g1|FZ+YZX1@ zuJrIAJH$B#?KU!R{21Ln{CYPu5I7!K$xONX8@2B9?VDST1KcVrBKFxwul`pAhF zM$EmEwm$cvY?Sn-*2`%us@rm7=N%5$oTbV`Q*PiV(7Dlbwx+A_(y@E_(t)J_V{DB& zI6C^lQWg>#dRc2PQGoeeT^)aZ$2yhn$BK{p)|Mq{Svk_$(mGL%3ms!+;3HdMV^X8zWnJRL}rVqK$zP;JKsKdJ(MZ(EieFamt^)H^Yl;b{%k|Y zZr?qXqJLPSd3iqAlD_^JVyx|pULZ(}Mb6jT&E12JG>)>H)mMfkPCKt&pd=YZ0=T66 z=8S_pO)l0T1bFd(X%i62W22jO_BVU*APO#PZH}|CNZC|`($9~tuC#}#pU3$tT4&|w zSlJlzWV}fvC-Y=s3BOVtE1Dnx3|dqpFj}A9^gf?JEHwuy;N}CpBMr;z_}L2DCe3Gn zsL-r{&R!oFiL~M9VHh`XGWrrW4lX-J%R{x!)Pgo09SUI*c*ci;3W@W$cN6Iuu%8R*5 zMHfo6W{w_Ns%Hz{*;%1=iJ-JkF!dG&-P)ja92mX*46J`SKYiP z1Pqb0Ey}m%nHV`K%#mkk{ibB=R=9+s4r_XmjY$? z0fm(a?E(bU6HN%PWUhBG4~Ov45CC}1M4Iy6301&~AjLIVV*OK*P6H=_t%pS_rdgZIqtRf$X%+4#w1Cu9zk{7TsKZ;Q{F#1Bxf_#ZADQD5 zmv@?6D+k2KkMl(U05dbQuHqL$LPBaZgFlDJ059Ae^>}Ekt*k(n0UQHiZB0j3IaXGj zI+>R=5w&ZZu2BE07nidq%55#5c;mF!r>BDiHAN{ftr0Fu*n&DCp;5@&Ua~A!_|=(j z7L>C6urcBL*5`iuXyTaSh-!B+>U5*(2_?i< zwHwW%x0aQY?W}mcZAO;tX+P%d?~o=Cgdw=6_iOaieGl@)?y%Fhy$(f&lRv%h0DvSv z(eEkzbx?8kC>g1#&E0(gYz-R8ARKvAhlwrKVJpQzFysqv6rTb&+;xP!Iow}I)`~Zm z##_K~%^!mWl&RGo(MkG%#US2A70@UU=xulLT0S-k^GUn+jtAt?n;iP?k~UGI-PnQ> zoVzwsXAF}sGTib@v~jrQVm|RFB*v}}Hga`6c6|2r^1^kgvt0P}x|cKt*%yW5<@zXn zSn)8ip3tI4RB!hKZSg48-3+P2iu=TS;$RO?{O%9G__|$q4Xq~Kw>F4Hk4nJjDBj3+ zf_Y!xNoVfFv>AOc;#H`Ys*baaZMpuN6oE#7eq7&}{s_I6TmRRr3E!KD`#tOPV9{O$zwb%;!to0QwJUy}lS z_T9{n&LjxvnJ;=$qY1g1fOE%DSz7t~GofVe4QcZ?1m7NC~#tgmFQ`%T-Lf!ih=?3pF7L8XN&Rv(!LhR#q$f z6$!N1kwnST8pCppUa@Rzi!W4`wvDftech)Yf08}KkoIssj5FVP4*RapznTk*pe*7otPIH~-~Fh-X*E zjnoK;*j=vsowp-LZMbWcN2gCVFGFC2U9Wq^B_3zKk*lI&1~Cp|#$5<*bET; zw!WiC>{(7gcjf#SW|?(xiGH_goInkN4lv-dbO)I5Sd`fuzBuMgZk_8aF8IuBB5dz; zwkRS#y89+0$HvsC(f&E;wrU$C{i*-FXb(-cQ|#Or5GG{XSLlm4u8dQQ)z&==#@Uk6 zac$06TZCNl)rff^Dfgo7v*srajgN8NX=`LdI_I&|{}}y!Wfq2hP!~w)xWfY73?w4@ zzF)NH0|4O`Iv##Ye#64Msc zj$20|`ZUh1hB?Mlo+ImuI9D>81v@&nn3%F^XpaMr)o9956n`+!WF6so{UOan0793$ z`*Zl>%G^b<84Euv$fpZ0Ceke0p$c~5>xF&9}?UL*lJ3p(gu@hR?m zV(+r66d3{lcrxidQfW%PJ##vUHL(mwkx$4?*v8IJsWPO0TMWZjZ%jJ&%!c%>X8`_3 z_v&hQ(R;m!#x)y+a9=oU*6)_3D@SkthzEoyjR+7th%?8{;me30htVWVsx)(L#)963 z8;FnoEo-m~vTzT=zmRGe6O7A22vZuQzL}#V*5aH)Bc}^D11m+9Q~BE0SVKn6_eNf` z2MMu5on+lrerP))#b!zI(ND~z_%2(WTOWJLL~^`n3syU7Y(l0jz$;mAhgDpx4%J?d zjZ76};(Vt#8kw@!cNMay?)gBAJ*yhePDhDRXF@K{zd9iv`C$JTrx z)x@5HcIPdP{wVyhtGsHl)l*J2WpigEKJP{0(YG0zw`vKedrb{>`F|ux4h27>Uc6D~ zdRlNZ;^E|}_bsVC0#dDOV8Hijh=Gr;tOlvm9&(8M1{Lk+pazM`@niQD!h*nOW7$fx zG~ObecF8>=cn^Y&i(sH}@SE2^dz#M-Ufz*yZ6zg%-8Zl5<9elWOBfGbdWF{%d}QEB z?RmP=R|D6sp!oGI9M8kneALw!bel?ozW1Ml=P3>_`9M9r%1J5Rh^z5cno<41df}Iq zYYZc-oiJf@yg~FH^v!c+g!@^!m0c6~Qk} zX@Xlt^Cpt6_Ej1x4NDV&$2)Gokw3`J0L6z@;60rVaUaefs$vJ zJZbi#Hflmyfl1*um+Eqz?HRt_2MvX6Q-6c>;}I2en&j4I;?4+c`XvKz>C`7rl`aJB z-R>(a{yVIZjKpN-^SloSFh_pwSbum8J0%j)_Zd1s;mNANbajcinjJK}kl;?5pY@&a zW7-ls4I@peo-ni%p-+6fyX}tSpR;dvWH7@hnOJZCsw*t2JIGjUt)+1n(MCP0tO}UV zQ~>EBT0TuBj(=6AD|yOD(S2~2%B0LRBMrgF@j zWFDVKf{LRfH?i{E+}w?PZ+)!FTT9&GMioxOS^T$!j%p{SQQZW2BPZ(bcYO!ok-W9q zqEWwHRh3y|vyJ392ETgEPYp()FkefFl~adse)2J+>XpnqG`G!ZK)f(PN@vLKlMOaq zAQ;@4uQ||s1Bkaz(@gihDXJ?k4=2Zz)#)3EOB66^M zYFmSFzKhj>gDERzKwkm+GKK7e<)p^csL_N|`UV0ulW`}RU_Q<3MOw3C-BJ>gp!Tq8G#o*j!OtEQS=bC%vDe!>OmZ69Zs_7KWjw=>7FD5rk?fK@*~N4$I#_oQ9z5&fZ@0Xi%YG{^z5 zgW*{be{d8M31)okY##)kb6z6_Qwm2iP}an9u;eSj#e=&6nfR=^C(j*2rB*4=ezcZs zt?vjSNnvfKe7TZCy%Yte!8Ei%{5>D$ZXKn%Gg@Z5%u|B zo=fs|fONhJPaHm?q6)FHy_$XT88$ZTZ4Wv*{dyS)SFfTQ*n*DvS-zGNG%-DND_qIX5;`KT3IX%fk z_>8o-&$$0N*jxHe0(fK9^n3}bcjbiVDG-D>QpQIGvhUa7NS}Gn zF@0_l{O|@D5{Q@l9^PBFM*m6E38>3?a_RbS-pWkSbb+}k+wvWPeMx>k>|Vi0rZ&%< zq!s`E4<+i?n1#yfqJxuu|1z(gy_L!7y#>&>!2Lbp&LgsJAJ$ByUh!bqjYZ+bi(ZHE zA5X?(+78anUJK=p9h}YuA{icKy=v)7MW>GL=NS#$DsGl;qlRae2|qc2#vtgQtE(f^ z@V5MpuG%ziQOx|`Ql=sa9@>v6>~IY!6+ev@O`VL4({+?`Qam+~N$pmpNfmJ4vU*>v zc!qQvKTm?M>`CZ)!V}7>mYB8T1{}a%AkGu~nBkX-ATW^<55>9T@r~P!%^j%0|E~gvgM{xk0{GA${EKy z)0>+=k`5clW%S?x;(tsO&(|8KE^%pkBTVkH0%Px-`G;sTyK^JmKbT2jt^x&&a8#O3 z;+ub!@9B|eDR%p`&7)AfDfGbF%C8+tIm#)oXkD`bG>lzV~e?gLJw?6^^Ep>gh JYLyqU{{;`{&&mJ* 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) + } +}