Merge branch 'worktree-agent-a9950b6fffa6f4a57'
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)})
|
||||
|
||||
@@ -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 变化时重建索引并清理已删除的选择。
|
||||
@@ -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 打开。
|
||||
|
||||
## 限制
|
||||
|
||||
仅创建可见瓦片;缩略图解码由调用方完成。
|
||||
@@ -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。
|
||||
|
||||
## 状态
|
||||
|
||||
文件、目录关闭、目录打开。
|
||||
|
||||
## 事件
|
||||
|
||||
展示组件。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
展示组件,沿用父级焦点。
|
||||
|
||||
## 限制
|
||||
|
||||
扩展名大小写无关;未知扩展名显示通用文件图标。
|
||||
@@ -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 到操作;文本支持选择与复制。
|
||||
|
||||
## 限制
|
||||
|
||||
读取和解码由调用方提供;预览作为内嵌面板布局。
|
||||
@@ -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 关闭折叠菜单。
|
||||
|
||||
## 限制
|
||||
|
||||
路径必须至少包含一个分段;调用方处理导航。
|
||||
@@ -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 处理,组件无法查询组合状态;使用输入法提交文本后再确认。
|
||||
@@ -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。
|
||||
@@ -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 变化时重算。
|
||||
@@ -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 聚焦分段条;左右循环读取分类与剩余空间。
|
||||
|
||||
## 限制
|
||||
|
||||
统计由调用方提供,组件展示分类大小与比例。
|
||||
@@ -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 更新会重算排序。
|
||||
@@ -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。
|
||||
@@ -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 关闭溢出名单。
|
||||
|
||||
## 限制
|
||||
|
||||
在线数据与跟随行为由调用方维护。
|
||||
@@ -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 关闭。
|
||||
|
||||
## 限制
|
||||
|
||||
计数更新与持久化由调用方执行;传入系统字体支持的表情。
|
||||
@@ -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 关闭并恢复焦点。
|
||||
|
||||
## 限制
|
||||
|
||||
权限和成员由调用方持有;网络与链接有效性校验由业务负责。
|
||||
@@ -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;授权操作由调用方处理。
|
||||
@@ -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 激活。
|
||||
|
||||
## 限制
|
||||
|
||||
同步由调用方执行;状态变化会朗读,减少动画使用静态说明。
|
||||
@@ -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 返回选区矩形。
|
||||
|
||||
## 状态
|
||||
|
||||
光标、选区、姓名;覆盖层输入穿透。
|
||||
|
||||
## 事件
|
||||
|
||||
展示组件。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
输入穿透至下方编辑器。
|
||||
|
||||
## 限制
|
||||
|
||||
坐标映射、远程状态由调用方提供;覆盖层放在编辑器后方。
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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: "输入穿透至下方编辑器。"},
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)`
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2"/></svg>`))
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 4 4"><rect width="4" height="4"/></svg>`))}, "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")
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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{})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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"},
|
||||
})
|
||||
}
|
||||
@@ -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() })
|
||||
}
|
||||
@@ -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"})
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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}) })
|
||||
})
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))]
|
||||
}
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 22 KiB |
@@ -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(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M9 5v14M15 5v14"/></svg>`))
|
||||
var transferPlayIcon = ui.MustParseSVG([]byte(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M8 5l11 7-11 7z"/></svg>`))
|
||||
|
||||
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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||