Merge branch 'main' into worktree-agent-ab9ba373bc47c2352
This commit is contained in:
27 files changed
+686
-257
No files matched your search
@@ -72,10 +72,10 @@ k := mujica.Tokens(c) // 当前窗口的颜色
|
||||
|
||||
```sh
|
||||
go run ./examples/gallery # 原生窗口
|
||||
go run ./examples/gallery -web :8080 # 浏览器镜像
|
||||
go run ./examples/gallery -web 127.0.0.1:8080 # 浏览器镜像,仅本机
|
||||
```
|
||||
|
||||
浏览器镜像不在浏览器里运行组件。Go 进程用 `ui.NewTester` 驱动同一个 Gallery 视图,把帧推给页面,再把指针、键盘、输入法和剪贴板事件送回 Go。
|
||||
`-web` 按传入的地址监听;写 `:8080` 会对所有网络接口开放。浏览器镜像不在浏览器里运行组件。Go 进程用 `ui.NewTester` 驱动同一个 Gallery 视图,把帧推给页面,再把指针、键盘、输入法和剪贴板事件送回 Go。
|
||||
|
||||
## 文档
|
||||
|
||||
|
||||
@@ -169,7 +169,7 @@ func TestFormFocusesDatePickerInput(t *testing.T) {
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
Form(c, &st, FormOptions{}, func(f *FormScope) {
|
||||
FormItem(f, "debut", &day, FormItemOptions[Date]{Label: "Debut date", Required: true}, func() *ui.Element {
|
||||
FormItem(c, f, "debut", &day, FormItemOptions[Date]{Label: "Debut date", Required: true}, func() *ui.Element {
|
||||
r := DatePicker(c, &day, DatePickerOptions{Today: calendarToday})
|
||||
input = r.Input
|
||||
return r.Input
|
||||
|
||||
+1
-1
@@ -22,4 +22,4 @@
|
||||
|
||||
## 离线
|
||||
|
||||
库与 Gallery 不发起网络请求,无遥测、账号或远程字体。图标与角饰随代码嵌入。浏览器镜像只在本机监听调用方给定的地址。
|
||||
库与 Gallery 不发起网络请求,无遥测、账号或远程字体。图标与角饰随代码嵌入。浏览器镜像按 `-web` 传入的地址监听:`127.0.0.1:8080` 仅本机可达,`:8080` 对所有网络接口开放。
|
||||
@@ -14,7 +14,7 @@ name := ""
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
res := mujica.Form(c, &state, mujica.FormOptions{}, func(f *mujica.FormScope) {
|
||||
mujica.FormItem(f, "name", &name, mujica.FormItemOptions[string]{Label: "Name", Required: true}, func() *ui.Element {
|
||||
mujica.FormItem(c, f, "name", &name, mujica.FormItemOptions[string]{Label: "Name", Required: true}, func() *ui.Element {
|
||||
return mujica.InputGroup(c, &name, mujica.InputGroupOptions{Invalid: state.Error("name") != ""}).Input
|
||||
})
|
||||
})
|
||||
|
||||
@@ -57,5 +57,4 @@ ui.Render(view, 400, 300, 1)
|
||||
- 段顺序固定为“时:分:秒 上午/下午”,zh-CN 下上午/下午也位于末尾。
|
||||
- MyGo 的 `ui.TimeInput` 只有时分且无 12 小时制、步长和范围(ui/timeinput.go:18),因此本组件自绘分段。
|
||||
- 日期与时间值不带时区:`Date`、`YearMonth`、`TimeOfDay` 只保存日历字段,组件内部计算不做 UTC 转换。
|
||||
- `Today` 为零值时读取 `c.Now()`(系统本地时区);需要固定“今天”(测试、跨时区应用)时显式传入。
|
||||
- 无障碍:MyGo v0.2.10 没有公开设置弹出按钮展开状态(`expanded`)、数值范围(`hasRange`/`accRange`)与值(`accValue`)的接口,这些字段只在 MyGo 内部组件中赋值(ui/element.go:286-295,例如 ui/base.go:297、ui/indicators.go:61)。因此触发器只能声明为 `RolePopUpButton` 而无法报告展开状态;时间段以 `Description` 读出当前值与范围,错误通过 `Error()` 关联。
|
||||
@@ -53,5 +53,4 @@ ui.Render(view, 400, 300, 1)
|
||||
- 开始等于结束视为错误。
|
||||
- `TimeRange.Overnight()` 报告是否跨午夜。
|
||||
- 日期与时间值不带时区:`Date`、`YearMonth`、`TimeOfDay` 只保存日历字段,组件内部计算不做 UTC 转换。
|
||||
- `Today` 为零值时读取 `c.Now()`(系统本地时区);需要固定“今天”(测试、跨时区应用)时显式传入。
|
||||
- 无障碍:MyGo v0.2.10 没有公开设置弹出按钮展开状态(`expanded`)、数值范围(`hasRange`/`accRange`)与值(`accValue`)的接口,这些字段只在 MyGo 内部组件中赋值(ui/element.go:286-295,例如 ui/base.go:297、ui/indicators.go:61)。因此触发器只能声明为 `RolePopUpButton` 而无法报告展开状态;时间段以 `Description` 读出当前值与范围,错误通过 `Error()` 关联。
|
||||
@@ -7,11 +7,13 @@
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
mujica.Tabs(c, &tab, []mujica.Tab{
|
||||
if mujica.Tabs(c, &tab, []mujica.Tab{
|
||||
{Label: "General", Panel: func() { ui.Text(c, "General settings") }},
|
||||
{Label: "Fonts", Icon: icons.Must("italic"), Panel: func() { ui.Text(c, "Fonts") }},
|
||||
{Label: "Archive", Disabled: true},
|
||||
}, mujica.TabsOptions{Label: "Settings"})
|
||||
}, mujica.TabsOptions{Label: "Settings"}).Changed() {
|
||||
fmt.Println("tab", tab)
|
||||
}
|
||||
```
|
||||
|
||||
签名:`func Tabs(c *ui.Context, selected *int, tabs []Tab, opts TabsOptions) ChoiceResult`
|
||||
|
||||
@@ -7,10 +7,12 @@
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
mujica.SegmentedControl(c, &view, []mujica.Segment{
|
||||
if mujica.SegmentedControl(c, &view, []mujica.Segment{
|
||||
{Label: "List", Icon: icons.Must("list"), HideLabel: true},
|
||||
{Label: "Grid", Icon: icons.Must("layout-grid"), HideLabel: true},
|
||||
}, mujica.SegmentedControlOptions{Label: "View"})
|
||||
}, mujica.SegmentedControlOptions{Label: "View"}).Changed() {
|
||||
fmt.Println("view", view)
|
||||
}
|
||||
```
|
||||
|
||||
签名:`func SegmentedControl(c *ui.Context, selected *int, segments []Segment, opts SegmentedControlOptions) *ui.Element`
|
||||
|
||||
@@ -61,7 +61,7 @@ if id, ok := res.Chosen(); ok {
|
||||
|
||||
## 限制
|
||||
|
||||
- MyGo `TextInputBase` 没有占位文字(`ui/editor.go:734`);为避免与输入法组合文字重叠,说明放在底部提示栏,搜索框以无障碍名称“搜索命令”标识。
|
||||
- 操作说明放在底部提示栏而不放进输入框,避免与输入法组合文字重叠;搜索框以无障碍名称“搜索命令”标识。
|
||||
- 搜索是子串匹配,不做模糊匹配或排序打分。
|
||||
- 焦点留在搜索框以便继续输入;方向键移动高亮时用 `c.Announce` 朗读当前命令,并写入搜索框的 `Description`(MyGo 的 active descendant 不公开)。
|
||||
- 弹层打开时在 160 ms 内淡入,减少动画时直接显示。关闭时立即消失:弹层关闭后 MyGo 不再构建其元素,公开接口无法为已移除的浮层做淡出。
|
||||
|
||||
+1
-1
@@ -33,6 +33,6 @@ PRD 第 2 节要求公开接口不足时向上游提交扩展。以下缺口在
|
||||
| `activeDescendant` 不公开 | `ui/element.go:331` | Menubar、CommandPalette |
|
||||
| `SliderBase` 方向键步长固定 1%,构建时处理指针 | `ui/base.go:111-117` | Slider |
|
||||
| `SelectBase` 高亮无设置接口 | `ui/base.go:260` | Select |
|
||||
| `TextInputBase` 无只读开关与占位符 | `ui/editor.go:139,734,811` | TextInput 只读模式、CommandPalette |
|
||||
| `TextInputBase` 无只读开关 | `ui/editor.go:139,811` | TextInput 只读模式 |
|
||||
| `ui.Stepper` 禁用时仍可步进,固定 9 位取整 | `ui/indicators.go:216`、`ui/widgets.go` `snap` | NumberInput(已改为自绘) |
|
||||
| 路径遮罩缓存按四分之一像素取键,进程内共享 | `ui/path.go:189-210` | 自绘路径以 `internal.Snap` 对齐;MyGo 自绘的箭头(Outline 等)无法对齐;快照测试因此在独立子进程中渲染,结果与测试顺序无关 |
|
||||
+79
-57
@@ -14,71 +14,93 @@ var (
|
||||
docsPageID = regexp.MustCompile(`ID: "(\d{3})"`)
|
||||
)
|
||||
|
||||
// TestDocSnippetsAreCompiledExamples keeps every doc's first Go block inside a compiled Example.
|
||||
// docsLines normalizes code into its non-empty, non-comment lines without whitespace.
|
||||
func docsLines(code string) []string {
|
||||
var out []string
|
||||
for _, l := range strings.Split(code, "\n") {
|
||||
s := strings.TrimSpace(l)
|
||||
if s != "" && !strings.HasPrefix(s, "//") {
|
||||
out = append(out, docsSpace.ReplaceAllString(s, ""))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// docsContains reports whether want appears in order inside have.
|
||||
func docsContains(have, want []string) bool {
|
||||
i := 0
|
||||
for _, h := range have {
|
||||
if i < len(want) && h == want[i] {
|
||||
i++
|
||||
}
|
||||
}
|
||||
return i == len(want)
|
||||
}
|
||||
|
||||
func readGlob(t *testing.T, pattern string) map[string]string {
|
||||
t.Helper()
|
||||
files, err := filepath.Glob(pattern)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := map[string]string{}
|
||||
for _, f := range files {
|
||||
b, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out[f] = string(b)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestDocSnippetsAreCompiledExamples keeps each doc's first Go block, in order, inside one Example.
|
||||
func TestDocSnippetsAreCompiledExamples(t *testing.T) {
|
||||
examples, err := filepath.Glob("example_*_test.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var all strings.Builder
|
||||
for _, f := range examples {
|
||||
b, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
all.Write(b)
|
||||
}
|
||||
compiled := docsSpace.ReplaceAllString(all.String(), "")
|
||||
docs, err := filepath.Glob("docs/components/*.md")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pages, err := filepath.Glob("examples/gallery/pages_*.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids := map[string]bool{}
|
||||
for _, f := range pages {
|
||||
b, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, m := range docsPageID.FindAllSubmatch(b, -1) {
|
||||
ids[string(m[1])] = true
|
||||
var examples [][]string
|
||||
for _, src := range readGlob(t, "example_*_test.go") {
|
||||
for _, fn := range strings.Split(src, "\nfunc Example")[1:] {
|
||||
examples = append(examples, docsLines(fn))
|
||||
}
|
||||
}
|
||||
documented := map[string]bool{}
|
||||
for _, d := range docs {
|
||||
documented[filepath.Base(d)[:3]] = true
|
||||
}
|
||||
for id := range ids {
|
||||
if !documented[id] {
|
||||
t.Errorf("gallery page %s has no doc", id)
|
||||
}
|
||||
}
|
||||
for id := range documented {
|
||||
if !ids[id] {
|
||||
t.Errorf("doc %s has no gallery page", id)
|
||||
}
|
||||
}
|
||||
for _, d := range docs {
|
||||
b, err := os.ReadFile(d)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := docsGoBlock.FindSubmatch(b)
|
||||
for d, src := range readGlob(t, "docs/components/*.md") {
|
||||
m := docsGoBlock.FindStringSubmatch(src)
|
||||
if m == nil {
|
||||
t.Errorf("%s has no Go example", d)
|
||||
continue
|
||||
}
|
||||
for _, line := range strings.Split(string(m[1]), "\n") {
|
||||
s := strings.TrimSpace(line)
|
||||
if s == "" || strings.HasPrefix(s, "//") {
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(compiled, docsSpace.ReplaceAllString(s, "")) {
|
||||
t.Errorf("%s: %q is not in any compiled Example", d, s)
|
||||
want, found := docsLines(m[1]), false
|
||||
for _, ex := range examples {
|
||||
if docsContains(ex, want) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("%s: its Go block is not, in order, inside any compiled Example", d)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDocsMatchGalleryPages wants exactly one doc and one gallery page per component ID.
|
||||
func TestDocsMatchGalleryPages(t *testing.T) {
|
||||
pages := map[string]int{}
|
||||
for _, src := range readGlob(t, "examples/gallery/pages_*.go") {
|
||||
for _, m := range docsPageID.FindAllStringSubmatch(src, -1) {
|
||||
pages[m[1]]++
|
||||
}
|
||||
}
|
||||
docs := map[string]int{}
|
||||
for d := range readGlob(t, "docs/components/*.md") {
|
||||
docs[filepath.Base(d)[:3]]++
|
||||
}
|
||||
for id, n := range pages {
|
||||
if n != 1 || docs[id] != 1 {
|
||||
t.Errorf("component %s: %d gallery pages, %d docs; want 1 and 1", id, n, docs[id])
|
||||
}
|
||||
}
|
||||
for id, n := range docs {
|
||||
if pages[id] == 0 {
|
||||
t.Errorf("doc %s (%d files) has no gallery page", id, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ func ExampleForm() {
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
res := mujica.Form(c, &state, mujica.FormOptions{}, func(f *mujica.FormScope) {
|
||||
mujica.FormItem(f, "name", &name, mujica.FormItemOptions[string]{Label: "Name", Required: true}, func() *ui.Element {
|
||||
mujica.FormItem(c, f, "name", &name, mujica.FormItemOptions[string]{Label: "Name", Required: true}, func() *ui.Element {
|
||||
return mujica.InputGroup(c, &name, mujica.InputGroupOptions{Invalid: state.Error("name") != ""}).Input
|
||||
})
|
||||
})
|
||||
|
||||
@@ -110,7 +110,7 @@ func (g *gallery) formEdit(c *ui.Context, log func(string)) {
|
||||
|
||||
func (g *gallery) formFields(c *ui.Context, f *mujica.FormScope, log func(string)) {
|
||||
s := &g.cForm
|
||||
mujica.FormItem(f, "name", &s.name, mujica.FormItemOptions[string]{Label: g.t("Stage name", "艺名"), Required: true,
|
||||
mujica.FormItem(c, f, "name", &s.name, mujica.FormItemOptions[string]{Label: g.t("Stage name", "艺名"), Required: true,
|
||||
Description: g.t("Checked against the archive; “Oblivionis” is taken.", "将与档案核对;“Oblivionis”已被占用。"),
|
||||
Validate: func(v string) string {
|
||||
if len([]rune(strings.TrimSpace(v))) < 2 {
|
||||
@@ -134,7 +134,7 @@ func (g *gallery) formFields(c *ui.Context, f *mujica.FormScope, log func(string
|
||||
}}, func() *ui.Element {
|
||||
return mujica.TextInput(c, &s.name, mujica.TextInputOptions{}).Input
|
||||
})
|
||||
mujica.FormItem(f, "password", &s.password, mujica.FormItemOptions[string]{Label: g.t("Password", "密码"), Required: true,
|
||||
mujica.FormItem(c, f, "password", &s.password, mujica.FormItemOptions[string]{Label: g.t("Password", "密码"), Required: true,
|
||||
Validate: func(v string) string {
|
||||
if len([]rune(v)) < 8 {
|
||||
return g.t("Use at least 8 characters.", "至少 8 个字符。")
|
||||
@@ -147,14 +147,14 @@ func (g *gallery) formFields(c *ui.Context, f *mujica.FormScope, log func(string
|
||||
}
|
||||
return r.Input
|
||||
})
|
||||
mujica.FormItem(f, "debut", &s.debut, mujica.FormItemOptions[mujica.Date]{Label: g.t("Debut date", "首演日期"), Required: true}, func() *ui.Element {
|
||||
mujica.FormItem(c, f, "debut", &s.debut, mujica.FormItemOptions[mujica.Date]{Label: g.t("Debut date", "首演日期"), Required: true}, func() *ui.Element {
|
||||
r := mujica.DatePicker(c, &s.debut, mujica.DatePickerOptions{Clearable: true})
|
||||
if r.Changed() {
|
||||
log("debut " + s.debut.Format("2006-01-02"))
|
||||
}
|
||||
return r.Input
|
||||
})
|
||||
mujica.FormItem(f, "roles", &s.tags, mujica.FormItemOptions[[]string]{Label: g.t("Roles", "担当"), Required: true,
|
||||
mujica.FormItem(c, f, "roles", &s.tags, mujica.FormItemOptions[[]string]{Label: g.t("Roles", "担当"), Required: true,
|
||||
Description: g.t("Up to 4; press Enter after each.", "最多 4 个;每个后按 Enter。")}, func() *ui.Element {
|
||||
r := mujica.TagsInput(c, &s.tags, mujica.TagsInputOptions{Max: 4, Invalid: s.st.Error("roles") != "", Placeholder: g.t("keyboard, bass…", "键盘、贝斯…")})
|
||||
if r.Changed() {
|
||||
@@ -162,7 +162,7 @@ func (g *gallery) formFields(c *ui.Context, f *mujica.FormScope, log func(string
|
||||
}
|
||||
return r.Input
|
||||
})
|
||||
mujica.FormItem(f, "terms", &s.terms, mujica.FormItemOptions[bool]{Label: g.t("Terms", "条款"),
|
||||
mujica.FormItem(c, f, "terms", &s.terms, mujica.FormItemOptions[bool]{Label: g.t("Terms", "条款"),
|
||||
Validate: func(v bool) string {
|
||||
if !v {
|
||||
return g.t("Accept the terms to continue.", "请先同意条款。")
|
||||
|
||||
@@ -111,11 +111,11 @@ func TestCompositeFormInvalidThenValid(t *testing.T) {
|
||||
click(t, tt, "I accept the covenant of the masquerade")
|
||||
if s.name != "Mortis" || s.password != "hunter22secret" || s.debut.Format("2006-01-02") != "2026-12-24" ||
|
||||
!slices.Equal(s.tags, []string{"guitar"}) || !s.terms {
|
||||
t.Fatalf("values %q %q %v %q %v", s.name, s.password, s.debut, s.tags, s.terms)
|
||||
t.Fatalf("values %q, password ok %v, %v %q %v", s.name, s.password == "hunter22secret", s.debut, s.tags, s.terms)
|
||||
}
|
||||
click(t, tt, "Submit entry")
|
||||
await(tt, s.st.Submitting) // the name check finishes first
|
||||
if !s.st.Submitting() || !tt.HasText("Submitting…") {
|
||||
await(tt, func() bool { return tt.HasText("Submitting…") }) // the name check finishes first
|
||||
if !s.st.Submitting() {
|
||||
t.Fatalf("no loading overlay in %q", tt.Texts())
|
||||
}
|
||||
click(t, tt, "Submit entry") // the overlay blocks a second submit
|
||||
@@ -124,9 +124,9 @@ func TestCompositeFormInvalidThenValid(t *testing.T) {
|
||||
t.Fatalf("phase %d, texts %q", s.phase, tt.Texts())
|
||||
}
|
||||
submits := 0
|
||||
for _, e := range g.events {
|
||||
for i, e := range g.events {
|
||||
if strings.Contains(e.text, "hunter22secret") {
|
||||
t.Fatalf("password logged: %q", e.text)
|
||||
t.Fatalf("password logged in event %d of page %s", i, e.page)
|
||||
}
|
||||
if e.page == formID && strings.HasPrefix(e.text, "submitted ") {
|
||||
submits++
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
// Gallery tours every MujicaUI component in a native MyGo window.
|
||||
//
|
||||
// go run ./examples/gallery native window
|
||||
// go run ./examples/gallery -web :8080 browser mirror
|
||||
// Gallery tours MujicaUI natively, or mirrored to a browser with -web.
|
||||
package main
|
||||
|
||||
import (
|
||||
|
||||
@@ -118,7 +118,7 @@ var formPages = []page{
|
||||
}
|
||||
formToggles(c, []string{"Labels beside fields", "Server rejects the submit"}, &d.horizontal, &d.slowServer)
|
||||
res := mujica.Form(c, &d.st, mujica.FormOptions{Horizontal: d.horizontal}, func(f *mujica.FormScope) {
|
||||
mujica.FormItem(f, "name", &d.name, mujica.FormItemOptions[string]{Label: "Stage name", Required: true, Initial: "Sakiko",
|
||||
mujica.FormItem(c, f, "name", &d.name, mujica.FormItemOptions[string]{Label: "Stage name", Required: true, Initial: "Sakiko",
|
||||
Description: "Checked against the archive; \"Oblivionis\" is taken.",
|
||||
Async: func(v string, version uint64) {
|
||||
log(fmt.Sprintf("async check started v%d", version))
|
||||
@@ -134,7 +134,7 @@ var formPages = []page{
|
||||
}}, func() *ui.Element {
|
||||
return mujica.InputGroup(c, &d.name, mujica.InputGroupOptions{Invalid: d.st.Error("name") != ""}).Input
|
||||
})
|
||||
mujica.FormItem(f, "email", &d.email, mujica.FormItemOptions[string]{Label: "Email", Required: true,
|
||||
mujica.FormItem(c, f, "email", &d.email, mujica.FormItemOptions[string]{Label: "Email", Required: true,
|
||||
Validate: func(v string) string {
|
||||
if !strings.Contains(v, "@") {
|
||||
return "Enter an email address."
|
||||
@@ -147,7 +147,7 @@ var formPages = []page{
|
||||
}
|
||||
return g.Input
|
||||
})
|
||||
mujica.FormItem(f, "tags", &d.tags, mujica.FormItemOptions[[]string]{Label: "Roles", Required: true, Initial: []string{"keyboard"}}, func() *ui.Element {
|
||||
mujica.FormItem(c, f, "tags", &d.tags, mujica.FormItemOptions[[]string]{Label: "Roles", Required: true, Initial: []string{"keyboard"}}, func() *ui.Element {
|
||||
return mujica.TagsInput(c, &d.tags, mujica.TagsInputOptions{Max: 4, Invalid: d.st.Error("tags") != ""}).Input
|
||||
})
|
||||
})
|
||||
@@ -336,7 +336,7 @@ name := ""
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
res := mujica.Form(c, &state, mujica.FormOptions{}, func(f *mujica.FormScope) {
|
||||
mujica.FormItem(f, "name", &name, mujica.FormItemOptions[string]{Label: "Name", Required: true}, func() *ui.Element {
|
||||
mujica.FormItem(c, f, "name", &name, mujica.FormItemOptions[string]{Label: "Name", Required: true}, func() *ui.Element {
|
||||
return mujica.InputGroup(c, &name, mujica.InputGroupOptions{Invalid: state.Error("name") != ""}).Input
|
||||
})
|
||||
})
|
||||
|
||||
+59
-149
@@ -20,18 +20,20 @@ var indexHTML []byte
|
||||
|
||||
// mirror runs the gallery view headless and streams its frames.
|
||||
type mirror struct {
|
||||
mu sync.Mutex
|
||||
tt *ui.Tester
|
||||
frame []byte
|
||||
seq int
|
||||
hash uint64
|
||||
seed maphash.Seed
|
||||
subs map[chan []byte]struct{}
|
||||
wake chan struct{}
|
||||
urls int
|
||||
told int
|
||||
clip string
|
||||
enc png.Encoder
|
||||
mu sync.Mutex
|
||||
tt *ui.Tester
|
||||
frame []byte
|
||||
seq int
|
||||
hash uint64
|
||||
seed maphash.Seed
|
||||
subs map[chan []byte]struct{}
|
||||
wake chan struct{}
|
||||
urls int
|
||||
clip string
|
||||
copied bool
|
||||
cursor ui.Cursor
|
||||
down *input
|
||||
enc png.Encoder
|
||||
}
|
||||
|
||||
func serveWeb(addr string, view func(c *ui.Context)) error {
|
||||
@@ -54,7 +56,9 @@ func newMirror(view func(c *ui.Context)) (*mirror, http.Handler) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write(indexHTML)
|
||||
if _, err := w.Write(indexHTML); err != nil {
|
||||
slog.Warn("gallery: web page write failed", "err", err)
|
||||
}
|
||||
})
|
||||
mux.HandleFunc("GET /frame.png", m.serveFrame)
|
||||
mux.HandleFunc("GET /events", m.serveEvents)
|
||||
@@ -62,20 +66,27 @@ func newMirror(view func(c *ui.Context)) (*mirror, http.Handler) {
|
||||
return m, mux
|
||||
}
|
||||
|
||||
// loop draws frames while the picture keeps changing, then sleeps.
|
||||
// loop frames at 33 ms while changing, else polls at 200 ms for timers.
|
||||
func (m *mirror) loop() {
|
||||
for range m.wake {
|
||||
for still := 0; still < 6; {
|
||||
time.Sleep(33 * time.Millisecond)
|
||||
m.mu.Lock()
|
||||
m.tt.Frame()
|
||||
changed := m.capture()
|
||||
m.mu.Unlock()
|
||||
if changed {
|
||||
still = 0
|
||||
} else {
|
||||
still++
|
||||
}
|
||||
still := 0
|
||||
for {
|
||||
wait := 33 * time.Millisecond
|
||||
if still > 30 {
|
||||
wait = 200 * time.Millisecond
|
||||
}
|
||||
select {
|
||||
case <-m.wake:
|
||||
still = 0
|
||||
case <-time.After(wait):
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.tt.Frame()
|
||||
changed := m.capture()
|
||||
m.mu.Unlock()
|
||||
if changed {
|
||||
still = 0
|
||||
} else {
|
||||
still++
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,12 +98,12 @@ func (m *mirror) kick() {
|
||||
}
|
||||
}
|
||||
|
||||
// capture encodes the frame and notifies browsers; the caller holds mu.
|
||||
// capture encodes a changed frame and notifies browsers; the caller holds mu.
|
||||
func (m *mirror) capture() bool {
|
||||
img := m.tt.Image()
|
||||
h := maphash.Bytes(m.seed, img.Pix)
|
||||
notice := m.notices()
|
||||
if h == m.hash && notice == nil {
|
||||
if h == m.hash && len(notice) == 0 {
|
||||
return false
|
||||
}
|
||||
if h != m.hash {
|
||||
@@ -103,11 +114,8 @@ func (m *mirror) capture() bool {
|
||||
m.frame, m.hash = b.Bytes(), h
|
||||
m.seq++
|
||||
}
|
||||
if notice == nil {
|
||||
notice = map[string]any{}
|
||||
}
|
||||
notice["seq"] = m.seq
|
||||
notice["cursor"] = cursorCSS(m.tt.Cursor())
|
||||
notice["cursor"] = cursorCSS(m.cursor)
|
||||
msg, err := json.Marshal(notice)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
@@ -122,20 +130,21 @@ func (m *mirror) capture() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// notices gathers opened URLs, announcements and clipboard changes.
|
||||
// notices gathers opened URLs, announcements, clipboard writes and cursor changes.
|
||||
func (m *mirror) notices() map[string]any {
|
||||
n := map[string]any{}
|
||||
if urls := m.tt.OpenedURLs(); len(urls) > m.urls {
|
||||
n["open"], m.urls = urls[m.urls:], len(urls)
|
||||
}
|
||||
if told := m.tt.Announcements(); len(told) > m.told {
|
||||
n["announce"], m.told = told[m.told:], len(told)
|
||||
if told := m.tt.Announcements(); len(told) > 0 {
|
||||
n["announce"] = told
|
||||
}
|
||||
if clip := m.tt.Clipboard(); clip != m.clip {
|
||||
n["clipboard"], m.clip = clip, clip
|
||||
if clip := m.tt.Clipboard(); clip != m.clip || m.copied {
|
||||
n["clipboard"], m.clip, m.copied = clip, clip, false
|
||||
}
|
||||
if len(n) == 0 {
|
||||
return nil
|
||||
if c := m.tt.Cursor(); c != m.cursor {
|
||||
m.cursor = c
|
||||
n["cursorChanged"] = true
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -146,7 +155,9 @@ func (m *mirror) serveFrame(w http.ResponseWriter, r *http.Request) {
|
||||
m.mu.Unlock()
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Write(frame)
|
||||
if _, err := w.Write(frame); err != nil {
|
||||
slog.Warn("gallery: web frame write failed", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mirror) serveEvents(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -169,122 +180,21 @@ func (m *mirror) serveEvents(w http.ResponseWriter, r *http.Request) {
|
||||
}()
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
fmt.Fprintf(w, "data: %s\n\n", first)
|
||||
if _, err := fmt.Fprintf(w, "data: %s\n\n", first); err != nil {
|
||||
slog.Warn("gallery: web events write failed", "remote", r.RemoteAddr, "err", err)
|
||||
return
|
||||
}
|
||||
flusher.Flush()
|
||||
for {
|
||||
select {
|
||||
case msg := <-ch:
|
||||
fmt.Fprintf(w, "data: %s\n\n", msg)
|
||||
if _, err := fmt.Fprintf(w, "data: %s\n\n", msg); err != nil {
|
||||
slog.Warn("gallery: web events write failed", "remote", r.RemoteAddr, "err", err)
|
||||
return
|
||||
}
|
||||
flusher.Flush()
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// input is one browser event.
|
||||
type input struct {
|
||||
Type string `json:"type"`
|
||||
X float32 `json:"x"`
|
||||
Y float32 `json:"y"`
|
||||
DX float32 `json:"dx"`
|
||||
DY float32 `json:"dy"`
|
||||
Button int `json:"button"`
|
||||
Code string `json:"code"`
|
||||
Text string `json:"text"`
|
||||
Caret int `json:"caret"`
|
||||
Mods []bool `json:"mods"`
|
||||
W int `json:"w"`
|
||||
H int `json:"h"`
|
||||
Scale float32 `json:"scale"`
|
||||
Dark bool `json:"dark"`
|
||||
Motion bool `json:"reduceMotion"`
|
||||
More bool `json:"moreContrast"`
|
||||
}
|
||||
|
||||
func (m *mirror) serveInput(w http.ResponseWriter, r *http.Request) {
|
||||
var in input
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
m.mu.Lock()
|
||||
err := m.apply(in)
|
||||
if err == nil {
|
||||
m.capture()
|
||||
}
|
||||
m.mu.Unlock()
|
||||
if err != nil {
|
||||
slog.Warn("gallery: web input rejected", "type", in.Type, "err", err)
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
m.kick()
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// apply sends one event to the tester; the caller holds mu.
|
||||
func (m *mirror) apply(in input) error {
|
||||
mods := modifiers(in.Mods)
|
||||
switch in.Type {
|
||||
case "down":
|
||||
if in.Button == 2 {
|
||||
m.tt.RightClickAt(in.X, in.Y)
|
||||
} else if mods != 0 {
|
||||
m.tt.ClickAtWith(mods, in.X, in.Y)
|
||||
} else {
|
||||
m.tt.Press(in.X, in.Y)
|
||||
}
|
||||
case "up":
|
||||
if in.Button == 0 && mods == 0 {
|
||||
m.tt.Release(in.X, in.Y)
|
||||
}
|
||||
case "move":
|
||||
m.tt.Move(in.X, in.Y)
|
||||
case "wheel":
|
||||
m.tt.Scroll(in.X, in.Y, in.DX, in.DY)
|
||||
case "key":
|
||||
k, ok := keyCodes[in.Code]
|
||||
if !ok {
|
||||
return fmt.Errorf("unmapped key code %q", in.Code)
|
||||
}
|
||||
if in.Text != "" && mods&^ui.Shift == 0 {
|
||||
m.tt.TypeKey(mods, k, in.Text)
|
||||
} else {
|
||||
m.tt.Key(mods, k)
|
||||
}
|
||||
case "text":
|
||||
m.tt.Type(in.Text)
|
||||
case "compose":
|
||||
m.tt.Compose(in.Text, in.Caret)
|
||||
case "paste":
|
||||
m.tt.SetClipboard(in.Text)
|
||||
m.clip = in.Text
|
||||
m.tt.Command("paste")
|
||||
case "command":
|
||||
m.tt.Command(in.Text)
|
||||
case "resize":
|
||||
if in.W < 320 || in.H < 240 || in.Scale <= 0 {
|
||||
return fmt.Errorf("bad size %dx%d@%v", in.W, in.H, in.Scale)
|
||||
}
|
||||
m.tt.SetSize(in.W, in.H)
|
||||
m.tt.SetScale(in.Scale)
|
||||
m.tt.SetDark(in.Dark)
|
||||
m.tt.SetPreferences(ui.Preferences{ReduceMotion: in.Motion, HighContrast: in.More, TextScale: 1})
|
||||
case "focus":
|
||||
m.tt.SetFocused(in.Text == "in")
|
||||
default:
|
||||
return fmt.Errorf("unknown input type %q", in.Type)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func modifiers(m []bool) ui.Modifiers {
|
||||
var out ui.Modifiers
|
||||
for i, mod := range []ui.Modifiers{ui.Shift, ui.Ctrl, ui.Alt, ui.Super} {
|
||||
if i < len(m) && m[i] {
|
||||
out |= mod
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -139,7 +139,7 @@ canvas.addEventListener("pointermove", (e) => {
|
||||
});
|
||||
canvas.addEventListener("wheel", (e) => {
|
||||
const k = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? canvas.clientHeight : 1;
|
||||
send({ type: "wheel", dx: -e.deltaX * k, dy: -e.deltaY * k, ...at(e) }); e.preventDefault();
|
||||
send({ type: "wheel", dx: e.deltaX * k, dy: e.deltaY * k, ...at(e) }); e.preventDefault();
|
||||
}, { passive: false });
|
||||
canvas.addEventListener("contextmenu", (e) => e.preventDefault());
|
||||
canvas.addEventListener("focus", () => keys.focus({ preventScroll: true }));
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
// input is one browser event.
|
||||
type input struct {
|
||||
Type string `json:"type"`
|
||||
X float32 `json:"x"`
|
||||
Y float32 `json:"y"`
|
||||
DX float32 `json:"dx"`
|
||||
DY float32 `json:"dy"`
|
||||
Button int `json:"button"`
|
||||
Code string `json:"code"`
|
||||
Text string `json:"text"`
|
||||
Caret int `json:"caret"`
|
||||
Mods []bool `json:"mods"`
|
||||
W int `json:"w"`
|
||||
H int `json:"h"`
|
||||
Scale float32 `json:"scale"`
|
||||
Dark bool `json:"dark"`
|
||||
Motion bool `json:"reduceMotion"`
|
||||
More bool `json:"moreContrast"`
|
||||
}
|
||||
|
||||
func (m *mirror) serveInput(w http.ResponseWriter, r *http.Request) {
|
||||
var in input
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
m.mu.Lock()
|
||||
err := m.apply(in)
|
||||
if err == nil {
|
||||
m.capture()
|
||||
}
|
||||
m.mu.Unlock()
|
||||
if err != nil {
|
||||
slog.Warn("gallery: web input rejected", "type", in.Type, "err", err)
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
m.kick()
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// apply sends one event to the tester; the caller holds mu.
|
||||
func (m *mirror) apply(in input) error {
|
||||
mods := modifiers(in.Mods)
|
||||
switch in.Type {
|
||||
case "down":
|
||||
switch {
|
||||
case in.Button == 2:
|
||||
m.tt.RightClickAt(in.X, in.Y)
|
||||
case mods != 0:
|
||||
m.down = &in
|
||||
default:
|
||||
m.tt.Press(in.X, in.Y)
|
||||
}
|
||||
case "up":
|
||||
if d := m.down; d != nil {
|
||||
m.down = nil
|
||||
if dx, dy := in.X-d.X, in.Y-d.Y; dx*dx+dy*dy <= 16 {
|
||||
m.tt.ClickAtWith(modifiers(d.Mods), d.X, d.Y)
|
||||
} else {
|
||||
slog.Info("gallery: modified drag ignored; tester clicks only", "mods", modifiers(d.Mods))
|
||||
}
|
||||
} else if in.Button == 0 {
|
||||
m.tt.Release(in.X, in.Y)
|
||||
}
|
||||
case "move":
|
||||
m.tt.Move(in.X, in.Y)
|
||||
case "wheel":
|
||||
m.tt.Scroll(in.X, in.Y, in.DX, in.DY)
|
||||
case "key":
|
||||
k, ok := keyCodes[in.Code]
|
||||
if !ok {
|
||||
return fmt.Errorf("unmapped key code %q", in.Code)
|
||||
}
|
||||
if in.Text != "" && mods&^(ui.Shift|ui.Alt) == 0 {
|
||||
m.tt.TypeKey(mods, k, in.Text)
|
||||
} else {
|
||||
m.tt.Key(mods, k)
|
||||
}
|
||||
case "text":
|
||||
m.tt.Type(in.Text)
|
||||
case "compose":
|
||||
m.tt.Compose(in.Text, in.Caret)
|
||||
case "paste":
|
||||
m.tt.SetClipboard(in.Text)
|
||||
m.clip = in.Text
|
||||
m.tt.Command("paste")
|
||||
case "command":
|
||||
m.tt.Command(in.Text)
|
||||
m.copied = in.Text == "copy" || in.Text == "cut"
|
||||
case "resize":
|
||||
if in.W < 320 || in.H < 240 || in.Scale <= 0 {
|
||||
return fmt.Errorf("bad size %dx%d@%v", in.W, in.H, in.Scale)
|
||||
}
|
||||
m.tt.SetSize(in.W, in.H)
|
||||
m.tt.SetScale(in.Scale)
|
||||
m.tt.SetDark(in.Dark)
|
||||
m.tt.SetPreferences(ui.Preferences{ReduceMotion: in.Motion, HighContrast: in.More, TextScale: 1})
|
||||
case "focus":
|
||||
m.tt.SetFocused(in.Text == "in")
|
||||
default:
|
||||
return fmt.Errorf("unknown input type %q", in.Type)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func modifiers(m []bool) ui.Modifiers {
|
||||
var out ui.Modifiers
|
||||
for i, mod := range []ui.Modifiers{ui.Shift, ui.Ctrl, ui.Alt, ui.Super} {
|
||||
if i < len(m) && m[i] {
|
||||
out |= mod
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -4,6 +4,10 @@ import "github.com/egoist/mygo/ui"
|
||||
|
||||
// keyCodes maps KeyboardEvent.code to MyGo keys.
|
||||
var keyCodes = map[string]ui.Key{
|
||||
"Numpad0": ui.Key0, "Numpad1": ui.Key1, "Numpad2": ui.Key2, "Numpad3": ui.Key3, "Numpad4": ui.Key4,
|
||||
"Numpad5": ui.Key5, "Numpad6": ui.Key6, "Numpad7": ui.Key7, "Numpad8": ui.Key8, "Numpad9": ui.Key9,
|
||||
"NumpadDecimal": ui.KeyPeriod, "NumpadSubtract": ui.KeyMinus, "NumpadAdd": ui.KeyEqual,
|
||||
"NumpadMultiply": ui.Key8, "NumpadDivide": ui.KeySlash, "NumpadEqual": ui.KeyEqual, "NumpadComma": ui.KeyComma,
|
||||
"Enter": ui.KeyEnter,
|
||||
"NumpadEnter": ui.KeyEnter,
|
||||
"Escape": ui.KeyEscape,
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
@@ -133,3 +134,129 @@ func TestMirrorRejectsBadInput(t *testing.T) {
|
||||
t.Fatalf("bad json: %d", res.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// mirrorWith serves view through a mirror for one test.
|
||||
func mirrorWith(t *testing.T, view func(c *ui.Context)) (*mirror, *httptest.Server) {
|
||||
m, h := newMirror(view)
|
||||
srv := httptest.NewServer(h)
|
||||
t.Cleanup(srv.Close)
|
||||
return m, srv
|
||||
}
|
||||
|
||||
func postInput(t *testing.T, srv *httptest.Server, in input) {
|
||||
t.Helper()
|
||||
body, err := json.Marshal(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res, err := http.Post(srv.URL+"/input", "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res.Body.Close()
|
||||
if res.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("%s: %d", in.Type, res.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMirrorTimersFireWithoutInput(t *testing.T) {
|
||||
start := time.Now()
|
||||
var late bool
|
||||
m, _ := mirrorWith(t, func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
late = c.Now().Sub(start) > 300*time.Millisecond
|
||||
if !late {
|
||||
c.After(350 * time.Millisecond)
|
||||
}
|
||||
})
|
||||
time.Sleep(900 * time.Millisecond)
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if !late {
|
||||
t.Fatal("the view was not rebuilt after its timer with no input")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMirrorModifiedClickNeedsReleaseInPlace(t *testing.T) {
|
||||
clicks := 0
|
||||
_, srv := mirrorWith(t, func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
ui.Column(c).Padding(20).Children(func() {
|
||||
if mujica.Button(c, "Seal", mujica.ButtonOptions{}).Clicked() {
|
||||
clicks++
|
||||
}
|
||||
})
|
||||
})
|
||||
shift := []bool{true}
|
||||
postInput(t, srv, input{Type: "down", X: 40, Y: 35, Mods: shift})
|
||||
postInput(t, srv, input{Type: "up", X: 400, Y: 400, Mods: shift})
|
||||
postInput(t, srv, input{Type: "down", X: 40, Y: 35, Mods: shift})
|
||||
postInput(t, srv, input{Type: "up", X: 41, Y: 35, Mods: shift})
|
||||
if clicks != 1 {
|
||||
t.Fatalf("clicks %d, want 1 (released elsewhere must not click)", clicks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMirrorForwardsRepeatedCopy(t *testing.T) {
|
||||
m, _ := mirrorWith(t, func(c *ui.Context) { mujica.Use(c, mujica.Settings{}) })
|
||||
ch := make(chan []byte, 8)
|
||||
m.mu.Lock()
|
||||
m.subs[ch] = struct{}{}
|
||||
notices := 0
|
||||
for range 2 {
|
||||
m.tt.SetClipboard("same")
|
||||
m.copied = true
|
||||
if m.capture() {
|
||||
notices++
|
||||
}
|
||||
}
|
||||
m.mu.Unlock()
|
||||
if notices != 2 {
|
||||
t.Fatalf("repeated copy of the same text notified %d times, want 2", notices)
|
||||
}
|
||||
for range 2 {
|
||||
msg := <-ch
|
||||
if !bytes.Contains(msg, []byte(`"clipboard":"same"`)) {
|
||||
t.Fatalf("notice %s", msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMirrorMapsNumpad(t *testing.T) {
|
||||
r := newMirrorRig(t)
|
||||
r.post(input{Type: "key", Code: "Tab"})
|
||||
if code := r.post(input{Type: "key", Code: "Numpad7", Text: "7"}); code != http.StatusNoContent {
|
||||
t.Fatalf("numpad: %d", code)
|
||||
}
|
||||
if got := r.value(); got != "7" {
|
||||
t.Fatalf("value %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMirrorForwardsEveryAnnouncement(t *testing.T) {
|
||||
m, srv := mirrorWith(t, func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
ui.Column(c).Padding(20).Children(func() {
|
||||
if mujica.Button(c, "Ring", mujica.ButtonOptions{}).Clicked() {
|
||||
c.Announce("bell rung")
|
||||
}
|
||||
})
|
||||
})
|
||||
ch := make(chan []byte, 64)
|
||||
m.mu.Lock()
|
||||
m.subs[ch] = struct{}{}
|
||||
m.mu.Unlock()
|
||||
for range 2 {
|
||||
postInput(t, srv, input{Type: "down", X: 40, Y: 35})
|
||||
postInput(t, srv, input{Type: "up", X: 40, Y: 35})
|
||||
}
|
||||
heard := 0
|
||||
for len(ch) > 0 {
|
||||
if bytes.Contains(<-ch, []byte("bell rung")) {
|
||||
heard++
|
||||
}
|
||||
}
|
||||
if heard != 2 {
|
||||
t.Fatalf("announcements forwarded %d times, want 2", heard)
|
||||
}
|
||||
}
|
||||
@@ -99,7 +99,6 @@ type FormOptions struct {
|
||||
|
||||
// FormScope registers a Form's fields with FormItem.
|
||||
type FormScope struct {
|
||||
c *ui.Context
|
||||
st *FormState
|
||||
horizontal bool
|
||||
}
|
||||
@@ -140,7 +139,7 @@ func Form(c *ui.Context, st *FormState, opts FormOptions, fields func(f *FormSco
|
||||
parts := FormParts{}
|
||||
col := ui.Column(c).Gap(d.Unit() * 5)
|
||||
parts.Element = col
|
||||
scope := &FormScope{c: c, st: st, horizontal: opts.Horizontal}
|
||||
scope := &FormScope{st: st, horizontal: opts.Horizontal}
|
||||
col.Children(func() {
|
||||
// A new key after Reset drops the controls' drafts and errors.
|
||||
ui.Box(c).Key(st.generation).Children(func() {
|
||||
@@ -242,8 +241,8 @@ func formReset(st *FormState) {
|
||||
}
|
||||
|
||||
// FormItem registers field name around the focusable element control returns.
|
||||
func FormItem[T any](f *FormScope, name string, value *T, opts FormItemOptions[T], control func() *ui.Element) *ui.Element {
|
||||
c, st := f.c, f.st
|
||||
func FormItem[T any](c *ui.Context, f *FormScope, name string, value *T, opts FormItemOptions[T], control func() *ui.Element) *ui.Element {
|
||||
st := f.st
|
||||
if name == "" || opts.Label == "" {
|
||||
panic("mujica: FormItem needs a name and a Label")
|
||||
}
|
||||
|
||||
+7
-7
@@ -25,7 +25,7 @@ func (p *formProbe) run(c *ui.Context) {
|
||||
|
||||
func (p *formProbe) view(c *ui.Context) {
|
||||
res := Form(c, &p.st, FormOptions{Horizontal: p.horizontal}, func(f *FormScope) {
|
||||
FormItem(f, "name", &p.name, FormItemOptions[string]{Label: "Name", Required: true, Initial: "Sakiko"}, func() *ui.Element {
|
||||
FormItem(c, f, "name", &p.name, FormItemOptions[string]{Label: "Name", Required: true, Initial: "Sakiko"}, func() *ui.Element {
|
||||
return InputGroup(c, &p.name, InputGroupOptions{Invalid: p.st.Error("name") != ""}).Input
|
||||
})
|
||||
opts := FormItemOptions[string]{Label: "Email", Validate: func(v string) string {
|
||||
@@ -40,10 +40,10 @@ func (p *formProbe) view(c *ui.Context) {
|
||||
p.asyncValues = append(p.asyncValues, v)
|
||||
}
|
||||
}
|
||||
FormItem(f, "email", &p.email, opts, func() *ui.Element {
|
||||
FormItem(c, f, "email", &p.email, opts, func() *ui.Element {
|
||||
return InputGroup(c, &p.email, InputGroupOptions{Invalid: p.st.Error("email") != ""}).Input
|
||||
})
|
||||
FormItem(f, "tags", &p.tags, FormItemOptions[[]string]{Label: "Tags", Required: true, Initial: []string{"rose"}}, func() *ui.Element {
|
||||
FormItem(c, f, "tags", &p.tags, FormItemOptions[[]string]{Label: "Tags", Required: true, Initial: []string{"rose"}}, func() *ui.Element {
|
||||
return TagsInput(c, &p.tags, TagsInputOptions{}).Input
|
||||
})
|
||||
})
|
||||
@@ -222,7 +222,7 @@ func TestFormResetClearsControlState(t *testing.T) {
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
Form(c, &st, FormOptions{}, func(f *FormScope) {
|
||||
FormItem(f, "tags", &tags, FormItemOptions[[]string]{Label: "Tags"}, func() *ui.Element {
|
||||
FormItem(c, f, "tags", &tags, FormItemOptions[[]string]{Label: "Tags"}, func() *ui.Element {
|
||||
return TagsInput(c, &tags, TagsInputOptions{Validate: func(s string) string {
|
||||
seen = s
|
||||
return "Invalid tag"
|
||||
@@ -289,13 +289,13 @@ func TestFormCrossFieldRuleAtSettle(t *testing.T) {
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
res := Form(c, &st, FormOptions{}, func(f *FormScope) {
|
||||
FormItem(f, "user", &user, FormItemOptions[string]{Label: "User", Async: func(_ string, v uint64) { version = v }}, func() *ui.Element {
|
||||
FormItem(c, f, "user", &user, FormItemOptions[string]{Label: "User", Async: func(_ string, v uint64) { version = v }}, func() *ui.Element {
|
||||
return InputGroup(c, &user, InputGroupOptions{}).Input
|
||||
})
|
||||
FormItem(f, "pw", &pw, FormItemOptions[string]{Label: "Password"}, func() *ui.Element {
|
||||
FormItem(c, f, "pw", &pw, FormItemOptions[string]{Label: "Password"}, func() *ui.Element {
|
||||
return InputGroup(c, &pw, InputGroupOptions{}).Input
|
||||
})
|
||||
FormItem(f, "confirm", &confirm, FormItemOptions[string]{Label: "Confirm", Validate: func(v string) string {
|
||||
FormItem(c, f, "confirm", &confirm, FormItemOptions[string]{Label: "Confirm", Validate: func(v string) string {
|
||||
if v != pw {
|
||||
return "Passwords differ."
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
package internal
|
||||
|
||||
import "strings"
|
||||
|
||||
// DiffOp says whether a diff line is kept, removed or added.
|
||||
type DiffOp uint8
|
||||
|
||||
const (
|
||||
DiffEqual DiffOp = iota
|
||||
DiffDelete
|
||||
DiffInsert
|
||||
)
|
||||
|
||||
// DiffLine is one line of a line diff with its 1-based line numbers; 0 means absent.
|
||||
type DiffLine struct {
|
||||
Op DiffOp
|
||||
Text string
|
||||
Old, New int
|
||||
}
|
||||
|
||||
// SplitLines splits text into lines without their line breaks; "" has no lines.
|
||||
func SplitLines(s string) []string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return strings.Split(strings.TrimSuffix(s, "\n"), "\n")
|
||||
}
|
||||
|
||||
// LineDiff returns a shortest edit script turning a into b (Myers, O((N+M)D)).
|
||||
func LineDiff(a, b []string) []DiffLine {
|
||||
// Common prefix and suffix need no search.
|
||||
pre := 0
|
||||
for pre < len(a) && pre < len(b) && a[pre] == b[pre] {
|
||||
pre++
|
||||
}
|
||||
suf := 0
|
||||
for suf < len(a)-pre && suf < len(b)-pre && a[len(a)-1-suf] == b[len(b)-1-suf] {
|
||||
suf++
|
||||
}
|
||||
out := make([]DiffLine, 0, len(a)+len(b)-pre-suf)
|
||||
for i := range pre {
|
||||
out = append(out, DiffLine{DiffEqual, a[i], i + 1, i + 1})
|
||||
}
|
||||
out = myers(out, a[pre:len(a)-suf], b[pre:len(b)-suf], pre, pre)
|
||||
for i := range suf {
|
||||
ai, bi := len(a)-suf+i, len(b)-suf+i
|
||||
out = append(out, DiffLine{DiffEqual, a[ai], ai + 1, bi + 1})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// myers appends the edit script of a to b, numbering from offsets ao and bo.
|
||||
func myers(out []DiffLine, a, b []string, ao, bo int) []DiffLine {
|
||||
n, m := len(a), len(b)
|
||||
total := n + m
|
||||
if total == 0 {
|
||||
return out
|
||||
}
|
||||
off := total + 1
|
||||
v := make([]int, 2*total+3)
|
||||
var trace [][]int
|
||||
end := -1
|
||||
for d := 0; d <= total && end < 0; d++ {
|
||||
trace = append(trace, append([]int(nil), v[off-d:off+d+1]...)) // diagonals -d..d
|
||||
for k := -d; k <= d; k += 2 {
|
||||
var x int
|
||||
if k == -d || k != d && v[off+k-1] < v[off+k+1] {
|
||||
x = v[off+k+1]
|
||||
} else {
|
||||
x = v[off+k-1] + 1
|
||||
}
|
||||
y := x - k
|
||||
for x < n && y < m && a[x] == b[y] {
|
||||
x, y = x+1, y+1
|
||||
}
|
||||
v[off+k] = x
|
||||
if x >= n && y >= m {
|
||||
end = d
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
// Walk the trace back from (n, m), collecting moves in reverse.
|
||||
var rev []DiffLine
|
||||
x, y := n, m
|
||||
for d := end; d >= 0; d-- {
|
||||
vd := trace[d]
|
||||
at := func(k int) int { return vd[k+d] }
|
||||
k := x - y
|
||||
var pk int
|
||||
if k == -d || k != d && at(k-1) < at(k+1) {
|
||||
pk = k + 1
|
||||
} else {
|
||||
pk = k - 1
|
||||
}
|
||||
px := 0
|
||||
if d > 0 {
|
||||
px = at(pk)
|
||||
}
|
||||
py := px - pk
|
||||
for x > px && y > py {
|
||||
x, y = x-1, y-1
|
||||
rev = append(rev, DiffLine{DiffEqual, a[x], ao + x + 1, bo + y + 1})
|
||||
}
|
||||
if d == 0 {
|
||||
break
|
||||
}
|
||||
if x == px {
|
||||
y--
|
||||
rev = append(rev, DiffLine{DiffInsert, b[y], 0, bo + y + 1})
|
||||
} else {
|
||||
x--
|
||||
rev = append(rev, DiffLine{DiffDelete, a[x], ao + x + 1, 0})
|
||||
}
|
||||
}
|
||||
for i := len(rev) - 1; i >= 0; i-- {
|
||||
out = append(out, rev[i])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// DiffStats counts the added and removed lines of a diff.
|
||||
func DiffStats(d []DiffLine) (added, removed int) {
|
||||
for _, l := range d {
|
||||
switch l.Op {
|
||||
case DiffInsert:
|
||||
added++
|
||||
case DiffDelete:
|
||||
removed++
|
||||
}
|
||||
}
|
||||
return added, removed
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// diffSides rebuilds both inputs from a diff and checks the line numbers.
|
||||
func diffSides(t *testing.T, d []DiffLine) (a, b []string) {
|
||||
t.Helper()
|
||||
for _, l := range d {
|
||||
if l.Op != DiffInsert {
|
||||
a = append(a, l.Text)
|
||||
if l.Old != len(a) {
|
||||
t.Fatalf("old number %d, want %d", l.Old, len(a))
|
||||
}
|
||||
}
|
||||
if l.Op != DiffDelete {
|
||||
b = append(b, l.Text)
|
||||
if l.New != len(b) {
|
||||
t.Fatalf("new number %d, want %d", l.New, len(b))
|
||||
}
|
||||
}
|
||||
}
|
||||
return a, b
|
||||
}
|
||||
|
||||
// lcs is the reference longest common subsequence length.
|
||||
func lcs(a, b []string) int {
|
||||
prev := make([]int, len(b)+1)
|
||||
for i := range a {
|
||||
cur := make([]int, len(b)+1)
|
||||
for j := range b {
|
||||
if a[i] == b[j] {
|
||||
cur[j+1] = prev[j] + 1
|
||||
} else {
|
||||
cur[j+1] = max(prev[j+1], cur[j])
|
||||
}
|
||||
}
|
||||
prev = cur
|
||||
}
|
||||
return prev[len(b)]
|
||||
}
|
||||
|
||||
func TestLineDiffKnown(t *testing.T) {
|
||||
d := LineDiff(SplitLines("a\nb\nc\nd\n"), SplitLines("a\nc\nd\ne\n"))
|
||||
var got []string
|
||||
for _, l := range d {
|
||||
got = append(got, fmt.Sprintf("%d%s", l.Op, l.Text))
|
||||
}
|
||||
want := []string{"0a", "1b", "0c", "0d", "2e"}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Fatalf("diff %v, want %v", got, want)
|
||||
}
|
||||
if add, del := DiffStats(d); add != 1 || del != 1 {
|
||||
t.Fatalf("stats +%d -%d", add, del)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLineDiffEdges(t *testing.T) {
|
||||
for _, tc := range []struct{ a, b string }{{"", ""}, {"", "x\ny"}, {"x\ny", ""}, {"same\n", "same\n"}, {"a", "b"}} {
|
||||
d := LineDiff(SplitLines(tc.a), SplitLines(tc.b))
|
||||
a, b := diffSides(t, d)
|
||||
if !slices.Equal(a, SplitLines(tc.a)) || !slices.Equal(b, SplitLines(tc.b)) {
|
||||
t.Errorf("%q -> %q: rebuilt %q, %q", tc.a, tc.b, a, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLineDiffMinimalRandom(t *testing.T) {
|
||||
r := rand.New(rand.NewSource(7))
|
||||
for range 300 {
|
||||
a := make([]string, r.Intn(30))
|
||||
b := make([]string, r.Intn(30))
|
||||
for i := range a {
|
||||
a[i] = string(rune('a' + r.Intn(4)))
|
||||
}
|
||||
for i := range b {
|
||||
b[i] = string(rune('a' + r.Intn(4)))
|
||||
}
|
||||
d := LineDiff(a, b)
|
||||
ra, rb := diffSides(t, d)
|
||||
if !slices.Equal(ra, a) || !slices.Equal(rb, b) {
|
||||
t.Fatalf("rebuilt %q %q from %q %q", ra, rb, a, b)
|
||||
}
|
||||
add, del := DiffStats(d)
|
||||
if keep := len(a) - del; keep != lcs(a, b) || len(b)-add != keep {
|
||||
t.Fatalf("%q -> %q: kept %d lines, LCS is %d", a, b, keep, lcs(a, b))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLineDiffLargeFileSmallEdit(t *testing.T) {
|
||||
a := make([]string, 50000)
|
||||
for i := range a {
|
||||
a[i] = fmt.Sprint("line ", i)
|
||||
}
|
||||
b := slices.Clone(a)
|
||||
b[25000] = "changed"
|
||||
d := LineDiff(a, b)
|
||||
if add, del := DiffStats(d); add != 1 || del != 1 || len(d) != 50001 {
|
||||
t.Fatalf("+%d -%d over %d lines", add, del, len(d))
|
||||
}
|
||||
if !strings.HasPrefix(d[25000].Text, "line 25000") || d[25000].Op != DiffDelete {
|
||||
t.Fatalf("line 25000: %+v", d[25000])
|
||||
}
|
||||
}
|
||||
@@ -6,12 +6,6 @@ import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
// Rule draws a pixel-aligned 1 DIP horizontal line at y.
|
||||
func Rule(p *ui.Painter, x0, x1, y float32, c ui.Color) {
|
||||
yy := ruleY(p, y)
|
||||
p.Line(x0, yy, x1, yy, 1, c)
|
||||
}
|
||||
|
||||
// ruleY moves y to where a 1 DIP line fills whole device pixels.
|
||||
func ruleY(p *ui.Painter, y float32) float32 {
|
||||
s := float64(p.Scale())
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/theme"
|
||||
|
||||
"mujicaui/icons"
|
||||
)
|
||||
|
||||
@@ -34,6 +36,7 @@ func TestNavigationPopupsFadeIn(t *testing.T) {
|
||||
CommandPalette(c, &open, commandPaletteSample(), CommandPaletteOptions{})
|
||||
}},
|
||||
} {
|
||||
opened := time.Now()
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{Mode: Light})
|
||||
ui.Column(c).Fill().Padding(16).AlignItems(ui.Start).Children(func() { tc.view(c) })
|
||||
@@ -49,9 +52,11 @@ func TestNavigationPopupsFadeIn(t *testing.T) {
|
||||
}
|
||||
x, y := int(r.X-3), int(r.Y+r.H/2)
|
||||
early := tt.Image().RGBAAt(x, y)
|
||||
mid := time.Since(opened) < theme.PopupDuration/2
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
tt.Frame()
|
||||
if late := tt.Image().RGBAAt(x, y); early == late {
|
||||
// A slow run may finish the fade before the first sample.
|
||||
if late := tt.Image().RGBAAt(x, y); mid && early == late {
|
||||
t.Errorf("%s: popup appeared at once (%v)", tc.name, late)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user