Add desk components 466-485 and icon.Bomb

Twenty pieces of a retro desktop: Desktop, DesktopIcon, Taskbar,
StartMenu, AppWindow, Calculator, Notepad, Minesweeper, ControlPanel,
TrayIcons, ShutdownScreen, BootScreen, Screensaver, Snake, Dock,
RunDialog, DiskDefrag, WallpaperPicker, ClipboardPanel and LockScreen.

The calculator really adds up and calls division by zero GAME OVER;
Minesweeper and Snake are playable, first click always safe; START
cascades its submenus without moving the rows already open; windows
drag by whole dots; the screensaver's logo changes color at each wall
and sparkles in a corner; the defragmenter is oddly soothing. Clocks
that step back hold at zero, and typed or copied text never reaches
the log.

icon.Bomb is the new 12x12 mine.
This commit is contained in:
ZacharyZhang-NY committed 2026-10-12 13:13:44 -04:00
1 parent d60d53ef90
commit 7800003418
39 files changed
+4120 -2

No files matched your search

+184
View File
@@ -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", &note, 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")
}
}
+109
View File
@@ -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
}
+80
View File
@@ -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
}
+180
View File
@@ -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
}
+88
View File
@@ -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
}
+73
View File
@@ -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
}
+124
View File
@@ -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
}
+24
View File
@@ -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)
}
+50
View File
@@ -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
}
+157
View File
@@ -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
}
+96
View File
@@ -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
}
+95
View File
@@ -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)
}
}
}
+146
View File
@@ -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)
}
}
}
+50
View File
@@ -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}
}
+91
View File
@@ -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)
}
}
+322
View File
@@ -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...`)
)
+52
View File
@@ -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
}
+145
View File
@@ -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")
}
}
+90
View File
@@ -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())
}
}
+59
View File
@@ -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")
}
}
+84
View File
@@ -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
}
+109
View File
@@ -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)
}
}
+80
View File
@@ -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
}
+284
View File
@@ -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)
}
}
}
+33
View File
@@ -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)
}
})
}
+190
View File
@@ -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
}
+264
View File
@@ -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)
}
})
}
+120
View File
@@ -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
}
+69
View File
@@ -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
}
+57
View File
@@ -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
}
+135
View File
@@ -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
}
+64
View File
@@ -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
}
+186
View File
@@ -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)
}
}
+160
View File
@@ -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")
}
}
+43
View File
@@ -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])
}
}
}
}
+1 -1
View File
@@ -56,7 +56,7 @@ var Categories = []Category{
{"tabletop", "Tabletop", "桌游", icon.Crown},
}
var entries = slices.Concat(foundationEntries, layoutEntries, textEntries, displayEntries, inputEntries, controlEntries, feedbackEntries, navigationEntries, dataEntries, recordEntries, overlayEntries, popupEntries, datetimeEntries, timeEntries, chartEntries, plotEntries, gameEntries, rpgEntries, screenEntries, lootEntries, fxEntries, screenFxEntries, chatEntries, mailEntries, mediaEntries, studioEntries, codeEntries, debugEntries, accountEntries, playerEntries, 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 }
+1 -1
View File
@@ -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},
+13
View File
@@ -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....
+12
View File
@@ -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%%",
},
}