From 822e7a673a1b2f9b36ff2315dd09609f470605a3 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:31:17 -0400 Subject: [PATCH] Display: review r1 fixes --- avatar.go | 7 +- avatar_group.go | 34 +++---- avatar_group_test.go | 14 +++ badge.go | 13 ++- badge_test.go | 17 ++++ docs/components/011-text.md | 20 +++-- docs/components/014-image.md | 2 +- docs/components/015-avatar.md | 2 + docs/components/016-avatar-group.md | 2 +- docs/components/017-badge.md | 2 +- docs/components/018-tag.md | 11 ++- docs/components/020-kbd.md | 2 +- example_display_test.go | 8 +- examples/gallery/pages_display.go | 60 +++++++------ examples/gallery/pages_display_parts.go | 9 ++ heading.go | 12 +-- icon.go | 9 +- image.go | 45 +++++++--- image_test.go | 28 +++++- internal/display.go | 4 +- kbd.go | 14 ++- kbd_test.go | 17 ++++ link.go | 11 ++- tag.go | 17 ++-- tag_test.go | 37 ++++++++ testdata/snapshots/darwin/kbd-dark.png | Bin 2646 -> 2716 bytes testdata/snapshots/darwin/kbd-light.png | Bin 2550 -> 2613 bytes testdata/snapshots/darwin/text-dark.png | Bin 21270 -> 21300 bytes testdata/snapshots/darwin/text-light.png | Bin 20426 -> 20479 bytes text.go | 108 ++++++++++++++++++----- text_test.go | 60 +++++++++++-- 31 files changed, 409 insertions(+), 156 deletions(-) diff --git a/avatar.go b/avatar.go index fdc2848..80c0082 100644 --- a/avatar.go +++ b/avatar.go @@ -10,14 +10,13 @@ import ( // AvatarOptions configure an Avatar. type AvatarOptions struct { - // Image is the picture; nil falls back to initials, then a person icon. + // Image is the picture; nil falls back to initials, icon. Image *ui.Bitmap // Size is the diameter in DIPs; zero means 32. Size float32 } -// Avatar shows a person as a round picture, their initials or an icon. -// Assistive technology reads it as an image named name. +// Avatar shows a person as picture, initials or icon. func Avatar(c *ui.Context, name string, opts AvatarOptions) *ui.Element { size := opts.Size if size < 0 { @@ -49,7 +48,7 @@ func Avatar(c *ui.Context, name string, opts AvatarOptions) *ui.Element { return e } -// avatarInitials returns the first letters of the first and last words. +// avatarInitials returns first letters of first and last words. func avatarInitials(name string) string { var first, last rune inWord := false diff --git a/avatar_group.go b/avatar_group.go index 0a755f0..f23054e 100644 --- a/avatar_group.go +++ b/avatar_group.go @@ -18,7 +18,7 @@ type AvatarItem struct { // AvatarGroupOptions configure an AvatarGroup. type AvatarGroupOptions struct { - // Max is how many avatars show before the "+N" button; zero means 4. + // Max avatars show before the "+N" button; zero means 4. Max int // Size is each avatar's diameter; zero means 32. Size float32 @@ -35,8 +35,7 @@ type AvatarGroupView struct { Toggled bool } -// AvatarGroup shows overlapping avatars. Past Max, a "+N" button opens a -// popover with the full list of names. +// AvatarGroup overlaps avatars; "+N" opens the full list. func AvatarGroup(c *ui.Context, items []AvatarItem, opts AvatarGroupOptions) AvatarGroupView { limit := opts.Max if limit < 0 { @@ -71,10 +70,7 @@ func AvatarGroup(c *ui.Context, items []AvatarItem, opts AvatarGroupOptions) Ava *open = false return } - more := avatarGroupMore(c, k, size, ring, rest, len(items)) - if more.Clicked() { - *open = !*open - } + more := avatarGroupMore(c, k, open, size, ring, rest, len(items)) ui.PopoverBase(c, more, open, func(panel *ui.Element) { avatarGroupList(c, k, panel, items) }) @@ -86,23 +82,29 @@ func AvatarGroup(c *ui.Context, items []AvatarItem, opts AvatarGroupOptions) Ava return v } -func avatarGroupMore(c *ui.Context, k theme.Tokens, size, ring float32, rest, total int) *ui.Element { - cell := ui.Box(c).Key("more").Padding(ring).Radius(size/2+ring).Background(k.Background). - Margin(0, 0, 0, -size/4).Shrink(0) +// avatarGroupMore is the "+N" disclosure toggling *open. +func avatarGroupMore(c *ui.Context, k theme.Tokens, open *bool, size, ring float32, rest, total int) *ui.Element { + hit := max(size+2*ring, theme.MinHitSize) var b *ui.Element - cell.Children(func() { - b = ui.ButtonBase(c).Size(size, size).Radius(size / 2).Label(fmt.Sprintf(msg(c, "avatar_group.more"), total)) + ui.Box(c).Key("more").Margin(0, 0, 0, -size/4).Shrink(0).Children(func() { + b = ui.CollapsibleBase(c, open).Trigger.Size(hit, hit).Center().Radius(hit / 2). + Label(fmt.Sprintf(msg(c, "avatar_group.more"), total)) + internal.Ring(b, k.Focus, hit/2) bg := k.Surface if b.Pressed() { bg = k.Selection } else if b.Hovered() { bg = k.SurfaceHover } - b.Background(bg).Border(theme.BorderWidth, k.ControlBorder).TextColor(k.Text) - internal.Ring(b, k.Focus, size/2) - internal.Fade(b, Motion(c, theme.StateDuration)) b.Children(func() { - ui.Text(c, fmt.Sprintf("+%d", rest)).FontSize(size * 0.36).FontWeight(600).SingleLine() + ui.Box(c).Padding(ring).Radius(size/2 + ring).Background(k.Background).Children(func() { + disc := ui.Box(c).Size(size, size).Center().Radius(size/2).Background(bg). + Border(theme.BorderWidth, k.ControlBorder).TextColor(k.Text) + internal.Fade(disc, Motion(c, theme.StateDuration)) + disc.Children(func() { + ui.Text(c, fmt.Sprintf("+%d", rest)).FontSize(size * 0.36).FontWeight(600).SingleLine() + }) + }) }) }) return b diff --git a/avatar_group_test.go b/avatar_group_test.go index 229add8..d6e7dc0 100644 --- a/avatar_group_test.go +++ b/avatar_group_test.go @@ -88,3 +88,17 @@ func TestAvatarGroupSnapshot(t *testing.T) { }) }) } + +func TestAvatarGroupSmallButtonHitArea(t *testing.T) { + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Row(c).AlignItems(ui.Start).Children(func() { + AvatarGroup(c, avatarGroupPeople, AvatarGroupOptions{Max: 2, Size: 20}) + }) + }, 400, 300) + tt.Frame() + r, ok := tt.Find("Show all 6 people") + if !ok || r.W < 28 || r.H < 28 { + t.Fatalf("hit area %v", r) + } +} diff --git a/badge.go b/badge.go index 8697378..fea4b2a 100644 --- a/badge.go +++ b/badge.go @@ -21,7 +21,7 @@ const ( BadgeInfo ) -// BadgePosition places a badge. The zero value lays it out inline. +// BadgePosition places a badge; zero is inline. type BadgePosition int const ( @@ -36,18 +36,17 @@ const ( type BadgeOptions struct { // Text shows as is; when empty, Count shows instead. Text string - // Count must not be negative; zero with no Text hides the badge. + // Count must not be negative; zero without Text hides it. Count int // Max caps the shown count as "Max+"; zero means 99. Max int Tone BadgeTone Position BadgePosition - // Label says what the badge counts; it is read before the full value. + // Label says what is counted; read before the full value. Label string } -// Badge shows a short text or count. Inline badges take no content; corner -// positions pin the badge on content's corner. +// Badge shows text or a count, inline or cornered. func Badge(c *ui.Context, opts BadgeOptions, content func()) *ui.Element { if opts.Count < 0 || opts.Max < 0 { panic("mujica: Badge Count and Max must not be negative") @@ -99,13 +98,13 @@ func badgePill(c *ui.Context, opts BadgeOptions, shown, full string) *ui.Element if opts.Label != "" { label = opts.Label + " " + full } - p := ui.Box(c).Row().Center().MinWidth(18).Height(18).Padding(0, 5).Radius(theme.CardRadius). + p := ui.Box(c).Row().Center().MinWidth(FontSize(c, 18)).MinHeight(FontSize(c, 18)).Padding(0, 5).Radius(theme.CardRadius). Background(bg).Shrink(0).Role(ui.RoleStatus).Label(label) if border.A > 0 { p.Border(theme.BorderWidth, border) } p.Children(func() { - ui.Text(c, shown).FontSize(theme.CaptionSize - 1).FontWeight(600).TextColor(fg). + ui.Text(c, shown).FontSize(FontSize(c, theme.CaptionSize-1)).FontWeight(600).TextColor(fg). FontFeatures("tnum").SingleLine() }) return p diff --git a/badge_test.go b/badge_test.go index 399de54..fc9029d 100644 --- a/badge_test.go +++ b/badge_test.go @@ -112,3 +112,20 @@ func TestBadgeSnapshot(t *testing.T) { }) }) } + +func TestBadgeTextScale(t *testing.T) { + var w [2]float32 + for i, s := range []float32{1, 2} { + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Row(c).AlignItems(ui.Start).Children(func() { + w[i] = Badge(c, BadgeOptions{Text: "Draft"}, nil).Bounds().W + }) + }, 400, 200) + tt.SetPreferences(ui.Preferences{TextScale: s}) + tt.Frame() + } + if w[1] < w[0]*1.5 { + t.Fatalf("width %v at 2x, %v at 1x", w[1], w[0]) + } +} diff --git a/docs/components/011-text.md b/docs/components/011-text.md index 8bb00a7..7c249c2 100644 --- a/docs/components/011-text.md +++ b/docs/components/011-text.md @@ -13,7 +13,7 @@ view := func(c *ui.Context) { mujica.Use(c, mujica.Settings{}) mujica.Text(c, "Order ID: 4821-AX", mujica.TextOptions{Selectable: true}) mujica.Text(c, "A long note that stops after two lines.", mujica.TextOptions{MaxLines: 2}) - if mujica.EditableText(c, &name, mujica.EditableTextOptions{Error: saveErr}).Changed() { + if mujica.EditableText(c, &name, mujica.EditableTextOptions{Error: saveErr}).Changed { saveErr = "" // save name here; set saveErr on failure } } @@ -28,33 +28,35 @@ view := func(c *ui.Context) { | `MaxLines` | `int` | `0` | 超过该行数时以省略号截断;`0` 不截断;`1` 为单行。负数 panic。 | | `Selectable` | `bool` | `false` | 允许用指针或键盘选择并复制文本。 | -`func EditableText(c *ui.Context, value *string, opts EditableTextOptions) *ui.Element` +`func EditableText(c *ui.Context, value *string, opts EditableTextOptions) EditableTextView` | 字段 | 类型 | 默认值 | 含义 | | --- | --- | --- | --- | | `Saving` | `bool` | `false` | 调用方正在保存:显示加载标记,并禁止再次编辑。 | | `Error` | `string` | `""` | 调用方保存失败的说明,显示在文本下方并报告给辅助技术。 | -| `Label` | `string` | `""` | 辅助技术读出的名称。 | +| `Label` | `string` | `""` | 辅助技术读出的名称;同时用在显示状态和编辑输入框上。 | + +返回值 `EditableTextView`:`Element`;`Changed` 表示本帧确认了新值;`Canceled` 表示本帧按 Escape 放弃了编辑。 ## 状态 - 默认:可编辑文本带虚线下边,提示可以编辑。 - 悬停:背景变为 `SurfaceHover`,尺寸不变。 -- 聚焦:2 DIP 焦点环。 -- 编辑中:显示 MyGo 文本输入框,酒红边框,选中扩展名之前的部分。 +- 聚焦:2 DIP 焦点环(可编辑文本与 `Selectable` 文本)。 +- 编辑中:显示文本输入框,酒红边框;名称与错误说明附在输入框上。 - 保存中(`Saving`):显示加载标记,阻止进入编辑。 - 错误(`Error`):虚线变为 `Danger`,下方显示错误说明,保留已确认的值。 ## 事件 -- `EditableText(...).Changed()`:用户确认了一个新值,`*value` 已更新。调用方在此保存,并通过 `Saving` 与 `Error` 回传结果。 -- 取消编辑不产生事件,`*value` 不变。 +- `EditableTextView.Changed`:用户确认了新值,`*value` 已更新。调用方在此保存,并通过 `Saving` 与 `Error` 回传结果。 +- `EditableTextView.Canceled`:用户按 Escape 放弃编辑,`*value` 不变。 ## 键盘操作 | 按键 | 作用 | | --- | --- | -| Tab | 聚焦可编辑文本 | +| Tab | 聚焦可编辑文本;`Selectable` 文本也在 Tab 顺序中 | | Enter / 双击 | 开始编辑 | | Enter | 确认 | | Escape | 取消并恢复原值 | @@ -63,7 +65,7 @@ view := func(c *ui.Context) { ## 限制 -- 编辑模式基于 `ui.EditableText`。MyGo 不报告“取消”事件,组件只能保证取消后值不变,不能通知调用方。 +- 编辑模式基于 `ui.TextInputBase` 构建,而不是 `ui.EditableText`,因为后者内部的输入框无法通过公开接口设置名称和错误说明。代价是开始编辑时不会像 Finder 那样自动选中扩展名之前的部分,光标停在文本末尾(可用 Cmd/Ctrl+A 全选)。 - 编辑模式只支持单行文本。 - 值为空字符串时文本区域没有可见文字;组件保留最小宽度和虚线,保证仍可点击。 - MyGo 无法检测文本是否真的被截断,所以截断时不会自动显示完整内容的提示。 diff --git a/docs/components/014-image.md b/docs/components/014-image.md index b19325b..b7db4fb 100644 --- a/docs/components/014-image.md +++ b/docs/components/014-image.md @@ -29,7 +29,7 @@ view := func(c *ui.Context) { | 字段 | 类型 | 默认值 | 含义 | | --- | --- | --- | --- | -| `Width`, `Height` | `float32` | `0` | 盒子尺寸(DIP)。为 0 时图片使用自身尺寸;占位、加载、错误状态为 160×120。负数 panic。 | +| `Width`, `Height` | `float32` | `0` | 盒子尺寸(DIP)。为 0 时使用 `src` 的尺寸(只给一边时按比例补齐),在占位、加载、错误状态下也保持这个尺寸;`src` 为 nil 时为 160×120。负数 panic。 | | `Crop` | `bool` | `false` | `true` 覆盖并裁切(`ui.Cover`);`false` 完整放入(`ui.Contain`)。 | | `Alt` | `string` | `""` | 替代说明,供辅助技术读取;在其他状态下作为描述。 | | `Loading` | `bool` | `false` | 调用方正在加载。 | diff --git a/docs/components/015-avatar.md b/docs/components/015-avatar.md index fedac25..8fca682 100644 --- a/docs/components/015-avatar.md +++ b/docs/components/015-avatar.md @@ -43,5 +43,7 @@ view := func(c *ui.Context) { ## 限制 +- 首字母字号随头像直径而定,不随系统文本缩放放大,以免溢出圆形。 + - 中文姓名没有空格时按一个词处理,只取第一个字,例如“张伟”显示为“张”。 - 头像不使用 MyGo `ui.Avatar`,因为它的尺寸固定为字号的 2.25 倍,无法配置(`ui/indicators.go:383`)。 diff --git a/docs/components/016-avatar-group.md b/docs/components/016-avatar-group.md index 4924c2f..f024548 100644 --- a/docs/components/016-avatar-group.md +++ b/docs/components/016-avatar-group.md @@ -33,7 +33,7 @@ view := func(c *ui.Context) { ## 状态 - 默认:头像之间重叠四分之一直径,用 2 DIP 窗口背景色描边分隔。 -- “+N”按钮:悬停、按下改变背景;聚焦时显示 2 DIP 焦点环。 +- “+N”按钮:基于 `ui.CollapsibleBase` 的 Trigger,辅助技术读作展开/收起按钮,并报告是否已展开。命中区域至少 28×28 DIP(`Size` 很小时也如此)。悬停、按下改变背景;聚焦时显示 2 DIP 焦点环。 - 展开:按钮下方弹出 6 DIP 圆角面板,列出全部头像与姓名,超过 240 DIP 时滚动;遵循减少动画设置。 ## 事件 diff --git a/docs/components/017-badge.md b/docs/components/017-badge.md index ebba68b..6a1bebd 100644 --- a/docs/components/017-badge.md +++ b/docs/components/017-badge.md @@ -34,7 +34,7 @@ view := func(c *ui.Context) { ## 状态 -纯展示。强调和危险色调使用实色填充(`OnAccent` 配 `Accent`,`OnDanger` 配 `DangerFill`);成功、警告、信息色调只把语义色用在文字和边框上。 +纯展示。字号与最小尺寸随系统文本缩放放大。强调和危险色调使用实色填充(`OnAccent` 配 `Accent`,`OnDanger` 配 `DangerFill`);成功、警告、信息色调只把语义色用在文字和边框上。 ## 事件 diff --git a/docs/components/018-tag.md b/docs/components/018-tag.md index f551e6c..e780a67 100644 --- a/docs/components/018-tag.md +++ b/docs/components/018-tag.md @@ -12,8 +12,10 @@ view := func(c *ui.Context) { mujica.Use(c, mujica.Settings{}) ui.Row(c).Gap(8).Children(func() { for i, t := range tags { - v := mujica.Tag(c, t, mujica.TagOptions{Icon: "star", Clickable: true, Closable: true}) - v.Element.Key(t) + var v mujica.TagView + ui.Box(c).Key(t).Children(func() { + v = mujica.Tag(c, t, mujica.TagOptions{Icon: "star", Clickable: true, Closable: true}) + }) if v.Closed { tags = append(tags[:i:i], tags[i+1:]...) } @@ -42,7 +44,8 @@ view := func(c *ui.Context) { - 默认:`Surface` 底色、细边框、2 DIP 圆角。 - 悬停 / 按下:可点击部分和关闭按钮分别变为 `SurfaceHover` / `Selection`。 - 聚焦:对应部分显示 2 DIP 焦点环。 -- 禁用:由 MyGo 统一调暗,不响应输入。 +- 禁用:由 MyGo 统一调暗,不响应输入;祖先元素禁用时同样不响应,包括 Delete/Backspace。 +- 文字随系统文本缩放放大。 ## 事件 @@ -59,5 +62,7 @@ view := func(c *ui.Context) { ## 限制 +- 在循环中渲染多个标签时,把稳定的 Key 放在包住 Tag 的外层容器上(见示例)。Tag 在创建时就处理输入,事后再设置 Key 无法保持身份。 + - 不可点击的标签不获取焦点,只能通过关闭按钮操作。 - 标签不提供“选中”状态;需要可选标签时请使用 Toggle 类组件。 diff --git a/docs/components/020-kbd.md b/docs/components/020-kbd.md index cda7863..76d278e 100644 --- a/docs/components/020-kbd.md +++ b/docs/components/020-kbd.md @@ -39,7 +39,7 @@ view := func(c *ui.Context) { ## 状态 -纯展示。键帽使用等宽字体、`Surface` 底色、`ControlBorder` 边框,底边 2 DIP。 +纯展示。键帽随系统文本缩放放大,使用等宽字体、`Surface` 底色、`ControlBorder` 边框,底边 2 DIP。 ## 事件 diff --git a/example_display_test.go b/example_display_test.go index a3e05f4..0bce4e1 100644 --- a/example_display_test.go +++ b/example_display_test.go @@ -15,7 +15,7 @@ func ExampleText() { mujica.Use(c, mujica.Settings{}) mujica.Text(c, "Order ID: 4821-AX", mujica.TextOptions{Selectable: true}) mujica.Text(c, "A long note that stops after two lines.", mujica.TextOptions{MaxLines: 2}) - if mujica.EditableText(c, &name, mujica.EditableTextOptions{Error: saveErr}).Changed() { + if mujica.EditableText(c, &name, mujica.EditableTextOptions{Error: saveErr}).Changed { saveErr = "" // save name here; set saveErr on failure } } @@ -99,8 +99,10 @@ func ExampleTag() { mujica.Use(c, mujica.Settings{}) ui.Row(c).Gap(8).Children(func() { for i, t := range tags { - v := mujica.Tag(c, t, mujica.TagOptions{Icon: "star", Clickable: true, Closable: true}) - v.Element.Key(t) + var v mujica.TagView + ui.Box(c).Key(t).Children(func() { + v = mujica.Tag(c, t, mujica.TagOptions{Icon: "star", Clickable: true, Closable: true}) + }) if v.Closed { tags = append(tags[:i:i], tags[i+1:]...) } diff --git a/examples/gallery/pages_display.go b/examples/gallery/pages_display.go index b151ddb..596ce4b 100644 --- a/examples/gallery/pages_display.go +++ b/examples/gallery/pages_display.go @@ -48,10 +48,10 @@ func textDemo(c *ui.Context, log func(string)) { displaySection(c, "Parameters", func() { displayControls(c, func() { ui.Text(c, fmt.Sprintf("MaxLines %d", int(s.maxLines))) - ui.StepSlider(c, &s.maxLines, 0, 4, 1).Width(160) - ui.Checkbox(c, &s.selectable, "Selectable") - ui.Checkbox(c, &s.saving, "Saving") - ui.Checkbox(c, &s.failNext, "Next save fails") + displayLog(log, ui.StepSlider(c, &s.maxLines, 0, 4, 1).Width(160), "maxLines", &s.maxLines) + displayLog(log, ui.Checkbox(c, &s.selectable, "Selectable"), "selectable", &s.selectable) + displayLog(log, ui.Checkbox(c, &s.saving, "Saving"), "saving", &s.saving) + displayLog(log, ui.Checkbox(c, &s.failNext, "Next save fails"), "failNext", &s.failNext) }) }) displaySection(c, "Display", func() { @@ -62,7 +62,7 @@ func textDemo(c *ui.Context, log func(string)) { }) displaySection(c, "Editable", func() { e := mujica.EditableText(c, &s.name, mujica.EditableTextOptions{Saving: s.saving, Error: s.saveErr, Label: "File name"}) - if e.Changed() { + if e.Changed { if s.failNext { s.saveErr = "Save failed: the name is already taken." log("save failed: " + s.name) @@ -71,13 +71,16 @@ func textDemo(c *ui.Context, log func(string)) { log("saved: " + s.name) } } + if e.Canceled { + log("edit canceled") + } }) displayCode(c, textCode, log) } const textCode = `mujica.Text(c, "Order ID: 4821-AX", mujica.TextOptions{Selectable: true}) mujica.Text(c, "A long note that stops after two lines.", mujica.TextOptions{MaxLines: 2}) -if mujica.EditableText(c, &name, mujica.EditableTextOptions{Error: saveErr}).Changed() { +if mujica.EditableText(c, &name, mujica.EditableTextOptions{Error: saveErr}).Changed { saveErr = "" // save name here; set saveErr on failure }` @@ -95,8 +98,8 @@ func headingDemo(c *ui.Context, log func(string)) { if ui.StepSlider(c, &s.level, 1, 3, 1).Width(120).Changed() { log(fmt.Sprintf("level %d", int(s.level))) } - ui.Checkbox(c, &s.subtitle, "Subtitle") - ui.Checkbox(c, &s.divide, "Divider") + displayLog(log, ui.Checkbox(c, &s.subtitle, "Subtitle"), "subtitle", &s.subtitle) + displayLog(log, ui.Checkbox(c, &s.divide, "Divider"), "divide", &s.divide) }) }) displaySection(c, "Preview", func() { @@ -141,7 +144,7 @@ func iconDemo(c *ui.Context, log func(string)) { if ui.Select(c, &s.tone, []string{"Inherit", "Muted", "Accent", "Success", "Warning", "Danger", "Info"}).Changed() { log("tone " + s.tone) } - ui.Checkbox(c, &s.label, "Label") + displayLog(log, ui.Checkbox(c, &s.label, "Label"), "label", &s.label) }) }) displaySection(c, "Preview", func() { @@ -185,10 +188,11 @@ func imageDemo(c *ui.Context, log func(string)) { mujica.Heading(c, "Image", mujica.HeadingOptions{Subtitle: "位图与 SVG;包含或裁切;占位、加载、错误;替代说明", Divider: true}) displaySection(c, "Parameters", func() { displayControls(c, func() { - ui.Checkbox(c, &s.crop, "Crop") - ui.Checkbox(c, &s.empty, "No source") - ui.Checkbox(c, &s.loading, "Loading") + displayLog(log, ui.Checkbox(c, &s.crop, "Crop"), "crop", &s.crop) + displayLog(log, ui.Checkbox(c, &s.empty, "No source"), "empty", &s.empty) + displayLog(log, ui.Checkbox(c, &s.loading, "Loading"), "loading", &s.loading) if ui.Checkbox(c, &s.fail, "Error").Changed() { + log(fmt.Sprintf("fail = %v", s.fail)) s.err = nil if s.fail { s.err = errors.New("connection reset") @@ -251,8 +255,8 @@ func avatarDemo(c *ui.Context, log func(string)) { log("name " + s.name) } ui.Text(c, fmt.Sprintf("Size %d", int(s.size))) - ui.StepSlider(c, &s.size, 24, 80, 4).Width(160) - ui.Checkbox(c, &s.img, "Image") + displayLog(log, ui.StepSlider(c, &s.size, 24, 80, 4).Width(160), "size", &s.size) + displayLog(log, ui.Checkbox(c, &s.img, "Image"), "img", &s.img) }) }) displaySection(c, "Preview", func() { @@ -332,10 +336,10 @@ func badgeDemo(c *ui.Context, log func(string)) { displaySection(c, "Parameters", func() { displayControls(c, func() { ui.Text(c, fmt.Sprintf("Count %d", int(s.count))) - ui.StepSlider(c, &s.count, 0, 200, 1).Width(160) + displayLog(log, ui.StepSlider(c, &s.count, 0, 200, 1).Width(160), "count", &s.count) ui.Text(c, fmt.Sprintf("Max %d", int(s.max))) - ui.StepSlider(c, &s.max, 9, 999, 10).Width(120) - ui.TextInput(c, &s.text).Placeholder("Text overrides count").Width(180) + displayLog(log, ui.StepSlider(c, &s.max, 9, 999, 10).Width(120), "max", &s.max) + displayLog(log, ui.TextInput(c, &s.text).Placeholder("Text overrides count").Width(180), "text", &s.text) if ui.Select(c, &s.tone, []string{"Accent", "Neutral", "Success", "Warning", "Danger", "Info"}).Changed() { log("tone " + s.tone) } @@ -377,10 +381,10 @@ func tagDemo(c *ui.Context, log func(string)) { mujica.Heading(c, "Tag", mujica.HeadingOptions{Subtitle: "标签文字、图标;点击与关闭分离", Divider: true}) displaySection(c, "Parameters", func() { displayControls(c, func() { - ui.Checkbox(c, &s.icon, "Icon") - ui.Checkbox(c, &s.clickable, "Clickable") - ui.Checkbox(c, &s.closable, "Closable") - ui.Checkbox(c, &s.dis, "Disabled") + displayLog(log, ui.Checkbox(c, &s.icon, "Icon"), "icon", &s.icon) + displayLog(log, ui.Checkbox(c, &s.clickable, "Clickable"), "clickable", &s.clickable) + displayLog(log, ui.Checkbox(c, &s.closable, "Closable"), "closable", &s.closable) + displayLog(log, ui.Checkbox(c, &s.dis, "Disabled"), "dis", &s.dis) if mujica.Button(c, "Reset tags", mujica.ButtonOptions{Variant: mujica.Ghost}).Clicked() { s.tags = []string{"Baroque", "Gothic", "Rococo", "Court"} log("reset") @@ -395,8 +399,10 @@ func tagDemo(c *ui.Context, log func(string)) { ui.Row(c).Gap(8).Wrap().Children(func() { for i := 0; i < len(s.tags); i++ { t := s.tags[i] - v := mujica.Tag(c, t, mujica.TagOptions{Icon: icon, Clickable: s.clickable, Closable: s.closable, Disabled: s.dis}) - v.Element.Key(t) + var v mujica.TagView + ui.Box(c).Key(t).Children(func() { + v = mujica.Tag(c, t, mujica.TagOptions{Icon: icon, Clickable: s.clickable, Closable: s.closable, Disabled: s.dis}) + }) if v.Clicked { log("clicked " + t) } @@ -412,8 +418,10 @@ func tagDemo(c *ui.Context, log func(string)) { } const tagCode = `for i, t := range tags { - v := mujica.Tag(c, t, mujica.TagOptions{Icon: "star", Clickable: true, Closable: true}) - v.Element.Key(t) + var v mujica.TagView + ui.Box(c).Key(t).Children(func() { + v = mujica.Tag(c, t, mujica.TagOptions{Icon: "star", Clickable: true, Closable: true}) + }) if v.Closed { tags = append(tags[:i:i], tags[i+1:]...) } @@ -429,7 +437,7 @@ func linkDemo(c *ui.Context, log func(string)) { mujica.Heading(c, "Link", mujica.HeadingOptions{Subtitle: "外部地址或应用内动作;键盘激活;打开失败反馈", Divider: true}) displaySection(c, "Parameters", func() { displayControls(c, func() { - ui.TextInput(c, &s.url).Placeholder("URL (empty = in-app action)").Width(320) + displayLog(log, ui.TextInput(c, &s.url).Placeholder("URL (empty = in-app action)").Width(320), "url", &s.url) if mujica.Button(c, "Simulate open failure", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() { s.external = "No application can open this link." log("open failure set") diff --git a/examples/gallery/pages_display_parts.go b/examples/gallery/pages_display_parts.go index 57d8d45..b0bc3fb 100644 --- a/examples/gallery/pages_display_parts.go +++ b/examples/gallery/pages_display_parts.go @@ -1,6 +1,8 @@ package main import ( + "fmt" + "github.com/egoist/mygo/ui" mujica "mujicaui" @@ -30,3 +32,10 @@ func displayCode(c *ui.Context, code string, log func(string)) { mujica.Text(c, code, mujica.TextOptions{Selectable: true}).Font(theme.MonoFont).FontSize(12) }) } + +// displayLog logs a control's new value when it changed. +func displayLog[T any](log func(string), e *ui.Element, name string, v *T) { + if e.Changed() { + log(fmt.Sprintf("%s = %v", name, *v)) + } +} diff --git a/heading.go b/heading.go index 7734afb..6766865 100644 --- a/heading.go +++ b/heading.go @@ -7,7 +7,7 @@ import ( "mujicaui/theme" ) -// HeadingOptions configure a Heading. The zero value is a level 1 heading. +// HeadingOptions configure a Heading; zero is level 1. type HeadingOptions struct { // Level is 1, 2 or 3; zero means 1. Level int @@ -17,21 +17,17 @@ type HeadingOptions struct { Divider bool } -// Heading shows a serif title that follows the desktop's text scale. +// Heading shows a serif title scaled with desktop text. func Heading(c *ui.Context, title string, opts HeadingOptions) *ui.Element { k := Tokens(c) size := headingSize(opts.Level) - scale := c.Preferences().TextScale - if scale <= 0 { - scale = 1 - } u := Density(c).Unit() col := ui.Column(c).Gap(u).Shrink(0) col.Children(func() { - ui.Text(c, title).Font(theme.SerifFont).FontSize(size * scale).FontWeight(600). + ui.Text(c, title).Font(theme.SerifFont).FontSize(FontSize(c, size)).FontWeight(600). LineHeight(1.25).TextColor(k.Text) if opts.Subtitle != "" { - ui.Text(c, opts.Subtitle).FontSize(theme.BodySize * scale).LineHeight(theme.LineHeight).TextColor(k.TextMuted) + ui.Text(c, opts.Subtitle).FontSize(FontSize(c, theme.BodySize)).LineHeight(theme.LineHeight).TextColor(k.TextMuted) } if opts.Divider { ui.Box(c).FillWidth().Height(9).Margin(u, 0, 0, 0).Draw(func(p *ui.Painter, r ui.Rect) { diff --git a/icon.go b/icon.go index ec0cc19..6db5450 100644 --- a/icon.go +++ b/icon.go @@ -10,7 +10,7 @@ import ( "mujicaui/theme" ) -// IconTone is an icon's semantic color. The zero value takes the text color. +// IconTone is an icon's semantic color; zero inherits text color. type IconTone int const ( @@ -25,17 +25,16 @@ const ( // IconOptions configure an Icon. type IconOptions struct { - // Size is the icon's width and height in DIPs; zero means 16. + // Size is width and height in DIPs; zero means 16. Size float32 Tone IconTone - // Label names a meaningful icon for assistive technology; empty hides it. + // Label names the icon for assistive technology; empty hides it. Label string } type iconMissingKey struct{} -// Icon shows the named icon of the icons package. A missing name shows a -// dashed fallback box in the Danger color and logs a warning. +// Icon shows a named icon; missing ones show a fallback. func Icon(c *ui.Context, name string, opts IconOptions) *ui.Element { if name == "" { panic("mujica: Icon needs a name") diff --git a/image.go b/image.go index 52edd9f..50481e3 100644 --- a/image.go +++ b/image.go @@ -10,10 +10,9 @@ import ( // ImageOptions configure an Image. type ImageOptions struct { - // Width and Height size the box in DIPs; zero keeps the picture's size, - // or 160×120 for the placeholder, loading and error states. + // Width, Height in DIPs; zero uses src size or 160×120. Width, Height float32 - // Crop covers the box and crops the rest; false fits the picture inside. + // Crop covers and crops the box; false fits inside. Crop bool // Alt describes the picture for assistive technology. Alt string @@ -23,14 +22,14 @@ type ImageOptions struct { Err error } -// ImageView is a shown Image and what the user did to it this frame. +// ImageView is a shown Image and this frame's events. type ImageView struct { Element *ui.Element - // Retry reports a click on the retry button of the error state. + // Retry reports the error state's retry button was pressed. Retry bool } -// Image shows a *ui.Bitmap or *ui.SVG. A nil src shows a placeholder. +// Image shows a bitmap or SVG; nil shows a placeholder. func Image(c *ui.Context, src ui.ImageSource, opts ImageOptions) ImageView { if opts.Width < 0 || opts.Height < 0 { panic("mujica: Image Width and Height must not be negative") @@ -53,13 +52,7 @@ func Image(c *ui.Context, src ui.ImageSource, opts ImageOptions) ImageView { } k := Tokens(c) u := Density(c).Unit() - w, h := opts.Width, opts.Height - if w == 0 { - w = 160 - } - if h == 0 { - h = 120 - } + w, h := imageBox(src, opts.Width, opts.Height) var v ImageView frame := ui.Column(c).Size(w, h).Shrink(0).Center().Gap(u*2).Padding(u*2).Clip(). Radius(theme.ControlRadius).Background(k.SurfaceHover).Border(theme.BorderWidth, k.Border) @@ -69,7 +62,7 @@ func Image(c *ui.Context, src ui.ImageSource, opts ImageOptions) ImageView { case opts.Err != nil: frame.Role(ui.RoleGroup).Label(msg(c, "image.error")).Description(opts.Alt).Error(opts.Err.Error()) Icon(c, "circle-alert", IconOptions{Size: 20, Tone: IconDanger}) - ui.Text(c, msg(c, "image.error")).FontSize(theme.CaptionSize).TextColor(k.Danger).TextAlign(ui.Center) + ui.Text(c, msg(c, "image.error")).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.Danger).TextAlign(ui.Center) if Button(c, msg(c, "image.retry"), ButtonOptions{Variant: Secondary}).Clicked() { v.Retry = true slog.Debug("mujica: image retry", "alt", opts.Alt) @@ -84,3 +77,27 @@ func Image(c *ui.Context, src ui.ImageSource, opts ImageOptions) ImageView { }) return v } + +// imageBox fills unset sides from src's aspect, else 160×120. +func imageBox(src ui.ImageSource, w, h float32) (float32, float32) { + var sw, sh float32 + switch s := src.(type) { + case *ui.Bitmap: + iw, ih := s.Size() + sw, sh = float32(iw), float32(ih) + case *ui.SVG: + sw, sh = s.Size() + } + if sw <= 0 || sh <= 0 { + sw, sh = 160, 120 + } + switch { + case w == 0 && h == 0: + return sw, sh + case w == 0: + return h * sw / sh, h + case h == 0: + return w, w * sh / sw + } + return w, h +} diff --git a/image_test.go b/image_test.go index 395fa69..7316b7b 100644 --- a/image_test.go +++ b/image_test.go @@ -49,8 +49,8 @@ func TestImageStates(t *testing.T) { label string }{ {"placeholder", nil, ImageOptions{}, "No image"}, - {"loading", imageTestBitmap(), ImageOptions{Loading: true}, "Loading image"}, - {"error", imageTestBitmap(), ImageOptions{Loading: true, Err: errors.New("404")}, "Image failed to load"}, + {"loading", nil, ImageOptions{Loading: true}, "Loading image"}, + {"error", nil, ImageOptions{Loading: true, Err: errors.New("404")}, "Image failed to load"}, } { var r ui.Rect tt := ui.NewTester(func(c *ui.Context) { @@ -111,3 +111,27 @@ func TestImageSnapshot(t *testing.T) { }) }) } + +func TestImageKeepsSizeWhileLoading(t *testing.T) { + bmp := imageTestBitmap() + for _, tc := range []struct { + opts ImageOptions + w, h float32 + }{ + {ImageOptions{Loading: true}, 80, 40}, + {ImageOptions{Width: 160, Loading: true}, 160, 80}, + {ImageOptions{Err: errors.New("x")}, 80, 40}, + } { + var r ui.Rect + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).AlignItems(ui.Start).Children(func() { + r = Image(c, bmp, tc.opts).Element.Bounds() + }) + }, 400, 400) + tt.Frame() + if r.W != tc.w || r.H != tc.h { + t.Errorf("%+v: %v, want %vx%v", tc.opts, r, tc.w, tc.h) + } + } +} diff --git a/internal/display.go b/internal/display.go index f84e2fc..90d7740 100644 --- a/internal/display.go +++ b/internal/display.go @@ -7,7 +7,7 @@ import ( "github.com/egoist/mygo/ui" ) -// Fade animates e's background and border colors over d; zero skips it. +// Fade animates e's colors over d; zero skips it. func Fade(e *ui.Element, d time.Duration) *ui.Element { if d > 0 { e.Transition(ui.ElementTransition{Colors: true, Duration: d}) @@ -15,7 +15,7 @@ func Fade(e *ui.Element, d time.Duration) *ui.Element { return e } -// Rule draws a 1 DIP horizontal line from x0 to x1 at y, on device pixels. +// Rule draws a pixel-aligned 1 DIP horizontal line at y. func Rule(p *ui.Painter, x0, x1, y float32, c ui.Color) { s := float64(p.Scale()) px := math.Floor(float64(y) * s) diff --git a/kbd.go b/kbd.go index 5e2575d..46462f2 100644 --- a/kbd.go +++ b/kbd.go @@ -9,9 +9,7 @@ import ( "mujicaui/theme" ) -// Kbd shows a key or key combination as key caps. It registers nothing. -// "Mod" is Command on macOS and Ctrl elsewhere; Ctrl, Alt, Shift, Enter, -// Esc, Tab, Backspace, Delete, Up, Down, Left and Right follow the platform. +// Kbd shows keys as caps; "Mod" is Command or Ctrl. func Kbd(c *ui.Context, keys ...string) *ui.Element { if len(keys) == 0 { panic("mujica: Kbd needs at least one key") @@ -29,20 +27,20 @@ func Kbd(c *ui.Context, keys ...string) *ui.Element { row.Children(func() { for i, key := range keys { if i > 0 && !mac { - ui.Text(c, "+").FontSize(theme.CaptionSize).TextColor(k.TextMuted) + ui.Text(c, "+").FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted) } face, _ := kbdKey(key, mac) - ui.Box(c).Key(i).Row().Center().MinWidth(20).Height(20).Padding(0, 5). + ui.Box(c).Key(i).Row().Center().MinWidth(FontSize(c, 20)).MinHeight(FontSize(c, 20)).Padding(0, 5). Radius(theme.ControlRadius).Background(k.Surface).BorderColor(k.ControlBorder). BorderWidth(1, 1, 2, 1).Children(func() { - ui.Text(c, face).Font(theme.MonoFont).FontSize(theme.CaptionSize).TextColor(k.Text).SingleLine() + ui.Text(c, face).Font(theme.MonoFont).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.Text).SingleLine() }) } }) return row } -// kbdNames maps a key to its macOS cap, macOS word and other platforms' cap. +// kbdNames maps keys to macOS cap, word, other cap. var kbdNames = map[string]struct{ macCap, macWord, other string }{ "mod": {"⌘", "Command", "Ctrl"}, "ctrl": {"⌃", "Control", "Ctrl"}, @@ -59,7 +57,7 @@ var kbdNames = map[string]struct{ macCap, macWord, other string }{ "right": {"→", "Right", "→"}, } -// kbdKey returns how key shows and how it is read on the platform. +// kbdKey returns how key shows and reads on the platform. func kbdKey(key string, mac bool) (shown, spoken string) { n, ok := kbdNames[strings.ToLower(key)] if !ok { diff --git a/kbd_test.go b/kbd_test.go index 35ba0e4..c3606a3 100644 --- a/kbd_test.go +++ b/kbd_test.go @@ -78,3 +78,20 @@ func TestKbdSnapshot(t *testing.T) { }) }) } + +func TestKbdTextScale(t *testing.T) { + var h [2]float32 + for i, s := range []float32{1, 2} { + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Row(c).AlignItems(ui.Start).Children(func() { + h[i] = Kbd(c, "Mod", "K").Bounds().H + }) + }, 400, 200) + tt.SetPreferences(ui.Preferences{TextScale: s}) + tt.Frame() + } + if h[1] < h[0]*1.5 { + t.Fatalf("height %v at 2x, %v at 1x", h[1], h[0]) + } +} diff --git a/link.go b/link.go index 6b47b66..f2a4c94 100644 --- a/link.go +++ b/link.go @@ -11,16 +11,15 @@ import ( "mujicaui/theme" ) -// LinkOptions configure a Link. An empty URL makes an in-app action link. +// LinkOptions configure a Link; empty URL means in-app action. type LinkOptions struct { - // URL opens in the browser, or goes there in a MyGo router for a path. + // URL opens in the browser; paths go to the router. URL string // Error is the caller's open failure, shown below the link. Error string } -// Link is underlined text activated by a click or Enter. Clicked reports -// every activation. A malformed URL is not opened and shows an error. +// Link is underlined text; malformed URLs show an error instead. func Link(c *ui.Context, label string, opts LinkOptions) *ui.Element { if label == "" { panic("mujica: Link needs a label") @@ -56,7 +55,7 @@ func Link(c *ui.Context, label string, opts LinkOptions) *ui.Element { }) if problem != "" { l.Error(problem) - ui.Text(c, problem).TextColor(k.Danger).FontSize(theme.CaptionSize) + ui.Text(c, problem).TextColor(k.Danger).FontSize(FontSize(c, theme.CaptionSize)) } }) if l.Clicked() { @@ -65,7 +64,7 @@ func Link(c *ui.Context, label string, opts LinkOptions) *ui.Element { return l } -// linkCheck reports whether raw has a scheme, and whether it can be opened. +// linkCheck reports whether raw has a scheme and is openable. func linkCheck(raw string) (external, ok bool) { p, err := url.Parse(raw) if err != nil { diff --git a/tag.go b/tag.go index 3ba3161..0bc9ebe 100644 --- a/tag.go +++ b/tag.go @@ -10,9 +10,9 @@ import ( "mujicaui/theme" ) -// TagOptions configure a Tag. The zero value is a plain label. +// TagOptions configure a Tag; zero is a plain label. type TagOptions struct { - // Icon names an icon of the icons package shown before the label. + // Icon names an icon shown before the label. Icon string // Clickable makes the label a button reported by TagView.Clicked. Clickable bool @@ -21,17 +21,16 @@ type TagOptions struct { Disabled bool } -// TagView is a shown Tag and what the user did to it this frame. +// TagView is a shown Tag and this frame's events. type TagView struct { Element *ui.Element - // Clicked reports the label was activated; never set by the remove button. + // Clicked reports the label was activated, not the remove button. Clicked bool - // Closed reports the remove button, or Delete or Backspace on the label. + // Closed reports the remove button, Delete or Backspace. Closed bool } -// Tag shows a short label. Clicking the label and removing the tag are -// separate actions with separate focus stops. +// Tag shows a label; click and remove are separate stops. func Tag(c *ui.Context, label string, opts TagOptions) TagView { if label == "" { panic("mujica: Tag needs a label") @@ -66,13 +65,13 @@ func Tag(c *ui.Context, label string, opts TagOptions) TagView { if opts.Icon != "" { Icon(c, opts.Icon, IconOptions{Size: 14}) } - ui.Text(c, label).FontSize(theme.BodySize - 1).SingleLine() + ui.Text(c, label).FontSize(FontSize(c, theme.BodySize-1)).SingleLine() }) if opts.Clickable && main.Clicked() { v.Clicked = true } if opts.Closable { - if opts.Clickable && !opts.Disabled && (main.Shortcut(0, ui.KeyDelete) || main.Shortcut(0, ui.KeyBackspace)) { + if opts.Clickable && !main.IsDisabled() && (main.Shortcut(0, ui.KeyDelete) || main.Shortcut(0, ui.KeyBackspace)) { v.Closed = true } if tagClose(c, k, label).Clicked() { diff --git a/tag_test.go b/tag_test.go index f2b76d0..e3c3586 100644 --- a/tag_test.go +++ b/tag_test.go @@ -90,3 +90,40 @@ func TestTagSnapshot(t *testing.T) { }) }) } + +func TestTagDisabledAncestorBlocksDelete(t *testing.T) { + closed, off := 0, false + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Row(c).Disabled(off).Children(func() { + if Tag(c, "Velvet", TagOptions{Clickable: true, Closable: true}).Closed { + closed++ + } + }) + }, 300, 100) + tt.Key(0, ui.KeyTab) + off = true + tt.Frame() + tt.Key(0, ui.KeyDelete) + tt.Key(0, ui.KeyBackspace) + if closed != 0 { + t.Fatalf("closed %d times under a disabled ancestor", closed) + } +} + +func TestTagTextScale(t *testing.T) { + var h [2]float32 + for i, s := range []float32{1, 2} { + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Row(c).AlignItems(ui.Start).Children(func() { + h[i] = Tag(c, "Velvet", TagOptions{}).Element.Bounds().W + }) + }, 400, 200) + tt.SetPreferences(ui.Preferences{TextScale: s}) + tt.Frame() + } + if h[1] < h[0]*1.5 { + t.Fatalf("width %v at 2x, %v at 1x", h[1], h[0]) + } +} diff --git a/testdata/snapshots/darwin/kbd-dark.png b/testdata/snapshots/darwin/kbd-dark.png index 119484d20c9c643d3f62d0dd237a520ea850b3f5..1cc0cc21080022ea5b5b82c13239e599bdfa4e88 100644 GIT binary patch literal 2716 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 delta 2259 zcmV;^2rT!U71k7xB!3-AL_t(|ob20qP?KjK2k<8eBqTs0D%3#TaonjEE21%gL{U&% zyta-ziq-?Qcp%nwE8eZT9*7YyR79-@q87yi3L+F20Xf1I6}7r+w~A6l#Ib-0K_Fqr zvJbn%j$=-I(eL;9CwZRt{k=ble0+~+C3fmY5ClOC37ftE9Fe7@f5g+>RcI$*v-QPD ztJ782s+2eXSy=j$St@j_ZcVSCe~dVm{-Zs+YI7{pD*%qS42P$?>!mzpc|{eScym}R zR(G-E2-$#^kI?iA9jjZ@E9f61j>XKb+8oRD3W#xG8@BoDhtN)7(C0@8X>M+Q`1q;F z&emKNI##!)SI|F3e;kXMU9~xu=@kISTZTjbbH&%~XnF;U?$TyQCRDKKF70p#qq}O| z(ew(4alwzzq#qv!#``${2jF-&;SdP;_V#x49iFN%^{LAL$H}p)bYMT_)f<&hpRw7j zVMD!DDs^#5DP7kq7m{L+pE!FuK8eHO?BBQL!w;RuPng}%f6zeJnbqxoF1&?yDwSHN zYo-$hq`2T>%jx&?=hfLc1@f(X8ylPG;=7+-AN6!~{is>joR?p0W5bh5-3cML@7(?Q z=lgUMy<#iiN9_$4@NMQTSW#VFL#F`; zhr>B^`1@IhwY_v7s?9XEEQW7jT&2YaNZU7j}e&xDYq4;@pjHVnD`? z9XVz4_zjzO{dD^-onvCRLwyFx!@o&NP9M;}@0QK0TgWpsWo6}x0_FCd2TGLJJ*4h! zm(3Rk2T|mX4Y}EgNeMBN13stIWbvY)i^<0`(&N{ySQ6M+ga?AK1HBPnk?w_Mkj9^)lT=-}LFz>)X|fj~zdA zF*SqEF|k{pA)XQPHHVL$+O#=xe#nY}61UBpe^#1u_ukIV_N4_*uUf~(^~-yI{KwsU zk6gQcYx&X;_kMi{A(8=ZGXn!+j>T`^v48X^zj0$nwmgFf4_n=}i$lb=XggcMn$?Th zY!)qJ{^aHrvDqw{x0Fs8kXf@P_vq1m!NQdl6_w9_s|gNSDe7duXyI(Sj!PA0U#bv7 zf8tIh#+^zmDNLIk^!2J0i4kEg)Z+^M=;K%f=xj|Fi++5qPF{WG93spj5f2vBQ z{&a*7Aw=Bm1K(lZ=gz0xymiOWZvA{^fzu}nh4y=*ju>>7ZqVTKZFoH1%)o%ul#>^d zVg&+zm(C7^kb**G@`X!Fmjq{|$8T7-v{R>7C;2^kbXV7D?%jXb@~e38&`C=>o{^C= z)W?gkBG|4FBS^zU0lRsv6Dtq9~QnlC%1@h zqHiL$MUNggJ#f}vj>ep(bLNjmQ(s%Fir5}4@$i>;_z(0PwQa|KLP%p{f7AMn+ei9O zS+{;$|Nft}+@5^$q{>#ncNTv@2oZ^d{-62scsyFkBFWAv;BvWMUJ^QiFe{dVqoJYk zNoCcXFQ-0wRFRukq)?QM^7s8icbCda!@eNg`p?L>YqvT(f3Pfc0i9!FxBvR3%y*b9 zIWZe^>G+2Yl}1BqP#)Z^@>ib)9GIQ9WA|afBW!}xVdwtT}-=N zTzs9bGppMl)3dU&6;_;A9M@^;jo?_P(={3X5ZAcG#-6ba7DQ}WV`XJ^C8r=Ir~pGp0?9j@nXIR=zbNs-dBgma$aA!gmiEBw?{w zx9=G9D6L7WHR5qNf8coga6GMgb}{WT{e5W8%2v=712X1MGw4eaeIPdaKrDU7#BK>8 zFJHbi#4RnL-=)9E{dvn%pD`Vw$-C^#@W#8B61=*$1+b^75hf1}y&GQRKkI9e_!Oxe-& z3Krd^&5lf{V9{OL;Sff5)w-kU6>!G!md`k9Rdq|goJijpkl$)m=Bm)Kx;4Fm{xSMk z%zLWy=GT5dkifa0xmCj@QDIAORnfa0yP6 ha0wS0420v~0|45l?UFCGZC3yQ002ovPDHLkV1g)?jNJeL diff --git a/testdata/snapshots/darwin/kbd-light.png b/testdata/snapshots/darwin/kbd-light.png index d38c7dd4c696a1fdc10c503b6110978ba761de44..3651fa8d22bd571af6d9362c522bac746d8c2676 100644 GIT binary patch literal 2613 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 LJ)+v&>;8WMqFSrO delta 2224 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