Layout: review r1 fixes
No files matched your search
@@ -33,8 +33,7 @@ type AppShellResult struct {
|
|||||||
Toggled bool
|
Toggled bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// AppShell lays out a window: title bar, sidebar, content and status bar.
|
// AppShell lays out title bar, sidebar, content and status bar; *collapsed folds the sidebar.
|
||||||
// *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 {
|
func AppShell(c *ui.Context, collapsed *bool, opts AppShellOptions) AppShellResult {
|
||||||
if collapsed == nil {
|
if collapsed == nil {
|
||||||
panic("mujica: AppShell needs a collapsed state")
|
panic("mujica: AppShell needs a collapsed state")
|
||||||
|
|||||||
@@ -8,8 +8,7 @@ type AspectRatioOptions struct {
|
|||||||
Ratio float32
|
Ratio float32
|
||||||
}
|
}
|
||||||
|
|
||||||
// AspectRatio fills its parent's box and centers a frame of Ratio inside, as large as fits.
|
// AspectRatio centers the largest Ratio frame that fits; the child is centered, not stretched.
|
||||||
// The child is centered in the frame without stretching and clipped to it.
|
|
||||||
func AspectRatio(c *ui.Context, opts AspectRatioOptions, child func()) *ui.Element {
|
func AspectRatio(c *ui.Context, opts AspectRatioOptions, child func()) *ui.Element {
|
||||||
if opts.Ratio < 0 {
|
if opts.Ratio < 0 {
|
||||||
panic("mujica: AspectRatio Ratio must be positive")
|
panic("mujica: AspectRatio Ratio must be positive")
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ func Divider(c *ui.Context, opts DividerOptions) *ui.Element {
|
|||||||
return e.Children(func() {
|
return e.Children(func() {
|
||||||
segment()
|
segment()
|
||||||
if opts.Label != "" {
|
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)
|
TextColor(k.TextMuted).SingleLine().Shrink(0)
|
||||||
} else {
|
} else {
|
||||||
ui.Box(c).Size(dividerDiamond, dividerDiamond).Shrink(0).Role(ui.RoleNone).
|
ui.Box(c).Size(dividerDiamond, dividerDiamond).Shrink(0).Role(ui.RoleNone).
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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。
|
- 左右内边距在 `c.TitleBar().Left/Right` 之外再加 3 单位,不覆盖系统按钮;全屏时预留为 0。
|
||||||
- Surface 底色,底部 1 DIP Border 分隔线。
|
- Surface 底色,底部 1 DIP Border 分隔线。
|
||||||
|
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ if bar.Clicked == "bell" {
|
|||||||
| 字段 | 类型 | 默认值 | 含义 |
|
| 字段 | 类型 | 默认值 | 含义 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `ID` | `string` | 必填 | 稳定键,也是 `Clicked` 的值;为空或重复 panic。 |
|
| `ID` | `string` | 必填 | 稳定键,也是 `Clicked` 的值;为空或重复 panic。 |
|
||||||
| `Text` | `string` | "" | 状态文字(12 DIP)。 |
|
| `Text` | `string` | "" | 状态文字(12 DIP,随系统文本缩放)。 |
|
||||||
| `Icon` | `*ui.SVG` | nil | 前置图标(14 DIP)。 |
|
| `Icon` | `*ui.SVG` | nil | 前置图标(16 DIP)。 |
|
||||||
| `Tone` | `StatusItemTone` | `StatusItemNeutral` | 语义色:Neutral、Success、Warning、Danger、Info。 |
|
| `Tone` | `StatusItemTone` | `StatusItemNeutral` | 语义色:Neutral、Success、Warning、Danger、Info。 |
|
||||||
| `ShowProgress` | `bool` | false | 显示 64 DIP 进度条。 |
|
| `ShowProgress` | `bool` | false | 显示 64 DIP 进度条。 |
|
||||||
| `Progress` | `float32` | 0 | 进度 0–1,超出 panic。 |
|
| `Progress` | `float32` | 0 | 进度 0–1,超出 panic。 |
|
||||||
@@ -45,7 +45,8 @@ if bar.Clicked == "bell" {
|
|||||||
|
|
||||||
- 默认:Surface 底色,顶部 Border 线,组内条目以细线分隔。
|
- 默认:Surface 底色,顶部 Border 线,组内条目以细线分隔。
|
||||||
- 操作条目:悬停 SurfaceHover,按下 Selection,键盘焦点环。
|
- 操作条目:悬停 SurfaceHover,按下 Selection,键盘焦点环。
|
||||||
- 折叠:按“右组末项 → 左组首项”的顺序隐藏 `Secondary` 条目,直到放得下;显示 `+N`,悬停提示列出被隐藏条目,无障碍名称为“另有 N 项”。主要条目永不隐藏。
|
- 折叠:按“右组末项 → 左组首项”的顺序隐藏 `Secondary` 条目,直到放得下(按实际宽度计算,操作条目至少 28 DIP,含条目间距);显示 `+N` 按钮,无障碍名称为“另有 N 项”,点击或 Enter/Space 打开弹层,列出被隐藏的条目,其中的操作照常可用,执行后弹层关闭。主要条目永不隐藏。
|
||||||
|
- 文本放大时状态栏随文字增高。
|
||||||
- 进度:AccentText 填充,Border 轨道,描述为百分比。
|
- 进度: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` 中说明。
|
- 不提供不确定进度动画;需要时在 `Text` 中说明。
|
||||||
@@ -30,7 +30,7 @@ mujica.ScrollArea(c, &offset, mujica.ScrollAreaOptions{Label: "Log"}, func() {
|
|||||||
|
|
||||||
## 状态
|
## 状态
|
||||||
|
|
||||||
- 内容溢出时区域可获得焦点,键盘焦点环画在区域内侧(2 DIP,Focus 色),避免被父级裁剪。
|
- 内容溢出时区域可获得焦点,键盘焦点环画在区域内侧:先留 2 DIP 间隔,再画 2 DIP Focus 色环线,避免被父级裁剪。溢出状态在布局后变化时组件立即重建,焦点停靠随之更新。
|
||||||
- 不溢出时不进入 Tab 序列。
|
- 不溢出时不进入 Tab 序列。
|
||||||
- 滚动条为 MyGo 叠加式滑块,颜色取主题 Scrollbar(TextMuted 50%),指针悬停时显示,不占布局空间。
|
- 滚动条为 MyGo 叠加式滑块,颜色取主题 Scrollbar(TextMuted 50%),指针悬停时显示,不占布局空间。
|
||||||
- 切换主题、折叠周围布局都不重置位置。
|
- 切换主题、折叠周围布局都不重置位置。
|
||||||
|
|||||||
@@ -38,4 +38,4 @@ ui.Box(c).Size(320, 240).Children(func() {
|
|||||||
## 限制
|
## 限制
|
||||||
|
|
||||||
- 父级高度不确定(随内容增长)时只按宽度计算。
|
- 父级高度不确定(随内容增长)时只按宽度计算。
|
||||||
- 框尺寸依据上一帧的父级尺寸计算,窗口缩放后组件请求下一帧完成调整(MyGo 布局的 `AspectRatio` 只能由宽推高,不能同时受宽高约束)。
|
- 框尺寸依据上一帧的父级尺寸计算,窗口缩放后组件请求下一帧完成调整(MyGo 布局的 `AspectRatio` 由已定的宽推出高,或由固定的高推出宽,见 `ui/layout.go:345` 与 `:364`;但不会在父级宽高同时约束时取两者中较小的一边,因此组件自行计算)。
|
||||||
@@ -17,7 +17,7 @@ var layoutPages = []page{
|
|||||||
{ID: "002", Name: "TitleBar", ZhName: "标题栏", Category: "Layout", Demo: layoutTitleBarDemo, Code: layoutTitleBarCode,
|
{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."},
|
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,
|
{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,
|
{ID: "004", Name: "Container", ZhName: "容器", Category: "Layout", Demo: layoutContainerDemo, Code: layoutContainerCode,
|
||||||
Keys: "None: Container never takes the focus."},
|
Keys: "None: Container never takes the focus."},
|
||||||
{ID: "005", Name: "Stack", ZhName: "堆叠", Category: "Layout", Demo: layoutStackDemo, Code: layoutStackCode,
|
{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.
|
// layoutChoice is a labeled drop-down that logs its changes.
|
||||||
func layoutChoice(c *ui.Context, v *string, label string, options []string, log func(string)) {
|
func layoutChoice(c *ui.Context, v *string, label string, options []string, log func(string)) {
|
||||||
was := *v
|
was := *v
|
||||||
ui.Row(c).Gap(8).Children(func() {
|
ui.Row(c).Gap(8).Children(func() { ui.Text(c, label); ui.Select(c, v, options) })
|
||||||
ui.Text(c, label)
|
|
||||||
ui.Select(c, v, options)
|
|
||||||
})
|
|
||||||
if *v != was {
|
if *v != was {
|
||||||
log(fmt.Sprintf("%s: %s", label, *v))
|
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 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 {
|
func layoutFrame(c *ui.Context, h float32, fn func()) *ui.Element {
|
||||||
k := mujica.Tokens(c)
|
return ui.Column(c).Height(h).Border(1, mujica.Tokens(c).Border).Radius(4).Clip().Children(fn)
|
||||||
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"}
|
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() {
|
layoutControls(c, func() {
|
||||||
layoutCheck(c, &s.horizontal, "Horizontal", log)
|
layoutCheck(c, &s.horizontal, "Horizontal", log)
|
||||||
s.wrap = s.wrap && s.horizontal
|
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)
|
layoutCheck(c, &s.hide, "Hide Draft", log)
|
||||||
layoutChoice(c, &s.gap, "Gap", layoutSpaces, log)
|
layoutChoice(c, &s.gap, "Gap", layoutSpaces, log)
|
||||||
layoutChoice(c, &s.align, "Align", aligns, log)
|
layoutChoice(c, &s.align, "Align", aligns, log)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,10 +22,7 @@ type ScrollAreaOptions struct {
|
|||||||
Label string
|
Label string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ScrollArea scrolls its children with the wheel, its scroll bars and the keyboard.
|
// ScrollArea scrolls children; state keeps and sets the offset, nil keeps it by place.
|
||||||
// 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 {
|
func ScrollArea(c *ui.Context, state *ui.ScrollState, opts ScrollAreaOptions, children func()) *ui.Element {
|
||||||
if children == nil {
|
if children == nil {
|
||||||
panic("mujica: ScrollArea needs children")
|
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{} })
|
state = ui.Local(e, "scroll_area.state", func() ui.ScrollState { return ui.ScrollState{} })
|
||||||
}
|
}
|
||||||
e.TrackScroll(state)
|
e.TrackScroll(state)
|
||||||
if state.MaxX > 0 || state.MaxY > 0 {
|
overflow := state.MaxX > 0 || state.MaxY > 0
|
||||||
focus := Tokens(c).Focus
|
if overflow {
|
||||||
// The ring goes inside: areas often fill a clipped pane edge to edge.
|
e.Focusable()
|
||||||
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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
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 != "" {
|
if opts.Label != "" {
|
||||||
e.Label(opts.Label)
|
e.Label(opts.Label)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package mujica
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"image/color"
|
||||||
"math"
|
"math"
|
||||||
"testing"
|
"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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,8 +33,7 @@ type SplitPaneOptions struct {
|
|||||||
Vertical bool
|
Vertical bool
|
||||||
// MinFirst and MinSecond bound the panes in DIPs; zero is 10 spacing units.
|
// MinFirst and MinSecond bound the panes in DIPs; zero is 10 spacing units.
|
||||||
MinFirst, MinSecond float32
|
MinFirst, MinSecond float32
|
||||||
// Collapsible lets that pane collapse: Enter on the divider, a double click,
|
// Collapsible names the pane that may collapse.
|
||||||
// dragging it under half its minimum, or an arrow key past its minimum.
|
|
||||||
Collapsible SplitPaneSide
|
Collapsible SplitPaneSide
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,7 +64,6 @@ func SplitPane(c *ui.Context, state *SplitPaneState, opts SplitPaneOptions, firs
|
|||||||
if state.Collapsed && opts.Collapsible == SplitPaneNeither {
|
if state.Collapsed && opts.Collapsible == SplitPaneNeither {
|
||||||
panic("mujica: SplitPane collapsed without a Collapsible pane")
|
panic("mujica: SplitPane collapsed without a Collapsible pane")
|
||||||
}
|
}
|
||||||
k := Tokens(c)
|
|
||||||
u := Density(c).Unit()
|
u := Density(c).Unit()
|
||||||
lo, minSecond := opts.MinFirst, opts.MinSecond
|
lo, minSecond := opts.MinFirst, opts.MinSecond
|
||||||
if lo == 0 {
|
if lo == 0 {
|
||||||
@@ -78,9 +76,29 @@ func SplitPane(c *ui.Context, state *SplitPaneState, opts SplitPaneOptions, firs
|
|||||||
if opts.Vertical {
|
if opts.Vertical {
|
||||||
e.Column()
|
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 {
|
if opts.Vertical {
|
||||||
total = e.Bounds().H
|
total = in.Bounds().H
|
||||||
}
|
}
|
||||||
hi := max(lo, total-theme.BorderWidth-minSecond)
|
hi := max(lo, total-theme.BorderWidth-minSecond)
|
||||||
clamp := func(v float32) float32 { return max(lo, min(v, hi)) }
|
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)
|
state.Size = clamp((total - theme.BorderWidth) / 2)
|
||||||
} else if total > 0 {
|
} else if total > 0 {
|
||||||
state.Size = clamp(state.Size)
|
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.
|
// Not laid out yet: measure, then size in the next frame.
|
||||||
*first = false
|
*once = false
|
||||||
c.Invalidate()
|
c.Invalidate()
|
||||||
}
|
}
|
||||||
var res SplitPaneResult
|
|
||||||
resize := func(v float32) {
|
resize := func(v float32) {
|
||||||
if v = clamp(v); v != state.Size {
|
if v = clamp(v); v != state.Size {
|
||||||
state.Size = v
|
state.Size = v
|
||||||
@@ -114,7 +131,7 @@ func SplitPane(c *ui.Context, state *SplitPaneState, opts SplitPaneOptions, firs
|
|||||||
el.Width(v)
|
el.Width(v)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
e.Children(func() {
|
in.Children(func() {
|
||||||
if firstShown {
|
if firstShown {
|
||||||
a := ui.Box(c).Key("first").Shrink(0).Clip()
|
a := ui.Box(c).Key("first").Shrink(0).Clip()
|
||||||
switch {
|
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.
|
// splitPaneFollow moves the divider where the pointer wants it; past half its minimum, a collapsible pane closes.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,6 @@ package mujica
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/egoist/mygo/ui"
|
"github.com/egoist/mygo/ui"
|
||||||
|
|
||||||
@@ -52,29 +51,36 @@ type StatusBarResult struct {
|
|||||||
Clicked string
|
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.
|
// StatusBar is a window's bottom strip with left and right groups of status items.
|
||||||
func StatusBar(c *ui.Context, opts StatusBarOptions) StatusBarResult {
|
func StatusBar(c *ui.Context, opts StatusBarOptions) StatusBarResult {
|
||||||
k := Tokens(c)
|
k := Tokens(c)
|
||||||
d := Density(c)
|
d := Density(c)
|
||||||
statusBarCheck(opts)
|
statusBarCheck(opts)
|
||||||
h := max(theme.MinHitSize, d.ControlHeight()-8)
|
|
||||||
gap := d.Unit() * 2
|
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).
|
Background(k.Surface).BorderWidth(theme.BorderWidth, 0, 0, 0).BorderColor(k.Border).
|
||||||
Label(msg(c, "status_bar.label"))
|
Label(msg(c, "status_bar.label"))
|
||||||
hidden := statusBarHide(c, e, opts, e.Bounds().W-d.Unit()*4, gap)
|
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
|
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) {
|
group := func(items []StatusItem) {
|
||||||
ui.Row(c).Gap(gap).Shrink(0).Dividers(theme.BorderWidth, k.Border).Children(func() {
|
ui.Row(c).Gap(gap).Shrink(0).Dividers(theme.BorderWidth, k.Border).Children(func() {
|
||||||
for _, it := range items {
|
for _, it := range items {
|
||||||
if hidden[it.ID] {
|
if !hidden[it.ID] {
|
||||||
continue
|
item(it)
|
||||||
}
|
|
||||||
if statusBarItem(c, it) {
|
|
||||||
res.Clicked = it.ID
|
|
||||||
slog.Debug("mujica: status_bar clicked", "id", it.ID)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -83,15 +89,23 @@ func StatusBar(c *ui.Context, opts StatusBarOptions) StatusBarResult {
|
|||||||
group(opts.Left)
|
group(opts.Left)
|
||||||
ui.Spacer(c)
|
ui.Spacer(c)
|
||||||
if len(hidden) > 0 {
|
if len(hidden) > 0 {
|
||||||
var names []string
|
more := statusBarMore(c, len(hidden))
|
||||||
for _, it := range append(append([]StatusItem{}, opts.Left...), opts.Right...) {
|
if more.Clicked() {
|
||||||
if hidden[it.ID] {
|
*open = !*open
|
||||||
names = append(names, statusBarName(it))
|
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)
|
group(opts.Right)
|
||||||
})
|
})
|
||||||
@@ -99,9 +113,30 @@ func StatusBar(c *ui.Context, opts StatusBarOptions) StatusBarResult {
|
|||||||
return res
|
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) {
|
func statusBarCheck(opts StatusBarOptions) {
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
for _, it := range append(append([]StatusItem{}, opts.Left...), opts.Right...) {
|
for _, it := range statusBarAll(opts) {
|
||||||
switch {
|
switch {
|
||||||
case it.ID == "":
|
case it.ID == "":
|
||||||
panic("mujica: StatusItem needs an ID")
|
panic("mujica: StatusItem needs an ID")
|
||||||
@@ -116,38 +151,39 @@ func statusBarCheck(opts StatusBarOptions) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func statusBarName(it StatusItem) string {
|
// statusBarHide returns the secondary items to hide, last first, so the rest fit in room DIPs.
|
||||||
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 {
|
func statusBarHide(c *ui.Context, bar *ui.Element, opts StatusBarOptions, room, gap float32) map[string]bool {
|
||||||
hidden := map[string]bool{}
|
hidden := map[string]bool{}
|
||||||
if room <= 0 {
|
if room <= 0 {
|
||||||
// Not laid out yet: show all, and measure in the next frame.
|
// 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 {
|
if once := ui.Local(bar, "status_bar.first", func() bool { return true }); *once {
|
||||||
*first = false
|
*once = false
|
||||||
c.Invalidate()
|
c.Invalidate()
|
||||||
}
|
}
|
||||||
return hidden
|
return hidden
|
||||||
}
|
}
|
||||||
all := append(append([]StatusItem{}, opts.Left...), opts.Right...)
|
u := Density(c).Unit()
|
||||||
need := func() float32 {
|
group := func(items []StatusItem) float32 {
|
||||||
var w float32
|
var w float32
|
||||||
for _, it := range all {
|
n := 0
|
||||||
|
for _, it := range items {
|
||||||
if !hidden[it.ID] {
|
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 {
|
if len(hidden) > 0 {
|
||||||
mw, _ := c.MeasureText(0, ui.Span{Text: fmt.Sprintf("+%d", len(hidden)), Size: theme.CaptionSize})
|
tw, _ := c.MeasureText(0, ui.Span{Text: fmt.Sprintf("+%d", len(hidden)), Size: FontSize(c, theme.CaptionSize)})
|
||||||
w += mw + gap
|
w += max(theme.MinHitSize, tw+2*u) + gap
|
||||||
}
|
}
|
||||||
return w
|
return w
|
||||||
}
|
}
|
||||||
|
all := statusBarAll(opts)
|
||||||
for i := len(all) - 1; i >= 0 && need() > room; i-- {
|
for i := len(all) - 1; i >= 0 && need() > room; i-- {
|
||||||
if all[i].Secondary {
|
if all[i].Secondary {
|
||||||
hidden[all[i].ID] = true
|
hidden[all[i].ID] = true
|
||||||
@@ -156,19 +192,29 @@ func statusBarHide(c *ui.Context, bar *ui.Element, opts StatusBarOptions, room,
|
|||||||
return hidden
|
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 {
|
func statusBarWidth(c *ui.Context, it StatusItem) float32 {
|
||||||
d := Density(c)
|
u := Density(c).Unit()
|
||||||
w := d.Unit() * 2
|
var parts []float32
|
||||||
if it.Icon != nil {
|
if it.Icon != nil {
|
||||||
w += 14 + d.Unit()
|
parts = append(parts, statusBarIcon)
|
||||||
}
|
}
|
||||||
if it.Text != "" {
|
if it.Text != "" {
|
||||||
tw, _ := c.MeasureText(0, ui.Span{Text: it.Text, Size: theme.CaptionSize})
|
tw, _ := c.MeasureText(0, ui.Span{Text: it.Text, Size: FontSize(c, theme.CaptionSize)})
|
||||||
w += tw + d.Unit()
|
parts = append(parts, tw)
|
||||||
}
|
}
|
||||||
if it.ShowProgress {
|
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
|
return w
|
||||||
}
|
}
|
||||||
@@ -212,10 +258,10 @@ func statusBarItem(c *ui.Context, it StatusItem) bool {
|
|||||||
}
|
}
|
||||||
e.Children(func() {
|
e.Children(func() {
|
||||||
if it.Icon != nil {
|
if it.Icon != nil {
|
||||||
ui.Icon(c, it.Icon).Size(14, 14)
|
ui.Icon(c, it.Icon).Size(statusBarIcon, statusBarIcon)
|
||||||
}
|
}
|
||||||
if it.Text != "" {
|
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 {
|
if it.ShowProgress {
|
||||||
statusBarProgress(c, it.Progress)
|
statusBarProgress(c, it.Progress)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package mujica
|
package mujica
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/egoist/mygo/ui"
|
"github.com/egoist/mygo/ui"
|
||||||
@@ -83,13 +84,19 @@ func TestStatusBarCollapsesSecondary(t *testing.T) {
|
|||||||
t.Fatalf("no hidden count named: %q", tt.Texts())
|
t.Fatalf("no hidden count named: %q", tt.Texts())
|
||||||
}
|
}
|
||||||
// Medium width drops only the last secondary items.
|
// Medium width drops only the last secondary items.
|
||||||
tt.SetSize(460, 100)
|
tt.SetSize(400, 100)
|
||||||
tt.Frame()
|
tt.Frame()
|
||||||
if !tt.HasText("2 warnings") || tt.HasText("Ln 12, Col 4") {
|
if !tt.HasText("2 warnings") || tt.HasText("Ln 12, Col 4") {
|
||||||
t.Fatalf("medium bar hid the wrong items: %q", tt.Texts())
|
t.Fatalf("medium bar hid the wrong items: %q", tt.Texts())
|
||||||
}
|
}
|
||||||
if _, ok := tt.Find("Notifications"); !ok {
|
// At every width the visible items stay inside the bar.
|
||||||
t.Fatal("primary action hidden")
|
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.SetSize(800, 100)
|
||||||
tt.Frame()
|
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) {
|
func TestStatusBarRejectsBadItems(t *testing.T) {
|
||||||
for _, o := range []StatusBarOptions{
|
for _, o := range []StatusBarOptions{
|
||||||
{Left: []StatusItem{{Text: "no id"}}},
|
{Left: []StatusItem{{Text: "no id"}}},
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 6.1 KiB |
@@ -14,14 +14,13 @@ type TitleBarOptions struct {
|
|||||||
Trailing func()
|
Trailing func()
|
||||||
}
|
}
|
||||||
|
|
||||||
// TitleBar is a window's title row. Its empty parts drag the window, and it keeps clear of the system window buttons
|
// TitleBar is a window title row that drags the window and clears the system 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 {
|
func TitleBar(c *ui.Context, title string, opts TitleBarOptions) *ui.Element {
|
||||||
k := Tokens(c)
|
k := Tokens(c)
|
||||||
d := Density(c)
|
d := Density(c)
|
||||||
bar := c.TitleBar()
|
bar := c.TitleBar()
|
||||||
pad := d.Unit() * 3
|
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).
|
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).
|
Background(k.Surface).BorderWidth(0, 0, theme.BorderWidth, 0).BorderColor(k.Border).
|
||||||
DragWindow()
|
DragWindow()
|
||||||
@@ -29,7 +28,7 @@ func TitleBar(c *ui.Context, title string, opts TitleBarOptions) *ui.Element {
|
|||||||
if opts.Leading != nil {
|
if opts.Leading != nil {
|
||||||
ui.Row(c).Gap(d.Unit()).Shrink(0).Children(opts.Leading)
|
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)
|
SingleLine().Grow(1).Shrink(1).MinWidth(0)
|
||||||
if opts.Trailing != nil {
|
if opts.Trailing != nil {
|
||||||
ui.Row(c).Gap(d.Unit()).Shrink(0).Children(opts.Trailing)
|
ui.Row(c).Gap(d.Unit()).Shrink(0).Children(opts.Trailing)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||