diff --git a/TASKS.md b/TASKS.md index d3c27b5..9ca350d 100644 --- a/TASKS.md +++ b/TASKS.md @@ -28,7 +28,7 @@ Ordered by PRD sections. Check an item only after its codex review passes. Revie - [ ] T12.12 §4.23 Terminal & Code 321–340 (docs/prd) - [ ] T12.13 §4.24 Media 341–360 (docs/prd) - [ ] T12.14 §4.25 Projects 361–380 (docs/prd) -- [ ] T12.15 §4.26 Files & Collaboration 381–400 (docs/prd) +- [x] T12.15 §4.26 Files & Collaboration 381–400 — existing 17 sources only (user scope); missing 393/397/398 dropped; r1 FAIL 7, r2 FAIL 2, r3 PASS; 386 native stem selection and IME Enter remain documented MyGo API limits - [ ] T12.16 §4.27 Messaging & Mail 401–420 (docs/prd) - [ ] T12.17 §4.28 Calendar 421–440 (docs/prd) - [ ] T12.18 §4.29 Dashboards & Dev Tools 441–460 (docs/prd) diff --git a/archive_viewer.go b/archive_viewer.go new file mode 100644 index 0000000..ac764df --- /dev/null +++ b/archive_viewer.go @@ -0,0 +1,197 @@ +package mujica + +import ( + "cmp" + "fmt" + "log/slog" + "maps" + "math" + "path" + "slices" + "strings" + + "github.com/egoist/mygo/ui" +) + +// ArchiveEntry describes an archive member with a slash-separated Path. +type ArchiveEntry struct { + Path string + Dir bool + // Size is the unpacked size; Packed the compressed size. + Size, Packed int64 +} + +// ArchiveViewerState preserves folders, selection, sort and indexed entries. +type ArchiveViewerState struct { + table TreeTableState[string] + index archiveIndex + data *ArchiveEntry + n int + revision uint64 +} + +// archiveIndex is the tree built from the entries. +type archiveIndex struct { + byPath map[string]*ArchiveEntry + kids map[string][]string + roots []string + // sums totals a folder's files. + sums map[string][2]int64 + files int + size, pack int64 +} + +// ArchiveViewerOptions configure an ArchiveViewer. +type ArchiveViewerOptions struct { + // Revision changes when entries are edited in place. + Revision uint64 + // Status shows the caller's listing in progress or failed. + Status DataStatus + Error string +} + +// ArchiveViewerResult is the viewer and the entry opened this frame. +type ArchiveViewerResult struct { + Element *ui.Element + Opened *ArchiveEntry + Retried bool +} + +// ArchiveViewer virtualizes archive entries with sizes and compression ratios. +func ArchiveViewer(c *ui.Context, entries []ArchiveEntry, s *ArchiveViewerState, opts ArchiveViewerOptions) ArchiveViewerResult { + if s == nil { + panic("mujica: ArchiveViewer needs a state") + } + if s.revision != opts.Revision { + s.data = nil + s.index.byPath = nil + s.revision = opts.Revision + } + archiveBuild(entries, s) + ix := &s.index + u := Density(c).Unit() + size := func(p string) (int64, int64) { + if e := ix.byPath[p]; e != nil && !e.Dir { + return e.Size, e.Packed + } + t := ix.sums[p] + return t[0], t[1] + } + cell := func(text string) { codeMono(c, text).TextColor(Tokens(c).TextMuted) } + var res ArchiveViewerResult + res.Element = ui.Column(c).MinHeight(0).Gap(u * 2) + if res.Element.IsDisabled() { + copy := *s + copy.table.Selection = filesSelectionCopy(s.table.Selection) + copy.table.tree.outline.Open = filesSelectionCopy(s.table.tree.outline.Open) + copy.table.Layout = ui.TableLayout{Order: slices.Clone(s.table.Layout.Order), Widths: maps.Clone(s.table.Layout.Widths)} + copy.table.tree.outline.List.Columns = copy.table.Layout + s = © + } + res.Element.Children(func() { + codeCaption(c, fmt.Sprintf(msg(c, "archive_viewer.summary"), ix.files, processListBytes(uint64(ix.size)), + processListBytes(uint64(ix.pack)), archiveRatio(ix.size, ix.pack))) + v := TreeTable(c, &s.table, TreeTableOptions[string]{ + Roots: ix.roots, Children: func(p string) []string { return ix.kids[p] }, + Label: func(p string) string { return path.Base(p) }, Status: opts.Status, Error: opts.Error, + Empty: msg(c, "archive_viewer.empty"), + Columns: []TreeColumn[string]{ + {ID: "name", Title: msg(c, "archive_viewer.name"), Compare: strings.Compare, Cell: func(p string) { + ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() { + FileIcon(c, p, FileIconOptions{Folder: ix.kids[p] != nil}) + ui.Text(c, path.Base(p)).SingleLine() + }) + }}, + {ID: "size", Title: msg(c, "archive_viewer.size"), Width: 96, Align: ui.End, + Compare: func(a, b string) int { x, _ := size(a); y, _ := size(b); return cmp.Compare(x, y) }, + Cell: func(p string) { n, _ := size(p); cell(processListBytes(uint64(n))) }}, + {ID: "packed", Title: msg(c, "archive_viewer.packed"), Width: 96, Align: ui.End, + Cell: func(p string) { _, n := size(p); cell(processListBytes(uint64(n))) }}, + {ID: "ratio", Title: msg(c, "archive_viewer.ratio"), Width: 72, Align: ui.End, + Cell: func(p string) { a, b := size(p); cell(archiveRatio(a, b)) }}, + }, + }) + v.Element.Grow(1) + res.Retried = v.Retried() + if p, ok := s.table.Selected(); ok && v.Submitted() && !res.Element.IsDisabled() { + if e := ix.byPath[p]; e != nil && !e.Dir { + open := *e + res.Opened = &open + slog.Debug("mujica: archive_viewer open") + } + } + }) + return res +} + +// archiveRatio formats the saved-size percentage. +func archiveRatio(size, packed int64) string { + if size <= 0 { + return "—" + } + return fmt.Sprintf("%.0f%%", 100*(1-float64(packed)/float64(size))) +} + +// archiveBuild caches entries and implied parent directories. +func archiveBuild(entries []ArchiveEntry, s *ArchiveViewerState) { + var first *ArchiveEntry + if len(entries) > 0 { + first = &entries[0] + } + if s.data == first && s.n == len(entries) && s.index.byPath != nil { + return + } + s.data, s.n = first, len(entries) + ix := archiveIndex{byPath: map[string]*ArchiveEntry{}, kids: map[string][]string{}, sums: map[string][2]int64{}} + var addDir func(p string) + link := func(p string) { + parent := path.Dir(p) + if parent == "." { + ix.roots = append(ix.roots, p) + return + } + addDir(parent) + ix.kids[parent] = append(ix.kids[parent], p) + } + addDir = func(p string) { + if e := ix.byPath[p]; e != nil && !e.Dir { + panic("mujica: ArchiveEntry file used as a folder: " + p) + } + if _, ok := ix.kids[p]; ok { + return + } + ix.kids[p] = []string{} + link(p) + } + for i := range entries { + e := &entries[i] + p := strings.Trim(e.Path, "/") + if p == "" || e.Size < 0 || e.Packed < 0 { + panic("mujica: ArchiveEntry needs a Path and sizes not negative") + } + if _, dup := ix.byPath[p]; dup { + panic("mujica: duplicate ArchiveEntry " + p) + } + if !e.Dir && ix.kids[p] != nil { + panic("mujica: ArchiveEntry folder used as a file: " + p) + } + ix.byPath[p] = e + if e.Dir { + addDir(p) + continue + } + link(p) + ix.files++ + if e.Size > math.MaxInt64-ix.size || e.Packed > math.MaxInt64-ix.pack { + panic("mujica: ArchiveEntry totals overflow") + } + ix.size += e.Size + ix.pack += e.Packed + for d := path.Dir(p); d != "."; d = path.Dir(d) { + t := ix.sums[d] + ix.sums[d] = [2]int64{t[0] + e.Size, t[1] + e.Packed} + } + } + s.index = ix + slog.Debug("mujica: archive_viewer index", "files", ix.files) +} diff --git a/archive_viewer_test.go b/archive_viewer_test.go new file mode 100644 index 0000000..7f15d59 --- /dev/null +++ b/archive_viewer_test.go @@ -0,0 +1,187 @@ +package mujica + +import ( + "fmt" + "strings" + "testing" + + "github.com/egoist/mygo/ui" +) + +func archiveSample() []ArchiveEntry { + return []ArchiveEntry{ + {Path: "court/seal.go", Size: 4000, Packed: 1000}, + {Path: "court/decrees/42.pdf", Size: 6000, Packed: 5000}, + {Path: "README.md", Size: 1000, Packed: 400}, + {Path: "empty/", Dir: true}, + } +} + +type archiveProbe struct { + entries []ArchiveEntry + s ArchiveViewerState + opts ArchiveViewerOptions + opened []string +} + +func (p *archiveProbe) view(c *ui.Context) { + Use(c, Settings{}) + r := ArchiveViewer(c, p.entries, &p.s, p.opts) + r.Element.Height(340) + if r.Opened != nil { + p.opened = append(p.opened, r.Opened.Path) + } +} + +func TestArchiveViewerIndex(t *testing.T) { + s := &ArchiveViewerState{} + archiveBuild(archiveSample(), s) + ix := s.index + if ix.files != 3 || ix.size != 11000 || ix.pack != 6400 { + t.Fatalf("totals %d %d %d", ix.files, ix.size, ix.pack) + } + if got := ix.sums["court"]; got != [2]int64{10000, 6000} { + t.Fatalf("court sums %v", got) + } + if len(ix.roots) != 3 || len(ix.kids["court"]) != 2 || ix.kids["empty"] == nil { + t.Fatalf("roots %v kids %v", ix.roots, ix.kids) + } + if archiveRatio(4000, 1000) != "75%" || archiveRatio(0, 0) != "—" { + t.Fatal("ratio") + } +} + +func TestArchiveViewerExpandAndOpen(t *testing.T) { + p := &archiveProbe{entries: archiveSample()} + tt := ui.NewTester(p.view, 620, 360) + if !tt.HasText("3 files, 11 KB unpacked, 6 KB packed (42% smaller)") { + t.Fatalf("summary: %q", tt.Texts()) + } + if !tt.HasText("40%") || tt.HasText("seal.go") { + t.Fatalf("court row: %q", tt.Texts()) + } + cr, _ := tt.Find("10 KB") + tt.ClickAt(cr.X, cr.Y+cr.H/2) + tt.Key(0, ui.KeyRight) + if !tt.HasText("seal.go") || !tt.HasText("75%") { + t.Fatalf("expand: %q", tt.Texts()) + } + r, _ := tt.Find("seal.go") + tt.ClickAt(r.X, r.Y+r.H/2) + tt.ClickAt(r.X, r.Y+r.H/2) + if len(p.opened) != 1 || p.opened[0] != "court/seal.go" { + t.Fatalf("opened %v", p.opened) + } +} + +func TestArchiveViewerStatusAndInvalid(t *testing.T) { + tt := ui.NewTester((&archiveProbe{opts: ArchiveViewerOptions{Status: DataFailed, Error: "bad archive"}}).view, 620, 360) + if !tt.HasText("bad archive") { + t.Fatalf("texts %q", tt.Texts()) + } + defer func() { + if recover() == nil { + t.Fatal("duplicate entry did not panic") + } + }() + archiveBuild([]ArchiveEntry{{Path: "a"}, {Path: "a"}}, &ArchiveViewerState{}) +} + +func TestArchiveViewerSnapshot(t *testing.T) { + snapshot(t, "archive_viewer", 600, 320, func(c *ui.Context) { + p := ui.Local(c.Root(), "snap", func() *archiveProbe { + p := &archiveProbe{entries: archiveSample()} + p.s.table.SetOpen("court", true) + return p + }) + ui.Column(c).Padding(12).Children(func() { + ArchiveViewer(c, (*p).entries, &(*p).s, ArchiveViewerOptions{}).Element.Height(296) + }) + }) +} + +func TestArchiveViewerRejectsFileParent(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("file used as a folder was accepted") + } + }() + archiveBuild([]ArchiveEntry{{Path: "court"}, {Path: "court/a.txt"}}, &ArchiveViewerState{}) +} +func TestArchiveViewerAncestorDisabled(t *testing.T) { + s := ArchiveViewerState{} + disabled, opened := false, 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Disabled(disabled).Children(func() { + r := ArchiveViewer(c, []ArchiveEntry{{Path: "a.txt", Size: 10}}, &s, ArchiveViewerOptions{}) + r.Element.Height(200) + if r.Opened != nil { + opened++ + } + }) + }, 500, 250) + if err := tt.Click("a.txt"); err != nil { + t.Fatal(err) + } + disabled = true + tt.Key(0, ui.KeyEnter) + if opened != 0 { + t.Fatal("disabled archive opened") + } +} + +func TestArchiveViewerLargeAndRevision(t *testing.T) { + entries := make([]ArchiveEntry, 100000) + for i := range entries { + entries[i] = ArchiveEntry{Path: fmt.Sprintf("f-%d.txt", i), Size: 10, Packed: 5} + } + s := ArchiveViewerState{} + revision := uint64(0) + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ArchiveViewer(c, entries, &s, ArchiveViewerOptions{Revision: revision}).Element.Height(220) + }, 600, 250) + n := 0 + for _, text := range tt.Texts() { + if strings.HasPrefix(text, "f-") { + n++ + } + } + if n == 0 || n > 60 { + t.Fatalf("built %d archive entries", n) + } + entries[0].Size = 20 + revision++ + tt.Frame() + if s.index.size != 1000010 { + t.Fatalf("revised size %d", s.index.size) + } +} + +func TestArchiveViewerTotalOverflow(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("overflowing archive total was accepted") + } + }() + archiveBuild([]ArchiveEntry{{Path: "a", Size: 1 << 62}, {Path: "b", Size: 1 << 62}}, &ArchiveViewerState{}) +} + +func TestArchiveViewerDisablingBeforeDeliveredKey(t *testing.T) { + s := ArchiveViewerState{} + disabled := false + entries := []ArchiveEntry{{Path: "a.txt", Size: 1}, {Path: "b.txt", Size: 1}} + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Disabled(disabled).Children(func() { ArchiveViewer(c, entries, &s, ArchiveViewerOptions{}).Element.Height(200) }) + }, 600, 250) + if err := tt.Click("a.txt"); err != nil { + t.Fatal(err) + } + disabled = true + tt.Key(0, ui.KeyDown) + if key, _ := s.table.Selected(); key != "a.txt" { + t.Fatalf("disabled selection %s", key) + } +} diff --git a/code_shared.go b/code_shared.go index 9a99563..b60a254 100644 --- a/code_shared.go +++ b/code_shared.go @@ -1,7 +1,6 @@ package mujica import ( - "math" "strings" "github.com/egoist/mygo/ui" @@ -162,13 +161,6 @@ func codeBadge(c *ui.Context, letter string, col ui.Color) { }) } -// codeRule draws a pixel-aligned 1 DIP line. -func codeRule(p *ui.Painter, x0, x1, y float32, c ui.Color) { - s := float64(p.Scale()) - yy := float32((math.Floor(float64(y)*s) + 0.5) / s) - p.Line(x0, yy, x1, yy, 1, c) -} - // codeTextWidth returns the width of s in the code font. func codeTextWidth(c *ui.Context, s string) float32 { w, _ := c.MeasureText(0, ui.Span{Text: s, Font: theme.MonoFont, Size: FontSize(c, codeSize)}) diff --git a/docs/components/381-file-explorer.md b/docs/components/381-file-explorer.md new file mode 100644 index 0000000..bc5ccf8 --- /dev/null +++ b/docs/components/381-file-explorer.md @@ -0,0 +1,43 @@ +# FileExplorer + +## 用途 + +组合文件夹树、文件列表、导航历史与多选。 + +## 最小示例 + +```go +var state mujica.FileExplorerState +opts := mujica.FileExplorerOptions{ + Roots: []string{"/court"}, + Folders: func(string) []string { return nil }, + Entries: func(string) []mujica.FileEntry { + return []mujica.FileEntry{{Path: "/court/seal.go", Name: "seal.go", Size: 1024}} + }, +} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.FileExplorer(c, &state, opts).Element.Height(240) +} +ui.Render(view, 600, 300, 1) +``` + +## 参数 + +Roots、Folders、Entries 必填;Disabled 默认 false;Revision 用于原地更新条目。 + +## 状态 + +默认、焦点、禁用;空数据按组件内容展示。 + +## 事件 + +Navigated、Changed、Opened 返回本帧导航、选择和打开。 + +## 键盘操作 + +Tab 切换;方向键选择;Enter 打开;macOS Command+[ / ] / ↑,其他平台 Alt+← / → / ↑。 + +## 限制 + +文件及目录由调用方提供,读取与打开由调用方执行。条目 Path 必须唯一且非空;切片地址、长度或 Revision 变化时重建索引并清理已删除的选择。 diff --git a/docs/components/382-file-grid.md b/docs/components/382-file-grid.md new file mode 100644 index 0000000..f0af769 --- /dev/null +++ b/docs/components/382-file-grid.md @@ -0,0 +1,37 @@ +# FileGrid + +## 用途 + +通过缩略图插槽展示文件网格与多选。 + +## 最小示例 + +```go +entries := []mujica.FileEntry{{Path: "/court/seal.go", Name: "seal.go"}} +var state mujica.FileGridState +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.FileGrid(c, entries, &state, mujica.FileGridOptions{}).Element.Height(240) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +ItemWidth 默认 128 DIP;Thumbnail 返回是否已绘制;Disabled 默认 false;Revision 用于原地更新条目。Path 顺序变化时重建范围选择锚点;相同 Path 顺序保留锚点与滚动位置。 + +## 状态 + +默认、焦点、禁用;空数据按组件内容展示。 + +## 事件 + +Changed 表示选择变化;Opened 表示打开请求。 + +## 键盘操作 + +方向键选择;Command/Ctrl+点击多选;Shift 扩展;Enter 打开。 + +## 限制 + +仅创建可见瓦片;缩略图解码由调用方完成。 diff --git a/docs/components/383-file-icon.md b/docs/components/383-file-icon.md new file mode 100644 index 0000000..155a18f --- /dev/null +++ b/docs/components/383-file-icon.md @@ -0,0 +1,35 @@ +# FileIcon + +## 用途 + +按扩展名和文件夹开合显示类型图标。 + +## 最小示例 + +```go +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.FileIcon(c, "seal.go", mujica.FileIconOptions{Size: 32}) +} +ui.Render(view, 100, 80, 1) +``` + +## 参数 + +Size 默认 20 DIP;Folder 表示目录;Open 需要 Folder。 + +## 状态 + +文件、目录关闭、目录打开。 + +## 事件 + +展示组件。 + +## 键盘操作 + +展示组件,沿用父级焦点。 + +## 限制 + +扩展名大小写无关;未知扩展名显示通用文件图标。 diff --git a/docs/components/384-file-preview.md b/docs/components/384-file-preview.md new file mode 100644 index 0000000..9b8f407 --- /dev/null +++ b/docs/components/384-file-preview.md @@ -0,0 +1,36 @@ +# FilePreview + +## 用途 + +展示图片、文本、代码和不支持类型说明。 + +## 最小示例 + +```go +file := mujica.PreviewFile{Name: "seal.go", Text: "package court\nfunc Seal() {}"} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.FilePreview(c, file, mujica.FilePreviewOptions{}).Element.Height(240) +} +ui.Render(view, 500, 300, 1) +``` + +## 参数 + +Name 必填;Image、Text、Language、Size 为调用方数据;Status、Error 描述加载状态。 + +## 状态 + +内容就绪、加载、失败、不支持类型。 + +## 事件 + +Closed、Opened、Retried 返回关闭、打开和重试请求。 + +## 键盘操作 + +Escape 关闭;Tab 到操作;文本支持选择与复制。 + +## 限制 + +读取和解码由调用方提供;预览作为内嵌面板布局。 diff --git a/docs/components/385-path-bar.md b/docs/components/385-path-bar.md new file mode 100644 index 0000000..173287d --- /dev/null +++ b/docs/components/385-path-bar.md @@ -0,0 +1,35 @@ +# PathBar + +## 用途 + +分段跳转,长路径折叠中间段。 + +## 最小示例 + +```go +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.PathBar(c, "/court/chronicles/seals", mujica.PathBarOptions{}) +} +ui.Render(view, 400, 100, 1) +``` + +## 参数 + +Separator 默认 /;RootLabel 默认分隔符;Disabled 默认 false。 + +## 状态 + +默认、焦点、禁用;空数据按组件内容展示。 + +## 事件 + +Navigated 返回目标祖先路径。 + +## 键盘操作 + +Tab 进入;左右移动;Enter 激活;Escape 关闭折叠菜单。 + +## 限制 + +路径必须至少包含一个分段;调用方处理导航。 diff --git a/docs/components/386-rename-inline.md b/docs/components/386-rename-inline.md new file mode 100644 index 0000000..b02d9c3 --- /dev/null +++ b/docs/components/386-rename-inline.md @@ -0,0 +1,36 @@ +# RenameInline + +## 用途 + +编辑文件名,校验后确认或取消。 + +## 最小示例 + +```go +name, editing := "seal.txt", true +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.RenameInline(c, &name, &editing, mujica.RenameInlineOptions{}) +} +ui.Render(view, 400, 100, 1) +``` + +## 参数 + +name、editing 必填;Folder 默认 false;Validate 返回错误文案;Disabled 默认 false。 + +## 状态 + +默认、焦点、禁用;空数据按组件内容展示。 + +## 事件 + +Renamed 表示已写回名称;Canceled 表示取消。 + +## 键盘操作 + +Enter 确认;Escape 取消;双击开始编辑;失焦确认有效名称。 + +## 限制 + +默认文件名主体选择采用视觉标记和首次输入替换。MyGo ui/editor.go:133 的选择接口为私有;剪贴板和撤销沿用原生编辑器的实际选区。组合输入期间 Enter 由 MyGo ui/editor.go:485、688 处理,组件无法查询组合状态;使用输入法提交文本后再确认。 diff --git a/docs/components/387-file-operation-progress.md b/docs/components/387-file-operation-progress.md new file mode 100644 index 0000000..6321e7f --- /dev/null +++ b/docs/components/387-file-operation-progress.md @@ -0,0 +1,36 @@ +# FileOperationProgress + +## 用途 + +显示复制、移动、删除进度、取消和冲突回答。 + +## 最小示例 + +```go +op := mujica.FileOperation{Kind: mujica.FileCopy, Done: 2, Total: 10, Current: "seal.go"} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.FileOperationProgress(c, op, mujica.FileOperationOptions{}) +} +ui.Render(view, 500, 240, 1) +``` + +## 参数 + +Done、Total、BytesDone、BytesTotal 为计数;Conflict 为冲突名称;Disabled 默认 false。 + +## 状态 + +默认、焦点、禁用;空数据按组件内容展示。 + +## 事件 + +Canceled、Resolved、ApplyAll 返回操作意图。 + +## 键盘操作 + +Tab 到取消、全选规则和冲突选项;Enter / Space 激活。 + +## 限制 + +文件操作由调用方执行;非法计数或 Kind 会 panic。 diff --git a/docs/components/388-transfer-queue.md b/docs/components/388-transfer-queue.md new file mode 100644 index 0000000..8f92405 --- /dev/null +++ b/docs/components/388-transfer-queue.md @@ -0,0 +1,37 @@ +# TransferQueue + +## 用途 + +显示上传下载队列并回传暂停、继续、重试、移除。 + +## 最小示例 + +```go +items := []mujica.QueuedTransfer{{ID: "a", Name: "ledger.zip", Upload: true, Status: mujica.TransferActive, Done: 25, Total: 100}} +var state mujica.TransferQueueState +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.TransferQueue(c, items, &state, mujica.TransferQueueOptions{}).Element.Height(240) +} +ui.Render(view, 500, 300, 1) +``` + +## 参数 + +ID 唯一且必填;Status 默认为排队;Revision 原地更新数据时递增;Disabled 默认 false。 + +## 状态 + +默认、焦点、禁用;空数据按组件内容展示。 + +## 事件 + +Action 与 ID 返回对应传输操作。 + +## 键盘操作 + +Tab 在可见行操作间移动;Enter / Space 激活。 + +## 限制 + +网络由调用方执行;汇总仅在数据切片或 Revision 变化时重算。 diff --git a/docs/components/389-storage-usage.md b/docs/components/389-storage-usage.md new file mode 100644 index 0000000..75eae34 --- /dev/null +++ b/docs/components/389-storage-usage.md @@ -0,0 +1,36 @@ +# StorageUsage + +## 用途 + +存储分段条、分类明细及键盘读数。 + +## 最小示例 + +```go +segments := []mujica.StorageSegment{{Label: "Documents", Bytes: 24 << 30}, {Label: "Photos", Bytes: 40 << 30}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.StorageUsage(c, segments, mujica.StorageUsageOptions{Capacity: 128 << 30, Label: "Court disk"}) +} +ui.Render(view, 500, 240, 1) +``` + +## 参数 + +Capacity 必须为正;Label 为业务名称;分类总量须处于容量内;原地更新时递增 Revision,分类明细采用虚拟列表。 + +## 状态 + +占用、剩余、焦点读数。 + +## 事件 + +方向键读数变化会朗读并记录状态。 + +## 键盘操作 + +Tab 聚焦分段条;左右循环读取分类与剩余空间。 + +## 限制 + +统计由调用方提供,组件展示分类大小与比例。 diff --git a/docs/components/390-recent-files.md b/docs/components/390-recent-files.md new file mode 100644 index 0000000..691f8a3 --- /dev/null +++ b/docs/components/390-recent-files.md @@ -0,0 +1,38 @@ +# RecentFiles + +## 用途 + +按今天、昨天、本周与更早分组展示最近文件。 + +## 最小示例 + +```go +now := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC) +files := []mujica.RecentFile{{Path: "/court/seal.go", Opened: now}} +var state mujica.RecentFilesState +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.RecentFiles(c, files, &state, mujica.RecentFilesOptions{Now: now}).Element.Height(240) +} +ui.Render(view, 500, 300, 1) +``` + +## 参数 + +Now 默认帧时间;Revision 原地更新时递增;Disabled 默认 false。 + +## 状态 + +默认、焦点、禁用;空数据按组件内容展示。 + +## 事件 + +Opened、Removed、Cleared 为打开、移除、清空请求。 + +## 键盘操作 + +上下选择文件;Enter 打开;Tab 到清除和移除按钮。 + +## 限制 + +时间按 Now 时区分组;切片替换或 Revision 更新会重算排序。 diff --git a/docs/components/391-archive-viewer.md b/docs/components/391-archive-viewer.md new file mode 100644 index 0000000..0b11285 --- /dev/null +++ b/docs/components/391-archive-viewer.md @@ -0,0 +1,37 @@ +# ArchiveViewer + +## 用途 + +展示调用方条目树、大小和压缩率。 + +## 最小示例 + +```go +entries := []mujica.ArchiveEntry{{Path: "court/seal.go", Size: 4000, Packed: 1000}} +var state mujica.ArchiveViewerState +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ArchiveViewer(c, entries, &state, mujica.ArchiveViewerOptions{}).Element.Height(240) +} +ui.Render(view, 600, 300, 1) +``` + +## 参数 + +Path 唯一;Size、Packed 非负;Status、Error 为加载状态;Revision 原地更新时递增。 + +## 状态 + +条目树、加载、失败、空列表。 + +## 事件 + +Opened 返回文件打开请求;Retried 返回重试请求。 + +## 键盘操作 + +上下选择;左右展开折叠;Enter 打开;Tab 到列操作。 + +## 限制 + +压缩包解码由调用方完成;祖先目录自动补齐,文件与目录身份冲突会 panic。 diff --git a/docs/components/392-presence-avatars.md b/docs/components/392-presence-avatars.md new file mode 100644 index 0000000..99a6fb1 --- /dev/null +++ b/docs/components/392-presence-avatars.md @@ -0,0 +1,36 @@ +# PresenceAvatars + +## 用途 + +显示协作者、溢出人数及跟随入口。 + +## 最小示例 + +```go +people := []mujica.Collaborator{{ID: "ada", Name: "Ada Lovelace"}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.PresenceAvatars(c, people, mujica.PresenceAvatarsOptions{}) +} +ui.Render(view, 300, 100, 1) +``` + +## 参数 + +Max 默认 4;Size 默认 28;Following 为跟随 ID;Disabled 默认 false。 + +## 状态 + +默认、焦点、禁用;空数据按组件内容展示。 + +## 事件 + +Follow 返回 ID;Unfollow 请求停止跟随。 + +## 键盘操作 + +Tab 进入;左右移动;Enter 激活;Escape 关闭溢出名单。 + +## 限制 + +在线数据与跟随行为由调用方维护。 diff --git a/docs/components/394-reactions.md b/docs/components/394-reactions.md new file mode 100644 index 0000000..333e190 --- /dev/null +++ b/docs/components/394-reactions.md @@ -0,0 +1,36 @@ +# Reactions + +## 用途 + +显示回应计数、当前用户标记与添加选择器。 + +## 最小示例 + +```go +items := []mujica.Reaction{{Emoji: "\U0001f44d", Count: 3, Mine: true}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.Reactions(c, items, mujica.ReactionsOptions{Choices: []string{"\U0001f44d", "\U0001f389"}}) +} +ui.Render(view, 400, 100, 1) +``` + +## 参数 + +Emoji 为显示字符;Count 必须为正;Choices 必填;People 用于提示;Disabled 默认 false。 + +## 状态 + +默认、焦点、禁用;空数据按组件内容展示。 + +## 事件 + +Toggled 为切换意图;Added 为选择器选项。 + +## 键盘操作 + +Tab 到回应与添加;Enter / Space 激活;选择器左右导航;Escape 关闭。 + +## 限制 + +计数更新与持久化由调用方执行;传入系统字体支持的表情。 diff --git a/docs/components/395-share-dialog.md b/docs/components/395-share-dialog.md new file mode 100644 index 0000000..606c262 --- /dev/null +++ b/docs/components/395-share-dialog.md @@ -0,0 +1,40 @@ +# ShareDialog + +## 用途 + +邀请成员、调整权限、移除成员与复制链接回调。 + +## 最小示例 + +```go +open := false +members := []mujica.ShareMember{{ID: "ada", Name: "Ada", Permission: mujica.PermissionEdit, Owner: true}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.Button(c, "Share", mujica.ButtonOptions{}).Clicked() { + open = true + } + mujica.ShareDialog(c, &open, &members, mujica.ShareDialogOptions{Title: "Ledger", Link: "https://court.org/ledger"}) +} +ui.Render(view, 600, 400, 1) +``` + +## 参数 + +Title 必填;Link 为空隐藏复制;OnCopy 可覆盖剪贴板动作;Disabled 或祖先禁用关闭弹层。 + +## 状态 + +默认、焦点、禁用;空数据按组件内容展示。 + +## 事件 + +Invited 为邀请请求;Changed、Removed 已应用成员变化;Copied 为复制请求完成。 + +## 键盘操作 + +Tab 在对话框内移动;Enter 邀请或激活操作;Escape 关闭并恢复焦点。 + +## 限制 + +权限和成员由调用方持有;网络与链接有效性校验由业务负责。 diff --git a/docs/components/396-permission-select.md b/docs/components/396-permission-select.md new file mode 100644 index 0000000..52daacf --- /dev/null +++ b/docs/components/396-permission-select.md @@ -0,0 +1,36 @@ +# PermissionSelect + +## 用途 + +选择可查看、可评论、可编辑。 + +## 最小示例 + +```go +value := mujica.PermissionView +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.PermissionSelect(c, &value, mujica.PermissionSelectOptions{}) +} +ui.Render(view, 300, 100, 1) +``` + +## 参数 + +PermissionView 为零值;Label 默认权限;Disabled 默认 false。 + +## 状态 + +默认、焦点、禁用;空数据按组件内容展示。 + +## 事件 + +ChoiceResult.Changed() 表示值已变化。 + +## 键盘操作 + +Enter 打开;上下移动;Enter 确认;Escape 关闭。 + +## 限制 + +权限枚举越界会 panic;授权操作由调用方处理。 diff --git a/docs/components/399-live-indicator.md b/docs/components/399-live-indicator.md new file mode 100644 index 0000000..0a368b9 --- /dev/null +++ b/docs/components/399-live-indicator.md @@ -0,0 +1,35 @@ +# LiveIndicator + +## 用途 + +显示实时同步状态、离线说明和重试。 + +## 最小示例 + +```go +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.LiveIndicator(c, mujica.LiveOffline, mujica.LiveIndicatorOptions{}) +} +ui.Render(view, 500, 100, 1) +``` + +## 参数 + +LastSynced 可选;Now 默认帧时间;Error 补充失败原因。 + +## 状态 + +已同步、同步中、重连、离线、失败。 + +## 事件 + +ChoiceResult.Changed() 表示用户请求重试。 + +## 键盘操作 + +Tab 到重试;Enter / Space 激活。 + +## 限制 + +同步由调用方执行;状态变化会朗读,减少动画使用静态说明。 diff --git a/docs/components/400-remote-cursor.md b/docs/components/400-remote-cursor.md new file mode 100644 index 0000000..131b437 --- /dev/null +++ b/docs/components/400-remote-cursor.md @@ -0,0 +1,40 @@ +# RemoteCursor + +## 用途 + +绘制远程光标、选区与姓名标签。 + +## 最小示例 + +```go +carets := []mujica.RemoteCaret{{ID: "ada", Name: "Ada", Offset: 4, Anchor: 4}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + ui.Box(c).Size(300, 100).Children(func() { + mujica.RemoteCursor(c, carets, mujica.RemoteCursorOptions{ + Caret: func(offset int) ui.Rect { return ui.Rect{X: float32(offset) * 8, Y: 20, H: 20} }, + }) + }) +} +ui.Render(view, 320, 120, 1) +``` + +## 参数 + +ID 唯一且必填;Offset、Anchor 非负;Caret 必填;Selection 返回选区矩形。 + +## 状态 + +光标、选区、姓名;覆盖层输入穿透。 + +## 事件 + +展示组件。 + +## 键盘操作 + +输入穿透至下方编辑器。 + +## 限制 + +坐标映射、远程状态由调用方提供;覆盖层放在编辑器后方。 diff --git a/example_files_test.go b/example_files_test.go new file mode 100644 index 0000000..e9a2047 --- /dev/null +++ b/example_files_test.go @@ -0,0 +1,177 @@ +package mujica_test + +import ( + "github.com/egoist/mygo/ui" + mujica "mujicaui" + "time" +) + +func ExampleFileExplorer() { + var state mujica.FileExplorerState + opts := mujica.FileExplorerOptions{ + Roots: []string{"/court"}, + Folders: func(string) []string { return nil }, + Entries: func(string) []mujica.FileEntry { + return []mujica.FileEntry{{Path: "/court/seal.go", Name: "seal.go", Size: 1024}} + }, + } + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.FileExplorer(c, &state, opts).Element.Height(240) + } + ui.Render(view, 600, 300, 1) +} + +func ExampleFileGrid() { + entries := []mujica.FileEntry{{Path: "/court/seal.go", Name: "seal.go"}} + var state mujica.FileGridState + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.FileGrid(c, entries, &state, mujica.FileGridOptions{}).Element.Height(240) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleFileIcon() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.FileIcon(c, "seal.go", mujica.FileIconOptions{Size: 32}) + } + ui.Render(view, 100, 80, 1) +} + +func ExampleFilePreview() { + file := mujica.PreviewFile{Name: "seal.go", Text: "package court\nfunc Seal() {}"} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.FilePreview(c, file, mujica.FilePreviewOptions{}).Element.Height(240) + } + ui.Render(view, 500, 300, 1) +} + +func ExamplePathBar() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.PathBar(c, "/court/chronicles/seals", mujica.PathBarOptions{}) + } + ui.Render(view, 400, 100, 1) +} + +func ExampleRenameInline() { + name, editing := "seal.txt", true + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.RenameInline(c, &name, &editing, mujica.RenameInlineOptions{}) + } + ui.Render(view, 400, 100, 1) +} + +func ExampleFileOperationProgress() { + op := mujica.FileOperation{Kind: mujica.FileCopy, Done: 2, Total: 10, Current: "seal.go"} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.FileOperationProgress(c, op, mujica.FileOperationOptions{}) + } + ui.Render(view, 500, 240, 1) +} + +func ExampleTransferQueue() { + items := []mujica.QueuedTransfer{{ID: "a", Name: "ledger.zip", Upload: true, Status: mujica.TransferActive, Done: 25, Total: 100}} + var state mujica.TransferQueueState + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.TransferQueue(c, items, &state, mujica.TransferQueueOptions{}).Element.Height(240) + } + ui.Render(view, 500, 300, 1) +} + +func ExampleStorageUsage() { + segments := []mujica.StorageSegment{{Label: "Documents", Bytes: 24 << 30}, {Label: "Photos", Bytes: 40 << 30}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.StorageUsage(c, segments, mujica.StorageUsageOptions{Capacity: 128 << 30, Label: "Court disk"}) + } + ui.Render(view, 500, 240, 1) +} + +func ExampleRecentFiles() { + now := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC) + files := []mujica.RecentFile{{Path: "/court/seal.go", Opened: now}} + var state mujica.RecentFilesState + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.RecentFiles(c, files, &state, mujica.RecentFilesOptions{Now: now}).Element.Height(240) + } + ui.Render(view, 500, 300, 1) +} + +func ExampleArchiveViewer() { + entries := []mujica.ArchiveEntry{{Path: "court/seal.go", Size: 4000, Packed: 1000}} + var state mujica.ArchiveViewerState + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ArchiveViewer(c, entries, &state, mujica.ArchiveViewerOptions{}).Element.Height(240) + } + ui.Render(view, 600, 300, 1) +} + +func ExamplePresenceAvatars() { + people := []mujica.Collaborator{{ID: "ada", Name: "Ada Lovelace"}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.PresenceAvatars(c, people, mujica.PresenceAvatarsOptions{}) + } + ui.Render(view, 300, 100, 1) +} + +func ExampleReactions() { + items := []mujica.Reaction{{Emoji: "\U0001f44d", Count: 3, Mine: true}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.Reactions(c, items, mujica.ReactionsOptions{Choices: []string{"\U0001f44d", "\U0001f389"}}) + } + ui.Render(view, 400, 100, 1) +} + +func ExampleShareDialog() { + open := false + members := []mujica.ShareMember{{ID: "ada", Name: "Ada", Permission: mujica.PermissionEdit, Owner: true}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.Button(c, "Share", mujica.ButtonOptions{}).Clicked() { + open = true + } + mujica.ShareDialog(c, &open, &members, mujica.ShareDialogOptions{Title: "Ledger", Link: "https://court.org/ledger"}) + } + ui.Render(view, 600, 400, 1) +} + +func ExamplePermissionSelect() { + value := mujica.PermissionView + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.PermissionSelect(c, &value, mujica.PermissionSelectOptions{}) + } + ui.Render(view, 300, 100, 1) +} + +func ExampleLiveIndicator() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.LiveIndicator(c, mujica.LiveOffline, mujica.LiveIndicatorOptions{}) + } + ui.Render(view, 500, 100, 1) +} + +func ExampleRemoteCursor() { + carets := []mujica.RemoteCaret{{ID: "ada", Name: "Ada", Offset: 4, Anchor: 4}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + ui.Box(c).Size(300, 100).Children(func() { + mujica.RemoteCursor(c, carets, mujica.RemoteCursorOptions{ + Caret: func(offset int) ui.Rect { return ui.Rect{X: float32(offset) * 8, Y: 20, H: 20} }, + }) + }) + } + ui.Render(view, 320, 120, 1) +} diff --git a/examples/gallery/pages_files.go b/examples/gallery/pages_files.go index b5be09e..f2956bc 100644 --- a/examples/gallery/pages_files.go +++ b/examples/gallery/pages_files.go @@ -1,3 +1,23 @@ package main -var filesPages = []page{} +const filesCategory = "Files & Collaboration" + +var filesPages = []page{ + {ID: "381", Name: "FileExplorer", ZhName: "文件浏览器", Category: filesCategory, Demo: fileExplorerDemo, Code: filesFileExplorerCode, Keys: "Tab 切换;方向键选择;Enter 打开;macOS Command+[ / ] / ↑,其他平台 Alt+← / → / ↑。"}, + {ID: "382", Name: "FileGrid", ZhName: "文件网格", Category: filesCategory, Demo: fileGridDemo, Code: filesFileGridCode, Keys: "方向键选择;Command/Ctrl+点击多选;Shift 扩展;Enter 打开。"}, + {ID: "383", Name: "FileIcon", ZhName: "文件图标", Category: filesCategory, Demo: fileIconDemo, Code: filesFileIconCode, Keys: "展示组件,沿用父级焦点。"}, + {ID: "384", Name: "FilePreview", ZhName: "文件预览", Category: filesCategory, Demo: filePreviewDemo, Code: filesFilePreviewCode, Keys: "Escape 关闭;Tab 到操作;文本支持选择与复制。"}, + {ID: "385", Name: "PathBar", ZhName: "路径栏", Category: filesCategory, Demo: pathBarDemo, Code: filesPathBarCode, Keys: "Tab 进入;左右移动;Enter 激活;Escape 关闭折叠菜单。"}, + {ID: "386", Name: "RenameInline", ZhName: "行内重命名", Category: filesCategory, Demo: renameInlineDemo, Code: filesRenameInlineCode, Keys: "Enter 确认;Escape 取消;双击开始编辑;失焦确认有效名称。"}, + {ID: "387", Name: "FileOperationProgress", ZhName: "文件操作进度", Category: filesCategory, Demo: fileOperationProgressDemo, Code: filesFileOperationProgressCode, Keys: "Tab 到取消、全选规则和冲突选项;Enter / Space 激活。"}, + {ID: "388", Name: "TransferQueue", ZhName: "传输队列", Category: filesCategory, Demo: transferQueueDemo, Code: filesTransferQueueCode, Keys: "Tab 在可见行操作间移动;Enter / Space 激活。"}, + {ID: "389", Name: "StorageUsage", ZhName: "存储占用", Category: filesCategory, Demo: storageUsageDemo, Code: filesStorageUsageCode, Keys: "Tab 聚焦分段条;左右循环读取分类与剩余空间。"}, + {ID: "390", Name: "RecentFiles", ZhName: "最近文件", Category: filesCategory, Demo: recentFilesDemo, Code: filesRecentFilesCode, Keys: "上下选择文件;Enter 打开;Tab 到清除和移除按钮。"}, + {ID: "391", Name: "ArchiveViewer", ZhName: "压缩包浏览", Category: filesCategory, Demo: archiveViewerDemo, Code: filesArchiveViewerCode, Keys: "上下选择;左右展开折叠;Enter 打开;Tab 到列操作。"}, + {ID: "392", Name: "PresenceAvatars", ZhName: "协作头像", Category: filesCategory, Demo: presenceAvatarsDemo, Code: filesPresenceAvatarsCode, Keys: "Tab 进入;左右移动;Enter 激活;Escape 关闭溢出名单。"}, + {ID: "394", Name: "Reactions", ZhName: "表情回应", Category: filesCategory, Demo: reactionsDemo, Code: filesReactionsCode, Keys: "Tab 到回应与添加;Enter / Space 激活;选择器左右导航;Escape 关闭。"}, + {ID: "395", Name: "ShareDialog", ZhName: "分享对话框", Category: filesCategory, Demo: shareDialogDemo, Code: filesShareDialogCode, Keys: "Tab 在对话框内移动;Enter 邀请或激活操作;Escape 关闭并恢复焦点。"}, + {ID: "396", Name: "PermissionSelect", ZhName: "权限选择", Category: filesCategory, Demo: permissionSelectDemo, Code: filesPermissionSelectCode, Keys: "Enter 打开;上下移动;Enter 确认;Escape 关闭。"}, + {ID: "399", Name: "LiveIndicator", ZhName: "同步状态", Category: filesCategory, Demo: liveIndicatorDemo, Code: filesLiveIndicatorCode, Keys: "Tab 到重试;Enter / Space 激活。"}, + {ID: "400", Name: "RemoteCursor", ZhName: "远程光标", Category: filesCategory, Demo: remoteCursorDemo, Code: filesRemoteCursorCode, Keys: "输入穿透至下方编辑器。"}, +} diff --git a/examples/gallery/pages_files_browser.go b/examples/gallery/pages_files_browser.go new file mode 100644 index 0000000..901957f --- /dev/null +++ b/examples/gallery/pages_files_browser.go @@ -0,0 +1,362 @@ +package main + +import ( + "fmt" + "path" + "strings" + "time" + + "github.com/egoist/mygo/ui" + mujica "mujicaui" +) + +var filesFolders = map[string][]string{ + "/court": {"chronicles/", "decrees/", "seal.go", "notes.md"}, + "/court/chronicles": {"first.md", "second.md"}, + "/court/decrees": {"42.pdf", "crest.png"}, +} + +func filesEntries(dir string) []mujica.FileEntry { + var entries []mujica.FileEntry + for _, n := range filesFolders[dir] { + name := strings.TrimSuffix(n, "/") + entries = append(entries, mujica.FileEntry{Path: path.Join(dir, name), Name: name, Dir: name != n, Size: 2048}) + } + return entries +} + +func fileExplorerDemo(c *ui.Context, log func(string)) { + type state struct { + explorer mujica.FileExplorerState + disabled, empty bool + } + s := ui.Local(c.Root(), "381", func() state { return state{} }) + mediaCheck(c, log, &s.disabled, "Disabled") + mediaCheck(c, log, &s.empty, "Empty folder") + r := mujica.FileExplorer(c, &s.explorer, mujica.FileExplorerOptions{ + Roots: []string{"/court"}, Disabled: s.disabled, + Folders: func(dir string) []string { + var dirs []string + for _, e := range filesEntries(dir) { + if e.Dir { + dirs = append(dirs, e.Path) + } + } + return dirs + }, + Entries: func(dir string) []mujica.FileEntry { + if s.empty { + return nil + } + return filesEntries(dir) + }, + }) + r.Element.Height(320).FillWidth() + if r.Navigated { + log("Navigate: " + s.explorer.Current) + } + if r.Changed { + log(fmt.Sprintf("Selected: %v", s.explorer.Selected())) + } + if r.Opened != nil { + log("Open: " + r.Opened.Path) + } +} + +func fileGridDemo(c *ui.Context, log func(string)) { + type state struct { + grid mujica.FileGridState + entries []mujica.FileEntry + disabled, thumbs, empty bool + } + s := ui.Local(c.Root(), "382", func() state { + return state{thumbs: true, entries: append(filesEntries("/court"), filesEntries("/court/decrees")...)} + }) + mediaCheck(c, log, &s.disabled, "Disabled") + mediaCheck(c, log, &s.thumbs, "Thumbnails") + mediaCheck(c, log, &s.empty, "Empty") + entries := s.entries + if s.empty { + entries = nil + } + r := mujica.FileGrid(c, entries, &s.grid, mujica.FileGridOptions{Disabled: s.disabled, Thumbnail: func(e mujica.FileEntry) bool { + if !s.thumbs || mujica.FileKindOf(e.Name) != mujica.FileImage { + return false + } + mujica.Image(c, mediaBitmaps[0], mujica.ImageOptions{Alt: e.Name}).Element.Fill() + return true + }}) + r.Element.Height(320).FillWidth() + if r.Changed { + log(fmt.Sprintf("Selected: %v", s.grid.Selected())) + } + if r.Opened != nil { + log("Open: " + r.Opened.Path) + } +} + +func fileIconDemo(c *ui.Context, log func(string)) { + type state struct { + name string + folder, open bool + size float64 + } + s := ui.Local(c.Root(), "383", func() state { return state{name: "seal.go", size: 32} }) + if mujica.TextInput(c, &s.name, mujica.TextInputOptions{Label: "File name"}).Changed() { + log("File name changed") + } + mediaCheck(c, log, &s.folder, "Folder") + if s.folder { + mediaCheck(c, log, &s.open, "Open folder") + } + if mujica.NumberInput(c, &s.size, mujica.NumberInputOptions{Min: 20, Max: 64, Step: 4, Label: "Icon size"}).Changed() { + log(fmt.Sprintf("Size: %.0f", s.size)) + } + mujica.FileIcon(c, s.name, mujica.FileIconOptions{Folder: s.folder, Open: s.folder && s.open, Size: float32(s.size)}) + ui.Row(c).Gap(12).Children(func() { + for _, name := range []string{"seal.go", "notes.md", "crest.png", "deed.pdf", "ledger.zip", "hymn.mp3", "rite.mov", "blob"} { + mujica.FileIcon(c, name, mujica.FileIconOptions{Size: 32}) + } + }) +} + +func filePreviewDemo(c *ui.Context, log func(string)) { + type state struct { + kind int + status mujica.DataStatus + closed bool + } + s := ui.Local(c.Root(), "384", func() state { return state{} }) + if mujica.Select(c, &s.kind, []mujica.SelectOption[int]{{Value: 0, Label: "Code"}, {Value: 1, Label: "Text"}, {Value: 2, Label: "Image"}, {Value: 3, Label: "Unsupported"}}, mujica.SelectOptions{Label: "Preview type"}).Changed() { + log("Preview type changed") + s.closed = false + } + filesStatus(c, log, &s.status) + if s.closed { + if mujica.Button(c, "Show preview", mujica.ButtonOptions{}).Clicked() { + s.closed = false + log("Preview reopened") + } + return + } + f := []mujica.PreviewFile{{Name: "seal.go", Text: "package court\n\nfunc Seal() {}"}, {Name: "notes.md", Text: "The seal of the court."}, {Name: "crest.png", Image: mediaBitmaps[0]}, {Name: "deed.pdf", Size: 4096}}[s.kind] + r := mujica.FilePreview(c, f, mujica.FilePreviewOptions{Status: s.status, Error: "Reading failed"}) + r.Element.Height(280).FillWidth() + if r.Closed { + s.closed = true + log("Preview closed") + } + if r.Opened { + log("Open: " + f.Name) + } + if r.Retried { + s.status = mujica.DataReady + log("Read retried") + } +} + +func filesStatus(c *ui.Context, log func(string), value *mujica.DataStatus) { + if mujica.Select(c, value, []mujica.SelectOption[mujica.DataStatus]{{Value: mujica.DataReady, Label: "Ready"}, {Value: mujica.DataLoading, Label: "Loading"}, {Value: mujica.DataFailed, Label: "Failed"}}, mujica.SelectOptions{Label: "Load status"}).Changed() { + log(fmt.Sprintf("Status: %d", *value)) + } +} + +func pathBarDemo(c *ui.Context, log func(string)) { + type state struct { + path string + disabled bool + } + s := ui.Local(c.Root(), "385", func() state { return state{path: "/court/chronicles/royal/archives/seals"} }) + mediaCheck(c, log, &s.disabled, "Disabled") + if mujica.TextInput(c, &s.path, mujica.TextInputOptions{Label: "Path"}).Changed() { + log("Path changed") + } + if strings.Trim(s.path, "/") == "" { + ui.Text(c, "Enter a path") + return + } + if r := mujica.PathBar(c, s.path, mujica.PathBarOptions{Disabled: s.disabled}); r.Navigated != "" { + s.path = r.Navigated + log("Navigate: " + r.Navigated) + } +} + +func renameInlineDemo(c *ui.Context, log func(string)) { + type state struct { + name string + editing, disabled, folder bool + } + s := ui.Local(c.Root(), "386", func() state { return state{name: "seal.txt"} }) + mediaCheck(c, log, &s.disabled, "Disabled") + mediaCheck(c, log, &s.folder, "Folder") + if mujica.Button(c, "Rename", mujica.ButtonOptions{Disabled: s.disabled}).Clicked() { + s.editing = true + log("Rename started") + } + r := mujica.RenameInline(c, &s.name, &s.editing, mujica.RenameInlineOptions{Disabled: s.disabled, Folder: s.folder, Validate: func(name string) string { + if strings.ContainsAny(name, "/\\") { + return "Name contains a path separator" + } + return "" + }}) + if r.Renamed { + log("Renamed: " + s.name) + } + if r.Canceled { + log("Rename canceled") + } +} + +func fileOperationProgressDemo(c *ui.Context, log func(string)) { + type state struct { + op mujica.FileOperation + disabled, conflict bool + canceled bool + } + s := ui.Local(c.Root(), "387", func() state { + return state{op: mujica.FileOperation{Kind: mujica.FileCopy, Total: 10, Current: "seal.go"}} + }) + mediaCheck(c, log, &s.disabled, "Disabled") + mediaCheck(c, log, &s.conflict, "Conflict") + if mujica.Select(c, &s.op.Kind, []mujica.SelectOption[mujica.FileOperationKind]{{Value: mujica.FileCopy, Label: "Copy"}, {Value: mujica.FileMove, Label: "Move"}, {Value: mujica.FileDelete, Label: "Delete"}}, mujica.SelectOptions{Label: "Operation"}).Changed() { + log("Operation changed") + } + if mujica.Button(c, "Advance", mujica.ButtonOptions{Disabled: s.op.Done == s.op.Total}).Clicked() { + s.op.Done++ + log(fmt.Sprintf("Done: %d", s.op.Done)) + } + if mujica.Button(c, "Reset", mujica.ButtonOptions{}).Clicked() { + s.op.Done = 0 + s.canceled = false + log("Operation reset") + } + op := s.op + if s.conflict { + op.Conflict = "seal.go" + } + r := mujica.FileOperationProgress(c, op, mujica.FileOperationOptions{Disabled: s.disabled || s.canceled}) + if r.Canceled { + s.canceled = true + log("Operation canceled") + } + if r.Resolved != mujica.ConflictNone { + s.conflict = false + log(fmt.Sprintf("Conflict: %d; apply all: %v", r.Resolved, r.ApplyAll)) + } +} + +func filesTransfers() []mujica.QueuedTransfer { + return []mujica.QueuedTransfer{ + {ID: "a", Name: "ledger.zip", Upload: true, Status: mujica.TransferActive, Done: 30, Total: 120}, + {ID: "b", Name: "crest.png", Status: mujica.TransferPaused, Done: 1, Total: 4}, + {ID: "c", Name: "deed.pdf", Upload: true, Status: mujica.TransferFailed, Total: 2, Error: "Network lost"}, + } +} + +func transferQueueDemo(c *ui.Context, log func(string)) { + type state struct { + items []mujica.QueuedTransfer + queue mujica.TransferQueueState + disabled bool + revision uint64 + } + s := ui.Local(c.Root(), "388", func() state { return state{items: filesTransfers()} }) + mediaCheck(c, log, &s.disabled, "Disabled") + if mujica.Button(c, "Reset", mujica.ButtonOptions{}).Clicked() { + s.items = filesTransfers() + log("Queue reset") + } + r := mujica.TransferQueue(c, s.items, &s.queue, mujica.TransferQueueOptions{Disabled: s.disabled, Revision: s.revision}) + r.Element.Height(260).FillWidth() + if r.Action == mujica.TransferNone { + return + } + for i := range s.items { + if s.items[i].ID == r.ID { + switch r.Action { + case mujica.TransferPause: + s.items[i].Status = mujica.TransferPaused + case mujica.TransferResume, mujica.TransferRetry: + s.items[i].Status = mujica.TransferActive + case mujica.TransferRemove: + s.items = append(s.items[:i:i], s.items[i+1:]...) + } + break + } + } + s.revision++ + log(fmt.Sprintf("Transfer %s: %d", r.ID, r.Action)) +} + +func storageUsageDemo(c *ui.Context, log func(string)) { + type state struct{ photos float64 } + s := ui.Local(c.Root(), "389", func() state { return state{photos: 40} }) + if mujica.NumberInput(c, &s.photos, mujica.NumberInputOptions{Min: 0, Max: 92, Step: 1, Label: "Photos (GB)"}).Changed() { + log(fmt.Sprintf("Photos: %.0f GB", s.photos)) + } + mujica.StorageUsage(c, []mujica.StorageSegment{{Label: "Documents", Bytes: 24 << 30}, {Label: "Photos", Bytes: int64(s.photos) * (1 << 30)}, {Label: "Apps", Bytes: 12 << 30}}, mujica.StorageUsageOptions{Capacity: 128 << 30, Label: "Court disk"}).FillWidth() +} + +func filesRecent() []mujica.RecentFile { + now := time.Date(2026, 10, 5, 15, 0, 0, 0, time.UTC) + return []mujica.RecentFile{{Path: "/court/seal.go", Opened: now}, {Path: "/court/notes.md", Opened: now.AddDate(0, 0, -1)}, {Path: "/court/crest.png", Opened: now.AddDate(0, 0, -4)}, {Path: "/court/charter.pdf", Opened: now.AddDate(0, -1, 0)}} +} + +func recentFilesDemo(c *ui.Context, log func(string)) { + type state struct { + files []mujica.RecentFile + recent mujica.RecentFilesState + disabled bool + } + s := ui.Local(c.Root(), "390", func() state { return state{files: filesRecent()} }) + mediaCheck(c, log, &s.disabled, "Disabled") + if mujica.Button(c, "Reset", mujica.ButtonOptions{}).Clicked() { + s.files = filesRecent() + log("Recent files reset") + } + r := mujica.RecentFiles(c, s.files, &s.recent, mujica.RecentFilesOptions{Disabled: s.disabled, Now: time.Date(2026, 10, 5, 15, 0, 0, 0, time.UTC)}) + r.Element.Height(320).FillWidth() + if r.Opened != nil { + log("Open: " + r.Opened.Path) + } + if r.Cleared { + s.files = nil + log("Recent files cleared") + } + if r.Removed != "" { + for i, f := range s.files { + if f.Path == r.Removed { + s.files = append(s.files[:i:i], s.files[i+1:]...) + break + } + } + log("Removed: " + r.Removed) + } +} + +func archiveViewerDemo(c *ui.Context, log func(string)) { + type state struct { + archive mujica.ArchiveViewerState + entries []mujica.ArchiveEntry + status mujica.DataStatus + empty bool + } + s := ui.Local(c.Root(), "391", func() state { + return state{entries: []mujica.ArchiveEntry{{Path: "court/seal.go", Size: 4000, Packed: 1000}, {Path: "court/decrees/42.pdf", Size: 6000, Packed: 5000}, {Path: "README.md", Size: 1000, Packed: 400}}} + }) + filesStatus(c, log, &s.status) + mediaCheck(c, log, &s.empty, "Empty") + entries := s.entries + if s.empty { + entries = nil + } + r := mujica.ArchiveViewer(c, entries, &s.archive, mujica.ArchiveViewerOptions{Status: s.status, Error: "Invalid archive"}) + r.Element.Height(280).FillWidth() + if r.Opened != nil { + log("Open archive entry: " + r.Opened.Path) + } + if r.Retried { + s.status = mujica.DataReady + log("Archive read retried") + } +} diff --git a/examples/gallery/pages_files_code.go b/examples/gallery/pages_files_code.go new file mode 100644 index 0000000..7b1c108 --- /dev/null +++ b/examples/gallery/pages_files_code.go @@ -0,0 +1,137 @@ +package main + +const filesFileExplorerCode = `var state mujica.FileExplorerState +opts := mujica.FileExplorerOptions{ + Roots: []string{"/court"}, + Folders: func(string) []string { return nil }, + Entries: func(string) []mujica.FileEntry { + return []mujica.FileEntry{{Path: "/court/seal.go", Name: "seal.go", Size: 1024}} + }, +} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.FileExplorer(c, &state, opts).Element.Height(240) +} +ui.Render(view, 600, 300, 1)` + +const filesFileGridCode = `entries := []mujica.FileEntry{{Path: "/court/seal.go", Name: "seal.go"}} +var state mujica.FileGridState +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.FileGrid(c, entries, &state, mujica.FileGridOptions{}).Element.Height(240) +} +ui.Render(view, 400, 300, 1)` + +const filesFileIconCode = `view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.FileIcon(c, "seal.go", mujica.FileIconOptions{Size: 32}) +} +ui.Render(view, 100, 80, 1)` + +const filesFilePreviewCode = `file := mujica.PreviewFile{Name: "seal.go", Text: "package court\nfunc Seal() {}"} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.FilePreview(c, file, mujica.FilePreviewOptions{}).Element.Height(240) +} +ui.Render(view, 500, 300, 1)` + +const filesPathBarCode = `view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.PathBar(c, "/court/chronicles/seals", mujica.PathBarOptions{}) +} +ui.Render(view, 400, 100, 1)` + +const filesRenameInlineCode = `name, editing := "seal.txt", true +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.RenameInline(c, &name, &editing, mujica.RenameInlineOptions{}) +} +ui.Render(view, 400, 100, 1)` + +const filesFileOperationProgressCode = `op := mujica.FileOperation{Kind: mujica.FileCopy, Done: 2, Total: 10, Current: "seal.go"} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.FileOperationProgress(c, op, mujica.FileOperationOptions{}) +} +ui.Render(view, 500, 240, 1)` + +const filesTransferQueueCode = `items := []mujica.QueuedTransfer{{ID: "a", Name: "ledger.zip", Upload: true, Status: mujica.TransferActive, Done: 25, Total: 100}} +var state mujica.TransferQueueState +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.TransferQueue(c, items, &state, mujica.TransferQueueOptions{}).Element.Height(240) +} +ui.Render(view, 500, 300, 1)` + +const filesStorageUsageCode = `segments := []mujica.StorageSegment{{Label: "Documents", Bytes: 24 << 30}, {Label: "Photos", Bytes: 40 << 30}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.StorageUsage(c, segments, mujica.StorageUsageOptions{Capacity: 128 << 30, Label: "Court disk"}) +} +ui.Render(view, 500, 240, 1)` + +const filesRecentFilesCode = `now := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC) +files := []mujica.RecentFile{{Path: "/court/seal.go", Opened: now}} +var state mujica.RecentFilesState +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.RecentFiles(c, files, &state, mujica.RecentFilesOptions{Now: now}).Element.Height(240) +} +ui.Render(view, 500, 300, 1)` + +const filesArchiveViewerCode = `entries := []mujica.ArchiveEntry{{Path: "court/seal.go", Size: 4000, Packed: 1000}} +var state mujica.ArchiveViewerState +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ArchiveViewer(c, entries, &state, mujica.ArchiveViewerOptions{}).Element.Height(240) +} +ui.Render(view, 600, 300, 1)` + +const filesPresenceAvatarsCode = `people := []mujica.Collaborator{{ID: "ada", Name: "Ada Lovelace"}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.PresenceAvatars(c, people, mujica.PresenceAvatarsOptions{}) +} +ui.Render(view, 300, 100, 1)` + +const filesReactionsCode = `items := []mujica.Reaction{{Emoji: "\U0001f44d", Count: 3, Mine: true}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.Reactions(c, items, mujica.ReactionsOptions{Choices: []string{"\U0001f44d", "\U0001f389"}}) +} +ui.Render(view, 400, 100, 1)` + +const filesShareDialogCode = `open := false +members := []mujica.ShareMember{{ID: "ada", Name: "Ada", Permission: mujica.PermissionEdit, Owner: true}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.Button(c, "Share", mujica.ButtonOptions{}).Clicked() { + open = true + } + mujica.ShareDialog(c, &open, &members, mujica.ShareDialogOptions{Title: "Ledger", Link: "https://court.org/ledger"}) +} +ui.Render(view, 600, 400, 1)` + +const filesPermissionSelectCode = `value := mujica.PermissionView +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.PermissionSelect(c, &value, mujica.PermissionSelectOptions{}) +} +ui.Render(view, 300, 100, 1)` + +const filesLiveIndicatorCode = `view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.LiveIndicator(c, mujica.LiveOffline, mujica.LiveIndicatorOptions{}) +} +ui.Render(view, 500, 100, 1)` + +const filesRemoteCursorCode = `carets := []mujica.RemoteCaret{{ID: "ada", Name: "Ada", Offset: 4, Anchor: 4}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + ui.Box(c).Size(300, 100).Children(func() { + mujica.RemoteCursor(c, carets, mujica.RemoteCursorOptions{ + Caret: func(offset int) ui.Rect { return ui.Rect{X: float32(offset) * 8, Y: 20, H: 20} }, + }) + }) +} +ui.Render(view, 320, 120, 1)` diff --git a/examples/gallery/pages_files_collaboration.go b/examples/gallery/pages_files_collaboration.go new file mode 100644 index 0000000..07df5a7 --- /dev/null +++ b/examples/gallery/pages_files_collaboration.go @@ -0,0 +1,195 @@ +package main + +import ( + "fmt" + "time" + + "github.com/egoist/mygo/ui" + mujica "mujicaui" + "mujicaui/theme" +) + +var filesPeople = []mujica.Collaborator{ + {ID: "a", Name: "Ada Lovelace"}, {ID: "b", Name: "Blaise Pascal", Away: true}, + {ID: "c", Name: "Carl Gauss"}, {ID: "d", Name: "Dora Maar"}, {ID: "e", Name: "Emmy Noether"}, +} + +func presenceAvatarsDemo(c *ui.Context, log func(string)) { + type state struct { + following string + disabled, empty bool + max float64 + } + s := ui.Local(c.Root(), "392", func() state { return state{max: 3} }) + mediaCheck(c, log, &s.disabled, "Disabled") + mediaCheck(c, log, &s.empty, "Empty") + if mujica.NumberInput(c, &s.max, mujica.NumberInputOptions{Min: 1, Max: 5, Step: 1, Label: "Visible avatars"}).Changed() { + log(fmt.Sprintf("Visible: %.0f", s.max)) + } + people := filesPeople + if s.empty { + people = nil + } + r := mujica.PresenceAvatars(c, people, mujica.PresenceAvatarsOptions{Following: s.following, Max: int(s.max), Disabled: s.disabled}) + if r.Follow != "" { + s.following = r.Follow + log("Follow: " + r.Follow) + } + if r.Unfollow { + s.following = "" + log("Following stopped") + } +} + +var filesReactionChoices = []string{"\U0001f44d", "\u2764\ufe0f", "\U0001f389", "\U0001f440", "\U0001f64f"} + +func reactionsDemo(c *ui.Context, log func(string)) { + type state struct { + items []mujica.Reaction + disabled bool + } + s := ui.Local(c.Root(), "394", func() state { + return state{items: []mujica.Reaction{{Emoji: filesReactionChoices[0], Count: 3, Mine: true}, {Emoji: filesReactionChoices[2], Count: 1}}} + }) + mediaCheck(c, log, &s.disabled, "Disabled") + if mujica.Button(c, "Clear reactions", mujica.ButtonOptions{}).Clicked() { + s.items = nil + log("Reactions cleared") + } + r := mujica.Reactions(c, s.items, mujica.ReactionsOptions{Choices: filesReactionChoices, Disabled: s.disabled}) + emoji := r.Toggled + if emoji == "" { + emoji = r.Added + } + if emoji == "" { + return + } + for i := range s.items { + if s.items[i].Emoji == emoji { + if s.items[i].Mine { + s.items[i].Mine = false + s.items[i].Count-- + } else { + s.items[i].Mine = true + s.items[i].Count++ + } + if s.items[i].Count == 0 { + s.items = append(s.items[:i:i], s.items[i+1:]...) + } + log("Reaction toggled") + return + } + } + s.items = append(s.items, mujica.Reaction{Emoji: emoji, Count: 1, Mine: true}) + log("Reaction added") +} + +func shareDialogDemo(c *ui.Context, log func(string)) { + type state struct { + members []mujica.ShareMember + open, disabled bool + next int + } + s := ui.Local(c.Root(), "395", func() state { + return state{members: []mujica.ShareMember{ + {ID: "owner", Name: "Ada Lovelace", Email: "ada@court.org", Owner: true, Permission: mujica.PermissionEdit}, + {ID: "b", Name: "Blaise Pascal", Email: "blaise@court.org", Permission: mujica.PermissionView}, + }} + }) + mediaCheck(c, log, &s.disabled, "Disabled") + if mujica.Button(c, "Share Ledger", mujica.ButtonOptions{Disabled: s.disabled}).Clicked() { + s.open = true + log("Share dialog opened") + } + was := s.open + r := mujica.ShareDialog(c, &s.open, &s.members, mujica.ShareDialogOptions{Title: "Ledger", Link: "https://court.org/ledger", Disabled: s.disabled, OnCopy: func(link string) { c.WriteClipboard(link); log("Copy callback") }}) + if r.Invited != nil { + s.next++ + s.members = append(s.members, mujica.ShareMember{ID: fmt.Sprintf("invite-%d", s.next), Name: r.Invited.Email, Email: r.Invited.Email, Permission: r.Invited.Permission}) + log("Member invited") + } + if r.Changed != "" { + log("Permission changed: " + r.Changed) + } + if r.Removed != "" { + log("Member removed: " + r.Removed) + } + if r.Copied { + log("Link copied") + } + if was && !s.open { + log("Share dialog closed") + } +} + +func permissionSelectDemo(c *ui.Context, log func(string)) { + type state struct { + value mujica.Permission + disabled bool + } + s := ui.Local(c.Root(), "396", func() state { return state{} }) + mediaCheck(c, log, &s.disabled, "Disabled") + if mujica.PermissionSelect(c, &s.value, mujica.PermissionSelectOptions{Disabled: s.disabled}).Changed() { + log(fmt.Sprintf("Permission: %d", s.value)) + } +} + +func liveIndicatorDemo(c *ui.Context, log func(string)) { + type state struct { + status mujica.LiveStatus + showTime bool + } + s := ui.Local(c.Root(), "399", func() state { return state{showTime: true} }) + if mujica.Select(c, &s.status, []mujica.SelectOption[mujica.LiveStatus]{{Value: mujica.LiveSynced, Label: "Synced"}, {Value: mujica.LiveSyncing, Label: "Syncing"}, {Value: mujica.LiveReconnecting, Label: "Reconnecting"}, {Value: mujica.LiveOffline, Label: "Offline"}, {Value: mujica.LiveError, Label: "Error"}}, mujica.SelectOptions{Label: "Sync status"}).Changed() { + log(fmt.Sprintf("Sync status: %d", s.status)) + } + mediaCheck(c, log, &s.showTime, "Last sync time") + now := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC) + opts := mujica.LiveIndicatorOptions{Error: "Server unavailable", Now: now} + if s.showTime { + opts.LastSynced = now.Add(-3 * time.Minute) + } + if mujica.LiveIndicator(c, s.status, opts).Changed() { + s.status = mujica.LiveReconnecting + log("Sync retried") + } +} + +func remoteCursorDemo(c *ui.Context, log func(string)) { + type state struct { + offset float64 + selection, second bool + } + s := ui.Local(c.Root(), "400", func() state { return state{offset: 48, selection: true, second: true} }) + if mujica.NumberInput(c, &s.offset, mujica.NumberInputOptions{Min: 0, Max: 79, Step: 1, Label: "Remote text offset"}).Changed() { + log(fmt.Sprintf("Offset: %.0f", s.offset)) + } + mediaCheck(c, log, &s.selection, "Selection") + mediaCheck(c, log, &s.second, "Second collaborator") + carets := []mujica.RemoteCaret{{ID: "ada", Name: "Ada", Offset: int(s.offset), Anchor: int(s.offset)}} + if s.selection { + carets[0].Anchor = max(0, carets[0].Offset-5) + } + if s.second { + carets = append(carets, mujica.RemoteCaret{ID: "blaise", Name: "Blaise", Offset: 8, Anchor: 8}) + } + ui.Box(c).Height(140).FillWidth().Background(mujica.Tokens(c).Surface).Children(func() { + ui.Column(c).Children(func() { + for _, line := range []string{"The seal of the court is kept", "by the chancellor, who answers"} { + ui.Text(c, line).Font(theme.MonoFont).FontSize(mujica.FontSize(c, 13)).Height(20) + } + }) + at := func(o int) (float32, float32) { return float32(o%40) * 8, float32(o/40) * 20 } + mujica.RemoteCursor(c, carets, mujica.RemoteCursorOptions{ + Caret: func(o int) ui.Rect { x, y := at(o); return ui.Rect{X: x, Y: y, H: 20} }, + Selection: func(from, to int) []ui.Rect { + var out []ui.Rect + for o := from; o < to; o++ { + x, y := at(o) + out = append(out, ui.Rect{X: x, Y: y, W: 8, H: 20}) + } + return out + }, + }) + }) +} diff --git a/examples/gallery/pages_files_test.go b/examples/gallery/pages_files_test.go new file mode 100644 index 0000000..7d1b593 --- /dev/null +++ b/examples/gallery/pages_files_test.go @@ -0,0 +1,116 @@ +package main + +import ( + "fmt" + "strings" + "testing" + + "github.com/egoist/mygo/ui" + mujica "mujicaui" +) + +func filesDemoTester(t *testing.T, id string, mode mujica.Mode) (*ui.Tester, *[]string) { + t.Helper() + for _, p := range filesPages { + if p.ID == id { + events := &[]string{} + tt := ui.NewTester(func(c *ui.Context) { + mujica.Use(c, mujica.Settings{Mode: mode}) + ui.Scroll(c).Fill().Padding(24).Gap(12).Children(func() { p.Demo(c, func(s string) { *events = append(*events, s) }) }) + }, 900, 800) + tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1}) + tt.Frame() + return tt, events + } + } + t.Fatalf("missing files page %s", id) + return nil, nil +} + +func TestFilesGalleryExistingSet(t *testing.T) { + want := []string{"381", "382", "383", "384", "385", "386", "387", "388", "389", "390", "391", "392", "394", "395", "396", "399", "400"} + if len(filesPages) != len(want) { + t.Fatalf("files pages %d, want %d", len(filesPages), len(want)) + } + for i, p := range filesPages { + if p.ID != want[i] || p.Category != "Files & Collaboration" || p.Code == "" || p.Keys == "" { + t.Fatalf("page %s has incorrect metadata", p.ID) + } + } +} + +func TestFilesGalleryActions(t *testing.T) { + cases := []struct{ id, action, event string }{ + {"381", "Disabled", "Disabled = true"}, + {"382", "Empty", "Empty = true"}, + {"383", "Folder", "Folder = true"}, + {"384", "Close preview", "Preview closed"}, + {"385", "court", "Navigate: /court"}, + {"386", "Rename", "Rename started"}, + {"387", "Cancel", "Operation canceled"}, + {"388", "Pause ledger.zip", "Transfer a: 1"}, + {"390", "Clear", "Recent files cleared"}, + {"391", "Empty", "Empty = true"}, + {"392", "Follow Ada Lovelace", "Follow: a"}, + {"394", "Clear reactions", "Reactions cleared"}, + {"395", "Share Ledger", "Share dialog opened"}, + {"396", "Disabled", "Disabled = true"}, + {"399", "Last sync time", "Last sync time = false"}, + {"400", "Selection", "Selection = false"}, + } + for _, tc := range cases { + for _, mode := range []mujica.Mode{mujica.Light, mujica.Dark} { + t.Run(fmt.Sprintf("%s-%s", tc.id, mode), func(t *testing.T) { + tt, events := filesDemoTester(t, tc.id, mode) + if err := tt.Click(tc.action); err != nil { + t.Fatal(err) + } + found := false + for _, event := range *events { + if event == tc.event { + found = true + } + } + if !found { + t.Fatalf("events %v, want %s", *events, tc.event) + } + if tc.id == "395" { + if err := tt.Click("Copy link"); err != nil { + t.Fatal(err) + } + if tt.Clipboard() != "https://court.org/ledger" { + t.Fatal("share callback did not copy link") + } + } + }) + } + } +} + +func TestFilesGalleryStorageParameter(t *testing.T) { + tt, events := filesDemoTester(t, "389", mujica.Light) + if err := tt.Click("Photos (GB)"); err != nil { + t.Fatal(err) + } + tt.Key(ui.Cmd, ui.KeyA) + tt.Type("41") + tt.Key(0, ui.KeyEnter) + if !tt.HasText("77.0 GB of 128.0 GB used") || len(*events) == 0 { + t.Fatalf("storage parameter left %q; events %v", tt.Texts(), *events) + } +} + +func TestFilesGalleryGridRange(t *testing.T) { + tt, events := filesDemoTester(t, "382", mujica.Light) + if err := tt.Click("notes.md"); err != nil { + t.Fatal(err) + } + tt.Frame() + if err := tt.ClickWith(ui.Shift, "crest.png"); err != nil { + t.Fatal(err) + } + last := (*events)[len(*events)-1] + if strings.Contains(last, "/court/chronicles") || !strings.Contains(last, "/court/notes.md") || !strings.Contains(last, "/court/decrees/crest.png") { + t.Fatalf("range selection event %q", last) + } +} diff --git a/file_explorer.go b/file_explorer.go new file mode 100644 index 0000000..4910f89 --- /dev/null +++ b/file_explorer.go @@ -0,0 +1,254 @@ +package mujica + +import ( + "log/slog" + "path" + "runtime" + "strings" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// FileEntry identifies a file or folder by slash-separated Path. +type FileEntry struct { + Path, Name string + Dir bool + Size int64 + Modified time.Time +} + +// FileExplorerOptions configure a FileExplorer. +type FileExplorerOptions struct { + // Roots are the top folders of the tree. + Roots []string + // Folders returns subfolders; required, nil means a leaf. + Folders func(dir string) []string + // Entries returns a folder's files and folders; required. + Entries func(dir string) []FileEntry + Disabled bool + // Revision changes when entries are edited in place. + Revision uint64 +} + +// FileExplorerState keeps navigation, selection and scroll position. +type FileExplorerState struct { + // Current defaults to the first root. + Current string + tree TreeState[string] + list ListState[string] + back, forward []string + data *FileEntry + n int + revision uint64 +} + +// Selected returns the chosen entries' paths. +func (s *FileExplorerState) Selected() []string { + var out []string + for p := range s.list.Selection.All() { + out = append(out, p) + } + return out +} + +// Navigate records the current folder and shows dir. +func (s *FileExplorerState) Navigate(dir string) { + if dir == s.Current { + return + } + if s.Current != "" { + s.back = append(s.back, s.Current) + } + s.Current, s.forward = dir, nil + s.list.Selection.Clear() + slog.Debug("mujica: file_explorer navigate", "depth", strings.Count(dir, "/")) +} + +func (s *FileExplorerState) step(from, to *[]string) { + n := len(*from) + if n == 0 { + return + } + *to = append(*to, s.Current) + s.Current, *from = (*from)[n-1], (*from)[:n-1] + s.list.Selection.Clear() +} + +// FileExplorerResult is a FileExplorer and what happened in this frame. +type FileExplorerResult struct { + Element *ui.Element + // Navigated reports a new current folder; Changed a new selection. + Navigated, Changed bool + // Opened is a file the user opened. + Opened *FileEntry +} + +// FileExplorer combines folder navigation, history and file selection. +func FileExplorer(c *ui.Context, s *FileExplorerState, opts FileExplorerOptions) FileExplorerResult { + if s == nil || opts.Folders == nil || opts.Entries == nil || len(opts.Roots) == 0 { + panic("mujica: FileExplorer needs a state, Roots, Folders and Entries") + } + if s.Current == "" { + s.Current = opts.Roots[0] + } + k := Tokens(c) + u := Density(c).Unit() + var res FileExplorerResult + res.Element = ui.Column(c).MinHeight(0).Gap(u * 2).Disabled(opts.Disabled) + disabled := res.Element.IsDisabled() + if disabled { + copy := *s + copy.list.Selection = filesSelectionCopy(s.list.Selection) + copy.tree.Selection = filesSelectionCopy(s.tree.Selection) + copy.tree.tree.outline.Open = filesSelectionCopy(s.tree.tree.outline.Open) + s = © + } + at := s.Current + move := func(fn func()) { + if !disabled { + fn() + } + } + mac := runtime.GOOS == "darwin" + switch { + case mac && res.Element.Shortcut(ui.Super, ui.KeyBracketLeft), !mac && res.Element.Shortcut(ui.Alt, ui.KeyLeft): + move(func() { s.step(&s.back, &s.forward) }) + case mac && res.Element.Shortcut(ui.Super, ui.KeyBracketRight), !mac && res.Element.Shortcut(ui.Alt, ui.KeyRight): + move(func() { s.step(&s.forward, &s.back) }) + case mac && res.Element.Shortcut(ui.Super, ui.KeyUp), !mac && res.Element.Shortcut(ui.Alt, ui.KeyUp): + move(func() { s.Navigate(fileExplorerParent(s.Current, opts.Roots)) }) + } + res.Element.Children(func() { + ui.Row(c).Gap(u).AlignItems(ui.Center).Shrink(0).Children(func() { + for _, b := range []struct { + icon, key string + on bool + do func() + }{ + {"chevron-left", "file_explorer.back", len(s.back) > 0, func() { s.step(&s.back, &s.forward) }}, + {"chevron-right", "file_explorer.forward", len(s.forward) > 0, func() { s.step(&s.forward, &s.back) }}, + {"arrow-up", "file_explorer.up", fileExplorerParent(s.Current, opts.Roots) != s.Current, func() { s.Navigate(fileExplorerParent(s.Current, opts.Roots)) }}, + } { + a := iconAction(c, icons.Must(b.icon), msg(c, b.key)).Disabled(!b.on) + if a.Clicked() && b.on { + move(b.do) + } + } + if r := PathBar(c, s.Current, PathBarOptions{Disabled: opts.Disabled}); r.Navigated != "" { + move(func() { s.Navigate(r.Navigated) }) + } + }) + ui.Row(c).Grow(1).MinHeight(Density(c).ControlHeight() * 4).Gap(u * 2).AlignItems(ui.Stretch).Children(func() { + fileExplorerTree(c, s, opts, move) + ui.Column(c).Grow(1).MinWidth(0).Children(func() { + entries := opts.Entries(s.Current) + var first *FileEntry + if len(entries) > 0 { + first = &entries[0] + } + if s.data != first || s.n != len(entries) || s.revision != opts.Revision { + valid := make(map[string]bool, len(entries)) + for _, entry := range entries { + if entry.Path == "" || valid[entry.Path] { + panic("mujica: FileExplorer needs unique entry paths") + } + valid[entry.Path] = true + } + for key := range s.list.Selection.All() { + if !valid[key] { + s.list.Selection.Remove(key) + } + } + s.data, s.n, s.revision = first, len(entries), opts.Revision + } + v := List(c, &s.list, len(entries), ListOptions[string]{Multiple: true, + Key: func(i int) string { return entries[i].Path }, Label: func(i int) string { return entries[i].Name }, + Empty: msg(c, "file_explorer.empty")}, func(i int) { fileExplorerRow(c, k, entries[i]) }) + v.Element.Grow(1) + res.Changed = v.Changed() && !disabled + if key, ok := s.list.Selected(); ok && v.Submitted() && !disabled { + for _, e := range entries { + if e.Path != key { + continue + } + if e.Dir { + s.Navigate(e.Path) + } else { + open := e + res.Opened = &open + slog.Debug("mujica: file_explorer open") + } + } + } + }) + }) + }) + res.Navigated = s.Current != at + return res +} + +func filesSelectionCopy(src ui.Selection[string]) ui.Selection[string] { + var dst ui.Selection[string] + for key := range src.All() { + dst.Add(key) + } + return dst +} + +// fileExplorerTree shows the folders, choosing the current one. +func fileExplorerTree(c *ui.Context, s *FileExplorerState, opts FileExplorerOptions, move func(func())) { + for _, a := range fileExplorerAncestors(s.Current, opts.Roots) { + s.tree.SetOpen(a, true) + } + s.tree.tree.sel, s.tree.tree.has = s.Current, true + v := Tree(c, &s.tree, TreeOptions[string]{Roots: opts.Roots, Children: opts.Folders, + Label: func(p string) string { return path.Base(p) }}) + v.Element.Width(220).Shrink(0) + if v.Changed() { + if dir, ok := s.tree.Selected(); ok && dir != s.Current { + move(func() { s.Navigate(dir) }) + } + } +} + +// fileExplorerAncestors lists the folders above dir, inside its root. +func fileExplorerAncestors(dir string, roots []string) []string { + var out []string + for p := dir; ; { + parent := fileExplorerParent(p, roots) + if parent == p { + return out + } + out = append(out, parent) + p = parent + } +} + +// fileExplorerParent is dir's parent, or dir itself at a root. +func fileExplorerParent(dir string, roots []string) string { + for _, r := range roots { + if dir == r { + return dir + } + } + p := path.Dir(dir) + if p == "." || p == dir { + return dir + } + return p +} + +func fileExplorerRow(c *ui.Context, k theme.Tokens, e FileEntry) { + FileIcon(c, e.Name, FileIconOptions{Folder: e.Dir}) + ui.Text(c, e.Name).SingleLine().Grow(1).MinWidth(0) + if !e.Dir { + ui.Text(c, processListBytes(uint64(max(e.Size, 0)))).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize)).Shrink(0) + } + if !e.Modified.IsZero() { + ui.Text(c, e.Modified.Format("2006-01-02 15:04")).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize)).Shrink(0) + } +} diff --git a/file_explorer_test.go b/file_explorer_test.go new file mode 100644 index 0000000..5fb6f2b --- /dev/null +++ b/file_explorer_test.go @@ -0,0 +1,228 @@ +package mujica + +import ( + "fmt" + "path" + "runtime" + "strings" + "testing" + + "github.com/egoist/mygo/ui" +) + +// fileFS maps folders to entry names, with trailing directory slashes. +var fileFS = map[string][]string{ + "/court": {"chronicles/", "decrees/", "seal.go", "notes.md"}, + "/court/chronicles": {"first.md", "second.md"}, + "/court/decrees": {"old/", "42.pdf"}, + "/court/decrees/old": {"7.pdf"}, +} + +func fileFSOptions() FileExplorerOptions { + return FileExplorerOptions{ + Roots: []string{"/court"}, + Folders: func(dir string) []string { + var out []string + for _, n := range fileFS[dir] { + if strings.HasSuffix(n, "/") { + out = append(out, path.Join(dir, strings.TrimSuffix(n, "/"))) + } + } + return out + }, + Entries: func(dir string) []FileEntry { + var out []FileEntry + for _, n := range fileFS[dir] { + name := strings.TrimSuffix(n, "/") + out = append(out, FileEntry{Path: path.Join(dir, name), Name: name, Dir: name != n, Size: 2048}) + } + return out + }, + } +} + +type explorerProbe struct { + s FileExplorerState + opts FileExplorerOptions + nav int + opened []string +} + +func (p *explorerProbe) view(c *ui.Context) { + Use(c, Settings{}) + r := FileExplorer(c, &p.s, p.opts) + r.Element.Height(380) + if r.Navigated { + p.nav++ + } + if r.Opened != nil { + p.opened = append(p.opened, r.Opened.Path) + } +} + +func TestFileExplorerOpenFolderAndHistory(t *testing.T) { + p := &explorerProbe{opts: fileFSOptions()} + tt := ui.NewTester(p.view, 720, 400) + if p.s.Current != "/court" || !tt.HasText("seal.go") { + t.Fatalf("start %q", p.s.Current) + } + rows := 0 + for _, s := range tt.Texts() { + if s == "decrees" { + rows++ + } + } + if rows != 2 { + t.Fatalf("decrees shows %d times, want tree and list", rows) + } + // The list's decrees row is the one above seal.go. + seal, _ := tt.Find("seal.go") + tt.ClickAt(seal.X, seal.Y+seal.H/2-36) + tt.ClickAt(seal.X, seal.Y+seal.H/2-36) + if p.s.Current != "/court/decrees" || !tt.HasText("42.pdf") || p.nav != 1 { + t.Fatalf("open folder: current %q nav %d", p.s.Current, p.nav) + } + if err := tt.Click("Back"); err != nil { + t.Fatal(err) + } + if p.s.Current != "/court" { + t.Fatalf("back to %q", p.s.Current) + } + _ = tt.Click("Forward") + if p.s.Current != "/court/decrees" { + t.Fatalf("forward to %q", p.s.Current) + } + _ = tt.Click("Enclosing folder") + if p.s.Current != "/court" { + t.Fatalf("up to %q", p.s.Current) + } +} + +func TestFileExplorerTreeAndOpenFile(t *testing.T) { + p := &explorerProbe{opts: fileFSOptions()} + tt := ui.NewTester(p.view, 720, 400) + p.s.Navigate("/court/decrees/old") + tt.Frame() + if !tt.HasText("old") || !tt.HasText("7.pdf") { + t.Fatalf("tree did not open the ancestors: %q", tt.Texts()) + } + if sel, _ := p.s.tree.Selected(); sel != "/court/decrees/old" { + t.Fatalf("tree chose %q", sel) + } + r, _ := tt.Find("7.pdf") + tt.ClickAt(r.X, r.Y+r.H/2) + tt.ClickAt(r.X, r.Y+r.H/2) + if len(p.opened) != 1 || p.opened[0] != "/court/decrees/old/7.pdf" { + t.Fatalf("opened %v", p.opened) + } + if got := p.s.Selected(); len(got) != 1 { + t.Fatalf("selection %v", got) + } +} + +func TestFileExplorerShortcutsAndDisabled(t *testing.T) { + p := &explorerProbe{opts: fileFSOptions()} + tt := ui.NewTester(p.view, 720, 400) + p.s.Navigate("/court/chronicles") + tt.Frame() + tt.Key(0, ui.KeyTab) + back, up := ui.Modifiers(ui.Alt), ui.Modifiers(ui.Alt) + bk, uk := ui.KeyLeft, ui.KeyUp + if runtime.GOOS == "darwin" { + back, up, bk = ui.Super, ui.Super, ui.KeyBracketLeft + } + tt.Key(back, bk) + if p.s.Current != "/court" { + t.Fatalf("back shortcut gave %q", p.s.Current) + } + p.s.Navigate("/court/decrees/old") + tt.Frame() + tt.Key(up, uk) + if p.s.Current != "/court/decrees" { + t.Fatalf("up shortcut gave %q", p.s.Current) + } + p.opts.Disabled = true + tt.Frame() + _ = tt.Click("Back") + if p.s.Current != "/court/decrees" { + t.Fatal("disabled explorer moved") + } +} + +func TestFileExplorerSnapshot(t *testing.T) { + snapshot(t, "file_explorer", 640, 300, func(c *ui.Context) { + p := ui.Local(c.Root(), "snap", func() *explorerProbe { + p := &explorerProbe{opts: fileFSOptions()} + p.s.Navigate("/court/decrees") + return p + }) + ui.Column(c).Padding(12).Children(func() { + FileExplorer(c, &(*p).s, (*p).opts).Element.Height(276) + }) + }) +} + +func TestFileExplorerDisablingBeforeDeliveredKey(t *testing.T) { + p := &explorerProbe{opts: fileFSOptions()} + tt := ui.NewTester(p.view, 720, 400) + if err := tt.Click("seal.go"); err != nil { + t.Fatal(err) + } + before := p.s.Selected() + p.opts.Disabled = true + tt.Key(0, ui.KeyDown) + after := p.s.Selected() + if len(after) != len(before) || after[0] != before[0] { + t.Fatalf("disabled selection %v, want %v", after, before) + } +} + +func TestFileExplorerPrunesRemovedSelection(t *testing.T) { + entries := []FileEntry{{Path: "/court/a.txt", Name: "a.txt"}, {Path: "/court/b.txt", Name: "b.txt"}} + var s FileExplorerState + opts := FileExplorerOptions{Roots: []string{"/court"}, Folders: func(string) []string { return nil }, Entries: func(string) []FileEntry { return entries }} + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + FileExplorer(c, &s, opts).Element.Height(380) + }, 720, 400) + if err := tt.Click("b.txt"); err != nil { + t.Fatal(err) + } + entries = entries[:1] + tt.Frame() + if got := s.Selected(); len(got) != 0 { + t.Fatalf("removed selection %v", got) + } +} + +func TestFileExplorerLargeAndRevision(t *testing.T) { + entries := make([]FileEntry, 100000) + for i := range entries { + name := fmt.Sprintf("f-%d.txt", i) + entries[i] = FileEntry{Path: "/court/" + name, Name: name} + } + var s FileExplorerState + opts := FileExplorerOptions{Roots: []string{"/court"}, Folders: func(string) []string { return nil }, Entries: func(string) []FileEntry { return entries }} + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + FileExplorer(c, &s, opts).Element.Height(380) + }, 720, 400) + n := 0 + for _, text := range tt.Texts() { + if strings.HasPrefix(text, "f-") { + n++ + } + } + if n == 0 || n > 60 { + t.Fatalf("built %d file labels", n) + } + if err := tt.Click("f-0.txt"); err != nil { + t.Fatal(err) + } + entries[0].Path = "/court/replaced.txt" + opts.Revision++ + tt.Frame() + if got := s.Selected(); len(got) != 0 { + t.Fatalf("replaced selection %v", got) + } +} diff --git a/file_grid.go b/file_grid.go new file mode 100644 index 0000000..46217c8 --- /dev/null +++ b/file_grid.go @@ -0,0 +1,129 @@ +package mujica + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// FileGridOptions configure a FileGrid. +type FileGridOptions struct { + // Revision changes when entries are edited in place. + Revision uint64 + // Thumbnail builds a preview; nil or false shows FileIcon. + Thumbnail func(e FileEntry) bool + // ItemWidth defaults to 128 DIP, minimum 28. + ItemWidth float32 + Disabled bool +} + +// FileGridResult is a FileGrid and what happened in this frame. +type FileGridResult struct { + Element *ui.Element + // Changed reports a new selection; Opened is the entry opened. + Changed bool + Opened *FileEntry +} + +// FileGridState is a FileGrid's selection and place. +type FileGridState struct { + list ListState[string] + data *FileEntry + n int + revision uint64 + paths []string +} + +// Selected returns the chosen entries' paths. +func (s *FileGridState) Selected() []string { + var out []string + for p := range s.list.Selection.All() { + out = append(out, p) + } + return out +} + +// FileGrid virtualizes thumbnails, file selection and opening. +func FileGrid(c *ui.Context, entries []FileEntry, s *FileGridState, opts FileGridOptions) FileGridResult { + if s == nil { + panic("mujica: FileGrid needs a state") + } + if opts.ItemWidth < 0 { + panic("mujica: FileGrid ItemWidth is negative") + } + w := opts.ItemWidth + if w == 0 { + w = 128 + } + k := Tokens(c) + u := Density(c).Unit() + if w < theme.MinHitSize { + panic("mujica: FileGrid ItemWidth is below the minimum hit size") + } + thumb := max(0, w-4*u) + var res FileGridResult + res.Element = ui.Column(c).MinHeight(0).Disabled(opts.Disabled) + disabled := res.Element.IsDisabled() + if disabled { + copy := *s + copy.list.Selection = filesSelectionCopy(s.list.Selection) + s = © + } + var first *FileEntry + if len(entries) > 0 { + first = &entries[0] + } + if s.data != first || s.n != len(entries) || s.revision != opts.Revision { + valid := make(map[string]bool, len(entries)) + moved := len(s.paths) != len(entries) + for i, entry := range entries { + if entry.Path == "" || valid[entry.Path] { + panic("mujica: FileGrid needs unique entry paths") + } + valid[entry.Path] = true + if i >= len(s.paths) || s.paths[i] != entry.Path { + moved = true + } + } + for key := range s.list.Selection.All() { + if !valid[key] { + s.list.Selection.Remove(key) + } + } + if moved { + s.list.grid = ui.GridState{} + s.paths = make([]string, len(entries)) + for i, entry := range entries { + s.paths[i] = entry.Path + } + } + s.data, s.n, s.revision = first, len(entries), opts.Revision + } + res.Element.Children(func() { + v := List(c, &s.list, len(entries), ListOptions[string]{Multiple: true, Grid: true, ItemWidth: w, ItemHeight: thumb*0.75 + 56, + Key: func(i int) string { return entries[i].Path }, Label: func(i int) string { return entries[i].Name }, + Empty: msg(c, "file_grid.empty")}, func(i int) { + e := entries[i] + ui.Box(c).Height(thumb * 0.75).Shrink(0).Center().Clip().Radius(theme.ControlRadius).Background(k.Background).Children(func() { + if opts.Thumbnail == nil || !opts.Thumbnail(e) { + FileIcon(c, e.Name, FileIconOptions{Folder: e.Dir, Size: 40}) + } + }) + ui.Text(c, e.Name).SingleLine().TextAlign(ui.Center).FontSize(FontSize(c, theme.CaptionSize)) + }) + v.Element.Grow(1) + res.Changed = v.Changed() && !disabled + if key, ok := s.list.Selected(); ok && v.Submitted() && !res.Element.IsDisabled() { + for _, e := range entries { + if e.Path == key { + open := e + res.Opened = &open + slog.Debug("mujica: file_grid open", "dir", e.Dir) + } + } + } + }) + return res +} diff --git a/file_grid_test.go b/file_grid_test.go new file mode 100644 index 0000000..869ab03 --- /dev/null +++ b/file_grid_test.go @@ -0,0 +1,172 @@ +package mujica + +import ( + "fmt" + "slices" + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestFileGridFreshSliceKeepsRangeAndScroll(t *testing.T) { + entries := fileGridEntries(6) + var s FileGridState + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + FileGrid(c, slices.Clone(entries), &s, FileGridOptions{}).Element.Height(320) + }, 600, 340) + if err := tt.Click("crest-3.png"); err != nil { + t.Fatal(err) + } + tt.Frame() + if err := tt.ClickWith(ui.Shift, "crest-5.png"); err != nil { + t.Fatal(err) + } + if got := s.Selected(); len(got) != 3 { + t.Fatalf("range selection %v", got) + } + entries = fileGridEntries(100000) + tt.Frame() + tt.Scroll(300, 200, 0, 10000) + tt.Frame() + if tt.HasText("sketches") { + t.Fatal("unchanged entries reset the scroll position") + } +} + +func fileGridEntries(n int) []FileEntry { + out := make([]FileEntry, n) + for i := range out { + name := fmt.Sprintf("crest-%d.png", i) + out[i] = FileEntry{Path: "/art/" + name, Name: name} + } + out[0] = FileEntry{Path: "/art/sketches", Name: "sketches", Dir: true} + return out +} + +type gridProbe struct { + entries []FileEntry + s FileGridState + disabled bool + thumbs int + opened []string +} + +func (p *gridProbe) view(c *ui.Context) { + Use(c, Settings{}) + r := FileGrid(c, p.entries, &p.s, FileGridOptions{Disabled: p.disabled, Thumbnail: func(e FileEntry) bool { + if FileKindOf(e.Name) != FileImage { + return false + } + p.thumbs++ + ui.Box(c).Fill().Background(Tokens(c).Info.Alpha(0.25)) + return true + }}) + r.Element.Height(320) + if r.Opened != nil { + p.opened = append(p.opened, r.Opened.Name) + } +} + +func TestFileGridMultiSelectAndOpen(t *testing.T) { + p := &gridProbe{entries: fileGridEntries(6)} + tt := ui.NewTester(p.view, 600, 340) + if err := tt.Click("crest-1.png"); err != nil { + t.Fatal(err) + } + if err := tt.ClickWith(ui.Cmd, "crest-3.png"); err != nil { + t.Fatal(err) + } + if got := p.s.Selected(); len(got) != 2 { + t.Fatalf("selection %v", got) + } + r, _ := tt.Find("sketches") + tt.ClickAt(r.X+r.W/2, r.Y+r.H/2) + tt.ClickAt(r.X+r.W/2, r.Y+r.H/2) + if len(p.opened) != 1 || p.opened[0] != "sketches" { + t.Fatalf("opened %v", p.opened) + } +} + +func TestFileGridThumbnailSlotAndVirtual(t *testing.T) { + p := &gridProbe{entries: fileGridEntries(100_000)} + tt := ui.NewTester(p.view, 600, 340) + p.thumbs = 0 + tt.Frame() + if p.thumbs == 0 || p.thumbs > 60 { + t.Fatalf("built %d thumbnails of 100000", p.thumbs) + } + if _, ok := tt.Find("Folder"); !ok { + t.Fatal("folder without thumbnail shows no icon") + } +} + +func TestFileGridKeysAndDisabled(t *testing.T) { + p := &gridProbe{entries: fileGridEntries(6)} + tt := ui.NewTester(p.view, 600, 340) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyEnter) + if len(p.opened) != 1 { + t.Fatalf("keyboard opened %v", p.opened) + } + p = &gridProbe{entries: fileGridEntries(6), disabled: true} + tt = ui.NewTester(p.view, 600, 340) + _ = tt.Click("crest-1.png") + if len(p.s.Selected()) != 0 { + t.Fatal("disabled grid selected") + } +} + +func TestFileGridSnapshot(t *testing.T) { + snapshot(t, "file_grid", 560, 340, func(c *ui.Context) { + p := ui.Local(c.Root(), "snap", func() *gridProbe { + p := &gridProbe{entries: append(fileGridEntries(3), FileEntry{Path: "/art/deed.pdf", Name: "deed.pdf"}, FileEntry{Path: "/art/x.zip", Name: "archive.zip"})} + p.s.list.Selection.Add("/art/crest-1.png") + return p + }) + ui.Column(c).Padding(12).Children(func() { FileGrid(c, (*p).entries, &(*p).s, FileGridOptions{}).Element.Height(316) }) + }) +} + +func TestFileGridRejectsSmallWidth(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("width below hit size was accepted") + } + }() + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + FileGrid(c, fileGridEntries(3), &FileGridState{}, FileGridOptions{ItemWidth: 1}) + }, 400, 300) +} + +func TestFileGridDisablingBeforeDeliveredKey(t *testing.T) { + p := &gridProbe{entries: fileGridEntries(6)} + tt := ui.NewTester(p.view, 600, 340) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyDown) + before := p.s.Selected() + p.disabled = true + tt.Key(0, ui.KeyDown) + after := p.s.Selected() + if len(after) != len(before) || after[0] != before[0] { + t.Fatalf("disabled selection %v, want %v", after, before) + } +} + +func TestFileGridShrinkThenShiftSelect(t *testing.T) { + p := &gridProbe{entries: fileGridEntries(6)} + tt := ui.NewTester(p.view, 600, 340) + if err := tt.Click("crest-5.png"); err != nil { + t.Fatal(err) + } + p.entries = p.entries[:1] + tt.Frame() + if err := tt.ClickWith(ui.Shift, "sketches"); err != nil { + t.Fatal(err) + } + if got := p.s.Selected(); len(got) != 1 || got[0] != "/art/sketches" { + t.Fatalf("selection %v", got) + } +} diff --git a/file_icon.go b/file_icon.go new file mode 100644 index 0000000..97ddb73 --- /dev/null +++ b/file_icon.go @@ -0,0 +1,125 @@ +package mujica + +import ( + "path" + "strings" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// FileKind is the family a file belongs to. +type FileKind int + +const ( + FileOther FileKind = iota + FileFolder + FileImage + FileText + FileCode + FileDocument + FileArchive + FileAudio + FileVideo +) + +var fileKinds = map[string]FileKind{ + "png": FileImage, "jpg": FileImage, "jpeg": FileImage, "gif": FileImage, "svg": FileImage, "webp": FileImage, "bmp": FileImage, "heic": FileImage, + "txt": FileText, "md": FileText, "log": FileText, "csv": FileText, "rtf": FileText, + "go": FileCode, "js": FileCode, "ts": FileCode, "py": FileCode, "json": FileCode, "sh": FileCode, "sql": FileCode, "html": FileCode, "css": FileCode, "rs": FileCode, "c": FileCode, "h": FileCode, "java": FileCode, "yaml": FileCode, "yml": FileCode, "toml": FileCode, + "pdf": FileDocument, "doc": FileDocument, "docx": FileDocument, "xls": FileDocument, "xlsx": FileDocument, "ppt": FileDocument, "pptx": FileDocument, "pages": FileDocument, "key": FileDocument, + "zip": FileArchive, "tar": FileArchive, "gz": FileArchive, "tgz": FileArchive, "7z": FileArchive, "rar": FileArchive, "xz": FileArchive, + "mp3": FileAudio, "wav": FileAudio, "flac": FileAudio, "aac": FileAudio, "ogg": FileAudio, "m4a": FileAudio, + "mp4": FileVideo, "mov": FileVideo, "mkv": FileVideo, "avi": FileVideo, "webm": FileVideo, +} + +// FileKindOf classifies a filename by its extension. +func FileKindOf(name string) FileKind { + return fileKinds[fileExt(name)] +} + +// fileExt is the lower-case extension without the dot. +func fileExt(name string) string { + return strings.ToLower(strings.TrimPrefix(path.Ext(name), ".")) +} + +// FileIconOptions configure a FileIcon. +type FileIconOptions struct { + // Folder draws a folder; Open draws it opened. + Folder, Open bool + // Size is the icon's edge in DIPs; zero is 20. + Size float32 +} + +// fileFolderOpen is an opened folder glyph in Lucide's style. +var fileFolderOpen = ui.MustParseSVG([]byte(``)) + +// fileKindColor is a kind's tint, readable on Surface. +func fileKindColor(k theme.Tokens, kind FileKind) ui.Color { + switch kind { + case FileFolder: + return k.Warning + case FileImage, FileVideo: + return k.Info + case FileCode: + return k.AccentText + case FileArchive, FileAudio: + return k.Warning + case FileDocument: + return k.Success + } + return k.TextMuted +} + +// FileIcon shows file type or folder opening state. +func FileIcon(c *ui.Context, name string, opts FileIconOptions) *ui.Element { + if opts.Size < 0 { + panic("mujica: FileIcon Size is negative") + } + if opts.Open && !opts.Folder { + panic("mujica: FileIcon Open needs Folder") + } + k := Tokens(c) + size := opts.Size + if size == 0 { + size = 20 + } + kind := FileKindOf(name) + label := msg(c, "file_icon.file") + if ext := fileExt(name); ext != "" { + label = strings.ToUpper(ext) + " " + label + } + if opts.Folder { + kind, label = FileFolder, msg(c, "file_icon.folder") + if opts.Open { + label = msg(c, "file_icon.open") + } + } + col := fileKindColor(k, kind) + box := ui.Box(c).Size(size, size).Shrink(0).Role(ui.RoleImage).Label(label).TextColor(col) + return box.Children(func() { + switch { + case opts.Open: + ui.Icon(c, fileFolderOpen).Size(size, size) + case opts.Folder: + ui.Icon(c, icons.Must("folder")).Size(size, size) + case kind == FileImage: + ui.Icon(c, icons.Must("image")).Size(size, size) + case kind == FileText: + ui.Icon(c, icons.Must("file-text")).Size(size, size) + default: + ui.Icon(c, icons.Must("file")).Size(size, size) + ext := strings.ToUpper(fileExt(name)) + if ext != "" && size >= 20 { + // The extension tag sits across the page's lower half. + if len(ext) > 3 { + ext = ext[:3] + } + ui.Text(c, ext).Font(theme.MonoFont).FontSize(FontSize(c, size*0.3)).FontWeight(700).TextColor(k.Surface). + Background(col).Radius(1).Padding(0, 1).Absolute().Bottom(size * 0.12).Left(0).Role(ui.RoleNone) + } + } + }) +} diff --git a/file_icon_test.go b/file_icon_test.go new file mode 100644 index 0000000..5060fe3 --- /dev/null +++ b/file_icon_test.go @@ -0,0 +1,71 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +func TestFileKindOf(t *testing.T) { + for name, want := range map[string]FileKind{"a.PNG": FileImage, "main.go": FileCode, "notes.md": FileText, "x.tar.gz": FileArchive, + "song.flac": FileAudio, "film.mov": FileVideo, "deed.pdf": FileDocument, "README": FileOther, "blob.xyz": FileOther} { + if got := FileKindOf(name); got != want { + t.Errorf("%s: kind %d, want %d", name, got, want) + } + } +} + +func TestFileIconLabels(t *testing.T) { + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Row(c).Children(func() { + FileIcon(c, "main.go", FileIconOptions{}) + FileIcon(c, "src", FileIconOptions{Folder: true}) + FileIcon(c, "lib", FileIconOptions{Folder: true, Open: true, Size: 32}) + }) + }, 300, 100) + for _, s := range []string{"GO file", "Folder", "Open folder"} { + if _, ok := tt.Find(s); !ok { + t.Fatalf("no icon named %q", s) + } + } + if r, _ := tt.Find("Open folder"); r.W != 32 { + t.Fatalf("size %v", r.W) + } +} + +func TestFileIconColorsReadable(t *testing.T) { + for _, k := range []theme.Tokens{theme.Light(), theme.Dark()} { + for kind := FileOther; kind <= FileVideo; kind++ { + if r := theme.Contrast(fileKindColor(k, kind), k.Surface); r < 3 { + t.Errorf("dark=%v kind %d contrast %.2f", k.Dark, kind, r) + } + } + } +} + +func TestFileIconBadOptionsPanic(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("Open without Folder did not panic") + } + }() + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + FileIcon(c, "a", FileIconOptions{Open: true}) + }, 100, 100) +} + +func TestFileIconSnapshot(t *testing.T) { + snapshot(t, "file_icon", 500, 70, func(c *ui.Context) { + ui.Row(c).Padding(16).Gap(14).Children(func() { + FileIcon(c, "src", FileIconOptions{Folder: true, Size: 32}) + FileIcon(c, "src", FileIconOptions{Folder: true, Open: true, Size: 32}) + for _, n := range []string{"seal.go", "notes.md", "crest.png", "deed.pdf", "ledger.zip", "hymn.mp3", "rite.mov", "blob"} { + FileIcon(c, n, FileIconOptions{Size: 32}) + } + }) + }) +} diff --git a/file_operation_progress.go b/file_operation_progress.go new file mode 100644 index 0000000..9f18ca2 --- /dev/null +++ b/file_operation_progress.go @@ -0,0 +1,125 @@ +package mujica + +import ( + "fmt" + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// FileOperationKind is what a file operation does. +type FileOperationKind int + +const ( + FileCopy FileOperationKind = iota + FileMove + FileDelete +) + +// FileOperation is the caller's running operation; counts are of items. +type FileOperation struct { + Kind FileOperationKind + Done, Total int + // BytesDone and BytesTotal show the amount when BytesTotal is set. + BytesDone, BytesTotal int64 + // Current is the item in progress. + Current string + // Conflict names an existing target awaiting a decision. + Conflict string +} + +// ConflictChoice answers a name conflict. +type ConflictChoice int + +const ( + ConflictNone ConflictChoice = iota + ConflictReplace + ConflictKeepBoth + ConflictSkip +) + +// FileOperationResult is the panel and the user's answer this frame. +type FileOperationResult struct { + Element *ui.Element + Canceled bool + // Resolved answers a conflict; ApplyAll extends that decision. + Resolved ConflictChoice + ApplyAll bool +} + +// FileOperationProgress displays progress, cancellation and conflict choices. +func FileOperationProgress(c *ui.Context, op FileOperation, opts FileOperationOptions) FileOperationResult { + if op.Total < 0 || op.Done < 0 || op.Done > op.Total || op.BytesTotal < 0 || op.BytesDone < 0 || op.BytesTotal > 0 && op.BytesDone > op.BytesTotal { + panic("mujica: FileOperation counts out of range") + } + if op.Kind < FileCopy || op.Kind > FileDelete { + panic("mujica: FileOperation kind out of range") + } + k := Tokens(c) + u := Density(c).Unit() + var res FileOperationResult + res.Element = codePanel(c).Padding(u * 4).Gap(u * 3).Disabled(opts.Disabled).Role(ui.RoleGroup) + all := ui.Local(res.Element, "file_operation_progress.all", func() bool { return false }) + disabled := res.Element.IsDisabled() + verb := msg(c, [...]string{"file_operation_progress.copying", "file_operation_progress.moving", "file_operation_progress.deleting"}[op.Kind]) + title := fmt.Sprintf(verb, op.Done, op.Total) + res.Element.Label(title) + frac := 0.0 + switch { + case op.BytesTotal > 0: + frac = float64(op.BytesDone) / float64(op.BytesTotal) + case op.Total > 0: + frac = float64(op.Done) / float64(op.Total) + } + res.Element.Children(func() { + Progress(c, frac, ProgressOptions{Label: title, ShowValue: true}) + ui.Row(c).Gap(u * 3).AlignItems(ui.Center).Children(func() { + ui.Column(c).Grow(1).MinWidth(0).Children(func() { + if op.Current != "" { + ui.Text(c, op.Current).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).SingleLine() + } + if op.BytesTotal > 0 { + ui.Text(c, fmt.Sprintf(msg(c, "file_operation_progress.bytes"), processListBytes(uint64(op.BytesDone)), processListBytes(uint64(op.BytesTotal)))). + FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted) + } + }) + if Button(c, msg(c, "file_operation_progress.cancel"), ButtonOptions{Variant: Secondary}).Clicked() && !disabled { + res.Canceled = true + } + }) + if op.Conflict == "" { + return + } + ui.Column(c).Gap(u*2).Padding(u*3).Radius(theme.ControlRadius).Border(theme.BorderWidth, k.Warning).Children(func() { + ui.Row(c).Gap(u * 2).AlignItems(ui.Center).TextColor(k.Warning).Children(func() { + ui.Icon(c, icons.Must("triangle-alert")).Size(16, 16) + ui.Text(c, fmt.Sprintf(msg(c, "file_operation_progress.conflict"), op.Conflict)).TextColor(k.Text).Role(ui.RoleStatus) + }) + Checkbox(c, all, msg(c, "file_operation_progress.all"), CheckboxOptions{Disabled: opts.Disabled}) + ui.Row(c).Gap(u * 2).Justify(ui.End).Children(func() { + for _, b := range []struct { + key string + v ButtonVariant + cho ConflictChoice + }{{"file_operation_progress.skip", Ghost, ConflictSkip}, {"file_operation_progress.keepBoth", Secondary, ConflictKeepBoth}, {"file_operation_progress.replace", Danger, ConflictReplace}} { + if Button(c, msg(c, b.key), ButtonOptions{Variant: b.v}).Clicked() && !disabled { + res.Resolved, res.ApplyAll = b.cho, *all + } + } + }) + }) + }) + if res.Canceled || res.Resolved != ConflictNone { + slog.Debug("mujica: file_operation_progress answer", "canceled", res.Canceled, "choice", res.Resolved, "all", res.ApplyAll) + *all = false + } + return res +} + +// FileOperationOptions configure a FileOperationProgress. +type FileOperationOptions struct { + Disabled bool +} diff --git a/file_operation_progress_test.go b/file_operation_progress_test.go new file mode 100644 index 0000000..018f78e --- /dev/null +++ b/file_operation_progress_test.go @@ -0,0 +1,100 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +type opProbe struct { + op FileOperation + disabled bool + canceled int + answers []ConflictChoice + all []bool +} + +func (p *opProbe) view(c *ui.Context) { + Use(c, Settings{}) + r := FileOperationProgress(c, p.op, FileOperationOptions{Disabled: p.disabled}) + r.Element.Width(480) + if r.Canceled { + p.canceled++ + } + if r.Resolved != ConflictNone { + p.answers = append(p.answers, r.Resolved) + p.all = append(p.all, r.ApplyAll) + } +} + +func TestFileOperationProgressShowsCounts(t *testing.T) { + p := &opProbe{op: FileOperation{Kind: FileMove, Done: 3, Total: 12, BytesDone: 12 << 20, BytesTotal: 48 << 20, Current: "decrees/42.pdf"}} + tt := ui.NewTester(p.view, 520, 300) + for _, s := range []string{"Moving 3 of 12 items", "25%", "12.0 MB of 48.0 MB", "decrees/42.pdf"} { + if !tt.HasText(s) { + t.Fatalf("missing %q in %q", s, tt.Texts()) + } + } + if err := tt.Click("Cancel"); err != nil { + t.Fatal(err) + } + if p.canceled != 1 { + t.Fatal("cancel not reported") + } +} + +func TestFileOperationProgressConflict(t *testing.T) { + p := &opProbe{op: FileOperation{Kind: FileCopy, Done: 1, Total: 4, Conflict: "seal.go"}} + tt := ui.NewTester(p.view, 520, 300) + if !tt.HasText("“seal.go” already exists here.") { + t.Fatalf("texts %q", tt.Texts()) + } + _ = tt.Click("Keep both") + _ = tt.Click("Do this for all conflicts") + _ = tt.Click("Replace") + if len(p.answers) != 2 || p.answers[0] != ConflictKeepBoth || p.all[0] || p.answers[1] != ConflictReplace || !p.all[1] { + t.Fatalf("answers %v all %v", p.answers, p.all) + } + // Keyboard: the answers are buttons in the Tab order. + for range 5 { + tt.Key(0, ui.KeyTab) + } + tt.Key(0, ui.KeyEnter) + if len(p.answers) != 3 { + t.Fatalf("keyboard answer missing: %v", p.answers) + } +} + +func TestFileOperationProgressDisabledAndInvalid(t *testing.T) { + p := &opProbe{op: FileOperation{Kind: FileDelete, Total: 2, Conflict: "a"}, disabled: true} + tt := ui.NewTester(p.view, 520, 300) + _ = tt.Click("Cancel") + _ = tt.Click("Skip") + if p.canceled != 0 || len(p.answers) != 0 { + t.Fatal("disabled panel answered") + } + defer func() { + if recover() == nil { + t.Fatal("Done above Total did not panic") + } + }() + ui.NewTester((&opProbe{op: FileOperation{Done: 5, Total: 2}}).view, 520, 300) +} + +func TestFileOperationProgressSnapshot(t *testing.T) { + snapshot(t, "file_operation_progress", 520, 300, func(c *ui.Context) { + ui.Column(c).Padding(12).Children(func() { + FileOperationProgress(c, FileOperation{Kind: FileCopy, Done: 4, Total: 10, BytesDone: 30 << 20, BytesTotal: 80 << 20, + Current: "chronicles/first.md", Conflict: "first.md"}, FileOperationOptions{}) + }) + }) +} + +func TestFileOperationProgressNegativeByteTotal(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("negative byte total was accepted") + } + }() + ui.NewTester((&opProbe{op: FileOperation{BytesTotal: -1}}).view, 520, 300) +} diff --git a/file_preview.go b/file_preview.go new file mode 100644 index 0000000..8ee8829 --- /dev/null +++ b/file_preview.go @@ -0,0 +1,114 @@ +package mujica + +import ( + "fmt" + "log/slog" + "strings" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// PreviewFile holds caller-provided preview content. +type PreviewFile struct { + Name string + Size int64 + // Image previews a picture; Text previews text or code. + Image ui.ImageSource + Text string + // Language highlights code; empty guesses it from the extension. + Language string +} + +// FilePreviewOptions configure a FilePreview. +type FilePreviewOptions struct { + // Status shows the caller's read in progress or failed. + Status DataStatus + Error string +} + +// FilePreviewResult exposes the panel and requested actions. +type FilePreviewResult struct { + Element *ui.Element + // Closed, Opened and Retried report requested actions. + Closed, Opened, Retried bool +} + +var filePreviewLanguages = map[string]string{"go": "go", "js": "javascript", "ts": "typescript", "py": "python", "json": "json", "sh": "shell", "sql": "sql"} + +type previewCodeState struct { + viewer CodeViewerState + text string + lines []string +} + +// FilePreview presents images, text, code or unsupported-type copy. +func FilePreview(c *ui.Context, f PreviewFile, opts FilePreviewOptions) FilePreviewResult { + if f.Name == "" { + panic("mujica: FilePreview needs a Name") + } + k := Tokens(c) + u := Density(c).Unit() + var res FilePreviewResult + res.Element = codePanel(c).Role(ui.RoleGroup).Label(fmt.Sprintf(msg(c, "file_preview.label"), f.Name)) + code := ui.Local(res.Element, "file_preview.code", func() previewCodeState { return previewCodeState{} }) + if res.Element.Shortcut(0, ui.KeyEscape) && !res.Element.IsDisabled() { + res.Closed = true + } + res.Element.Children(func() { + codeHeader(c, func() { + FileIcon(c, f.Name, FileIconOptions{}) + ui.Text(c, f.Name).FontWeight(600).SingleLine().MinWidth(0).Shrink(1) + if f.Size > 0 { + ui.Text(c, processListBytes(uint64(f.Size))).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).Shrink(0) + } + ui.Spacer(c) + if Button(c, msg(c, "file_preview.open"), ButtonOptions{Variant: Ghost}).MinHeight(theme.MinHitSize).Clicked() { + res.Opened = true + } + if iconAction(c, icons.Must("x"), msg(c, "file_preview.close")).Clicked() { + res.Closed = true + } + }) + body := ui.Column(c).Grow(1).MinHeight(u * 40).Padding(u * 3) + body.Children(func() { + if opts.Status != DataReady { + res.Retried = dataPlaceholder(c, opts.Status, opts.Error, "", "") + return + } + lang := f.Language + if lang == "" { + lang = filePreviewLanguages[fileExt(f.Name)] + } + switch { + case f.Image != nil: + ui.Box(c).Grow(1).Center().Clip().Children(func() { + Image(c, f.Image, ImageOptions{Alt: f.Name}).Element.MaxWidthPercent(100).MaxHeightPercent(100) + }) + case lang != "" || FileKindOf(f.Name) == FileCode: + if code.lines == nil || code.text != f.Text { + code.text, code.lines = f.Text, strings.Split(f.Text, "\n") + } + if lang == "" { + lang = fileExt(f.Name) + } + CodeViewer(c, code.lines, &code.viewer, CodeViewerOptions{Language: lang, Label: f.Name}).Element.Grow(1) + case f.Text != "" || FileKindOf(f.Name) == FileText: + ScrollArea(c, nil, ScrollAreaOptions{Label: f.Name}, func() { + ui.Text(c, f.Text).Font(theme.MonoFont).FontSize(FontSize(c, codeSize)).Selectable() + }).Grow(1) + default: + ui.Column(c).Grow(1).Center().Gap(u * 2).Children(func() { + FileIcon(c, f.Name, FileIconOptions{Size: 48}) + ui.Text(c, fmt.Sprintf(msg(c, "file_preview.unsupported"), strings.ToUpper(fileExt(f.Name)))).TextColor(k.TextMuted).Role(ui.RoleStatus) + }) + } + }) + }) + if res.Closed || res.Opened { + slog.Debug("mujica: file_preview action", "closed", res.Closed, "opened", res.Opened) + } + return res +} diff --git a/file_preview_test.go b/file_preview_test.go new file mode 100644 index 0000000..347ff9d --- /dev/null +++ b/file_preview_test.go @@ -0,0 +1,127 @@ +package mujica + +import ( + "strings" + "testing" + + "github.com/egoist/mygo/ui" +) + +type previewProbe struct { + f PreviewFile + opts FilePreviewOptions + closed, opened, retried int +} + +func (p *previewProbe) view(c *ui.Context) { + Use(c, Settings{}) + r := FilePreview(c, p.f, p.opts) + r.Element.Size(480, 300) + for _, e := range []struct { + on bool + n *int + }{{r.Closed, &p.closed}, {r.Opened, &p.opened}, {r.Retried, &p.retried}} { + if e.on { + *e.n++ + } + } +} + +func TestFilePreviewKinds(t *testing.T) { + cases := []struct { + f PreviewFile + want string + }{ + {PreviewFile{Name: "seal.go", Text: "package court\nfunc Seal() {}"}, "func Seal() {}"}, + {PreviewFile{Name: "notes.md", Text: "# The court"}, "# The court"}, + {PreviewFile{Name: "deed.pdf", Size: 4096}, "No preview for PDF files"}, + {PreviewFile{Name: "crest.svg", Image: ui.MustParseSVG([]byte(``))}, "crest.svg"}, + } + for _, tc := range cases { + tt := ui.NewTester((&previewProbe{f: tc.f}).view, 500, 320) + if !tt.HasText(tc.want) { + t.Fatalf("%s: texts %q", tc.f.Name, tt.Texts()) + } + } + tt := ui.NewTester((&previewProbe{f: PreviewFile{Name: "deed.pdf", Size: 4096}}).view, 500, 320) + if !tt.HasText("4 KB") { + t.Fatal("size missing") + } +} + +func TestFilePreviewActions(t *testing.T) { + p := &previewProbe{f: PreviewFile{Name: "notes.md", Text: "text"}} + tt := ui.NewTester(p.view, 500, 320) + _ = tt.Click("Open") + _ = tt.Click("Close preview") + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEscape) + if p.opened != 1 || p.closed != 2 { + t.Fatalf("opened %d closed %d", p.opened, p.closed) + } +} + +func TestFilePreviewStatusAndNoName(t *testing.T) { + p := &previewProbe{f: PreviewFile{Name: "big.log"}, opts: FilePreviewOptions{Status: DataFailed, Error: "read denied"}} + tt := ui.NewTester(p.view, 500, 320) + if !tt.HasText("read denied") { + t.Fatalf("texts %q", tt.Texts()) + } + _ = tt.Click("Retry") + if p.retried != 1 { + t.Fatal("retry not reported") + } + defer func() { + if recover() == nil { + t.Fatal("missing name did not panic") + } + }() + ui.NewTester((&previewProbe{}).view, 500, 320) +} + +func TestFilePreviewSnapshot(t *testing.T) { + snapshot(t, "file_preview", 500, 340, func(c *ui.Context) { + ui.Column(c).Padding(12).Children(func() { + FilePreview(c, PreviewFile{Name: "seal.go", Size: 912, Text: "package court\n\n// Seal stamps a decree.\nfunc Seal(d string) int {\n\treturn len(d)\n}"}, FilePreviewOptions{}).Element.Height(316) + }) + }) +} + +func TestFilePreviewEmptyCode(t *testing.T) { + for _, f := range []PreviewFile{{Name: "empty.go"}, {Name: "empty.html"}, {Name: "empty.custom", Language: "go"}} { + tt := ui.NewTester((&previewProbe{f: f}).view, 500, 320) + if tt.HasText("No preview for GO files") || tt.HasText("No preview for HTML files") || tt.HasText("No preview for CUSTOM files") { + t.Fatalf("empty code shows unsupported type: %s", f.Name) + } + } +} + +func TestFilePreviewLargeCodeCache(t *testing.T) { + file := PreviewFile{Name: "large.go", Text: strings.Repeat("var seal = 1\n", 100000)} + var state *previewCodeState + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + r := FilePreview(c, file, FilePreviewOptions{}) + r.Element.Size(480, 300) + state = ui.Local(r.Element, "file_preview.code", func() previewCodeState { return previewCodeState{} }) + }, 500, 320) + before := state.viewer.data + tt.Frame() + if before == nil || state.viewer.data != before || len(state.lines) != 100001 { + t.Fatal("unchanged preview rebuilt code cache") + } + n := 0 + for _, text := range tt.Texts() { + if text == "var seal = 1" { + n++ + } + } + if n == 0 || n > 60 { + t.Fatalf("built %d code lines", n) + } + file.Text = "package court" + tt.Frame() + if len(state.lines) != 1 || state.lines[0] != "package court" || state.viewer.data == before { + t.Fatal("changed preview retained code cache") + } +} diff --git a/live_indicator.go b/live_indicator.go new file mode 100644 index 0000000..03607f8 --- /dev/null +++ b/live_indicator.go @@ -0,0 +1,96 @@ +package mujica + +import ( + "fmt" + "log/slog" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// LiveStatus is the state of real-time sync. +type LiveStatus int + +const ( + LiveSynced LiveStatus = iota + LiveSyncing + LiveReconnecting + LiveOffline + LiveError +) + +// LiveIndicatorOptions configure a LiveIndicator. +type LiveIndicatorOptions struct { + // LastSynced dates the last good sync; zero hides it. + LastSynced time.Time + // Now measures LastSynced; zero uses the frame's time. + Now time.Time + // Error explains LiveError. + Error string +} + +// LiveIndicator displays sync status, announces changes and offers retries. +func LiveIndicator(c *ui.Context, status LiveStatus, opts LiveIndicatorOptions) ChoiceResult { + if status < LiveSynced || status > LiveError { + panic("mujica: LiveStatus out of range") + } + k := Tokens(c) + u := Density(c).Unit() + now := opts.Now + if now.IsZero() { + now = c.Now() + } + text := msg(c, [...]string{"live_indicator.synced", "live_indicator.syncing", "live_indicator.reconnecting", "live_indicator.offline", "live_indicator.error"}[status]) + if status == LiveError && opts.Error != "" { + text += ": " + opts.Error + } + var res ChoiceResult + res.Element = ui.Row(c).Gap(u * 2).AlignItems(ui.Center).MinHeight(theme.MinHitSize) + last := ui.Local(res.Element, "live_indicator.last", func() LiveStatus { return status }) + if *last != status { + *last = status + c.Announce(text) + slog.Debug("mujica: live_indicator status", "status", status) + } + res.Element.Children(func() { + box := ui.Row(c).Gap(u*2).AlignItems(ui.Center).Padding(u, u*3).Radius(theme.MinHitSize / 2).Role(ui.RoleStatus).Label(text) + col := [...]ui.Color{k.Success, k.TextMuted, k.Warning, k.Warning, k.Danger}[status] + box.Border(theme.BorderWidth, col.Mix(k.Surface, 0.5)).Background(k.Surface) + box.Children(func() { + switch status { + case LiveSyncing, LiveReconnecting: + Spinner(c, SpinnerOptions{Size: 14, Label: text}) + default: + name := [...]string{"circle-check", "", "", "wifi-off", "circle-alert"}[status] + ui.Icon(c, icons.Must(name)).Size(16, 16).Shrink(0).TextColor(col) + } + ui.Text(c, text).FontSize(FontSize(c, theme.CaptionSize)).SingleLine() + if !opts.LastSynced.IsZero() && status != LiveSyncing { + ui.Text(c, liveAgo(c, now.Sub(opts.LastSynced))).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).SingleLine() + } + }) + if status == LiveOffline || status == LiveError { + if Button(c, msg(c, "live_indicator.retry"), ButtonOptions{Variant: Ghost}).MinHeight(theme.MinHitSize).Clicked() && !res.Element.IsDisabled() { + res.changed = true + slog.Debug("mujica: live_indicator retry") + } + } + }) + return res +} + +// liveAgo words how long ago the last sync was. +func liveAgo(c *ui.Context, d time.Duration) string { + switch { + case d < time.Minute: + return msg(c, "live_indicator.justNow") + case d < time.Hour: + return fmt.Sprintf(msg(c, "live_indicator.minutes"), int(d.Minutes())) + case d < 24*time.Hour: + return fmt.Sprintf(msg(c, "live_indicator.hours"), int(d.Hours())) + } + return fmt.Sprintf(msg(c, "live_indicator.days"), int(d.Hours()/24)) +} diff --git a/live_indicator_test.go b/live_indicator_test.go new file mode 100644 index 0000000..bffb04e --- /dev/null +++ b/live_indicator_test.go @@ -0,0 +1,89 @@ +package mujica + +import ( + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +var liveNow = time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC) + +type liveProbe struct { + status LiveStatus + err string + retried int +} + +func (p *liveProbe) view(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Padding(12).Children(func() { + if LiveIndicator(c, p.status, LiveIndicatorOptions{LastSynced: liveNow.Add(-3 * time.Minute), Now: liveNow, Error: p.err}).Changed() { + p.retried++ + } + }) +} + +func TestLiveIndicatorStates(t *testing.T) { + p := &liveProbe{} + tt := ui.NewTester(p.view, 500, 120) + if !tt.HasText("All changes saved") || !tt.HasText("3 min ago") || tt.HasText("Retry") { + t.Fatalf("synced: %q", tt.Texts()) + } + p.status = LiveOffline + tt.Frame() + if !tt.HasText("Offline, changes kept on this device") { + t.Fatalf("offline: %q", tt.Texts()) + } + ann := tt.Announcements() + if len(ann) == 0 || ann[len(ann)-1] != "Offline, changes kept on this device" { + t.Fatalf("not announced: %q", ann) + } + if err := tt.Click("Retry"); err != nil { + t.Fatal(err) + } + p.status, p.err = LiveError, "server refused" + tt.Frame() + if !tt.HasText("Sync failed: server refused") { + t.Fatalf("error: %q", tt.Texts()) + } + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnter) + if p.retried != 2 { + t.Fatalf("retried %d", p.retried) + } + p.status = LiveSyncing + tt.Frame() + if !tt.HasText("Saving…") || tt.HasText("Retry") { + t.Fatalf("syncing: %q", tt.Texts()) + } +} + +func TestLiveAgo(t *testing.T) { + var got []string + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + got = []string{liveAgo(c, 10*time.Second), liveAgo(c, 5*time.Hour), liveAgo(c, 50*time.Hour)} + }, 10, 10) + if got[0] != "just now" || got[1] != "5 h ago" || got[2] != "2 days ago" { + t.Fatalf("ago %q", got) + } +} + +func TestLiveIndicatorInvalid(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("bad status did not panic") + } + }() + ui.NewTester((&liveProbe{status: 9}).view, 400, 100) +} + +func TestLiveIndicatorSnapshot(t *testing.T) { + snapshot(t, "live_indicator", 520, 150, func(c *ui.Context) { + ui.Column(c).Gap(4).Children(func() { + LiveIndicator(c, LiveSynced, LiveIndicatorOptions{LastSynced: liveNow.Add(-3 * time.Minute), Now: liveNow}) + LiveIndicator(c, LiveOffline, LiveIndicatorOptions{}) + }) + }) +} diff --git a/messages_files.go b/messages_files.go new file mode 100644 index 0000000..936f37a --- /dev/null +++ b/messages_files.go @@ -0,0 +1,98 @@ +package mujica + +func init() { + addMessages(map[string][2]string{ + "file_icon.file": {"file", "文件"}, + "file_icon.folder": {"Folder", "文件夹"}, + "file_icon.open": {"Open folder", "打开的文件夹"}, + "path_bar.label": {"Path", "路径"}, + "path_bar.more": {"Hidden folders", "折叠的文件夹"}, + "path_bar.current": {"Current folder", "当前文件夹"}, + "rename_inline.label": {"Name", "名称"}, + "rename_inline.empty": {"A name is required", "名称不能为空"}, + "file_explorer.back": {"Back", "后退"}, + "file_explorer.forward": {"Forward", "前进"}, + "file_explorer.up": {"Enclosing folder", "上级文件夹"}, + "file_explorer.empty": {"This folder is empty", "此文件夹为空"}, + "file_grid.empty": {"This folder is empty", "此文件夹为空"}, + "file_preview.label": {"Preview of %s", "%s 的预览"}, + "file_preview.open": {"Open", "打开"}, + "file_preview.close": {"Close preview", "关闭预览"}, + "file_preview.unsupported": {"No preview for %s files", "无法预览 %s 文件"}, + "file_operation_progress.copying": {"Copying %d of %d items", "正在复制 %d / %d 项"}, + "file_operation_progress.moving": {"Moving %d of %d items", "正在移动 %d / %d 项"}, + "file_operation_progress.deleting": {"Deleting %d of %d items", "正在删除 %d / %d 项"}, + "file_operation_progress.cancel": {"Cancel", "取消"}, + "file_operation_progress.bytes": {"%s of %s", "%s / %s"}, + "file_operation_progress.conflict": {"“%s” already exists here.", "此处已存在“%s”。"}, + "file_operation_progress.all": {"Do this for all conflicts", "对所有冲突执行此操作"}, + "file_operation_progress.skip": {"Skip", "跳过"}, + "file_operation_progress.keepBoth": {"Keep both", "保留两者"}, + "file_operation_progress.replace": {"Replace", "替换"}, + "transfer_queue.summary": {"%d in progress, %d failed", "%d 项进行中,%d 项失败"}, + "transfer_queue.empty": {"No transfers", "没有传输"}, + "transfer_queue.upload": {"Upload", "上传"}, + "transfer_queue.download": {"Download", "下载"}, + "transfer_queue.queued": {"Waiting", "等待中"}, + "transfer_queue.paused": {"Paused at %.0f%%", "已暂停于 %.0f%%"}, + "transfer_queue.failed": {"Failed: %s", "失败:%s"}, + "transfer_queue.done": {"Done", "已完成"}, + "transfer_queue.pause": {"Pause %s", "暂停 %s"}, + "transfer_queue.resume": {"Resume %s", "继续 %s"}, + "transfer_queue.retry": {"Retry %s", "重试 %s"}, + "transfer_queue.remove": {"Remove %s", "移除 %s"}, + "storage_usage.used": {"%s of %s used", "已用 %s,共 %s"}, + "storage_usage.free": {"Free: %s (%.1f%%)", "可用:%s(%.1f%%)"}, + "storage_usage.freeLabel": {"Free", "可用"}, + "recent_files.title": {"Recent", "最近使用"}, + "recent_files.clear": {"Clear", "清除"}, + "recent_files.empty": {"No recent files", "没有最近使用的文件"}, + "recent_files.remove": {"Remove %s from recent", "从最近使用中移除 %s"}, + "recent_files.today": {"Today", "今天"}, + "recent_files.yesterday": {"Yesterday", "昨天"}, + "recent_files.week": {"This week", "本周"}, + "recent_files.earlier": {"Earlier", "更早"}, + "archive_viewer.summary": {"%d files, %s unpacked, %s packed (%s smaller)", "%d 个文件,解压后 %s,压缩后 %s(减小 %s)"}, + "archive_viewer.empty": {"The archive is empty", "压缩包为空"}, + "archive_viewer.name": {"Name", "名称"}, + "archive_viewer.size": {"Size", "大小"}, + "archive_viewer.packed": {"Packed", "压缩后"}, + "archive_viewer.ratio": {"Saved", "压缩率"}, + "presence_avatars.label": {"%d people here", "%d 人在线"}, + "presence_avatars.more": {"%d more people", "另有 %d 人"}, + "presence_avatars.others": {"Others here", "其他在线者"}, + "presence_avatars.follow": {"Follow %s", "跟随 %s"}, + "presence_avatars.unfollow": {"Stop following %s", "停止跟随 %s"}, + "presence_avatars.away": {"Away", "离开"}, + "permission_select.view": {"Can view", "可查看"}, + "permission_select.comment": {"Can comment", "可评论"}, + "permission_select.edit": {"Can edit", "可编辑"}, + "permission_select.label": {"Permission", "权限"}, + "share_dialog.title": {"Share “%s”", "分享“%s”"}, + "share_dialog.placeholder": {"Add people by email", "通过邮箱添加成员"}, + "share_dialog.invite": {"Email to invite", "要邀请的邮箱"}, + "share_dialog.invitePermission": {"Permission for the invite", "邀请的权限"}, + "share_dialog.send": {"Invite", "邀请"}, + "share_dialog.people": {"People with access", "有权访问的成员"}, + "share_dialog.owner": {"Owner", "所有者"}, + "share_dialog.remove": {"Remove %s", "移除 %s"}, + "share_dialog.copy": {"Copy link", "复制链接"}, + "share_dialog.copied": {"Link copied", "链接已复制"}, + "reactions.label": {"Reactions", "表情回应"}, + "reactions.add": {"Add reaction", "添加回应"}, + "reactions.pick": {"Choose a reaction", "选择回应"}, + "reactions.count": {"%s %d", "%s %d"}, + "reactions.mine": {"including you", "包括你"}, + "live_indicator.synced": {"All changes saved", "所有更改已保存"}, + "live_indicator.syncing": {"Saving…", "正在保存…"}, + "live_indicator.reconnecting": {"Reconnecting…", "正在重新连接…"}, + "live_indicator.offline": {"Offline, changes kept on this device", "已离线,更改保存在本机"}, + "live_indicator.error": {"Sync failed", "同步失败"}, + "live_indicator.retry": {"Retry", "重试"}, + "live_indicator.justNow": {"just now", "刚刚"}, + "live_indicator.minutes": {"%d min ago", "%d 分钟前"}, + "live_indicator.hours": {"%d h ago", "%d 小时前"}, + "live_indicator.days": {"%d days ago", "%d 天前"}, + "remote_cursor.label": {"Also editing: %s", "同时编辑:%s"}, + }) +} diff --git a/path_bar.go b/path_bar.go new file mode 100644 index 0000000..8b5f6f7 --- /dev/null +++ b/path_bar.go @@ -0,0 +1,157 @@ +package mujica + +import ( + "log/slog" + "strings" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// PathBarOptions configure a PathBar. +type PathBarOptions struct { + // Separator splits the path; empty is "/". + Separator string + // RootLabel defaults to the absolute path separator. + RootLabel string + Disabled bool +} + +// PathBarResult exposes the toolbar and requested path. +type PathBarResult struct { + Element *ui.Element + // Navigated is the chosen ancestor path, or empty. + Navigated string +} + +// pathSegment is a crumb and the path up to it. +type pathSegment struct{ label, path string } + +// pathSegments splits p into crumbs with cumulative paths. +func pathSegments(p, sep, root string) []pathSegment { + var out []pathSegment + acc := "" + if strings.HasPrefix(p, sep) { + label := root + if label == "" { + label = sep + } + out = append(out, pathSegment{label, sep}) + acc = sep + } + for _, part := range strings.Split(strings.Trim(p, sep), sep) { + if part == "" { + continue + } + if acc != "" && !strings.HasSuffix(acc, sep) { + acc += sep + } + acc += part + out = append(out, pathSegment{part, acc}) + } + return out +} + +// PathBar displays clickable segments and collapses overflowing middle segments. +func PathBar(c *ui.Context, p string, opts PathBarOptions) PathBarResult { + sep := opts.Separator + if sep == "" { + sep = "/" + } + segs := pathSegments(p, sep, opts.RootLabel) + if len(segs) == 0 { + panic("mujica: PathBar needs a path") + } + k := Tokens(c) + u := Density(c).Unit() + var res PathBarResult + e := ui.Row(c).Grow(1).MinWidth(0).Gap(u).AlignItems(ui.Center).Role(ui.RoleToolbar).Label(msg(c, "path_bar.label")). + FocusGroup(ui.Horizontal).Disabled(opts.Disabled) + res.Element = e + was := e.Bounds() + internal.Remeasure(c, e, was.W, was.H) + hidden := pathBarFold(c, segs, was.W) + disabled := e.IsDisabled() + open := ui.Local(e, "path_bar.open", func() bool { return false }) + *open = *open && hidden > 0 && !disabled + goTo := func(s pathSegment) { + if !disabled { + res.Navigated, *open = s.path, false + slog.Debug("mujica: path_bar navigate", "depth", strings.Count(s.path, sep)) + } + } + e.Children(func() { + for i, s := range segs { + if i > 0 && (i <= 1 || i > hidden) { + ui.Icon(c, icons.Must("chevron-right")).Size(12, 12).Shrink(0).TextColor(k.TextMuted) + } + if i >= 1 && i <= hidden { + if i == 1 { + more := pathBarCrumb(c, "…", false).Label(msg(c, "path_bar.more")) + if more.Clicked() && !disabled { + *open = !*open + } + Popover(c, more, open, PopoverOptions{Label: msg(c, "path_bar.more")}, func() { + for _, h := range segs[1 : hidden+1] { + if pathBarCrumb(c, h.label, false).Key(h.path).Clicked() { + goTo(h) + } + } + }) + } + continue + } + if pathBarCrumb(c, s.label, i == len(segs)-1).Key(s.path).Clicked() { + goTo(s) + } + } + }) + return res +} + +// pathBarFold counts middle segments to collapse into the available width. +func pathBarFold(c *ui.Context, segs []pathSegment, room float32) int { + if room <= 0 { + return 0 + } + u := Density(c).Unit() + width := func(s string) float32 { + w, _ := c.MeasureText(0, ui.Span{Text: s, Size: FontSize(c, theme.BodySize)}) + return max(w+4*u, theme.MinHitSize) + 12 + 2*u + } + total := float32(0) + for _, s := range segs { + total += width(s.label) + } + hidden := 0 + for total > room && hidden < len(segs)-2 { + hidden++ + total -= width(segs[hidden].label) + if hidden == 1 { + total += width("…") + } + } + return hidden +} + +// pathBarCrumb builds a button, emphasizing the current segment. +func pathBarCrumb(c *ui.Context, label string, current bool) *ui.Element { + k := Tokens(c) + d := Density(c) + b := ui.ButtonBase(c).MinHeight(theme.MinHitSize).MinWidth(theme.MinHitSize).Padding(0, d.Unit()*2). + Radius(theme.ControlRadius).Shrink(0).TextColor(k.TextMuted) + internal.Ring(b, k.Focus, theme.ControlRadius) + switch { + case b.Pressed(): + b.Background(k.Selection).TextColor(k.Text) + case b.Hovered(): + b.Background(k.SurfaceHover).TextColor(k.Text) + } + if current { + b.TextColor(k.Text).FontWeight(600).Description(msg(c, "path_bar.current")) + } + return b.Children(func() { ui.Text(c, label).SingleLine() }) +} diff --git a/path_bar_test.go b/path_bar_test.go new file mode 100644 index 0000000..3b5dc37 --- /dev/null +++ b/path_bar_test.go @@ -0,0 +1,99 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func pathBarTester(p *string, opts PathBarOptions, w int, went *[]string) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Row(c).Width(float32(w)).Children(func() { + if r := PathBar(c, *p, opts); r.Navigated != "" { + *went = append(*went, r.Navigated) + *p = r.Navigated + } + }) + }, w+20, 300) +} + +func TestPathSegments(t *testing.T) { + segs := pathSegments("/Users/ada/court", "/", "Disk") + want := []pathSegment{{"Disk", "/"}, {"Users", "/Users"}, {"ada", "/Users/ada"}, {"court", "/Users/ada/court"}} + if len(segs) != 4 || segs[0] != want[0] || segs[3] != want[3] { + t.Fatalf("segments %v", segs) + } + if s := pathSegments(`C:\Court\Seals`, `\`, ""); len(s) != 3 || s[2].path != `C:\Court\Seals` { + t.Fatalf("windows segments %v", s) + } +} + +func TestPathBarClickNavigates(t *testing.T) { + p := "/Users/ada/court/seals" + var went []string + tt := pathBarTester(&p, PathBarOptions{}, 600, &went) + if err := tt.Click("ada"); err != nil { + t.Fatal(err) + } + if len(went) != 1 || went[0] != "/Users/ada" || tt.HasText("seals") { + t.Fatalf("went %v", went) + } + if err := tt.Click("/"); err != nil { + t.Fatal(err) + } + if went[1] != "/" { + t.Fatalf("root went %v", went) + } +} + +func TestPathBarFoldsMiddle(t *testing.T) { + p := "/Users/ada/chronicles/of/the/royal/court/seals" + var went []string + tt := pathBarTester(&p, PathBarOptions{RootLabel: "Disk"}, 300, &went) + if !tt.HasText("Disk") || !tt.HasText("seals") || tt.HasText("chronicles") { + t.Fatalf("narrow bar shows %q", tt.Texts()) + } + if err := tt.Click("Hidden folders"); err != nil { + t.Fatal(err) + } + if err := tt.Click("chronicles"); err != nil { + t.Fatal(err) + } + if len(went) != 1 || went[0] != "/Users/ada/chronicles" { + t.Fatalf("went %v", went) + } + if !tt.HasText("chronicles") { + t.Fatal("shorter path still folded") + } +} + +func TestPathBarKeyboardAndDisabled(t *testing.T) { + p := "/a/b/c" + var went []string + tt := pathBarTester(&p, PathBarOptions{}, 600, &went) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyEnter) + if len(went) != 1 || went[0] != "/a" { + t.Fatalf("keyboard went %v", went) + } + p = "/a/b/c" + went = nil + tt = pathBarTester(&p, PathBarOptions{Disabled: true}, 600, &went) + _ = tt.Click("a") + if len(went) != 0 { + t.Fatal("disabled bar navigated") + } +} + +func TestPathBarSnapshot(t *testing.T) { + snapshot(t, "path_bar", 460, 110, func(c *ui.Context) { + ui.Column(c).Padding(12).Gap(12).Children(func() { + PathBar(c, "/Users/ada/court/seals", PathBarOptions{RootLabel: "Disk"}) + ui.Row(c).Width(300).Children(func() { + PathBar(c, "/Users/ada/chronicles/of/the/royal/court/seals", PathBarOptions{RootLabel: "Disk"}) + }) + }) + }) +} diff --git a/permission_select.go b/permission_select.go new file mode 100644 index 0000000..023b872 --- /dev/null +++ b/permission_select.go @@ -0,0 +1,52 @@ +package mujica + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" +) + +// Permission defines access to a shared item. +type Permission int + +const ( + PermissionView Permission = iota + PermissionComment + PermissionEdit +) + +// PermissionSelectOptions configure a PermissionSelect. +type PermissionSelectOptions struct { + // Label names the select, as the person it is for. + Label string + Disabled bool +} + +// permissionName is a permission's built-in name. +func permissionName(c *ui.Context, p Permission) string { + if p < PermissionView || p > PermissionEdit { + panic("mujica: Permission out of range") + } + return msg(c, [...]string{"permission_select.view", "permission_select.comment", "permission_select.edit"}[p]) +} + +// PermissionSelect chooses can view, can comment or can edit. +func PermissionSelect(c *ui.Context, value *Permission, opts PermissionSelectOptions) ChoiceResult { + if value == nil { + panic("mujica: PermissionSelect needs a value") + } + permissionName(c, *value) + options := make([]SelectOption[Permission], 3) + for p := PermissionView; p <= PermissionEdit; p++ { + options[p] = SelectOption[Permission]{Value: p, Label: permissionName(c, p)} + } + label := opts.Label + if label == "" { + label = msg(c, "permission_select.label") + } + r := Select(c, value, options, SelectOptions{Label: label, Disabled: opts.Disabled}) + if r.Changed() { + slog.Debug("mujica: permission_select change", "permission", *value) + } + return r +} diff --git a/permission_select_test.go b/permission_select_test.go new file mode 100644 index 0000000..0e1c147 --- /dev/null +++ b/permission_select_test.go @@ -0,0 +1,92 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func permissionSelectTester(v *Permission, disabled bool, changed *int) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Padding(12).Children(func() { + if PermissionSelect(c, v, PermissionSelectOptions{Label: "Ada", Disabled: disabled}).Changed() { + *changed++ + } + }) + }, 360, 260) +} + +func TestPermissionSelectChoose(t *testing.T) { + v, n := PermissionView, 0 + tt := permissionSelectTester(&v, false, &n) + if err := tt.Click("Can view"); err != nil { + t.Fatal(err) + } + if err := tt.Click("Can edit"); err != nil { + t.Fatal(err) + } + if v != PermissionEdit || n != 1 { + t.Fatalf("value %d changed %d", v, n) + } +} + +func TestPermissionSelectKeyboard(t *testing.T) { + v, n := PermissionEdit, 0 + tt := permissionSelectTester(&v, false, &n) + tt.Key(0, ui.KeyTab) + if !tt.Focused("Ada") { + t.Fatal("Tab did not reach the select") + } + tt.Key(0, ui.KeyEnter) + tt.Key(0, ui.KeyUp) + tt.Key(0, ui.KeyEnter) + if v != PermissionComment { + t.Fatalf("keys chose %d", v) + } +} + +func TestPermissionSelectDisabledAndInvalid(t *testing.T) { + v, n := PermissionComment, 0 + tt := permissionSelectTester(&v, true, &n) + _ = tt.Click("Can comment") + _ = tt.Click("Can edit") + if v != PermissionComment || n != 0 { + t.Fatal("disabled select changed") + } + defer func() { + if recover() == nil { + t.Fatal("bad permission did not panic") + } + }() + bad := Permission(7) + permissionSelectTester(&bad, false, &n) +} + +func TestPermissionSelectSnapshot(t *testing.T) { + snapshot(t, "permission_select", 260, 70, func(c *ui.Context) { + v := PermissionComment + ui.Column(c).Padding(12).Children(func() { PermissionSelect(c, &v, PermissionSelectOptions{}) }) + }) +} + +func TestPermissionSelectDisabledClosesAndStaysClosed(t *testing.T) { + v, disabled := PermissionView, false + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Disabled(disabled).Children(func() { PermissionSelect(c, &v, PermissionSelectOptions{Label: "Permission"}) }) + }, 400, 300) + if err := tt.Click("Can view"); err != nil { + t.Fatal(err) + } + disabled = true + tt.Frame() + if tt.HasText("Can edit") { + t.Fatal("disabled permission popup stayed open") + } + disabled = false + tt.Frame() + if tt.HasText("Can edit") { + t.Fatal("reenabling permission reopened popup") + } +} diff --git a/presence_avatars.go b/presence_avatars.go new file mode 100644 index 0000000..9816c85 --- /dev/null +++ b/presence_avatars.go @@ -0,0 +1,148 @@ +package mujica + +import ( + "fmt" + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// Collaborator is someone working on the same document. +type Collaborator struct { + ID, Name string + Image *ui.Bitmap + // Away marks someone present but idle. + Away bool +} + +// PresenceAvatarsOptions configure PresenceAvatars. +type PresenceAvatarsOptions struct { + // Max avatars show before the "+N" button; zero is 4. + Max int + // Following identifies the followed collaborator, empty for none. + Following string + // Size is the avatar diameter; zero is 28. + Size float32 + Disabled bool +} + +// PresenceAvatarsResult is the avatars and the follow request this frame. +type PresenceAvatarsResult struct { + Element *ui.Element + // Follow identifies a collaborator; Unfollow requests stopping. + Follow string + Unfollow bool +} + +// PresenceAvatars displays collaborators, overflow count and follow actions. +func PresenceAvatars(c *ui.Context, people []Collaborator, opts PresenceAvatarsOptions) PresenceAvatarsResult { + if opts.Max < 0 || opts.Size < 0 { + panic("mujica: PresenceAvatars Max and Size must not be negative") + } + limit, size := opts.Max, opts.Size + if limit == 0 { + limit = 4 + } + if size == 0 { + size = 28 + } + u := Density(c).Unit() + var res PresenceAvatarsResult + res.Element = ui.Row(c).AlignItems(ui.Center).Gap(u).Role(ui.RoleGroup).Label(fmt.Sprintf(msg(c, "presence_avatars.label"), len(people))). + FocusGroup(ui.Horizontal).Disabled(opts.Disabled) + disabled := res.Element.IsDisabled() + open := ui.Local(res.Element, "presence_avatars.open", func() bool { return false }) + rows := ui.Local(res.Element, "presence_avatars.rows", func() ui.ListState { return ui.ListState{} }) + *open = *open && len(people) > limit && !disabled + pick := func(p Collaborator) { + if disabled { + return + } + if p.ID == opts.Following { + res.Unfollow = true + } else { + res.Follow = p.ID + } + *open = false + slog.Debug("mujica: presence_avatars follow", "id", p.ID, "unfollow", res.Unfollow) + } + res.Element.Children(func() { + for i, p := range people { + if i >= limit { + break + } + if presenceAvatar(c, p, size, p.ID == opts.Following).Clicked() { + pick(p) + } + } + if extra := len(people) - limit; extra > 0 { + more := ui.ButtonBase(c).Size(max(size+4, theme.MinHitSize), max(size+4, theme.MinHitSize)).Radius((size + 4) / 2).Label(fmt.Sprintf(msg(c, "presence_avatars.more"), extra)) + presenceStyle(c, more, size, false) + more.Children(func() { + ui.Text(c, fmt.Sprintf("+%d", extra)).FontSize(FontSize(c, theme.CaptionSize)).FontWeight(600) + }) + if more.Clicked() && !disabled { + *open = !*open + } + Popover(c, more, open, PopoverOptions{Label: msg(c, "presence_avatars.others")}, func() { + rows.Key = func(i int) any { return people[limit+i].ID } + ui.List(c, rows, extra, func(i int) { + p := people[limit+i] + ui.Row(c).Key(p.ID).Gap(u * 2).AlignItems(ui.Center).Children(func() { + if presenceAvatar(c, p, 24, p.ID == opts.Following).Clicked() { + pick(p) + } + ui.Text(c, p.Name).SingleLine() + }) + }).Width(260).Height(min(240, float32(extra)*Density(c).ControlHeight())) + }) + } + }) + return res +} + +// presenceAvatar builds a collaborator button with presence status. +func presenceAvatar(c *ui.Context, p Collaborator, size float32, following bool) *ui.Element { + if p.ID == "" { + panic("mujica: Collaborator needs an ID") + } + k := Tokens(c) + key := "presence_avatars.follow" + if following { + key = "presence_avatars.unfollow" + } + b := ui.ButtonBase(c).Key(p.ID).Size(max(size+4, theme.MinHitSize), max(size+4, theme.MinHitSize)).Radius((size + 4) / 2).Label(fmt.Sprintf(msg(c, key), p.Name)).Tooltip(p.Name) + if p.Away { + b.Description(msg(c, "presence_avatars.away")) + } + presenceStyle(c, b, size, following) + return b.Children(func() { + Avatar(c, p.Name, AvatarOptions{Image: p.Image, Size: size}) + dot := ui.Box(c).Size(9, 9).Radius(4.5).Absolute().Right(0).Bottom(0).Border(1.5, k.Surface).Role(ui.RoleNone) + if p.Away { + dot.Background(k.Surface).Border(1.5, k.TextMuted) + } else { + dot.Background(k.Success) + } + }) +} + +// presenceStyle styles hover, focus and following rings. +func presenceStyle(c *ui.Context, b *ui.Element, size float32, following bool) { + k := Tokens(c) + b.Shrink(0).Background(k.Surface).FocusRing(false) + if b.Hovered() { + b.Background(k.SurfaceHover) + } + b.DrawOver(func(p *ui.Painter, r ui.Rect) { + if following { + p.Stroke(r, k.AccentText, r.W/2, 2) + } + if b.FocusVisible() { + internal.FocusRing(p, r, r.W/2, k.Focus) + } + }) +} diff --git a/presence_avatars_test.go b/presence_avatars_test.go new file mode 100644 index 0000000..1099044 --- /dev/null +++ b/presence_avatars_test.go @@ -0,0 +1,126 @@ +package mujica + +import ( + "fmt" + "strings" + "testing" + + "github.com/egoist/mygo/ui" +) + +func presenceSample() []Collaborator { + return []Collaborator{{ID: "a", Name: "Ada Lovelace"}, {ID: "b", Name: "Blaise Pascal", Away: true}, + {ID: "c", Name: "Carl Gauss"}, {ID: "d", Name: "Dora Maar"}, {ID: "e", Name: "Emmy Noether"}} +} + +type presenceProbe struct { + people []Collaborator + following string + max int + disabled bool + events []string +} + +func (p *presenceProbe) view(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Padding(12).Children(func() { + r := PresenceAvatars(c, p.people, PresenceAvatarsOptions{Max: p.max, Following: p.following, Disabled: p.disabled}) + if r.Follow != "" { + p.following = r.Follow + p.events = append(p.events, "follow "+r.Follow) + } + if r.Unfollow { + p.following = "" + p.events = append(p.events, "unfollow") + } + }) +} + +func TestPresenceAvatarsFollow(t *testing.T) { + p := &presenceProbe{people: presenceSample(), max: 3} + tt := ui.NewTester(p.view, 400, 300) + if !tt.HasText("+2") { + t.Fatalf("overflow missing: %q", tt.Texts()) + } + if err := tt.Click("Follow Ada Lovelace"); err != nil { + t.Fatal(err) + } + if err := tt.Click("Stop following Ada Lovelace"); err != nil { + t.Fatal(err) + } + if err := tt.Click("2 more people"); err != nil { + t.Fatal(err) + } + if err := tt.Click("Follow Emmy Noether"); err != nil { + t.Fatal(err) + } + want := []string{"follow a", "unfollow", "follow e"} + if len(p.events) != 3 || p.events[2] != want[2] { + t.Fatalf("events %v", p.events) + } +} + +func TestPresenceAvatarsKeyboardAndDisabled(t *testing.T) { + p := &presenceProbe{people: presenceSample()} + tt := ui.NewTester(p.view, 400, 300) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyEnter) + if p.following != "b" { + t.Fatalf("keyboard followed %q", p.following) + } + p = &presenceProbe{people: presenceSample(), disabled: true} + tt = ui.NewTester(p.view, 400, 300) + _ = tt.Click("Follow Ada Lovelace") + if p.following != "" { + t.Fatal("disabled avatars followed") + } +} + +func TestPresenceAvatarsInvalid(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("missing ID did not panic") + } + }() + ui.NewTester((&presenceProbe{people: []Collaborator{{Name: "x"}}}).view, 400, 300) +} + +func TestPresenceAvatarsSnapshot(t *testing.T) { + snapshot(t, "presence_avatars", 300, 70, func(c *ui.Context) { + ui.Column(c).Padding(12).Children(func() { PresenceAvatars(c, presenceSample(), PresenceAvatarsOptions{Following: "c"}) }) + }) +} + +func TestPresenceAvatarsSmallHitArea(t *testing.T) { + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + PresenceAvatars(c, presenceSample(), PresenceAvatarsOptions{Size: 12, Max: 1}) + }, 300, 200) + for _, name := range []string{"Follow Ada Lovelace", "4 more people"} { + r, ok := tt.Find(name) + if !ok || r.W < 28 || r.H < 28 { + t.Fatalf("%s: hit area %v", name, r) + } + } +} + +func TestPresenceAvatarsLargeOverflow(t *testing.T) { + people := make([]Collaborator, 100000) + for i := range people { + people[i] = Collaborator{ID: fmt.Sprint(i), Name: fmt.Sprintf("Member %d", i)} + } + tt := ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); PresenceAvatars(c, people, PresenceAvatarsOptions{Max: 1}) }, 400, 300) + if err := tt.Click("99999 more people"); err != nil { + t.Fatal(err) + } + n := 0 + for _, text := range tt.Texts() { + if strings.HasPrefix(text, "Member ") { + n++ + } + } + if n == 0 || n > 60 { + t.Fatalf("built %d overflow member labels", n) + } +} diff --git a/reactions.go b/reactions.go new file mode 100644 index 0000000..37fd785 --- /dev/null +++ b/reactions.go @@ -0,0 +1,115 @@ +package mujica + +import ( + "fmt" + "log/slog" + "strings" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// Reaction is an emoji and who reacted with it. +type Reaction struct { + Emoji string + Count int + // Mine marks the user's own reaction. + Mine bool + // People names who reacted, for the tooltip. + People []string +} + +// ReactionsOptions configure Reactions. +type ReactionsOptions struct { + // Choices are the emoji the add picker offers; required. + Choices []string + Disabled bool +} + +// ReactionsResult exposes reactions and requested changes. +type ReactionsResult struct { + Element *ui.Element + // Toggled identifies a reaction; Added identifies a picker choice. + Toggled, Added string +} + +// Reactions displays counts, toggles and an add picker. +func Reactions(c *ui.Context, reactions []Reaction, opts ReactionsOptions) ReactionsResult { + if len(opts.Choices) == 0 { + panic("mujica: Reactions needs Choices") + } + u := Density(c).Unit() + var res ReactionsResult + res.Element = ui.Row(c).Wrap().Gap(u * 2).AlignItems(ui.Center).Role(ui.RoleGroup).Label(msg(c, "reactions.label")).Disabled(opts.Disabled) + disabled := res.Element.IsDisabled() + open := ui.Local(res.Element, "reactions.open", func() bool { return false }) + *open = *open && !disabled + res.Element.Children(func() { + for _, r := range reactions { + if r.Count < 1 || r.Emoji == "" { + panic("mujica: Reaction needs an Emoji and a positive Count") + } + if reactionChip(c, r).Clicked() && !disabled { + res.Toggled = r.Emoji + } + } + add := iconAction(c, icons.Must("plus"), msg(c, "reactions.add")) + if add.Clicked() && !disabled { + *open = !*open + } + Popover(c, add, open, PopoverOptions{Label: msg(c, "reactions.pick")}, func() { + ui.Grid(c).Columns(min(len(opts.Choices), 6)).Gap(u).FocusGroup(ui.Horizontal).Children(func() { + for _, e := range opts.Choices { + b := ui.ButtonBase(c).Key(e).Size(36, 36).Radius(theme.ControlRadius).Label(e) + internal.Ring(b, Tokens(c).Focus, theme.ControlRadius) + if b.Hovered() { + b.Background(Tokens(c).SurfaceHover) + } + b.Children(func() { ui.Text(c, e).FontSize(FontSize(c, 20)) }) + if b.Clicked() && !disabled { + res.Added, *open = e, false + } + } + }) + }) + }) + if res.Toggled != "" || res.Added != "" { + slog.Debug("mujica: reactions change", "toggled", res.Toggled != "", "added", res.Added != "") + } + return res +} + +// reactionChip frames and marks the current user's reaction. +func reactionChip(c *ui.Context, r Reaction) *ui.Element { + k := Tokens(c) + u := Density(c).Unit() + label := fmt.Sprintf(msg(c, "reactions.count"), r.Emoji, r.Count) + if r.Mine { + label += ", " + msg(c, "reactions.mine") + } + b := ui.ButtonBase(c).Key(r.Emoji).MinHeight(theme.MinHitSize).MinWidth(theme.MinHitSize).Padding(0, u*2).Gap(u). + Radius(theme.MinHitSize / 2).Role(ui.RoleToggleButton).Selected(r.Mine).Label(label).Transition(colorFade) + if len(r.People) > 0 { + b.Tooltip(strings.Join(r.People, ", ")) + } + internal.Ring(b, k.Focus, theme.MinHitSize/2) + bg, border := k.Surface, k.Border + if r.Mine { + bg, border = k.Selection, k.AccentText + } + if b.Hovered() && !r.Mine { + bg = k.SurfaceHover + } + b.Background(bg).Border(theme.BorderWidth, border) + return b.Children(func() { + ui.Text(c, r.Emoji) + fg := k.TextMuted + if r.Mine { + fg = k.AccentText + } + ui.Text(c, fmt.Sprint(r.Count)).FontFeatures("tnum").FontWeight(600).TextColor(fg) + }) +} diff --git a/reactions_test.go b/reactions_test.go new file mode 100644 index 0000000..d83a652 --- /dev/null +++ b/reactions_test.go @@ -0,0 +1,95 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +type reactionsProbe struct { + items []Reaction + disabled bool + toggled []string + added []string +} + +var reactionChoices = []string{"👍", "❤️", "🎉", "👀", "🙏", "😂", "🔥"} + +func (p *reactionsProbe) view(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Padding(12).Children(func() { + r := Reactions(c, p.items, ReactionsOptions{Choices: reactionChoices, Disabled: p.disabled}) + if r.Toggled != "" { + p.toggled = append(p.toggled, r.Toggled) + } + if r.Added != "" { + p.added = append(p.added, r.Added) + } + }) +} + +func newReactionsProbe() *reactionsProbe { + return &reactionsProbe{items: []Reaction{{Emoji: "👍", Count: 3, Mine: true, People: []string{"Ada", "Blaise", "you"}}, {Emoji: "🎉", Count: 1}}} +} + +func TestReactionsToggleAndAdd(t *testing.T) { + p := newReactionsProbe() + tt := ui.NewTester(p.view, 400, 300) + if err := tt.Click("👍 3, including you"); err != nil { + t.Fatal(err) + } + if err := tt.Click("🎉 1"); err != nil { + t.Fatal(err) + } + if err := tt.Click("Add reaction"); err != nil { + t.Fatal(err) + } + if err := tt.Click("🔥"); err != nil { + t.Fatal(err) + } + if len(p.toggled) != 2 || p.toggled[0] != "👍" || len(p.added) != 1 || p.added[0] != "🔥" { + t.Fatalf("toggled %v added %v", p.toggled, p.added) + } + if _, ok := tt.Find("❤️"); ok { + t.Fatal("picker stayed open") + } +} + +func TestReactionsKeyboard(t *testing.T) { + p := newReactionsProbe() + tt := ui.NewTester(p.view, 400, 300) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeySpace) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnter) + if !tt.HasText("❤️") { + t.Fatal("Enter on add did not open the picker") + } + if len(p.toggled) != 1 { + t.Fatalf("Space toggled %v", p.toggled) + } +} + +func TestReactionsDisabledAndInvalid(t *testing.T) { + p := newReactionsProbe() + p.disabled = true + tt := ui.NewTester(p.view, 400, 300) + _ = tt.Click("🎉 1") + _ = tt.Click("Add reaction") + if len(p.toggled) != 0 || tt.HasText("❤️") { + t.Fatal("disabled reactions acted") + } + defer func() { + if recover() == nil { + t.Fatal("zero count did not panic") + } + }() + ui.NewTester((&reactionsProbe{items: []Reaction{{Emoji: "x"}}}).view, 400, 300) +} + +func TestReactionsSnapshot(t *testing.T) { + snapshot(t, "reactions", 300, 70, func(c *ui.Context) { + ui.Column(c).Padding(12).Children(func() { Reactions(c, newReactionsProbe().items, ReactionsOptions{Choices: reactionChoices}) }) + }) +} diff --git a/recent_files.go b/recent_files.go new file mode 100644 index 0000000..1c67560 --- /dev/null +++ b/recent_files.go @@ -0,0 +1,183 @@ +package mujica + +import ( + "cmp" + "fmt" + "log/slog" + "path" + "slices" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// RecentFile is a file the user opened. +type RecentFile struct { + Path string + Opened time.Time +} + +// RecentFilesOptions configure a RecentFiles. +type RecentFilesOptions struct { + // Revision changes when files are edited in place. + Revision uint64 + // Now dates the groups; zero uses the frame's time. + Now time.Time + Disabled bool +} + +// RecentFilesState is a RecentFiles' choice and place. +type RecentFilesState struct { + rows ui.ListState + selected int + flat []recentRow + data *RecentFile + n int + day time.Time + started bool + revision uint64 +} + +// recentRow holds a group header or file index. +type recentRow struct{ group, file int } + +// RecentFilesResult is the list and what happened this frame. +type RecentFilesResult struct { + Element *ui.Element + Opened *RecentFile + // Removed identifies one file; Cleared requests clearing all files. + Removed string + Cleared bool +} + +// RecentFiles groups recent files by time and supports clearing. +func RecentFiles(c *ui.Context, files []RecentFile, s *RecentFilesState, opts RecentFilesOptions) RecentFilesResult { + if s == nil { + panic("mujica: RecentFiles needs a state") + } + if !s.started { + s.started, s.selected = true, -1 + } + now := opts.Now + if now.IsZero() { + now = c.Now() + } + if s.revision != opts.Revision { + s.day = time.Time{} + s.revision = opts.Revision + } + recentGroup(files, s, now) + k := Tokens(c) + d := Density(c) + var res RecentFilesResult + res.Element = codePanel(c).Disabled(opts.Disabled) + disabled := res.Element.IsDisabled() + if disabled { + copy := *s + s = © + } + res.Element.Children(func() { + codeHeader(c, func() { + ui.Text(c, msg(c, "recent_files.title")).Font(theme.SerifFont).FontWeight(600).Grow(1) + if len(files) > 0 && Button(c, msg(c, "recent_files.clear"), ButtonOptions{Variant: Ghost}).MinHeight(theme.MinHitSize).Clicked() && !disabled { + res.Cleared = true + slog.Debug("mujica: recent_files clear") + } + }) + if len(files) == 0 { + codeCaption(c, msg(c, "recent_files.empty")).Padding(d.Unit() * 4) + return + } + s.rows.Selected = &s.selected + s.rows.Key = func(i int) any { + r := s.flat[i] + if r.file < 0 { + return r.group + } + return files[r.file].Path + } + s.rows.Header = func(i int) bool { return s.flat[i].file < 0 } + s.rows.Label = func(i int) string { + if r := s.flat[i]; r.file >= 0 { + return path.Base(files[r.file].Path) + } + return recentGroupName(c, s.flat[i].group) + } + list := codeRows(c, &s.rows, len(s.flat), func(i int) { + r := s.flat[i] + if r.file < 0 { + ui.Text(c, recentGroupName(c, r.group)).Font(theme.SerifFont).FontSize(FontSize(c, theme.CaptionSize)). + TextColor(k.Ornament.Mix(k.Text, 0.2)).Grow(1) + return + } + f := files[r.file] + FileIcon(c, f.Path, FileIconOptions{}) + ui.Column(c).Grow(1).MinWidth(0).Children(func() { + ui.Text(c, path.Base(f.Path)).SingleLine() + codeCaption(c, path.Dir(f.Path)) + }) + when := f.Opened.Format("15:04") + if r.group >= 2 { + when = f.Opened.Format("Jan 2") + } + codeCaption(c, when).Shrink(0) + if iconAction(c, icons.Must("x"), fmt.Sprintf(msg(c, "recent_files.remove"), path.Base(f.Path))).Clicked() && !disabled { + res.Removed = f.Path + slog.Debug("mujica: recent_files remove") + } + }) + list.Background(k.Surface) + if list.Submitted() && !disabled && s.selected >= 0 && s.selected < len(s.flat) && s.flat[s.selected].file >= 0 { + f := files[s.flat[s.selected].file] + res.Opened = &f + slog.Debug("mujica: recent_files open") + } + }) + return res +} + +func recentGroupName(c *ui.Context, g int) string { + return msg(c, [...]string{"recent_files.today", "recent_files.yesterday", "recent_files.week", "recent_files.earlier"}[g]) +} + +// recentGroup caches files sorted into relative-day groups. +func recentGroup(files []RecentFile, s *RecentFilesState, now time.Time) { + var first *RecentFile + if len(files) > 0 { + first = &files[0] + } + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + if s.data == first && s.n == len(files) && s.day.Equal(today) { + return + } + s.data, s.n, s.day = first, len(files), today + idx := make([]int, len(files)) + for i := range idx { + idx[i] = i + } + slices.SortStableFunc(idx, func(a, b int) int { return cmp.Compare(files[b].Opened.UnixNano(), files[a].Opened.UnixNano()) }) + group := func(t time.Time) int { + switch { + case !t.Before(today): + return 0 + case !t.Before(today.AddDate(0, 0, -1)): + return 1 + case !t.Before(today.AddDate(0, 0, -6)): + return 2 + } + return 3 + } + s.flat = s.flat[:0] + last := -1 + for _, i := range idx { + g := group(files[i].Opened) + if g != last { + s.flat = append(s.flat, recentRow{g, -1}) + last = g + } + s.flat = append(s.flat, recentRow{g, i}) + } +} diff --git a/recent_files_test.go b/recent_files_test.go new file mode 100644 index 0000000..da63658 --- /dev/null +++ b/recent_files_test.go @@ -0,0 +1,172 @@ +package mujica + +import ( + "fmt" + "strings" + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +var recentNow = time.Date(2026, 10, 5, 15, 0, 0, 0, time.UTC) + +func recentSample() []RecentFile { + return []RecentFile{ + {"/court/old/charter.pdf", recentNow.AddDate(0, -2, 0)}, + {"/court/seal.go", recentNow.Add(-2 * time.Hour)}, + {"/court/notes.md", recentNow.Add(-26 * time.Hour)}, + {"/court/crest.png", recentNow.AddDate(0, 0, -4)}, + {"/court/ledger.csv", recentNow.Add(-10 * time.Minute)}, + } +} + +type recentProbe struct { + files []RecentFile + s RecentFilesState + disabled bool + opened []string +} + +func (p *recentProbe) view(c *ui.Context) { + Use(c, Settings{}) + r := RecentFiles(c, p.files, &p.s, RecentFilesOptions{Now: recentNow, Disabled: p.disabled}) + r.Element.Height(420) + if r.Opened != nil { + p.opened = append(p.opened, r.Opened.Path) + } + if r.Removed != "" { + for i, f := range p.files { + if f.Path == r.Removed { + p.files = append(p.files[:i:i], p.files[i+1:]...) + break + } + } + } + if r.Cleared { + p.files = nil + } +} + +func TestRecentFilesGroups(t *testing.T) { + p := &recentProbe{files: recentSample()} + ui.NewTester(p.view, 480, 440) + want := []recentRow{{0, -1}, {0, 4}, {0, 1}, {1, -1}, {1, 2}, {2, -1}, {2, 3}, {3, -1}, {3, 0}} + if len(p.s.flat) != len(want) { + t.Fatalf("rows %v", p.s.flat) + } + for i := range want { + if p.s.flat[i] != want[i] { + t.Fatalf("rows %v, want %v", p.s.flat, want) + } + } +} + +func TestRecentFilesOpenRemoveClear(t *testing.T) { + p := &recentProbe{files: recentSample()} + tt := ui.NewTester(p.view, 480, 440) + for _, s := range []string{"Today", "Yesterday", "This week", "Earlier", "14:50", "Oct 1"} { + if !tt.HasText(s) { + t.Fatalf("missing %q in %q", s, tt.Texts()) + } + } + r, _ := tt.Find("seal.go") + tt.ClickAt(r.X, r.Y+r.H/2) + tt.ClickAt(r.X, r.Y+r.H/2) + if len(p.opened) != 1 || p.opened[0] != "/court/seal.go" { + t.Fatalf("opened %v", p.opened) + } + if err := tt.Click("Remove notes.md from recent"); err != nil { + t.Fatal(err) + } + if len(p.files) != 4 || tt.HasText("Yesterday") { + t.Fatalf("remove left %d files", len(p.files)) + } + _ = tt.Click("Clear") + if len(p.files) != 0 || !tt.HasText("No recent files") { + t.Fatal("clear did not empty the list") + } +} + +func TestRecentFilesKeyboardAndDisabled(t *testing.T) { + p := &recentProbe{files: recentSample()} + tt := ui.NewTester(p.view, 480, 440) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyEnter) + if len(p.opened) != 1 || p.opened[0] != "/court/ledger.csv" { + t.Fatalf("keyboard opened %v", p.opened) + } + p = &recentProbe{files: recentSample(), disabled: true} + tt = ui.NewTester(p.view, 480, 440) + _ = tt.Click("Clear") + if len(p.files) != 5 { + t.Fatal("disabled list cleared") + } +} + +func TestRecentFilesSnapshot(t *testing.T) { + snapshot(t, "recent_files", 460, 440, func(c *ui.Context) { + p := ui.Local(c.Root(), "snap", func() *recentProbe { return &recentProbe{files: recentSample()} }) + ui.Column(c).Padding(12).Children(func() { + RecentFiles(c, (*p).files, &(*p).s, RecentFilesOptions{Now: recentNow}).Element.Height(416) + }) + }) +} + +func TestRecentFilesLargeAndRevision(t *testing.T) { + files := make([]RecentFile, 100000) + for i := range files { + files[i] = RecentFile{Path: fmt.Sprintf("/f-%d.txt", i), Opened: recentNow} + } + s := RecentFilesState{} + revision := uint64(0) + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + RecentFiles(c, files, &s, RecentFilesOptions{Now: recentNow, Revision: revision}).Element.Height(220) + }, 500, 250) + n := 0 + for _, text := range tt.Texts() { + if strings.HasPrefix(text, "f-") { + n++ + } + } + if n == 0 || n > 60 { + t.Fatalf("built %d recent files", n) + } + files[0].Opened = recentNow.AddDate(0, -1, 0) + revision++ + tt.Frame() + if r := s.flat[len(s.flat)-1]; r.group != 3 || r.file != 0 { + t.Fatalf("revised last row %+v", r) + } +} + +func TestRecentFilesDisablingBeforeDeliveredKey(t *testing.T) { + p := &recentProbe{files: recentSample()} + tt := ui.NewTester(p.view, 480, 440) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyDown) + before := p.s.selected + p.disabled = true + tt.Key(0, ui.KeyDown) + if p.s.selected != before { + t.Fatalf("disabled row %d, want %d", p.s.selected, before) + } +} + +func TestRecentFilesRemovedSelectionDoesNotOpenAnother(t *testing.T) { + p := &recentProbe{files: []RecentFile{{Path: "a.txt", Opened: recentNow}, {Path: "b.txt", Opened: recentNow}}} + tt := ui.NewTester(p.view, 480, 440) + if err := tt.Click("b.txt"); err != nil { + t.Fatal(err) + } + p.files = p.files[:1] + tt.Frame() + tt.Key(0, ui.KeyEnter) + if len(p.opened) != 0 { + t.Fatalf("opened %v after removing selection", p.opened) + } +} diff --git a/remote_cursor.go b/remote_cursor.go new file mode 100644 index 0000000..1e1f5a7 --- /dev/null +++ b/remote_cursor.go @@ -0,0 +1,85 @@ +package mujica + +import ( + "fmt" + "hash/fnv" + "strings" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// RemoteCaret describes a collaborator's caret and selection offsets. +type RemoteCaret struct { + ID, Name string + // Offset and Anchor bound the selection, equal for a caret. + Offset, Anchor int +} + +// RemoteCursorOptions configure RemoteCursor. +type RemoteCursorOptions struct { + // Caret maps text offsets into overlay-relative rectangles; required. + Caret func(offset int) ui.Rect + // Selection maps offset ranges into overlay-relative rectangles; optional. + Selection func(from, to int) []ui.Rect +} + +// RemoteCursor overlays collaborator carets, selections and names; input passes through. +// Place it filling the editor's box, after the editor. +func RemoteCursor(c *ui.Context, carets []RemoteCaret, opts RemoteCursorOptions) *ui.Element { + if opts.Caret == nil { + panic("mujica: RemoteCursor needs Caret") + } + k := Tokens(c) + names := make([]string, len(carets)) + for i, rc := range carets { + if rc.ID == "" || rc.Offset < 0 || rc.Anchor < 0 { + panic("mujica: RemoteCaret needs an ID and offsets not negative") + } + names[i] = rc.Name + } + size := FontSize(c, theme.CaptionSize) + e := ui.Box(c).Absolute().Top(0).Left(0).Right(0).Bottom(0).PassThrough().Clip().Role(ui.RoleNone) + if len(carets) > 0 { + e.Role(ui.RoleStatus).Label(fmt.Sprintf(msg(c, "remote_cursor.label"), strings.Join(names, ", "))) + } + return e.Draw(func(p *ui.Painter, r ui.Rect) { + for _, rc := range carets { + col := remoteColor(k, rc.ID) + if opts.Selection != nil && rc.Anchor != rc.Offset { + for _, b := range opts.Selection(min(rc.Anchor, rc.Offset), max(rc.Anchor, rc.Offset)) { + p.Fill(remoteRect(p, ui.Rect{X: r.X + b.X, Y: r.Y + b.Y, W: b.W, H: b.H}), col.Alpha(0.22), 2) + } + } + cb := opts.Caret(rc.Offset) + x, y := internal.Snap(p, r.X+cb.X), internal.Snap(p, r.Y+cb.Y) + p.Fill(ui.Rect{X: x - 1, Y: y, W: 2, H: cb.H}, col, 0) + // Keep the name flag inside the overlay. + tw, th := p.MeasureText(0, ui.Span{Text: rc.Name, Size: size}) + flag := ui.Rect{X: x - 1, Y: y - th - 2, W: min(tw+8, r.W), H: th + 2} + if flag.Y < r.Y { + flag.Y = y + cb.H + } + flag.X = max(r.X, min(flag.X, r.X+r.W-flag.W)) + flag.Y = max(r.Y, min(flag.Y, r.Y+r.H-flag.H)) + flag = remoteRect(p, flag) + p.Fill(flag, col, 2) + p.Clip(flag, 2, func() { p.Text(flag.X+4, flag.Y+1, rc.Name, size, k.Surface) }) + } + }) +} + +func remoteRect(p *ui.Painter, r ui.Rect) ui.Rect { + x, y := internal.Snap(p, r.X), internal.Snap(p, r.Y) + return ui.Rect{X: x, Y: y, W: internal.Snap(p, r.X+r.W) - x, H: internal.Snap(p, r.Y+r.H) - y} +} + +// remoteColor assigns a palette color by collaborator ID. +func remoteColor(k theme.Tokens, id string) ui.Color { + h := fnv.New32a() + h.Write([]byte(id)) + pal := flamegraphPalette(k) + return pal[h.Sum32()%uint32(len(pal))] +} diff --git a/remote_cursor_test.go b/remote_cursor_test.go new file mode 100644 index 0000000..72ad2d1 --- /dev/null +++ b/remote_cursor_test.go @@ -0,0 +1,87 @@ +package mujica + +import ( + "image/color" + "testing" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// remoteMono maps offsets onto 40-column, 8×20 DIP cells. +func remoteMono() RemoteCursorOptions { + at := func(o int) (float32, float32) { return float32(o%40) * 8, float32(o/40) * 20 } + return RemoteCursorOptions{ + Caret: func(o int) ui.Rect { x, y := at(o); return ui.Rect{X: x, Y: y, W: 2, H: 20} }, + Selection: func(from, to int) []ui.Rect { + var out []ui.Rect + for o := from; o < to; o++ { + x, y := at(o) + out = append(out, ui.Rect{X: x, Y: y, W: 8, H: 20}) + } + return out + }, + } +} + +func remoteTester(carets []RemoteCaret, button *int) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{Mode: Light}) + ui.Box(c).Size(320, 120).Children(func() { + if Button(c, "Underneath", ButtonOptions{}).Size(320, 120).Clicked() { + *button++ + } + RemoteCursor(c, carets, remoteMono()) + }) + }, 340, 140) +} + +func TestRemoteCursorDrawCaretAndSelection(t *testing.T) { + carets := []RemoteCaret{{ID: "ada", Name: "Ada", Offset: 45, Anchor: 42}} + tt := remoteTester(carets, new(int)) + col := remoteColor(theme.Light(), "ada") + img := tt.Image() + // Offset 45 places the caret at x=40, y=20..40. + if got := img.RGBAAt(40, 35); got != (color.RGBA{col.R, col.G, col.B, 255}) { + t.Fatalf("caret pixel %v, want %v", got, col) + } + if got := img.RGBAAt(28, 35); got == (color.RGBA{col.R, col.G, col.B, 255}) || got == img.RGBAAt(100, 35) { + t.Fatalf("selection pixel %v not tinted", got) + } + if !tt.HasText("Also editing: Ada") { + t.Fatalf("texts %q", tt.Texts()) + } +} + +func TestRemoteCursorPassInput(t *testing.T) { + n := 0 + tt := remoteTester([]RemoteCaret{{ID: "b", Name: "Blaise", Offset: 3, Anchor: 3}}, &n) + tt.ClickAt(30, 10) + if n != 1 { + t.Fatal("the overlay took the click") + } +} + +func TestRemoteCursorInvalid(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("missing ID did not panic") + } + }() + remoteTester([]RemoteCaret{{Name: "x"}}, new(int)) +} + +func TestRemoteCursorSnapshot(t *testing.T) { + snapshot(t, "remote_cursor", 360, 120, func(c *ui.Context) { + k := Tokens(c) + ui.Box(c).Margin(12).Size(320, 80).Background(k.Surface).Children(func() { + ui.Column(c).Children(func() { + for _, l := range []string{"The seal of the court is kept", "by the chancellor, who answers"} { + ui.Text(c, l).Font(theme.MonoFont).FontSize(FontSize(c, 13)).Height(20) + } + }) + RemoteCursor(c, []RemoteCaret{{ID: "ada", Name: "Ada", Offset: 48, Anchor: 43}, {ID: "blaise", Name: "Blaise", Offset: 8, Anchor: 8}}, remoteMono()) + }) + }) +} diff --git a/rename_inline.go b/rename_inline.go new file mode 100644 index 0000000..3633525 --- /dev/null +++ b/rename_inline.go @@ -0,0 +1,197 @@ +package mujica + +import ( + "log/slog" + "path" + "strings" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// RenameInlineOptions configure a RenameInline. +type RenameInlineOptions struct { + // Validate returns an error message, or empty for acceptance. + Validate func(name string) string + // Folder selects the whole name: folders have no extension. + Folder bool + Disabled bool +} + +// RenameInlineResult is a RenameInline and its outcome in this frame. +type RenameInlineResult struct { + Element *ui.Element + // Renamed reports a saved name; Canceled reports an abandoned edit. + Renamed, Canceled bool +} + +// renameState is one editing session. +type renameState struct { + active bool + draft string + // gen rebuilds the editor with its caret at the end. + gen int + // tail appends text next frame behind the caret. + tail string + shown bool + stemSel bool + focused bool + err string + folder bool +} + +// renameStem is the part of name before its extension. +func renameStem(name string, folder bool) string { + ext := path.Ext(name) + if folder || ext == name || ext == "" { + return name + } + return strings.TrimSuffix(name, ext) +} + +// RenameInline edits names with validation and initial stem replacement. +// Enter validates; Escape cancels; blur confirms valid names. +func RenameInline(c *ui.Context, name *string, editing *bool, opts RenameInlineOptions) RenameInlineResult { + if name == nil || editing == nil { + panic("mujica: RenameInline needs a name and editing") + } + var res RenameInlineResult + res.Element = ui.Row(c).MinWidth(0).MinHeight(theme.MinHitSize).AlignItems(ui.Center).Disabled(opts.Disabled) + st := ui.Local(res.Element, "rename_inline", func() renameState { return renameState{} }) + if res.Element.IsDisabled() && *editing { + *editing = false + res.Canceled = st.active + st.active = false + } + res.Element.Children(func() { + if !*editing { + st.active = false + t := ui.Text(c, *name).SingleLine().MinWidth(0) + if t.DoubleClicked() && !res.Element.IsDisabled() { + *editing = true + c.Invalidate() + } + return + } + stem := renameStem(*name, opts.Folder) + if !st.active { + // Start with the stem and its caret at the end. + *st = renameState{active: true, draft: stem, gen: st.gen + 1, tail: (*name)[len(stem):], stemSel: true, folder: opts.Folder} + slog.Debug("mujica: rename_inline start") + } + switch { + case st.tail != "" && st.shown: + st.draft, st.tail, st.shown = st.draft+st.tail, "", false + case st.tail != "": + // Show the head for one frame to place its caret. + st.shown = true + c.Invalidate() + } + finish := func(ok bool) { + if ok { + if why := renameCheck(c, st.draft, opts.Validate); why != "" { + st.err = why + return + } + res.Renamed = st.draft != *name + *name = st.draft + } else { + res.Canceled = true + } + *editing, st.active = false, false + slog.Debug("mujica: rename_inline end", "renamed", res.Renamed) + } + ui.Box(c).Key(st.gen).Grow(1).MinWidth(0).Children(func() { + in := TextInput(c, &st.draft, TextInputOptions{Label: msg(c, "rename_inline.label"), Error: st.err}) + in.Element.FillWidth() + in.Input.AutoFocus() + if in.Changed() { + st.err = "" + renameTyped(st, *name, opts.Folder) + } + renameSelection(c, in.Input, st) + switch { + case in.Input.Shortcut(0, ui.KeyEscape): + finish(false) + case in.Submitted(): + finish(true) + case in.Input.Focused(): + st.focused = true + case st.focused && st.tail == "": + // Confirm valid names when focus leaves. + finish(renameCheck(c, st.draft, opts.Validate) == "") + } + }) + }) + return res +} + +func renameCheck(c *ui.Context, name string, validate func(string) string) string { + if strings.TrimSpace(name) == "" { + return msg(c, "rename_inline.empty") + } + if validate != nil { + return validate(name) + } + return "" +} + +// renameSelection marks the stem for replacement on initial input. +func renameSelection(c *ui.Context, in *ui.Element, st *renameState) { + k := Tokens(c) + stem := renameStem(st.draft, st.folder) + if st.stemSel && st.tail == "" { + w, _ := c.MeasureText(0, ui.Span{Text: stem, Size: FontSize(c, theme.BodySize)}) + in.Draw(func(p *ui.Painter, r ui.Rect) { + p.Fill(ui.Rect{X: r.X, Y: r.Y + 2, W: w, H: r.H - 4}, k.Selection, 2) + }) + } + in.HandleInput(func(ev ui.InputEvent) bool { + if !st.stemSel || in.IsDisabled() { + return false + } + switch { + case ev.Kind == ui.InputKeyDown && ev.Mods == 0: + switch ev.Key { + case ui.KeyBackspace, ui.KeyDelete: + renameReplace(st, "") + return true + case ui.KeyLeft, ui.KeyHome, ui.KeyUp: + // An empty head clamps the caret to the start. + st.draft, st.tail, st.stemSel, st.shown = "", st.draft, false, false + return true + case ui.KeyRight: + st.stemSel = false + return true + } + } + if ev.Kind == ui.InputPointerDown || ev.Kind == ui.InputKeyDown && + (ev.Key == ui.KeyLeft || ev.Key == ui.KeyRight || ev.Key == ui.KeyHome || ev.Key == ui.KeyEnd || ev.Key == ui.KeyUp || ev.Key == ui.KeyDown || ev.Key == ui.KeyA && ev.Mods&ui.Cmd != 0) { + st.stemSel = false + } + return false + }) +} + +// renameReplace substitutes the stem and rebuilds the caret. +func renameReplace(st *renameState, text string) { + ext := st.draft[len(renameStem(st.draft, st.folder)):] + st.draft, st.tail, st.gen, st.stemSel, st.shown = text, ext, st.gen+1, false, false +} + +// renameTyped replaces the stem with newly inserted text. +func renameTyped(st *renameState, name string, folder bool) { + if !st.stemSel { + return + } + stem := renameStem(name, folder) + ext := name[len(stem):] + st.stemSel = false + if strings.HasPrefix(st.draft, stem) && strings.HasSuffix(st.draft, ext) && len(st.draft) >= len(name) { + typed := st.draft[len(stem) : len(st.draft)-len(ext)] + st.draft = stem + ext + renameReplace(st, typed) + slog.Debug("mujica: rename_inline replace stem") + } +} diff --git a/rename_inline_test.go b/rename_inline_test.go new file mode 100644 index 0000000..f530ee1 --- /dev/null +++ b/rename_inline_test.go @@ -0,0 +1,169 @@ +package mujica + +import ( + "strings" + "testing" + + "github.com/egoist/mygo/ui" +) + +type renameProbe struct { + name string + editing, disabled bool + folder bool + renamed, canceled int +} + +func (p *renameProbe) view(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Padding(12).Gap(8).Children(func() { + r := RenameInline(c, &p.name, &p.editing, RenameInlineOptions{Folder: p.folder, Disabled: p.disabled, + Validate: func(n string) string { + if strings.ContainsRune(n, '/') { + return "No slashes" + } + return "" + }}) + if r.Renamed { + p.renamed++ + } + if r.Canceled { + p.canceled++ + } + Button(c, "Elsewhere", ButtonOptions{}) + }) +} + +func TestRenameStem(t *testing.T) { + for in, want := range map[string]string{"seal.txt": "seal", "a.tar.gz": "a.tar", ".env": ".env", "README": "README"} { + if got := renameStem(in, false); got != want { + t.Errorf("%s: stem %q, want %q", in, got, want) + } + } + if renameStem("v1.2", true) != "v1.2" { + t.Fatal("folder stem dropped its dot part") + } +} + +func TestRenameInlineTypingReplacesStem(t *testing.T) { + p := &renameProbe{name: "seal.txt", editing: true} + tt := ui.NewTester(p.view, 400, 200) + if !tt.Focused("Name") { + t.Fatal("editor did not take the focus") + } + tt.Type("wax") + tt.Type("y") + tt.Key(0, ui.KeyEnter) + if p.name != "waxy.txt" || p.renamed != 1 || p.editing { + t.Fatalf("name %q renamed %d editing %v", p.name, p.renamed, p.editing) + } +} + +func TestRenameInlineKeysAroundSelection(t *testing.T) { + p := &renameProbe{name: "seal.txt", editing: true} + tt := ui.NewTester(p.view, 400, 200) + tt.Key(0, ui.KeyLeft) + tt.Type("royal ") + tt.Key(0, ui.KeyEnter) + if p.name != "royal seal.txt" { + t.Fatalf("Left then typing gave %q", p.name) + } + p.editing = true + tt.Frame() + tt.Key(0, ui.KeyBackspace) + tt.Type("crest") + tt.Key(0, ui.KeyEnter) + if p.name != "crest.txt" { + t.Fatalf("Backspace then typing gave %q", p.name) + } + p.editing = true + tt.Frame() + tt.Key(0, ui.KeyRight) + tt.Type("s") + tt.Key(0, ui.KeyEnter) + if p.name != "crests.txt" { + t.Fatalf("Right then typing gave %q", p.name) + } +} + +func TestRenameInlineValidationAndCancel(t *testing.T) { + p := &renameProbe{name: "seal.txt", editing: true} + tt := ui.NewTester(p.view, 400, 200) + tt.Type("a/b") + tt.Key(0, ui.KeyEnter) + if !p.editing || p.name != "seal.txt" || !tt.HasText("No slashes") { + t.Fatalf("invalid name accepted: %q %q", p.name, tt.Texts()) + } + tt.Key(0, ui.KeyEscape) + if p.editing || p.name != "seal.txt" || p.canceled != 1 { + t.Fatalf("Escape: name %q editing %v canceled %d", p.name, p.editing, p.canceled) + } +} + +func TestRenameInlineBlurCommitsAndFolder(t *testing.T) { + p := &renameProbe{name: "v1.2", editing: true, folder: true} + tt := ui.NewTester(p.view, 400, 200) + tt.Type("archive") + if err := tt.Click("Elsewhere"); err != nil { + t.Fatal(err) + } + if p.name != "archive" || p.editing { + t.Fatalf("blur gave %q editing %v", p.name, p.editing) + } +} + +func TestRenameInlineDoubleClickAndDisabled(t *testing.T) { + p := &renameProbe{name: "seal.txt"} + tt := ui.NewTester(p.view, 400, 200) + r, _ := tt.Find("seal.txt") + tt.ClickAt(r.X+4, r.Y+4) + tt.ClickAt(r.X+4, r.Y+4) + if !p.editing { + t.Fatal("double click did not start editing") + } + p.disabled = true + tt.Frame() + if p.editing || p.canceled != 1 { + t.Fatal("disabling did not end the edit") + } +} + +func TestRenameInlineSnapshot(t *testing.T) { + snapshot(t, "rename_inline", 360, 110, func(c *ui.Context) { + p := ui.Local(c.Root(), "snap", func() *renameProbe { return &renameProbe{name: "royal seal.txt", editing: true} }) + ui.Column(c).Padding(12).Gap(8).Children(func() { + RenameInline(c, &(*p).name, &(*p).editing, RenameInlineOptions{}) + name, off := "ledger.md", false + RenameInline(c, &name, &off, RenameInlineOptions{}) + }) + }) +} + +func TestRenameInlineCompositionCommit(t *testing.T) { + p := &renameProbe{name: "seal.txt", editing: true} + tt := ui.NewTester(p.view, 400, 200) + tt.Compose("印", 1) + tt.Type("印章") + tt.Key(0, ui.KeyEnter) + if p.name != "印章.txt" || p.renamed != 1 { + t.Fatalf("committed name %q", p.name) + } +} + +func TestRenameInlineNativeTypingAndPaste(t *testing.T) { + p := &renameProbe{name: "seal.txt", editing: true} + tt := ui.NewTester(p.view, 400, 200) + tt.TypeKey(0, ui.KeyW, "w") + tt.Key(0, ui.KeyEnter) + if p.name != "w.txt" { + t.Fatalf("native typing renamed %q", p.name) + } + p.editing = true + tt.Frame() + tt.SetClipboard("wax") + tt.Key(ui.Cmd, ui.KeyV) + tt.Key(0, ui.KeyEnter) + if p.name != "wax.txt" { + t.Fatalf("paste renamed %q", p.name) + } +} diff --git a/select.go b/select.go index 91e46ac..6f3de2a 100644 --- a/select.go +++ b/select.go @@ -41,6 +41,11 @@ type selectTypeahead struct { at time.Time } +type selectLifetime struct { + disabled bool + generation int +} + // Select chooses *value from options in a popup; typing on the closed select jumps to a match. func Select[T comparable](c *ui.Context, value *T, options []SelectOption[T], opts SelectOptions) ChoiceResult { k := Tokens(c) @@ -48,7 +53,15 @@ func Select[T comparable](c *ui.Context, value *T, options []SelectOption[T], op // SelectBase chooses into a copy as it is built; a choice counts once the option is known enabled. pick := old var s *ui.SelectParts[T] - ui.Row(c).Shrink(0).Disabled(opts.Disabled).Children(func() { s = ui.SelectBase(c, &pick) }) + site := ui.Row(c).Shrink(0).Disabled(opts.Disabled) + life := ui.Local(site, selectKey{"lifetime"}, func() selectLifetime { return selectLifetime{} }) + if disabled := site.IsDisabled(); disabled != life.disabled { + life.disabled = disabled + life.generation++ + } + site.Children(func() { + ui.Row(c).Key(life.generation).Children(func() { s = ui.SelectBase(c, &pick) }) + }) tr := s.Trigger commit := func() { if pick != *value && !tr.IsDisabled() && selectEnabled(options, pick) { @@ -62,6 +75,9 @@ func Select[T comparable](c *ui.Context, value *T, options []SelectOption[T], op } ta := ui.Local(tr, selectKey{"typeahead"}, func() selectTypeahead { return selectTypeahead{} }) tr.TextCaret(ui.Rect{W: 1, H: Density(c).ControlHeight()}).HandleInput(func(ev ui.InputEvent) bool { + if tr.IsDisabled() { + return false + } if ev.Kind != ui.InputText || strings.TrimSpace(ev.Text) == "" { return false } @@ -97,8 +113,7 @@ func Select[T comparable](c *ui.Context, value *T, options []SelectOption[T], op }) pick = *value lastHi := ui.Local(tr, selectKey{"shown"}, func() int { return -1 }) - if opts.Disabled { - // SelectParts has no way to close it (ui/base.go:310): a disabled select builds no popup. + if tr.IsDisabled() { return ChoiceResult{tr, false} } s.Popup(func(panel *ui.Element) { diff --git a/share_dialog.go b/share_dialog.go new file mode 100644 index 0000000..f8babc0 --- /dev/null +++ b/share_dialog.go @@ -0,0 +1,142 @@ +package mujica + +import ( + "fmt" + "log/slog" + "strings" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// ShareMember is someone the item is shared with. +type ShareMember struct { + ID, Name, Email string + Permission Permission + // Owner cannot be changed or removed. + Owner bool +} + +// ShareInvite is a new person to share with. +type ShareInvite struct { + Email string + Permission Permission +} + +// ShareDialogOptions configure a ShareDialog. +type ShareDialogOptions struct { + // Title names what is shared. + Title string + // Link is the item's address; empty hides the link row. + Link string + Disabled bool + // OnCopy handles copying the link; nil uses the clipboard. + OnCopy func(link string) +} + +// ShareDialogResult is what happened in the dialog this frame. +type ShareDialogResult struct { + Element *ui.Element + // Invited requests membership; Changed and Removed identify applied changes. + Invited *ShareInvite + Changed, Removed string + // Copied reports the link was written to the clipboard. + Copied bool +} + +type shareDraft struct { + email string + perm Permission + rows ui.ListState +} + +// ShareDialog manages invitations, permissions, members and copying links. +func ShareDialog(c *ui.Context, open *bool, members *[]ShareMember, opts ShareDialogOptions) ShareDialogResult { + if open == nil || members == nil || opts.Title == "" { + panic("mujica: ShareDialog needs open, members and a Title") + } + var res ShareDialogResult + site := ui.Box(c).Disabled(opts.Disabled) + if site.IsDisabled() { + *open = false + } + draft := ui.Local(site, "share_dialog.draft", func() shareDraft { return shareDraft{} }) + u := Density(c).Unit() + res.Element = Dialog(c, open, DialogOptions{Title: fmt.Sprintf(msg(c, "share_dialog.title"), opts.Title), Width: 480}, func() { + ui.Column(c).Gap(u * 3).Disabled(opts.Disabled).Children(func() { + ui.Row(c).Gap(u * 2).AlignItems(ui.Start).Children(func() { + in := TextInput(c, &draft.email, TextInputOptions{Placeholder: msg(c, "share_dialog.placeholder"), Label: msg(c, "share_dialog.invite")}) + in.Element.Grow(1).MinWidth(0) + PermissionSelect(c, &draft.perm, PermissionSelectOptions{Label: msg(c, "share_dialog.invitePermission")}) + send := Button(c, msg(c, "share_dialog.send"), ButtonOptions{Disabled: strings.TrimSpace(draft.email) == ""}) + if (send.Clicked() || in.Submitted()) && strings.TrimSpace(draft.email) != "" && !send.IsDisabled() { + res.Invited = &ShareInvite{Email: strings.TrimSpace(draft.email), Permission: draft.perm} + draft.email = "" + slog.Debug("mujica: share_dialog invite") + } + }) + ui.Text(c, msg(c, "share_dialog.people")).Font(theme.SerifFont).FontWeight(600) + shareMembers(c, members, &res, &draft.rows) + if opts.Link != "" { + Divider(c, DividerOptions{}) + ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() { + ui.Icon(c, icons.Must("external-link")).Size(16, 16).Shrink(0).TextColor(Tokens(c).TextMuted) + ui.Text(c, opts.Link).SingleLine().Grow(1).MinWidth(0).Selectable() + b := Button(c, msg(c, "share_dialog.copy"), ButtonOptions{Variant: Secondary, Icon: icons.Must("copy")}) + if b.Clicked() && !b.IsDisabled() { + if opts.OnCopy != nil { + opts.OnCopy(opts.Link) + } else { + c.WriteClipboard(opts.Link) + } + res.Copied = true + c.Announce(msg(c, "share_dialog.copied")) + slog.Debug("mujica: share_dialog copy link") + } + }) + } + }) + }) + return res +} + +// shareMembers virtualizes membership permissions and removal actions. +func shareMembers(c *ui.Context, members *[]ShareMember, res *ShareDialogResult, rows *ui.ListState) { + k := Tokens(c) + u := Density(c).Unit() + remove := -1 + rows.Key = func(i int) any { return (*members)[i].ID } + _, h := c.Size() + ui.List(c, rows, len(*members), func(i int) { + m := &(*members)[i] + if m.ID == "" { + panic("mujica: ShareMember needs an ID") + } + ui.Row(c).Key(m.ID).Gap(u * 2).AlignItems(ui.Center).MinHeight(Density(c).ControlHeight()).Children(func() { + Avatar(c, m.Name, AvatarOptions{Size: 28}) + ui.Column(c).Grow(1).MinWidth(0).Children(func() { + ui.Text(c, m.Name).SingleLine() + if m.Email != "" { + ui.Text(c, m.Email).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).SingleLine() + } + }) + if m.Owner { + ui.Text(c, msg(c, "share_dialog.owner")).TextColor(k.TextMuted) + return + } + if PermissionSelect(c, &m.Permission, PermissionSelectOptions{Label: m.Name}).Changed() { + res.Changed = m.ID + } + if b := iconAction(c, icons.Must("x"), fmt.Sprintf(msg(c, "share_dialog.remove"), m.Name)); b.Clicked() && !b.IsDisabled() { + remove = i + } + }) + }).Height(min(float32(len(*members))*(Density(c).ControlHeight()+u), max(28, h*0.4))).FillWidth() + if remove >= 0 { + res.Removed = (*members)[remove].ID + *members = append((*members)[:remove:remove], (*members)[remove+1:]...) + slog.Debug("mujica: share_dialog remove") + } +} diff --git a/share_dialog_test.go b/share_dialog_test.go new file mode 100644 index 0000000..dc427a7 --- /dev/null +++ b/share_dialog_test.go @@ -0,0 +1,192 @@ +package mujica + +import ( + "fmt" + "strings" + "testing" + + "github.com/egoist/mygo/ui" +) + +type shareProbe struct { + open bool + members []ShareMember + disabled bool + invites []ShareInvite + changed []string + removed []string + copied int +} + +func newShareProbe() *shareProbe { + return &shareProbe{open: true, members: []ShareMember{ + {ID: "o", Name: "Ada Lovelace", Email: "ada@court.org", Permission: PermissionEdit, Owner: true}, + {ID: "b", Name: "Blaise Pascal", Email: "blaise@court.org", Permission: PermissionView}, + }} +} + +func (p *shareProbe) view(c *ui.Context) { + Use(c, Settings{}) + r := ShareDialog(c, &p.open, &p.members, ShareDialogOptions{Title: "Ledger", Link: "https://court.org/ledger", Disabled: p.disabled}) + if r.Invited != nil { + p.invites = append(p.invites, *r.Invited) + } + if r.Changed != "" { + p.changed = append(p.changed, r.Changed) + } + if r.Removed != "" { + p.removed = append(p.removed, r.Removed) + } + if r.Copied { + p.copied++ + } +} + +func TestShareDialogInvite(t *testing.T) { + p := newShareProbe() + tt := ui.NewTester(p.view, 640, 520) + if !tt.HasText("Share “Ledger”") || !tt.HasText("Owner") { + t.Fatalf("texts %q", tt.Texts()) + } + for !tt.Focused("Email to invite") { + tt.Key(0, ui.KeyTab) + } + tt.Type("carl@court.org") + tt.Key(0, ui.KeyEnter) + if len(p.invites) != 1 || p.invites[0] != (ShareInvite{"carl@court.org", PermissionView}) { + t.Fatalf("invites %v", p.invites) + } +} + +func TestShareDialogChangeRemoveCopy(t *testing.T) { + p := newShareProbe() + tt := ui.NewTester(p.view, 640, 520) + if err := tt.Click("Copy link"); err != nil { + t.Fatal(err) + } + if p.copied != 1 || tt.Clipboard() != "https://court.org/ledger" { + t.Fatalf("copied %d clipboard %q", p.copied, tt.Clipboard()) + } + if err := tt.Click("Remove Blaise Pascal"); err != nil { + t.Fatal(err) + } + if len(p.removed) != 1 || len(p.members) != 1 || tt.HasText("Blaise Pascal") { + t.Fatalf("removed %v members %v", p.removed, p.members) + } + if tt.HasText("Remove Ada Lovelace") { + t.Fatal("owner can be removed") + } +} + +func TestShareDialogPermissionChange(t *testing.T) { + p := newShareProbe() + tt := ui.NewTester(p.view, 640, 520) + // Two "Can view" selects show: the invite's, then Blaise's. + inv, _ := tt.Find("Permission for the invite") + r, _ := tt.Find("Blaise Pascal") + tt.ClickAt(inv.X+inv.W/2, r.Y+r.H/2) + if err := tt.Click("Can comment"); err != nil { + t.Fatal(err) + } + if p.members[1].Permission != PermissionComment || len(p.changed) != 1 || p.changed[0] != "b" { + t.Fatalf("permission %d changed %v", p.members[1].Permission, p.changed) + } +} + +func TestShareDialogDisabled(t *testing.T) { + p := newShareProbe() + p.disabled = true + tt := ui.NewTester(p.view, 640, 520) + _ = tt.Click("Remove Blaise Pascal") + _ = tt.Click("Copy link") + if len(p.members) != 2 || p.copied != 0 { + t.Fatal("disabled dialog acted") + } +} + +func TestShareDialogSnapshot(t *testing.T) { + snapshot(t, "share_dialog", 560, 440, func(c *ui.Context) { + p := ui.Local(c.Root(), "snap", newShareProbe) + ShareDialog(c, &(*p).open, &(*p).members, ShareDialogOptions{Title: "Ledger", Link: "https://court.org/ledger"}) + }) +} + +func TestShareDialogAncestorDisabledCloses(t *testing.T) { + open, disabled := true, false + members := []ShareMember{{ID: "a", Name: "Ada"}} + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Disabled(disabled).Children(func() { + ShareDialog(c, &open, &members, ShareDialogOptions{Title: "Decree"}) + }) + }, 600, 400) + disabled = true + tt.Frame() + if open || tt.HasText("Share “Decree”") { + t.Fatal("disabled dialog stayed open") + } +} + +func TestShareDialogCopyCallback(t *testing.T) { + open := true + members := []ShareMember{} + var copied []string + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ShareDialog(c, &open, &members, ShareDialogOptions{Title: "Ledger", Link: "https://court.org/ledger", OnCopy: func(s string) { copied = append(copied, s) }}) + }, 600, 400) + if err := tt.Click("Copy link"); err != nil { + t.Fatal(err) + } + if len(copied) != 1 || copied[0] != "https://court.org/ledger" || tt.Clipboard() != "" { + t.Fatalf("callback count %d, clipboard %q", len(copied), tt.Clipboard()) + } +} +func TestShareDialogLargeList(t *testing.T) { + open := true + members := make([]ShareMember, 100000) + for i := range members { + members[i] = ShareMember{ID: fmt.Sprint(i), Name: fmt.Sprintf("Member %d", i)} + } + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ShareDialog(c, &open, &members, ShareDialogOptions{Title: "Roster"}) + }, 640, 520) + n := 0 + for _, text := range tt.Texts() { + if strings.HasPrefix(text, "Member ") { + n++ + } + } + if n == 0 || n > 60 { + t.Fatalf("built %d member labels", n) + } +} + +func TestShareDialogSameTitleIndependent(t *testing.T) { + openA, openB := true, false + membersA, membersB := []ShareMember{}, []ShareMember{} + var invited *ShareInvite + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Children(func() { + ShareDialog(c, &openA, &membersA, ShareDialogOptions{Title: "Ledger"}) + r := ShareDialog(c, &openB, &membersB, ShareDialogOptions{Title: "Ledger"}) + if r.Invited != nil { + invited = r.Invited + } + }) + }, 640, 520) + for i := 0; i < 10 && !tt.Focused("Email to invite"); i++ { + tt.Key(0, ui.KeyTab) + } + tt.Type("ada@court.org") + openA, openB = false, true + tt.Frame() + if err := tt.Click("Invite"); err != nil { + t.Fatal(err) + } + if invited != nil { + t.Fatal("second instance inherited invitation draft") + } +} diff --git a/storage_usage.go b/storage_usage.go new file mode 100644 index 0000000..9cfb8da --- /dev/null +++ b/storage_usage.go @@ -0,0 +1,150 @@ +package mujica + +import ( + "fmt" + "log/slog" + "sort" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// StorageSegment is one category of used space. +type StorageSegment struct { + Label string + Bytes int64 +} + +// StorageUsageOptions configure a StorageUsage. +type StorageUsageOptions struct { + // Capacity is the total space; required. + Capacity int64 + // Label names the volume. + Label string + // Revision changes when segments are edited in place. + Revision uint64 +} + +type storageUsageState struct { + data *StorageSegment + n int + revision uint64 + capacity, used int64 + ends []int64 + rows ui.ListState +} + +// StorageUsage displays segmented capacity with keyboard readouts and details. +func StorageUsage(c *ui.Context, segments []StorageSegment, opts StorageUsageOptions) *ui.Element { + if opts.Capacity <= 0 { + panic("mujica: StorageUsage needs a positive Capacity") + } + k := Tokens(c) + u := Density(c).Unit() + e := ui.Column(c).Gap(u * 3).Role(ui.RoleGroup).Label(opts.Label) + s := ui.Local(e, "storage_usage.data", func() storageUsageState { return storageUsageState{} }) + var first *StorageSegment + if len(segments) > 0 { + first = &segments[0] + } + if s.data != first || s.n != len(segments) || s.capacity != opts.Capacity || s.revision != opts.Revision { + s.used, s.ends = 0, s.ends[:0] + for _, seg := range segments { + if seg.Bytes < 0 || seg.Bytes > opts.Capacity-s.used { + panic("mujica: StorageUsage segment size out of range") + } + s.used += seg.Bytes + s.ends = append(s.ends, s.used) + } + s.data, s.n, s.capacity, s.revision = first, len(segments), opts.Capacity, opts.Revision + } + used := s.used + pal := flamegraphPalette(k) + pct := func(b int64) float64 { return 100 * float64(b) / float64(opts.Capacity) } + bytes := func(b int64) string { return processListBytes(uint64(b)) } + readout := func(i int) string { + if i < 0 || i >= len(segments) { + return fmt.Sprintf(msg(c, "storage_usage.free"), bytes(opts.Capacity-used), pct(opts.Capacity-used)) + } + return fmt.Sprintf("%s: %s (%.1f%%)", segments[i].Label, bytes(segments[i].Bytes), pct(segments[i].Bytes)) + } + summary := fmt.Sprintf(msg(c, "storage_usage.used"), bytes(used), bytes(opts.Capacity)) + focus := ui.Local(e, "storage_usage.focus", func() int { return 0 }) + *focus = min(*focus, len(segments)) + e.Children(func() { + ui.Row(c).Gap(u * 2).Children(func() { + if opts.Label != "" { + ui.Text(c, opts.Label).Font(theme.SerifFont).FontWeight(600).Grow(1) + } + ui.Text(c, summary).TextColor(k.TextMuted) + }) + bar := ui.Box(c).Height(14).Focusable().Role(ui.RoleMeter).Label(summary).Description(readout(*focus)) + n := len(segments) + 1 + moved := true + switch { + case bar.Shortcut(0, ui.KeyRight) && !bar.IsDisabled(): + *focus = (*focus + 1) % n + case bar.Shortcut(0, ui.KeyLeft) && !bar.IsDisabled(): + *focus = (*focus + n - 1) % n + default: + moved = false + } + if moved { + c.Announce(readout(*focus)) + slog.Debug("mujica: storage_usage focus", "segment", *focus) + } + internal.Ring(bar, k.Focus, theme.ControlRadius) + focused, at := bar.Focused(), *focus + bar.Draw(func(p *ui.Painter, r ui.Rect) { + p.Fill(r, k.Border.Mix(k.Surface, 0.4), 3) + if len(segments) > int(r.W) { + for px := float32(0); px < r.W; px++ { + b := int64(float64(px) / float64(r.W) * float64(opts.Capacity)) + i := sort.Search(len(s.ends), func(i int) bool { return s.ends[i] > b }) + if i == len(segments) { + break + } + p.Fill(ui.Rect{X: internal.Snap(p, r.X+px), Y: r.Y, W: min(1, r.W-px), H: r.H}, pal[i%len(pal)], 0) + } + return + } + x := r.X + for i, s := range segments { + w := internal.Snap(p, r.W*float32(s.Bytes)/float32(opts.Capacity)) + if w <= 0 { + continue + } + seg := ui.Rect{X: x, Y: r.Y, W: max(w-2, 1), H: r.H} + p.Fill(seg, pal[i%len(pal)], 2) + if focused && i == at { + p.Stroke(ui.Rect{X: seg.X - 1, Y: seg.Y - 1, W: seg.W + 2, H: seg.H + 2}, k.Text, 3, 1.5) + } + x += w + } + }) + if focused { + ui.Text(c, readout(at)).FontSize(FontSize(c, theme.CaptionSize)).Role(ui.RoleStatus) + } + ui.List(c, &s.rows, len(segments)+1, func(i int) { + if i == len(segments) { + storageLegend(c, k.Border.Mix(k.Surface, 0.4), msg(c, "storage_usage.freeLabel"), bytes(opts.Capacity-used), pct(opts.Capacity-used)) + return + } + seg := segments[i] + storageLegend(c, pal[i%len(pal)], seg.Label, bytes(seg.Bytes), pct(seg.Bytes)) + }).Height(min(float32(len(segments)+1)*Density(c).ControlHeight(), Density(c).ControlHeight()*6)) + }) + return e +} + +func storageLegend(c *ui.Context, col ui.Color, label, size string, pct float64) { + k := Tokens(c) + ui.Row(c).MinHeight(Density(c).ControlHeight()).Gap(Density(c).Unit() * 2).AlignItems(ui.Center).Children(func() { + ui.Box(c).Size(10, 10).Radius(2).Shrink(0).Background(col).Role(ui.RoleNone) + ui.Text(c, label).Grow(1).MinWidth(0).SingleLine() + ui.Text(c, size).TextColor(k.TextMuted).Shrink(0) + ui.Text(c, fmt.Sprintf("%.1f%%", pct)).TextColor(k.TextMuted).FontFeatures("tnum").Width(52).TextAlign(ui.End).Shrink(0) + }) +} diff --git a/storage_usage_test.go b/storage_usage_test.go new file mode 100644 index 0000000..da1d876 --- /dev/null +++ b/storage_usage_test.go @@ -0,0 +1,102 @@ +package mujica + +import ( + "fmt" + "strings" + "testing" + + "github.com/egoist/mygo/ui" +) + +func storageSample() []StorageSegment { + return []StorageSegment{{"Documents", 24 << 30}, {"Photos", 40 << 30}, {"Apps", 12 << 30}} +} + +func storageTester(segs []StorageSegment, capacity int64) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + StorageUsage(c, segs, StorageUsageOptions{Capacity: capacity, Label: "Court disk"}).Width(420) + }, 460, 300) +} + +func TestStorageUsageLegend(t *testing.T) { + tt := storageTester(storageSample(), 128<<30) + for _, s := range []string{"76.0 GB of 128.0 GB used", "Photos", "40.0 GB", "31.2%", "Free", "52.0 GB", "40.6%"} { + if !tt.HasText(s) { + t.Fatalf("missing %q in %q", s, tt.Texts()) + } + } +} + +func TestStorageUsageKeyboardReadout(t *testing.T) { + tt := storageTester(storageSample(), 128<<30) + tt.Key(0, ui.KeyTab) + if !tt.HasText("Documents: 24.0 GB (18.8%)") { + t.Fatalf("focused readout missing: %q", tt.Texts()) + } + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyRight) + if !tt.HasText("Free: 52.0 GB (40.6%)") { + t.Fatalf("readout after three Rights: %q", tt.Texts()) + } + ann := tt.Announcements() + if len(ann) == 0 || ann[len(ann)-1] != "Free: 52.0 GB (40.6%)" { + t.Fatalf("announcements %q", ann) + } + tt.Key(0, ui.KeyRight) + if !tt.HasText("Documents: 24.0 GB (18.8%)") { + t.Fatal("Right did not wrap to the first segment") + } +} + +func TestStorageUsageInvalid(t *testing.T) { + for _, f := range []func(){ + func() { storageTester(storageSample(), 0) }, + func() { storageTester(storageSample(), 10) }, + func() { storageTester([]StorageSegment{{"x", -1}}, 10) }, + } { + func() { + defer func() { + if recover() == nil { + t.Error("invalid storage did not panic") + } + }() + f() + }() + } +} + +func TestStorageUsageSnapshot(t *testing.T) { + snapshot(t, "storage_usage", 460, 280, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { + StorageUsage(c, storageSample(), StorageUsageOptions{Capacity: 128 << 30, Label: "Court disk"}) + }) + }) +} + +func TestStorageUsageOverflow(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("overflowing storage was accepted") + } + }() + storageTester([]StorageSegment{{"a", 1 << 62}, {"b", 1 << 62}}, 1<<62) +} + +func TestStorageUsageLargeLegend(t *testing.T) { + segments := make([]StorageSegment, 100000) + for i := range segments { + segments[i] = StorageSegment{Label: fmt.Sprintf("Category %d", i), Bytes: 1} + } + tt := storageTester(segments, int64(len(segments))) + n := 0 + for _, text := range tt.Texts() { + if strings.HasPrefix(text, "Category ") { + n++ + } + } + if n == 0 || n > 60 { + t.Fatalf("built %d legend labels", n) + } +} diff --git a/terminal.go b/terminal.go index deee353..b9ee8cf 100644 --- a/terminal.go +++ b/terminal.go @@ -223,7 +223,7 @@ func terminalPaint(p *ui.Painter, r ui.Rect, s *TerminalState, k theme.Tokens, f if st.Strike { ly = y + ch/2 } - codeRule(p, x0, x0+float32(end-col)*cw, ly, fg) + internal.Rule(p, x0, x0+float32(end-col)*cw, ly, fg) } col = end } @@ -232,7 +232,7 @@ func terminalPaint(p *ui.Painter, r ui.Rect, s *TerminalState, k theme.Tokens, f continue } mr := ui.Rect{X: ox + float32(m.pos.Col)*cw, Y: y, W: float32(m.n) * cw, H: ch} - codeRule(p, mr.X, mr.X+mr.W, y+ch-1, k.AccentText) + internal.Rule(p, mr.X, mr.X+mr.W, y+ch-1, k.AccentText) if mi == s.current { p.Stroke(mr, k.AccentText, 1, 1.5) } @@ -248,7 +248,7 @@ func terminalPaint(p *ui.Painter, r ui.Rect, s *TerminalState, k theme.Tokens, f if s.preedit != "" { g := terminalShape(s, s.preedit, font) p.Glyphs(g, cr.X, cr.Y+base, k.AccentText) - codeRule(p, cr.X, cr.X+float32(len([]rune(s.preedit)))*cw, cr.Y+ch-1, k.AccentText) + internal.Rule(p, cr.X, cr.X+float32(len([]rune(s.preedit)))*cw, cr.Y+ch-1, k.AccentText) return } if !focused { diff --git a/testdata/snapshots/darwin/archive_viewer-dark.png b/testdata/snapshots/darwin/archive_viewer-dark.png new file mode 100644 index 0000000..e542032 Binary files /dev/null and b/testdata/snapshots/darwin/archive_viewer-dark.png differ diff --git a/testdata/snapshots/darwin/archive_viewer-light.png b/testdata/snapshots/darwin/archive_viewer-light.png new file mode 100644 index 0000000..edce394 Binary files /dev/null and b/testdata/snapshots/darwin/archive_viewer-light.png differ diff --git a/testdata/snapshots/darwin/file_explorer-dark.png b/testdata/snapshots/darwin/file_explorer-dark.png new file mode 100644 index 0000000..53a2f44 Binary files /dev/null and b/testdata/snapshots/darwin/file_explorer-dark.png differ diff --git a/testdata/snapshots/darwin/file_explorer-light.png b/testdata/snapshots/darwin/file_explorer-light.png new file mode 100644 index 0000000..88eaa90 Binary files /dev/null and b/testdata/snapshots/darwin/file_explorer-light.png differ diff --git a/testdata/snapshots/darwin/file_grid-dark.png b/testdata/snapshots/darwin/file_grid-dark.png new file mode 100644 index 0000000..86e6448 Binary files /dev/null and b/testdata/snapshots/darwin/file_grid-dark.png differ diff --git a/testdata/snapshots/darwin/file_grid-light.png b/testdata/snapshots/darwin/file_grid-light.png new file mode 100644 index 0000000..75a9c73 Binary files /dev/null and b/testdata/snapshots/darwin/file_grid-light.png differ diff --git a/testdata/snapshots/darwin/file_icon-dark.png b/testdata/snapshots/darwin/file_icon-dark.png new file mode 100644 index 0000000..2f44c3c Binary files /dev/null and b/testdata/snapshots/darwin/file_icon-dark.png differ diff --git a/testdata/snapshots/darwin/file_icon-light.png b/testdata/snapshots/darwin/file_icon-light.png new file mode 100644 index 0000000..1dd2970 Binary files /dev/null and b/testdata/snapshots/darwin/file_icon-light.png differ diff --git a/testdata/snapshots/darwin/file_operation_progress-dark.png b/testdata/snapshots/darwin/file_operation_progress-dark.png new file mode 100644 index 0000000..85d996f Binary files /dev/null and b/testdata/snapshots/darwin/file_operation_progress-dark.png differ diff --git a/testdata/snapshots/darwin/file_operation_progress-light.png b/testdata/snapshots/darwin/file_operation_progress-light.png new file mode 100644 index 0000000..f6c864f Binary files /dev/null and b/testdata/snapshots/darwin/file_operation_progress-light.png differ diff --git a/testdata/snapshots/darwin/file_preview-dark.png b/testdata/snapshots/darwin/file_preview-dark.png new file mode 100644 index 0000000..84402f9 Binary files /dev/null and b/testdata/snapshots/darwin/file_preview-dark.png differ diff --git a/testdata/snapshots/darwin/file_preview-light.png b/testdata/snapshots/darwin/file_preview-light.png new file mode 100644 index 0000000..9ecc700 Binary files /dev/null and b/testdata/snapshots/darwin/file_preview-light.png differ diff --git a/testdata/snapshots/darwin/live_indicator-dark.png b/testdata/snapshots/darwin/live_indicator-dark.png new file mode 100644 index 0000000..2272315 Binary files /dev/null and b/testdata/snapshots/darwin/live_indicator-dark.png differ diff --git a/testdata/snapshots/darwin/live_indicator-light.png b/testdata/snapshots/darwin/live_indicator-light.png new file mode 100644 index 0000000..f471994 Binary files /dev/null and b/testdata/snapshots/darwin/live_indicator-light.png differ diff --git a/testdata/snapshots/darwin/path_bar-dark.png b/testdata/snapshots/darwin/path_bar-dark.png new file mode 100644 index 0000000..da59905 Binary files /dev/null and b/testdata/snapshots/darwin/path_bar-dark.png differ diff --git a/testdata/snapshots/darwin/path_bar-light.png b/testdata/snapshots/darwin/path_bar-light.png new file mode 100644 index 0000000..dfbc75e Binary files /dev/null and b/testdata/snapshots/darwin/path_bar-light.png differ diff --git a/testdata/snapshots/darwin/permission_select-dark.png b/testdata/snapshots/darwin/permission_select-dark.png new file mode 100644 index 0000000..2696a29 Binary files /dev/null and b/testdata/snapshots/darwin/permission_select-dark.png differ diff --git a/testdata/snapshots/darwin/permission_select-light.png b/testdata/snapshots/darwin/permission_select-light.png new file mode 100644 index 0000000..45c5631 Binary files /dev/null and b/testdata/snapshots/darwin/permission_select-light.png differ diff --git a/testdata/snapshots/darwin/presence_avatars-dark.png b/testdata/snapshots/darwin/presence_avatars-dark.png new file mode 100644 index 0000000..8ee33ad Binary files /dev/null and b/testdata/snapshots/darwin/presence_avatars-dark.png differ diff --git a/testdata/snapshots/darwin/presence_avatars-light.png b/testdata/snapshots/darwin/presence_avatars-light.png new file mode 100644 index 0000000..2ec0234 Binary files /dev/null and b/testdata/snapshots/darwin/presence_avatars-light.png differ diff --git a/testdata/snapshots/darwin/reactions-dark.png b/testdata/snapshots/darwin/reactions-dark.png new file mode 100644 index 0000000..8ee0655 Binary files /dev/null and b/testdata/snapshots/darwin/reactions-dark.png differ diff --git a/testdata/snapshots/darwin/reactions-light.png b/testdata/snapshots/darwin/reactions-light.png new file mode 100644 index 0000000..18490c9 Binary files /dev/null and b/testdata/snapshots/darwin/reactions-light.png differ diff --git a/testdata/snapshots/darwin/recent_files-dark.png b/testdata/snapshots/darwin/recent_files-dark.png new file mode 100644 index 0000000..13892a8 Binary files /dev/null and b/testdata/snapshots/darwin/recent_files-dark.png differ diff --git a/testdata/snapshots/darwin/recent_files-light.png b/testdata/snapshots/darwin/recent_files-light.png new file mode 100644 index 0000000..a9cd29c Binary files /dev/null and b/testdata/snapshots/darwin/recent_files-light.png differ diff --git a/testdata/snapshots/darwin/remote_cursor-dark.png b/testdata/snapshots/darwin/remote_cursor-dark.png new file mode 100644 index 0000000..17f0a14 Binary files /dev/null and b/testdata/snapshots/darwin/remote_cursor-dark.png differ diff --git a/testdata/snapshots/darwin/remote_cursor-light.png b/testdata/snapshots/darwin/remote_cursor-light.png new file mode 100644 index 0000000..554eaa9 Binary files /dev/null and b/testdata/snapshots/darwin/remote_cursor-light.png differ diff --git a/testdata/snapshots/darwin/rename_inline-dark.png b/testdata/snapshots/darwin/rename_inline-dark.png new file mode 100644 index 0000000..4bb8c66 Binary files /dev/null and b/testdata/snapshots/darwin/rename_inline-dark.png differ diff --git a/testdata/snapshots/darwin/rename_inline-light.png b/testdata/snapshots/darwin/rename_inline-light.png new file mode 100644 index 0000000..9aa29a0 Binary files /dev/null and b/testdata/snapshots/darwin/rename_inline-light.png differ diff --git a/testdata/snapshots/darwin/share_dialog-dark.png b/testdata/snapshots/darwin/share_dialog-dark.png new file mode 100644 index 0000000..3a42e2a Binary files /dev/null and b/testdata/snapshots/darwin/share_dialog-dark.png differ diff --git a/testdata/snapshots/darwin/share_dialog-light.png b/testdata/snapshots/darwin/share_dialog-light.png new file mode 100644 index 0000000..5f98539 Binary files /dev/null and b/testdata/snapshots/darwin/share_dialog-light.png differ diff --git a/testdata/snapshots/darwin/storage_usage-dark.png b/testdata/snapshots/darwin/storage_usage-dark.png new file mode 100644 index 0000000..b529932 Binary files /dev/null and b/testdata/snapshots/darwin/storage_usage-dark.png differ diff --git a/testdata/snapshots/darwin/storage_usage-light.png b/testdata/snapshots/darwin/storage_usage-light.png new file mode 100644 index 0000000..a54badd Binary files /dev/null and b/testdata/snapshots/darwin/storage_usage-light.png differ diff --git a/testdata/snapshots/darwin/transfer_queue-dark.png b/testdata/snapshots/darwin/transfer_queue-dark.png new file mode 100644 index 0000000..68ffed4 Binary files /dev/null and b/testdata/snapshots/darwin/transfer_queue-dark.png differ diff --git a/testdata/snapshots/darwin/transfer_queue-light.png b/testdata/snapshots/darwin/transfer_queue-light.png new file mode 100644 index 0000000..26228f3 Binary files /dev/null and b/testdata/snapshots/darwin/transfer_queue-light.png differ diff --git a/transfer_queue.go b/transfer_queue.go new file mode 100644 index 0000000..3f3e592 --- /dev/null +++ b/transfer_queue.go @@ -0,0 +1,211 @@ +package mujica + +import ( + "fmt" + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// TransferStatus is where a transfer is. +type TransferStatus int + +const ( + TransferQueued TransferStatus = iota + TransferActive + TransferPaused + TransferFailed + TransferDone +) + +// QueuedTransfer is one upload or download the caller runs. +type QueuedTransfer struct { + ID, Name string + Upload bool + Status TransferStatus + Done, Total int64 + // Error explains a failure. + Error string +} + +// TransferAction is what the user asked of a transfer. +type TransferAction int + +const ( + TransferNone TransferAction = iota + TransferPause + TransferResume + TransferRetry + TransferRemove +) + +// TransferQueueOptions configure a TransferQueue. +type TransferQueueOptions struct { + Disabled bool + // Revision changes when items are edited in place. + Revision uint64 +} + +// TransferQueueResult exposes the queue and requested item action. +type TransferQueueResult struct { + Element *ui.Element + Action TransferAction + // ID is the transfer the action is for. + ID string +} + +// TransferQueueState is the queue's place. +type TransferQueueState struct { + rows ui.ListState + data *QueuedTransfer + n int + revision uint64 + active, failed int + started bool +} + +// TransferQueue virtualizes uploads, downloads and their available actions. +func TransferQueue(c *ui.Context, items []QueuedTransfer, s *TransferQueueState, opts TransferQueueOptions) TransferQueueResult { + if s == nil { + panic("mujica: TransferQueue needs a state") + } + var first *QueuedTransfer + if len(items) > 0 { + first = &items[0] + } + if !s.started || s.data != first || s.n != len(items) || s.revision != opts.Revision { + s.active, s.failed = 0, 0 + ids := make(map[string]bool, len(items)) + for _, t := range items { + if t.ID == "" || ids[t.ID] { + panic("mujica: Transfer needs a unique ID") + } + ids[t.ID] = true + if t.Status < TransferQueued || t.Status > TransferDone || t.Done < 0 || t.Total < 0 || t.Total > 0 && t.Done > t.Total { + panic("mujica: Transfer " + t.ID + " has an invalid status or size") + } + switch t.Status { + case TransferActive, TransferQueued: + s.active++ + case TransferFailed: + s.failed++ + } + } + s.data, s.n, s.revision, s.started = first, len(items), opts.Revision, true + } + d := Density(c) + var res TransferQueueResult + res.Element = codePanel(c).Disabled(opts.Disabled) + disabled := res.Element.IsDisabled() + res.Element.Children(func() { + codeHeader(c, func() { + ui.Text(c, fmt.Sprintf(msg(c, "transfer_queue.summary"), s.active, s.failed)).Role(ui.RoleStatus) + }) + if len(items) == 0 { + codeCaption(c, msg(c, "transfer_queue.empty")).Padding(d.Unit() * 4) + return + } + s.rows.Key = func(i int) any { return items[i].ID } + s.rows.Label = func(i int) string { return items[i].Name } + ui.List(c, &s.rows, len(items), func(i int) { + if a := transferRow(c, items[i]); a != TransferNone && !disabled { + res.Action, res.ID = a, items[i].ID + slog.Debug("mujica: transfer_queue action", "action", a, "id", items[i].ID) + } + }).Grow(1).MinHeight(d.ControlHeight() * 3) + }) + return res +} + +// transferRow builds one transfer and returns the action clicked. +func transferRow(c *ui.Context, t QueuedTransfer) TransferAction { + k := Tokens(c) + d := Density(c) + u := d.Unit() + action := TransferNone + ui.Row(c).MinHeight(d.ControlHeight()+2*u).Padding(u, u*3).Gap(u * 3).AlignItems(ui.Center).Children(func() { + FileIcon(c, t.Name, FileIconOptions{}) + ui.Column(c).Grow(1).MinWidth(0).Gap(u).Children(func() { + ui.Row(c).Gap(u).AlignItems(ui.Center).Children(func() { + dir, key := "arrow-down", "transfer_queue.download" + if t.Upload { + dir, key = "arrow-up", "transfer_queue.upload" + } + ui.Icon(c, icons.Must(dir)).Size(14, 14).Shrink(0).TextColor(k.TextMuted).Label(msg(c, key)).Role(ui.RoleImage) + ui.Text(c, t.Name).SingleLine().MinWidth(0) + }) + frac := float32(0) + if t.Total > 0 { + frac = float32(t.Done) / float32(t.Total) + } + if t.Status == TransferDone { + frac = 1 + } + ui.Box(c).Height(4).Shrink(0).Role(ui.RoleNone).Draw(func(p *ui.Painter, r ui.Rect) { + p.Fill(r, k.Border, 2) + fill := k.AccentText + if t.Status == TransferFailed { + fill = k.Danger + } + if frac > 0 { + p.Fill(ui.Rect{X: internal.Snap(p, r.X), Y: internal.Snap(p, r.Y), W: internal.Snap(p, max(r.W*frac, r.H)), H: r.H}, fill, 2) + } + }) + transferStatusText(c, t, frac) + }) + var acts []TransferAction + switch t.Status { + case TransferActive, TransferQueued: + acts = []TransferAction{TransferPause} + case TransferPaused: + acts = []TransferAction{TransferResume} + case TransferFailed: + acts = []TransferAction{TransferRetry} + } + acts = append(acts, TransferRemove) + for _, a := range acts { + icon, key := transferActionIcon(a) + if iconAction(c, icon, fmt.Sprintf(msg(c, key), t.Name)).Clicked() { + action = a + } + } + }) + return action +} + +func transferStatusText(c *ui.Context, t QueuedTransfer, frac float32) { + k := Tokens(c) + text, col := "", k.TextMuted + switch t.Status { + case TransferQueued: + text = msg(c, "transfer_queue.queued") + case TransferActive: + text = fmt.Sprintf("%.0f%% · %s / %s", frac*100, processListBytes(uint64(t.Done)), processListBytes(uint64(t.Total))) + case TransferPaused: + text = fmt.Sprintf(msg(c, "transfer_queue.paused"), frac*100) + case TransferFailed: + text, col = fmt.Sprintf(msg(c, "transfer_queue.failed"), t.Error), k.Danger + case TransferDone: + text, col = msg(c, "transfer_queue.done"), k.Success + } + ui.Text(c, text).FontSize(FontSize(c, theme.CaptionSize)).TextColor(col).SingleLine() +} + +var transferPauseIcon = ui.MustParseSVG([]byte(``)) +var transferPlayIcon = ui.MustParseSVG([]byte(``)) + +func transferActionIcon(a TransferAction) (*ui.SVG, string) { + switch a { + case TransferPause: + return transferPauseIcon, "transfer_queue.pause" + case TransferResume: + return transferPlayIcon, "transfer_queue.resume" + case TransferRetry: + return icons.Must("refresh-cw"), "transfer_queue.retry" + } + return icons.Must("x"), "transfer_queue.remove" +} diff --git a/transfer_queue_test.go b/transfer_queue_test.go new file mode 100644 index 0000000..a98e9f2 --- /dev/null +++ b/transfer_queue_test.go @@ -0,0 +1,123 @@ +package mujica + +import ( + "fmt" + "strings" + "testing" + + "github.com/egoist/mygo/ui" +) + +func transferSample() []QueuedTransfer { + return []QueuedTransfer{ + {ID: "a", Name: "ledger.zip", Upload: true, Status: TransferActive, Done: 30 << 20, Total: 120 << 20}, + {ID: "b", Name: "crest.png", Status: TransferPaused, Done: 1 << 20, Total: 4 << 20}, + {ID: "c", Name: "deed.pdf", Upload: true, Status: TransferFailed, Done: 0, Total: 2 << 20, Error: "network lost"}, + {ID: "d", Name: "hymn.mp3", Status: TransferDone, Done: 5 << 20, Total: 5 << 20}, + } +} + +type transferProbe struct { + items []QueuedTransfer + s TransferQueueState + disabled bool + got []string +} + +func (p *transferProbe) view(c *ui.Context) { + Use(c, Settings{}) + r := TransferQueue(c, p.items, &p.s, TransferQueueOptions{Disabled: p.disabled}) + r.Element.Height(380) + if r.Action != TransferNone { + p.got = append(p.got, fmt.Sprintf("%d:%s", r.Action, r.ID)) + } +} + +func TestTransferQueueActions(t *testing.T) { + p := &transferProbe{items: transferSample()} + tt := ui.NewTester(p.view, 560, 400) + for _, s := range []string{"1 in progress, 1 failed", "25% · 30.0 MB / 120.0 MB", "Paused at 25%", "Failed: network lost", "Done"} { + if !tt.HasText(s) { + t.Fatalf("missing %q in %q", s, tt.Texts()) + } + } + for _, name := range []string{"Pause ledger.zip", "Resume crest.png", "Retry deed.pdf", "Remove hymn.mp3"} { + if err := tt.Click(name); err != nil { + t.Fatal(err) + } + } + want := "1:a 2:b 3:c 4:d" + if strings.Join(p.got, " ") != want { + t.Fatalf("actions %v, want %s", p.got, want) + } + if tt.HasText("Pause hymn.mp3") || tt.HasText("Retry ledger.zip") { + t.Fatal("actions shown for the wrong status") + } +} + +func TestTransferQueueKeyboardAndDisabled(t *testing.T) { + p := &transferProbe{items: transferSample()} + tt := ui.NewTester(p.view, 560, 400) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnter) + if len(p.got) != 1 { + t.Fatalf("keyboard actions %v", p.got) + } + p = &transferProbe{items: transferSample(), disabled: true} + tt = ui.NewTester(p.view, 560, 400) + _ = tt.Click("Pause ledger.zip") + if len(p.got) != 0 { + t.Fatal("disabled queue acted") + } +} + +func TestTransferQueueEmptyAndInvalid(t *testing.T) { + tt := ui.NewTester((&transferProbe{}).view, 560, 400) + if !tt.HasText("No transfers") { + t.Fatalf("texts %q", tt.Texts()) + } + defer func() { + if recover() == nil { + t.Fatal("Done above Total did not panic") + } + }() + ui.NewTester((&transferProbe{items: []QueuedTransfer{{ID: "x", Done: 9, Total: 1}}}).view, 560, 400) +} + +func TestTransferQueueSnapshot(t *testing.T) { + snapshot(t, "transfer_queue", 520, 330, func(c *ui.Context) { + p := ui.Local(c.Root(), "snap", func() *transferProbe { return &transferProbe{items: transferSample()} }) + ui.Column(c).Padding(12).Children(func() { + TransferQueue(c, (*p).items, &(*p).s, TransferQueueOptions{}).Element.Height(306) + }) + }) +} + +func TestTransferQueueLargeAndRevision(t *testing.T) { + items := make([]QueuedTransfer, 100000) + for i := range items { + items[i] = QueuedTransfer{ID: fmt.Sprint(i), Name: fmt.Sprintf("f-%d.txt", i), Status: TransferActive, Total: 100} + } + s := TransferQueueState{} + revision := uint64(0) + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + TransferQueue(c, items, &s, TransferQueueOptions{Revision: revision}).Element.Height(220) + }, 500, 250) + n := 0 + for _, text := range tt.Texts() { + if strings.HasPrefix(text, "f-") { + n++ + } + } + if n == 0 || n > 60 { + t.Fatalf("built %d transfers", n) + } + items[0].Status = TransferFailed + revision++ + tt.Frame() + if !tt.HasText("99999 in progress, 1 failed") { + t.Fatalf("revised summary %d active %d failed", s.active, s.failed) + } +}