diff --git a/data/tree.go b/data/tree.go index 5d6108c..2174656 100644 --- a/data/tree.go +++ b/data/tree.go @@ -59,6 +59,14 @@ func Tree(c *ui.Context, text string, roots []TreeNode, selected *string, o Tree return ui.Box(c).Focusable().Role(ui.RoleTree).Label(text).Disabled(o.Disabled) }) opened := ui.Local(base, treeKey{}, func() map[string]bool { return map[string]bool{} }) + // A choice set inside closed branches opens its way. + if _, ok := nodes[*selected]; ok { + for i, r := range *selected { + if r == '/' { + (*opened)[(*selected)[:i]] = true + } + } + } rows := flatten(tops, children, *opened) if len(rows) == 0 { return TreeResult{Element: shell} diff --git a/data/treevalue_test.go b/data/treevalue_test.go index 56e3d5b..09e8fd2 100644 --- a/data/treevalue_test.go +++ b/data/treevalue_test.go @@ -38,3 +38,14 @@ func TestTreeDrawsValuesInTheirSlot(t *testing.T) { t.Fatalf("choosing hp (%q) did not swap which value is inked", sel) } } + +func TestTreeRevealsAChoiceInAClosedBranch(t *testing.T) { + // Ether sits in Bag, which starts closed. + sel := "0/1" + tt := view(400, 300, newClock(), false, func(c *ui.Context) { + data.Tree(c, "Items", bag, &sel, data.TreeOptions{}) + }) + if r := find(t, tt, "Items"); sel != "0/1" || r.H != 4*2*pixel.LineHeight { + t.Fatalf("the choice became %q in %v, want Bag opened on Ether", sel, r) + } +} diff --git a/files/archiveviewer.go b/files/archiveviewer.go new file mode 100644 index 0000000..1d3b415 --- /dev/null +++ b/files/archiveviewer.go @@ -0,0 +1,88 @@ +package files + +import ( + "cmp" + "fmt" + "log/slog" + "math" + "slices" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/data" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +// Entry is a file packed in an archive. +type Entry struct { + Path string + // Size and Packed are bytes before and after packing. + Size, Packed int64 +} + +type ArchiveViewerOptions struct { + // Height is the rows' in dots; zero means 120. + Height int + Disabled bool +} + +type ArchiveViewerResult struct { + Element *ui.Element + // Sorted reports a header click this frame. + Changed, Sorted bool +} + +type archiveKey struct{} + +// archiveView is the sort and the row chosen in it. +type archiveView struct { + sort data.Sort + row int +} + +// ArchiveViewer lists entries and sizes; headers sort them. +func ArchiveViewer(c *ui.Context, text string, entries []Entry, selected *int, o ArchiveViewerOptions) ArchiveViewerResult { + k := pixel.K(c) + pick("ArchiveViewer", selected, len(entries)) + var total, packed int64 + for _, e := range entries { + // Checked against what is left, so sums cannot overflow. + if e.Path == "" || e.Size < 0 || e.Packed < 0 || e.Size > math.MaxInt64-total || e.Packed > math.MaxInt64-packed { + panic(fmt.Sprintf("files: ArchiveViewer entry %q of %d, %d bytes", e.Path, e.Size, e.Packed)) + } + total, packed = total+e.Size, packed+e.Packed + } + col := ui.Column(c).Gap(k.D(3)) + st := ui.Local(col, archiveKey{}, func() archiveView { return archiveView{} }) + // The view sorts; the caller's index stays on its entry. + order := make([]int, len(entries)) + for i := range order { + order[i] = i + } + slices.SortStableFunc(order, func(a, b int) int { + ea, eb := entries[a], entries[b] + n := [3]int{cmp.Compare(ea.Path, eb.Path), cmp.Compare(ea.Size, eb.Size), cmp.Compare(ea.Packed, eb.Packed)}[st.sort.Column] + if st.sort.Desc { + return -n + } + return n + }) + st.row = slices.Index(order, *selected) + var res ArchiveViewerResult + cols := []data.Column{{Title: k.Msg("name"), Width: 112}, {Title: k.Msg("size"), Width: 56}, {Title: k.Msg("packed"), Width: 64}} + res.Element = col.Children(func() { + t := data.Table(c, text, cols, len(entries), &st.sort, &st.row, data.TableOptions{Height: o.Height, Disabled: o.Disabled}, func(r, col int) string { + e := entries[order[r]] + return [3]string{e.Path, size(e.Size), size(e.Packed)}[col] + }) + if t.Changed && st.row >= 0 { + *selected, res.Changed = order[st.row], true + } + res.Sorted = t.Sorted + pixel.Text(c, fmt.Sprintf(k.Msg("archive_total"), len(entries), size(total), size(packed)), pixel.TextOptions{Slot: pixel.Faint}) + }) + if res.Changed { + slog.Debug("8bit: archive viewer", "label", text, "entry", *selected) + } + return res +} diff --git a/files/browse_test.go b/files/browse_test.go new file mode 100644 index 0000000..c9f008b --- /dev/null +++ b/files/browse_test.go @@ -0,0 +1,200 @@ +package files_test + +import ( + "testing" + "time" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/files" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +var drive = files.Dir{Name: "C:", Files: []files.File{{Name: "BOOT.INI", Kind: files.Text, Size: 10}}, Dirs: []files.Dir{ + {Name: "GAMES", Files: []files.File{{Name: "README.TXT", Kind: files.Text, Size: 2048}}, Dirs: []files.Dir{ + {Name: "QUEST", Files: []files.File{{Name: "SAVE.SAV", Kind: files.Archive, Size: 8192}, {Name: "MAP.PNG", Kind: files.Image, Size: 4096}}}, + }}, + {Name: "MUSIC"}, +}} + +func TestFileExplorerOpensAtThePathAndWalksFolders(t *testing.T) { + path, file, opened := "0/0/0", 1, 0 + tt := view(600, 400, newWorld(), func(c *ui.Context) { + if files.FileExplorer(c, "Explorer", drive, &path, &file, files.FileExplorerOptions{}).Opened { + opened++ + } + }) + // The tree opens to QUEST; the list shows its files. + if find(t, tt, "QUEST"); !tt.HasText("8.0 KB") { + t.Fatalf("no sizes in %q", tt.Texts()) + } + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyUp) + if path != "0/0" || file != -1 { + t.Fatalf("Up chose %q, file %d", path, file) + } + find(t, tt, "GAMES") + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyEnter) + if file != 0 || opened != 1 { + t.Fatalf("Down, Enter in the list chose %d, opened %d", file, opened) + } + for _, bad := range []string{"0/7", "1", "0/x", "0/00", "0/+0", "0/-0"} { + mustPanic(t, "the path "+bad, func() { + p, f := bad, -1 + view(600, 400, newWorld(), func(c *ui.Context) { + files.FileExplorer(c, "E", drive, &p, &f, files.FileExplorerOptions{}) + }) + }) + } +} + +func TestFileGridMovesByRowsAndOpens(t *testing.T) { + grid := make([]files.File, 7) + for i := range grid { + grid[i] = files.File{Name: string(rune('A' + i)), Kind: files.Kind(i % 6), Size: 1} + } + sel, opened := -1, 0 + // 400 pixels is 200 dots: three 64-dot cells a row. + tt := view(400, 400, newWorld(), func(c *ui.Context) { + if files.FileGrid(c, "Files", grid, &sel, files.FileGridOptions{}).Opened { + opened++ + } + }) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyEnter) + if sel != 4 || opened != 1 { + t.Fatalf("Right, Right, Down, Enter chose %d, opened %d", sel, opened) + } + find(t, tt, "E") + mustPanic(t, "a choice past the files", func() { + s := 7 + view(400, 400, newWorld(), func(c *ui.Context) { files.FileGrid(c, "F", grid, &s, files.FileGridOptions{}) }) + }) + mustPanic(t, "a negative size", func() { + s := -1 + view(400, 400, newWorld(), func(c *ui.Context) { + files.FileGrid(c, "F", []files.File{{Name: "A", Size: -1}}, &s, files.FileGridOptions{}) + }) + }) +} + +func TestRecentFilesSayWhenAndOpen(t *testing.T) { + wd := newWorld() + recent := []files.File{ + {Name: "NOTES.TXT", Kind: files.Text, Modified: wd.clk.t.Add(-12 * time.Minute)}, + {Name: "SONG.MID", Kind: files.Audio, Modified: wd.clk.t.Add(-30 * time.Hour)}, + } + sel, opened := -1, false + tt := view(500, 300, wd, func(c *ui.Context) { + opened = files.RecentFiles(c, "Recent", recent, &sel, files.RecentFilesOptions{}).Opened || opened + }) + if !tt.HasText("12 MIN AGO") || !tt.HasText("YESTERDAY") { + t.Fatalf("no times in %q", tt.Texts()) + } + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyEnter) + if sel != 1 || !opened { + t.Fatalf("Down, Down, Enter chose %d, opened %v", sel, opened) + } +} + +func TestArchiveViewerSortsAndKeepsTheChoice(t *testing.T) { + entries := []files.Entry{{Path: "B.TXT", Size: 300, Packed: 100}, {Path: "A.TXT", Size: 100, Packed: 50}, {Path: "C.TXT", Size: 200, Packed: 90}} + sel, sorted := 0, false + tt := view(600, 400, newWorld(), func(c *ui.Context) { + sorted = files.ArchiveViewer(c, "Zip", entries, &sel, files.ArchiveViewerOptions{}).Sorted || sorted + }) + // By name B is the second row; Down reaches C. + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyDown) + if sel != 2 { + t.Fatalf("Down from B chose entry %d, want C", sel) + } + // By size C sits in the middle; Down reaches B. + sorted = false + click(t, tt, "SIZE") + if !sorted { + t.Fatal("a header click did not report Sorted") + } + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyDown) + if sel != 0 || !tt.HasText("3 FILES: 600 B → 240 B") { + t.Fatalf("by size, Down from C chose %d; texts %q", sel, tt.Texts()) + } + // Biggest first, B leads; Down reaches C. + click(t, tt, "SIZE") + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyDown) + if sel != 2 { + t.Fatalf("by size falling, Down from B chose %d", sel) + } + mustPanic(t, "sizes past int64", func() { + s := -1 + view(600, 400, newWorld(), func(c *ui.Context) { + files.ArchiveViewer(c, "Z", []files.Entry{{Path: "A", Size: 1 << 62}, {Path: "B", Size: 1 << 62}, {Path: "C", Size: 1 << 62}}, &s, files.ArchiveViewerOptions{}) + }) + }) +} + +func TestPathBarClimbsToParents(t *testing.T) { + got := -1 + tt := view(600, 200, newWorld(), func(c *ui.Context) { + if r := files.PathBar(c, "Path", []string{"C:", "GAMES", "QUEST"}, files.PathBarOptions{}); r.Clicked >= 0 { + got = r.Clicked + } + }) + click(t, tt, "GAMES") + if got != 1 { + t.Fatalf("clicking GAMES gave %d", got) + } + // The folders are one Tab stop; arrows walk them. + tt.Key(0, ui.KeyLeft) + tt.Key(0, ui.KeyEnter) + if got != 0 || !tt.Focused("C:") { + t.Fatalf("Left, Enter gave %d", got) + } + got = -1 + click(t, tt, "QUEST") + if got != -1 { + t.Fatalf("the folder you are in clicked as %d", got) + } +} + +func TestBrowsersRefuseNamelessThings(t *testing.T) { + cases := map[string]func(c *ui.Context){ + "a nameless folder": func(c *ui.Context) { + p, f := "0", -1 + files.FileExplorer(c, "E", files.Dir{Name: "C:", Dirs: []files.Dir{{}}}, &p, &f, files.FileExplorerOptions{}) + }, + "an empty path part": func(c *ui.Context) { + files.PathBar(c, "P", []string{"C:", ""}, files.PathBarOptions{}) + }, + "a recent file with no time": func(c *ui.Context) { + s := -1 + files.RecentFiles(c, "R", []files.File{{Name: "A", Kind: files.Text}}, &s, files.RecentFilesOptions{}) + }, + } + for name, build := range cases { + mustPanic(t, name, func() { view(600, 300, newWorld(), build) }) + } +} + +func TestFileExplorerTreeFitsLongNames(t *testing.T) { + root := files.Dir{Name: "C:", Dirs: []files.Dir{{Name: "GAMES", Dirs: []files.Dir{{Name: "THE VERY LONG QUEST"}}}}} + path, file := "0/0/0", -1 + tt := view(900, 300, newWorld(), func(c *ui.Context) { + files.FileExplorer(c, "E", root, &path, &file, files.FileExplorerOptions{}) + }) + // Depth 2 indents 20 dots; the arrow takes 12. + if w := find(t, tt, "FOLDERS").W / 2; int(w) < 20+12+pixel.Measure("THE VERY LONG QUEST") { + t.Fatalf("the tree is %v dots for a %d-dot name", w, pixel.Measure("THE VERY LONG QUEST")) + } +} diff --git a/files/diskusage.go b/files/diskusage.go new file mode 100644 index 0000000..176cfab --- /dev/null +++ b/files/diskusage.go @@ -0,0 +1,80 @@ +package files + +import ( + "fmt" + "strings" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +// Use is the room one sort of file takes. +type Use struct { + Name string + Bytes int64 + // Slot colors it; zero picks one by order. + Slot pixel.Slot +} + +type DiskUsageOptions struct { + // Width is in dots; zero means 200. + Width int +} + +var useSlots = []pixel.Slot{pixel.Info, pixel.Good, pixel.Warn, pixel.Magic, pixel.Bad, pixel.Accent} + +// DiskUsage stacks each use in one bar over a legend. +func DiskUsage(c *ui.Context, text string, uses []Use, capacity int64, o DiskUsageOptions) *ui.Element { + k := pixel.K(c) + if capacity <= 0 { + panic(fmt.Sprintf("files: DiskUsage capacity %d", capacity)) + } + slots, said, used := make([]pixel.Slot, len(uses)), make([]string, len(uses)), int64(0) + for i, u := range uses { + // Checked against what is left, so sums cannot overflow. + if u.Name == "" || u.Bytes < 0 || u.Bytes > capacity-used { + panic(fmt.Sprintf("files: DiskUsage use %q of %d bytes past %d", u.Name, u.Bytes, capacity)) + } + used += u.Bytes + slots[i] = u.Slot + if slots[i] == 0 { + slots[i] = useSlots[i%len(useSlots)] + } + said[i] = u.Name + " " + size(u.Bytes) + } + free := capacity - used + w := pixel.Or("DiskUsage Width", o.Width, 200) + return ui.Column(c).Width(k.D(w)).Gap(k.D(4)).Children(func() { + desc := fmt.Sprintf(k.Msg("used_of"), size(used), size(capacity)) + ", " + strings.Join(append(said, k.Msg("free")+" "+size(free)), ", ") + ui.Box(c).FillWidth().Height(k.D(12)).Role(ui.RoleMeter).Label(text).Description(desc).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Frame(0, 0, g.W, 12, pixel.Sunken, pixel.Well) + // Edges come from running sums, so dots never drift. + iw, sum, from := g.W-4, int64(0), 2 + for i, u := range uses { + sum += u.Bytes + to := 2 + int(float64(iw)*float64(sum)/float64(capacity)) + g.Rect(from, 2, to-from, 8, slots[i]) + from = to + } + }) + for i, u := range uses { + legend(c, k, u.Name, u.Bytes, slots[i]) + } + legend(c, k, k.Msg("free"), free, pixel.Well) + }) +} + +// legend is a swatch, a name and a size. +func legend(c *ui.Context, k pixel.Kit, name string, n int64, s pixel.Slot) { + ui.Row(c).FillWidth().Gap(k.D(4)).AlignItems(ui.Center).Children(func() { + ui.Box(c).Size(k.D(7), k.D(7)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Rect(0, 0, 7, 7, pixel.Edge) + g.Rect(1, 1, 5, 5, s) + }) + pixel.Text(c, name, pixel.TextOptions{Wrap: true, MaxLines: 1}).Grow(1) + pixel.Text(c, size(n), pixel.TextOptions{Slot: pixel.Faint}) + }) +} diff --git a/files/filedetails.go b/files/filedetails.go new file mode 100644 index 0000000..a6e1229 --- /dev/null +++ b/files/filedetails.go @@ -0,0 +1,51 @@ +package files + +import ( + "strconv" + "time" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/data" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type FileDetailsOptions struct { + // Width is in dots; zero means 220. + Width int +} + +// FileDetails shows a file's icon, name, kind, size and dates. +func FileDetails(c *ui.Context, f File, o FileDetailsOptions) *ui.Element { + k := pixel.K(c) + check("FileDetails", f) + return ui.Column(c).Width(k.D(pixel.Or("FileDetails Width", o.Width, 220))).Gap(k.D(6)).Role(ui.RoleGroup).Label(f.Name).Children(func() { + ui.Row(c).FillWidth().Gap(k.D(6)).AlignItems(ui.Center).Children(func() { + FileIcon(c, f.Kind, FileIconOptions{Scale: 3}) + pixel.Text(c, f.Name, pixel.TextOptions{Wrap: true, MaxLines: 2}).Grow(1) + }) + data.DescriptionList(c, [][2]string{ + {k.Msg("kind"), k.Msg(look(f.Kind).msg)}, + {k.Msg("size"), size(f.Size) + " (" + grouped(f.Size) + " B)"}, + {k.Msg("created"), when(f.Created)}, + {k.Msg("modified"), when(f.Modified)}, + }, data.DescriptionListOptions{TermWidth: 64}) + }) +} + +// when is a date and time, or a dash. +func when(t time.Time) string { + if t.IsZero() { + return "-" + } + return t.Format("2006-01-02 15:04") +} + +// grouped writes n with commas between thousands. +func grouped(n int64) string { + s := strconv.FormatInt(n, 10) + for i := len(s) - 3; i > 0; i -= 3 { + s = s[:i] + "," + s[i:] + } + return s +} diff --git a/files/fileexplorer.go b/files/fileexplorer.go new file mode 100644 index 0000000..fc53024 --- /dev/null +++ b/files/fileexplorer.go @@ -0,0 +1,101 @@ +package files + +import ( + "fmt" + "log/slog" + "strconv" + "strings" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/data" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +// Dir is a named folder of folders and files. +type Dir struct { + Name string + Dirs []Dir + Files []File +} + +type FileExplorerOptions struct { + // Height is the file list's in dots; zero means 120. + Height int + Disabled bool +} + +type FileExplorerResult struct { + Element *ui.Element + // Opened is Enter or a double click on a file. + Changed, Opened bool +} + +// FileExplorer sets the folder tree beside the chosen folder's files. +func FileExplorer(c *ui.Context, text string, root Dir, path *string, file *int, o FileExplorerOptions) FileExplorerResult { + k := pixel.K(c) + dir, ok := folderAt(root, *path) + if !ok { + panic(fmt.Sprintf("files: FileExplorer has no folder at %q", *path)) + } + pick("FileExplorer", file, len(dir.Files)) + h := pixel.Or("FileExplorer Height", o.Height, 120) + tree, tw := node(root, 0) + var res FileExplorerResult + res.Element = ui.Row(c).FillWidth().Gap(k.D(4)).AlignItems(ui.Start).Role(ui.RoleGroup).Label(text).Children(func() { + old := *path + data.Tree(c, k.Msg("folders"), []data.TreeNode{tree}, path, data.TreeOptions{Width: tw, Disabled: o.Disabled}) + if *path != old { + // A new folder starts with no file chosen. + dir, _ = folderAt(root, *path) + *file, res.Changed = -1, true + slog.Debug("8bit: file explorer folder", "label", text, "path", *path) + } + files := dir.Files + for _, f := range files { + check("FileExplorer", f) + } + l := data.List(c, dir.Name, len(files), file, data.ListOptions{Height: h, Disabled: o.Disabled}, func(i int) string { return files[i].Name }, func(i int) { + row(c, k, files[i], func() { pixel.Text(c, size(files[i].Size), pixel.TextOptions{Slot: pixel.Faint}) }) + }) + l.Element.Grow(1) + res.Changed = res.Changed || l.Changed + res.Opened = l.Submitted && *file >= 0 + }) + if res.Opened { + slog.Debug("8bit: file explorer open", "label", text, "file", *file) + } + return res +} + +// node makes a folder's tree and the width it needs. +func node(f Dir, depth int) (data.TreeNode, int) { + if f.Name == "" { + panic("files: FileExplorer has a folder with no name") + } + // data.Tree indents ten dots a level; the arrow takes twelve. + n, w := data.TreeNode{Label: f.Name}, depth*10+16+pixel.Measure(f.Name) + for _, sub := range f.Dirs { + child, cw := node(sub, depth+1) + n.Children, w = append(n.Children, child), max(w, cw) + } + return n, w +} + +// folderAt follows a tree path, "0/1", down from root. +func folderAt(root Dir, path string) (Dir, bool) { + parts := strings.Split(path, "/") + if parts[0] != "0" { + return Dir{}, false + } + f := root + for _, p := range parts[1:] { + // Only the plain form, as data.Tree writes it, is known. + i, err := strconv.Atoi(p) + if err != nil || strconv.Itoa(i) != p || i < 0 || i >= len(f.Dirs) { + return Dir{}, false + } + f = f.Dirs[i] + } + return f, true +} diff --git a/files/filegrid.go b/files/filegrid.go new file mode 100644 index 0000000..38fa179 --- /dev/null +++ b/files/filegrid.go @@ -0,0 +1,62 @@ +package files + +import ( + "log/slog" + "strings" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/data" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type FileGridOptions struct { + // Height is in dots; zero means 140. + Height int + Disabled bool +} + +type FileGridResult struct { + Element *ui.Element + // Opened is Enter or a double click on a file. + Changed, Opened bool +} + +const gridCellW, gridCellH = 64, 64 + +// FileGrid shows files as big icons over their names. +func FileGrid(c *ui.Context, text string, files []File, selected *int, o FileGridOptions) FileGridResult { + k := pixel.K(c) + pick("FileGrid", selected, len(files)) + for _, f := range files { + check("FileGrid", f) + } + g := data.GridView(c, text, len(files), selected, data.GridViewOptions{CellWidth: gridCellW, CellHeight: gridCellH, Height: o.Height, Disabled: o.Disabled}, func(i int) { + ui.Column(c).FillWidth().Padding(k.D(4), 0, 0, 0).Gap(k.D(2)).AlignItems(ui.Center).Children(func() { + FileIcon(c, files[i].Kind, FileIconOptions{Scale: 2}) + lines := label(files[i].Name, gridCellW-4) + ui.Box(c).Size(k.D(gridCellW), k.D(2*pixel.LineHeight)).Role(ui.RoleText).Label(files[i].Name).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + for j, l := range lines { + g.Text((gridCellW-pixel.Measure(l))/2, j*pixel.LineHeight, l, pixel.Ink, 1) + } + }) + }) + }) + res := FileGridResult{Element: g.Element, Changed: g.Changed, Opened: g.Submitted && *selected >= 0} + if res.Opened { + slog.Debug("8bit: file grid open", "label", text, "file", *selected) + } + return res +} + +// label fits a name in two lines, extension whole. +func label(name string, w int) []string { + if pixel.Measure(name) <= w { + return []string{name} + } + if i := strings.LastIndex(name, "."); i > 0 { + return []string{pixel.Wrap(name[:i], w, 1)[0], pixel.Wrap(name[i:], w, 1)[0]} + } + return pixel.Wrap(name, w, 2) +} diff --git a/files/fileicon.go b/files/fileicon.go new file mode 100644 index 0000000..bc68409 --- /dev/null +++ b/files/fileicon.go @@ -0,0 +1,27 @@ +package files + +import ( + "fmt" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type FileIconOptions struct { + // Scale is 1–4; zero means 1. + Scale int +} + +// FileIcon draws a kind: a folder or a dog-eared page. +func FileIcon(c *ui.Context, kind Kind, o FileIconOptions) *ui.Element { + k := pixel.K(c) + ka := look(kind) + s := pixel.Or("FileIcon Scale", o.Scale, 1) + if s > 4 { + panic(fmt.Sprintf("files: FileIcon Scale %d is past 4", s)) + } + return ui.Box(c).Size(k.D(12*s), k.D(12*s)).Role(ui.RoleImage).Label(k.Msg(ka.msg)).Draw(func(p *ui.Painter, r ui.Rect) { + k.Canvas(p, r).Sprite(0, 0, ka.art, 0, pixel.Ink, s) + }) +} diff --git a/files/filepreview.go b/files/filepreview.go new file mode 100644 index 0000000..19fccc2 --- /dev/null +++ b/files/filepreview.go @@ -0,0 +1,63 @@ +package files + +import ( + "image" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/display" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/layout" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +// Preview is what a file shows: a picture, else text. +type Preview struct { + Picture image.Image + Text string +} + +type FilePreviewOptions struct { + // W and H are dots; zeros mean 160 and 120. + W, H int +} + +type previewKey struct{} + +// FilePreview shows a picture, else text, in a sunken pane. +func FilePreview(c *ui.Context, name string, pv Preview, o FilePreviewOptions) *ui.Element { + k := pixel.K(c) + w, h := pixel.Or("FilePreview W", o.W, 160), pixel.Or("FilePreview H", o.H, 120) + return ui.Column(c).Gap(k.D(2)).AlignItems(ui.Start).Role(ui.RoleGroup).Label(name).Children(func() { + // The group says the name; the title only shows it. + ui.Box(c).Size(k.D(pixel.Measure(name)), k.D(pixel.LineHeight)).Draw(func(p *ui.Painter, r ui.Rect) { + k.Canvas(p, r).Text(0, 0, name, pixel.Faint, 1) + }) + layout.Panel(c, layout.PanelOptions{Style: pixel.Sunken, Fill: pixel.Well, Padding: 2}, func() { + pane := ui.Column(c).Size(k.D(w), k.D(h)).AlignItems(ui.Center).Justify(ui.Center) + pane.Children(func() { + switch { + case pv.Picture != nil: + pw, ph := fit(pv.Picture.Bounds(), w, h) + display.Picture(c, pv.Picture, display.PictureOptions{W: pw, H: ph, Label: k.Msg("preview")}) + case pv.Text != "": + s := ui.Local(pane, previewKey{}, func() ui.ScrollState { return ui.ScrollState{} }) + layout.ScrollArea(c, s, layout.ScrollAreaOptions{Label: k.Msg("preview")}, func() { + pixel.Text(c, pv.Text, pixel.TextOptions{Wrap: true}) + }).Element.Size(k.D(w), k.D(h)) + default: + pixel.Text(c, k.Msg("no_preview"), pixel.TextOptions{Slot: pixel.Faint}) + } + }) + }) + }) +} + +// fit scales b into w×h: whole steps up, any down. +func fit(b image.Rectangle, w, h int) (int, int) { + bw, bh := max(1, b.Dx()), max(1, b.Dy()) + s := min(float64(w)/float64(bw), float64(h)/float64(bh)) + if s >= 1 { + s = float64(int(s)) + } + return max(1, int(float64(bw)*s)), max(1, int(float64(bh)*s)) +} diff --git a/files/files.go b/files/files.go new file mode 100644 index 0000000..f62cb56 --- /dev/null +++ b/files/files.go @@ -0,0 +1,60 @@ +// Package files holds browsing, storage and sharing components. +package files + +import ( + "fmt" + "time" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +// File is an entry in a folder. +type File struct { + Name string + Kind Kind + // Size is in bytes. + Size int64 + Created, Modified time.Time +} + +// size says n bytes in B, KB, MB, GB, TB. +func size(n int64) string { + if n < 1024 { + return fmt.Sprintf("%d B", n) + } + v, i := float64(n)/1024, 0 + // From 999.5 KB it reads 1.0 MB, not 1000 KB. + for v >= 999.5 && i < 4 { + v, i = v/1024, i+1 + } + if v < 9.95 { + return fmt.Sprintf("%.1f %cB", v, "KMGTP"[i]) + } + return fmt.Sprintf("%.0f %cB", v, "KMGTP"[i]) +} + +// pick panics unless *i is -1 or points into n. +func pick(name string, i *int, n int) { + if *i < -1 || *i >= n { + panic(fmt.Sprintf("files: %s choice %d of %d", name, *i, n)) + } +} + +// check panics on a nameless file, bad kind or size. +func check(name string, f File) { + look(f.Kind) + if f.Name == "" || f.Size < 0 { + panic(fmt.Sprintf("files: %s file %q of %d bytes", name, f.Name, f.Size)) + } +} + +// row sets a file's icon, name and a note. +func row(c *ui.Context, k pixel.Kit, f File, note func()) { + ui.Row(c).FillWidth().Gap(k.D(4)).AlignItems(ui.Center).Children(func() { + FileIcon(c, f.Kind, FileIconOptions{}) + pixel.Text(c, f.Name, pixel.TextOptions{Wrap: true, MaxLines: 1}).Grow(1) + note() + }) +} diff --git a/files/helpers_internal_test.go b/files/helpers_internal_test.go new file mode 100644 index 0000000..8f87add --- /dev/null +++ b/files/helpers_internal_test.go @@ -0,0 +1,47 @@ +package files + +import ( + "image" + "math" + "slices" + "testing" +) + +func TestSizeSaysUnits(t *testing.T) { + for n, want := range map[int64]string{ + 0: "0 B", 1023: "1023 B", 1024: "1.0 KB", 1536: "1.5 KB", 10 << 10: "10 KB", + 1023<<10 + 600: "1.0 MB", 1 << 30: "1.0 GB", 41 << 30: "41 GB", 1 << 50: "1.0 PB", math.MaxInt64: "8192 PB", + } { + if got := size(n); got != want { + t.Errorf("size(%d) = %q, want %q", n, got, want) + } + } +} + +func TestGroupedPutsCommasInThousands(t *testing.T) { + for n, want := range map[int64]string{0: "0", 999: "999", 1000: "1,000", 1234567: "1,234,567"} { + if got := grouped(n); got != want { + t.Errorf("grouped(%d) = %q, want %q", n, got, want) + } + } +} + +func TestLabelKeepsTheExtension(t *testing.T) { + for name, want := range map[string][]string{ + "MAP.PNG": {"MAP.PNG"}, "CHEATS.TXT": {"CHEATS", ".TXT"}, "SECRET LEVEL": {"SECRET", "LEVEL"}, + } { + if got := label(name, 60); !slices.Equal(got, want) { + t.Errorf("label(%q) = %q, want %q", name, got, want) + } + } +} + +func TestFitScalesWholeUpAndAnyDown(t *testing.T) { + for _, tc := range []struct{ iw, ih, w, h, ww, wh int }{ + {64, 40, 160, 80, 128, 80}, {640, 400, 160, 120, 160, 100}, {10, 10, 160, 120, 120, 120}, + } { + if w, h := fit(image.Rect(0, 0, tc.iw, tc.ih), tc.w, tc.h); w != tc.ww || h != tc.wh { + t.Errorf("%d×%d in %d×%d is %d×%d, want %d×%d", tc.iw, tc.ih, tc.w, tc.h, w, h, tc.ww, tc.wh) + } + } +} diff --git a/files/kinds.go b/files/kinds.go new file mode 100644 index 0000000..7acb2be --- /dev/null +++ b/files/kinds.go @@ -0,0 +1,114 @@ +package files + +import ( + "fmt" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +// Kind is what a file holds; it picks the icon. +type Kind int + +const ( + Folder Kind = iota + Text + Image + Audio + Code + Archive +) + +type kindArt struct { + msg string + art *pixel.Sprite +} + +// kinds name each Kind's message and 12×12 page (S7). +var kinds = []kindArt{ + {"folder", pixel.MustSprite(` + ............ + ............ + .eeee....... + .eyyye...... + .eyyyyeeeee. + .eyyyyyyyye. + .eeeeeeeeee. + .elllyyyyye. + .eyyyyyyyye. + .eyyyyyyyye. + .eeeeeeeeee. + ............`)}, + {"text_file", pixel.MustSprite(` + .eeeeeee.... + .elllllee... + .elnnnlese.. + .elllllesse. + .ellllleeee. + .elllllllle. + .elnnnnnnle. + .elllllllle. + .elnnnnnnle. + .elllllllle. + .elnnnnllle. + .eeeeeeeeee.`)}, + {"image_file", pixel.MustSprite(` + .eeeeeee.... + .elllllee... + .elllllese.. + .elllllesse. + .ellllleeee. + .elllllllle. + .elccccyyle. + .elccgcccle. + .elcgggcgle. + .elggggggle. + .elllllllle. + .eeeeeeeeee.`)}, + {"audio_file", pixel.MustSprite(` + .eeeeeee.... + .elllllee... + .elllllese.. + .elllllesse. + .ellllleeee. + .ellllmmlle. + .ellllmlmle. + .ellllmllle. + .ellmmmllle. + .elmmmmllle. + .ellmmlllle. + .eeeeeeeeee.`)}, + {"code_file", pixel.MustSprite(` + .eeeeeee.... + .elllllee... + .elllllese.. + .elllllesse. + .ellllleeee. + .elllllllle. + .ellcllclle. + .elcllllcle. + .ecllllllce. + .elcllllcle. + .ellcllclle. + .eeeeeeeeee.`)}, + {"archive_file", pixel.MustSprite(` + .eeeeeee.... + .ellselee... + .elleslese.. + .ellselesse. + .ellesleeee. + .ellselllle. + .elllllllle. + .elyyyyllle. + .elyeeyllle. + .elyyyyllle. + .elllllllle. + .eeeeeeeeee.`)}, +} + +// look returns kind's message and art; unknown kinds panic. +func look(kind Kind) kindArt { + if kind < 0 || int(kind) >= len(kinds) { + panic(fmt.Sprintf("files: unknown kind %d", kind)) + } + return kinds[kind] +} diff --git a/files/memorycard.go b/files/memorycard.go new file mode 100644 index 0000000..11d7526 --- /dev/null +++ b/files/memorycard.go @@ -0,0 +1,98 @@ +package files + +import ( + "fmt" + "log/slog" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +// Save is a game's save: a title and its icon. +type Save struct { + Title string + // Icon is at most 16 dots square. + Icon *pixel.Sprite + // Slot inks the icon's # dots; zero means Ink. + Slot pixel.Slot +} + +type MemoryCardOptions struct { + Disabled bool +} + +type MemoryCardResult struct { + Element *ui.Element + Changed bool + // Deleted is the block whose DELETE was pressed, else -1. + Deleted int +} + +const blocks, block = 15, 24 + +// MemoryCard lays fifteen blocks; saves fill them in order. +func MemoryCard(c *ui.Context, text string, saves []Save, chosen *int, o MemoryCardOptions) MemoryCardResult { + k := pixel.K(c) + if len(saves) > blocks || *chosen < 0 || *chosen >= blocks { + panic(fmt.Sprintf("files: MemoryCard has %d saves, block %d chosen", len(saves), *chosen)) + } + for _, s := range saves { + if s.Title == "" || s.Icon == nil || s.Icon.W > 16 || s.Icon.H > 16 { + panic(fmt.Sprintf("files: MemoryCard save %q needs an icon of 16 dots at most", s.Title)) + } + } + old := *chosen + res := MemoryCardResult{Deleted: -1} + res.Element = ui.Column(c).Width(k.D(5 * (block + 2))).Gap(k.D(4)).Children(func() { + pixel.Text(c, fmt.Sprintf(k.Msg("blocks_used"), len(saves)), pixel.TextOptions{Slot: pixel.Faint}) + // Arrows choose at once; the cursor is the block. + shell, base, st, _ := pixel.Pad(c, text, 5, blocks, block+2, block+2, chosen, o.Disabled) + say := k.Msg("free") + if *chosen < len(saves) { + say = saves[*chosen].Title + } + base.Description(fmt.Sprintf("%d, %s", *chosen+1, say)) + at := *chosen + shell.Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + for i := range blocks { + x, y := i%5*(block+2)+1, i/5*(block+2)+1 + g.Frame(x, y, block, block, pixel.Sunken, pixel.Well) + if i < len(saves) { + s := saves[i] + g.Sprite(x+(block-s.Icon.W)/2, y+(block-s.Icon.H)/2, s.Icon, 0, s.Slot, 1) + } else { + g.Checker(x+2, y+2, block-4, block-4, pixel.Shade) + } + if i == at { + ring(g, x-1, y-1, block+2, block+2) + } + } + g.Finish(st) + }) + ui.Row(c).FillWidth().Gap(k.D(4)).AlignItems(ui.Center).Children(func() { + pixel.Text(c, say, pixel.TextOptions{Wrap: true, MaxLines: 1}).Grow(1) + free := *chosen >= len(saves) + if input.Button(c, k.Msg("delete"), input.ButtonOptions{Size: input.Small, Variant: input.Danger, Disabled: o.Disabled || free, Label: k.Msg("delete") + " " + say}).Clicked { + res.Deleted = *chosen + } + }) + }) + res.Changed = *chosen != old + if res.Changed || res.Deleted >= 0 { + slog.Debug("8bit: memory card", "label", text, "block", *chosen, "deleted", res.Deleted) + } + return res +} + +// ring draws a two-dot Accent ring inside the box. +func ring(g pixel.Canvas, x, y, w, h int) { + for d := range 2 { + g.Rect(x+d, y+d, w-2*d, 1, pixel.Accent) + g.Rect(x+d, y+h-1-d, w-2*d, 1, pixel.Accent) + g.Rect(x+d, y+d, 1, h-2*d, pixel.Accent) + g.Rect(x+w-1-d, y+d, 1, h-2*d, pixel.Accent) + } +} diff --git a/files/off_test.go b/files/off_test.go new file mode 100644 index 0000000..044ce12 --- /dev/null +++ b/files/off_test.go @@ -0,0 +1,66 @@ +package files_test + +import ( + "image" + "testing" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/files" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon" +) + +// offPalette counts dots no palette color matches. +func offPalette(img *image.RGBA) int { + b := img.Bounds() + return count(img, 0, 0, b.Dx()/2, b.Dy()/2, none) +} + +func TestFilesStayOnThePaletteWhenOff(t *testing.T) { + list := []files.File{{Name: "A.TXT", Kind: files.Text, Size: 1, Modified: newWorld().clk.t}} + builds := map[string]func(c *ui.Context, off bool){ + "explorer": func(c *ui.Context, off bool) { + p, f := "0/0/0", 0 + files.FileExplorer(c, "X", drive, &p, &f, files.FileExplorerOptions{Disabled: off}) + }, + "grid": func(c *ui.Context, off bool) { + i := 0 + files.FileGrid(c, "X", list, &i, files.FileGridOptions{Disabled: off}) + }, + "path": func(c *ui.Context, off bool) { + files.PathBar(c, "X", []string{"C:", "A"}, files.PathBarOptions{Disabled: off}) + }, + "card": func(c *ui.Context, off bool) { + i := 0 + files.MemoryCard(c, "X", []files.Save{{Title: "A", Icon: icon.Sword}}, &i, files.MemoryCardOptions{Disabled: off}) + }, + "uploads": func(c *ui.Context, off bool) { + files.UploadQueue(c, "X", []files.Upload{{Name: "A", Sent: 1, Size: 2}}, files.UploadQueueOptions{Disabled: off}) + }, + "trash": func(c *ui.Context, off bool) { + files.Trash(c, "X", 2, files.TrashOptions{Disabled: off}) + }, + "recent": func(c *ui.Context, off bool) { + i := 0 + files.RecentFiles(c, "X", list, &i, files.RecentFilesOptions{Disabled: off}) + }, + "archive": func(c *ui.Context, off bool) { + i := 0 + files.ArchiveViewer(c, "X", []files.Entry{{Path: "A", Size: 2, Packed: 1}}, &i, files.ArchiveViewerOptions{Disabled: off}) + }, + "share": func(c *ui.Context, off bool) { + a := files.CanView + files.SharePanel(c, "X", "https://x.example", &a, files.SharePanelOptions{Disabled: off}) + }, + } + for name, build := range builds { + for _, inert := range []bool{false, true} { + wd := newWorld() + wd.inert = inert + tt := view(700, 400, wd, func(c *ui.Context) { build(c, !inert) }) + if n := offPalette(tt.Image()); n != 0 { + t.Errorf("%s, inert %v: %d pixels off the palette", name, inert, n) + } + } + } +} diff --git a/files/pathbar.go b/files/pathbar.go new file mode 100644 index 0000000..fafd384 --- /dev/null +++ b/files/pathbar.go @@ -0,0 +1,72 @@ +package files + +import ( + "fmt" + "log/slog" + "slices" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/layout" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type PathBarOptions struct { + Disabled bool +} + +type PathBarResult struct { + Element *ui.Element + // Clicked is the folder clicked, else -1. + Clicked int +} + +// PathBar sets a path's folders in a well, ▸ between. +func PathBar(c *ui.Context, text string, parts []string, o PathBarOptions) PathBarResult { + k := pixel.K(c) + if len(parts) == 0 || slices.Contains(parts, "") { + panic(fmt.Sprintf("files: PathBar parts %q", parts)) + } + res := PathBarResult{Clicked: -1} + res.Element = layout.Panel(c, layout.PanelOptions{Style: pixel.Sunken, Fill: pixel.Well, Padding: 1}, func() { + // One Tab stop; arrows walk the folders. + ui.Row(c).Gap(k.D(2)).AlignItems(ui.Center).FocusGroup(ui.Horizontal).Role(ui.RoleToolbar).Label(text).Children(func() { + ui.Box(c).Size(k.D(icon.FolderOpen.W), k.D(icon.FolderOpen.H)).Draw(func(p *ui.Painter, r ui.Rect) { + k.Canvas(p, r).Sprite(0, 0, icon.FolderOpen, 0, pixel.Warn, 1) + }) + for i, part := range parts { + if i > 0 { + ui.Box(c).Size(k.D(pixel.Measure("▸")), k.D(pixel.LineHeight)).Draw(func(p *ui.Painter, r ui.Rect) { + k.Canvas(p, r).Text(0, 0, "▸", pixel.Faint, 1) + }) + } + // The last folder is where you are; it stays put. + if i == len(parts)-1 { + ui.Box(c).Size(k.D(pixel.Measure(part)+4), k.D(pixel.LineHeight)).Role(ui.RoleText).Label(part).Draw(func(p *ui.Painter, r ui.Rect) { + k.Canvas(p, r).Text(2, 0, part, pixel.Accent, 1) + }) + break + } + shell, base, st := pixel.Shell(c, func() *ui.Element { + return ui.ButtonBase(c).Label(part).Disabled(o.Disabled) + }) + if base.Clicked() { + res.Clicked = i + } + shell.Size(k.D(pixel.Measure(part)+4), k.D(pixel.LineHeight)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + if st.Hover || st.Press { + g.Rect(0, 0, g.W, g.H, pixel.Face) + } + g.Text(2, 0, part, pixel.Ink, 1) + g.Finish(st) + }) + } + }) + }) + if res.Clicked >= 0 { + slog.Debug("8bit: path bar", "label", text, "part", res.Clicked) + } + return res +} diff --git a/files/quotameter.go b/files/quotameter.go new file mode 100644 index 0000000..c02a73f --- /dev/null +++ b/files/quotameter.go @@ -0,0 +1,68 @@ +package files + +import ( + "fmt" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type QuotaMeterOptions struct { + // Gems stand for the whole quota, 1–40; zero means 10. + Gems int +} + +var ( + gemFull = pixel.MustSprite(` + ..eeee.. + .e#ll#e. + e#l####e + e######e + .e####e. + ..e##e.. + ...ee...`) + gemEmpty = pixel.MustSprite(` + ..eeee.. + .ewwwwe. + ewwwwwwe + ewwwwwwe + .ewwwwe. + ..ewwe.. + ...ee...`) +) + +// QuotaMeter shows storage used as gems filling up. +func QuotaMeter(c *ui.Context, text string, used, quota int64, o QuotaMeterOptions) *ui.Element { + k := pixel.K(c) + n := pixel.Or("QuotaMeter Gems", o.Gems, 10) + if n > 40 || used < 0 || quota <= 0 { + panic(fmt.Sprintf("files: QuotaMeter %d gems, %d of %d bytes", n, used, quota)) + } + frac := float64(used) / float64(quota) + // Half gems fill only when wholly used; over quota caps. + halves := int(min(frac, 1) * float64(2*n)) + ink := pixel.Info + switch { + case frac >= 1: + ink = pixel.Bad + case frac >= 0.8: + ink = pixel.Warn + } + say := fmt.Sprintf(k.Msg("used_of"), size(used), size(quota)) + w := max(n*9-1, pixel.Measure(say)) + return ui.Box(c).Size(k.D(w), k.D(8+pixel.LineHeight)).Role(ui.RoleMeter).Label(text).Description(say).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + for i := range n { + x := i * 9 + g.Sprite(x, 0, gemEmpty, 0, ink, 1) + switch { + case halves >= 2*i+2: + g.Sprite(x, 0, gemFull, 0, ink, 1) + case halves == 2*i+1: + g.Clip(x, 0, 4, 7, func() { g.Sprite(x, 0, gemFull, 0, ink, 1) }) + } + } + g.Text(0, 8, say, pixel.Faint, 1) + }) +} diff --git a/files/recentfiles.go b/files/recentfiles.go new file mode 100644 index 0000000..83cb449 --- /dev/null +++ b/files/recentfiles.go @@ -0,0 +1,48 @@ +package files + +import ( + "fmt" + "log/slog" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/data" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/datetime" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type RecentFilesOptions struct { + // Width and Height are dots; zeros mean 220, 120. + Width, Height int + Disabled bool +} + +type RecentFilesResult struct { + Element *ui.Element + // Opened is Enter or a double click on a file. + Changed, Opened bool +} + +// RecentFiles lists files with how long since each changed. +func RecentFiles(c *ui.Context, text string, files []File, selected *int, o RecentFilesOptions) RecentFilesResult { + k := pixel.K(c) + pick("RecentFiles", selected, len(files)) + for _, f := range files { + check("RecentFiles", f) + if f.Modified.IsZero() { + panic(fmt.Sprintf("files: RecentFiles %q has no time", f.Name)) + } + } + var res RecentFilesResult + res.Element = ui.Column(c).Width(k.D(pixel.Or("RecentFiles Width", o.Width, 220))).Children(func() { + l := data.List(c, text, len(files), selected, data.ListOptions{Height: o.Height, Disabled: o.Disabled}, func(i int) string { return files[i].Name }, func(i int) { + row(c, k, files[i], func() { datetime.RelativeTime(c, files[i].Modified, datetime.RelativeTimeOptions{}) }) + }) + l.Element.FillWidth() + res.Changed, res.Opened = l.Changed, l.Submitted && *selected >= 0 + }) + if res.Opened { + slog.Debug("8bit: recent files open", "label", text, "file", *selected) + } + return res +} diff --git a/files/sharepanel.go b/files/sharepanel.go new file mode 100644 index 0000000..8e29f17 --- /dev/null +++ b/files/sharepanel.go @@ -0,0 +1,80 @@ +package files + +import ( + "fmt" + "log/slog" + "strings" + "time" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +// Access is what a link lets its holder do. +type Access int + +const ( + CanView Access = iota + CanComment + CanEdit +) + +type SharePanelOptions struct { + // Width is in dots; zero means 220. + Width int + Disabled bool +} + +type SharePanelResult struct { + Element *ui.Element + Copied, Changed bool +} + +type shareKey struct{} + +// SharePanel shows a link to copy and its access. +func SharePanel(c *ui.Context, text, link string, access *Access, o SharePanelOptions) SharePanelResult { + k := pixel.K(c) + if link == "" || *access < CanView || *access > CanEdit { + panic(fmt.Sprintf("files: SharePanel link of %d bytes, access %d", len(link), *access)) + } + var res SharePanelResult + res.Element = ui.Column(c).Width(k.D(pixel.Or("SharePanel Width", o.Width, 220))).Gap(k.D(4)).Role(ui.RoleGroup).Label(text).Children(func() { + ui.Row(c).FillWidth().Gap(k.D(4)).AlignItems(ui.Center).Children(func() { + // The link is cut to fit; readers hear it whole. + field := ui.Box(c).Grow(1).Height(k.D(pixel.LineHeight + 4)).Role(ui.RoleText).Label(k.Msg("link")).Description(link) + lw, fits := pixel.WrapWidth(c, field) + shown := pixel.Wrap(link, max(0, lw-6), 1)[0] + field.Draw(func(p *ui.Painter, r ui.Rect) { + fits(r) + g := k.Canvas(p, r) + g.Frame(0, 0, g.W, g.H, pixel.Sunken, pixel.Well) + g.Clip(2, 0, g.W-4, g.H, func() { g.Text(3, 2, shown, pixel.Ink, 1) }) + }) + e := ui.Box(c) + at := ui.Local(e, shareKey{}, func() time.Time { return time.Time{} }) + // The button says COPIED! for two seconds. + label := k.Msg("copy") + if k.Since(c, *at, 1, 2) < 2 { + label = k.Msg("copied") + } + e.Children(func() { + if input.Button(c, label, input.ButtonOptions{Size: input.Small, Disabled: o.Disabled}).Clicked { + c.WriteClipboard(link) + *at, res.Copied = k.Now(), true + } + }) + }) + i := int(*access) + if input.Segmented(c, k.Msg("access"), strings.Fields(k.Msg("access_levels")), &i, input.SegmentedOptions{Disabled: o.Disabled}).Changed { + *access, res.Changed = Access(i), true + } + }) + // A share link is a key; it never reaches logs. + if res.Copied || res.Changed { + slog.Debug("8bit: share panel", "label", text, "copied", res.Copied, "access", *access) + } + return res +} diff --git a/files/storage_test.go b/files/storage_test.go new file mode 100644 index 0000000..2eb6214 --- /dev/null +++ b/files/storage_test.go @@ -0,0 +1,336 @@ +package files_test + +import ( + "image" + "image/color" + "math" + "strings" + "testing" + "time" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/files" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +func TestMemoryCardChoosesAndDeletes(t *testing.T) { + saves := []files.Save{{Title: "QUEST", Icon: icon.Sword}, {Title: "RACE", Icon: icon.Star, Slot: pixel.Magic}} + block, deleted := 0, -1 + tt := view(400, 400, newWorld(), func(c *ui.Context) { + if r := files.MemoryCard(c, "Card", saves, &block, files.MemoryCardOptions{}); r.Deleted >= 0 { + deleted = r.Deleted + } + }) + x, y, _, _ := dots(find(t, tt, "Card")) + // The sword keeps its colors; Slot inks the star. + if count(tt.Image(), x+1, y+1, 24, 24, pixel.Warn) == 0 || count(tt.Image(), x+27, y+1, 24, 24, pixel.Magic) == 0 { + t.Fatal("the sword or the Magic star is missing") + } + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyRight) + click(t, tt, "DELETE RACE") + if block != 1 || deleted != 1 { + t.Fatalf("Right, DELETE chose %d, deleted %d", block, deleted) + } + // A free block has nothing to delete. + deleted = -1 + tt.Key(ui.Shift, ui.KeyTab) + tt.Key(0, ui.KeyDown) + click(t, tt, "DELETE FREE") + if block != 6 || deleted != -1 || !tt.HasText("2/15 BLOCKS") { + t.Fatalf("on a free block DELETE gave %d at %d", deleted, block) + } + mustPanic(t, "sixteen saves", func() { + b, full := 0, make([]files.Save, 16) + for i := range full { + full[i] = files.Save{Title: "S", Icon: icon.Star} + } + view(400, 400, newWorld(), func(c *ui.Context) { files.MemoryCard(c, "C", full, &b, files.MemoryCardOptions{}) }) + }) +} + +func TestDiskUsageSharesTheBar(t *testing.T) { + tt := view(500, 400, newWorld(), func(c *ui.Context) { + files.DiskUsage(c, "Disk", []files.Use{{Name: "GAMES", Bytes: 1 << 30}, {Name: "MUSIC", Bytes: 1 << 30, Slot: pixel.Magic}}, 4<<30, files.DiskUsageOptions{Width: 204}) + }) + x, y, w, _ := dots(find(t, tt, "Disk")) + img := tt.Image() + // A quarter each, out of a 200-dot inside. + if g, m := count(img, x, y+5, w, 1, pixel.Info), count(img, x, y+5, w, 1, pixel.Magic); g != 50 || m != 50 { + t.Fatalf("GAMES fills %d, MUSIC %d, want 50 each", g, m) + } + for _, s := range []string{"GAMES", "1.0 GB", "FREE", "2.0 GB"} { + find(t, tt, s) + } + mustPanic(t, "more than the disk", func() { + view(500, 400, newWorld(), func(c *ui.Context) { + files.DiskUsage(c, "D", []files.Use{{Name: "A", Bytes: 3}, {Name: "B", Bytes: 2}}, 4, files.DiskUsageOptions{}) + }) + }) +} + +func TestUploadQueueCancelsAndFinishes(t *testing.T) { + ups := []files.Upload{{Name: "A.ZIP", Kind: files.Archive, Sent: 5, Size: 10}, {Name: "B.PNG", Kind: files.Image, Sent: 10, Size: 10}, {Name: "C.GO", Kind: files.Code, Sent: 1, Size: 10, Failed: true}} + canceled := -1 + tt := view(600, 400, newWorld(), func(c *ui.Context) { + if r := files.UploadQueue(c, "Up", ups, files.UploadQueueOptions{}); r.Canceled >= 0 { + canceled = r.Canceled + } + }) + click(t, tt, "CANCEL C.GO") + if canceled != 2 { + t.Fatalf("CANCEL C.GO gave %d", canceled) + } + // Done has no cancel; failed says so. + if _, ok := tt.Find("CANCEL B.PNG"); ok || !tt.HasText("DONE") || !tt.HasText("FAILED") { + t.Fatalf("texts %q", tt.Texts()) + } + mustPanic(t, "more sent than the size", func() { + view(600, 400, newWorld(), func(c *ui.Context) { + files.UploadQueue(c, "U", []files.Upload{{Name: "A", Sent: 11, Size: 10}}, files.UploadQueueOptions{}) + }) + }) +} + +func TestTrashEmptiesOnlyWhenFull(t *testing.T) { + n, emptied := 3, 0 + tt := view(400, 200, newWorld(), func(c *ui.Context) { + if files.Trash(c, "Bin", n, files.TrashOptions{}).Emptied { + emptied++ + n = 0 + } + }) + x, y, _, h := dots(find(t, tt, "Bin")) + // The bin sits mid-row; paper adds Lit dots while full. + y += (h - 32) / 2 + full := count(tt.Image(), x, y, 32, 32, pixel.Lit) + click(t, tt, "EMPTY") + click(t, tt, "EMPTY") + if bare := count(tt.Image(), x, y, 32, 32, pixel.Lit); emptied != 1 || !tt.HasText("NO ITEMS") || full != bare+8 { + t.Fatalf("emptied %d times; Lit %d full, %d bare; texts %q", emptied, full, bare, tt.Texts()) + } + mustPanic(t, "-1 in the bin", func() { + view(400, 200, newWorld(), func(c *ui.Context) { files.Trash(c, "B", -1, files.TrashOptions{}) }) + }) +} + +func TestFilePreviewShowsPictureTextOrNothing(t *testing.T) { + img := image.NewRGBA(image.Rect(0, 0, 40, 20)) + for i := range img.Pix { + img.Pix[i] = 255 + } + img.SetRGBA(0, 0, color.RGBA{A: 255}) + pv := files.Preview{Picture: img} + tt := view(600, 400, newWorld(), func(c *ui.Context) { + files.FilePreview(c, "A.PNG", pv, files.FilePreviewOptions{W: 100, H: 60}) + }) + // 40×20 fits 100×60 twice over, by whole steps. + if r := find(t, tt, "PREVIEW"); r.W != 2*80 || r.H != 2*40 { + t.Fatalf("the picture is %v, want 80×40 dots", r) + } + pv = files.Preview{Text: "LINE ONE"} + tt.Frame() + // The text scrolls in the pane's full height. + if !tt.HasText("LINE ONE") || find(t, tt, "PREVIEW").H != 2*60 { + t.Fatalf("the text pane %v: %q", find(t, tt, "PREVIEW"), tt.Texts()) + } + pv = files.Preview{} + tt.Frame() + if !tt.HasText("NO PREVIEW") { + t.Fatalf("texts %q", tt.Texts()) + } +} + +func TestFileDetailsReadsEveryField(t *testing.T) { + f := files.File{Name: "BOSS.PNG", Kind: files.Image, Size: 1_234_567, Modified: time.Date(2026, 4, 1, 23, 59, 0, 0, time.UTC)} + tt := view(600, 400, newWorld(), func(c *ui.Context) { + files.FileDetails(c, f, files.FileDetailsOptions{}) + }) + // No creation date reads as a lone dash. + for _, s := range []string{"1.2 MB (1,234,567 B)", "-", "2026-04-01 23:59"} { + find(t, tt, s) + } + // IMAGE names the icon, then fills the KIND row. + if n := strings.Count(strings.Join(tt.Texts(), "|"), "IMAGE"); n != 2 { + t.Fatalf("IMAGE shows %d times in %q", n, tt.Texts()) + } +} + +func TestSharePanelCopiesAndKeepsTheLinkOutOfTheLog(t *testing.T) { + wd := newWorld() + access := files.CanView + const link = "https://x.example/s/SECRET" + tt := view(600, 300, wd, func(c *ui.Context) { + files.SharePanel(c, "Share", link, &access, files.SharePanelOptions{}) + }) + log := logged(func() { + click(t, tt, "COPY") + click(t, tt, "EDIT") + }) + if tt.Clipboard() != link || access != files.CanEdit || !tt.HasText("COPIED!") || strings.Contains(log, "SECRET") { + t.Fatalf("clipboard %q, access %d; log:\n%s", tt.Clipboard(), access, log) + } + later(tt, wd, 3*time.Second) + if !tt.HasText("COPY") { + t.Fatalf("COPIED! stayed: %q", tt.Texts()) + } + mustPanic(t, "an access past EDIT", func() { + a := files.Access(3) + view(600, 300, newWorld(), func(c *ui.Context) { files.SharePanel(c, "S", link, &a, files.SharePanelOptions{}) }) + }) +} + +func TestSyncStatusTurnsWhileSyncing(t *testing.T) { + for _, reduce := range []bool{false, true} { + wd := newWorld() + wd.reduce = reduce + tt := view(300, 100, wd, func(c *ui.Context) { + files.SyncStatus(c, files.Syncing, files.SyncStatusOptions{}) + }) + x, y, _, _ := dots(find(t, tt, "SYNCING")) + before := badge(tt, x+7, y+8) + later(tt, wd, 250*time.Millisecond) + // Work under way keeps turning, even under Reduce Motion. + if badge(tt, x+7, y+8) == before { + t.Errorf("reduce %v: the arrows held still", reduce) + } + } + tt := view(300, 100, newWorld(), func(c *ui.Context) { + files.SyncStatus(c, files.SyncFailed, files.SyncStatusOptions{}) + }) + x, y, w, h := dots(find(t, tt, "SYNC FAILED")) + if count(tt.Image(), x+16, y, w-16, h, pixel.Bad) == 0 { + t.Fatal("SYNC FAILED is not written in Bad") + } + mustPanic(t, "a fourth state", func() { + view(300, 100, newWorld(), func(c *ui.Context) { files.SyncStatus(c, 3, files.SyncStatusOptions{}) }) + }) +} + +func TestQuotaMeterFillsGemsByHalves(t *testing.T) { + for _, tc := range []struct { + used int64 + slot pixel.Slot + fills int + }{{3, pixel.Info, 9}, {9, pixel.Warn, 28}, {12, pixel.Bad, 38}, {math.MaxInt64, pixel.Bad, 38}} { + tt := view(400, 200, newWorld(), func(c *ui.Context) { + files.QuotaMeter(c, "Space", tc.used, 10, files.QuotaMeterOptions{Gems: 2}) + }) + x, y, _, _ := dots(find(t, tt, "Space")) + // A gem inks nineteen dots, its left half nine. + if n := count(tt.Image(), x, y, 17, 7, tc.slot); n != tc.fills { + t.Errorf("%d of 10: %d dots of slot %d, want %d", tc.used, n, tc.slot, tc.fills) + } + } + mustPanic(t, "41 gems", func() { + view(400, 200, newWorld(), func(c *ui.Context) { files.QuotaMeter(c, "S", 1, 2, files.QuotaMeterOptions{Gems: 41}) }) + }) +} + +func TestFileIconsDiffer(t *testing.T) { + kinds := []files.Kind{files.Folder, files.Text, files.Image, files.Audio, files.Code, files.Archive} + tt := view(400, 200, newWorld(), func(c *ui.Context) { + ui.Row(c).Children(func() { + for _, k := range kinds { + files.FileIcon(c, k, files.FileIconOptions{Scale: 2}) + } + }) + }) + // S7: icons are 12 dots square, here at scale 2. + if r := find(t, tt, "FOLDER"); r.W != 2*24 || r.H != 2*24 { + t.Fatalf("a FileIcon at scale 2 is %v", r) + } + seen := map[string]bool{} + for _, name := range []string{"FOLDER", "TEXT", "IMAGE", "AUDIO", "CODE", "ARCHIVE"} { + x, y, w, h := dots(find(t, tt, name)) + var b strings.Builder + for j := y; j < y+h; j += 2 { + for i := x; i < x+w; i += 2 { + b.WriteByte(byte(at(tt.Image(), i, j))) + } + } + seen[b.String()] = true + } + if len(seen) != len(kinds) { + t.Fatalf("%d kinds look alike", len(kinds)-len(seen)) + } + mustPanic(t, "scale 5", func() { + view(400, 200, newWorld(), func(c *ui.Context) { files.FileIcon(c, files.Text, files.FileIconOptions{Scale: 5}) }) + }) + mustPanic(t, "a kind past the six", func() { + view(400, 200, newWorld(), func(c *ui.Context) { files.FileIcon(c, 6, files.FileIconOptions{}) }) + }) +} + +// badge is the slots of the 7×7 mark there. +func badge(tt *ui.Tester, x, y int) string { + var b strings.Builder + for j := y; j < y+7; j++ { + for i := x; i < x+7; i++ { + b.WriteByte(byte(at(tt.Image(), i, j))) + } + } + return b.String() +} + +func TestUploadQueueRowsFitTheirWidth(t *testing.T) { + ups := []files.Upload{{Name: "A.ZIP", Kind: files.Archive, Sent: 5, Size: 10}} + tt := view(600, 300, newWorld(), func(c *ui.Context) { + files.UploadQueue(c, "Up", ups, files.UploadQueueOptions{}) + }) + q, b := find(t, tt, "Up"), find(t, tt, "CANCEL A.ZIP") + // The ✕ keeps its 16 dots inside the 240-dot queue. + if b.W != 2*16 || b.X+b.W > q.X+q.W { + t.Fatalf("the ✕ is %v in a queue of %v", b, q) + } +} + +func TestMemoryCardFitsAFullCard(t *testing.T) { + full, b := make([]files.Save, 15), 0 + for i := range full { + full[i] = files.Save{Title: "HERO QUEST", Icon: icon.Star} + } + tt := view(500, 400, newWorld(), func(c *ui.Context) { + files.MemoryCard(c, "Card", full, &b, files.MemoryCardOptions{}) + }) + card := find(t, tt, "Card") + // Every line stays within the grid's 130 dots. + for _, s := range []string{"15/15 BLOCKS", "HERO QUEST", "DELETE HERO QUEST"} { + if r := find(t, tt, s); r.X < card.X || r.X+r.W > card.X+card.W { + t.Errorf("%q at %v spills out of %v", s, r, card) + } + } +} + +func TestUploadQueueFocusFollowsItsRow(t *testing.T) { + ups := []files.Upload{{Name: "A.ZIP", Sent: 1, Size: 9}, {Name: "B.ZIP", Sent: 1, Size: 9}, {Name: "C.ZIP", Sent: 1, Size: 9}} + gone := "" + tt := view(600, 400, newWorld(), func(c *ui.Context) { + if r := files.UploadQueue(c, "Up", ups, files.UploadQueueOptions{}); r.Canceled >= 0 { + gone = ups[r.Canceled].Name + } + }) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyTab) + // A leaves; the focus stays on B's ✕, not C's. + ups = ups[1:] + tt.Frame() + tt.Key(0, ui.KeyEnter) + if gone != "B.ZIP" { + t.Fatalf("Enter after A left canceled %q", gone) + } + for name, bad := range map[string][]files.Upload{ + "a bar of no dots": {{Name: "A", Sent: 1, Size: 2}}, + "two A uploads": {{Name: "A", Sent: 1, Size: 2}, {Name: "A", Sent: 1, Size: 2}}, + } { + w := 59 + if len(bad) == 2 { + w = 0 + } + mustPanic(t, name, func() { + view(600, 400, newWorld(), func(c *ui.Context) { files.UploadQueue(c, "U", bad, files.UploadQueueOptions{Width: w}) }) + }) + } +} diff --git a/files/syncstatus.go b/files/syncstatus.go new file mode 100644 index 0000000..d8dd025 --- /dev/null +++ b/files/syncstatus.go @@ -0,0 +1,109 @@ +package files + +import ( + "fmt" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +// SyncState is where a sync stands. +type SyncState int + +const ( + Synced SyncState = iota + Syncing + SyncFailed +) + +type SyncStatusOptions struct{} + +var ( + cloud = pixel.MustSprite(` + ....eeee.... + ..eellllee.. + .ellllllllle + elllllllllle + elllllllllle + elllllllllle + .eeeeeeeeee.`) + tick = pixel.MustSprite(` + ....... + ......g + .....gg + g...gg. + gg.gg.. + .ggg... + ..g....`) + // Four quarter turns of a round arrow. + turning = pixel.MustSprite(` + ..ccc.. + .c...c. + c....cc + c...ccc + c...... + .c...c. + ..ccc.. + + ..ccc.. + .c...c. + c.....c + c.....c + c..c..c + .c.ccc. + ...cc.. + + ..ccc.. + .c...c. + ......c + ccc...c + cc....c + .c...c. + ..ccc.. + + ..cc... + .ccc.c. + c..c..c + c.....c + c.....c + .c...c. + ..ccc..`) + bang = pixel.MustSprite(` + ..rrr.. + .rrlrr. + rrrlrrr + rrrlrrr + rrrrrrr + .rrlrr. + ..rrr..`) +) + +var syncs = [...]struct { + msg string + ink pixel.Slot + mark *pixel.Sprite +}{ + {"synced", pixel.Faint, tick}, {"syncing", pixel.Ink, turning}, {"sync_failed", pixel.Bad, bang}, +} + +// SyncStatus is a cloud: synced, syncing (arrows turning) or failed. +func SyncStatus(c *ui.Context, state SyncState, o SyncStatusOptions) *ui.Element { + k := pixel.K(c) + if state < Synced || state > SyncFailed { + panic(fmt.Sprintf("files: SyncStatus state %d", state)) + } + s := syncs[state] + say := k.Msg(s.msg) + return ui.Box(c).Size(k.D(16+pixel.Measure(say)), k.D(16)).Role(ui.RoleStatus).Label(say).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Sprite(0, 3, cloud, 0, pixel.Ink, 1) + // Syncing is work, so it turns under Reduce Motion. + frame := 0 + if state == Syncing { + frame = g.Busy(4) % 4 + } + g.Sprite(7, 8, s.mark, frame, pixel.Ink, 1) + g.Text(16, 0, say, s.ink, 1) + }) +} diff --git a/files/trash.go b/files/trash.go new file mode 100644 index 0000000..bfc5432 --- /dev/null +++ b/files/trash.go @@ -0,0 +1,84 @@ +package files + +import ( + "fmt" + "log/slog" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type TrashOptions struct { + Disabled bool +} + +type TrashResult struct { + Element *ui.Element + Emptied bool +} + +var ( + binEmpty = pixel.MustSprite(` + ................ + ......eeee...... + ..eeeeelleeeee.. + ..elllllllllle.. + ..eeeeeeeeeeee.. + ...essssssssse.. + ...eslslslslse.. + ...eslslslslse.. + ...eslslslslse.. + ...eslslslslse.. + ...eslslslslse.. + ...eslslslslse.. + ...eslslslslse.. + ...essssssssse.. + ....eeeeeeeee... + ................`) + // Crumpled paper peeks out from under the lid. + binFull = pixel.MustSprite(` + ......ee........ + .....elle.ee.... + ..eeeeleeeelee.. + ..elllllllllle.. + ..eeeeeeeeeeee.. + ...essssssssse.. + ...eslslslslse.. + ...eslslslslse.. + ...eslslslslse.. + ...eslslslslse.. + ...eslslslslse.. + ...eslslslslse.. + ...eslslslslse.. + ...essssssssse.. + ....eeeeeeeee... + ................`) +) + +// Trash is a bin with its count; EMPTY clears it. +func Trash(c *ui.Context, text string, count int, o TrashOptions) TrashResult { + k := pixel.K(c) + if count < 0 { + panic(fmt.Sprintf("files: Trash count %d", count)) + } + art, say := binEmpty, k.Msg("empty") + if count > 0 { + art, say = binFull, fmt.Sprintf(k.Msg("in_bin"), count) + } + var res TrashResult + res.Element = ui.Row(c).Gap(k.D(6)).AlignItems(ui.Center).Role(ui.RoleGroup).Label(text).Children(func() { + ui.Box(c).Size(k.D(32), k.D(32)).Draw(func(p *ui.Painter, r ui.Rect) { + k.Canvas(p, r).Sprite(0, 0, art, 0, pixel.Ink, 2) + }) + ui.Column(c).Gap(k.D(3)).Children(func() { + pixel.Text(c, say, pixel.TextOptions{}) + res.Emptied = input.Button(c, k.Msg("empty_trash"), input.ButtonOptions{Size: input.Small, Variant: input.Danger, Disabled: o.Disabled || count == 0}).Clicked + }) + }) + if res.Emptied { + slog.Debug("8bit: trash emptied", "label", text, "count", count) + } + return res +} diff --git a/files/uploadqueue.go b/files/uploadqueue.go new file mode 100644 index 0000000..a70cfe7 --- /dev/null +++ b/files/uploadqueue.go @@ -0,0 +1,89 @@ +package files + +import ( + "fmt" + "log/slog" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/feedback" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +// Upload is a file on its way up. +type Upload struct { + // Name is unique in the queue; it keys the row. + Name string + Kind Kind + // Sent and Size are bytes; Sent reaches Size when done. + Sent, Size int64 + Failed bool +} + +type UploadQueueOptions struct { + // Width is in dots; zero means 240. + Width int + Disabled bool +} + +type UploadQueueResult struct { + Element *ui.Element + // Canceled is the upload whose ✕ was pressed, else -1. + Canceled int +} + +// UploadQueue lists uploads with progress; ✕ cancels one. +func UploadQueue(c *ui.Context, text string, ups []Upload, o UploadQueueOptions) UploadQueueResult { + k := pixel.K(c) + w := pixel.Or("UploadQueue Width", o.Width, 240) + // ✕, icon and gaps take 40 dots, plus the percent. + bar := w - 44 - pixel.MicroWidth("100%") + if bar < 8 { + panic(fmt.Sprintf("files: UploadQueue Width %d leaves a %d-dot bar", w, bar)) + } + names := map[string]bool{} + for _, u := range ups { + look(u.Kind) + if u.Name == "" || names[u.Name] || u.Size <= 0 || u.Sent < 0 || u.Sent > u.Size { + panic(fmt.Sprintf("files: UploadQueue %q, twice %v, sent %d of %d", u.Name, names[u.Name], u.Sent, u.Size)) + } + names[u.Name] = true + } + res := UploadQueueResult{Canceled: -1} + res.Element = ui.Column(c).Width(k.D(w)).Gap(k.D(4)).Role(ui.RoleList).Label(text).Children(func() { + for i, u := range ups { + done := u.Sent == u.Size && !u.Failed + ui.Row(c).Key(u.Name).FillWidth().Gap(k.D(4)).AlignItems(ui.Center).Role(ui.RoleListItem).Children(func() { + FileIcon(c, u.Kind, FileIconOptions{}) + ui.Column(c).Grow(1).Gap(k.D(1)).Children(func() { + ui.Row(c).FillWidth().Gap(k.D(4)).Children(func() { + pixel.Text(c, u.Name, pixel.TextOptions{Wrap: true, MaxLines: 1}).Grow(1) + switch { + case u.Failed: + pixel.Text(c, k.Msg("failed"), pixel.TextOptions{Slot: pixel.Bad}) + case done: + pixel.Text(c, k.Msg("done"), pixel.TextOptions{Slot: pixel.Good}) + } + }) + feedback.Progress(c, u.Name, float64(u.Sent)/float64(u.Size), feedback.ProgressOptions{Width: bar, Percent: !done && !u.Failed}) + }) + // A finished upload has nothing left to cancel. + if done { + ui.Box(c).Size(k.D(16), k.D(16)).Draw(func(p *ui.Painter, r ui.Rect) { + k.Canvas(p, r).Sprite(2, 2, icon.Check, 0, pixel.Good, 1) + }) + return + } + if input.IconButton(c, icon.Close, k.Msg("cancel")+" "+u.Name, input.IconButtonOptions{Size: input.Small, Disabled: o.Disabled}).Clicked { + res.Canceled = i + } + }) + } + }) + if res.Canceled >= 0 { + slog.Debug("8bit: upload canceled", "label", text, "upload", res.Canceled) + } + return res +} diff --git a/files/view_test.go b/files/view_test.go new file mode 100644 index 0000000..99071e4 --- /dev/null +++ b/files/view_test.go @@ -0,0 +1,110 @@ +package files_test + +import ( + "bytes" + "image" + "log/slog" + "testing" + "time" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type clock struct{ t time.Time } + +func (c *clock) now() time.Time { return c.t } + +// world is the settings a test view runs under. +type world struct { + clk *clock + reduce, inert bool +} + +func newWorld() *world { + return &world{clk: &clock{time.Date(2026, 9, 2, 10, 30, 0, 0, time.UTC)}} +} + +// view builds on a fake clock, two pixels a dot. +func view(w, h int, wd *world, build func(c *ui.Context)) *ui.Tester { + tt := ui.NewTester(func(c *ui.Context) { + pixel.Use(c, pixel.Settings{Palette: pixel.SpringDay, Now: wd.clk.now, Inert: wd.inert, Reduce: wd.reduce}) + build(c) + }, w, h) + return tt +} + +// later moves the clock on and builds a frame. +func later(tt *ui.Tester, wd *world, d time.Duration) { + wd.clk.t = wd.clk.t.Add(d) + tt.Frame() +} + +func click(t *testing.T, tt *ui.Tester, label string) { + t.Helper() + if err := tt.Click(label); err != nil { + t.Fatalf("%v in %q", err, tt.Texts()) + } +} + +func find(t *testing.T, tt *ui.Tester, label string) ui.Rect { + t.Helper() + r, ok := tt.Find(label) + if !ok { + t.Fatalf("no %q in %q", label, tt.Texts()) + } + return r +} + +// none is a color outside the palette. +const none pixel.Slot = 255 + +// at is the slot at dot x, y, or none. +func at(img *image.RGBA, x, y int) pixel.Slot { + c := img.RGBAAt(2*x, 2*y) + for s, want := range pixel.SpringDay.Colors { + if c.R == want.R && c.G == want.G && c.B == want.B { + return pixel.Slot(s) + } + } + return none +} + +// count is how many dots in a box hold s. +func count(img *image.RGBA, x, y, w, h int, s pixel.Slot) int { + n := 0 + for j := y; j < y+h; j++ { + for i := x; i < x+w; i++ { + if at(img, i, j) == s { + n++ + } + } + } + return n +} + +// dots is a found box in dots. +func dots(r ui.Rect) (x, y, w, h int) { + return int(r.X / 2), int(r.Y / 2), int(r.W / 2), int(r.H / 2) +} + +func mustPanic(t *testing.T, name string, fn func()) { + t.Helper() + defer func() { + if recover() == nil { + t.Errorf("%s did not panic", name) + } + }() + fn() +} + +// logged runs fn with the debug log captured. +func logged(fn func()) string { + var buf bytes.Buffer + old := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))) + defer slog.SetDefault(old) + fn() + return buf.String() +} diff --git a/gallery/demos_files.go b/gallery/demos_files.go new file mode 100644 index 0000000..d7a4964 --- /dev/null +++ b/gallery/demos_files.go @@ -0,0 +1,173 @@ +package gallery + +import ( + "fmt" + "slices" + "time" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/files" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +var filesEntries = []Entry{ + {406, "FileExplorer", "文件管理器", "files", "Folders on the left, their files on the right. Double click opens.", demoFileExplorer}, + {407, "FileGrid", "文件网格", "files", "Big icons over short names. Arrows walk the grid; Enter opens.", demoFileGrid}, + {408, "FileIcon", "文件图标", "files", "Six kinds, placed dot by dot: folder, text, image, audio, code, archive.", demoFileIcon}, + {409, "PathBar", "路径栏", "files", "Where you are, folder by folder. Click one to climb back up.", demoPathBar}, + {410, "MemoryCard", "记忆卡", "files", "Fifteen blocks, like the grey card of old. DELETE frees one.", demoMemoryCard}, + {411, "DiskUsage", "磁盘占用", "files", "Where the room went, stacked in one bar.", demoDiskUsage}, + {412, "UploadQueue", "上传队列", "files", "Files on their way up. ✕ calls one back.", demoUploadQueue}, + {413, "Trash", "回收站", "files", "A bin that looks full when it is. EMPTY takes it out.", demoTrash}, +} + +// disk is the demo's drive, from C: down. +var disk = files.Dir{Name: "C:", Files: []files.File{{Name: "AUTOEXEC.BAT", Kind: files.Code, Size: 512}}, Dirs: []files.Dir{ + {Name: "GAMES", Dirs: []files.Dir{{Name: "HERO QUEST", Files: []files.File{ + {Name: "SAVE1.SAV", Kind: files.Archive, Size: 8192}, + {Name: "MAP.PNG", Kind: files.Image, Size: 48_000}, + {Name: "THEME.MID", Kind: files.Audio, Size: 21_504}, + {Name: "CHEATS.TXT", Kind: files.Text, Size: 1_337}, + }}}, Files: []files.File{{Name: "README.TXT", Kind: files.Text, Size: 2_048}}}, + {Name: "MUSIC", Files: []files.File{ + {Name: "BOSS FIGHT.MID", Kind: files.Audio, Size: 30_720}, + {Name: "CREDITS.MID", Kind: files.Audio, Size: 12_288}, + }}, +}} + +// path is the demo path's folder names, root first. +var path = []string{"C:", "GAMES", "HERO QUEST", "SAVES"} + +type filesDemo struct { + dir string + file, grid, block, trash, at int + saves []files.Save + ups []files.Upload + started time.Time +} + +func filesState(c *ui.Context) *filesDemo { + return ui.Local(c.Root(), demoKey("files"), func() filesDemo { + return filesDemo{dir: "0/0/0", file: -1, grid: -1, trash: 3, at: len(path) - 1, saves: []files.Save{ + {Title: "HERO QUEST", Icon: icon.Sword}, + {Title: "SPACE RACE", Icon: icon.Star, Slot: pixel.Warn}, + {Title: "MONSTER FARM", Icon: icon.Heart, Slot: pixel.Bad}, + {Title: "PUZZLE TOWER", Icon: icon.Gem, Slot: pixel.Magic}, + {Title: "FISHING DAY", Icon: icon.Drop, Slot: pixel.Info}, + }, ups: []files.Upload{ + {Name: "SPEEDRUN.ZIP", Kind: files.Archive, Size: 9 << 20}, + {Name: "BOSS.PNG", Kind: files.Image, Size: 3 << 20}, + {Name: "LEVEL 9.MAP", Kind: files.Code, Size: 1 << 20, Sent: 1 << 19, Failed: true}, + }} + }) +} + +func demoFileExplorer(c *ui.Context, log func(string)) { + s := filesState(c) + r := files.FileExplorer(c, "Explorer", disk, &s.dir, &s.file, files.FileExplorerOptions{}) + if r.Changed { + log(fmt.Sprintf("folder %s, file %d", s.dir, s.file)) + } + if r.Opened { + log(fmt.Sprintf("open file %d", s.file)) + } +} + +func demoFileGrid(c *ui.Context, log func(string)) { + s := filesState(c) + all := slices.Concat(disk.Dirs[0].Dirs[0].Files, []files.File{{Name: "SECRET LEVEL", Kind: files.Folder}}) + r := files.FileGrid(c, "Files", all, &s.grid, files.FileGridOptions{Height: 150}) + if r.Changed { + log("chose " + all[s.grid].Name) + } + if r.Opened { + log("open " + all[s.grid].Name) + } +} + +func demoFileIcon(c *ui.Context, log func(string)) { + k := pixel.K(c) + kinds := []files.Kind{files.Folder, files.Text, files.Image, files.Audio, files.Code, files.Archive} + ui.Column(c).Gap(k.D(6)).Children(func() { + for _, scale := range []int{1, 2} { + ui.Row(c).Gap(k.D(6)).Children(func() { + for _, kind := range kinds { + files.FileIcon(c, kind, files.FileIconOptions{Scale: scale}) + } + }) + } + }) +} + +func demoPathBar(c *ui.Context, log func(string)) { + s := filesState(c) + // Climbing up trims the path; the reset walks back down. + r := files.PathBar(c, "Path", path[:s.at+1], files.PathBarOptions{}) + if r.Clicked >= 0 { + s.at = r.Clicked + log("up to " + path[s.at]) + } + if s.at < len(path)-1 && input.Button(c, "BACK DOWN", input.ButtonOptions{Size: input.Small}).Clicked { + s.at = len(path) - 1 + log("down to " + path[s.at]) + } +} + +func demoMemoryCard(c *ui.Context, log func(string)) { + s := filesState(c) + r := files.MemoryCard(c, "Memory card 1", s.saves, &s.block, files.MemoryCardOptions{}) + if r.Changed { + log(fmt.Sprintf("block %d", s.block+1)) + } + if r.Deleted >= 0 { + log("deleted " + s.saves[r.Deleted].Title) + s.saves = append(s.saves[:r.Deleted:r.Deleted], s.saves[r.Deleted+1:]...) + } +} + +func demoDiskUsage(c *ui.Context, log func(string)) { + files.DiskUsage(c, "Disk", []files.Use{ + {Name: "GAMES", Bytes: 41 << 30}, {Name: "SCREENSHOTS", Bytes: 6 << 30}, + {Name: "MUSIC", Bytes: 12 << 30}, {Name: "SAVES", Bytes: 300 << 20}, + }, 128<<30, files.DiskUsageOptions{Width: 220}) +} + +func demoUploadQueue(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := filesState(c) + if s.started.IsZero() { + s.started = k.Now() + } + // The demo sends a megabyte a second, row by row. + sent := int64(k.Since(c, s.started, 4, 60)) << 18 + ups := make([]files.Upload, len(s.ups)) + for i, u := range s.ups { + if !u.Failed { + u.Sent = min(u.Size, sent) + sent = max(0, sent-u.Size) + } + ups[i] = u + } + if r := files.UploadQueue(c, "Uploads", ups, files.UploadQueueOptions{}); r.Canceled >= 0 { + log("canceled " + s.ups[r.Canceled].Name) + s.ups = append(s.ups[:r.Canceled:r.Canceled], s.ups[r.Canceled+1:]...) + } +} + +func demoTrash(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := filesState(c) + ui.Column(c).Gap(k.D(6)).Children(func() { + if files.Trash(c, "Trash", s.trash, files.TrashOptions{}).Emptied { + log(fmt.Sprintf("emptied %d", s.trash)) + s.trash = 0 + } + if input.Button(c, "THROW AWAY", input.ButtonOptions{Size: input.Small}).Clicked { + s.trash++ + log(fmt.Sprintf("threw one away: %d", s.trash)) + } + }) +} diff --git a/gallery/demos_storage.go b/gallery/demos_storage.go new file mode 100644 index 0000000..dc1cd54 --- /dev/null +++ b/gallery/demos_storage.go @@ -0,0 +1,124 @@ +package gallery + +import ( + "time" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/files" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +var storageEntries = []Entry{ + {414, "RecentFiles", "最近文件", "files", "What you touched lately, and how long ago.", demoRecentFiles}, + {415, "FilePreview", "文件预览", "files", "Pictures shrink to fit, text scrolls, and the rest says so.", demoFilePreview}, + {416, "ArchiveViewer", "压缩包", "files", "What is inside the zip, and how much squeezing saved. Headers sort.", demoArchiveViewer}, + {417, "FileDetails", "文件详情", "files", "Name, kind, size to the byte, and when it all happened.", demoFileDetails}, + {418, "SharePanel", "分享面板", "files", "A link to send and how much it lets them do. COPY takes it.", demoSharePanel}, + {419, "SyncStatus", "同步状态", "files", "All saved, saving (the arrows turn), or something went wrong.", demoSyncStatus}, + {420, "QuotaMeter", "配额", "files", "Storage as gems, like hearts in an old adventure. Low turns gold, then red.", demoQuotaMeter}, +} + +type storageDemo struct { + recent, entry, kind int + access files.Access +} + +func storageState(c *ui.Context) *storageDemo { + return ui.Local(c.Root(), demoKey("storage"), func() storageDemo { return storageDemo{recent: -1, entry: -1} }) +} + +func demoRecentFiles(c *ui.Context, log func(string)) { + s := storageState(c) + now := pixel.K(c).Now() + recent := []files.File{ + {Name: "SAVE1.SAV", Kind: files.Archive, Modified: now.Add(-20 * time.Second)}, + {Name: "CHEATS.TXT", Kind: files.Text, Modified: now.Add(-12 * time.Minute)}, + {Name: "MAP.PNG", Kind: files.Image, Modified: now.Add(-5 * time.Hour)}, + {Name: "THEME.MID", Kind: files.Audio, Modified: now.Add(-30 * time.Hour)}, + {Name: "MAIN.GO", Kind: files.Code, Modified: now.Add(-9 * 24 * time.Hour)}, + } + r := files.RecentFiles(c, "Recent", recent, &s.recent, files.RecentFilesOptions{}) + if r.Changed { + log("chose " + recent[s.recent].Name) + } + if r.Opened { + log("open " + recent[s.recent].Name) + } +} + +const cheats = "UP UP DOWN DOWN LEFT RIGHT LEFT RIGHT B A START.\n\nWorks on the title screen. Thirty lives, or so the playground said. Hold SELECT for the level select, but only on Tuesdays." + +func demoFilePreview(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := storageState(c) + ui.Column(c).Gap(k.D(6)).Children(func() { + if input.Segmented(c, "File", []string{"CHEATS.TXT", "SUNSET.PNG", "THEME.MID"}, &s.kind, input.SegmentedOptions{}).Changed { + log([]string{"text", "picture", "no preview"}[s.kind]) + } + switch s.kind { + case 0: + files.FilePreview(c, "CHEATS.TXT", files.Preview{Text: cheats}, files.FilePreviewOptions{H: 80}) + case 1: + files.FilePreview(c, "SUNSET.PNG", files.Preview{Picture: sunset}, files.FilePreviewOptions{H: 80}) + default: + files.FilePreview(c, "THEME.MID", files.Preview{}, files.FilePreviewOptions{H: 80}) + } + }) +} + +func demoArchiveViewer(c *ui.Context, log func(string)) { + s := storageState(c) + entries := []files.Entry{ + {Path: "LEVELS/1-1.MAP", Size: 18_432, Packed: 3_120}, + {Path: "LEVELS/1-2.MAP", Size: 20_480, Packed: 3_604}, + {Path: "MUSIC/THEME.MID", Size: 21_504, Packed: 19_800}, + {Path: "SPRITES.PNG", Size: 65_536, Packed: 64_900}, + {Path: "README.TXT", Size: 2_048, Packed: 900}, + } + r := files.ArchiveViewer(c, "LOOT.ZIP", entries, &s.entry, files.ArchiveViewerOptions{Height: 80}) + if r.Changed { + log("chose " + entries[s.entry].Path) + } + if r.Sorted { + log("sorted") + } +} + +func demoFileDetails(c *ui.Context, log func(string)) { + files.FileDetails(c, files.File{ + Name: "BOSS FIGHT.PNG", Kind: files.Image, Size: 1_234_567, + Created: time.Date(1998, 11, 21, 9, 30, 0, 0, time.UTC), + Modified: time.Date(2026, 4, 1, 23, 59, 0, 0, time.UTC), + }, files.FileDetailsOptions{}) +} + +func demoSharePanel(c *ui.Context, log func(string)) { + s := storageState(c) + r := files.SharePanel(c, "Share", "https://8bit.example/s/UUDDLRLRBA", &s.access, files.SharePanelOptions{}) + if r.Copied { + log("link copied") + } + if r.Changed { + log([]string{"view", "comment", "edit"}[s.access]) + } +} + +func demoSyncStatus(c *ui.Context, log func(string)) { + k := pixel.K(c) + ui.Column(c).Gap(k.D(6)).Children(func() { + for _, st := range []files.SyncState{files.Synced, files.Syncing, files.SyncFailed} { + files.SyncStatus(c, st, files.SyncStatusOptions{}) + } + }) +} + +func demoQuotaMeter(c *ui.Context, log func(string)) { + k := pixel.K(c) + ui.Column(c).Gap(k.D(8)).Children(func() { + for _, used := range []int64{3 << 30, 13 << 30, 16 << 30} { + files.QuotaMeter(c, "Storage", used, 15<<30, files.QuotaMeterOptions{}) + } + }) +} diff --git a/gallery/files_demo_test.go b/gallery/files_demo_test.go new file mode 100644 index 0000000..a68eba3 --- /dev/null +++ b/gallery/files_demo_test.go @@ -0,0 +1,35 @@ +package gallery + +import ( + "strings" + "testing" +) + +func TestFilesDemosLogTheirActions(t *testing.T) { + // Clicks that only focus a component may log nothing. + quiet := map[string]bool{"FOLDERS": true} + for _, tc := range []struct { + path string + steps []string + }{ + {"/c/406-fileexplorer", []string{"FOLDERS", "key:Up"}}, {"/c/407-filegrid", []string{"SAVE1.SAV", "key:Right", "key:Enter"}}, + {"/c/409-pathbar", []string{"GAMES", "BACK DOWN"}}, {"/c/410-memorycard", []string{"DELETE HERO QUEST"}}, + {"/c/412-uploadqueue", []string{"CANCEL BOSS.PNG"}}, {"/c/413-trash", []string{"EMPTY", "THROW AWAY"}}, + {"/c/414-recentfiles", []string{"Recent", "key:Down", "key:Enter"}}, {"/c/415-filepreview", []string{"SUNSET.PNG"}}, + {"/c/416-archiveviewer", []string{"LOOT.ZIP", "key:Down", "SIZE"}}, {"/c/418-sharepanel", []string{"COPY", "EDIT"}}, + } { + a, tt := open(tc.path) + slug := tc.path[3:] + for _, step := range tc.steps { + before := len(a.logs[slug]) + if k, ok := strings.CutPrefix(step, "key:"); ok { + tt.Key(0, letters[k]) + } else { + click(t, tt, step) + } + if len(a.logs[slug]) <= before && !quiet[step] { + t.Errorf("%s: %s logged nothing: %q", slug, step, a.logs[slug]) + } + } + } +} diff --git a/gallery/media_demo_test.go b/gallery/media_demo_test.go index 8052d16..da60738 100644 --- a/gallery/media_demo_test.go +++ b/gallery/media_demo_test.go @@ -7,7 +7,7 @@ import ( "github.com/egoist/mygo/ui" ) -var letters = map[string]ui.Key{"A": ui.KeyA, "Right": ui.KeyRight, "Up": ui.KeyUp, "Enter": ui.KeyEnter} +var letters = map[string]ui.Key{"A": ui.KeyA, "Right": ui.KeyRight, "Up": ui.KeyUp, "Down": ui.KeyDown, "Enter": ui.KeyEnter, "Tab": ui.KeyTab} func TestMediaDemosLogTheirActions(t *testing.T) { for _, tc := range []struct { diff --git a/gallery/registry.go b/gallery/registry.go index 3cdba8f..18deaa6 100644 --- a/gallery/registry.go +++ b/gallery/registry.go @@ -56,7 +56,7 @@ var Categories = []Category{ {"tabletop", "Tabletop", "桌游", icon.Crown}, } -var entries = slices.Concat(foundationEntries, layoutEntries, textEntries, displayEntries, inputEntries, controlEntries, feedbackEntries, navigationEntries, dataEntries, recordEntries, overlayEntries, popupEntries, datetimeEntries, timeEntries, chartEntries, plotEntries, gameEntries, rpgEntries, screenEntries, lootEntries, fxEntries, screenFxEntries, chatEntries, mailEntries, mediaEntries, studioEntries, codeEntries, debugEntries, accountEntries, playerEntries) +var entries = slices.Concat(foundationEntries, layoutEntries, textEntries, displayEntries, inputEntries, controlEntries, feedbackEntries, navigationEntries, dataEntries, recordEntries, overlayEntries, popupEntries, datetimeEntries, timeEntries, chartEntries, plotEntries, gameEntries, rpgEntries, screenEntries, lootEntries, fxEntries, screenFxEntries, chatEntries, mailEntries, mediaEntries, studioEntries, codeEntries, debugEntries, accountEntries, playerEntries, filesEntries, storageEntries) func Entries() []Entry { return entries } diff --git a/pixel/messages.go b/pixel/messages.go index 51f543e..d961dec 100644 --- a/pixel/messages.go +++ b/pixel/messages.go @@ -45,6 +45,11 @@ var messages = map[Locale]map[string]string{ "added": "ADDED", "fixed": "FIXED", "changed": "CHANGED", "hair": "HAIR", "eyes": "EYES", "outfit": "OUTFIT", "hair_color": "HAIR COLOR", "outfit_color": "OUTFIT COLOR", "bgm": "BGM", "se": "SE", "voice": "VOICE", "styles": "SPIKY BOB BALD", "looks": "DOTS WIDE SLEEPY", "outfits": "TUNIC ROBE ARMOR", "hues": "DARK GOLD RED VIOLET SKY", "time_left": "TIME LEFT", "in_use": "IN USE", "rating": "RATING", "message": "MESSAGE", "symbols": "SYMBOLS", + "folder": "FOLDER", "text_file": "TEXT", "image_file": "IMAGE", "audio_file": "AUDIO", "code_file": "CODE", "archive_file": "ARCHIVE", + "folders": "FOLDERS", "free": "FREE", "blocks_used": "%d/15 BLOCKS", "delete": "DELETE", "used_of": "%s OF %s", "failed": "FAILED", + "in_bin": "IN THE BIN: %d", "empty_trash": "EMPTY", "no_preview": "NO PREVIEW", "kind": "KIND", "size": "SIZE", "created": "CREATED", + "modified": "MODIFIED", "packed": "PACKED", "archive_total": "%d FILES: %s → %s", "link": "LINK", "access": "ACCESS", + "access_levels": "VIEW COMMENT EDIT", "synced": "SYNCED", "syncing": "SYNCING", "sync_failed": "SYNC FAILED", }, Zh: { "ok": "确定", "cancel": "取消", "yes": "是", "no": "否", "close": "关闭", @@ -88,6 +93,11 @@ var messages = map[Locale]map[string]string{ "added": "新增", "fixed": "修复", "changed": "改动", "hair": "发型", "eyes": "眼睛", "outfit": "服装", "hair_color": "发色", "outfit_color": "衣色", "bgm": "音乐", "se": "音效", "voice": "语音", "styles": "刺头 短发 光头", "looks": "圆眼 大眼 睡眼", "outfits": "短衣 长袍 铠甲", "hues": "墨 金 红 堇 蓝", "time_left": "剩余时间", "in_use": "使用中", "rating": "评分", "message": "留言", "symbols": "符号", + "folder": "文件夹", "text_file": "文本", "image_file": "图片", "audio_file": "音频", "code_file": "代码", "archive_file": "压缩包", + "folders": "文件夹", "free": "空闲", "blocks_used": "已用 %d/15 块", "delete": "删除", "used_of": "已用 %s,共 %s", "failed": "失败", + "in_bin": "回收站:%d 项", "empty_trash": "清空", "no_preview": "无法预览", "kind": "类型", "size": "大小", "created": "创建", + "modified": "修改", "packed": "压缩后", "archive_total": "%d 个文件:%s → %s", "link": "链接", "access": "权限", + "access_levels": "查看 评论 编辑", "synced": "已同步", "syncing": "同步中", "sync_failed": "同步出错", }, }