emulator: real gui — lift upstream v1.3.0 gui/ + wire Platform

The Phase 2 scaffolding boot screen is replaced with the actual
upstream v1.3.0 gui package running in browser. cmd/emulator now
calls gui.NewApp(plat, version) + drives a.Frame() in a goroutine.

Lifts:
  gui/                         (top-level UI + state machine)
  gui/assets                   (icons, fonts, 9-patch images)
  gui/layout                   (constraint-based UI layout)
  gui/op                       (drawing op primitives)
  gui/saver                    (screensaver + idle timeout)
  gui/text                     (text shaping)
  gui/widget                   (button/menu/keyboard widgets)
All 11 .go files lifted verbatim from seedhammer/seedhammer @ v1.3.0
(commit 2f071c1d...), import paths rewritten seedhammer.com → mineracks.

browserPlatform now implements gui.Platform (12 methods):
  Events(deadline)   — drains v1.Event chan, maps to gui.ButtonEvent
                       (gui.Button enum order matches platform/v1.Button,
                        so the conversion is a direct uint cast)
  Wakeup()           — no-op (no sleep state in browser)
  PlateSizes()       — backup.SquarePlate, backup.LargePlate
  Engraver()         — nullEngraver stub (browser can't punch metal)
  EngraverParams()   — copy of mjolnir.Params {StrokeWidth: 38,
                       Millimeter: 126}. Inlined because tarm/serial
                       (mjolnir's USB dep) doesn't compile to js/wasm.
  CameraFrame(size)  — emits FrameEvent{Error: stubbed} so QR-scan
                       screens fall through cleanly; real handoff
                       lands when SeedSigner sim wires up (Phase 2.5)
  Now()              — time.Now()
  DisplaySize()      — 240×240
  Dirty(r)           — records rect, resets chunk cursor
  NextChunk()        — one-shot: returns full sub-image once per
                       Dirty cycle, then flushes the whole frame
                       buffer to JS via emulatorPaint()
  ScanQR()           — returns nil decodes (stub)
  Debug()            — false

The Engraver interface is satisfied by nullEngraver — Engrave() returns
"engraver not connected" so the GUI's engrave flow fails cleanly
instead of looking like it's working.

WASM grows 2.7MB → 8.0MB (gui package + btcd + crypto deps + fonts
all linked in). Acceptable for the v1 emulator one-time cache.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
mineracks 2026-05-28 20:53:06 +10:00
parent c427c68359
commit 9675c05ff1
79 changed files with 5820 additions and 134 deletions

View File

@ -2,116 +2,250 @@
// Command emulator is a browser-based SeedHammer v1 firmware runner.
//
// Phase 2 scaffolding stage. This binary boots a 240×240 canvas-backed
// LCD mock and an 8-button input layer that maps keyboard events to the
// v1's joystick + 3 keys. The real firmware GUI lift (upstream's gui/
// package + its assets/layout/op/saver/text/widget subpackages) lands in
// a follow-up commit; today this stage proves the build pipeline + the
// platform/v1.Platform interface contract.
// Loads the upstream v1.3.0 gui package and drives it through a
// browser-side Platform implementation:
// - Display: a 240×240 canvas, painted from Go RGBA via JS callback
// - Input: keyboard + on-screen button events through gui.ButtonEvent
// - Engraver: a no-op stub (the browser doesn't drive real hardware)
// - Camera: stub that emits empty FrameEvents (real QR-scan handoff
// lands once the SeedSigner sim wiring lands in Phase 2.5)
//
// Build:
//
// GOOS=js GOARCH=wasm go build -o ./web/emulator/emulator.wasm ./cmd/emulator
//
// The static shell (HTML/CSS/JS) lives under web/emulator/. JS exports
// documented in web/emulator/app.js.
package main
import (
"fmt"
"errors"
"image"
"image/color"
"image/draw"
"strconv"
"sync"
"syscall/js"
"time"
"github.com/mineracks/seedhammer-v1-companion/platform/v1"
"github.com/mineracks/seedhammer-v1-companion/backup"
"github.com/mineracks/seedhammer-v1-companion/engrave"
"github.com/mineracks/seedhammer-v1-companion/gui"
v1 "github.com/mineracks/seedhammer-v1-companion/platform/v1"
)
const emulatorVersion = "v0.1-phase2-stub"
const emulatorVersion = "v0.2-phase2-gui"
// v1 hardware native LCD resolution.
const (
lcdWidth = 240
lcdHeight = 240
)
// browserPlatform implements v1.Platform against the JS host.
//
// Display() draws into an *image.RGBA buffer then ships it to JS via a
// Uint8ClampedArray that the JS shell paints onto a <canvas>. Events()
// returns a channel fed by exportPushEvent calls from JS.
// browserPlatform implements gui.Platform against the JS host.
type browserPlatform struct {
frame *image.RGBA
events chan v1.Event
mu sync.Mutex
pending []gui.Event
dirtyRect image.Rectangle
chunkSent bool
}
func newBrowserPlatform() *browserPlatform {
return &browserPlatform{
frame: image.NewRGBA(image.Rect(0, 0, lcdWidth, lcdHeight)),
events: make(chan v1.Event, 32),
events: make(chan v1.Event, 64),
}
}
func (p *browserPlatform) Events() <-chan v1.Event { return p.events }
// ─── gui.Platform impl ────────────────────────────────────────────────────
func (p *browserPlatform) Display(frame image.Image) {
draw.Draw(p.frame, p.frame.Bounds(), frame, frame.Bounds().Min, draw.Src)
// Convert RGBA buffer → JS Uint8ClampedArray and call back into JS.
func (p *browserPlatform) Events(deadline time.Time) []gui.Event {
// Drain the v1.Event channel into gui.ButtonEvents. If no events
// pending, block (briefly) waiting for one or until deadline.
wait := time.Until(deadline)
if wait < 0 {
wait = 0
}
out := p.drainPending()
if len(out) > 0 {
return out
}
if wait == 0 {
return nil
}
timer := time.NewTimer(wait)
defer timer.Stop()
select {
case ev := <-p.events:
out = append(out, p.toGuiEvent(ev))
case <-timer.C:
}
// Drain any extras that piled up while we were waiting.
for {
select {
case ev := <-p.events:
out = append(out, p.toGuiEvent(ev))
default:
return out
}
}
}
func (p *browserPlatform) drainPending() []gui.Event {
p.mu.Lock()
out := p.pending
p.pending = nil
p.mu.Unlock()
return out
}
func (p *browserPlatform) toGuiEvent(ev v1.Event) gui.Event {
return gui.ButtonEvent{
Button: gui.Button(ev.Button), // enum order matches by construction
Pressed: ev.Pressed,
}.Event()
}
// push is called from the JS bridge — feeds the buffered channel.
func (p *browserPlatform) push(button v1.Button, pressed bool) {
select {
case p.events <- v1.Event{Button: button, Pressed: pressed}:
default:
}
}
func (p *browserPlatform) Wakeup() {
// no-op — JS-driven runtime; nothing to wake from.
}
func (p *browserPlatform) PlateSizes() []backup.PlateSize {
// Mirror what's defined in backup.PlateSize. v1.3.0 ships
// SquarePlate and LargePlate.
return []backup.PlateSize{backup.SquarePlate, backup.LargePlate}
}
func (p *browserPlatform) Engraver() (gui.Engraver, error) {
// The browser can't engrave anything. Return an Engraver that
// politely says "no" if the GUI ever tries to drive it.
return nullEngraver{}, nil
}
func (p *browserPlatform) EngraverParams() engrave.Params {
// Values copied from upstream driver/mjolnir.Params at v1.3.0.
// We can't import the mjolnir package here because it transitively
// pulls in tarm/serial, which doesn't compile to GOOS=js (uses
// OS-specific syscalls). The layout math doesn't need the serial
// driver — just these two constants.
return engrave.Params{
StrokeWidth: 38,
Millimeter: 126,
}
}
func (p *browserPlatform) CameraFrame(size image.Point) {
// Stub: no camera in the browser yet. Emit an error FrameEvent so
// the gui's QR-scan screen stays in its "no camera" state instead
// of waiting forever.
p.mu.Lock()
p.pending = append(p.pending, gui.FrameEvent{Error: errCameraStubbed}.Event())
p.mu.Unlock()
}
func (p *browserPlatform) Now() time.Time { return time.Now() }
func (p *browserPlatform) DisplaySize() image.Point {
return image.Pt(lcdWidth, lcdHeight)
}
func (p *browserPlatform) Dirty(r image.Rectangle) error {
p.mu.Lock()
p.dirtyRect = r.Intersect(p.frame.Bounds())
p.chunkSent = false
p.mu.Unlock()
return nil
}
func (p *browserPlatform) NextChunk() (draw.RGBA64Image, bool) {
p.mu.Lock()
if p.chunkSent || p.dirtyRect.Empty() {
p.mu.Unlock()
// One-chunk model: we ship the whole frame to JS in a single
// JS callback when the gui completes a Dirty/NextChunk cycle.
p.flushFrame()
return nil, false
}
p.chunkSent = true
r := p.dirtyRect
p.mu.Unlock()
// gui writes into the returned RGBA64Image — sub-image of our frame
// for the dirty rect. Our buffer is RGBA which satisfies
// draw.RGBA64Image via the standard image package.
return p.frame.SubImage(r).(*image.RGBA), true
}
// flushFrame ships the current frame buffer to JS. Called after each
// Dirty/NextChunk render cycle.
func (p *browserPlatform) flushFrame() {
jsBuf := js.Global().Get("Uint8ClampedArray").New(len(p.frame.Pix))
js.CopyBytesToJS(jsBuf, p.frame.Pix)
js.Global().Call("emulatorPaint", jsBuf, lcdWidth, lcdHeight)
}
// Push an event from JS into the events channel.
func (p *browserPlatform) push(button v1.Button, pressed bool) {
select {
case p.events <- v1.Event{Button: button, Pressed: pressed}:
default:
// Drop on full — shouldn't happen with the modest buffer the
// firmware needs, but better than blocking the JS thread.
}
func (p *browserPlatform) ScanQR(qr *image.Gray) ([][]byte, error) {
// Stub: no decodes. Real implementation lands when SeedSigner sim
// handoff wires up — the mock camera reads a sibling pane's canvas.
return nil, nil
}
func (p *browserPlatform) Debug() bool { return false }
var errCameraStubbed = errors.New("camera not implemented in browser stub")
// ─── nullEngraver ─────────────────────────────────────────────────────────
type nullEngraver struct{}
func (nullEngraver) Engrave(_ backup.PlateSize, _ engrave.Plan, _ <-chan struct{}) error {
return errors.New("engraver not connected (browser emulator)")
}
func (nullEngraver) Close() {}
// ─── JS bridge ────────────────────────────────────────────────────────────
var plat *browserPlatform
func main() {
plat = newBrowserPlatform()
// Initial paint so the canvas isn't blank during gui bring-up.
clearBlack(plat.frame)
plat.flushFrame()
js.Global().Set("emulatorVersion", js.FuncOf(exportVersion))
js.Global().Set("emulatorPushEvent", js.FuncOf(exportPushEvent))
js.Global().Set("emulatorBootScreen", js.FuncOf(exportBootScreen))
js.Global().Set("emulatorLCDSize", js.ValueOf(map[string]any{
"w": lcdWidth,
"h": lcdHeight,
"w": lcdWidth, "h": lcdHeight,
}))
// Show the placeholder boot screen so the canvas isn't blank on load.
drawBootScreen(plat.frame)
plat.Display(plat.frame)
app, err := gui.NewApp(plat, emulatorVersion)
if err != nil {
js.Global().Get("console").Call("error", "gui.NewApp failed: "+err.Error())
select {}
}
// Future: wire plat to gui.Loop or whatever the lifted GUI exposes.
// For now, just consume events so the channel doesn't fill up.
// Drive frames in a goroutine. Each Frame call processes events
// and may render a new frame via Dirty + NextChunk.
go func() {
for ev := range plat.events {
// Echo to console for debugging; the real GUI will replace this.
js.Global().Get("console").Call("log",
fmt.Sprintf("emu: %s %s",
buttonName(ev.Button),
boolStr(ev.Pressed, "press", "release"),
),
)
for {
app.Frame()
}
}()
select {} // keep the runtime alive
select {}
}
func exportVersion(this js.Value, args []js.Value) any {
return emulatorVersion
}
// exportPushEvent: emulatorPushEvent(buttonId:number, pressed:boolean)
func exportPushEvent(this js.Value, args []js.Value) any {
if len(args) != 2 {
return nil
@ -125,66 +259,6 @@ func exportPushEvent(this js.Value, args []js.Value) any {
return nil
}
// exportBootScreen redraws the boot placeholder, useful for re-testing
// after manual mucking.
func exportBootScreen(this js.Value, args []js.Value) any {
drawBootScreen(plat.frame)
plat.Display(plat.frame)
return nil
}
// drawBootScreen fills the frame with a minimal welcome image so users see
// SOMETHING the moment the WASM finishes loading. Future: this is replaced
// by gui.Loop()'s first frame.
func drawBootScreen(dst *image.RGBA) {
// Background.
draw.Draw(dst, dst.Bounds(), &image.Uniform{color.RGBA{0x12, 0x12, 0x12, 0xff}}, image.Point{}, draw.Src)
// Frame border to make the LCD area unambiguous.
border := color.RGBA{0xff, 0x88, 0x00, 0xff} // accent orange
for x := 0; x < lcdWidth; x++ {
dst.SetRGBA(x, 0, border)
dst.SetRGBA(x, lcdHeight-1, border)
}
for y := 0; y < lcdHeight; y++ {
dst.SetRGBA(0, y, border)
dst.SetRGBA(lcdWidth-1, y, border)
}
// Eight tick marks around the perimeter to show the button positions.
// Just a visual cue that this is real hardware-resolution rendering.
tick := color.RGBA{0xaa, 0xaa, 0xaa, 0xff}
for i := 0; i < 16; i++ {
dst.SetRGBA(lcdWidth/2-1+i-8, lcdHeight/2, tick)
dst.SetRGBA(lcdWidth/2, lcdHeight/2-1+i-8, tick)
}
}
func buttonName(b v1.Button) string {
switch b {
case v1.ButtonUp:
return "Up"
case v1.ButtonDown:
return "Down"
case v1.ButtonLeft:
return "Left"
case v1.ButtonRight:
return "Right"
case v1.ButtonCenter:
return "Center"
case v1.Button1:
return "Button1"
case v1.Button2:
return "Button2"
case v1.Button3:
return "Button3"
}
return "btn-" + strconv.Itoa(int(b))
}
func boolStr(b bool, t, f string) string {
if b {
return t
}
return f
func clearBlack(dst *image.RGBA) {
draw.Draw(dst, dst.Bounds(), &image.Uniform{color.RGBA{0, 0, 0, 0xff}}, image.Point{}, draw.Src)
}

BIN
gui/assets/arrow-down.bin Normal file

Binary file not shown.

BIN
gui/assets/arrow-down.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 B

BIN
gui/assets/arrow-left.bin Normal file

Binary file not shown.

BIN
gui/assets/arrow-left.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 B

BIN
gui/assets/arrow-right.bin Normal file

Binary file not shown.

BIN
gui/assets/arrow-right.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 B

BIN
gui/assets/arrow-up.bin Normal file

Binary file not shown.

BIN
gui/assets/arrow-up.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 578 B

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 B

BIN
gui/assets/circle.bin Normal file

Binary file not shown.

BIN
gui/assets/circle.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 337 B

276
gui/assets/embed.go Normal file
View File

@ -0,0 +1,276 @@
// Code generated by gui/assets/gen.go; DO NOT EDIT.
package assets
import (
_ "embed"
"unsafe"
"github.com/mineracks/seedhammer-v1-companion/image/ninepatch"
"github.com/mineracks/seedhammer-v1-companion/image/paletted"
)
var (
ArrowDown = &paletted.Image{
Pix: unsafe.Slice(unsafe.StringData(ArrowDownData[:135]), len(ArrowDownData[:135])),
Rect: paletted.Rectangle{MinX: 0, MinY: 0, MaxX: 15, MaxY: 9},
Palette: paletted.Palette(unsafe.Slice(unsafe.StringData(ArrowDownData[135:]), len(ArrowDownData[135:]))),
}
//go:embed arrow-down.bin
ArrowDownData string
ArrowLeft = &paletted.Image{
Pix: unsafe.Slice(unsafe.StringData(ArrowLeftData[:135]), len(ArrowLeftData[:135])),
Rect: paletted.Rectangle{MinX: 0, MinY: 0, MaxX: 9, MaxY: 15},
Palette: paletted.Palette(unsafe.Slice(unsafe.StringData(ArrowLeftData[135:]), len(ArrowLeftData[135:]))),
}
//go:embed arrow-left.bin
ArrowLeftData string
ArrowRight = &paletted.Image{
Pix: unsafe.Slice(unsafe.StringData(ArrowRightData[:135]), len(ArrowRightData[:135])),
Rect: paletted.Rectangle{MinX: 0, MinY: 0, MaxX: 9, MaxY: 15},
Palette: paletted.Palette(unsafe.Slice(unsafe.StringData(ArrowRightData[135:]), len(ArrowRightData[135:]))),
}
//go:embed arrow-right.bin
ArrowRightData string
ArrowUp = &paletted.Image{
Pix: unsafe.Slice(unsafe.StringData(ArrowUpData[:135]), len(ArrowUpData[:135])),
Rect: paletted.Rectangle{MinX: 0, MinY: 0, MaxX: 15, MaxY: 9},
Palette: paletted.Palette(unsafe.Slice(unsafe.StringData(ArrowUpData[135:]), len(ArrowUpData[135:]))),
}
//go:embed arrow-up.bin
ArrowUpData string
ButtonFocused = ninepatch.New(&paletted.Image{
Pix: unsafe.Slice(unsafe.StringData(ButtonFocusedData[:850]), len(ButtonFocusedData[:850])),
Rect: paletted.Rectangle{MinX: 0, MinY: 0, MaxX: 34, MaxY: 25},
Palette: paletted.Palette(unsafe.Slice(unsafe.StringData(ButtonFocusedData[850:]), len(ButtonFocusedData[850:]))),
})
//go:embed button-focused.bin
ButtonFocusedData string
CameraCorners = ninepatch.New(&paletted.Image{
Pix: unsafe.Slice(unsafe.StringData(CameraCornersData[:3249]), len(CameraCornersData[:3249])),
Rect: paletted.Rectangle{MinX: 0, MinY: 0, MaxX: 57, MaxY: 57},
Palette: paletted.Palette(unsafe.Slice(unsafe.StringData(CameraCornersData[3249:]), len(CameraCornersData[3249:]))),
})
//go:embed camera-corners.bin
CameraCornersData string
CircleFilled = &paletted.Image{
Pix: unsafe.Slice(unsafe.StringData(CircleFilledData[:169]), len(CircleFilledData[:169])),
Rect: paletted.Rectangle{MinX: 0, MinY: 0, MaxX: 13, MaxY: 13},
Palette: paletted.Palette(unsafe.Slice(unsafe.StringData(CircleFilledData[169:]), len(CircleFilledData[169:]))),
}
//go:embed circle-filled.bin
CircleFilledData string
Circle = &paletted.Image{
Pix: unsafe.Slice(unsafe.StringData(CircleData[:169]), len(CircleData[:169])),
Rect: paletted.Rectangle{MinX: 0, MinY: 0, MaxX: 13, MaxY: 13},
Palette: paletted.Palette(unsafe.Slice(unsafe.StringData(CircleData[169:]), len(CircleData[169:]))),
}
//go:embed circle.bin
CircleData string
Hammer = &paletted.Image{
Pix: unsafe.Slice(unsafe.StringData(HammerData[:14760]), len(HammerData[:14760])),
Rect: paletted.Rectangle{MinX: 0, MinY: 0, MaxX: 120, MaxY: 123},
Palette: paletted.Palette(unsafe.Slice(unsafe.StringData(HammerData[14760:]), len(HammerData[14760:]))),
}
//go:embed hammer.bin
HammerData string
IconBack = &paletted.Image{
Pix: unsafe.Slice(unsafe.StringData(IconBackData[:270]), len(IconBackData[:270])),
Rect: paletted.Rectangle{MinX: 8, MinY: 10, MaxX: 26, MaxY: 25},
Palette: paletted.Palette(unsafe.Slice(unsafe.StringData(IconBackData[270:]), len(IconBackData[270:]))),
}
//go:embed icon-back.bin
IconBackData string
IconBackspace = &paletted.Image{
Pix: unsafe.Slice(unsafe.StringData(IconBackspaceData[:260]), len(IconBackspaceData[:260])),
Rect: paletted.Rectangle{MinX: 7, MinY: 11, MaxX: 27, MaxY: 24},
Palette: paletted.Palette(unsafe.Slice(unsafe.StringData(IconBackspaceData[260:]), len(IconBackspaceData[260:]))),
}
//go:embed icon-backspace.bin
IconBackspaceData string
IconCheckmark = &paletted.Image{
Pix: unsafe.Slice(unsafe.StringData(IconCheckmarkData[:414]), len(IconCheckmarkData[:414])),
Rect: paletted.Rectangle{MinX: 6, MinY: 8, MaxX: 29, MaxY: 26},
Palette: paletted.Palette(unsafe.Slice(unsafe.StringData(IconCheckmarkData[414:]), len(IconCheckmarkData[414:]))),
}
//go:embed icon-checkmark.bin
IconCheckmarkData string
IconDiscard = &paletted.Image{
Pix: unsafe.Slice(unsafe.StringData(IconDiscardData[:460]), len(IconDiscardData[:460])),
Rect: paletted.Rectangle{MinX: 7, MinY: 6, MaxX: 27, MaxY: 29},
Palette: paletted.Palette(unsafe.Slice(unsafe.StringData(IconDiscardData[460:]), len(IconDiscardData[460:]))),
}
//go:embed icon-discard.bin
IconDiscardData string
IconDot = &paletted.Image{
Pix: unsafe.Slice(unsafe.StringData(IconDotData[:121]), len(IconDotData[:121])),
Rect: paletted.Rectangle{MinX: 12, MinY: 12, MaxX: 23, MaxY: 23},
Palette: paletted.Palette(unsafe.Slice(unsafe.StringData(IconDotData[121:]), len(IconDotData[121:]))),
}
//go:embed icon-dot.bin
IconDotData string
IconEdit = &paletted.Image{
Pix: unsafe.Slice(unsafe.StringData(IconEditData[:441]), len(IconEditData[:441])),
Rect: paletted.Rectangle{MinX: 7, MinY: 7, MaxX: 28, MaxY: 28},
Palette: paletted.Palette(unsafe.Slice(unsafe.StringData(IconEditData[441:]), len(IconEditData[441:]))),
}
//go:embed icon-edit.bin
IconEditData string
IconFlip = &paletted.Image{
Pix: unsafe.Slice(unsafe.StringData(IconFlipData[:360]), len(IconFlipData[:360])),
Rect: paletted.Rectangle{MinX: 8, MinY: 9, MaxX: 28, MaxY: 27},
Palette: paletted.Palette(unsafe.Slice(unsafe.StringData(IconFlipData[360:]), len(IconFlipData[360:]))),
}
//go:embed icon-flip.bin
IconFlipData string
IconHammer = &paletted.Image{
Pix: unsafe.Slice(unsafe.StringData(IconHammerData[:506]), len(IconHammerData[:506])),
Rect: paletted.Rectangle{MinX: 6, MinY: 7, MaxX: 29, MaxY: 29},
Palette: paletted.Palette(unsafe.Slice(unsafe.StringData(IconHammerData[506:]), len(IconHammerData[506:]))),
}
//go:embed icon-hammer.bin
IconHammerData string
IconInfo = &paletted.Image{
Pix: unsafe.Slice(unsafe.StringData(IconInfoData[:152]), len(IconInfoData[:152])),
Rect: paletted.Rectangle{MinX: 14, MinY: 8, MaxX: 22, MaxY: 27},
Palette: paletted.Palette(unsafe.Slice(unsafe.StringData(IconInfoData[152:]), len(IconInfoData[152:]))),
}
//go:embed icon-info.bin
IconInfoData string
IconLeft = &paletted.Image{
Pix: unsafe.Slice(unsafe.StringData(IconLeftData[:270]), len(IconLeftData[:270])),
Rect: paletted.Rectangle{MinX: 8, MinY: 10, MaxX: 26, MaxY: 25},
Palette: paletted.Palette(unsafe.Slice(unsafe.StringData(IconLeftData[270:]), len(IconLeftData[270:]))),
}
//go:embed icon-left.bin
IconLeftData string
IconProgress = &paletted.Image{
Pix: unsafe.Slice(unsafe.StringData(IconProgressData[:529]), len(IconProgressData[:529])),
Rect: paletted.Rectangle{MinX: 6, MinY: 6, MaxX: 29, MaxY: 29},
Palette: paletted.Palette(unsafe.Slice(unsafe.StringData(IconProgressData[529:]), len(IconProgressData[529:]))),
}
//go:embed icon-progress.bin
IconProgressData string
IconRight = &paletted.Image{
Pix: unsafe.Slice(unsafe.StringData(IconRightData[:270]), len(IconRightData[:270])),
Rect: paletted.Rectangle{MinX: 9, MinY: 10, MaxX: 27, MaxY: 25},
Palette: paletted.Palette(unsafe.Slice(unsafe.StringData(IconRightData[270:]), len(IconRightData[270:]))),
}
//go:embed icon-right.bin
IconRightData string
IconSkip = &paletted.Image{
Pix: unsafe.Slice(unsafe.StringData(IconSkipData[:270]), len(IconSkipData[:270])),
Rect: paletted.Rectangle{MinX: 8, MinY: 10, MaxX: 26, MaxY: 25},
Palette: paletted.Palette(unsafe.Slice(unsafe.StringData(IconSkipData[270:]), len(IconSkipData[270:]))),
}
//go:embed icon-skip.bin
IconSkipData string
KeyActive = ninepatch.New(&paletted.Image{
Pix: unsafe.Slice(unsafe.StringData(KeyActiveData[:112]), len(KeyActiveData[:112])),
Rect: paletted.Rectangle{MinX: 0, MinY: 0, MaxX: 8, MaxY: 14},
Palette: paletted.Palette(unsafe.Slice(unsafe.StringData(KeyActiveData[112:]), len(KeyActiveData[112:]))),
})
//go:embed key-active.bin
KeyActiveData string
KeyBackspace = &paletted.Image{
Pix: unsafe.Slice(unsafe.StringData(KeyBackspaceData[:187]), len(KeyBackspaceData[:187])),
Rect: paletted.Rectangle{MinX: 2, MinY: 0, MaxX: 19, MaxY: 11},
Palette: paletted.Palette(unsafe.Slice(unsafe.StringData(KeyBackspaceData[187:]), len(KeyBackspaceData[187:]))),
}
//go:embed key-backspace.bin
KeyBackspaceData string
Key = ninepatch.New(&paletted.Image{
Pix: unsafe.Slice(unsafe.StringData(KeyData[:112]), len(KeyData[:112])),
Rect: paletted.Rectangle{MinX: 0, MinY: 0, MaxX: 8, MaxY: 14},
Palette: paletted.Palette(unsafe.Slice(unsafe.StringData(KeyData[112:]), len(KeyData[112:]))),
})
//go:embed key.bin
KeyData string
LogoSmall = &paletted.Image{
Pix: unsafe.Slice(unsafe.StringData(LogoSmallData[:441]), len(LogoSmallData[:441])),
Rect: paletted.Rectangle{MinX: 0, MinY: 0, MaxX: 21, MaxY: 21},
Palette: paletted.Palette(unsafe.Slice(unsafe.StringData(LogoSmallData[441:]), len(LogoSmallData[441:]))),
}
//go:embed logo-small.bin
LogoSmallData string
NavBtnPrimary = &paletted.Image{
Pix: unsafe.Slice(unsafe.StringData(NavBtnPrimaryData[:1225]), len(NavBtnPrimaryData[:1225])),
Rect: paletted.Rectangle{MinX: 0, MinY: 0, MaxX: 35, MaxY: 35},
Palette: paletted.Palette(unsafe.Slice(unsafe.StringData(NavBtnPrimaryData[1225:]), len(NavBtnPrimaryData[1225:]))),
}
//go:embed nav-btn-primary.bin
NavBtnPrimaryData string
NavBtnSecondary = &paletted.Image{
Pix: unsafe.Slice(unsafe.StringData(NavBtnSecondaryData[:1225]), len(NavBtnSecondaryData[:1225])),
Rect: paletted.Rectangle{MinX: 0, MinY: 0, MaxX: 35, MaxY: 35},
Palette: paletted.Palette(unsafe.Slice(unsafe.StringData(NavBtnSecondaryData[1225:]), len(NavBtnSecondaryData[1225:]))),
}
//go:embed nav-btn-secondary.bin
NavBtnSecondaryData string
ProgressCircle = &paletted.Image{
Pix: unsafe.Slice(unsafe.StringData(ProgressCircleData[:16384]), len(ProgressCircleData[:16384])),
Rect: paletted.Rectangle{MinX: 0, MinY: 0, MaxX: 128, MaxY: 128},
Palette: paletted.Palette(unsafe.Slice(unsafe.StringData(ProgressCircleData[16384:]), len(ProgressCircleData[16384:]))),
}
//go:embed progress-circle.bin
ProgressCircleData string
Sh02 = &paletted.Image{
Pix: unsafe.Slice(unsafe.StringData(Sh02Data[:5720]), len(Sh02Data[:5720])),
Rect: paletted.Rectangle{MinX: 1, MinY: 0, MaxX: 89, MaxY: 65},
Palette: paletted.Palette(unsafe.Slice(unsafe.StringData(Sh02Data[5720:]), len(Sh02Data[5720:]))),
}
//go:embed sh02.bin
Sh02Data string
Sh03 = &paletted.Image{
Pix: unsafe.Slice(unsafe.StringData(Sh03Data[:5720]), len(Sh03Data[:5720])),
Rect: paletted.Rectangle{MinX: 1, MinY: 0, MaxX: 89, MaxY: 65},
Palette: paletted.Palette(unsafe.Slice(unsafe.StringData(Sh03Data[5720:]), len(Sh03Data[5720:]))),
}
//go:embed sh03.bin
Sh03Data string
WarningBoxBg = ninepatch.New(&paletted.Image{
Pix: unsafe.Slice(unsafe.StringData(WarningBoxBgData[:405]), len(WarningBoxBgData[:405])),
Rect: paletted.Rectangle{MinX: 0, MinY: 0, MaxX: 27, MaxY: 15},
Palette: paletted.Palette(unsafe.Slice(unsafe.StringData(WarningBoxBgData[405:]), len(WarningBoxBgData[405:]))),
})
//go:embed warning-box-bg.bin
WarningBoxBgData string
WarningBoxBorder = ninepatch.New(&paletted.Image{
Pix: unsafe.Slice(unsafe.StringData(WarningBoxBorderData[:405]), len(WarningBoxBorderData[:405])),
Rect: paletted.Rectangle{MinX: 0, MinY: 0, MaxX: 27, MaxY: 15},
Palette: paletted.Palette(unsafe.Slice(unsafe.StringData(WarningBoxBorderData[405:]), len(WarningBoxBorderData[405:]))),
})
//go:embed warning-box-border.bin
WarningBoxBorderData string
)

3
gui/assets/gen.go Normal file
View File

@ -0,0 +1,3 @@
package assets
//go:generate go run generator.go

151
gui/assets/generator.go Normal file
View File

@ -0,0 +1,151 @@
//go:build ignore
// gen converts the PNG assets to embedded image literals.
package main
import (
"bytes"
"fmt"
"go/format"
"image"
"image/color"
"image/draw"
_ "image/png"
"log"
"os"
"path/filepath"
"strings"
"unicode"
simage "github.com/mineracks/seedhammer-v1-companion/image"
"github.com/mineracks/seedhammer-v1-companion/image/rgb565"
)
func main() {
pngs, err := filepath.Glob("*.png")
if err != nil {
log.Fatal(err)
}
// out is the generated embed.go file.
out := new(bytes.Buffer)
// data is the binary embed.bin containing image data.
fmt.Fprintf(out, "// Code generated by gui/assets/gen.go; DO NOT EDIT.\n")
fmt.Fprintf(out, "package assets\n\n")
fmt.Fprintf(out, "import (\n")
fmt.Fprintf(out, "_ \"embed\"\n")
fmt.Fprintf(out, "\"unsafe\"\n\n")
fmt.Fprintf(out, "\"github.com/mineracks/seedhammer-v1-companion/image/ninepatch\"\n")
fmt.Fprintf(out, "\"github.com/mineracks/seedhammer-v1-companion/image/paletted\"\n\n")
fmt.Fprintf(out, ")\n\n")
fmt.Fprintf(out, "var (\n")
for _, p := range pngs {
r, err := os.Open(p)
if err != nil {
log.Fatal(err)
}
img, _, err := image.Decode(r)
r.Close()
if err != nil {
log.Fatal(err)
}
// Convert RGBA images to image.Paletted.
if nrgba, ok := img.(*image.NRGBA); ok {
// Convert to alpha pre-multiplied RGBA.
rgba := image.NewRGBA(nrgba.Bounds())
draw.Draw(rgba, rgba.Bounds(), nrgba, nrgba.Bounds().Min, draw.Src)
paletteMap := make(map[color.RGBA64]uint8)
b := rgba.Bounds()
index := uint8(0)
for y := b.Min.Y; y < b.Max.Y; y++ {
for x := b.Min.X; x < b.Max.X; x++ {
c := rgba.RGBA64At(x, y)
idx, ok := paletteMap[c]
if !ok {
idx = index
paletteMap[c] = idx
index++
if index < idx {
log.Fatalf("too many colors in %q:", p)
}
}
}
}
palette := make([]color.Color, len(paletteMap))
for col, idx := range paletteMap {
palette[idx] = col
}
pimg := image.NewPaletted(rgba.Bounds(), palette)
for y := b.Min.Y; y < b.Max.Y; y++ {
for x := b.Min.X; x < b.Max.X; x++ {
c := rgba.RGBA64At(x, y)
idx := paletteMap[c]
pimg.SetColorIndex(x, y, idx)
}
}
img = pimg
}
name := p[:len(p)-len(filepath.Ext(p))]
ninePatchPrefix, ninePatchSuffix := "", ""
if ext := filepath.Ext(name); ext == ".9" {
name = name[:len(name)-len(ext)]
ninePatchPrefix, ninePatchSuffix = "ninepatch.New(", ")"
}
goName := filenameToGoName(name)
fmt.Fprintf(out, "%s = %s&", goName, ninePatchPrefix)
data := new(bytes.Buffer)
switch img := img.(type) {
case *image.Paletted:
r := simage.Crop(img)
crop := image.NewPaletted(r, img.Palette)
draw.Draw(crop, crop.Rect, img, crop.Rect.Min, draw.Src)
img = crop
data.Write(img.Pix)
start := data.Len()
// Write palette.
for _, c := range img.Palette {
r, g, b, a := c.RGBA()
rgb565 := rgb565.RGB888ToRGB565(uint8(r>>8), uint8(g>>8), uint8(b>>8))
data.Write([]byte{rgb565[0], rgb565[1], uint8(a >> 8)})
}
b := img.Rect
fmt.Fprintf(out, "paletted.Image{\n")
fmt.Fprintf(out, "Pix: unsafe.Slice(unsafe.StringData(%sData[:%d]), len(%[1]sData[:%[2]d])),\n", goName, start)
fmt.Fprintf(out, "Rect: paletted.Rectangle{MinX: %d, MinY: %d, MaxX: %d, MaxY: %d},\n", b.Min.X, b.Min.Y, b.Max.X, b.Max.Y)
fmt.Fprintf(out, "Palette: paletted.Palette(unsafe.Slice(unsafe.StringData(%sData[%d:]), len(%[1]sData[%[2]d:]))),\n", goName, start)
default:
log.Fatalf("unsupported image format for %q: %T\n", p, img)
}
fmt.Fprintf(out, "}%s\n", ninePatchSuffix)
binName := fmt.Sprintf("%s.bin", name)
fmt.Fprintf(out, "//go:embed %s\n", binName)
fmt.Fprintf(out, "%sData string\n\n", goName)
if err := os.WriteFile(binName, data.Bytes(), 0o644); err != nil {
log.Fatal(err)
}
}
fmt.Fprintf(out, ")\n\n")
src, err := format.Source(out.Bytes())
if err != nil {
log.Fatal(err)
}
if err := os.WriteFile("embed.go", src, 0o644); err != nil {
log.Fatal(err)
}
}
func filenameToGoName(n string) string {
var name strings.Builder
toTitle := true
for _, ch := range n {
if !unicode.IsLetter(ch) && !unicode.IsDigit(ch) {
toTitle = true
continue
}
if toTitle {
toTitle = false
ch = unicode.ToTitle(ch)
}
name.WriteRune(ch)
}
return name.String()
}

BIN
gui/assets/hammer.bin Normal file

Binary file not shown.

BIN
gui/assets/hammer.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

BIN
gui/assets/icon-back.bin Normal file

Binary file not shown.

BIN
gui/assets/icon-back.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 563 B

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 B

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 B

BIN
gui/assets/icon-discard.bin Normal file

Binary file not shown.

BIN
gui/assets/icon-discard.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 479 B

BIN
gui/assets/icon-dot.bin Normal file

Binary file not shown.

BIN
gui/assets/icon-dot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 B

BIN
gui/assets/icon-edit.bin Normal file

Binary file not shown.

BIN
gui/assets/icon-edit.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 B

BIN
gui/assets/icon-flip.bin Normal file

Binary file not shown.

BIN
gui/assets/icon-flip.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

BIN
gui/assets/icon-hammer.bin Normal file

Binary file not shown.

BIN
gui/assets/icon-hammer.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 512 B

BIN
gui/assets/icon-info.bin Normal file

Binary file not shown.

BIN
gui/assets/icon-info.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
gui/assets/icon-left.bin Normal file

Binary file not shown.

BIN
gui/assets/icon-left.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 549 B

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

BIN
gui/assets/icon-right.bin Normal file

Binary file not shown.

BIN
gui/assets/icon-right.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
gui/assets/icon-skip.bin Normal file

Binary file not shown.

BIN
gui/assets/icon-skip.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 B

BIN
gui/assets/key-active.9.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

BIN
gui/assets/key-active.bin Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
gui/assets/key.9.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

BIN
gui/assets/key.bin Normal file

Binary file not shown.

BIN
gui/assets/logo-small.bin Normal file

Binary file not shown.

BIN
gui/assets/logo-small.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 B

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 B

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

BIN
gui/assets/sh02.bin Normal file

Binary file not shown.

BIN
gui/assets/sh02.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

BIN
gui/assets/sh03.bin Normal file

Binary file not shown.

BIN
gui/assets/sh03.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 550 B

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 551 B

Binary file not shown.

View File

@ -1,22 +0,0 @@
// Package gui is the v1 controller's on-device UI — screen drawing, menu
// navigation, page state machine.
//
// Self-contained, hardware-abstracted: it talks to platform/ for input
// events and frame output. That abstraction lets the same gui run on a
// real Pi (via driver/wshat + driver/drm) and in the browser emulator
// (via platform/v1 + canvas).
//
// Screen flow follows the v1 controller's existing conventions:
//
// - Joystick (Up/Down/Left/Right/Center) = navigation
// - Button1 = back
// - Button2 = secondary action (hold-to-arm dry-run)
// - Button3 = primary confirm (hold-to-engrave)
//
// Hold-to-confirm distinguishes Pressed from Click with a confirmDelay
// timer (see gui.go in upstream).
//
// Status: STUB — to be lifted from upstream seedhammer/seedhammer at v1.3.0.
// Browser-emulator-specific tweaks (mock camera, keyboard event source)
// live in platform/v1, NOT in this package.
package gui

2951
gui/gui.go Normal file

File diff suppressed because it is too large Load Diff

960
gui/gui_test.go Normal file
View File

@ -0,0 +1,960 @@
package gui
import (
"errors"
"fmt"
"image"
"image/draw"
"io"
"reflect"
"strings"
"testing"
"time"
"github.com/btcsuite/btcd/btcutil/hdkeychain"
"github.com/btcsuite/btcd/chaincfg"
"github.com/kortschak/qr"
"github.com/mineracks/seedhammer-v1-companion/backup"
"github.com/mineracks/seedhammer-v1-companion/bc/urtypes"
"github.com/mineracks/seedhammer-v1-companion/bip32"
"github.com/mineracks/seedhammer-v1-companion/bip39"
"github.com/mineracks/seedhammer-v1-companion/driver/mjolnir"
"github.com/mineracks/seedhammer-v1-companion/engrave"
"github.com/mineracks/seedhammer-v1-companion/font/constant"
"github.com/mineracks/seedhammer-v1-companion/gui/op"
"github.com/mineracks/seedhammer-v1-companion/nonstandard"
"github.com/mineracks/seedhammer-v1-companion/seedqr"
)
func TestDescriptorScreenError(t *testing.T) {
dupDesc := urtypes.OutputDescriptor{
Script: urtypes.P2WSH,
Type: urtypes.SortedMulti,
Threshold: 2,
Keys: make([]urtypes.KeyDescriptor, 2),
}
dupMnemonic := fillDescriptor(t, dupDesc, dupDesc.Script.DerivationPath(), 12, 0)
dupDesc.Keys[1] = dupDesc.Keys[0]
smallDesc := urtypes.OutputDescriptor{
Script: urtypes.P2WSH,
Type: urtypes.SortedMulti,
Threshold: 2,
Keys: make([]urtypes.KeyDescriptor, 5),
}
smallMnemonic := fillDescriptor(t, smallDesc, smallDesc.Script.DerivationPath(), 12, 0)
okDesc := urtypes.OutputDescriptor{
Script: urtypes.P2WSH,
Type: urtypes.SortedMulti,
Threshold: 3,
Keys: make([]urtypes.KeyDescriptor, 5),
}
okMnemonic := fillDescriptor(t, okDesc, okDesc.Script.DerivationPath(), 12, 0)
tests := []struct {
name string
desc urtypes.OutputDescriptor
mnemonic bip39.Mnemonic
ok bool
}{
{"duplicate key", dupDesc, dupMnemonic, false},
{"small threshold", smallDesc, smallMnemonic, false},
{"ok descriptor", okDesc, okMnemonic, true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
scr := &DescriptorScreen{
Descriptor: test.desc,
Mnemonic: test.mnemonic,
}
ctx := NewContext(newPlatform())
// Ok descriptor, ok error message, back.
ctxButton(ctx, Button3, Button3, Button1)
quit := runUI(ctx, func() {
if _, ok := scr.Confirm(ctx, op.Ctx{}, &descriptorTheme); ok != test.ok {
t.Fatalf("DescriptorScreen.Confirm returned %v, expected %v", ok, test.ok)
}
})
defer quit()
})
}
}
func TestValidateDescriptor(t *testing.T) {
// Duplicate key.
dup := urtypes.OutputDescriptor{
Script: urtypes.P2WSH,
Threshold: 1,
Type: urtypes.SortedMulti,
Keys: make([]urtypes.KeyDescriptor, 2),
}
fillDescriptor(t, dup, dup.Script.DerivationPath(), 12, 0)
dup.Keys[1] = dup.Keys[0]
// Threshold too small.
smallDesc := urtypes.OutputDescriptor{
Script: urtypes.P2WSH,
Threshold: 2,
Type: urtypes.SortedMulti,
Keys: make([]urtypes.KeyDescriptor, 5),
}
fillDescriptor(t, smallDesc, smallDesc.Script.DerivationPath(), 12, 0)
tests := []struct {
name string
desc urtypes.OutputDescriptor
err error
}{
{"duplicate key", dup, new(errDuplicateKey)},
{"threshold too small", smallDesc, backup.ErrDescriptorTooLarge},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
err := validateDescriptor(mjolnir.Params, test.desc)
if err == nil {
t.Fatal("validateDescriptor accepted an unsupported descriptor")
}
if !errors.Is(err, test.err) {
t.Fatalf("validateDescriptor returned %v, expected %v", err, test.err)
}
})
}
}
func runUI(ctx *Context, f func()) func() {
token := new(int)
frameCh := make(chan struct{})
closed := make(chan struct{})
frames := 0
ctx.Frame = func() {
frames++
if frames > 1000 {
panic("UI is not making progress")
}
frameCh <- struct{}{}
select {
case <-frameCh:
case <-closed:
}
}
go func() {
defer func() {
if v := recover(); v != nil && v != token {
panic(v)
}
}()
defer close(closed)
<-frameCh
f()
}()
quit := func() {
ctx.Frame = func() {
panic(token)
}
close(frameCh)
<-closed
ctx.Frame = nil
}
return quit
}
func resetOps(ops *op.Ops, f func()) func() {
return func() {
ops.Reset()
f()
}
}
func opsContains(ops *op.Ops, str string) bool {
clip := image.Rectangle{Max: image.Pt(testDisplayDim, testDisplayDim)}
str = strings.ToLower(str)
txt := strings.Join(ops.ExtractText(clip), " ")
return strings.Index(strings.ToLower(txt), str) != -1
}
func TestMainScreen(t *testing.T) {
p := newPlatform()
ctx := NewContext(p)
ops := new(op.Ops)
quit := runUI(ctx, func() {
mainFlow(ctx, ops.Context())
})
defer quit()
frame := resetOps(ops, ctx.Frame)
// Test sd card warning.
ctxButton(ctx, Button3)
frame()
if !opsContains(ops, "Remove SD") {
t.Fatal("MainScreen ignored SD card present")
}
ctx.EmptySDSlot = true
frame()
if opsContains(ops, "Remove SD") {
t.Fatal("MainScreen ignored SD card ejected")
}
// Input method camera
ctxButton(ctx, Down, Button3)
// Scan xpub as descriptor.
ctxQR(t, ctx, p, "xpub6F148LnjUhGrHfEN6Pa8VkwF8L6FJqYALxAkuHfacfVhMLVY4MRuUVMxr9pguAv67DHx1YFxqoKN8s4QfZtD9sR2xRCffTqi9E8FiFLAYk8")
frame()
if !opsContains(ops, "Invalid Seed") {
t.Fatal("MainScreen accepted invalid data for a Seed")
}
}
func TestNonParticipatingSeed(t *testing.T) {
// Enter seed not part of the descriptor.
mnemonic := make(bip39.Mnemonic, 12)
for i := range mnemonic {
mnemonic[i] = bip39.RandomWord()
}
mnemonic = mnemonic.FixChecksum()
scr := &DescriptorScreen{
Mnemonic: mnemonic,
Descriptor: twoOfThree.Descriptor,
}
ctx := NewContext(newPlatform())
// Accept descriptor.
ctxButton(ctx, Button3)
ops := new(op.Ops)
quit := runUI(ctx, func() {
if _, ok := scr.Confirm(ctx, ops.Context(), &descriptorTheme); ok {
t.Fatal("a non-participating seed was accepted")
}
})
defer quit()
ctx.Frame()
if !opsContains(ops, "Unknown Wallet") {
t.Fatal("a non-participating seed was accepted")
}
}
func newTestEngraveScreen(t *testing.T, ctx *Context) *EngraveScreen {
desc := twoOfThree.Descriptor
const keyIdx = 0
plate, err := engravePlate(plateSizes, mjolnir.Params, desc, keyIdx, twoOfThree.Mnemonic)
if err != nil {
t.Fatal(err)
}
return NewEngraveScreen(
ctx,
plate,
)
}
func TestEngraveScreenCancel(t *testing.T) {
p := newPlatform()
ctx := NewContext(p)
scr := newTestEngraveScreen(t, ctx)
// Back.
ctxButton(ctx, Button1)
// Hold confirm.
ctxPress(ctx, Button3)
var cancelled bool
quit := runUI(ctx, func() {
cancelled = !scr.Engrave(ctx, op.Ctx{}, &engraveTheme)
})
defer quit()
ctx.Frame()
if cancelled {
t.Error("exited screen without confirmation")
}
p.timeOffset += confirmDelay
ctx.Frame()
if !cancelled {
t.Error("failed to exit screen")
}
}
func TestEngraveError(t *testing.T) {
nonstdPath := []uint32{
hdkeychain.HardenedKeyStart + 86,
hdkeychain.HardenedKeyStart + 0,
hdkeychain.HardenedKeyStart + 0,
}
tests := []struct {
name string
threshold int
keys int
path []uint32
err error
}{
{"threshold too small", 1, 5, nonstdPath, backup.ErrDescriptorTooLarge},
}
for i, test := range tests {
name := fmt.Sprintf("%d-%d-of-%d", i, test.threshold, test.keys)
t.Run(name, func(t *testing.T) {
desc := urtypes.OutputDescriptor{
Script: urtypes.P2WSH,
Threshold: test.threshold,
Type: urtypes.SortedMulti,
Keys: make([]urtypes.KeyDescriptor, test.keys),
}
mnemonic := fillDescriptor(t, desc, test.path, 12, 0)
_, err := engravePlate(plateSizes, mjolnir.Params, desc, 0, mnemonic)
if err == nil {
t.Fatal("invalid descriptor succeeded")
}
if !errors.Is(err, test.err) {
t.Fatalf("got error %v, expected %v", err, test.err)
}
})
}
}
func TestEngraveScreenConnectionError(t *testing.T) {
p := newPlatform()
p.engrave.closed = make(chan []mjolnir.Cmd, 1)
p.engrave.connErr = errors.New("failed to connect")
ctx := NewContext(p)
scr := newTestEngraveScreen(t, ctx)
ops := new(op.Ops)
quit := runUI(ctx, func() {
scr.Engrave(ctx, ops.Context(), &engraveTheme)
})
defer quit()
frame := resetOps(ops, ctx.Frame)
// Press next until connect is reached.
for scr.instructions[scr.step].Type != ConnectInstruction {
ctxButton(ctx, Button3)
ctx.Frame()
}
// Hold connect.
ctxPress(ctx, Button3)
frame()
p.timeOffset += confirmDelay
frame()
if !opsContains(ops, p.engrave.connErr.Error()) {
t.Fatal("engraver error did not propagate to screen")
}
// Dismiss error.
ctxButton(ctx, Button3)
// Successfully connect, but fail during engraving.
p.engrave.connErr = nil
p.engrave.ioErr = errors.New("error during engraving")
delivered := make(chan struct{})
p.engrave.ioErrDelivered = delivered
// Hold connect.
ctxPress(ctx, Button3)
frame()
p.timeOffset += confirmDelay
frame()
if opsContains(ops, "error") {
t.Fatal("screen reported error for connection success")
}
<-delivered
for {
frame()
if opsContains(ops, p.engrave.ioErr.Error()) {
// t.Fatal("screen didn't report engraver error")
break
}
}
// Dismiss error and verify screen exits.
ctxButton(ctx, Button3)
frame()
if opsContains(ops, "error") {
t.Fatal("screen didn't exit after fatal engraver error")
}
// Verify device was closed.
<-p.engrave.closed
}
func TestScanScreenConnectError(t *testing.T) {
p := newPlatform()
// Fail on connect.
ctx := NewContext(p)
scr := &ScanScreen{}
camErr := errors.New("failed to open camera")
ctx.events = append(ctx.events, FrameEvent{Error: camErr}.Event())
ops := new(op.Ops)
quit := runUI(ctx, func() {
scr.Scan(ctx, ops.Context())
})
defer quit()
frame := resetOps(ops, ctx.Frame)
frame()
if !opsContains(ops, camErr.Error()) {
t.Fatal("initial camera error not reported")
}
}
func TestScanScreenStreamError(t *testing.T) {
p := newPlatform()
ctx := NewContext(p)
// Fail during streaming.
scr := &ScanScreen{}
// Connect.
camErr := errors.New("error during streaming")
ctx.events = append(ctx.events, FrameEvent{Error: camErr}.Event())
ops := new(op.Ops)
quit := runUI(ctx, func() {
scr.Scan(ctx, ops.Context())
})
defer quit()
ctx.Frame()
if !opsContains(ops, camErr.Error()) {
t.Fatal("streaming camera error not reported")
}
}
func TestWordKeyboardScreen(t *testing.T) {
ctx := NewContext(newPlatform())
for i := bip39.Word(0); i < bip39.NumWords; i++ {
w := bip39.LabelFor(i)
ctxString(ctx, strings.ToUpper(w))
ctxButton(ctx, Button2)
m := make(bip39.Mnemonic, 1)
inputWordsFlow(ctx, op.Ctx{}, &descriptorTheme, m, 0)
if got := bip39.LabelFor(m[0]); got != w {
t.Errorf("keyboard mapped %q to %q", w, got)
}
}
}
func ctxMnemonic(ctx *Context, m bip39.Mnemonic) {
for _, word := range m {
ctxString(ctx, strings.ToUpper(bip39.LabelFor(word)))
ctxButton(ctx, Button2)
}
}
func ctxQR(t *testing.T, ctx *Context, p *testPlatform, qrs ...string) {
t.Helper()
for _, qr := range qrs {
ctx.events = append(ctx.events, qrFrame(t, p, qr).Event())
}
}
func TestSeedScreenScan(t *testing.T) {
p := newPlatform()
ctx := NewContext(p)
// Select camera.
ctxButton(ctx, Down, Button3)
want, err := bip39.ParseMnemonic("attack pizza motion avocado network gather crop fresh patrol unusual wild holiday candy pony ranch winter theme error hybrid van cereal salon goddess expire")
if err != nil {
t.Fatal(err)
}
ctxQR(t, ctx, p, string(seedqr.QR(want)))
got, ok := newMnemonicFlow(ctx, op.Ctx{}, &descriptorTheme)
if !ok {
t.Errorf("no mnemonic from scanned seed")
}
if !reflect.DeepEqual(got, want) {
t.Errorf("scanned %v, want %v", got, want)
}
}
func TestSeedScreenScanInvalid(t *testing.T) {
p := newPlatform()
ctx := NewContext(p)
// Select camera.
ctxButton(ctx, Down, Button3)
ctxQR(t, ctx, p, "UR:CRYPTO-SEED/OYADGDIYWLAMAEJSZSWDWYTLTIFEENFTLNMNWKBDHNSSRO")
ops := new(op.Ops)
quit := runUI(ctx, func() {
newMnemonicFlow(ctx, ops.Context(), &descriptorTheme)
})
defer quit()
ctx.Frame()
if !opsContains(ops, "invalid seed") {
t.Error("invalid seed accepted")
}
}
func TestSeedScreenInvalidSeed(t *testing.T) {
p := newPlatform()
ctx := NewContext(p)
m := append(bip39.Mnemonic{}, twoOfThree.Mnemonic...)
// Invalidate seed.
m[0] = 0
// Accept seed.
ctxButton(ctx, Button3)
scr := new(SeedScreen)
var confirmed bool
ops := new(op.Ops)
var exited bool
quit := runUI(ctx, func() {
scr.Confirm(ctx, ops.Context(), &singleTheme, m)
exited = true
})
defer quit()
frame := resetOps(ops, ctx.Frame)
frame()
if confirmed || !opsContains(ops, "invalid seed") {
t.Fatal("invalid seed accepted")
}
// Dismiss error.
ctxButton(ctx, Button3)
// Back.
ctxButton(ctx, Button1)
// Hold confirm.
ctxPress(ctx, Button3)
frame()
if exited {
t.Error("exited screen without confirmation")
}
p.timeOffset += confirmDelay
frame()
if !exited {
t.Error("failed to exit screen")
}
}
func TestXpubMasterFingerprintSinglesig(t *testing.T) {
const mnemonic = "upset toe sheriff cotton vibrant shock torch waste congress innocent company review"
const descriptor = "zpub6qiC7jMrWkhNEu7YamFTWx8YHQaDFynLYQCUmxjCWpBiLQ4Qp6c6PEwpZpkN27XmUtBjX7hVLyyBKa7zhgaB5B2qvdckaP21ADwx7oYgYD6"
m, err := bip39.ParseMnemonic(mnemonic)
if err != nil {
t.Fatal(err)
}
p := newPlatform()
ctx := NewContext(p)
ops := new(op.Ops)
ctxQR(t, ctx, p, descriptor)
ctxButton(ctx, Button3)
got, parsed := inputDescriptorFlow(ctx, ops.Context(), &descriptorTheme, m)
if !parsed {
t.Error("failed to parse descriptor")
}
want, err := nonstandard.OutputDescriptor([]byte(descriptor))
if err != nil {
t.Fatal(err)
}
mfp, err := masterFingerprintFor(m, &chaincfg.MainNetParams)
if err != nil {
t.Fatal(err)
}
want.Keys[0].MasterFingerprint = mfp
if !reflect.DeepEqual(want, *got) {
t.Error("descriptors don't match")
}
}
func TestSeed(t *testing.T) {
p := newPlatform()
ctx := NewContext(p)
const mnemonic = "doll clerk nice coast caught valid shallow taxi buyer economy lunch roof"
m, err := bip39.ParseMnemonic(mnemonic)
if err != nil {
t.Fatal(err)
}
mk, ok := deriveMasterKey(m, &chaincfg.MainNetParams)
if !ok {
t.Fatal("failed to derive master key")
}
mfp, _, err := bip32.Derive(mk, urtypes.Path{0})
if err != nil {
t.Fatal(err)
}
seedDesc := backup.Seed{
KeyIdx: 0,
Mnemonic: m,
Keys: 1,
MasterFingerprint: mfp,
Font: constant.Font,
Size: backup.SquarePlate,
}
side, err := backup.EngraveSeed(p.EngraverParams(), seedDesc)
if err != nil {
t.Fatal(err)
}
plate := Plate{
Sides: []engrave.Plan{side},
Size: backup.SquarePlate,
MasterFingerprint: mfp,
}
var completed bool
scr := NewEngraveScreen(ctx, plate)
quit := runUI(ctx, func() {
completed = scr.Engrave(ctx, op.Ctx{}, &engraveTheme)
})
defer quit()
testEngraving(t, p, ctx, scr, side)
for !completed {
ctxButton(ctx, Button3)
ctx.Frame()
}
}
func TestMulti(t *testing.T) {
const oneOfTwoDesc = "wsh(sortedmulti(1,[94631f99/48h/0h/0h/2h]xpub6ENfRaMWq2UoFy5FrLRMwiEkdgFdMgjEoikR34RBGzhsx8JzAkn7fyQeR5odirEwERvmxhSEv7rsmV7nuzjSKKKJHBP2aQZVu3R2d5ERgcw,[4bbaa801/48h/0h/0h/2h]xpub6E8mpiqJiVKuJZqxtu5SbHQnwUWWPQpZEy9CVtvfU1gxXZnbb9DG2AvZyMHvyVRtUPAEmu6BuRCy4LK2rKMeNr7jQKXsCyFfr1osgFCMYpc))"
mnemonics := []string{
"doll clerk nice coast caught valid shallow taxi buyer economy lunch roof",
"road lend lyrics shift rabbit amazing fetch impulse provide reopen sphere network",
}
for i, mnemonic := range mnemonics {
p := newPlatform()
ctx := NewContext(p)
m, err := bip39.ParseMnemonic(mnemonic)
if err != nil {
t.Fatal(err)
}
oneOfTwo, err := nonstandard.OutputDescriptor([]byte(oneOfTwoDesc))
if err != nil {
t.Fatal(err)
}
const size = backup.LargePlate
descPlate := backup.Descriptor{
Descriptor: oneOfTwo,
KeyIdx: i,
Font: constant.Font,
Size: size,
}
descSide, err := backup.EngraveDescriptor(p.EngraverParams(), descPlate)
if err != nil {
t.Fatal(err)
}
seedDesc := backup.Seed{
Title: oneOfTwo.Title,
KeyIdx: i,
Mnemonic: m,
Keys: len(oneOfTwo.Keys),
MasterFingerprint: oneOfTwo.Keys[i].MasterFingerprint,
Font: constant.Font,
Size: size,
}
seedSide, err := backup.EngraveSeed(p.EngraverParams(), seedDesc)
if err != nil {
t.Fatal(err)
}
plate := Plate{
Size: size,
Sides: []engrave.Plan{descSide, seedSide},
}
var completed bool
scr := NewEngraveScreen(ctx, plate)
quit := runUI(ctx, func() {
completed = scr.Engrave(ctx, op.Ctx{}, &engraveTheme)
})
defer quit()
for _, side := range plate.Sides {
testEngraving(t, p, ctx, scr, side)
}
for !completed {
ctxButton(ctx, Button3)
ctx.Frame()
}
}
}
func fillDescriptor(t *testing.T, desc urtypes.OutputDescriptor, path urtypes.Path, seedlen int, keyIdx int) bip39.Mnemonic {
var mnemonic bip39.Mnemonic
for i := range desc.Keys {
m := make(bip39.Mnemonic, seedlen)
for j := range m {
m[j] = bip39.Word(i*seedlen + j)
}
m = m.FixChecksum()
seed := bip39.MnemonicSeed(m, "")
network := &chaincfg.MainNetParams
mk, err := hdkeychain.NewMaster(seed, network)
if err != nil {
t.Fatal(err)
}
mfp, xpub, err := bip32.Derive(mk, path)
if err != nil {
t.Fatal(err)
}
pub, err := xpub.ECPubKey()
if err != nil {
t.Fatal(err)
}
desc.Keys[i] = urtypes.KeyDescriptor{
Network: network,
MasterFingerprint: mfp,
DerivationPath: path,
KeyData: pub.SerializeCompressed(),
ChainCode: xpub.ChainCode(),
ParentFingerprint: xpub.ParentFingerprint(),
}
if i == keyIdx {
mnemonic = m
}
}
return mnemonic
}
type testPlatform struct {
events []Event
engrave struct {
closed chan []mjolnir.Cmd
connErr error
ioErr error
ioErrDelivered chan<- struct{}
}
timeOffset time.Duration
qrImages map[*uint8][]byte
}
func (t *testPlatform) ScanQR(img *image.Gray) ([][]byte, error) {
if content, ok := t.qrImages[&img.Pix[0]]; ok {
return [][]byte{content}, nil
}
return nil, errors.New("no QR code")
}
const testDisplayDim = 240
func (*testPlatform) DisplaySize() image.Point {
return image.Pt(testDisplayDim, testDisplayDim)
}
func (*testPlatform) Dirty(r image.Rectangle) error {
return nil
}
func (*testPlatform) NextChunk() (draw.RGBA64Image, bool) {
return nil, false
}
func (t *testPlatform) Now() time.Time {
return time.Now().Add(t.timeOffset)
}
func (*testPlatform) Debug() bool {
return false
}
func ctxString(ctx *Context, str string) {
for _, r := range str {
ctx.Events(
ButtonEvent{
Button: Rune,
Rune: r,
Pressed: true,
}.Event(),
)
}
}
func ctxPress(ctx *Context, bs ...Button) {
for _, b := range bs {
ctx.Events(
ButtonEvent{
Button: b,
Pressed: true,
}.Event(),
)
}
}
func ctxButton(ctx *Context, bs ...Button) {
for _, b := range bs {
ctx.Events(
ButtonEvent{
Button: b,
Pressed: true,
}.Event(),
ButtonEvent{
Button: b,
Pressed: false,
}.Event(),
)
}
}
func (p *testPlatform) Wakeup() {
}
func (p *testPlatform) Events(deadline time.Time) []Event {
evts := p.events
p.events = nil
return evts
}
type wrappedEngraver struct {
dev *mjolnir.Simulator
closed chan<- []mjolnir.Cmd
ioErr error
ioErrDelivered chan<- struct{}
}
func (w *wrappedEngraver) Read(p []byte) (int, error) {
n, err := w.dev.Read(p)
if err == nil && w.ioErr != nil {
err = w.ioErr
w.ioErr = nil
close(w.ioErrDelivered)
}
return n, err
}
func (w *wrappedEngraver) Write(p []byte) (int, error) {
n, err := w.dev.Write(p)
if err == nil && w.ioErr != nil {
err = w.ioErr
w.ioErr = nil
close(w.ioErrDelivered)
}
return n, err
}
func (w *wrappedEngraver) Close() error {
if w.closed != nil {
w.closed <- w.dev.Cmds
}
return w.dev.Close()
}
func (p *testPlatform) EngraverParams() engrave.Params {
return mjolnir.Params
}
var plateSizes = []backup.PlateSize{backup.SquarePlate, backup.LargePlate}
func (p *testPlatform) PlateSizes() []backup.PlateSize {
return plateSizes
}
func (p *testPlatform) Engraver() (Engraver, error) {
if err := p.engrave.connErr; err != nil {
return nil, err
}
sim := mjolnir.NewSimulator()
return &engraver{
dev: &wrappedEngraver{sim, p.engrave.closed, p.engrave.ioErr, p.engrave.ioErrDelivered},
}, nil
}
type engraver struct {
dev io.ReadWriteCloser
}
func (e *engraver) Engrave(sz backup.PlateSize, plan engrave.Plan, quit <-chan struct{}) error {
return mjolnir.Engrave(e.dev, mjolnir.Options{}, plan, quit)
}
func (e *engraver) Close() {
e.dev.Close()
}
func (p *testPlatform) CameraFrame(dims image.Point) {
}
func newPlatform() *testPlatform {
return &testPlatform{}
}
func qrFrame(t *testing.T, p *testPlatform, content string) FrameEvent {
qr, err := qr.Encode(content, qr.L)
if err != nil {
t.Fatal(err)
}
qrImg := qr.Image()
b := qrImg.Bounds()
frameImg := image.NewYCbCr(b, image.YCbCrSubsampleRatio420)
for y := b.Min.Y; y < b.Max.Y; y++ {
for x := b.Min.X; x < b.Max.X; x++ {
off := frameImg.YOffset(x, y)
r, _, _, _ := qrImg.At(x, y).RGBA()
frameImg.Y[off] = uint8(r >> 8)
}
}
if p.qrImages == nil {
p.qrImages = make(map[*byte][]byte)
}
p.qrImages[&frameImg.Y[0]] = []byte(content)
return FrameEvent{
Image: frameImg,
}
}
func testEngraving(t *testing.T, p *testPlatform, ctx *Context, scr *EngraveScreen, side engrave.Plan) {
p.engrave.closed = make(chan []mjolnir.Cmd)
done:
for {
switch scr.instructions[scr.step].Type {
case EngraveInstruction:
break done
case ConnectInstruction:
// Hold connect.
ctxPress(ctx, Button3)
ctx.Frame()
p.timeOffset += confirmDelay
ctx.Frame()
default:
ctxButton(ctx, Button3)
ctx.Frame()
}
}
got := <-p.engrave.closed
// Verify the step is advanced after engrave completion.
for scr.instructions[scr.step].Type == EngraveInstruction {
ctx.Frame()
}
want := simEngrave(t, side)
if !reflect.DeepEqual(want, got) {
t.Fatalf("engraver commands mismatch for side %v", side)
}
}
func simEngrave(t *testing.T, plate engrave.Plan) []mjolnir.Cmd {
sim := mjolnir.NewSimulator()
defer sim.Close()
if err := mjolnir.Engrave(sim, mjolnir.Options{}, plate, nil); err != nil {
t.Fatal(err)
}
return sim.Cmds
}
func mnemonicFor(phrase string) bip39.Mnemonic {
m, err := bip39.ParseMnemonic(phrase)
if err != nil {
panic(err)
}
return m
}
var twoOfThree = struct {
Descriptor urtypes.OutputDescriptor
Mnemonic bip39.Mnemonic
}{
Mnemonic: mnemonicFor("flip begin artist fringe online release swift genre wool general transfer arm"),
Descriptor: urtypes.OutputDescriptor{
Script: urtypes.P2WSH,
Threshold: 2,
Type: urtypes.SortedMulti,
Keys: []urtypes.KeyDescriptor{
{
Network: &chaincfg.MainNetParams,
MasterFingerprint: 0x5a0804e3,
DerivationPath: urtypes.Path{0x80000030, 0x80000000, 0x80000000, 0x80000002},
KeyData: []byte{0x3, 0xa9, 0x39, 0x4a, 0x2f, 0x1a, 0x4f, 0x99, 0x61, 0x3a, 0x71, 0x69, 0x56, 0xc8, 0x54, 0xf, 0x6d, 0xba, 0x6f, 0x18, 0x93, 0x1c, 0x26, 0x39, 0x10, 0x72, 0x21, 0xb2, 0x67, 0xd7, 0x40, 0xaf, 0x23},
ChainCode: []byte{0xdb, 0xe8, 0xc, 0xbb, 0x4e, 0xe, 0x41, 0x8b, 0x6, 0xf4, 0x70, 0xd2, 0xaf, 0xe7, 0xa8, 0xc1, 0x7b, 0xe7, 0x1, 0xab, 0x20, 0x6c, 0x59, 0xa6, 0x5e, 0x65, 0xa8, 0x24, 0x1, 0x6a, 0x6c, 0x70},
ParentFingerprint: 0xc7bce7a8,
},
{
Network: &chaincfg.MainNetParams,
MasterFingerprint: 0xdd4fadee,
DerivationPath: urtypes.Path{0x80000030, 0x80000000, 0x80000000, 0x80000002},
KeyData: []byte{0x2, 0x21, 0x96, 0xad, 0xc2, 0x5f, 0xde, 0x16, 0x9f, 0xe9, 0x2e, 0x70, 0x76, 0x90, 0x59, 0x10, 0x22, 0x75, 0xd2, 0xb4, 0xc, 0xc9, 0x87, 0x76, 0xea, 0xab, 0x92, 0xb8, 0x2a, 0x86, 0x13, 0x5e, 0x92},
ChainCode: []byte{0x43, 0x8e, 0xff, 0x7b, 0x3b, 0x36, 0xb6, 0xd1, 0x1a, 0x60, 0xa2, 0x2c, 0xcb, 0x93, 0x6, 0xee, 0xa3, 0x5, 0xb0, 0x43, 0x9f, 0x1e, 0xa0, 0x9d, 0x59, 0x28, 0x1, 0x5d, 0xe3, 0x73, 0x81, 0x16},
ParentFingerprint: 0x22969377,
},
{
Network: &chaincfg.MainNetParams,
MasterFingerprint: 0x9bacd5c0,
DerivationPath: urtypes.Path{0x80000030, 0x80000000, 0x80000000, 0x80000002},
KeyData: []byte{0x2, 0xfb, 0x72, 0x50, 0x7f, 0xc2, 0xd, 0xdb, 0xa9, 0x29, 0x91, 0xb1, 0x7c, 0x4b, 0xb4, 0x66, 0x13, 0xa, 0xd9, 0x3a, 0x88, 0x6e, 0x73, 0x17, 0x50, 0x33, 0xbb, 0x43, 0xe3, 0xbc, 0x78, 0x5a, 0x6d},
ChainCode: []byte{0x95, 0xb3, 0x49, 0x13, 0x93, 0x7f, 0xa5, 0xf1, 0xc6, 0x20, 0x5b, 0x52, 0x5b, 0xb5, 0x7d, 0xe1, 0x51, 0x76, 0x25, 0xe0, 0x45, 0x86, 0xb5, 0x95, 0xbe, 0x68, 0xe7, 0x13, 0x62, 0xd3, 0xed, 0xc5},
ParentFingerprint: 0x97ec38f9,
},
},
},
}

160
gui/layout/layout.go Normal file
View File

@ -0,0 +1,160 @@
package layout
import (
"image"
)
type Rectangle image.Rectangle
func (r Rectangle) Shrink(top, end, bottom, start int) Rectangle {
r2 := Rectangle{
Min: r.Min.Add(image.Pt(start, top)),
Max: r.Max.Sub(image.Pt(end, bottom)),
}
if r2.Min.X > r.Max.X {
r2.Min.X = r.Max.X
}
if r2.Max.X < r.Min.X {
r2.Max.X = r.Min.X
}
if r2.Min.Y > r.Max.Y {
r2.Min.Y = r.Max.Y
}
if r2.Max.Y < r.Min.Y {
r2.Max.Y = r.Min.Y
}
return r2
}
func (r Rectangle) Center(sz image.Point) image.Point {
off := r.Size().Sub(sz).Div(2)
return r.Min.Add(off)
}
func (r Rectangle) E(sz image.Point) image.Point {
return image.Point{
X: r.Max.X - sz.X,
Y: (r.Max.Y + r.Min.Y - sz.Y) / 2,
}
}
func (r Rectangle) N(sz image.Point) image.Point {
return image.Point{
X: (r.Max.X + r.Min.X - sz.X) / 2,
Y: r.Min.Y,
}
}
func (r Rectangle) W(sz image.Point) image.Point {
return image.Point{
X: r.Min.X,
Y: (r.Max.Y + r.Min.Y - sz.Y) / 2,
}
}
func (r Rectangle) S(sz image.Point) image.Point {
return image.Point{
X: (r.Max.X + r.Min.X - sz.X) / 2,
Y: r.Max.Y - sz.Y,
}
}
func (r Rectangle) NW(sz image.Point) image.Point {
return r.Min
}
func (r Rectangle) NE(sz image.Point) image.Point {
return image.Point{
X: r.Max.X - sz.X,
Y: r.Min.Y,
}
}
func (r Rectangle) SW(sz image.Point) image.Point {
return image.Point{
X: r.Min.X,
Y: r.Max.Y - sz.Y,
}
}
func (r Rectangle) SE(sz image.Point) image.Point {
return r.Max.Sub(sz)
}
func (r Rectangle) Dx() int {
return image.Rectangle(r).Dx()
}
func (r Rectangle) Dy() int {
return image.Rectangle(r).Dy()
}
func (r Rectangle) Size() image.Point {
return image.Rectangle(r).Size()
}
func (r Rectangle) CutTop(height int) (top Rectangle, bottom Rectangle) {
cuty := r.Min.Y + height
if cuty > r.Max.Y {
cuty = r.Max.Y
}
return r.cutY(cuty)
}
func (r Rectangle) CutBottom(height int) (top Rectangle, bottom Rectangle) {
cuty := r.Max.Y - height
if cuty < r.Min.Y {
cuty = r.Min.Y
}
return r.cutY(cuty)
}
func (r Rectangle) cutY(cuty int) (top Rectangle, bottom Rectangle) {
top = Rectangle(image.Rect(r.Min.X, r.Min.Y, r.Max.X, cuty))
bottom = Rectangle(image.Rect(r.Min.X, cuty, r.Max.X, r.Max.Y))
return top, bottom
}
func (r Rectangle) CutStart(width int) (start Rectangle, end Rectangle) {
cutx := r.Min.X + width
if cutx > r.Max.X {
cutx = r.Max.X
}
return r.cutX(cutx)
}
func (r Rectangle) CutEnd(width int) (start Rectangle, end Rectangle) {
cuty := r.Max.X - width
if cuty < r.Min.X {
cuty = r.Min.X
}
return r.cutX(cuty)
}
func (r Rectangle) cutX(cutx int) (start Rectangle, end Rectangle) {
start = Rectangle(image.Rect(r.Min.X, r.Min.Y, cutx, r.Max.Y))
end = Rectangle(image.Rect(cutx, r.Min.Y, r.Max.X, r.Max.Y))
return start, end
}
type Align struct {
Size image.Point
}
func (h *Align) Add(sz image.Point) image.Point {
if sz.Y > h.Size.Y {
h.Size.Y = sz.Y
}
if sz.X > h.Size.X {
h.Size.X = sz.X
}
return sz
}
func (h *Align) X(sz image.Point) int {
return (h.Size.X - sz.X) / 2
}
func (h *Align) Y(sz image.Point) int {
return (h.Size.Y - sz.Y) / 2
}

372
gui/op/op.go Normal file
View File

@ -0,0 +1,372 @@
package op
import (
"image"
"image/color"
"golang.org/x/image/draw"
"golang.org/x/image/math/fixed"
"github.com/mineracks/seedhammer-v1-companion/font/bitmap"
"github.com/mineracks/seedhammer-v1-companion/image/alpha4"
"github.com/mineracks/seedhammer-v1-companion/image/ninepatch"
"github.com/mineracks/seedhammer-v1-companion/image/rgb565"
)
type Ops struct {
ops []any
uniforms map[color.Color]*image.Uniform
ninep map[ninepatch.Image]*ninepatch.Image
colors map[color.NRGBA]*image.Uniform
prevOps map[frameOp]bool
frameOps map[frameOp]bool
frame []frameOp
scratch scratch
}
type Ctx struct {
beginIdx int
ops *Ops
}
type frameOp struct {
state drawState
op drawOp
}
func (o *Ctx) add(op any) {
if o.ops == nil {
return
}
o.ops.ops = append(o.ops.ops, op)
}
func (o *Ctx) Begin() Ctx {
if o.ops == nil {
return Ctx{}
}
o.add(beginOp{})
o.beginIdx = len(o.ops.ops)
return Ctx{ops: o.ops}
}
func (o *Ctx) End() CallOp {
if o.ops == nil {
return CallOp{}
}
if o.beginIdx == 0 {
panic("End without a Begin")
}
o.add(endOp{})
call := CallOp{startIdx: o.beginIdx}
o.beginIdx = 0
return call
}
func (o *Ops) Context() Ctx {
return Ctx{ops: o}
}
func (o *Ops) Reset() {
o.ops = o.ops[:0]
if o.frameOps == nil {
o.frameOps = make(map[frameOp]bool)
}
if o.prevOps == nil {
o.prevOps = make(map[frameOp]bool)
}
o.frameOps, o.prevOps = o.prevOps, o.frameOps
// Clear for GC.
for i := range o.frameOps {
delete(o.frameOps, i)
}
for i := range o.frame {
o.frame[i] = frameOp{}
}
o.frame = o.frame[:0]
}
func (o *Ops) nrgba(c color.NRGBA) *image.Uniform {
if o == nil {
return image.NewUniform(c)
}
if o.colors == nil {
o.colors = make(map[color.NRGBA]*image.Uniform)
}
if u, ok := o.colors[c]; ok {
return u
}
u := image.NewUniform(c)
o.colors[c] = u
return u
}
func (o *Ops) intern(img image.Image) image.Image {
if o == nil {
return img
}
switch img := img.(type) {
case *image.Uniform:
if o.uniforms == nil {
o.uniforms = make(map[color.Color]*image.Uniform)
}
if img, ok := o.uniforms[img.C]; ok {
return img
}
o.uniforms[img.C] = img
case *ninepatch.Image:
if o.ninep == nil {
o.ninep = make(map[ninepatch.Image]*ninepatch.Image)
}
if img, ok := o.ninep[*img]; ok {
return img
}
o.ninep[*img] = img
}
return img
}
type drawState struct {
pos image.Point
clip image.Rectangle
mask image.Image
maskp image.Point
}
type scratch struct {
glyph alpha4.Image
}
func (o *Ops) ExtractText(dst image.Rectangle) []string {
o.serialize(drawState{clip: dst}, 0)
var text []string
for _, op := range o.frame {
if op, ok := op.op.(TextOp); ok {
text = append(text, op.Txt)
}
}
return text
}
func (o *Ops) Clip(dst image.Rectangle) image.Rectangle {
o.serialize(drawState{clip: dst}, 0)
var clip image.Rectangle
for _, op := range o.frame {
o.frameOps[op] = true
if !o.prevOps[op] {
clip = clip.Union(op.state.clip)
} else {
delete(o.prevOps, op)
}
}
for op := range o.prevOps {
clip = clip.Union(op.state.clip)
}
return clip
}
func (o *Ops) Draw(dst draw.Image) {
b := dst.Bounds()
for _, op := range o.frame {
clip := b.Intersect(op.state.clip)
if clip.Empty() {
continue
}
pos := clip.Min.Sub(op.state.pos)
maskp := clip.Min.Sub(op.state.maskp)
op.op.draw(&o.scratch, dst, clip, op.state.mask, maskp, pos)
}
}
func (o *Ops) serialize(state drawState, from int) {
macros := 0
origState := state
for _, op := range o.ops[from:] {
switch op.(type) {
case beginOp:
macros++
continue
case endOp:
if macros == 0 {
return
}
macros--
continue
}
if macros > 0 {
continue
}
switch op := op.(type) {
case offsetOp:
state.pos = state.pos.Add(image.Point(op))
continue
case ClipOp:
r := image.Rectangle(op).Add(state.pos)
state.clip = state.clip.Intersect(r)
continue
case maskOp:
r := op.src.Bounds().Add(state.pos)
state.clip = state.clip.Intersect(r)
state.mask = op.src
state.maskp = state.pos
continue
case CallOp:
o.serialize(state, op.startIdx)
case drawOp:
r := op.bounds(&o.scratch).Add(state.pos)
state.clip = state.clip.Intersect(r)
if !state.clip.Empty() {
o.frame = append(o.frame, frameOp{state, op})
}
}
state = origState
}
}
type offsetOp image.Point
func (o offsetOp) Add(ops Ctx) {
ops.add(o)
}
func Offset(ops Ctx, off image.Point) {
offsetOp(off).Add(ops)
}
func Position(ops Ctx, c CallOp, off image.Point) {
Offset(ops, off)
c.Add(ops)
}
type ClipOp image.Rectangle
func (c ClipOp) Add(ops Ctx) {
ops.add(c)
}
func ColorOp(ops Ctx, col color.NRGBA) {
ops.add(imageOp{ops.ops.nrgba(col)})
}
func MaskOp(ops Ctx, img image.Image) {
ops.add(maskOp{ops.ops.intern(img)})
}
type maskOp struct {
src image.Image
}
func ImageOp(ops Ctx, img image.Image) {
ops.add(imageOp{ops.ops.intern(img)})
}
type imageOp struct {
src image.Image
}
func (im imageOp) bounds(scr *scratch) image.Rectangle {
return im.src.Bounds()
}
func (im imageOp) draw(_ *scratch, dst draw.Image, dr image.Rectangle, mask image.Image, maskp, pos image.Point) {
drawMask(dst, dr, im.src, pos, mask, maskp)
}
func drawMask(dst draw.Image, dr image.Rectangle, src image.Image, pos image.Point, mask image.Image, maskOff image.Point) {
// Optimize special cases.
if rgb, ok := dst.(*rgb565.Image); ok {
if mask == nil {
rgb.Draw(dr, src, pos, draw.Over)
return
}
}
// General case.
draw.DrawMask(
dst, dr,
src, pos,
mask, maskOff,
draw.Over,
)
}
type CallOp struct {
startIdx int
}
func (c CallOp) Add(ops Ctx) {
if c.startIdx > 0 {
ops.add(c)
}
}
type beginOp struct{}
type endOp struct{}
type drawOp interface {
bounds(scratch *scratch) image.Rectangle
draw(scratch *scratch, dst draw.Image, dr image.Rectangle, mask image.Image, maskp, pos image.Point)
}
type TextOp struct {
Src image.Image
Face *bitmap.Face
Dot image.Point
Txt string
LetterSpacing int
}
func (t TextOp) bounds(scr *scratch) image.Rectangle {
b := t.drawBounds(scr, nil, image.Rectangle{}, nil, image.Point{}, image.Point{})
return b.Intersect(t.Src.Bounds())
}
func (t TextOp) draw(scr *scratch, dst draw.Image, dr image.Rectangle, mask image.Image, maskp, pos image.Point) {
t.drawBounds(scr, dst, dr, mask, maskp, pos)
}
func (t TextOp) drawBounds(scr *scratch, dst draw.Image, dr image.Rectangle, mask image.Image, maskp, pos image.Point) image.Rectangle {
var orig draw.Image
src := t.Src
tpos := pos
if dst != nil && mask != nil {
orig = dst
src = mask
dst = image.NewAlpha(dr)
tpos = maskp
}
prevC := rune(-1)
dot := fixed.I(t.Dot.X)
var bounds image.Rectangle
for _, c := range t.Txt {
if prevC >= 0 {
dot += t.Face.Kern(prevC, c)
}
mask, advance, ok := t.Face.Glyph(c)
if !ok {
continue
}
off := image.Pt(dot.Round(), t.Dot.Y)
gdr := mask.Bounds().Add(off)
advance += fixed.I(t.LetterSpacing)
bounds = bounds.Union(gdr)
if dst != nil {
scr.glyph = mask
drawMask(dst, dr, src, tpos, &scr.glyph, pos.Sub(off))
}
dot += advance
prevC = c
}
if orig != nil {
drawMask(orig, dr, t.Src, pos, dst, dr.Min)
}
return bounds
}
func (t TextOp) Add(ops Ctx) {
t2 := t
t2.Src = ops.ops.intern(t2.Src)
ops.add(t2)
}

495
gui/saver/saver.go Normal file
View File

@ -0,0 +1,495 @@
package saver
import (
"image"
"image/color"
"image/draw"
"math/rand"
"time"
"golang.org/x/image/math/fixed"
"github.com/mineracks/seedhammer-v1-companion/gui/assets"
"github.com/mineracks/seedhammer-v1-companion/image/rgb565"
)
type State struct {
before time.Time
prev struct {
snake image.Rectangle
logo image.Rectangle
}
snake []joint
food struct {
color int
image.Point
}
dx, dy int
shY fixed.Int26_6
shV fixed.Int26_6
sY fixed.Int26_6
sV fixed.Int26_6
shTop int
mode mode
delay int
rand *rand.Rand
clear struct {
x int
y int
}
}
type mode int
const (
modeClear mode = iota
modeSnake
modeGameOver
)
type joint struct {
filled bool
image.Point
}
const gridSize = 8
const snakeLen = 5
type logo struct {
Bounds image.Rectangle
Boxes []image.Point
}
var (
tail = rgb(0xd9d9d9)
white = rgb(0xffffff)
)
var colors = []image.Image{
rgb(0xff0000), // Red
rgb(0xffa202), // Orange Peel
rgb(0xffff00), // Yellow
rgb(0x00ff00), // Green
rgb(0x00fff2), // Cyan / Aqua
rgb(0x0097fe), // Azure Radiance
rgb(0xe000ff), // Electric Violet
rgb(0xff00aa), // Hollywood Cerise
}
func logoFor(width int) logo {
return buildLogo(width > 400)
}
func (s *State) reset(dims image.Point) {
s.delay = 20
s.shY = 0
s.shV = fixed.I(-20)
s.sY = 0
s.sV = 0
s.mode = modeSnake
s.rand = rand.New(rand.NewSource(time.Now().UnixNano()))
location := image.Point{
X: s.rand.Intn(dims.X / gridSize),
Y: s.rand.Intn(dims.Y / gridSize),
}
switch s.rand.Intn(4) {
case 0:
s.dx = 0
s.dy = -1
location.Y = dims.Y + snakeLen
case 1:
s.dx = 1
s.dy = 0
location.X = -snakeLen - 1
case 2:
s.dx = 0
s.dy = 1
location.Y = -snakeLen - 1
case 3:
s.dx = -1
s.dy = 0
location.X = dims.X + snakeLen
}
s.snake = []joint{}
for len(s.snake) < snakeLen {
location.X += s.dx
location.Y += s.dy
s.snake = append(s.snake, joint{Point: location})
}
placeFood(s, dims)
}
func placeFood(s *State, dims image.Point) {
outer:
for {
s.food.X = s.rand.Intn(dims.X/gridSize-2*1) + 1
s.food.Y = s.rand.Intn(dims.Y/gridSize-2*1) + 1
for _, j := range s.snake {
if j.Point == s.food.Point {
continue outer
}
}
break
}
}
func (s *State) stepClear(dims image.Point) {
for i := 0; i < 3; i++ {
s.snake = append(s.snake, joint{Point: image.Point{
s.clear.x,
s.clear.y,
}})
if len(s.snake) > snakeLen {
tail := s.snake[0]
if tail.Y*gridSize == dims.Y {
s.mode = modeSnake
s.reset(dims)
return
}
s.snake = append(s.snake[:0], s.snake[1:]...)
}
if s.clear.y%2 == 0 {
s.clear.x += 1
} else {
s.clear.x -= 1
}
if s.clear.x*gridSize >= dims.X || s.clear.x*gridSize < 0 {
s.clear.y += 1
}
}
}
func (s *State) update(dims image.Point) {
if s.delay > 0 {
s.delay -= 1
return
}
if s.mode == modeClear {
s.stepClear(dims)
return
}
head := s.snake[len(s.snake)-1]
switch {
case s.food.X < head.X:
s.dx = -1
case s.food.X > head.X:
s.dx = 1
case s.food.X == head.X:
s.dx = 0
}
switch {
case s.food.Y < head.Y:
s.dy = -1
case s.food.Y > head.Y:
s.dy = 1
case s.food.Y == head.Y:
s.dy = 0
}
if s.dx != 0 {
s.dy = 0
}
update:
for s.mode == modeSnake {
newHead := image.Point{X: head.X + s.dx, Y: head.Y + s.dy}
if newHead.X < 0 {
newHead.X = dims.X/gridSize - 1
} else if newHead.X > dims.X/gridSize-1 {
newHead.X = 0
}
if newHead.Y < 0 {
newHead.Y = dims.Y/gridSize - 1
} else if newHead.Y > dims.Y/gridSize-1 {
newHead.Y = 0
}
neck := s.snake[len(s.snake)-2]
if neck.Point == newHead {
s.dx *= -1
s.dy *= -1
continue
}
for _, j := range s.snake {
if j.Point == newHead {
s.mode = modeGameOver
continue update
}
}
j := joint{
Point: newHead,
}
if newHead == s.food.Point {
s.snake = append(s.snake, j)
placeFood(s, dims)
for {
color := s.rand.Intn(len(colors))
if color != s.food.color {
s.food.color = color
break
}
}
} else {
s.snake = append(s.snake[:0], s.snake[1:]...)
s.snake = append(s.snake, j)
}
break
}
if s.mode == modeGameOver {
minY := 1000
for _, c := range s.snake {
if c.Y < minY {
minY = c.Y
}
}
const a = fixed.Int26_6(1.7*10*64) / 10
const b = fixed.Int26_6(-3.5*10*64) / 10
s.shV += a
s.shY += s.shV
s.sV += b
if s.sV < 0 {
s.sV = 0
}
s.sY += s.sV
sTop := fixed.I(minY*gridSize) + s.sY
if sTop < s.shY && sTop < fixed.I(dims.Y) {
s.shY = fixed.I(minY*gridSize) + s.sY
s.sV = s.shV
const k = fixed.Int26_6(0.8*10*64) / 10
s.shV = -s.shV.Mul(k)
}
l := logoFor(dims.X)
s.shTop = s.shY.Round() - l.Bounds.Dy()
if s.shTop > dims.Y {
s.reset(dims)
}
}
}
type Screen interface {
DisplaySize() image.Point
// Dirty begins a refresh of the content
// specified by r.
Dirty(r image.Rectangle) error
// NextChunk returns the next chunk of the refresh.
NextChunk() (draw.RGBA64Image, bool)
Now() time.Time
}
func drawScreen(screen Screen, dr image.Rectangle, f func(chunk draw.RGBA64Image)) {
screen.Dirty(dr)
for {
c, ok := screen.NextChunk()
if !ok {
break
}
imageDraw(c, c.Bounds(), image.NewUniform(color.Black), image.Point{}, draw.Src)
f(c)
}
}
func imageDraw(dst draw.RGBA64Image, dr image.Rectangle, src image.Image, sp image.Point, op draw.Op) {
switch dst := dst.(type) {
case *rgb565.Image:
dst.Draw(dr, src, sp, op)
return
}
draw.Draw(dst, dr, src, sp, op)
}
func (s *State) Draw(screen Screen) {
// Throttle frame time.
now := screen.Now()
d := now.Sub(s.before)
s.before = now
const minFrameTime = 40 * time.Millisecond
if sleep := minFrameTime - d; sleep > 0 {
time.Sleep(sleep)
}
dims := screen.DisplaySize()
s.update(dims)
lr := s.prev.logo
s.prev.logo = image.Rectangle{}
var logo logo
if s.mode == modeGameOver {
logo = logoFor(dims.X)
centerx := (dims.X - logo.Bounds.Dx()) / 2
s.prev.logo = logo.Bounds.Add(image.Pt(centerx, s.shTop))
lr = lr.Union(s.prev.logo)
}
drawScreen(screen, lr, func(screen draw.RGBA64Image) {
if s.mode == modeGameOver {
b := s.prev.logo
drawBoxes(screen, logo.Boxes, b.Min.X, b.Min.Y)
}
})
var snake image.Rectangle
for _, j := range s.snake {
m := image.Pt(j.X*gridSize, j.Y*gridSize+s.sY.Round())
snake = snake.Union(image.Rectangle{
Min: m,
Max: m.Add(image.Pt(boxSize, boxSize)),
})
}
food := assets.LogoSmall.Bounds().Add(image.Pt(s.food.X*gridSize-6, s.food.Y*gridSize-3))
if s.mode == modeSnake {
snake = snake.Union(food)
}
drawScreen(screen, snake.Union(s.prev.snake), func(screen draw.RGBA64Image) {
s.drawSnake(screen)
if s.mode == modeSnake {
draw.DrawMask(
screen,
food,
colors[s.food.color],
image.Pt(0, 0),
assets.LogoSmall,
image.Pt(0, 0),
draw.Over,
)
}
})
s.prev.snake = snake
}
func (s *State) drawSnake(screen draw.RGBA64Image) {
for i, j := range s.snake {
color := tail
if i == len(s.snake)-1 {
color = white
}
dr := image.Rectangle{
Min: image.Pt(j.X*gridSize, j.Y*gridSize+s.sY.Round()),
}
dr.Max = dr.Min.Add(image.Pt(boxSize, boxSize))
if j.filled {
clearBox(screen, dr.Min.X, dr.Min.Y, color)
} else {
drawBox(screen, dr.Min.X, dr.Min.Y, color)
}
}
}
func buildLogo(wide bool) logo {
S := []image.Point{
{0, 0}, {1, 0}, {2, 0},
{0, 1},
{0, 2}, {1, 2}, {2, 2},
{2, 3},
{0, 4}, {1, 4}, {2, 4},
}
E := []image.Point{
{0, 0}, {1, 0}, {2, 0},
{0, 1},
{0, 2}, {1, 2}, {2, 2},
{0, 3},
{0, 4}, {1, 4}, {2, 4},
}
D := []image.Point{
{0, 0}, {1, 0}, {2, 0},
{0, 1}, {3, 1},
{0, 2}, {3, 2},
{0, 3}, {3, 3},
{0, 4}, {1, 4}, {2, 4},
}
A := []image.Point{
{1, 0}, {2, 0},
{0, 1}, {3, 1},
{0, 2}, {1, 2}, {2, 2}, {3, 2},
{0, 3}, {3, 3},
{0, 4}, {3, 4},
}
H := []image.Point{
{0, 0}, {3, 0},
{0, 1}, {3, 1},
{0, 2}, {1, 2}, {2, 2}, {3, 2},
{0, 3}, {3, 3},
{0, 4}, {3, 4},
}
M := []image.Point{
{0, 0}, {4, 0},
{0, 1}, {1, 1}, {3, 1}, {4, 1},
{0, 2}, {1, 2}, {2, 2}, {3, 2}, {4, 2},
{0, 3}, {2, 3}, {4, 3},
{0, 4}, {4, 4},
}
R := []image.Point{
{0, 0}, {1, 0}, {2, 0},
{0, 1}, {3, 1},
{0, 2}, {1, 2}, {2, 2},
{0, 3}, {3, 3},
{0, 4}, {3, 4},
}
seedOff := 7
hammerOff := image.Pt(0, 6)
if wide {
seedOff = 0
hammerOff = image.Pt(12+seedOff+5, 0)
}
logo := logo{
Bounds: image.Rectangle{Min: image.Pt(10000, 10000)},
}
buildBoxes := func(boxes []image.Point, x, y int) {
for _, b := range boxes {
b = b.Add(image.Pt(x, y))
logo.Bounds = logo.Bounds.Union(image.Rectangle{
Min: image.Pt(b.X, b.Y),
Max: image.Pt(b.X+1, b.Y+1),
})
logo.Boxes = append(logo.Boxes, b)
}
}
buildBoxes(S, 0+seedOff, 0)
buildBoxes(E, 4+seedOff, 0)
buildBoxes(E, 8+seedOff, 0)
buildBoxes(D, 12+seedOff, 0)
buildBoxes(H, 0+hammerOff.X, hammerOff.Y)
buildBoxes(A, 5+hammerOff.X, hammerOff.Y)
buildBoxes(M, 10+hammerOff.X, hammerOff.Y)
buildBoxes(M, 16+hammerOff.X, hammerOff.Y)
buildBoxes(E, 22+hammerOff.X, hammerOff.Y)
buildBoxes(R, 26+hammerOff.X, hammerOff.Y)
logo.Bounds = logo.Bounds.Canon()
logo.Bounds = image.Rectangle{
Min: logo.Bounds.Min.Mul(gridSize),
Max: logo.Bounds.Max.Mul(gridSize),
}
return logo
}
func drawBoxes(screen draw.RGBA64Image, boxes []image.Point, x, y int) {
for _, c := range boxes {
drawBox(screen, c.X*gridSize+x, c.Y*gridSize+y, white)
}
}
const boxSize = gridSize
func clearBox(screen draw.RGBA64Image, x, y int, img image.Image) {
dr := image.Rect(x, y, x+boxSize, y+boxSize)
imageDraw(screen, dr, img, image.Point{}, draw.Src)
}
func drawBox(screen draw.RGBA64Image, x, y int, img image.Image) {
const boxSize = gridSize - 1
dr := image.Rect(x+1, y+1, x+boxSize, y+boxSize)
imageDraw(screen, dr, img, image.Point{}, draw.Src)
}
func rgb(c uint32) image.Image {
r := uint8(c >> 16)
g := uint8(c >> 8)
b := uint8(c)
return image.NewUniform(color.RGBA{
A: 0xff, R: r, G: g, B: b,
})
}

118
gui/text/text.go Normal file
View File

@ -0,0 +1,118 @@
package text
import (
"image"
"unicode"
"unicode/utf8"
"golang.org/x/image/math/fixed"
"github.com/mineracks/seedhammer-v1-companion/font/bitmap"
)
type Line struct {
Text string
Width int
Dot image.Point
}
type Style struct {
Face *bitmap.Face
Alignment Alignment
LineHeight float32
LetterSpacing int
}
type Alignment int
const (
AlignStart Alignment = iota
AlignEnd
AlignCenter
)
func (l Style) Layout(maxWidth int, txt string) ([]Line, image.Point) {
var lines []Line
prevC := rune(-1)
adv := fixed.I(0)
wordAdv := fixed.I(0)
wordIdx := 0
maxAdv := 0
prev := 0
idx := 0
m := l.Face.Metrics()
asc, desc := m.Ascent, m.Descent
lheight := m.Height.Ceil()
if l.LineHeight != 0 {
lheight = int(float32(lheight) * l.LineHeight)
}
doty := asc.Ceil()
endLine := func() {
prevC = -1
if a := adv.Ceil(); a > maxAdv {
maxAdv = a
}
lines = append(lines, Line{
Text: txt[prev:idx],
Width: adv.Ceil(),
Dot: image.Pt(0, doty),
})
wordIdx = 0
wordAdv = 0
doty += lheight
}
for idx < len(txt) {
c, n := utf8.DecodeRuneInString(txt[idx:])
a, ok := l.Face.GlyphAdvance(c)
if !ok {
prevC = -1
idx += n
continue
}
softnl := unicode.IsSpace(c)
if softnl {
wordIdx = idx
wordAdv = adv
}
if prevC >= 0 {
a += l.Face.Kern(prevC, c)
}
a += fixed.I(l.LetterSpacing)
if c == '\n' || (idx > prev && (adv+a).Ceil() > maxWidth) {
if wordIdx > 0 {
idx = wordIdx
adv = wordAdv
_, n = utf8.DecodeRuneInString(txt[idx:])
softnl = true
}
endLine()
prev = idx
idx += n
adv = a
if softnl {
// Skip space or newline.
prev += n
adv = 0
}
continue
}
idx += n
prevC = c
adv += a
}
idx = len(txt)
if prev < idx {
endLine()
}
for i, line := range lines {
switch l.Alignment {
case AlignCenter:
lines[i].Dot.X = (maxAdv - line.Width) / 2
case AlignEnd:
lines[i].Dot.X = maxAdv - line.Width
}
}
return lines, image.Point{
X: maxAdv,
Y: doty - lheight + desc.Ceil(),
}
}

120
gui/theme.go Normal file
View File

@ -0,0 +1,120 @@
package gui
import (
"image/color"
"github.com/mineracks/seedhammer-v1-companion/font/comfortaa"
"github.com/mineracks/seedhammer-v1-companion/font/poppins"
"github.com/mineracks/seedhammer-v1-companion/gui/text"
)
var theme struct {
overlayMask uint8
activeMask uint8
inactiveMask uint8
}
type Styles struct {
title text.Style
subtitle text.Style
body text.Style
lead text.Style
button text.Style
word text.Style
keyboard text.Style
warning text.Style
nav text.Style
debug text.Style
progress text.Style
}
type Colors struct {
Background color.NRGBA
Text color.NRGBA
Primary color.NRGBA
}
var (
descriptorTheme Colors
singleTheme Colors
engraveTheme Colors
cameraTheme Colors
)
const leadingSize = 44
func init() {
prim := rgb(0x02427d)
descriptorTheme = Colors{
Background: rgb(0x267f26),
Text: rgb(0xe9f2ea),
Primary: prim,
}
singleTheme = Colors{
Background: rgb(0xdd9700),
Text: rgb(0xfbf4e8),
Primary: prim,
}
engraveTheme = Colors{
Background: rgb(0xd1e83cb),
Text: rgb(0xdffffff),
Primary: prim,
}
cameraTheme = Colors{
Text: rgb(0xfbf4e8),
}
theme.overlayMask = 0x55
theme.activeMask = 0x55
theme.inactiveMask = 0x55
}
func NewStyles() Styles {
return Styles{
title: text.Style{
Face: poppins.Bold23,
Alignment: text.AlignCenter,
LetterSpacing: -1,
LineHeight: 0.75,
},
body: text.Style{
Face: poppins.Regular16,
LineHeight: 0.75,
},
debug: text.Style{
Face: poppins.Bold10,
},
warning: text.Style{
Face: poppins.Bold23,
LineHeight: 0.75,
Alignment: text.AlignCenter,
},
lead: text.Style{
Face: poppins.Regular16,
LineHeight: 0.9,
Alignment: text.AlignCenter,
},
subtitle: text.Style{
Face: poppins.Bold16,
LineHeight: 0.9,
},
nav: text.Style{
Face: poppins.Bold23,
},
button: text.Style{
Face: poppins.Bold20,
Alignment: text.AlignCenter,
LineHeight: 0.70,
},
word: text.Style{
Face: comfortaa.Bold17,
},
keyboard: text.Style{
Face: poppins.Bold16,
},
progress: text.Style{
Face: poppins.Boldprogress45,
Alignment: text.AlignCenter,
LetterSpacing: -1,
},
}
}

28
gui/widget/label.go Normal file
View File

@ -0,0 +1,28 @@
package widget
import (
"image"
"image/color"
"math"
"github.com/mineracks/seedhammer-v1-companion/gui/op"
"github.com/mineracks/seedhammer-v1-companion/gui/text"
)
func Label(ops op.Ctx, l text.Style, col color.NRGBA, txt string) image.Point {
return LabelW(ops, l, math.MaxInt, col, txt)
}
func LabelW(ops op.Ctx, l text.Style, width int, col color.NRGBA, txt string) image.Point {
lines, sz := l.Layout(width, txt)
for _, line := range lines {
(&op.TextOp{
Src: image.NewUniform(col),
Face: l.Face,
Dot: image.Pt(line.Dot.X, line.Dot.Y),
Txt: line.Text,
LetterSpacing: l.LetterSpacing,
}).Add(ops)
}
return sz
}