diff --git a/desk/apps_test.go b/desk/apps_test.go new file mode 100644 index 0000000..d264821 --- /dev/null +++ b/desk/apps_test.go @@ -0,0 +1,184 @@ +package desk_test + +import ( + "strings" + "testing" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/desk" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +func TestAppWindowDragsArrowsAndStaysInside(t *testing.T) { + x, y, closed, off := 10, 10, 0, false + tt := view(700, 500, newWorld(), func(c *ui.Context) { + ui.Box(c).Size(640, 400).Children(func() { + r := desk.AppWindow(c, "WIN", &x, &y, desk.AppWindowOptions{Width: 100, Height: 40, Room: [2]int{320, 200}, Disabled: off}, func() {}) + if r.Closed { + closed++ + } + }) + }) + b := find(t, tt, "WIN") + // The title bar drags the window by whole dots. + tt.Press(b.X+20, b.Y+10) + tt.Move(b.X+20+41, b.Y+10+20) + tt.Release(b.X+20+41, b.Y+10+20) + if x != 30 || y != 20 { + t.Fatalf("a 41×20 DIP drag left it at %d, %d", x, y) + } + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyDown) + if x != 34 || y != 24 { + t.Fatalf("arrows left it at %d, %d", x, y) + } + // It stays in its room, title bar and all. + for range 100 { + tt.Key(0, ui.KeyRight) + } + if x != 320-100-6 { + t.Fatalf("far right is %d", x) + } + // On the bar, a key sent while turning off fails. + click(t, tt, "CLOSE") + tt.Key(ui.Shift, ui.KeyTab) + if !tt.Focused("WIN") { + t.Fatal("Shift+Tab did not reach the title bar") + } + off = true + tt.Key(0, ui.KeyLeft) + if closed != 1 || x != 214 { + t.Fatalf("closed %d; a disabled window moved to %d", closed, x) + } +} + +func TestCalculatorAddsUp(t *testing.T) { + tt := view(400, 300, newWorld(), func(c *ui.Context) { desk.Calculator(c, "Calc", desk.CalculatorOptions{}) }) + tt.Key(0, ui.KeyTab) + press := func(keys string) { + for _, r := range keys { + switch r { + case '+': + tt.Key(ui.Shift, ui.KeyEqual) + case '*': + tt.Key(ui.Shift, ui.Key8) + case '-': + tt.Key(0, ui.KeyMinus) + case '/': + tt.Key(0, ui.KeySlash) + case '=': + tt.Key(0, ui.KeyEqual) + case 'C': + tt.Key(0, ui.KeyC) + default: + tt.Key(0, ui.Key0+ui.Key(r-'0')) + } + } + } + for keys, want := range map[string]string{ + "12+30=": "42", "7/2=": "3.5", "2*3-1=": "5", "100+0=": "100", "1/3=": "0.3333333333", "0-5=": "-5", "5*0=": "0", + "1/0=": "GAME OVER", "999999*999999=": "999998000001", "9999999*999999=": "GAME OVER", "100000000000+0=": "100000000000", + } { + press("C" + keys) + if _, ok := tt.Find(want); !ok { + t.Errorf("%s gave %q, want %q", keys, tt.Texts(), want) + } + } + // After GAME OVER only C continues. + press("C1/0=5") + find(t, tt, "GAME OVER") + press("C") + find(t, tt, "0") + // The pad's keys work by arrows and Enter too. + tt.Key(0, ui.KeyHome) + tt.Key(0, ui.KeyEnter) + find(t, tt, "7") +} + +func TestNotepadMenus(t *testing.T) { + note, saved := "hi", 0 + tt := view(600, 400, newWorld(), func(c *ui.Context) { + if desk.Notepad(c, "Pad", ¬e, desk.NotepadOptions{}).Saved { + saved++ + } + }) + click(t, tt, "EDIT") + click(t, tt, "TIME/DATE") + if note != "hi10:30 2026-09-02" { + t.Fatalf("TIME/DATE gave %q", note) + } + click(t, tt, "FILE") + click(t, tt, "SAVE") + click(t, tt, "FILE") + click(t, tt, "NEW") + if saved != 1 || note != "" { + t.Fatalf("saved %d, note %q", saved, note) + } +} + +func TestRunDialogRunsOrQuits(t *testing.T) { + wd := newWorld() + run, ran, quit := "", 0, 0 + tt := view(600, 300, wd, func(c *ui.Context) { + r := desk.RunDialog(c, "Run", &run, desk.RunDialogOptions{}) + if r.Submitted { + ran++ + } + if r.Canceled { + quit++ + } + }) + click(t, tt, "OK") + if ran != 0 { + t.Fatal("a blank name ran") + } + click(t, tt, "OPEN:") + log := logged(func() { + tt.Type("snake") + tt.Key(0, ui.KeyEnter) + }) + if ran != 1 || strings.Contains(log, "snake") || !strings.Contains(log, "runes=5") { + t.Fatalf("ran %d; log:\n%s", ran, log) + } + click(t, tt, "OK") + tt.Key(0, ui.KeyEscape) + click(t, tt, "CANCEL") + click(t, tt, "CLOSE") + if ran != 2 || quit != 3 { + t.Fatalf("ran %d, quit %d", ran, quit) + } + // Veiled, Enter in the field runs nothing. + click(t, tt, "OPEN:") + wd.inert = true + tt.Key(0, ui.KeyEnter) + if ran != 2 { + t.Fatal("a veiled dialog ran") + } +} + +func TestClipboardPanelCopiesAndKeepsFocus(t *testing.T) { + wd := newWorld() + clips, copied := []string{"alpha", "beta secret"}, -1 + tt := view(600, 300, wd, func(c *ui.Context) { + if i := desk.ClipboardPanel(c, "Clips", clips, desk.ClipboardPanelOptions{}).Copied; i >= 0 { + copied = i + } + }) + log := logged(func() { click(t, tt, "beta secret") }) + x, y, w, h := dots(find(t, tt, "beta secret")) + if copied != 1 || tt.Clipboard() != "beta secret" || strings.Contains(log, "secret") || count(tt.Image(), x, y, w, h, pixel.Good) == 0 { + t.Fatalf("copied %d, clipboard %q, no COPIED!, or log:\n%s", copied, tt.Clipboard(), log) + } + // A new clip on top keeps the focus on beta. + clips = append([]string{"gamma"}, clips...) + tt.Frame() + if !tt.Focused("beta secret") { + t.Fatal("the focus left beta") + } + later(tt, wd, 3e9) + x, y, w, h = dots(find(t, tt, "beta secret")) + if count(tt.Image(), x, y, w, h, pixel.Good) > 0 { + t.Fatal("COPIED! outstayed two seconds") + } +} diff --git a/desk/appwindow.go b/desk/appwindow.go new file mode 100644 index 0000000..98a3895 --- /dev/null +++ b/desk/appwindow.go @@ -0,0 +1,109 @@ +package desk + +import ( + "fmt" + "log/slog" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type AppWindowOptions struct { + // The body's Width and Height are dots; zeros mean 160×100. + Width, Height int + // Room is its area in dots; zero leaves it open. + Room [2]int + Disabled bool +} + +type AppWindowResult struct { + Element *ui.Element + // Closed is the close box; Moved, a drag or arrow. + Closed, Moved bool +} + +type dragKey struct{} + +// AppWindow floats at x, y; drag or arrow its bar. +func AppWindow(c *ui.Context, title string, x, y *int, o AppWindowOptions, content func()) AppWindowResult { + k := pixel.K(c) + w, h := pixel.Or("AppWindow Width", o.Width, 160), pixel.Or("AppWindow Height", o.Height, 100) + // The bar needs room for a grip and close box. + if w < 40 { + panic(fmt.Sprintf("desk: AppWindow Width %d is under 40", w)) + } + // The window is its body, bar and frame. + ww, wh := w+6, h+pixel.LineHeight+10 + if o.Room[0] > 0 && o.Room[0] < ww || o.Room[1] > 0 && o.Room[1] < wh { + panic(fmt.Sprintf("desk: AppWindow %d×%d does not fit its Room %v", ww, wh, o.Room)) + } + ox, oy := *x, *y + keep := func() { + if o.Room[0] > 0 { + *x = min(*x, o.Room[0]-ww) + } + if o.Room[1] > 0 { + *y = min(*y, o.Room[1]-wh) + } + *x, *y = max(0, *x), max(0, *y) + } + keep() + win := ui.Column(c).Absolute().Width(k.D(ww)).Padding(k.D(3)).Gap(k.D(2)).Role(ui.RoleGroup).Label(title) + // rest keeps a drag's part-dot for the next frame. + rest := ui.Local(win, dragKey{}, func() [2]float32 { return [2]float32{} }) + active := win.FocusWithin() + if o.Disabled { + win.Description(k.Msg("disabled")) + } + res := AppWindowResult{Element: win} + win.Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Frame(0, 0, g.W, g.H, pixel.Raised, pixel.Face) + }).Children(func() { + ui.Row(c).FillWidth().Gap(k.D(2)).AlignItems(ui.Center).Children(func() { + shell, base, st := pixel.Shell(c, func() *ui.Element { + return ui.Box(c).Focusable().Role(ui.RoleGroup).Label(title).Description(k.Msg("arrows_move")).Disabled(o.Disabled) + }) + if !base.IsDisabled() { + if dx, dy, ok := base.Dragged(); ok { + rest[0], rest[1] = rest[0]+dx/k.Dot(), rest[1]+dy/k.Dot() + *x, *y = *x+int(rest[0]), *y+int(rest[1]) + rest[0], rest[1] = rest[0]-float32(int(rest[0])), rest[1]-float32(int(rest[1])) + } + for _, m := range []struct { + key ui.Key + dx, dy int + }{{ui.KeyLeft, -4, 0}, {ui.KeyRight, 4, 0}, {ui.KeyUp, 0, -4}, {ui.KeyDown, 0, 4}} { + if base.Shortcut(0, m.key) { + *x, *y = *x+m.dx, *y+m.dy + } + } + } + shell.Grow(1).Height(k.D(pixel.LineHeight + 2)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + barLook(g, title, active) + g.Finish(st) + }) + res.Closed = input.IconButton(c, icon.Close, k.Msg("close"), input.IconButtonOptions{Size: input.Small, Disabled: o.Disabled}).Clicked + }) + // Off, the body's controls are off too, and veiled. + in := pixel.Current(c) + in.Inert = in.Inert || o.Disabled + ui.Box(c).Size(k.D(w), k.D(h)).Clip().Children(func() { pixel.Scope(c, in, content) }).DrawOver(func(p *ui.Painter, r ui.Rect) { + if o.Disabled { + g := k.Canvas(p, r) + g.Checker(0, 0, g.W, g.H, pixel.Face) + } + }) + }) + keep() + win.Left(k.D(*x)).Top(k.D(*y)) + res.Moved = *x != ox || *y != oy + if res.Moved || res.Closed { + slog.Debug("8bit: app window", "title", title, "x", *x, "y", *y, "closed", res.Closed) + } + return res +} diff --git a/desk/bootscreen.go b/desk/bootscreen.go new file mode 100644 index 0000000..559bf1f --- /dev/null +++ b/desk/bootscreen.go @@ -0,0 +1,80 @@ +package desk + +import ( + "fmt" + "log/slog" + "time" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type BootScreenOptions struct { + // Memory counts up in kilobytes; zero means 640. + Memory int + // Width and Height are dots; zeros mean 320 by 200. + Width, Height int +} + +type BootScreenResult struct { + Element *ui.Element + // Done is the frame the last line shows. + Done bool +} + +type bootKey struct{} + +// boot is when the screen showed, and if it finished. +type boot struct { + at time.Time + done bool +} + +// BootScreen counts memory, then prints POST lines by the logo. +func BootScreen(c *ui.Context, title string, lines []string, o BootScreenOptions) BootScreenResult { + k := pixel.K(c) + mem := pixel.Or("BootScreen Memory", o.Memory, 640) + w, h := pixel.Or("BootScreen Width", o.Width, 320), pixel.Or("BootScreen Height", o.Height, 200) + if title == "" || mem < 1 || w < 120 || 6+(len(lines)+3)*pixel.LineHeight > h { + panic(fmt.Sprintf("desk: BootScreen %q, %dK, %d lines in %d dots", title, mem, len(lines), h)) + } + e := ui.Box(c).Size(k.D(w), k.D(h)).Role(ui.RoleStatus).Label(title) + st := ui.Local(e, bootKey{}, func() boot { return boot{at: k.Now()} }) + // Counting takes 15 steps, a line 4; reduced, at once. + total := 15 + 4*max(0, len(lines)-1) + n := total + if !k.Reduce() { + n = max(0, k.Since(c, st.at, 15, total)) + } + res := BootScreenResult{Element: e, Done: n == total && !st.done} + st.done = n == total + kb := mem / 15 * min(n, 15) + if n >= 15 { + kb = mem + } + count := fmt.Sprintf(k.Msg("memory_test"), kb) + if n >= 15 { + count += " " + k.Msg("ok") + } + shown := lines[:max(0, min(len(lines), (n-15)/4+1))] + if n < 15 { + shown = nil + } + e.Description(count) + if res.Done { + slog.Debug("8bit: boot screen done", "label", title) + } + e.Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Rect(0, 0, w, h, pixel.Well) + g.Sprite(w-44, 6, icon.Star, 0, pixel.Warn, 3) + g.Text(6, 6, pixel.Wrap(title, w-56, 1)[0], pixel.Ink, 1) + g.Text(6, 6+2*pixel.LineHeight, pixel.Wrap(count, w-56, 1)[0], pixel.Ink, 1) + for i, l := range shown { + g.Text(6, 6+(3+i)*pixel.LineHeight, pixel.Wrap(l, w-12, 1)[0], pixel.Faint, 1) + } + }) + return res +} diff --git a/desk/calculator.go b/desk/calculator.go new file mode 100644 index 0000000..777dc23 --- /dev/null +++ b/desk/calculator.go @@ -0,0 +1,180 @@ +package desk + +import ( + "log/slog" + "math" + "strconv" + "strings" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type CalculatorOptions struct { + Disabled bool +} + +type CalculatorResult struct { + Element *ui.Element + // Changed is a key pressed this frame. + Changed bool +} + +type calcKey struct{} + +// calc is the entry, total, waiting sign and cursor. +type calc struct { + entry, op string + acc float64 + over bool + cur int +} + +var calcKeys = []string{"7", "8", "9", "÷", "4", "5", "6", "×", "1", "2", "3", "-", "C", "0", "=", "+"} + +// signs are keyboard keys for the operator keys. +var signs = []struct { + mods ui.Modifiers + key ui.Key + sign string +}{{0, ui.KeyMinus, "-"}, {0, ui.KeySlash, "÷"}, {0, ui.KeyEqual, "="}, {ui.Shift, ui.KeyEqual, "+"}, {ui.Shift, ui.Key8, "×"}, {0, ui.KeyC, "C"}} + +const keyW, keyH = 24, 20 + +// Calculator works: digits, signs, = and C; ÷0 ends it. +func Calculator(c *ui.Context, text string, o CalculatorOptions) CalculatorResult { + k := pixel.K(c) + col := ui.Column(c).Gap(k.D(4)) + cl := ui.Local(col, calcKey{}, func() calc { return calc{} }) + res := CalculatorResult{} + res.Element = col.Children(func() { + screen := ui.Box(c).Size(k.D(4*keyW), k.D(pixel.LineHeight+6)).Role(ui.RoleStatus) + shell, base, st, picked := pixel.Pad(c, text, 4, len(calcKeys), keyW, keyH, &cl.cur, o.Disabled) + // Digits and signs typed on the keyboard press their keys. + if picked < 0 && !base.IsDisabled() && !pixel.Typing(c) { + for i := range 10 { + if base.Shortcut(0, ui.Key0+ui.Key(i)) { + picked = indexOf(strconv.Itoa(i)) + } + } + for _, s := range signs { + if base.Shortcut(s.mods, s.key) { + picked = indexOf(s.sign) + } + } + } + if picked >= 0 { + cl.press(calcKeys[picked]) + res.Changed = true + slog.Debug("8bit: calculator", "label", text, "key", calcKeys[picked]) + } + shown, ink := cl.display(), pixel.Ink + if cl.over { + shown, ink = k.Msg("game_over"), pixel.Bad + } + screen.Label(shown).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Frame(0, 0, g.W, g.H, pixel.Sunken, pixel.Well) + s := pixel.Wrap(shown, g.W-8, 1)[0] + g.Text(g.W-4-pixel.Measure(s), 3, s, ink, 1) + }) + base.Description(calcKeys[cl.cur]) + at, over := cl.cur, cl.over + shell.Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + for i, key := range calcKeys { + x, y := i%4*keyW, i/4*keyH + fill, ink := pixel.Face, pixel.Ink + // After GAME OVER, C glows: it is the CONTINUE. + if key == "C" && over { + fill, ink = pixel.Accent, pixel.AccentInk + } + g.Frame(x+1, y+1, keyW-2, keyH-2, pixel.Raised, fill) + if i == at && st.Focus { + g.Cursor(x, y, keyW, keyH) + ink = pixel.Ink + } + g.Text(x+(keyW-pixel.Measure(key))/2, y+2, key, ink, 1) + } + g.Finish(st) + }) + }) + return res +} + +// indexOf is a key's place on the pad. +func indexOf(key string) int { + for i, k := range calcKeys { + if k == key { + return i + } + } + panic("desk: no calculator key " + key) +} + +// press takes a key: digits build, signs fold in. +func (cl *calc) press(key string) { + switch key { + case "C": + *cl = calc{cur: cl.cur} + case "=": + cl.fold() + cl.op = "" + case "+", "-", "×", "÷": + cl.fold() + cl.op = key + default: + switch { + case cl.entry == "0": + cl.entry = key + case len(cl.entry) < 12: + cl.entry += key + } + } +} + +// fold applies the waiting sign to the entry. +func (cl *calc) fold() { + if cl.entry == "" { + return + } + v := 0.0 + for _, r := range cl.entry { + v = v*10 + float64(r-'0') + } + cl.entry = "" + switch cl.op { + case "": + cl.acc = v + case "+": + cl.acc += v + case "-": + cl.acc -= v + case "×": + cl.acc *= v + case "÷": + cl.acc /= v + } + // ÷0 and past twelve digits are GAME OVER. + if math.IsNaN(cl.acc) || math.Abs(cl.acc) >= 1e12 { + cl.over = true + } +} + +// display is the entry while typing, else the total. +func (cl *calc) display() string { + if cl.entry != "" { + return cl.entry + } + v := cl.acc + whole := len(strconv.Itoa(int(math.Abs(v)))) + s := strconv.FormatFloat(v, 'f', max(0, 11-whole), 64) + if strings.Contains(s, ".") { + s = strings.TrimSuffix(strings.TrimRight(s, "0"), ".") + } + if s == "-0" { + return "0" + } + return s +} diff --git a/desk/clipboardpanel.go b/desk/clipboardpanel.go new file mode 100644 index 0000000..450c0e3 --- /dev/null +++ b/desk/clipboardpanel.go @@ -0,0 +1,88 @@ +package desk + +import ( + "fmt" + "log/slog" + "strings" + "time" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type ClipboardPanelOptions struct { + // Width is in dots; zero means 200. + Width int + Disabled bool +} + +type ClipboardPanelResult struct { + Element *ui.Element + // Copied is the clip copied this frame, else -1. + Copied int +} + +type clipKey struct{} + +// copied is the clip last copied and when. +type copied struct { + clip string + at time.Time +} + +// ClipboardPanel lists recent clips; a click copies one back. +func ClipboardPanel(c *ui.Context, text string, clips []string, o ClipboardPanelOptions) ClipboardPanelResult { + k := pixel.K(c) + seen := map[string]bool{} + for i, s := range clips { + if s == "" || seen[s] { + panic(fmt.Sprintf("desk: ClipboardPanel clip %d is blank or twice", i)) + } + seen[s] = true + } + w := pixel.Or("ClipboardPanel Width", o.Width, 200) + col := ui.Column(c).Width(k.D(w)).Gap(k.D(2)).Role(ui.RoleList).Label(text) + last := ui.Local(col, clipKey{}, func() copied { return copied{} }) + // COPIED! shows for two seconds on the clip copied. + shown := "" + if until := last.at.Add(2 * time.Second); k.Now().Before(until) { + shown = last.clip + k.At(c, until) + } + res := ClipboardPanelResult{Copied: -1} + res.Element = col.Children(func() { + if len(clips) == 0 { + pixel.Text(c, k.Msg("empty"), pixel.TextOptions{Slot: pixel.Faint}) + } + for i, clip := range clips { + // Keyed by the clip: new ones come in on top. + ui.Box(c).Key(clip).FillWidth().Children(func() { + shell, base, st := pixel.Shell(c, func() *ui.Element { + return ui.Box(c).Focusable().Role(ui.RoleButton).Label(clip).Description(k.Msg("copy")).Disabled(o.Disabled) + }) + if !base.IsDisabled() && base.Clicked() { + c.WriteClipboard(clip) + res.Copied, *last = i, copied{clip, k.Now()} + } + done := clip == shown || res.Copied == i + line := strings.Fields(clip) + shell.FillWidth().Height(k.D(pixel.LineHeight + 6)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Frame(0, 0, g.W, g.H, pixel.Raised, pixel.Face) + mark := "" + if done { + mark = k.Msg("copied") + g.Text(g.W-4-pixel.Measure(mark), 3, mark, pixel.Good, 1) + } + g.Text(4, 3, pixel.Wrap(strings.Join(line, " "), g.W-12-pixel.Measure(mark), 1)[0], pixel.Ink, 1) + g.Finish(st) + }) + }) + } + }) + if res.Copied >= 0 { + slog.Debug("8bit: clip copied", "label", text, "clip", res.Copied) + } + return res +} diff --git a/desk/controlpanel.go b/desk/controlpanel.go new file mode 100644 index 0000000..7f44412 --- /dev/null +++ b/desk/controlpanel.go @@ -0,0 +1,73 @@ +package desk + +import ( + "fmt" + "log/slog" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +// Applet is a setting in the control panel. +type Applet struct { + Name string + Icon *pixel.Sprite +} + +type ControlPanelOptions struct { + Disabled bool +} + +type ControlPanelResult struct { + Element *ui.Element + // Opened is the applet opened this frame, else -1. + Opened int +} + +type panelKey struct{} + +const appletW, appletH = 66, 46 + +// ControlPanel is a settings grid; Enter or a click opens. +func ControlPanel(c *ui.Context, text string, applets []Applet, o ControlPanelOptions) ControlPanelResult { + k := pixel.K(c) + if len(applets) == 0 { + panic("desk: ControlPanel has no applets") + } + lines := make([][]string, len(applets)) + for i, a := range applets { + sprite("ControlPanel", a.Icon) + if a.Name == "" { + panic(fmt.Sprintf("desk: ControlPanel applet %d has no name", i)) + } + lines[i] = pixel.Wrap(a.Name, appletW-4, 1) + } + box := ui.Box(c) + cur := ui.Local(box, panelKey{}, func() int { return 0 }) + *cur = min(*cur, len(applets)-1) + res := ControlPanelResult{Opened: -1} + res.Element = box.Children(func() { + shell, base, st, picked := pixel.Pad(c, text, min(len(applets), 4), len(applets), appletW, appletH, cur, o.Disabled) + res.Opened = picked + at := *cur + base.Description(applets[at].Name) + cols := min(len(applets), 4) + shell.Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + for i, a := range applets { + x, y := i%cols*appletW, i/cols*appletH + lit := i == at && st.Focus + if lit { + g.Cursor(x, y, appletW, appletH) + } + tile(g, x, y, appletW, a.Icon, lines[i], false) + } + g.Finish(st) + }) + }) + if res.Opened >= 0 { + slog.Debug("8bit: control panel opened", "label", text, "applet", applets[res.Opened].Name) + } + return res +} diff --git a/desk/desk.go b/desk/desk.go new file mode 100644 index 0000000..9e52757 --- /dev/null +++ b/desk/desk.go @@ -0,0 +1,124 @@ +// Package desk is a retro desktop: windows, a taskbar, games. +package desk + +import ( + "fmt" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +// Wallpaper is a dithered desktop pattern. +type Wallpaper int + +const ( + Checks Wallpaper = iota + Bricks + Weave + Dots + Waves + Fade +) + +var papers = [...]string{"paper_checks", "paper_bricks", "paper_weave", "paper_dots", "paper_waves", "paper_fade"} + +// name is the pattern's name; unknown ones panic. +func (wp Wallpaper) name(k pixel.Kit) string { + if wp < Checks || wp > Fade { + panic(fmt.Sprintf("desk: unknown Wallpaper %d", wp)) + } + return k.Msg(papers[wp]) +} + +// paper fills a box with the pattern from its corner. +func paper(g pixel.Canvas, x, y, w, h int, wp Wallpaper) { + switch wp { + case Checks: + g.Rect(x, y, w, h, pixel.Well) + for j := 0; j < h; j += 4 { + for i := j / 4 % 2 * 4; i < w; i += 8 { + g.Rect(x+i, y+j, min(4, w-i), min(4, h-j), pixel.Info) + } + } + case Bricks: + g.Rect(x, y, w, h, pixel.Shade) + for j := 0; j < h; j += 5 { + for i := j/5%2*4 - 4; i < w; i += 8 { + l, r := max(i, 0), min(i+7, w) + g.Rect(x+l, y+j, r-l, min(4, h-j), pixel.Face) + g.Rect(x+l, y+j, r-l, 1, pixel.Lit) + } + } + case Weave: + g.Rect(x, y, w, h, pixel.Well) + for j := range h { + for i := range w { + if (i+j)%6 == 0 || (i-j+6*h)%6 == 0 { + g.Dot(x+i, y+j, pixel.Accent) + } + } + } + case Dots: + g.Rect(x, y, w, h, pixel.Well) + for j := 1; j < h; j += 4 { + for i := 1 + j/4%2*3; i < w; i += 6 { + g.Dot(x+i, y+j, pixel.Faint) + } + } + case Waves: + g.Rect(x, y, w, h, pixel.Info) + for j := 0; j < h; j += 6 { + for i := range w { + g.Dot(x+i, y+j+[]int{0, 1, 2, 1}[i/2%4], pixel.Magic) + } + } + case Fade: + // Seventeen Bayer bands run Magic down into Info. + for b := range 17 { + top, foot := b*h/17, (b+1)*h/17 + g.Dither(x, y+top, w, foot-top, pixel.Magic, pixel.Info, b) + } + default: + panic(fmt.Sprintf("desk: unknown Wallpaper %d", wp)) + } +} + +// tile draws an icon over a label of two lines. +func tile(g pixel.Canvas, x, y, w int, sp *pixel.Sprite, lines []string, chosen bool) { + ink, back, text := pixel.Ink, pixel.Face, pixel.Ink + // Chosen, the icon takes the Accent and the label inverts. + if chosen { + ink, back, text = pixel.Accent, pixel.Accent, pixel.AccentInk + } + g.Sprite(x+(w-2*sp.W)/2, y+2+(12-sp.H), sp, 0, ink, 2) + for i, l := range lines { + lw := pixel.Measure(l) + lx, ly := x+(w-lw)/2, y+28+i*pixel.LineHeight + g.Rect(lx-1, ly+1, lw+2, pixel.LineHeight-1, back) + g.Text(lx, ly, l, text, 1) + } +} + +// sprite checks a 12-dot icon, named for the panic. +func sprite(name string, sp *pixel.Sprite) *pixel.Sprite { + if sp == nil || sp.W > 12 || sp.H > 12 { + panic(fmt.Sprintf("desk: %s needs an icon of 12 dots at most", name)) + } + return sp +} + +// barLook paints a title bar: Accent while active, else Shade. +func barLook(g pixel.Canvas, title string, active bool) { + back, ink := pixel.Shade, pixel.Lit + if active { + back, ink = pixel.Accent, pixel.AccentInk + } + g.Rect(0, 0, g.W, g.H, back) + g.Text(3, (g.H-pixel.LineHeight)/2, pixel.Wrap(title, g.W-6, 1)[0], ink, 1) +} + +func abs(n int) int { + if n < 0 { + return -n + } + return n +} diff --git a/desk/desktop.go b/desk/desktop.go new file mode 100644 index 0000000..ff0d146 --- /dev/null +++ b/desk/desktop.go @@ -0,0 +1,24 @@ +package desk + +import ( + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type DesktopOptions struct { + Wallpaper Wallpaper + // Width and Height are dots; zeros mean 320 by 200. + Width, Height int +} + +// Desktop lays icons down a dithered wallpaper, column by column. +func Desktop(c *ui.Context, text string, o DesktopOptions, icons func()) *ui.Element { + k := pixel.K(c) + o.Wallpaper.name(k) + w, h := pixel.Or("Desktop Width", o.Width, 320), pixel.Or("Desktop Height", o.Height, 200) + return ui.Column(c).Size(k.D(w), k.D(h)).Wrap().AlignContent(ui.Start).Padding(k.D(4)).Gap(k.D(2)).Clip(). + FocusGroup(ui.Horizontal | ui.Vertical).Role(ui.RoleGroup).Label(text).Draw(func(p *ui.Painter, r ui.Rect) { + paper(k.Canvas(p, r), 0, 0, w, h, o.Wallpaper) + }).Children(icons) +} diff --git a/desk/desktopicon.go b/desk/desktopicon.go new file mode 100644 index 0000000..4f2d2c4 --- /dev/null +++ b/desk/desktopicon.go @@ -0,0 +1,50 @@ +package desk + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type DesktopIconOptions struct { + // Chosen inverts it, as a chosen icon does. + Chosen bool + Disabled bool +} + +type DesktopIconResult struct { + Element *ui.Element + // Clicked chooses it; Opened is a double click or Enter. + Clicked, Opened bool +} + +const iconW, iconH = 72, 60 + +// DesktopIcon is an icon over its label; chosen, it inverts. +func DesktopIcon(c *ui.Context, label string, icon *pixel.Sprite, o DesktopIconOptions) DesktopIconResult { + k := pixel.K(c) + sp := sprite("DesktopIcon", icon) + if label == "" { + panic("desk: DesktopIcon has no label") + } + shell, base, st := pixel.Shell(c, func() *ui.Element { + return ui.Box(c).Focusable().Role(ui.RoleButton).Label(label).Disabled(o.Disabled) + }) + res := DesktopIconResult{Element: shell} + if !base.IsDisabled() { + res.Opened = base.Shortcut(0, ui.KeyEnter) || base.DoubleClicked() + res.Clicked = base.Clicked() + } + if res.Clicked || res.Opened { + slog.Debug("8bit: desktop icon chosen", "label", label, "opened", res.Opened) + } + lines := pixel.Wrap(label, iconW-4, 2) + shell.Size(k.D(iconW), k.D(iconH)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + tile(g, 0, 0, iconW, sp, lines, o.Chosen) + g.Finish(st) + }) + return res +} diff --git a/desk/diskdefrag.go b/desk/diskdefrag.go new file mode 100644 index 0000000..1e16f2a --- /dev/null +++ b/desk/diskdefrag.go @@ -0,0 +1,157 @@ +package desk + +import ( + "fmt" + "log/slog" + "math/rand/v2" + "slices" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type DiskDefragOptions struct { + // Blocks on the disk; zero means 160. + Blocks int + // Seed scatters the files; zero takes the clock. + Seed uint64 +} + +type DiskDefragResult struct { + Element *ui.Element + // Done is the frame the last block falls in place. + Done bool +} + +type defragKey struct{} + +// disk is the first layout, its swaps and moves made. +type disk struct { + from []int + plan [][2]int + moves, last int + done bool +} + +// files color the six files on the disk. +var files = []pixel.Slot{pixel.Info, pixel.Good, pixel.Magic, pixel.Accent, pixel.Warn, pixel.Bad} + +const dblock, dcols = 6, 32 + +// DiskDefrag moves scattered blocks into order while running. +func DiskDefrag(c *ui.Context, text string, running bool, o DiskDefragOptions) DiskDefragResult { + k := pixel.K(c) + n := pixel.Or("DiskDefrag Blocks", o.Blocks, 160) + e := ui.Box(c).Role(ui.RoleProgress).Label(text) + d := ui.Local(e, defragKey{}, func() disk { + seed := o.Seed + if seed == 0 { + seed = uint64(k.Now().UnixNano()) + } + from := scatter(n, seed) + return disk{from: from, plan: swaps(from), last: -1} + }) + if len(d.from) != n { + panic(fmt.Sprintf("desk: DiskDefrag went from %d blocks to %d", len(d.from), n)) + } + // A loader keeps time: it runs on under Reduce Motion. + if running && d.moves < len(d.plan) { + tick := k.Tick(c, 15) + // A clock stepping back moves nothing; a long gap, four. + if d.last >= 0 { + d.moves = min(len(d.plan), d.moves+max(0, min(tick-d.last, 4))) + } + d.last = tick + } else { + d.last = -1 + } + res := DiskDefragResult{Element: e, Done: d.moves == len(d.plan) && !d.done} + d.done = d.moves == len(d.plan) + cells := slices.Clone(d.from) + for _, s := range d.plan[:d.moves] { + cells[s[0]], cells[s[1]] = cells[s[1]], cells[s[0]] + } + want := slices.Clone(d.from) + slices.SortFunc(want, func(a, b int) int { return order(a) - order(b) }) + read, write := -1, -1 + if running && d.moves > 0 && !d.done { + read, write = d.plan[d.moves-1][0], d.plan[d.moves-1][1] + } + pct := 100 + if len(d.plan) > 0 { + pct = 100 * d.moves / len(d.plan) + } + status := fmt.Sprintf(k.Msg("percent_done"), pct) + e.Description(status) + if res.Done { + slog.Debug("8bit: defrag done", "label", text, "moves", d.moves) + } + rows := (n + dcols - 1) / dcols + w, h := dcols*dblock+4, rows*dblock+4 + e.Size(k.D(w), k.D(h+pixel.LineHeight+2)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Frame(0, 0, w, h, pixel.Sunken, pixel.Well) + for i, f := range cells { + x, y := 2+i%dcols*dblock, 2+i/dcols*dblock + switch { + case i == read: + g.Rect(x, y, dblock-1, dblock-1, pixel.Lit) + case i == write: + g.Rect(x, y, dblock-1, dblock-1, pixel.Ink) + case f < 0: + g.Dot(x+2, y+2, pixel.Shade) + default: + g.Rect(x, y, dblock-1, dblock-1, files[f]) + // A block not yet in its place is still fragmented. + if f != want[i] { + g.Checker(x, y, dblock-1, dblock-1, pixel.Lit) + } + } + } + g.Text(0, h+2, status, pixel.Ink, 1) + }) + return res +} + +// order sorts files first, by number, and free blocks last. +func order(f int) int { + if f < 0 { + return len(files) + } + return f +} + +// scatter puts six files on two thirds of n blocks. +func scatter(n int, seed uint64) []int { + rng := rand.New(rand.NewPCG(seed, seed^0x9e3779b97f4a7c15)) + cells := make([]int, n) + for i := range cells { + cells[i] = -1 + } + used := n * 2 / 3 + for j, at := range rng.Perm(n)[:used] { + cells[at] = j * len(files) / max(1, used) + } + return cells +} + +// swaps sorts the disk a block at a time. +func swaps(cells []int) [][2]int { + cur, want := slices.Clone(cells), slices.Clone(cells) + slices.SortFunc(want, func(a, b int) int { return order(a) - order(b) }) + var plan [][2]int + for p := range cur { + if cur[p] == want[p] { + continue + } + for q := p + 1; q < len(cur); q++ { + if cur[q] == want[p] { + cur[p], cur[q] = cur[q], cur[p] + plan = append(plan, [2]int{q, p}) + break + } + } + } + return plan +} diff --git a/desk/dock.go b/desk/dock.go new file mode 100644 index 0000000..179f33d --- /dev/null +++ b/desk/dock.go @@ -0,0 +1,96 @@ +package desk + +import ( + "fmt" + "log/slog" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +// DockApp is an app on the dock. +type DockApp struct { + Name string + Icon *pixel.Sprite +} + +type DockOptions struct { + Disabled bool +} + +type DockResult struct { + Element *ui.Element + // Launched is the app pressed this frame, else -1. + Launched int +} + +type dockKey struct{} + +const dockCell = 30 + +// Dock: a shelf of apps; the pointed or focused rises. +func Dock(c *ui.Context, text string, apps []DockApp, o DockOptions) DockResult { + k := pixel.K(c) + if len(apps) == 0 { + panic("desk: Dock has no apps") + } + seen := map[string]bool{} + for i, a := range apps { + sprite("Dock", a.Icon) + if a.Name == "" || seen[a.Name] { + panic(fmt.Sprintf("desk: Dock app %d %q is nameless or twice", i, a.Name)) + } + seen[a.Name] = true + } + w := len(apps) * dockCell + col := ui.Column(c).Width(k.D(w + 8)).AlignItems(ui.Center) + // lit is the risen app, read when the names paint. + lit := ui.Local(col, dockKey{}, func() int { return -1 }) + *lit = -1 + res := DockResult{Launched: -1} + res.Element = col.Children(func() { + ui.Box(c).Size(k.D(w+8), k.D(pixel.LineHeight)).Role(ui.RoleNone).Draw(func(p *ui.Painter, r ui.Rect) { + if *lit < 0 { + return + } + g := k.Canvas(p, r) + name := pixel.Wrap(apps[*lit].Name, w+8, 1)[0] + x := max(0, min(g.W-pixel.Measure(name), 4+*lit*dockCell+dockCell/2-pixel.Measure(name)/2)) + g.Text(x, 0, name, pixel.Ink, 1) + }) + ui.Row(c).Padding(0, k.D(4)).FocusGroup(ui.Horizontal).Role(ui.RoleToolbar).Label(text).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Rect(0, g.H-6, g.W, 6, pixel.Face) + g.HLine(0, g.H-6, g.W, pixel.Lit) + g.HLine(0, g.H-1, g.W, pixel.Edge) + }).Children(func() { + for i, a := range apps { + // Keyed by name: apps come and go. + ui.Box(c).Key(a.Name).Children(func() { + shell, base, st := pixel.Shell(c, func() *ui.Element { + return ui.Box(c).Focusable().Role(ui.RoleButton).Label(a.Name).Disabled(o.Disabled) + }) + up := st.Hover || st.Focus + if up { + *lit = i + } + // It rises eight dots in four steps, and sinks back. + rise := 2 * k.Toward(c, shell, up, 15, 4) + if !base.IsDisabled() && base.Clicked() { + res.Launched = i + } + shell.Size(k.D(dockCell), k.D(40)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Sprite((dockCell-2*a.Icon.W)/2, 10-rise+2*(12-a.Icon.H), a.Icon, 0, pixel.Ink, 2) + g.Finish(st) + }) + }) + } + }) + }) + if res.Launched >= 0 { + slog.Debug("8bit: dock launched", "label", text, "app", apps[res.Launched].Name) + } + return res +} diff --git a/desk/extremes_test.go b/desk/extremes_test.go new file mode 100644 index 0000000..a409c5a --- /dev/null +++ b/desk/extremes_test.go @@ -0,0 +1,95 @@ +package desk_test + +import ( + "strings" + "testing" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/desk" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +func TestBadInputPanics(t *testing.T) { + big := pixel.MustSprite(strings.Repeat("#", 13)) + bad := desk.Wallpaper(9) + n := 0 + for name, build := range map[string]func(c *ui.Context){ + "paper": func(c *ui.Context) { desk.Desktop(c, "D", desk.DesktopOptions{Wallpaper: bad}, func() {}) }, + "picker": func(c *ui.Context) { desk.WallpaperPicker(c, "W", &bad, desk.WallpaperPickerOptions{}) }, + "no label": func(c *ui.Context) { desk.DesktopIcon(c, "", icon.File, desk.DesktopIconOptions{}) }, + "no icon": func(c *ui.Context) { desk.DesktopIcon(c, "A", nil, desk.DesktopIconOptions{}) }, + "big icon": func(c *ui.Context) { desk.DesktopIcon(c, "A", big, desk.DesktopIconOptions{}) }, + "no applets": func(c *ui.Context) { desk.ControlPanel(c, "P", nil, desk.ControlPanelOptions{}) }, + "blank applet": func(c *ui.Context) { + desk.ControlPanel(c, "P", []desk.Applet{{Icon: icon.File}}, desk.ControlPanelOptions{}) + }, + "no programs": func(c *ui.Context) { desk.StartMenu(c, "S", nil, desk.StartMenuOptions{}) }, + "slashed": func(c *ui.Context) { + desk.StartMenu(c, "S", []desk.Program{{Name: "A/B", Icon: icon.File}}, desk.StartMenuOptions{}) + }, + "twins": func(c *ui.Context) { + desk.StartMenu(c, "S", []desk.Program{{Name: "A", Icon: icon.File}, {Name: "A", Icon: icon.File}}, desk.StartMenuOptions{}) + }, + "empty submenu": func(c *ui.Context) { + desk.StartMenu(c, "S", []desk.Program{{Name: "A", Icon: icon.File, Programs: []desk.Program{}}}, desk.StartMenuOptions{}) + }, + "no tip": func(c *ui.Context) { + desk.TrayIcons(c, "T", []desk.TrayIcon{{Icon: icon.Wifi}}, desk.TrayIconsOptions{}) + }, + "active": func(c *ui.Context) { + a := 2 + desk.Taskbar(c, "B", []string{"A", "B"}, &a, desk.TaskbarOptions{}) + }, + "no apps": func(c *ui.Context) { desk.Dock(c, "D", nil, desk.DockOptions{}) }, + "nameless": func(c *ui.Context) { desk.Dock(c, "D", []desk.DockApp{{Icon: icon.Mail}}, desk.DockOptions{}) }, + "mine field": func(c *ui.Context) { desk.Minesweeper(c, "M", desk.MinesweeperOptions{Cols: 2, Rows: 2, Mines: 4}) }, + "tiny pit": func(c *ui.Context) { desk.Snake(c, "S", desk.SnakeOptions{Cols: 3, Rows: 3}) }, + "small shut": func(c *ui.Context) { desk.ShutdownScreen(c, desk.ShutdownScreenOptions{Width: 60, Height: 20}) }, + "no title": func(c *ui.Context) { desk.BootScreen(c, "", nil, desk.BootScreenOptions{}) }, + "long post": func(c *ui.Context) { desk.BootScreen(c, "B", make([]string, 20), desk.BootScreenOptions{}) }, + "blank clip": func(c *ui.Context) { desk.ClipboardPanel(c, "C", []string{""}, desk.ClipboardPanelOptions{}) }, + "twin clips": func(c *ui.Context) { desk.ClipboardPanel(c, "C", []string{"a", "a"}, desk.ClipboardPanelOptions{}) }, + "no logo": func(c *ui.Context) { + on := false + desk.Screensaver(c, "", &on, desk.ScreensaverOptions{}) + }, + } { + n++ + mustPanic(t, name, func() { view(600, 300, newWorld(), build) }) + } + if n != 23 { + t.Fatalf("%d cases", n) + } +} + +func TestExtremesStayInside(t *testing.T) { + long := strings.Repeat("W", 60) + many := make([]string, 30) + for i := range many { + many[i] = long + string(rune('A'+i)) + } + for _, tc := range []struct { + label string + build func(c *ui.Context) + }{ + {long, func(c *ui.Context) { desk.DesktopIcon(c, long, icon.File, desk.DesktopIconOptions{Chosen: true}) }}, + {"P", func(c *ui.Context) { + desk.ControlPanel(c, "P", []desk.Applet{{Name: long, Icon: icon.File}}, desk.ControlPanelOptions{}) + }}, + {"B", func(c *ui.Context) { + a := 3 + desk.Taskbar(c, "B", many, &a, desk.TaskbarOptions{}) + }}, + {"C", func(c *ui.Context) { desk.ClipboardPanel(c, "C", []string{long + long}, desk.ClipboardPanelOptions{}) }}, + {long, func(c *ui.Context) { desk.BootScreen(c, long, []string{long + long}, desk.BootScreenOptions{}) }}, + } { + tt := view(1400, 700, newWorld(), tc.build) + tt.Frame() + x, y, w, h := dots(find(t, tt, tc.label)) + if n := spill(tt.Image(), x, y, w, h); n > 0 { + t.Errorf("%.12s spills %d dots", tc.label, n) + } + } +} diff --git a/desk/games_test.go b/desk/games_test.go new file mode 100644 index 0000000..fb2695f --- /dev/null +++ b/desk/games_test.go @@ -0,0 +1,146 @@ +package desk_test + +import ( + "testing" + "time" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/desk" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +func TestMinesweeperFirstClickIsSafe(t *testing.T) { + won := 0 + tt := view(600, 500, newWorld(), func(c *ui.Context) { + // Seven mines fill all but the click's 3×3: it wins. + if desk.Minesweeper(c, "Mines", desk.MinesweeperOptions{Cols: 4, Rows: 4, Mines: 7, Seed: 3}).Won { + won++ + } + }) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyEnter) + if won != 1 { + t.Fatalf("won %d times: %q", won, tt.Texts()) + } + find(t, tt, "STAGE CLEAR!") +} + +func TestMinesweeperFlagsTimesAndResets(t *testing.T) { + wd := newWorld() + off := false + tt := view(600, 500, wd, func(c *ui.Context) { + desk.Minesweeper(c, "Mines", desk.MinesweeperOptions{Mines: 3, Seed: 5, Disabled: off}) + }) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyF) + find(t, tt, "002") + r := find(t, tt, "MINEFIELD") + // The flag flies in Bad on the first cell. + if fx, fy, _, _ := dots(r); count(tt.Image(), fx, fy, 16, 16, pixel.Bad) == 0 { + t.Fatal("no Bad flag on the first cell") + } + tt.RightClickAt(r.X+r.W-4, r.Y+r.H-4) + find(t, tt, "001") + // A flag lifts again; the first open starts the clock. + tt.Key(0, ui.KeyF) + tt.Key(0, ui.KeyEnd) + tt.Key(0, ui.KeyLeft) + tt.Key(0, ui.KeyLeft) + tt.Key(0, ui.KeyEnter) + later(tt, wd, 5*time.Second) + find(t, tt, "005") + click(t, tt, "NEW GAME") + find(t, tt, "003") + find(t, tt, "000") + off = true + tt.Frame() + tt.Key(0, ui.KeyF) + find(t, tt, "003") +} + +func TestSnakeRunsCrashesAndContinues(t *testing.T) { + wd := newWorld() + over, score := 0, -1 + tt := view(400, 300, wd, func(c *ui.Context) { + r := desk.Snake(c, "Snake", desk.SnakeOptions{Cols: 6, Rows: 4, Seed: 9}) + score = r.Score + if r.Over { + over++ + } + }) + x, y, w, h := dots(find(t, tt, "Snake")) + frozen := append([]byte(nil), tt.Image().Pix...) + later(tt, wd, time.Second) + if string(frozen) != string(tt.Image().Pix) { + t.Fatal("it moved before it started") + } + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnter) + // A turn back on its neck is ignored; it lives. + tt.Key(0, ui.KeyLeft) + later(tt, wd, 250*time.Millisecond) + if over != 0 { + t.Fatal("turning back on itself crashed it") + } + // Heading right from the fourth column, the wall comes fast. + later(tt, wd, time.Second) + if over != 1 || count(tt.Image(), x, y+16, w, h-16, pixel.Good) > 0 { + t.Fatalf("over %d times, or the body is still Good", over) + } + later(tt, wd, time.Second) + tt.Key(0, ui.KeyEnter) + if over != 1 || score != 0 || count(tt.Image(), x, y+16, w, h-16, pixel.Good) == 0 { + t.Fatalf("after CONTINUE: over %d, score %d", over, score) + } +} + +func TestSnakePausesWithoutFocus(t *testing.T) { + wd := newWorld() + tt := view(400, 300, wd, func(c *ui.Context) { + desk.Snake(c, "Snake", desk.SnakeOptions{Seed: 9}) + }) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnter) + tt.SetFocused(false) + tt.Frame() + before := append([]byte(nil), tt.Image().Pix...) + later(tt, wd, time.Second) + if string(before) != string(tt.Image().Pix) { + t.Fatal("it ran on without the keyboard") + } +} + +func TestDiskDefragSortsWhileRunning(t *testing.T) { + for _, reduce := range []bool{false, true} { + wd := newWorld() + wd.reduce = reduce + running, done := false, 0 + tt := view(600, 300, wd, func(c *ui.Context) { + if desk.DiskDefrag(c, "Disk", running, desk.DiskDefragOptions{Blocks: 64, Seed: 4}).Done { + done++ + } + }) + x, y, w, _ := dots(find(t, tt, "Disk")) + // Inside the frame, Lit is a fragment or a read. + frag := func() int { return count(tt.Image(), x+2, y+2, w-4, 12, pixel.Lit) } + start := frag() + later(tt, wd, 10*time.Second) + if frag() != start || done != 0 { + t.Fatalf("reduce %v: it sorted while stopped", reduce) + } + // A loader keeps time, so Reduce Motion runs it too. + running = true + tt.Frame() + for range 20 { + later(tt, wd, time.Second) + } + if done != 1 || frag() != 0 || start == 0 { + t.Fatalf("reduce %v: done %d, %d fragmented dots of %d", reduce, done, frag(), start) + } + } +} diff --git a/desk/lockscreen.go b/desk/lockscreen.go new file mode 100644 index 0000000..5eab05f --- /dev/null +++ b/desk/lockscreen.go @@ -0,0 +1,50 @@ +package desk + +import ( + "fmt" + "log/slog" + "strings" + "time" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type LockScreenOptions struct{} + +type LockScreenResult struct { + Element *ui.Element + // Unlocked is a key or click opening it this frame. + Unlocked bool +} + +// LockScreen covers the window: time, date, PRESS ANY KEY. +func LockScreen(c *ui.Context, text string, open *bool, o LockScreenOptions) LockScreenResult { + k := pixel.K(c) + now := k.Now() + if *open { + k.At(c, now.Truncate(time.Minute).Add(time.Minute)) + } + clock := now.Format("15:04") + days, months := strings.Fields(k.Msg("days")), strings.Fields(k.Msg("months")) + date := fmt.Sprintf(k.Msg("day_date"), days[now.Weekday()], now.Day(), months[now.Month()-1], int(now.Month())) + press := k.Msg("press_any_key") + anchor, panel, woke := cover(c, k, text, open, false, func(g pixel.Canvas) { + g.Rect(0, 0, g.W, g.H, pixel.Well) + cy := g.H/2 - 40 + g.Text((g.W-4*pixel.Measure(clock))/2, cy, clock, pixel.Ink, 4) + g.Text((g.W-pixel.Measure(date))/2, cy+4*pixel.LineHeight+4, date, pixel.Faint, 1) + // The prompt blinks on 4 fps steps; reduced, it holds. + if g.Step(4)%4 < 2 { + g.Text((g.W-pixel.Measure(press))/2, g.H-3*pixel.LineHeight, press, pixel.Accent, 1) + } + }) + if panel != nil { + panel.Description(clock + ", " + date + ", " + press) + } + if woke { + slog.Debug("8bit: lock screen unlocked", "label", text) + } + return LockScreenResult{Element: anchor, Unlocked: woke} +} diff --git a/desk/logic_internal_test.go b/desk/logic_internal_test.go new file mode 100644 index 0000000..af7fd62 --- /dev/null +++ b/desk/logic_internal_test.go @@ -0,0 +1,91 @@ +package desk + +import ( + "slices" + "testing" + "time" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +func TestCalcPrintsAndPresses(t *testing.T) { + for keys, want := range map[string]string{ + "100": "100", "007": "7", "0": "0", "1234567890123": "123456789012", "5+": "5", "5-9=": "-4", "2÷4=": "0.5", + } { + var cl calc + for _, r := range keys { + cl.press(string(r)) + } + if got := cl.display(); got != want { + t.Errorf("%s shows %q, want %q", keys, got, want) + } + } + cl := calc{acc: -0.0000000000001} + if got := cl.display(); got != "0" { + t.Errorf("a tiny minus shows %q", got) + } +} + +func TestFieldLaysFairAndOpens(t *testing.T) { + f := newField(25) + f.lay(5, 5, 10, 12, 1, time.Unix(1, 0)) + for _, i := range append(around(5, 5, 12), 12) { + if f.mine[i] { + t.Fatalf("a mine at %d, beside the first click", i) + } + } + // A crowded field keeps only the first click safe. + g := newField(9) + g.lay(3, 3, 8, 4, 1, time.Unix(1, 0)) + if g.mine[4] || slices.Index(g.mine, false) != 4 { + t.Fatalf("crowded mines %v", g.mine) + } + // With mines on 7 and 8, the flood misses 6. + h := newField(9) + h.mine[7], h.mine[8], h.laid = true, true, true + h.reveal(3, 3, 0, time.Unix(2, 0)) + if h.over || !h.open[4] || !h.open[5] || h.open[6] { + t.Fatalf("a flood from a corner opened %v", h.open) + } + h.reveal(3, 3, 8, time.Unix(3, 0)) + if !h.over || h.won || h.boom != 8 { + t.Fatal("stepping on the mine did not end it") + } +} + +func TestPitGrowsAndCrashes(t *testing.T) { + var s pit + s.reset(6, 4, 1, 1) + s.food = [2]int{4, 2} + if s.step(6, 4) || len(s.body) != 4 || s.score != 1 { + t.Fatalf("eating gave %v, score %d", s.body, s.score) + } + // Into the cell the tail leaves is no crash. + s.body, s.next, s.food = [][2]int{{1, 1}, {1, 2}, {2, 2}, {2, 1}}, [2]int{1, 0}, [2]int{5, 3} + if s.step(6, 4) { + t.Fatal("chasing its tail crashed") + } + s.next = [2]int{0, -1} + s.step(6, 4) + if !s.step(6, 4) || !s.dead { + t.Fatal("the top wall did not stop it") + } +} + +func TestSwapsSortTheDisk(t *testing.T) { + from := scatter(100, 2) + cells := slices.Clone(from) + for _, s := range swaps(from) { + cells[s[0]], cells[s[1]] = cells[s[1]], cells[s[0]] + } + if !slices.IsSortedFunc(cells, func(a, b int) int { return order(a) - order(b) }) { + t.Fatalf("not sorted: %v", cells) + } +} + +func TestFieldTimeNeverGoesBelowZero(t *testing.T) { + f := field{laid: true, over: true, start: time.Unix(100, 0), end: time.Unix(95, 0)} + if s := f.seconds(nil, pixel.Kit{}); s != 0 { + t.Fatalf("an end before the start shows %d", s) + } +} diff --git a/desk/minesweeper.go b/desk/minesweeper.go new file mode 100644 index 0000000..b495265 --- /dev/null +++ b/desk/minesweeper.go @@ -0,0 +1,322 @@ +package desk + +import ( + "fmt" + "log/slog" + "math/rand/v2" + "time" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/text" +) + +type MinesweeperOptions struct { + // Cols, Rows, Mines: zeros mean 9, 9 and 10. + Cols, Rows, Mines int + // Seed lays the mines; zero takes the clock. + Seed uint64 + Disabled bool +} + +type MinesweeperResult struct { + Element *ui.Element + // Won or Lost is the game ending this frame. + Won, Lost bool + // Flagged is a flag set or lifted; Restarted, the face. + Flagged, Restarted bool +} + +type mineKey struct{} + +// field is a game: mines, opened and flagged cells, clock. +type field struct { + mine, open, flag []bool + laid, over, won bool + boom, cur int + start, end time.Time +} + +const mcell = 16 + +// Minesweeper: Enter or click opens; F or right click flags. +func Minesweeper(c *ui.Context, label string, o MinesweeperOptions) MinesweeperResult { + k := pixel.K(c) + cols, rows, n := pixel.Or("Minesweeper Cols", o.Cols, 9), pixel.Or("Minesweeper Rows", o.Rows, 9), pixel.Or("Minesweeper Mines", o.Mines, 10) + if n >= cols*rows { + panic(fmt.Sprintf("desk: Minesweeper %d mines in %d cells", n, cols*rows)) + } + col := ui.Column(c).Gap(k.D(4)).AlignItems(ui.Center).Role(ui.RoleGroup).Label(label) + f := ui.Local(col, mineKey{}, func() field { return newField(cols * rows) }) + if len(f.mine) != cols*rows { + *f = newField(cols * rows) + } + res := MinesweeperResult{} + res.Element = col.Children(func() { + ui.Row(c).Gap(k.D(12)).AlignItems(ui.Center).Children(func() { + flags := 0 + for _, on := range f.flag { + if on { + flags++ + } + } + text.SevenSegment(c, fmt.Sprintf("%03d", max(-99, n-flags)), text.SevenSegmentOptions{}) + face := smile + switch { + case f.over && f.won: + face = cool + case f.over: + face = dead + } + if input.IconButton(c, face, k.Msg("new_game"), input.IconButtonOptions{Disabled: o.Disabled}).Clicked { + *f, res.Restarted = newField(cols*rows), true + slog.Debug("8bit: minesweeper reset", "label", label) + } + text.SevenSegment(c, fmt.Sprintf("%03d", min(999, f.seconds(c, k))), text.SevenSegmentOptions{}) + }) + shell, base, st, picked := pixel.Pad(c, k.Msg("minefield"), cols, cols*rows, mcell, mcell, &f.cur, o.Disabled) + if !base.IsDisabled() && !f.over { + if !pixel.Typing(c) && base.Shortcut(0, ui.KeyF) { + res.Flagged = f.toggle(f.cur) + } + if base.RightClicked() { + if x, y, _ := base.PointerPosition(); x >= 0 && y >= 0 { + if cx, cy := int(x/k.D(mcell)), int(y/k.D(mcell)); cx < cols && cy < rows { + f.cur = cy*cols + cx + res.Flagged = f.toggle(f.cur) + } + } + } + if picked >= 0 && !f.flag[picked] { + if !f.laid { + f.lay(cols, rows, n, picked, o.Seed, k.Now()) + } + f.reveal(cols, rows, picked, k.Now()) + res.Won, res.Lost = f.over && f.won, f.over && !f.won + } + } + base.Description(f.say(k, cols, f.cur)) + shell.Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + for i := range f.mine { + f.drawCell(g, cols, rows, i%cols*mcell, i/cols*mcell, i) + } + if st.Focus { + x, y := f.cur%cols*mcell, f.cur/cols*mcell + g.Rect(x, y, mcell, 1, pixel.Accent) + g.Rect(x, y+mcell-1, mcell, 1, pixel.Accent) + g.Rect(x, y, 1, mcell, pixel.Accent) + g.Rect(x+mcell-1, y, 1, mcell, pixel.Accent) + } + g.Finish(st) + }) + switch { + case f.over && f.won: + pixel.Text(c, k.Msg("stage_clear"), pixel.TextOptions{Slot: pixel.Good}) + case f.over: + // GAME OVER, and the face is the CONTINUE. + pixel.Text(c, k.Msg("game_over"), pixel.TextOptions{Slot: pixel.Bad}) + } + }) + if res.Flagged { + slog.Debug("8bit: minesweeper flag", "label", label, "cell", f.cur, "on", f.flag[f.cur]) + } + if res.Won || res.Lost { + slog.Debug("8bit: minesweeper over", "label", label, "won", res.Won) + } + return res +} + +func newField(cells int) field { + return field{mine: make([]bool, cells), open: make([]bool, cells), flag: make([]bool, cells), boom: -1} +} + +// seconds since the first click, frozen at the end. +func (f *field) seconds(c *ui.Context, k pixel.Kit) int { + switch { + case !f.laid: + return 0 + case f.over: + return max(0, int(f.end.Sub(f.start)/time.Second)) + } + // A clock stepping back holds the count at zero. + s := max(0, int(k.Now().Sub(f.start)/time.Second)) + k.At(c, f.start.Add(time.Duration(s+1)*time.Second)) + return s +} + +// toggle flags or unflags a shut cell, reporting a change. +func (f *field) toggle(i int) bool { + if f.open[i] { + return false + } + f.flag[i] = !f.flag[i] + return true +} + +// lay hides mines, none at the first click or beside. +func (f *field) lay(cols, rows, n, first int, seed uint64, now time.Time) { + if seed == 0 { + seed = uint64(now.UnixNano()) + } + rng := rand.New(rand.NewPCG(seed, seed^0x9e3779b97f4a7c15)) + var spots []int + for i := range f.mine { + if abs(i%cols-first%cols) > 1 || abs(i/cols-first/cols) > 1 { + spots = append(spots, i) + } + } + // Too few spots left? Then only the click is safe. + if len(spots) < n { + spots = spots[:0] + for i := range f.mine { + if i != first { + spots = append(spots, i) + } + } + } + rng.Shuffle(len(spots), func(a, b int) { spots[a], spots[b] = spots[b], spots[a] }) + for _, i := range spots[:n] { + f.mine[i] = true + } + f.laid, f.start = true, now +} + +// near counts the mines around a cell. +func (f *field) near(cols, rows, i int) int { + n := 0 + for _, j := range around(cols, rows, i) { + if f.mine[j] { + n++ + } + } + return n +} + +// around lists a cell's neighbors on the board. +func around(cols, rows, i int) []int { + var out []int + for dy := -1; dy <= 1; dy++ { + for dx := -1; dx <= 1; dx++ { + x, y := i%cols+dx, i/cols+dy + if (dx != 0 || dy != 0) && x >= 0 && x < cols && y >= 0 && y < rows { + out = append(out, y*cols+x) + } + } + } + return out +} + +// reveal opens a cell; empties spread, a mine ends it. +func (f *field) reveal(cols, rows, i int, now time.Time) { + if f.mine[i] { + f.over, f.boom, f.end = true, i, now + return + } + todo := []int{i} + for len(todo) > 0 { + j := todo[len(todo)-1] + todo = todo[:len(todo)-1] + if f.open[j] || f.flag[j] { + continue + } + f.open[j] = true + if f.near(cols, rows, j) == 0 { + todo = append(todo, around(cols, rows, j)...) + } + } + for j := range f.mine { + if !f.mine[j] && !f.open[j] { + return + } + } + f.over, f.won, f.end = true, true, now + copy(f.flag, f.mine) +} + +// say reads a cell out: where, and what shows. +func (f *field) say(k pixel.Kit, cols, i int) string { + at := fmt.Sprintf("%d,%d ", i%cols+1, i/cols+1) + switch { + case f.open[i]: + return at + fmt.Sprint(f.near(cols, len(f.mine)/cols, i)) + case f.flag[i]: + return at + k.Msg("flag") + case f.over && f.mine[i]: + return at + k.Msg("mine") + } + return at + k.Msg("unknown") +} + +// numbers ink the mine counts, one to eight. +var numbers = [...]pixel.Slot{pixel.Info, pixel.Good, pixel.Bad, pixel.Magic, pixel.Warn, pixel.Accent, pixel.Ink, pixel.Faint} + +// drawCell draws a cell: raised, flagged, counted or mined. +func (f *field) drawCell(g pixel.Canvas, cols, rows, x, y, i int) { + switch { + case f.open[i]: + g.Rect(x, y, mcell, mcell, pixel.Well) + g.Rect(x, y, mcell, 1, pixel.Shade) + g.Rect(x, y, 1, mcell, pixel.Shade) + if n := f.near(cols, rows, i); n > 0 { + g.Micro(x+7, y+6, fmt.Sprint(n), numbers[n-1], 1) + } + case f.over && f.mine[i] && !f.flag[i]: + back := pixel.Well + if i == f.boom { + back = pixel.Bad + } + g.Rect(x, y, mcell, mcell, back) + g.Sprite(x+2, y+2, icon.Bomb, 0, pixel.Warn, 1) + default: + g.Frame(x, y, mcell, mcell, pixel.Raised, pixel.Face) + if f.flag[i] { + g.Sprite(x+2, y+2, icon.Flag, 0, pixel.Bad, 1) + } + } +} + +var ( + smile = pixel.MustSprite(` + ...eeeeee... + ..eyyyyyye.. + .eyyyyyyyye. + eyyykyykyyye + eyyykyykyyye + eyyyyyyyyyye + eykyyyyyykye + eyykyyyykyye + eyyykkkkyyye + .eyyyyyyyye. + ..eyyyyyye.. + ...eeeeee...`) + dead = pixel.MustSprite(` + ...eeeeee... + ..eyyyyyye.. + .eyyyyyyyye. + eykykyykykye + eyykyyyykyye + eykykyykykye + eyyyyyyyyyye + eyyykkkkyyye + eyykyyyykyye + .eyyyyyyyye. + ..eyyyyyye.. + ...eeeeee...`) + cool = pixel.MustSprite(` + ...eeeeee... + ..eyyyyyye.. + .eyyyyyyyye. + ekkkkkkkkkke + eykkkyykkkye + eyykyyyykyye + eyyyyyyyyyye + eykyyyyyykye + eyykkkkkkyye + .eyyyyyyyye. + ..eyyyyyye.. + ...eeeeee...`) +) diff --git a/desk/notepad.go b/desk/notepad.go new file mode 100644 index 0000000..0ac55d8 --- /dev/null +++ b/desk/notepad.go @@ -0,0 +1,52 @@ +package desk + +import ( + "log/slog" + "unicode/utf8" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/navigation" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type NotepadOptions struct { + // Width is in dots, zero 240; Lines zero means 6. + Width, Lines int + Disabled bool +} + +type NotepadResult struct { + Element *ui.Element + // Saved is FILE, SAVE chosen this frame. + Changed, Saved bool +} + +// Notepad is a text area under FILE and EDIT menus. +func Notepad(c *ui.Context, text string, value *string, o NotepadOptions) NotepadResult { + k := pixel.K(c) + w, lines := pixel.Or("Notepad Width", o.Width, 240), pixel.Or("Notepad Lines", o.Lines, 6) + var res NotepadResult + res.Element = ui.Column(c).Width(k.D(w)).Gap(k.D(2)).Role(ui.RoleGroup).Label(text).Children(func() { + m := navigation.MenuBar(c, k.Msg("menu_bar"), []navigation.Menu{ + {Title: k.Msg("file_menu"), Items: []navigation.MenuItem{{Label: k.Msg("new_file")}, {Label: k.Msg("save")}}}, + {Title: k.Msg("edit_menu"), Items: []navigation.MenuItem{{Label: k.Msg("time_date")}}}, + }, navigation.MenuBarOptions{Disabled: o.Disabled}) + switch [2]int{m.Menu, m.Item} { + case [2]int{0, 0}: + *value, res.Changed = "", true + case [2]int{0, 1}: + res.Saved = true + case [2]int{1, 0}: + // As old Notepad's F5: the time and date, typed in. + *value, res.Changed = *value+k.Now().Format("15:04 2006-01-02"), true + } + f := input.TextArea(c, text, value, input.TextAreaOptions{Width: w, Lines: lines, Disabled: o.Disabled}) + res.Changed = res.Changed || f.Changed + }) + if res.Changed || res.Saved { + slog.Debug("8bit: notepad", "label", text, "runes", utf8.RuneCountInString(*value), "saved", res.Saved) + } + return res +} diff --git a/desk/round1_test.go b/desk/round1_test.go new file mode 100644 index 0000000..17996b6 --- /dev/null +++ b/desk/round1_test.go @@ -0,0 +1,145 @@ +package desk_test + +import ( + "strings" + "testing" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/desk" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input" +) + +func TestStartMenuSurvivesShrinkingPrograms(t *testing.T) { + progs := []desk.Program{{Name: "A", Icon: icon.File}, {Name: "B", Icon: icon.Folder, Programs: []desk.Program{{Name: "C", Icon: icon.File}}}} + launched := "" + tt := view(600, 400, newWorld(), func(c *ui.Context) { + if r := desk.StartMenu(c, "Start", progs, desk.StartMenuOptions{}); r.Launched != "" { + launched = r.Launched + } + }) + click(t, tt, "Start") + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyRight) + progs = progs[:1] + tt.Frame() + tt.Key(0, ui.KeyEnter) + if launched != "A" { + t.Fatalf("after B left, Enter launched %q", launched) + } +} + +func TestDisabledAppWindowTurnsItsBodyOff(t *testing.T) { + x, y, pressed := 0, 0, 0 + tt := view(600, 400, newWorld(), func(c *ui.Context) { + desk.AppWindow(c, "WIN", &x, &y, desk.AppWindowOptions{Disabled: true}, func() { + if input.Button(c, "PRESS", input.ButtonOptions{}).Clicked { + pressed++ + } + }) + }) + click(t, tt, "PRESS") + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnter) + if pressed != 0 || tt.Focused("PRESS") { + t.Fatalf("a disabled window's body took %d presses", pressed) + } +} + +func TestAppWindowKeepsItsWholeBodyInTheRoom(t *testing.T) { + x, y := 0, 190 + tt := view(700, 500, newWorld(), func(c *ui.Context) { + desk.AppWindow(c, "WIN", &x, &y, desk.AppWindowOptions{Height: 40, Room: [2]int{320, 200}}, func() {}) + }) + _, top, _, _ := dots(find(t, tt, "WIN")) + if _, wy, _, wh := dots(find(t, tt, "WIN")); wy+wh > 200 || top != y { + t.Fatalf("the window spans %d to %d, past 200", wy, wy+wh) + } +} + +func TestDesktopIsOneTabStop(t *testing.T) { + tt := view(600, 400, newWorld(), func(c *ui.Context) { + desk.Desktop(c, "Desk", desk.DesktopOptions{}, func() { + desk.DesktopIcon(c, "A", icon.File, desk.DesktopIconOptions{}) + desk.DesktopIcon(c, "B", icon.File, desk.DesktopIconOptions{}) + }) + input.Button(c, "AFTER", input.ButtonOptions{}) + }) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyDown) + if !tt.Focused("B") { + t.Fatal("Down did not move to B") + } + tt.Key(0, ui.KeyTab) + if !tt.Focused("AFTER") { + t.Fatal("Tab stopped on another icon") + } +} + +func TestRowsKeepFocusAsSiblingsGo(t *testing.T) { + apps := []desk.DockApp{{Name: "A", Icon: icon.File}, {Name: "B", Icon: icon.Mail}, {Name: "C", Icon: icon.Music}} + tt := view(600, 300, newWorld(), func(c *ui.Context) { desk.Dock(c, "Dock", apps, desk.DockOptions{}) }) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyRight) + apps = apps[1:] + tt.Frame() + if !tt.Focused("B") { + t.Fatal("the dock lost B's focus as A left") + } + active, progs := -1, []desk.Program{{Name: "RUN", Icon: icon.Terminal}} + tray := []desk.TrayIcon{{Icon: icon.Wifi, Tip: "ONLINE"}} + tt = view(700, 300, newWorld(), func(c *ui.Context) { + desk.Taskbar(c, "Bar", nil, &active, desk.TaskbarOptions{Programs: progs, Tray: tray}) + }) + click(t, tt, "ONLINE") + progs = nil + tt.Frame() + if !tt.Focused("ONLINE") { + t.Fatal("the tray lost its focus as START went") + } +} + +func TestScreensaverWakesOnEveryButtonAndTheWheel(t *testing.T) { + for _, how := range []string{"right", "wheel"} { + on := true + tt := view(600, 400, newWorld(), func(c *ui.Context) { desk.Screensaver(c, "LOGO", &on, desk.ScreensaverOptions{}) }) + if how == "right" { + tt.RightClickAt(100, 100) + } else { + tt.Scroll(100, 100, 0, 30) + } + tt.Frame() + if on { + t.Errorf("a %s did not wake it", how) + } + } +} + +func TestStateChangesAreLogged(t *testing.T) { + run := "" + log := logged(func() { + tt := view(700, 1600, newWorld(), func(c *ui.Context) { + desk.DesktopIcon(c, "ICON", icon.File, desk.DesktopIconOptions{}) + desk.Minesweeper(c, "Mines", desk.MinesweeperOptions{Seed: 1}) + desk.Snake(c, "Snake", desk.SnakeOptions{Seed: 1}) + desk.RunDialog(c, "Run", &run, desk.RunDialogOptions{}) + }) + click(t, tt, "ICON") + // Past the face, F flags the field's first shut cell. + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyF) + click(t, tt, "Snake") + click(t, tt, "OPEN:") + tt.Type("pwd") + }) + for _, want := range []string{"desktop icon chosen", "minesweeper flag", "snake start", "run dialog typed"} { + if !strings.Contains(log, want) { + t.Errorf("no %q in the log:\n%s", want, log) + } + } + if strings.Contains(log, "pwd") { + t.Error("the log carries what was typed") + } +} diff --git a/desk/round2_test.go b/desk/round2_test.go new file mode 100644 index 0000000..5771cbf --- /dev/null +++ b/desk/round2_test.go @@ -0,0 +1,90 @@ +package desk_test + +import ( + "fmt" + "testing" + "time" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/desk" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +func TestDiskDefragTakesAClockStepBack(t *testing.T) { + wd := newWorld() + tt := view(600, 300, wd, func(c *ui.Context) { + desk.DiskDefrag(c, "Disk", true, desk.DiskDefragOptions{Blocks: 64, Seed: 4}) + }) + later(tt, wd, time.Second) + later(tt, wd, -2*time.Second) + later(tt, wd, time.Second) +} + +func TestTaskbarCursorStaysInView(t *testing.T) { + windows := make([]string, 30) + for i := range windows { + windows[i] = fmt.Sprint("W", i) + } + active := -1 + tt := view(700, 300, newWorld(), func(c *ui.Context) { + desk.Taskbar(c, "Bar", windows, &active, desk.TaskbarOptions{}) + }) + tt.Frame() + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnd) + x, y, _, h := dots(find(t, tt, "Bar")) + // The cursor's Accent ring shows inside the bar. + if count(tt.Image(), x, y, 320, h, pixel.Accent) == 0 { + t.Fatal("End moved the cursor out of sight") + } + tt.Key(0, ui.KeyEnter) + if active != 29 { + t.Fatalf("Enter chose %d", active) + } +} + +func TestSnakeStartedByAnArrowSaysSo(t *testing.T) { + started := 0 + tt := view(400, 300, newWorld(), func(c *ui.Context) { + if desk.Snake(c, "Snake", desk.SnakeOptions{Seed: 2}).Started { + started++ + } + }) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyRight) + if started != 1 { + t.Fatalf("an arrow start reported %d starts", started) + } +} + +func TestSizesThatCannotFitPanic(t *testing.T) { + for name, build := range map[string]func(c *ui.Context){ + "boot": func(c *ui.Context) { + desk.BootScreen(c, "B", []string{"L"}, desk.BootScreenOptions{Height: 64}) + }, + "room": func(c *ui.Context) { + x, y := 0, 0 + desk.AppWindow(c, "W", &x, &y, desk.AppWindowOptions{Room: [2]int{320, 100}}, func() {}) + }, + } { + mustPanic(t, name, func() { view(600, 400, newWorld(), build) }) + } +} + +func TestClocksSteppingBackHoldAtZero(t *testing.T) { + wd := newWorld() + on := true + tt := view(600, 400, wd, func(c *ui.Context) { + desk.Screensaver(c, "LOGO", &on, desk.ScreensaverOptions{}) + desk.BootScreen(c, "BIOS", []string{"A"}, desk.BootScreenOptions{}) + }) + later(tt, wd, -time.Hour) + tt = view(600, 400, wd, func(c *ui.Context) { desk.Minesweeper(c, "Mines", desk.MinesweeperOptions{Seed: 1}) }) + click(t, tt, "MINEFIELD") + later(tt, wd, -time.Hour) + if _, ok := tt.Find("000"); !ok { + t.Fatalf("a clock stepped back shows %q", tt.Texts()) + } +} diff --git a/desk/round3_test.go b/desk/round3_test.go new file mode 100644 index 0000000..97e079a --- /dev/null +++ b/desk/round3_test.go @@ -0,0 +1,59 @@ +package desk_test + +import ( + "bytes" + "testing" + "time" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/desk" +) + +func TestNarrowSizesStayInsideOrPanic(t *testing.T) { + mustPanic(t, "thin window", func() { + view(600, 400, newWorld(), func(c *ui.Context) { + x, y := 0, 0 + desk.AppWindow(c, "W", &x, &y, desk.AppWindowOptions{Width: 1, Height: 10}, func() {}) + }) + }) + mustPanic(t, "thin boot", func() { + view(600, 400, newWorld(), func(c *ui.Context) { desk.BootScreen(c, "B", nil, desk.BootScreenOptions{Width: 100}) }) + }) + for _, tc := range []struct { + label string + build func(c *ui.Context) + }{ + {"BIOS", func(c *ui.Context) { + desk.BootScreen(c, "BIOS", []string{"A LONG POST LINE THAT RUNS ON"}, desk.BootScreenOptions{Width: 120}) + }}, + {"Snake", func(c *ui.Context) { desk.Snake(c, "Snake", desk.SnakeOptions{Cols: 6, Rows: 4, Seed: 1}) }}, + } { + tt := view(800, 500, newWorld(), tc.build) + x, y, w, h := dots(find(t, tt, tc.label)) + if n := spill(tt.Image(), x, y, w, h); n > 0 { + t.Errorf("%s spills %d dots", tc.label, n) + } + } +} + +func TestClocksSteppingBackHoldStill(t *testing.T) { + wd := newWorld() + tt := view(600, 300, wd, func(c *ui.Context) { + desk.DiskDefrag(c, "Disk", true, desk.DiskDefragOptions{Blocks: 64, Seed: 4}) + }) + later(tt, wd, 500*time.Millisecond) + before := append([]byte(nil), tt.Image().Pix...) + later(tt, wd, -2*time.Second) + if !bytes.Equal(before, tt.Image().Pix) { + t.Fatal("the disk moved as the clock stepped back") + } + // Booted behind the clock, the count shows zero. + fresh := view(600, 300, newWorld(), func(c *ui.Context) { desk.BootScreen(c, "BIOS", nil, desk.BootScreenOptions{}) }) + wb := newWorld() + back := view(600, 300, wb, func(c *ui.Context) { desk.BootScreen(c, "BIOS", nil, desk.BootScreenOptions{}) }) + later(back, wb, -time.Hour) + if !bytes.Equal(fresh.Image().Pix, back.Image().Pix) { + t.Fatal("a boot behind the clock shows a count under zero") + } +} diff --git a/desk/rundialog.go b/desk/rundialog.go new file mode 100644 index 0000000..8006e4a --- /dev/null +++ b/desk/rundialog.go @@ -0,0 +1,84 @@ +package desk + +import ( + "log/slog" + "strings" + "unicode/utf8" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type RunDialogOptions struct { + // Width is in dots; zero means 240. + Width int + Disabled bool +} + +type RunDialogResult struct { + Element *ui.Element + // Canceled is CANCEL, the close box or Escape. + Changed, Submitted, Canceled bool +} + +// RunDialog asks for a program: OK runs, CANCEL quits. +func RunDialog(c *ui.Context, text string, value *string, o RunDialogOptions) RunDialogResult { + k := pixel.K(c) + w := pixel.Or("RunDialog Width", o.Width, 240) + open := k.Msg("open_label") + win := ui.Column(c).Width(k.D(w)).Padding(k.D(3)).Gap(k.D(4)).Role(ui.RoleGroup).Label(text) + if o.Disabled { + win.Description(k.Msg("disabled")) + } + active := win.FocusWithin() + var res RunDialogResult + win.Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Frame(0, 0, g.W, g.H, pixel.Raised, pixel.Face) + }).Children(func() { + ui.Row(c).FillWidth().Gap(k.D(2)).AlignItems(ui.Center).Children(func() { + ui.Box(c).Grow(1).Height(k.D(pixel.LineHeight + 2)).Role(ui.RoleNone).Draw(func(p *ui.Painter, r ui.Rect) { + barLook(k.Canvas(p, r), k.Msg("run"), active) + }) + res.Canceled = input.IconButton(c, icon.Close, k.Msg("close"), input.IconButtonOptions{Size: input.Small, Disabled: o.Disabled}).Clicked + }) + ui.Row(c).Gap(k.D(6)).Padding(0, k.D(4)).AlignItems(ui.Center).Children(func() { + ui.Box(c).Size(k.D(24), k.D(24)).Role(ui.RoleNone).Draw(func(p *ui.Painter, r ui.Rect) { + k.Canvas(p, r).Sprite(0, 0, icon.Terminal, 0, pixel.Ink, 2) + }) + ui.Box(c).Width(k.D(w - 44)).Children(func() { + pixel.Text(c, k.Msg("run_prompt"), pixel.TextOptions{Wrap: true, MaxLines: 2}) + }) + }) + var f input.TextInputResult + ui.Row(c).Gap(k.D(4)).Padding(0, k.D(4)).AlignItems(ui.Center).Children(func() { + // The field carries the name; this is for eyes. + ui.Box(c).Size(k.D(pixel.Measure(open)), k.D(pixel.LineHeight)).Role(ui.RoleNone).Draw(func(p *ui.Painter, r ui.Rect) { + k.Canvas(p, r).Text(0, 0, open, pixel.Ink, 1) + }) + f = input.TextInput(c, open, value, input.TextInputOptions{Width: w - 16 - pixel.Measure(open), Disabled: o.Disabled}) + res.Changed = f.Changed + }) + blank := strings.TrimSpace(*value) == "" + ui.Row(c).Gap(k.D(6)).Padding(0, k.D(4), k.D(4), 0).AlignSelf(ui.End).Children(func() { + ok := input.Button(c, k.Msg("ok"), input.ButtonOptions{Variant: input.Primary, Disabled: o.Disabled || blank}).Clicked + res.Submitted = !blank && !f.Input.IsDisabled() && (ok || f.Submitted) + res.Canceled = input.Button(c, k.Msg("cancel"), input.ButtonOptions{Disabled: o.Disabled}).Clicked || res.Canceled + }) + }) + // Escape quits from anywhere inside, as CANCEL does. + if !o.Disabled && !pixel.Current(c).Inert && win.Shortcut(0, ui.KeyEscape) { + res.Canceled = true + } + res.Element = win + if res.Changed { + slog.Debug("8bit: run dialog typed", "label", text, "runes", utf8.RuneCountInString(*value)) + } + if res.Submitted || res.Canceled { + slog.Debug("8bit: run dialog", "label", text, "runes", utf8.RuneCountInString(*value), "submitted", res.Submitted) + } + return res +} diff --git a/desk/screens_test.go b/desk/screens_test.go new file mode 100644 index 0000000..53455c7 --- /dev/null +++ b/desk/screens_test.go @@ -0,0 +1,109 @@ +package desk_test + +import ( + "testing" + "time" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/desk" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +func TestShutdownScreenSaysItIsSafe(t *testing.T) { + tt := view(700, 300, newWorld(), func(c *ui.Context) { desk.ShutdownScreen(c, desk.ShutdownScreenOptions{}) }) + find(t, tt, "IT'S NOW SAFE TO TURN OFF YOUR COMPUTER.") +} + +func TestBootScreenCountsThenPrints(t *testing.T) { + for _, reduce := range []bool{false, true} { + wd := newWorld() + wd.reduce = reduce + done := 0 + tt := view(700, 400, wd, func(c *ui.Context) { + if desk.BootScreen(c, "BIOS", []string{"A", "B", "C"}, desk.BootScreenOptions{}).Done { + done++ + } + }) + x, y, w, _ := dots(find(t, tt, "BIOS")) + // POST lines print in Faint, one each, under the count. + lines := func() int { + n := 0 + for i := range 3 { + if count(tt.Image(), x, y+6+(3+i)*16, w, 16, pixel.Faint) > 0 { + n++ + } + } + return n + } + first := lines() + later(tt, wd, 2*time.Second) + if done != 1 || lines() != 3 || reduce != (first == 3) { + t.Errorf("reduce %v: done %d, %d lines at first, %d after", reduce, done, first, lines()) + } + later(tt, wd, time.Second) + if done != 1 { + t.Errorf("reduce %v: done reported %d times", reduce, done) + } + } +} + +func TestScreensaverWakesOnAnyInput(t *testing.T) { + for _, wake := range []string{"key", "click", "move"} { + wd := newWorld() + on, woke := false, 0 + tt := view(600, 400, wd, func(c *ui.Context) { + if input.Button(c, "SAVER", input.ButtonOptions{}).Clicked { + on = true + } + if desk.Screensaver(c, "LOGO", &on, desk.ScreensaverOptions{}).Woke { + woke++ + } + }) + click(t, tt, "SAVER") + if !on || !tt.Focused("LOGO") { + t.Fatalf("%s: the saver did not take over", wake) + } + // A small nudge of the pointer is not a wake. + tt.Move(300, 200) + tt.Move(304, 200) + before := moves(tt, wd) + switch wake { + case "key": + tt.Key(0, ui.KeyA) + case "click": + tt.ClickAt(10, 10) + case "move": + tt.Move(380, 300) + } + tt.Frame() + if on || woke != 1 || !before { + t.Errorf("%s: on %v, woke %d, logo moved %v", wake, on, woke, before) + } + if !tt.Focused("SAVER") { + t.Errorf("%s: the focus did not come back", wake) + } + } +} + +func TestLockScreenOpensOnAKeyNotAMove(t *testing.T) { + wd := newWorld() + locked, open := true, 0 + tt := view(600, 400, wd, func(c *ui.Context) { + if desk.LockScreen(c, "Lock", &locked, desk.LockScreenOptions{}).Unlocked { + open++ + } + }) + tt.Move(10, 10) + tt.Move(300, 300) + tt.Frame() + if !locked { + t.Fatal("a move unlocked it") + } + tt.Key(0, ui.KeySpace) + tt.Frame() + if locked || open != 1 { + t.Fatalf("Space left it locked %v, %d unlocks", locked, open) + } +} diff --git a/desk/screensaver.go b/desk/screensaver.go new file mode 100644 index 0000000..4352a56 --- /dev/null +++ b/desk/screensaver.go @@ -0,0 +1,80 @@ +package desk + +import ( + "log/slog" + "math" + "time" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type ScreensaverOptions struct{} + +type ScreensaverResult struct { + Element *ui.Element + // Woke is the input that ended it this frame. + Woke bool +} + +type saverKey struct{} + +// bounces ink the logo, a new one at each wall. +var bounces = []pixel.Slot{pixel.Accent, pixel.Good, pixel.Warn, pixel.Info, pixel.Magic, pixel.Bad} + +// Screensaver bounces a logo around; any input stops it. +func Screensaver(c *ui.Context, logo string, open *bool, o ScreensaverOptions) ScreensaverResult { + k := pixel.K(c) + if logo == "" { + panic("desk: Screensaver has no logo") + } + n := 0 + anchor, _, woke := cover(c, k, logo, open, true, func(g pixel.Canvas) { + g.Rect(0, 0, g.W, g.H, pixel.Well) + bw, bh := 2*pixel.Measure(logo)+12, 2*pixel.LineHeight+8 + rx, ry := max(1, g.W-bw), max(1, g.H-bh) + x, y := fold(n+rx/3, rx), fold(n+ry/2, ry) + hits := (n+rx/3)/rx + (n+ry/2)/ry + ink := bounces[hits%len(bounces)] + g.Frame(x, y, bw, bh, pixel.Raised, pixel.Face) + g.Text(x+6, y+4, logo, ink, 2) + // A corner hit, the rarest bounce, earns sparkles. + if (x == 0 || x == rx) && (y == 0 || y == ry) { + for _, d := range [][2]int{{-4, -4}, {bw + 1, -4}, {-4, bh + 1}, {bw + 1, bh + 1}} { + g.Rect(x+d[0]+1, y+d[1], 1, 3, pixel.Lit) + g.Rect(x+d[0], y+d[1]+1, 3, 1, pixel.Lit) + } + } + }) + st := ui.Local(anchor, saverKey{}, func() saver { return saver{} }) + switch { + case !*open: + st.on = false + case !st.on: + *st = saver{on: true, at: k.Now()} + } + // It moves a dot a step; reduced, it holds still. + if *open && !k.Reduce() { + n = max(0, k.Since(c, st.at, 15, math.MaxInt32)) + } + if woke { + slog.Debug("8bit: screensaver woke", "logo", logo) + } + return ScreensaverResult{Element: anchor, Woke: woke} +} + +// saver is whether it shows, and since when. +type saver struct { + on bool + at time.Time +} + +// fold reflects t back and forth across 0 to r. +func fold(t, r int) int { + m := t % (2 * r) + if m > r { + return 2*r - m + } + return m +} diff --git a/desk/shell_test.go b/desk/shell_test.go new file mode 100644 index 0000000..f550388 --- /dev/null +++ b/desk/shell_test.go @@ -0,0 +1,284 @@ +package desk_test + +import ( + "strings" + "testing" + "time" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/desk" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +func TestDesktopFillsColumnsFirst(t *testing.T) { + tt := view(700, 400, newWorld(), func(c *ui.Context) { + desk.Desktop(c, "Desk", desk.DesktopOptions{Height: 140}, func() { + for _, name := range []string{"A", "B", "C"} { + desk.DesktopIcon(c, name, icon.File, desk.DesktopIconOptions{}) + } + }) + }) + a, b, cc := find(t, tt, "A"), find(t, tt, "B"), find(t, tt, "C") + // Two icons fill a 140-dot column; the third starts another. + if b.X != a.X || b.Y <= a.Y || cc.X <= a.X || cc.Y != a.Y { + t.Fatalf("icons at %v, %v, %v", a, b, cc) + } +} + +func TestDesktopIconChoosesOpensAndInverts(t *testing.T) { + chosen, clicks, opens, off := false, 0, 0, false + tt := view(300, 200, newWorld(), func(c *ui.Context) { + r := desk.DesktopIcon(c, "NOTES", icon.FileText, desk.DesktopIconOptions{Chosen: chosen, Disabled: off}) + if r.Clicked { + clicks++ + chosen = true + } + if r.Opened { + opens++ + } + }) + x, y, w, h := dots(find(t, tt, "NOTES")) + if count(tt.Image(), x, y, w, h, pixel.Accent) > 0 { + t.Fatal("an unchosen icon shows Accent") + } + click(t, tt, "NOTES") + if clicks != 1 || opens != 0 || count(tt.Image(), x, y, w, h, pixel.Accent) == 0 { + t.Fatalf("one click: %d clicks, %d opens, or it did not invert", clicks, opens) + } + // The second quick click is a double click: it opens. + click(t, tt, "NOTES") + tt.Key(0, ui.KeyEnter) + if opens != 2 { + t.Fatalf("double click and Enter opened %d times", opens) + } + // A key sent as it turns off still does nothing. + off = true + tt.Key(0, ui.KeyEnter) + if opens != 2 { + t.Fatal("a disabled icon opened") + } +} + +func TestTaskbarSwitchesMinimizesAndTellsTheTime(t *testing.T) { + active, launched, trayed := 0, "", -1 + wd := newWorld() + progs := []desk.Program{{Name: "GAMES", Icon: icon.Folder, Programs: []desk.Program{{Name: "SNAKE", Icon: icon.Bolt}}}, {Name: "RUN", Icon: icon.Terminal}} + tt := view(700, 300, wd, func(c *ui.Context) { + r := desk.Taskbar(c, "Bar", []string{"ONE", "TWO"}, &active, desk.TaskbarOptions{Programs: progs, Tray: []desk.TrayIcon{{Icon: icon.Wifi, Tip: "ONLINE"}}}) + if r.Launched != "" { + launched = r.Launched + } + if r.Tray >= 0 { + trayed = r.Tray + } + }) + tt.Frame() + find(t, tt, "10:30") + x, y, w, h := dots(find(t, tt, "OPEN WINDOWS")) + // The second button chooses TWO; pressing it again minimizes. + tt.ClickAt(float32(2*(x+w*3/4)), float32(2*(y+h/2))) + if active != 1 { + t.Fatalf("active %d after pressing TWO", active) + } + tt.Key(0, ui.KeyEnter) + if active != -1 { + t.Fatalf("pressing the active window left %d", active) + } + click(t, tt, "ONLINE") + if trayed != 0 { + t.Fatalf("tray click gave %d", trayed) + } + click(t, tt, "START") + tt.Key(0, ui.KeyEnter) + tt.Key(0, ui.KeyEnter) + if launched != "GAMES/SNAKE" { + t.Fatalf("START, Enter, Enter launched %q", launched) + } +} + +func TestStartMenuCascades(t *testing.T) { + launched, off := "", false + progs := []desk.Program{ + {Name: "PROGRAMS", Icon: icon.Folder, Programs: []desk.Program{ + {Name: "GAMES", Icon: icon.Folder, Programs: []desk.Program{{Name: "SNAKE", Icon: icon.Bolt}, {Name: "MINES", Icon: icon.Flag}}}, + {Name: "NOTEPAD", Icon: icon.FileText}, + }}, + {Name: "HELP", Icon: icon.Question}, + } + tt := view(700, 500, newWorld(), func(c *ui.Context) { + if r := desk.StartMenu(c, "Start", progs, desk.StartMenuOptions{Disabled: off}); r.Launched != "" { + launched = r.Launched + } + }) + click(t, tt, "Start") + if _, ok := tt.Find("PROGRAMS"); !ok { + t.Fatal("START did not open") + } + // Right goes in, Left comes back out, Enter runs. + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyRight) + if _, ok := tt.Find("MINES"); !ok { + t.Fatalf("two Rights did not reach GAMES: %q", tt.Texts()) + } + tt.Key(0, ui.KeyLeft) + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyEnter) + if launched != "PROGRAMS/NOTEPAD" { + t.Fatalf("launched %q", launched) + } + if _, ok := tt.Find("PROGRAMS"); ok { + t.Fatal("the menu stayed open after a launch") + } + // Clicks: a submenu opens, a program runs. + click(t, tt, "Start") + click(t, tt, "PROGRAMS") + click(t, tt, "GAMES") + click(t, tt, "MINES") + if launched != "PROGRAMS/GAMES/MINES" { + t.Fatalf("clicks launched %q", launched) + } + // Turned off while open, it closes. + click(t, tt, "Start") + off = true + tt.Frame() + if _, ok := tt.Find("PROGRAMS"); ok { + t.Fatal("a START turned off stayed open") + } + click(t, tt, "Start") + if _, ok := tt.Find("PROGRAMS"); ok { + t.Fatal("a disabled START opened") + } +} + +func TestStartMenuOpensSubmenusUnderThePointer(t *testing.T) { + progs := []desk.Program{{Name: "TOOLS", Icon: icon.Folder, Programs: []desk.Program{{Name: "HAMMER", Icon: icon.Sword}}}, {Name: "QUIT", Icon: icon.Power}} + tt := view(700, 500, newWorld(), func(c *ui.Context) { desk.StartMenu(c, "Start", progs, desk.StartMenuOptions{}) }) + click(t, tt, "Start") + tt.Key(0, ui.KeyDown) + r := find(t, tt, "TOOLS") + tt.Move(r.X+r.W/2, r.Y+r.H/2) + tt.Frame() + if _, ok := tt.Find("HAMMER"); !ok { + t.Fatalf("pointing at TOOLS did not open it: %q", tt.Texts()) + } +} + +func TestTrayIconsAreOneTabStop(t *testing.T) { + clicked := -1 + tt := view(400, 200, newWorld(), func(c *ui.Context) { + if i := desk.TrayIcons(c, "Tray", []desk.TrayIcon{{Icon: icon.Wifi, Tip: "ONLINE"}, {Icon: icon.Volume, Tip: "LOUD"}}, desk.TrayIconsOptions{}).Clicked; i >= 0 { + clicked = i + } + }) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyEnter) + if clicked != 1 || !tt.Focused("LOUD") { + t.Fatalf("Tab, Right, Enter clicked %d", clicked) + } + // Focused, the tip shows in a bubble too. + if n := strings.Count(strings.Join(tt.Texts(), "|"), "LOUD"); n < 2 { + t.Fatalf("LOUD shows %d times: %q", n, tt.Texts()) + } +} + +func TestDockRaisesAndLaunches(t *testing.T) { + for _, reduce := range []bool{false, true} { + wd := newWorld() + wd.reduce = reduce + launched := -1 + tt := view(400, 200, wd, func(c *ui.Context) { + if i := desk.Dock(c, "Dock", []desk.DockApp{{Name: "MAIL", Icon: icon.Mail}, {Name: "MUSIC", Icon: icon.Music}}, desk.DockOptions{}).Launched; i >= 0 { + launched = i + } + }) + x, y, _, _ := dots(find(t, tt, "MAIL")) + top := func() int { + for j := y; j < y+40; j++ { + if count(tt.Image(), x, j, 30, 1, pixel.Ink) > 0 { + return j - y + } + } + return -1 + } + rest := top() + tt.Key(0, ui.KeyTab) + first := top() + later(tt, wd, time.Second) + up := top() + // Focused, it rises eight dots: stepping, or at once reduced. + if up != rest-8 || reduce != (first == up) { + t.Errorf("reduce %v: rest %d, first %d, risen %d", reduce, rest, first, up) + } + tt.Key(0, ui.KeyEnter) + if launched != 0 { + t.Errorf("Enter launched %d", launched) + } + } +} + +func TestControlPanelOpensByKeysAndClicks(t *testing.T) { + opened := -1 + applets := []desk.Applet{{Name: "SOUND", Icon: icon.Volume}, {Name: "CLOCK", Icon: icon.Clock}, {Name: "POWER", Icon: icon.Power}} + tt := view(500, 200, newWorld(), func(c *ui.Context) { + if i := desk.ControlPanel(c, "Panel", applets, desk.ControlPanelOptions{}).Opened; i >= 0 { + opened = i + } + }) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnd) + tt.Key(0, ui.KeyEnter) + if opened != 2 { + t.Fatalf("End, Enter opened %d", opened) + } + r := find(t, tt, "Panel") + tt.ClickAt(r.X+2*33, r.Y+2*20) + if opened != 0 { + t.Fatalf("a click on SOUND opened %d", opened) + } +} + +func TestWallpaperPickerChooses(t *testing.T) { + paper, changed := desk.Checks, 0 + tt := view(500, 300, newWorld(), func(c *ui.Context) { + if desk.WallpaperPicker(c, "Paper", &paper, desk.WallpaperPickerOptions{}).Changed { + changed++ + } + }) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyEnter) + if paper != desk.Dots || changed != 1 { + t.Fatalf("Down, Enter chose %d, %d changes", paper, changed) + } + find(t, tt, "DOTS") +} + +func TestStartMenuHoldsStillAsSubmenusOpen(t *testing.T) { + progs := []desk.Program{ + {Name: "TOOLS", Icon: icon.Folder, Programs: []desk.Program{{Name: "SAW", Icon: icon.Sword}, {Name: "AXE", Icon: icon.Sword}, {Name: "HOE", Icon: icon.Leaf}}}, + {Name: "QUIT", Icon: icon.Power}, + } + for _, low := range []bool{false, true} { + launched := "" + tt := view(700, 500, newWorld(), func(c *ui.Context) { + // Low on the window, the menu opens upward. + ui.Box(c).Height(map[bool]float32{false: 0, true: 420}[low]) + if r := desk.StartMenu(c, "Start", progs, desk.StartMenuOptions{}); r.Launched != "" { + launched = r.Launched + } + }) + click(t, tt, "Start") + before := find(t, tt, "TOOLS") + click(t, tt, "TOOLS") + if after := find(t, tt, "TOOLS"); after != before { + t.Errorf("low %v: TOOLS moved from %v to %v", low, before, after) + } + click(t, tt, "AXE") + if launched != "TOOLS/AXE" { + t.Errorf("low %v: launched %q", low, launched) + } + } +} diff --git a/desk/shutdownscreen.go b/desk/shutdownscreen.go new file mode 100644 index 0000000..4fed155 --- /dev/null +++ b/desk/shutdownscreen.go @@ -0,0 +1,33 @@ +package desk + +import ( + "fmt" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type ShutdownScreenOptions struct { + // Width and Height are dots; zeros mean 320 by 200. + Width, Height int +} + +// ShutdownScreen says it is safe to turn off the computer. +func ShutdownScreen(c *ui.Context, o ShutdownScreenOptions) *ui.Element { + k := pixel.K(c) + w, h := pixel.Or("ShutdownScreen Width", o.Width, 320), pixel.Or("ShutdownScreen Height", o.Height, 200) + s := k.Msg("safe_to_turn_off") + lines := pixel.Wrap(s, (w-16)/2, 0) + if len(lines)*2*pixel.LineHeight > h-16 { + panic(fmt.Sprintf("desk: ShutdownScreen %d×%d is too small for its words", w, h)) + } + return ui.Box(c).Size(k.D(w), k.D(h)).Role(ui.RoleText).Label(s).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Rect(0, 0, w, h, pixel.Well) + top := (h - len(lines)*2*pixel.LineHeight) / 2 + for i, l := range lines { + g.Text((w-2*pixel.Measure(l))/2, top+i*2*pixel.LineHeight, l, pixel.Warn, 2) + } + }) +} diff --git a/desk/snake.go b/desk/snake.go new file mode 100644 index 0000000..d47e39b --- /dev/null +++ b/desk/snake.go @@ -0,0 +1,190 @@ +package desk + +import ( + "fmt" + "log/slog" + "math/rand/v2" + "slices" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type SnakeOptions struct { + // Cols and Rows are zeros for 16 by 12. + Cols, Rows int + // Seed places the food; zero takes the clock. + Seed uint64 + Disabled bool +} + +type SnakeResult struct { + Element *ui.Element + // Score is food eaten; Started and Over are this frame's. + Score int + Started, Over bool +} + +type snakeKey struct{} + +// pit is a game: body head first, heading, food, score. +type pit struct { + body [][2]int + dir, next [2]int + food [2]int + score, last int + playing, dead bool + rng *rand.Rand +} + +const scell = 8 + +// Snake: arrows steer, food grows it; crashes are GAME OVER. +func Snake(c *ui.Context, text string, o SnakeOptions) SnakeResult { + k := pixel.K(c) + cols, rows := pixel.Or("Snake Cols", o.Cols, 16), pixel.Or("Snake Rows", o.Rows, 12) + if cols < 6 || rows < 4 { + panic(fmt.Sprintf("desk: Snake %d×%d is too small", cols, rows)) + } + shell, base, st := pixel.Shell(c, func() *ui.Element { + return ui.Box(c).Focusable().Role(ui.RoleGroup).Label(text).Disabled(o.Disabled) + }) + s := ui.Local(shell, snakeKey{}, func() pit { return pit{} }) + res := SnakeResult{Element: shell} + if s.rng == nil || len(s.body) == 0 { + s.reset(cols, rows, o.Seed, uint64(k.Now().UnixNano())) + } + if !base.IsDisabled() { + for _, m := range []struct { + key ui.Key + d [2]int + }{{ui.KeyUp, [2]int{0, -1}}, {ui.KeyDown, [2]int{0, 1}}, {ui.KeyLeft, [2]int{-1, 0}}, {ui.KeyRight, [2]int{1, 0}}} { + // A turn back onto its own neck is ignored. + if base.Shortcut(0, m.key) && m.d != [2]int{-s.dir[0], -s.dir[1]} && !s.dead { + res.Started = res.Started || !s.playing + s.next, s.playing = m.d, true + } + } + // Enter, Space or a click starts it, or CONTINUEs. + enter, space := base.Shortcut(0, ui.KeyEnter), base.Shortcut(0, ui.KeySpace) + if enter || space || base.Clicked() { + if s.dead { + s.reset(cols, rows, o.Seed, uint64(k.Now().UnixNano())) + } + res.Started, s.playing = res.Started || !s.playing, true + } + } + // It runs 8 steps a second, only while focused. + if s.playing && !s.dead && base.Focused() && !base.IsDisabled() { + tick := k.Tick(c, 8) + if s.last >= 0 { + for range min(tick-s.last, 3) { + if s.step(cols, rows) { + res.Over = true + break + } + } + } + s.last = tick + } else { + s.last = -1 + } + res.Score = s.score + if res.Started { + slog.Debug("8bit: snake start", "label", text) + } + if res.Over { + slog.Debug("8bit: snake over", "label", text, "score", s.score) + } + word := "" + switch { + case s.dead: + word = k.Msg("game_over") + case !s.playing: + word = k.Msg("press_start") + case !base.Focused(): + word = k.Msg("paused") + } + score := fmt.Sprintf("%s %d", k.Msg("score"), s.score) + base.Description(score + ", " + word) + body, food, dead := slices.Clone(s.body), s.food, s.dead + w, h := cols*scell+4, rows*scell+4 + shell.Size(k.D(w), k.D(h+pixel.LineHeight)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Text(0, 0, pixel.Wrap(score, w, 1)[0], pixel.Ink, 1) + g.Frame(0, pixel.LineHeight, w, h, pixel.Sunken, pixel.Well) + at := func(c [2]int) (int, int) { return 2 + c[0]*scell, pixel.LineHeight + 2 + c[1]*scell } + fx, fy := at(food) + g.Rect(fx+2, fy+2, scell-4, scell-4, pixel.Bad) + g.Dot(fx+4, fy+1, pixel.Good) + for i, b := range body { + x, y := at(b) + ink := pixel.Good + if dead { + ink = pixel.Faint + } + g.Rect(x+1, y+1, scell-2, scell-2, ink) + if i == 0 { + g.Rect(x+2, y+2, 2, 2, pixel.Ink) + g.Rect(x+5, y+2, 2, 2, pixel.Ink) + } + } + // The word sits on top, clear of the snake. + if word != "" { + word := pixel.Wrap(word, w-8, 1)[0] + ww := pixel.Measure(word) + g.Rect((w-ww)/2-3, pixel.LineHeight+3, ww+6, pixel.LineHeight+2, pixel.Face) + g.Text((w-ww)/2, pixel.LineHeight+4, word, pixel.Ink, 1) + } + g.Finish(st) + }) + return res +} + +// reset lays a three-long snake mid-left, heading right. +func (s *pit) reset(cols, rows int, seed, clock uint64) { + if seed == 0 { + seed = clock + } + y := rows / 2 + *s = pit{body: [][2]int{{3, y}, {2, y}, {1, y}}, dir: [2]int{1, 0}, next: [2]int{1, 0}, last: -1, + rng: rand.New(rand.NewPCG(seed, seed^0x9e3779b97f4a7c15))} + s.feed(cols, rows) +} + +// feed drops food on a free cell. +func (s *pit) feed(cols, rows int) { + var free [][2]int + for y := range rows { + for x := range cols { + if !slices.Contains(s.body, [2]int{x, y}) { + free = append(free, [2]int{x, y}) + } + } + } + if len(free) > 0 { + s.food = free[s.rng.IntN(len(free))] + } +} + +// step moves the head on; it reports a crash. +func (s *pit) step(cols, rows int) bool { + s.dir = s.next + head := [2]int{s.body[0][0] + s.dir[0], s.body[0][1] + s.dir[1]} + grow := head == s.food + rest := s.body + if !grow { + rest = s.body[:len(s.body)-1] + } + if head[0] < 0 || head[0] >= cols || head[1] < 0 || head[1] >= rows || slices.Contains(rest, head) { + s.dead = true + return true + } + s.body = append([][2]int{head}, rest...) + if grow { + s.score++ + s.feed(cols, rows) + } + return false +} diff --git a/desk/startmenu.go b/desk/startmenu.go new file mode 100644 index 0000000..f17298c --- /dev/null +++ b/desk/startmenu.go @@ -0,0 +1,264 @@ +package desk + +import ( + "fmt" + "log/slog" + "slices" + "strings" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +// Program is a START entry; with Programs, a submenu. +type Program struct { + Name string + Icon *pixel.Sprite + Programs []Program +} + +type StartMenuOptions struct { + Disabled bool +} + +type StartMenuResult struct { + Element *ui.Element + // Launched is the program run this frame, as "GAMES/SNAKE". + Launched string +} + +type startKey struct{} + +// start is the open menu: each open level's lit row. +type start struct { + open bool + path []int + ptr [2]float32 +} + +const rowH = pixel.LineHeight + 2 + +// StartMenu is the START button and its cascading programs. +func StartMenu(c *ui.Context, text string, programs []Program, o StartMenuOptions) StartMenuResult { + k := pixel.K(c) + checkPrograms(programs) + off := o.Disabled || pixel.Current(c).Inert + b := input.Button(c, k.Msg("start"), input.ButtonOptions{Icon: icon.Gamepad, Label: text, Disabled: o.Disabled}) + m := ui.Local(b.Element, startKey{}, func() start { return start{} }) + if off { + // Closing on disable must not return focus to it. + if m.open { + c.Root().Focus() + } + m.open = false + } + res := StartMenuResult{Element: b.Element} + // It rises from a taskbar, so Up opens it too. + switch { + case b.Clicked && m.open: + m.open = false + case b.Clicked, !m.open && !off && b.Element.Shortcut(0, ui.KeyUp): + m.open, m.path = true, []int{0} + } + if m.open { + m.fit(programs) + res.Launched = m.keys(b.Element.Shortcut, programs) + } + if run := m.list(c, k, b.Element, programs); run != "" { + res.Launched = run + } + if res.Launched != "" { + slog.Debug("8bit: start menu launched", "label", text, "program", res.Launched) + } + return res +} + +// checkPrograms fails on blank, slashed or twin names. +func checkPrograms(ps []Program) { + if len(ps) == 0 { + panic("desk: a START menu level is empty") + } + seen := map[string]bool{} + for _, p := range ps { + sprite("StartMenu "+p.Name, p.Icon) + if p.Name == "" || strings.Contains(p.Name, "/") || seen[p.Name] { + panic(fmt.Sprintf("desk: START menu program %q is blank, slashed or twice", p.Name)) + } + seen[p.Name] = true + if p.Programs != nil { + checkPrograms(p.Programs) + } + } +} + +// fit trims the path to the programs now given. +func (m *start) fit(ps []Program) { + items := ps + for d, i := range m.path { + if i >= len(items) { + m.path = append(m.path[:d], len(items)-1) + return + } + if d+1 < len(m.path) { + if items = items[i].Programs; items == nil { + m.path = m.path[:d+1] + return + } + } + } +} + +// level is the programs at a depth of the path. +func level(ps []Program, path []int, depth int) []Program { + for _, i := range path[:depth] { + ps = ps[i].Programs + } + return ps +} + +// launch closes the menu and names the path's program. +func (m *start) launch(ps []Program) string { + names := make([]string, len(m.path)) + for d, i := range m.path { + names[d] = level(ps, m.path, d)[i].Name + } + m.open = false + return strings.Join(names, "/") +} + +// keys walks the deepest level; Right or Enter goes in. +func (m *start) keys(short func(ui.Modifiers, ui.Key) bool, ps []Program) string { + down, up, left, right := short(0, ui.KeyDown), short(0, ui.KeyUp), short(0, ui.KeyLeft), short(0, ui.KeyRight) + enter, space := short(0, ui.KeyEnter), short(0, ui.KeySpace) + d := len(m.path) - 1 + items := level(ps, m.path, d) + switch { + case down: + m.path[d] = (m.path[d] + 1) % len(items) + case up: + m.path[d] = (m.path[d] - 1 + len(items)) % len(items) + case left && d > 0: + m.path = m.path[:d] + case (right || enter || space) && items[m.path[d]].Programs != nil: + m.path = append(m.path, 0) + case enter || space: + return m.launch(ps) + } + return "" +} + +// list shows each open level beside its parent row. +func (m *start) list(c *ui.Context, k pixel.Kit, anchor *ui.Element, ps []Program) string { + run := "" + ui.PopoverBase(c, anchor, &m.open, func(panel *ui.Element) { + // Only a pointer that moves lights the row under it. + px, py, over := panel.PointerPosition() + moved := over && [2]float32{px, py} != m.ptr + m.ptr = [2]float32{px, py} + path := slices.Clone(m.path) + // Submenus grow away from START, so open rows hold still. + below, align := panel.Bounds().Y > anchor.Bounds().Y, ui.End + if below { + align = ui.Start + } + panel.Children(func() { + ui.Row(c).AlignItems(align).Children(func() { + shift := 0 + for d := range path { + items := level(ps, path, d) + col := ui.Column(c).Padding(k.D(3)) + // A submenu lines up with its parent row. + if d > 0 && below { + shift += path[d-1] * rowH + col.Margin(k.D(shift), 0, 0, 0) + } + if d > 0 && !below { + shift += (len(level(ps, path, d-1)) - 1 - path[d-1]) * rowH + col.Margin(0, 0, k.D(shift), 0) + } + col.Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Frame(0, 0, g.W, g.H, pixel.Window, pixel.Face) + }).Children(func() { + ui.Row(c).Children(func() { + if d == 0 { + banner(c, k, len(items)) + } + ui.Column(c).Children(func() { + for i, p := range items { + if r := m.row(c, k, p, d, i, moved); r == launched { + run = m.launch(ps) + } + } + }) + }) + }) + } + }) + }) + }) + return run +} + +type rowEvent int + +const ( + quiet rowEvent = iota + launched +) + +// row is one program; hover or click opens a submenu. +func (m *start) row(c *ui.Context, k pixel.Kit, p Program, d, i int, moved bool) rowEvent { + w := 20 + pixel.Measure(p.Name) + 16 + row := ui.Row(c).Height(k.D(rowH)).MinWidth(k.D(w)).Role(ui.RoleListItem).Label(p.Name) + // open lights the row and shows its submenu, if any. + open := func() { + m.path = append(m.path[:d], i) + if p.Programs != nil { + m.path = append(m.path, 0) + } + } + // A level a row before closed takes no input. + ev, alive := quiet, len(m.path) > d + shut := alive && m.path[d] == i && len(m.path) == d+1 && p.Programs != nil + if alive && moved && row.Hovered() && (m.path[d] != i || shut) { + open() + c.Invalidate() + } + if alive && row.Clicked() { + open() + if p.Programs == nil { + ev = launched + } + } + row.Draw(func(pt *ui.Painter, r ui.Rect) { + g := k.Canvas(pt, r) + ink := pixel.Ink + if len(m.path) > d && m.path[d] == i { + g.Rect(0, 0, g.W, g.H, pixel.Accent) + ink = pixel.AccentInk + } + g.Sprite(2, (g.H-p.Icon.H)/2, p.Icon, 0, ink, 1) + g.Text(18, 1, p.Name, ink, 1) + if p.Programs != nil { + for j := range 4 { + g.VLine(g.W-8+j, g.H/2-3+j, 7-2*j, ink) + } + } + }) + return ev +} + +// banner is START's dithered side strip. +func banner(c *ui.Context, k pixel.Kit, rows int) { + ui.Box(c).Size(k.D(10), k.D(rows*rowH)).Role(ui.RoleNone).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + for b := range 17 { + top, foot := b*g.H/17, (b+1)*g.H/17 + g.Dither(0, top, g.W, foot-top, pixel.Accent, pixel.Magic, 16-b) + } + }) +} diff --git a/desk/taskbar.go b/desk/taskbar.go new file mode 100644 index 0000000..4ecaba3 --- /dev/null +++ b/desk/taskbar.go @@ -0,0 +1,120 @@ +package desk + +import ( + "fmt" + "log/slog" + "time" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type TaskbarOptions struct { + // Programs fill the START menu; none leaves START out. + Programs []Program + // Tray icons sit by the clock. + Tray []TrayIcon + // Width is in dots; zero means 320. + Width int + Disabled bool +} + +type TaskbarResult struct { + Element *ui.Element + // Changed is a window button pressed; Launched a program. + Changed bool + Launched string + // Tray is the tray icon pressed this frame, else -1. + Tray int +} + +type barKey struct{} + +type slideKey struct{} + +const taskW = 72 + +// Taskbar: START, a button per window, tray and clock. +func Taskbar(c *ui.Context, text string, windows []string, active *int, o TaskbarOptions) TaskbarResult { + k := pixel.K(c) + if *active < -1 || *active >= len(windows) { + panic(fmt.Sprintf("desk: Taskbar active %d of %d windows", *active, len(windows))) + } + w := pixel.Or("Taskbar Width", o.Width, 320) + row := ui.Row(c).Width(k.D(w)).Height(k.D(pixel.LineHeight + 10)).Padding(k.D(3)).Gap(k.D(3)).AlignItems(ui.Center).Role(ui.RoleToolbar).Label(text) + cur := ui.Local(row, barKey{}, func() int { return max(0, *active) }) + first := ui.Local(row, slideKey{}, func() int { return 0 }) + res := TaskbarResult{Tray: -1} + res.Element = row.Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Rect(0, 0, g.W, g.H, pixel.Face) + g.HLine(0, 0, g.W, pixel.Lit) + }).Children(func() { + if len(o.Programs) > 0 { + res.Launched = StartMenu(c, k.Msg("start"), o.Programs, StartMenuOptions{Disabled: o.Disabled}).Launched + } + // Window buttons share the room; arrows reach any past it. + room := ui.Box(c).Key("windows").Grow(1).Height(k.D(pixel.LineHeight + 4)).Clip() + free, check := pixel.WrapWidth(c, room) + room.Draw(func(p *ui.Painter, r ui.Rect) { check(r) }).Children(func() { + if len(windows) == 0 { + return + } + bw := max(24, min(taskW, free/len(windows))) + *cur = min(*cur, len(windows)-1) + was := *active + shell, base, st, picked := pixel.Pad(c, k.Msg("open_windows"), len(windows), len(windows), bw, pixel.LineHeight+4, cur, o.Disabled) + // Pressing the active window's button minimizes it. + if picked >= 0 { + *active, res.Changed = picked, true + if picked == was { + *active = -1 + } + } + // Past the room, the buttons slide so the cursor shows. + fit := max(1, free/bw) + *first = max(0, min(*first, *cur, len(windows)-fit)) + if *cur >= *first+fit { + *first = *cur - fit + 1 + } + shell.Absolute().Left(-k.D(*first * bw)).Top(0) + at, on := *cur, *active + base.Description(windows[at]) + shell.Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + for i, name := range windows { + x, style := i*bw, pixel.Raised + if i == on { + style = pixel.Sunken + } + g.Frame(x+1, 0, bw-2, g.H, style, pixel.Face) + if i == on { + g.Checker(x+3, 2, bw-6, g.H-4, pixel.Lit) + } + if i == at && st.Focus { + g.Cursor(x+3, 3, bw-6, g.H-6) + } + g.Text(x+5, 2, pixel.Wrap(name, bw-10, 1)[0], pixel.Ink, 1) + } + g.Finish(st) + }) + }) + // Keyed: the tray keeps focus as START comes and goes. + ui.Row(c).Key("tray").Gap(k.D(4)).Padding(k.D(2), k.D(4)).AlignItems(ui.Center).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Frame(0, 0, g.W, g.H, pixel.Sunken, pixel.Face) + }).Children(func() { + if len(o.Tray) > 0 { + res.Tray = TrayIcons(c, k.Msg("tray"), o.Tray, TrayIconsOptions{Disabled: o.Disabled}).Clicked + } + now := k.Now() + k.At(c, now.Truncate(time.Minute).Add(time.Minute)) + pixel.Text(c, now.Format("15:04"), pixel.TextOptions{}) + }) + }) + if res.Changed { + slog.Debug("8bit: taskbar window", "label", text, "active", *active) + } + return res +} diff --git a/desk/trayicons.go b/desk/trayicons.go new file mode 100644 index 0000000..7a3cc54 --- /dev/null +++ b/desk/trayicons.go @@ -0,0 +1,69 @@ +package desk + +import ( + "fmt" + "log/slog" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/overlay" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +// TrayIcon is a small icon by the clock, tipped. +type TrayIcon struct { + Icon *pixel.Sprite + Tip string +} + +type TrayIconsOptions struct { + Disabled bool +} + +type TrayIconsResult struct { + Element *ui.Element + // Clicked is the icon pressed this frame, else -1. + Clicked int +} + +// TrayIcons are small icons; hover or focus shows tips. +func TrayIcons(c *ui.Context, text string, icons []TrayIcon, o TrayIconsOptions) TrayIconsResult { + k := pixel.K(c) + seen := map[string]bool{} + for i, t := range icons { + sprite("TrayIcons", t.Icon) + if t.Tip == "" || seen[t.Tip] { + panic(fmt.Sprintf("desk: TrayIcons icon %d tip %q is blank or twice", i, t.Tip)) + } + seen[t.Tip] = true + } + res := TrayIconsResult{Clicked: -1} + res.Element = ui.Row(c).Gap(k.D(1)).FocusGroup(ui.Horizontal).Role(ui.RoleToolbar).Label(text).Children(func() { + for i, t := range icons { + // Keyed by tip: icons come and go. + ui.Box(c).Key(t.Tip).Children(func() { + overlay.Tooltip(c, t.Tip, overlay.TooltipOptions{}, func() { + shell, base, st := pixel.Shell(c, func() *ui.Element { + return ui.Box(c).Focusable().Role(ui.RoleButton).Label(t.Tip).Disabled(o.Disabled) + }) + if !base.IsDisabled() && base.Clicked() { + res.Clicked = i + } + shell.Size(k.D(14), k.D(14)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + dy := 0 + if st.Press { + dy = 1 + } + g.Sprite(1, 1+dy, t.Icon, 0, pixel.Ink, 1) + g.Finish(st) + }) + }) + }) + } + }) + if res.Clicked >= 0 { + slog.Debug("8bit: tray icon clicked", "label", text, "tip", icons[res.Clicked].Tip) + } + return res +} diff --git a/desk/veil.go b/desk/veil.go new file mode 100644 index 0000000..ec8a469 --- /dev/null +++ b/desk/veil.go @@ -0,0 +1,57 @@ +package desk + +import ( + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type wakeKey struct{} + +// wake is input between frames; from is the first pointer. +type wake struct { + woke, seen bool + from [2]float32 +} + +// cover fills the window; keys, clicks, long moves end it. +func cover(c *ui.Context, k pixel.Kit, text string, open *bool, moves bool, draw func(g pixel.Canvas)) (anchor, panel *ui.Element, woke bool) { + anchor = ui.Box(c).Role(ui.RoleNone) + st := ui.Local(anchor, wakeKey{}, func() wake { return wake{} }) + was := *open + if st.woke { + *open = false + } + if !*open { + *st = wake{} + } + panel = ui.DialogBase(c, open, func(back, panel *ui.Element) { + back.Draw(func(p *ui.Painter, r ui.Rect) { draw(k.Canvas(p, r)) }) + back.HandleInput(func(ev ui.InputEvent) bool { + // Any button and the wheel end it, as keys do. + if ev.Kind == ui.InputPointerDown || ev.Kind == ui.InputScroll { + st.woke = true + } + if !moves || ev.Kind != ui.InputPointerMove { + return false + } + // The first move marks the spot; eight dots away wakes. + at := [2]float32{ev.X / k.Dot(), ev.Y / k.Dot()} + if !st.seen { + st.from, st.seen = at, true + } + if dx, dy := at[0]-st.from[0], at[1]-st.from[1]; dx*dx+dy*dy > 64 { + st.woke = true + } + return false + }) + panel.Focusable().FocusRing(false).Label(text).HandleInput(func(ev ui.InputEvent) bool { + if ev.Kind == ui.InputKeyDown { + st.woke = true + return true + } + return false + }) + }) + return anchor, panel, was && !*open +} diff --git a/desk/view_test.go b/desk/view_test.go new file mode 100644 index 0000000..deb89fe --- /dev/null +++ b/desk/view_test.go @@ -0,0 +1,135 @@ +package desk_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() +} + +// moves reports whether a view's picture changes over a second. +func moves(tt *ui.Tester, wd *world) bool { + first := append([]byte(nil), tt.Image().Pix...) + for range 15 { + later(tt, wd, time.Second/15) + if !bytes.Equal(first, tt.Image().Pix) { + return true + } + } + return false +} + +// spill counts dots drawn outside a box. +func spill(img *image.RGBA, x, y, w, h int) int { + n, b := 0, img.Bounds() + for j := range b.Dy() / 2 { + for i := range b.Dx() / 2 { + if (i < x || i >= x+w || j < y || j >= y+h) && at(img, i, j) != pixel.Back { + n++ + } + } + } + return n +} diff --git a/desk/wallpaperpicker.go b/desk/wallpaperpicker.go new file mode 100644 index 0000000..6b55cfa --- /dev/null +++ b/desk/wallpaperpicker.go @@ -0,0 +1,64 @@ +package desk + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type WallpaperPickerOptions struct { + Disabled bool +} + +type WallpaperPickerResult struct { + Element *ui.Element + Changed bool +} + +type pickerKey struct{} + +const swatchW, swatchH = 44, 36 + +// WallpaperPicker chooses a dither pattern by its swatch. +func WallpaperPicker(c *ui.Context, text string, chosen *Wallpaper, o WallpaperPickerOptions) WallpaperPickerResult { + k := pixel.K(c) + chosen.name(k) + col := ui.Column(c).Gap(k.D(4)) + cur := ui.Local(col, pickerKey{}, func() int { return int(*chosen) }) + res := WallpaperPickerResult{} + res.Element = col.Children(func() { + shell, base, st, picked := pixel.Pad(c, text, 3, len(papers), swatchW, swatchH, cur, o.Disabled) + if picked >= 0 && Wallpaper(picked) != *chosen { + *chosen, res.Changed = Wallpaper(picked), true + } + at, pick := *cur, *chosen + said := Wallpaper(at).name(k) + if Wallpaper(at) == pick { + said += ", " + k.Msg("chosen") + } + base.Description(said) + shell.Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + for i := range papers { + x, y := i%3*swatchW, i/3*swatchH + if i == at && st.Focus { + g.Cursor(x, y, swatchW, swatchH) + } + ring := pixel.Edge + if Wallpaper(i) == pick { + ring = pixel.Accent + } + g.Rect(x+2, y+2, swatchW-4, swatchH-4, ring) + paper(g, x+4, y+4, swatchW-8, swatchH-8, Wallpaper(i)) + } + g.Finish(st) + }) + pixel.Text(c, Wallpaper(at).name(k), pixel.TextOptions{Slot: pixel.Faint}) + }) + if res.Changed { + slog.Debug("8bit: wallpaper chosen", "label", text, "wallpaper", int(*chosen)) + } + return res +} diff --git a/gallery/demos_desk.go b/gallery/demos_desk.go new file mode 100644 index 0000000..d177581 --- /dev/null +++ b/gallery/demos_desk.go @@ -0,0 +1,186 @@ +package gallery + +import ( + "fmt" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/desk" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +var deskEntries = []Entry{ + {466, "Desktop", "桌面", "desk", "A dithered wallpaper with icons down the left, as desktops had.", demoDesktop}, + {467, "DesktopIcon", "桌面图标", "desk", "Click to choose and it inverts; double-click or Enter opens it.", demoDesktopIcon}, + {468, "Taskbar", "任务栏", "desk", "START, a button for each open window, the tray and the clock.", demoTaskbar}, + {469, "StartMenu", "开始菜单", "desk", "Programs in cascading menus. Arrows walk in and out; Enter runs one.", demoStartMenu}, + {470, "AppWindow", "应用窗口", "desk", "Drag a window by its title bar, or focus the bar and use the arrows.", demoAppWindow}, + {471, "Calculator", "计算器", "desk", "It really adds up. Divide by zero for a GAME OVER; C continues.", demoCalculator}, + {472, "Notepad", "记事本", "desk", "A text area under FILE and EDIT. TIME/DATE stamps the moment.", demoNotepad}, + {473, "Minesweeper", "扫雷", "desk", "Open with a click or Enter, flag with a right click or F. The face starts over.", demoMinesweeper}, + {474, "ControlPanel", "控制面板", "desk", "Settings as an icon grid. Arrows choose; Enter opens.", demoControlPanel}, + {475, "TrayIcons", "托盘图标", "desk", "Small icons by the clock. Hover or focus one for its tip.", demoTrayIcons}, +} + +type deskDemo struct { + chosen, active, wx, wy int + note string + shut bool +} + +func deskState(c *ui.Context) *deskDemo { + return ui.Local(c.Root(), demoKey("desk"), func() deskDemo { + return deskDemo{chosen: -1, wx: 70, wy: 12, note: "Dear diary,\ntoday I defragged."} + }) +} + +// icons sit on the demo desktops. +var icons = []struct { + name string + sp *pixel.Sprite +}{{"MY COMPUTER", icon.Gamepad}, {"RECYCLE BIN", icon.Trash}, {"NOTES.TXT", icon.FileText}, {"GAMES", icon.Folder}} + +// programs fill the demo START menus. +var programs = []desk.Program{ + {Name: "PROGRAMS", Icon: icon.Folder, Programs: []desk.Program{ + {Name: "ACCESSORIES", Icon: icon.Folder, Programs: []desk.Program{{Name: "NOTEPAD", Icon: icon.FileText}, {Name: "CALCULATOR", Icon: icon.Grid}}}, + {Name: "GAMES", Icon: icon.Gamepad, Programs: []desk.Program{{Name: "MINESWEEPER", Icon: icon.Flag}, {Name: "SNAKE", Icon: icon.Bolt}}}, + }}, + {Name: "DOCUMENTS", Icon: icon.FileText}, + {Name: "SETTINGS", Icon: icon.Settings, Programs: []desk.Program{{Name: "CONTROL PANEL", Icon: icon.Settings}, {Name: "WALLPAPER", Icon: icon.Image}}}, + {Name: "RUN...", Icon: icon.Terminal}, + {Name: "SHUT DOWN", Icon: icon.Power}, +} + +func demoDesktop(c *ui.Context, log func(string)) { + s := deskState(c) + desk.Desktop(c, "Desktop", desk.DesktopOptions{Width: 300, Height: 150}, func() { + for i, it := range icons { + r := desk.DesktopIcon(c, it.name, it.sp, desk.DesktopIconOptions{Chosen: s.chosen == i}) + if r.Clicked { + s.chosen = i + log("chose " + it.name) + } + if r.Opened { + log("opened " + it.name) + } + } + }) +} + +func demoDesktopIcon(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := deskState(c) + ui.Row(c).Gap(k.D(4)).Children(func() { + for i, it := range icons[:3] { + r := desk.DesktopIcon(c, it.name, it.sp, desk.DesktopIconOptions{Chosen: s.chosen == i}) + if r.Clicked { + s.chosen = i + log("chose " + it.name) + } + if r.Opened { + log("opened " + it.name) + } + } + }) +} + +// tray sits by the demo taskbars' clocks. +var tray = []desk.TrayIcon{{Icon: icon.Wifi, Tip: "ONLINE"}, {Icon: icon.Volume, Tip: "VOLUME 80%"}, {Icon: icon.Battery, Tip: "BATTERY 99%"}} + +func demoTaskbar(c *ui.Context, log func(string)) { + s := deskState(c) + windows := []string{"NOTEPAD", "CALC", "MINES"} + r := desk.Taskbar(c, "Taskbar", windows, &s.active, desk.TaskbarOptions{Programs: programs, Tray: tray}) + if r.Changed { + log(fmt.Sprintf("active window %d", s.active)) + } + if r.Launched != "" { + log("launched " + r.Launched) + } + if r.Tray >= 0 { + log("tray: " + tray[r.Tray].Tip) + } +} + +func demoStartMenu(c *ui.Context, log func(string)) { + if r := desk.StartMenu(c, "Start", programs, desk.StartMenuOptions{}); r.Launched != "" { + log("launched " + r.Launched) + } +} + +func demoAppWindow(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := deskState(c) + ui.Column(c).Gap(k.D(4)).Children(func() { + desk.Desktop(c, "Desktop", desk.DesktopOptions{Wallpaper: desk.Dots, Width: 320, Height: 170}, func() { + if !s.shut { + r := desk.AppWindow(c, "HELLO.TXT", &s.wx, &s.wy, desk.AppWindowOptions{Width: 120, Height: 48, Room: [2]int{320, 170}}, func() { + pixel.Text(c, "Drag me around!", pixel.TextOptions{}) + }) + if r.Moved { + log(fmt.Sprintf("window at %d, %d", s.wx, s.wy)) + } + if r.Closed { + s.shut = true + log("window closed") + } + } + }) + if s.shut && input.Button(c, "REOPEN", input.ButtonOptions{Size: input.Small}).Clicked { + s.shut = false + log("window reopened") + } + }) +} + +func demoCalculator(c *ui.Context, log func(string)) { + if desk.Calculator(c, "Pocket calculator", desk.CalculatorOptions{}).Changed { + log("key pressed") + } +} + +func demoNotepad(c *ui.Context, log func(string)) { + s := deskState(c) + r := desk.Notepad(c, "Notepad", &s.note, desk.NotepadOptions{}) + if r.Changed { + log(fmt.Sprintf("%d runes", len([]rune(s.note)))) + } + if r.Saved { + log("saved") + } +} + +func demoMinesweeper(c *ui.Context, log func(string)) { + r := desk.Minesweeper(c, "Minesweeper", desk.MinesweeperOptions{}) + if r.Flagged { + log("flag") + } + if r.Restarted { + log("new game") + } + if r.Won { + log("cleared the field") + } + if r.Lost { + log("boom") + } +} + +func demoControlPanel(c *ui.Context, log func(string)) { + applets := []desk.Applet{ + {Name: "DISPLAY", Icon: icon.Image}, {Name: "SOUND", Icon: icon.Volume}, {Name: "CLOCK", Icon: icon.Clock}, {Name: "NETWORK", Icon: icon.Wifi}, + {Name: "POWER", Icon: icon.Power}, {Name: "USERS", Icon: icon.Users}, {Name: "FONTS", Icon: icon.FileText}, {Name: "GAMEPAD", Icon: icon.Gamepad}, + } + if i := desk.ControlPanel(c, "Control panel", applets, desk.ControlPanelOptions{}).Opened; i >= 0 { + log("opened " + applets[i].Name) + } +} + +func demoTrayIcons(c *ui.Context, log func(string)) { + if i := desk.TrayIcons(c, "Tray", tray, desk.TrayIconsOptions{}).Clicked; i >= 0 { + log("clicked " + tray[i].Tip) + } +} diff --git a/gallery/demos_retro.go b/gallery/demos_retro.go new file mode 100644 index 0000000..2b5a930 --- /dev/null +++ b/gallery/demos_retro.go @@ -0,0 +1,160 @@ +package gallery + +import ( + "fmt" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/desk" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +var retroEntries = []Entry{ + {476, "ShutdownScreen", "关机画面", "desk", "The kindest words a computer ever said.", demoShutdownScreen}, + {477, "BootScreen", "开机自检", "desk", "Memory counts up, POST lines roll by, the logo waits.", demoBootScreen}, + {478, "Screensaver", "屏保", "desk", "A logo bounces round the screen. Any key, click or move wakes it. Watch the corners.", demoScreensaver}, + {479, "Snake", "贪吃蛇", "desk", "Arrows steer, food grows you. The wall is not your friend.", demoSnake}, + {480, "Dock", "程序坞", "desk", "A shelf of apps. The one you point at or focus rises.", demoDock}, + {481, "RunDialog", "运行", "desk", "Type a program's name. OK runs it; CANCEL or Escape walks away.", demoRunDialog}, + {482, "DiskDefrag", "磁盘整理", "desk", "Scattered blocks march into order. Oddly soothing.", demoDiskDefrag}, + {483, "WallpaperPicker", "壁纸选择", "desk", "Dither patterns to dress the desktop.", demoWallpaperPicker}, + {484, "ClipboardPanel", "剪贴板", "desk", "Recent clips. Click one and it is yours to paste again.", demoClipboardPanel}, + {485, "LockScreen", "锁屏", "desk", "The time, the date, and PRESS ANY KEY.", demoLockScreen}, +} + +type retroDemo struct { + saver, locked, defrag bool + boots, disks int + run string + paper desk.Wallpaper +} + +func retroState(c *ui.Context) *retroDemo { + return ui.Local(c.Root(), demoKey("retro"), func() retroDemo { return retroDemo{run: "notepad"} }) +} + +func demoShutdownScreen(c *ui.Context, log func(string)) { + desk.ShutdownScreen(c, desk.ShutdownScreenOptions{Width: 300, Height: 120}) +} + +func demoBootScreen(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := retroState(c) + ui.Column(c).Gap(k.D(4)).Children(func() { + // A new key is a new screen: REBOOT starts over. + ui.Box(c).Key(s.boots).Children(func() { + if desk.BootScreen(c, "8BIT BIOS V4.51", []string{ + "CPU: PIXEL 8086 AT 4.77 MHZ", "FLOPPY A: 1.44 MB", "KEYBOARD... OK", "BOOTING FROM C:", + }, desk.BootScreenOptions{Width: 300, Height: 140}).Done { + log("booted") + } + }) + if input.Button(c, "REBOOT", input.ButtonOptions{Size: input.Small}).Clicked { + s.boots++ + log("rebooting") + } + }) +} + +func demoScreensaver(c *ui.Context, log func(string)) { + s := retroState(c) + if input.Button(c, "START SAVER", input.ButtonOptions{}).Clicked { + s.saver = true + log("saver on") + } + if desk.Screensaver(c, "8BIT", &s.saver, desk.ScreensaverOptions{}).Woke { + log("woke up") + } +} + +func demoSnake(c *ui.Context, log func(string)) { + r := desk.Snake(c, "Snake pit", desk.SnakeOptions{}) + if r.Started { + log("go!") + } + if r.Over { + log(fmt.Sprintf("game over at %d", r.Score)) + } +} + +func demoDock(c *ui.Context, log func(string)) { + apps := []desk.DockApp{ + {Name: "FILES", Icon: icon.Folder}, {Name: "MAIL", Icon: icon.Mail}, {Name: "MUSIC", Icon: icon.Music}, + {Name: "CAMERA", Icon: icon.Camera}, {Name: "TERMINAL", Icon: icon.Terminal}, {Name: "TRASH", Icon: icon.Trash}, + } + if i := desk.Dock(c, "Dock", apps, desk.DockOptions{}).Launched; i >= 0 { + log("launched " + apps[i].Name) + } +} + +func demoRunDialog(c *ui.Context, log func(string)) { + s := retroState(c) + r := desk.RunDialog(c, "Run", &s.run, desk.RunDialogOptions{}) + if r.Changed { + log(fmt.Sprintf("%d letters typed", len([]rune(s.run)))) + } + if r.Submitted { + log(fmt.Sprintf("running a %d-letter program", len([]rune(s.run)))) + } + if r.Canceled { + log("canceled") + } +} + +func demoDiskDefrag(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := retroState(c) + ui.Column(c).Gap(k.D(4)).Children(func() { + ui.Box(c).Key(s.disks).Children(func() { + if desk.DiskDefrag(c, "Drive C:", s.defrag, desk.DiskDefragOptions{Seed: 7}).Done { + s.defrag = false + log("drive in order") + } + }) + ui.Row(c).Gap(k.D(4)).Children(func() { + label := "START" + if s.defrag { + label = "PAUSE" + } + if input.Button(c, label, input.ButtonOptions{Size: input.Small}).Clicked { + s.defrag = !s.defrag + log(fmt.Sprintf("defrag running %v", s.defrag)) + } + if input.Button(c, "NEW DISK", input.ButtonOptions{Size: input.Small}).Clicked { + s.disks, s.defrag = s.disks+1, false + log("a fresh mess") + } + }) + }) +} + +func demoWallpaperPicker(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := retroState(c) + ui.Row(c).Gap(k.D(8)).AlignItems(ui.Start).Children(func() { + if desk.WallpaperPicker(c, "Wallpaper", &s.paper, desk.WallpaperPickerOptions{}).Changed { + log(fmt.Sprintf("wallpaper %d", s.paper)) + } + desk.Desktop(c, "Preview", desk.DesktopOptions{Wallpaper: s.paper, Width: 120, Height: 90}, func() {}) + }) +} + +func demoClipboardPanel(c *ui.Context, log func(string)) { + clips := []string{"git push gitea main", "IT'S NOW SAFE TO TURN OFF", "UP UP DOWN DOWN LEFT RIGHT", "8bitmygoui.workers.dev"} + if i := desk.ClipboardPanel(c, "Clipboard", clips, desk.ClipboardPanelOptions{Width: 240}).Copied; i >= 0 { + log(fmt.Sprintf("copied clip %d", i+1)) + } +} + +func demoLockScreen(c *ui.Context, log func(string)) { + s := retroState(c) + if input.Button(c, "LOCK", input.ButtonOptions{}).Clicked { + s.locked = true + log("locked") + } + if desk.LockScreen(c, "Lock screen", &s.locked, desk.LockScreenOptions{}).Unlocked { + log("unlocked") + } +} diff --git a/gallery/desk_demo_test.go b/gallery/desk_demo_test.go new file mode 100644 index 0000000..e688fd8 --- /dev/null +++ b/gallery/desk_demo_test.go @@ -0,0 +1,43 @@ +package gallery + +import ( + "strings" + "testing" +) + +func TestDeskDemosLogTheirActions(t *testing.T) { + // Focusing, opening a menu or moving a light logs nothing. + quiet := map[string]bool{"key:Tab": true, "Start": true, "key:Up": true, "FILE": true, "OPEN:": true} + for _, tc := range []struct { + path string + steps []string + }{ + {"/c/466-desktop", []string{"MY COMPUTER"}}, {"/c/473-minesweeper", []string{"NEW GAME"}}, + {"/c/479-snake", []string{"Snake pit"}}, {"/c/481-rundialog", []string{"OPEN:", "type:ls"}}, + {"/c/467-desktopicon", []string{"MY COMPUTER", "key:Enter"}}, {"/c/468-taskbar", []string{"ONLINE"}}, + {"/c/469-startmenu", []string{"Start", "key:Up", "key:Enter"}}, {"/c/470-appwindow", []string{"CLOSE", "REOPEN"}}, + {"/c/471-calculator", []string{"Pocket calculator"}}, {"/c/472-notepad", []string{"FILE", "SAVE"}}, + {"/c/474-controlpanel", []string{"Control panel"}}, {"/c/475-trayicons", []string{"ONLINE"}}, + {"/c/477-bootscreen", []string{"REBOOT"}}, {"/c/478-screensaver", []string{"START SAVER", "key:A"}}, + {"/c/480-dock", []string{"MAIL"}}, {"/c/481-rundialog", []string{"OK", "CANCEL"}}, + {"/c/482-diskdefrag", []string{"START", "NEW DISK"}}, {"/c/483-wallpaperpicker", []string{"Wallpaper"}}, + {"/c/484-clipboardpanel", []string{"git push gitea main"}}, {"/c/485-lockscreen", []string{"LOCK", "key:A"}}, + } { + 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]) + tt.Frame() + } else if s, ok := strings.CutPrefix(step, "type:"); ok { + tt.Type(s) + } 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/registry.go b/gallery/registry.go index 1afae92..7f1408f 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, filesEntries, storageEntries, projectEntries, teamEntries, shopEntries, marketEntries, agentEntries, guildEntries) +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, projectEntries, teamEntries, shopEntries, marketEntries, agentEntries, guildEntries, deskEntries, retroEntries) func Entries() []Entry { return entries } diff --git a/icon/icon.go b/icon/icon.go index 61fa3f9..c6e7c47 100644 --- a/icon/icon.go +++ b/icon/icon.go @@ -31,7 +31,7 @@ var All = []Named{ {"heart", Heart}, {"heart-empty", HeartEmpty}, {"star", Star}, {"star-empty", StarEmpty}, {"sparkle", Sparkle}, {"volume", Volume}, {"mute", Mute}, {"play", Play}, {"pause", Pause}, {"stop", Stop}, {"next", Next}, {"prev", Prev}, - {"coin", Coin}, {"gem", Gem}, {"crystal", Crystal}, {"potion", Potion}, {"sword", Sword}, + {"coin", Coin}, {"gem", Gem}, {"crystal", Crystal}, {"bomb", Bomb}, {"potion", Potion}, {"sword", Sword}, {"shield", Shield}, {"chest", Chest}, {"crown", Crown}, {"trophy", Trophy}, {"skull", Skull}, {"scroll", Scroll}, {"flame", Flame}, {"drop", Drop}, {"leaf", Leaf}, {"flower", Flower}, {"sun", Sun}, {"moon", Moon}, diff --git a/icon/items.go b/icon/items.go index 4f5c7d2..9334a05 100644 --- a/icon/items.go +++ b/icon/items.go @@ -43,6 +43,19 @@ var ( ...e#lsse... ....e#se.... .....ee.....`) + Bomb = pixel.MustSprite(` + ......#.#... + .......#.... + ......e..... + .....e...... + ...eeeee.... + ..ekkkkke... + .ekllkkkke.. + .eklkkkkke.. + .ekkkkkkke.. + ..ekkkkke... + ...eeeee.... + ............`) Potion = pixel.MustSprite(` ....eeee.... ....eeee.... diff --git a/pixel/messages.go b/pixel/messages.go index 9ba417d..62bac06 100644 --- a/pixel/messages.go +++ b/pixel/messages.go @@ -65,6 +65,12 @@ var messages = map[Locale]map[string]string{ "danger": "DANGER!", "log_thought": "%s ponders: %s", "log_cast": "%s casts %s!", "log_hit": "A hit! %s", "log_miss": "A miss! %s", "log_said": "%s: %s", "empty_crystal": "EMPTY CRYSTAL", "files_changed": "FILES CHANGED: %d", "speed": "SPD", "power": "POW", "cost": "COST", "cast": "CAST", "restore": "RESTORE", + "paper_checks": "CHECKS", "paper_bricks": "BRICKS", "paper_weave": "WEAVE", "paper_dots": "DOTS", "paper_waves": "WAVES", + "paper_fade": "FADE", "arrows_move": "ARROWS MOVE IT", "menu_bar": "MENU", "file_menu": "FILE", "edit_menu": "EDIT", + "new_file": "NEW", "save": "SAVE", "time_date": "TIME/DATE", "open_windows": "OPEN WINDOWS", "tray": "TRAY", + "run": "RUN", "run_prompt": "TYPE A PROGRAM'S NAME, AND IT WILL OPEN.", "open_label": "OPEN:", + "safe_to_turn_off": "IT'S NOW SAFE TO TURN OFF YOUR COMPUTER.", "memory_test": "MEMORY TEST: %dK", + "press_any_key": "PRESS ANY KEY", "minefield": "MINEFIELD", "flag": "FLAG", "mine": "MINE", "percent_done": "%d%% DONE", }, Zh: { "ok": "确定", "cancel": "取消", "yes": "是", "no": "否", "close": "关闭", @@ -128,6 +134,12 @@ var messages = map[Locale]map[string]string{ "danger": "危险!", "log_thought": "%s沉思:%s", "log_cast": "%s释放了%s!", "log_hit": "命中!%s", "log_miss": "落空!%s", "log_said": "%s:%s", "empty_crystal": "空水晶", "files_changed": "改动文件:%d", "speed": "速度", "power": "力量", "cost": "花费", "cast": "释放", "restore": "读档", + "paper_checks": "格子", "paper_bricks": "砖墙", "paper_weave": "编织", "paper_dots": "圆点", "paper_waves": "波浪", + "paper_fade": "渐变", "arrows_move": "方向键移动", "menu_bar": "菜单", "file_menu": "文件", "edit_menu": "编辑", + "new_file": "新建", "save": "保存", "time_date": "时间/日期", "open_windows": "已开程序", "tray": "托盘", + "run": "运行", "run_prompt": "输入程序的名称,就能打开它。", "open_label": "打开:", + "safe_to_turn_off": "现在可以安全地关闭计算机了。", "memory_test": "内存测试:%dK", + "press_any_key": "按任意键", "minefield": "雷区", "flag": "旗子", "mine": "地雷", "percent_done": "已完成 %d%%", }, }