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:
39 files changed
+4120
-2
No files matched your search
@@ -0,0 +1,184 @@
|
||||
package desk_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/desk"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
func TestAppWindowDragsArrowsAndStaysInside(t *testing.T) {
|
||||
x, y, closed, off := 10, 10, 0, false
|
||||
tt := view(700, 500, newWorld(), func(c *ui.Context) {
|
||||
ui.Box(c).Size(640, 400).Children(func() {
|
||||
r := desk.AppWindow(c, "WIN", &x, &y, desk.AppWindowOptions{Width: 100, Height: 40, Room: [2]int{320, 200}, Disabled: off}, func() {})
|
||||
if r.Closed {
|
||||
closed++
|
||||
}
|
||||
})
|
||||
})
|
||||
b := find(t, tt, "WIN")
|
||||
// The title bar drags the window by whole dots.
|
||||
tt.Press(b.X+20, b.Y+10)
|
||||
tt.Move(b.X+20+41, b.Y+10+20)
|
||||
tt.Release(b.X+20+41, b.Y+10+20)
|
||||
if x != 30 || y != 20 {
|
||||
t.Fatalf("a 41×20 DIP drag left it at %d, %d", x, y)
|
||||
}
|
||||
tt.Key(0, ui.KeyRight)
|
||||
tt.Key(0, ui.KeyDown)
|
||||
if x != 34 || y != 24 {
|
||||
t.Fatalf("arrows left it at %d, %d", x, y)
|
||||
}
|
||||
// It stays in its room, title bar and all.
|
||||
for range 100 {
|
||||
tt.Key(0, ui.KeyRight)
|
||||
}
|
||||
if x != 320-100-6 {
|
||||
t.Fatalf("far right is %d", x)
|
||||
}
|
||||
// On the bar, a key sent while turning off fails.
|
||||
click(t, tt, "CLOSE")
|
||||
tt.Key(ui.Shift, ui.KeyTab)
|
||||
if !tt.Focused("WIN") {
|
||||
t.Fatal("Shift+Tab did not reach the title bar")
|
||||
}
|
||||
off = true
|
||||
tt.Key(0, ui.KeyLeft)
|
||||
if closed != 1 || x != 214 {
|
||||
t.Fatalf("closed %d; a disabled window moved to %d", closed, x)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculatorAddsUp(t *testing.T) {
|
||||
tt := view(400, 300, newWorld(), func(c *ui.Context) { desk.Calculator(c, "Calc", desk.CalculatorOptions{}) })
|
||||
tt.Key(0, ui.KeyTab)
|
||||
press := func(keys string) {
|
||||
for _, r := range keys {
|
||||
switch r {
|
||||
case '+':
|
||||
tt.Key(ui.Shift, ui.KeyEqual)
|
||||
case '*':
|
||||
tt.Key(ui.Shift, ui.Key8)
|
||||
case '-':
|
||||
tt.Key(0, ui.KeyMinus)
|
||||
case '/':
|
||||
tt.Key(0, ui.KeySlash)
|
||||
case '=':
|
||||
tt.Key(0, ui.KeyEqual)
|
||||
case 'C':
|
||||
tt.Key(0, ui.KeyC)
|
||||
default:
|
||||
tt.Key(0, ui.Key0+ui.Key(r-'0'))
|
||||
}
|
||||
}
|
||||
}
|
||||
for keys, want := range map[string]string{
|
||||
"12+30=": "42", "7/2=": "3.5", "2*3-1=": "5", "100+0=": "100", "1/3=": "0.3333333333", "0-5=": "-5", "5*0=": "0",
|
||||
"1/0=": "GAME OVER", "999999*999999=": "999998000001", "9999999*999999=": "GAME OVER", "100000000000+0=": "100000000000",
|
||||
} {
|
||||
press("C" + keys)
|
||||
if _, ok := tt.Find(want); !ok {
|
||||
t.Errorf("%s gave %q, want %q", keys, tt.Texts(), want)
|
||||
}
|
||||
}
|
||||
// After GAME OVER only C continues.
|
||||
press("C1/0=5")
|
||||
find(t, tt, "GAME OVER")
|
||||
press("C")
|
||||
find(t, tt, "0")
|
||||
// The pad's keys work by arrows and Enter too.
|
||||
tt.Key(0, ui.KeyHome)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
find(t, tt, "7")
|
||||
}
|
||||
|
||||
func TestNotepadMenus(t *testing.T) {
|
||||
note, saved := "hi", 0
|
||||
tt := view(600, 400, newWorld(), func(c *ui.Context) {
|
||||
if desk.Notepad(c, "Pad", ¬e, desk.NotepadOptions{}).Saved {
|
||||
saved++
|
||||
}
|
||||
})
|
||||
click(t, tt, "EDIT")
|
||||
click(t, tt, "TIME/DATE")
|
||||
if note != "hi10:30 2026-09-02" {
|
||||
t.Fatalf("TIME/DATE gave %q", note)
|
||||
}
|
||||
click(t, tt, "FILE")
|
||||
click(t, tt, "SAVE")
|
||||
click(t, tt, "FILE")
|
||||
click(t, tt, "NEW")
|
||||
if saved != 1 || note != "" {
|
||||
t.Fatalf("saved %d, note %q", saved, note)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDialogRunsOrQuits(t *testing.T) {
|
||||
wd := newWorld()
|
||||
run, ran, quit := "", 0, 0
|
||||
tt := view(600, 300, wd, func(c *ui.Context) {
|
||||
r := desk.RunDialog(c, "Run", &run, desk.RunDialogOptions{})
|
||||
if r.Submitted {
|
||||
ran++
|
||||
}
|
||||
if r.Canceled {
|
||||
quit++
|
||||
}
|
||||
})
|
||||
click(t, tt, "OK")
|
||||
if ran != 0 {
|
||||
t.Fatal("a blank name ran")
|
||||
}
|
||||
click(t, tt, "OPEN:")
|
||||
log := logged(func() {
|
||||
tt.Type("snake")
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
})
|
||||
if ran != 1 || strings.Contains(log, "snake") || !strings.Contains(log, "runes=5") {
|
||||
t.Fatalf("ran %d; log:\n%s", ran, log)
|
||||
}
|
||||
click(t, tt, "OK")
|
||||
tt.Key(0, ui.KeyEscape)
|
||||
click(t, tt, "CANCEL")
|
||||
click(t, tt, "CLOSE")
|
||||
if ran != 2 || quit != 3 {
|
||||
t.Fatalf("ran %d, quit %d", ran, quit)
|
||||
}
|
||||
// Veiled, Enter in the field runs nothing.
|
||||
click(t, tt, "OPEN:")
|
||||
wd.inert = true
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if ran != 2 {
|
||||
t.Fatal("a veiled dialog ran")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClipboardPanelCopiesAndKeepsFocus(t *testing.T) {
|
||||
wd := newWorld()
|
||||
clips, copied := []string{"alpha", "beta secret"}, -1
|
||||
tt := view(600, 300, wd, func(c *ui.Context) {
|
||||
if i := desk.ClipboardPanel(c, "Clips", clips, desk.ClipboardPanelOptions{}).Copied; i >= 0 {
|
||||
copied = i
|
||||
}
|
||||
})
|
||||
log := logged(func() { click(t, tt, "beta secret") })
|
||||
x, y, w, h := dots(find(t, tt, "beta secret"))
|
||||
if copied != 1 || tt.Clipboard() != "beta secret" || strings.Contains(log, "secret") || count(tt.Image(), x, y, w, h, pixel.Good) == 0 {
|
||||
t.Fatalf("copied %d, clipboard %q, no COPIED!, or log:\n%s", copied, tt.Clipboard(), log)
|
||||
}
|
||||
// A new clip on top keeps the focus on beta.
|
||||
clips = append([]string{"gamma"}, clips...)
|
||||
tt.Frame()
|
||||
if !tt.Focused("beta secret") {
|
||||
t.Fatal("the focus left beta")
|
||||
}
|
||||
later(tt, wd, 3e9)
|
||||
x, y, w, h = dots(find(t, tt, "beta secret"))
|
||||
if count(tt.Image(), x, y, w, h, pixel.Good) > 0 {
|
||||
t.Fatal("COPIED! outstayed two seconds")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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...`)
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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},
|
||||
|
||||
@@ -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....
|
||||
|
||||
@@ -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%%",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user