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.
This commit is contained in:
ZacharyZhang-NY committed 2026-10-12 09:27:26 -04:00
1 parent c745b082a9
commit 552b073e81
30 files changed
+2416 -2

No files matched your search

+8
View File
@@ -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}
+11
View File
@@ -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)
}
}
+88
View File
@@ -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
}
+200
View File
@@ -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"))
}
}
+80
View File
@@ -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})
})
}
+51
View File
@@ -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
}
+101
View File
@@ -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
}
+62
View File
@@ -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)
}
+27
View File
@@ -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)
})
}
+63
View File
@@ -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))
}
+60
View File
@@ -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()
})
}
+47
View File
@@ -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)
}
}
}
+114
View File
@@ -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]
}
+98
View File
@@ -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)
}
}
+66
View File
@@ -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)
}
}
}
}
+72
View File
@@ -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
}
+68
View File
@@ -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)
})
}
+48
View File
@@ -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
}
+80
View File
@@ -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
}
+336
View File
@@ -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}) })
})
}
}
+109
View File
@@ -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)
})
}
+84
View File
@@ -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
}
+89
View File
@@ -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
}
+110
View File
@@ -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()
}
+173
View File
@@ -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))
}
})
}
+124
View File
@@ -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{})
}
})
}
+35
View File
@@ -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])
}
}
}
}
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -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 }
+10
View File
@@ -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": "同步出错",
},
}