From 552b073e81477c09d420b0a1789e2e07744489b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 12 Oct 2026 09:27:26 -0400 Subject: [PATCH] Add files components 406-420; Tree opens to a known choice Fifteen file components: FileExplorer, FileGrid, FileIcon, PathBar, MemoryCard, DiskUsage, UploadQueue, Trash, RecentFiles, FilePreview, ArchiveViewer, FileDetails, SharePanel, SyncStatus and QuotaMeter. FileIcon draws six kinds as hand-placed 12x12 pages and a folder. The browsers build on data.Tree, List, GridView and Table; ArchiveViewer owns its sort and keeps the caller's index on its entry. UploadQueue keys rows by name so focus stays on its row. SharePanel never logs the link. SyncStatus turns its arrows on Busy steps, so syncing moves under Reduce Motion. data.Tree now opens the closed branches above a chosen path it knows, so a caller's starting path survives the first frame. --- data/tree.go | 8 + data/treevalue_test.go | 11 ++ files/archiveviewer.go | 88 +++++++++ files/browse_test.go | 200 ++++++++++++++++++++ files/diskusage.go | 80 ++++++++ files/filedetails.go | 51 +++++ files/fileexplorer.go | 101 ++++++++++ files/filegrid.go | 62 ++++++ files/fileicon.go | 27 +++ files/filepreview.go | 63 +++++++ files/files.go | 60 ++++++ files/helpers_internal_test.go | 47 +++++ files/kinds.go | 114 +++++++++++ files/memorycard.go | 98 ++++++++++ files/off_test.go | 66 +++++++ files/pathbar.go | 72 +++++++ files/quotameter.go | 68 +++++++ files/recentfiles.go | 48 +++++ files/sharepanel.go | 80 ++++++++ files/storage_test.go | 336 +++++++++++++++++++++++++++++++++ files/syncstatus.go | 109 +++++++++++ files/trash.go | 84 +++++++++ files/uploadqueue.go | 89 +++++++++ files/view_test.go | 110 +++++++++++ gallery/demos_files.go | 173 +++++++++++++++++ gallery/demos_storage.go | 124 ++++++++++++ gallery/files_demo_test.go | 35 ++++ gallery/media_demo_test.go | 2 +- gallery/registry.go | 2 +- pixel/messages.go | 10 + 30 files changed, 2416 insertions(+), 2 deletions(-) create mode 100644 files/archiveviewer.go create mode 100644 files/browse_test.go create mode 100644 files/diskusage.go create mode 100644 files/filedetails.go create mode 100644 files/fileexplorer.go create mode 100644 files/filegrid.go create mode 100644 files/fileicon.go create mode 100644 files/filepreview.go create mode 100644 files/files.go create mode 100644 files/helpers_internal_test.go create mode 100644 files/kinds.go create mode 100644 files/memorycard.go create mode 100644 files/off_test.go create mode 100644 files/pathbar.go create mode 100644 files/quotameter.go create mode 100644 files/recentfiles.go create mode 100644 files/sharepanel.go create mode 100644 files/storage_test.go create mode 100644 files/syncstatus.go create mode 100644 files/trash.go create mode 100644 files/uploadqueue.go create mode 100644 files/view_test.go create mode 100644 gallery/demos_files.go create mode 100644 gallery/demos_storage.go create mode 100644 gallery/files_demo_test.go 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": "同步出错", }, }