Merge branch 'worktree-agent-a1dd614394ee20ff2'
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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 无法检测文本是否真的被截断,所以截断时不会自动显示完整内容的提示。
|
||||
@@ -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` | 调用方正在加载。 |
|
||||
|
||||
@@ -43,5 +43,7 @@ view := func(c *ui.Context) {
|
||||
|
||||
## 限制
|
||||
|
||||
- 首字母字号随头像直径而定,不随系统文本缩放放大,以免溢出圆形。
|
||||
|
||||
- 中文姓名没有空格时按一个词处理,只取第一个字,例如“张伟”显示为“张”。
|
||||
- 头像不使用 MyGo `ui.Avatar`,因为它的尺寸固定为字号的 2.25 倍,无法配置(`ui/indicators.go:383`)。
|
||||
@@ -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 时滚动;遵循减少动画设置。
|
||||
|
||||
## 事件
|
||||
|
||||
@@ -34,7 +34,7 @@ view := func(c *ui.Context) {
|
||||
|
||||
## 状态
|
||||
|
||||
纯展示。强调和危险色调使用实色填充(`OnAccent` 配 `Accent`,`OnDanger` 配 `DangerFill`);成功、警告、信息色调只把语义色用在文字和边框上。
|
||||
纯展示。字号与最小尺寸随系统文本缩放放大。强调和危险色调使用实色填充(`OnAccent` 配 `Accent`,`OnDanger` 配 `DangerFill`);成功、警告、信息色调只把语义色用在文字和边框上。
|
||||
|
||||
## 事件
|
||||
|
||||
|
||||
@@ -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 类组件。
|
||||
@@ -39,7 +39,7 @@ view := func(c *ui.Context) {
|
||||
|
||||
## 状态
|
||||
|
||||
纯展示。键帽使用等宽字体、`Surface` 底色、`ControlBorder` 边框,底边 2 DIP。
|
||||
纯展示。键帽随系统文本缩放放大,使用等宽字体、`Surface` 底色、`ControlBorder` 边框,底边 2 DIP。
|
||||
|
||||
## 事件
|
||||
|
||||
|
||||
@@ -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:]...)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
@@ -11,13 +11,13 @@ import (
|
||||
|
||||
// TextOptions configure a Text. The zero value wraps every line.
|
||||
type TextOptions struct {
|
||||
// MaxLines cuts the text with an ellipsis after that many lines; zero never cuts.
|
||||
// MaxLines cuts with an ellipsis after n lines; zero never.
|
||||
MaxLines int
|
||||
// Selectable lets the user select and copy the text.
|
||||
Selectable bool
|
||||
}
|
||||
|
||||
// Text shows body text that wraps at the width it gets.
|
||||
// Text shows body text wrapping at its width.
|
||||
func Text(c *ui.Context, s string, opts TextOptions) *ui.Element {
|
||||
if opts.MaxLines < 0 {
|
||||
panic("mujica: Text MaxLines must not be negative")
|
||||
@@ -30,7 +30,8 @@ func Text(c *ui.Context, s string, opts TextOptions) *ui.Element {
|
||||
e.MaxLines(opts.MaxLines)
|
||||
}
|
||||
if opts.Selectable {
|
||||
e.Selectable()
|
||||
e.Selectable().Focusable()
|
||||
internal.Ring(e, Tokens(c).Focus, theme.ControlRadius)
|
||||
}
|
||||
return e
|
||||
}
|
||||
@@ -45,41 +46,100 @@ type EditableTextOptions struct {
|
||||
Label string
|
||||
}
|
||||
|
||||
// EditableText is Text's editable mode. Double click or Enter edits; Enter
|
||||
// or leaving confirms, Escape cancels. Changed reports a confirmed new value.
|
||||
func EditableText(c *ui.Context, value *string, opts EditableTextOptions) *ui.Element {
|
||||
// EditableTextView is a shown EditableText and this frame's outcome.
|
||||
type EditableTextView struct {
|
||||
Element *ui.Element
|
||||
// Changed reports a confirmed new value, already in *value.
|
||||
Changed bool
|
||||
// Canceled reports Escape discarded an edit.
|
||||
Canceled bool
|
||||
}
|
||||
|
||||
type editableTextState struct {
|
||||
editing, fresh bool
|
||||
draft string
|
||||
}
|
||||
|
||||
type editableTextKey struct{}
|
||||
|
||||
// EditableText is Text's editable mode with confirm and cancel.
|
||||
func EditableText(c *ui.Context, value *string, opts EditableTextOptions) EditableTextView {
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
line := k.ControlBorder
|
||||
if opts.Error != "" {
|
||||
line = k.Danger
|
||||
}
|
||||
var box *ui.Element
|
||||
var v EditableTextView
|
||||
ui.Column(c).Gap(u).AlignItems(ui.Start).Children(func() {
|
||||
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Disabled(opts.Saving).Children(func() {
|
||||
box = ui.EditableText(c, value)
|
||||
pad := u / 2
|
||||
box.Padding(pad+1, pad*2+1).MinWidth(theme.MinHitSize*2).MinHeight(theme.MinHitSize).Radius(theme.ControlRadius).
|
||||
BorderWidth(0, 0, 1, 0).BorderColor(line).BorderStyle(ui.BorderDashed)
|
||||
internal.Ring(box, k.Focus, theme.ControlRadius)
|
||||
if box.Hovered() {
|
||||
box.Background(k.SurfaceHover)
|
||||
}
|
||||
internal.Fade(box, Motion(c, theme.StateDuration))
|
||||
if opts.Label != "" {
|
||||
box.Label(opts.Label)
|
||||
}
|
||||
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
|
||||
v = editableTextField(c, k, value, opts, line)
|
||||
if opts.Saving {
|
||||
Spinner(c, SpinnerOptions{Size: 12, Label: msg(c, "editable_text.saving")})
|
||||
}
|
||||
})
|
||||
if opts.Error != "" {
|
||||
box.Error(opts.Error)
|
||||
ui.Text(c, opts.Error).TextColor(k.Danger).FontSize(theme.CaptionSize)
|
||||
ui.Text(c, opts.Error).TextColor(k.Danger).FontSize(FontSize(c, theme.CaptionSize))
|
||||
}
|
||||
})
|
||||
if box.Changed() {
|
||||
switch {
|
||||
case v.Changed:
|
||||
slog.Debug("mujica: editabletext changed", "len", len(*value))
|
||||
case v.Canceled:
|
||||
slog.Debug("mujica: editabletext canceled")
|
||||
}
|
||||
return box
|
||||
return v
|
||||
}
|
||||
|
||||
func editableTextField(c *ui.Context, k theme.Tokens, value *string, opts EditableTextOptions, line ui.Color) EditableTextView {
|
||||
pad := Density(c).Unit() / 2
|
||||
box := ui.Row(c).AlignItems(ui.Center).MinWidth(theme.MinHitSize * 2).MinHeight(theme.MinHitSize).
|
||||
Radius(theme.ControlRadius).Disabled(opts.Saving)
|
||||
v := EditableTextView{Element: box}
|
||||
st := ui.Local(box, editableTextKey{}, func() editableTextState { return editableTextState{} })
|
||||
if opts.Saving {
|
||||
st.editing = false
|
||||
}
|
||||
if !st.editing {
|
||||
box.Focusable().Padding(pad+1, pad*2+1).BorderWidth(0, 0, 1, 0).BorderColor(line).BorderStyle(ui.BorderDashed).
|
||||
Label(opts.Label).Error(opts.Error)
|
||||
internal.Ring(box, k.Focus, theme.ControlRadius)
|
||||
if box.Hovered() {
|
||||
box.Background(k.SurfaceHover)
|
||||
}
|
||||
internal.Fade(box, Motion(c, theme.StateDuration))
|
||||
if !box.IsDisabled() && (box.DoubleClicked() || box.Shortcut(0, ui.KeyEnter)) {
|
||||
st.editing, st.fresh, st.draft = true, true, *value
|
||||
} else {
|
||||
box.Children(func() { ui.Text(c, *value).SingleLine() })
|
||||
return v
|
||||
}
|
||||
}
|
||||
var in *ui.Element
|
||||
box.Children(func() {
|
||||
in = ui.TextInputBase(c, &st.draft).Grow(1).MinWidth(theme.MinHitSize*3).Padding(pad, pad*2).
|
||||
Radius(theme.ControlRadius).Background(k.Surface).Border(theme.BorderWidth, k.Accent).TextColor(k.Text).
|
||||
Label(opts.Label).Error(opts.Error)
|
||||
})
|
||||
done := func() {
|
||||
st.editing = false
|
||||
box.Focus()
|
||||
}
|
||||
switch {
|
||||
case st.fresh:
|
||||
st.fresh = false
|
||||
in.Focus()
|
||||
case in.Submitted():
|
||||
v.Changed = st.draft != *value
|
||||
*value = st.draft
|
||||
done()
|
||||
case in.Shortcut(0, ui.KeyEscape):
|
||||
v.Canceled = true
|
||||
done()
|
||||
case !in.Focused():
|
||||
v.Changed = st.draft != *value
|
||||
*value = st.draft
|
||||
st.editing = false
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -21,6 +21,43 @@ func TestTextSelectAndCopy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextSelectableTakesTab(t *testing.T) {
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
Text(c, "ID-4821", TextOptions{Selectable: true})
|
||||
}, 300, 100)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
if !tt.Focused("ID-4821") {
|
||||
t.Fatal("selectable text not in Tab order")
|
||||
}
|
||||
tt.Command("selectAll")
|
||||
tt.Command("copy")
|
||||
if got := tt.Clipboard(); got != "ID-4821" {
|
||||
t.Fatalf("clipboard %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditableTextBlurConfirms(t *testing.T) {
|
||||
name := "a"
|
||||
changed := 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
if EditableText(c, &name, EditableTextOptions{}).Changed {
|
||||
changed++
|
||||
}
|
||||
Button(c, "Other", ButtonOptions{})
|
||||
}, 300, 120)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
tt.Type("b")
|
||||
if err := tt.Click("Other"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if name != "ab" || changed != 1 {
|
||||
t.Fatalf("name %q, changed %d", name, changed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextNotSelectableByDefault(t *testing.T) {
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
@@ -68,34 +105,45 @@ func TestEditableTextConfirm(t *testing.T) {
|
||||
changed := 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
if EditableText(c, &name, EditableTextOptions{Label: "File name"}).Changed() {
|
||||
if EditableText(c, &name, EditableTextOptions{Label: "File name"}).Changed {
|
||||
changed++
|
||||
}
|
||||
}, 300, 120)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
tt.Type("summary")
|
||||
if !tt.Focused("File name") {
|
||||
t.Fatal("editing input not named File name")
|
||||
}
|
||||
tt.Command("selectAll")
|
||||
tt.Type("summary.pdf")
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if name != "summary.pdf" || changed != 1 {
|
||||
t.Fatalf("name %q, changed %d", name, changed)
|
||||
}
|
||||
if !tt.Focused("File name") {
|
||||
t.Fatal("focus not back on the text after confirm")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditableTextCancel(t *testing.T) {
|
||||
name := "report.pdf"
|
||||
changed := 0
|
||||
changed, canceled := 0, 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
if EditableText(c, &name, EditableTextOptions{}).Changed() {
|
||||
v := EditableText(c, &name, EditableTextOptions{})
|
||||
if v.Changed {
|
||||
changed++
|
||||
}
|
||||
if v.Canceled {
|
||||
canceled++
|
||||
}
|
||||
}, 300, 120)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
tt.Type("draft")
|
||||
tt.Key(0, ui.KeyEscape)
|
||||
if name != "report.pdf" || changed != 0 {
|
||||
t.Fatalf("name %q, changed %d", name, changed)
|
||||
if name != "report.pdf" || changed != 0 || canceled != 1 {
|
||||
t.Fatalf("name %q, changed %d, canceled %d", name, changed, canceled)
|
||||
}
|
||||
if !tt.HasText("report.pdf") {
|
||||
t.Fatal("text not shown after cancel")
|
||||
|
||||