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>
@ -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
BIN
gui/assets/arrow-down.png
Normal file
|
After Width: | Height: | Size: 152 B |
BIN
gui/assets/arrow-left.bin
Normal file
BIN
gui/assets/arrow-left.png
Normal file
|
After Width: | Height: | Size: 159 B |
BIN
gui/assets/arrow-right.bin
Normal file
BIN
gui/assets/arrow-right.png
Normal file
|
After Width: | Height: | Size: 159 B |
BIN
gui/assets/arrow-up.bin
Normal file
BIN
gui/assets/arrow-up.png
Normal file
|
After Width: | Height: | Size: 152 B |
BIN
gui/assets/button-focused.9.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
gui/assets/button-focused.bin
Normal file
BIN
gui/assets/camera-corners.9.png
Normal file
|
After Width: | Height: | Size: 578 B |
BIN
gui/assets/camera-corners.bin
Normal file
BIN
gui/assets/circle-filled.bin
Normal file
BIN
gui/assets/circle-filled.png
Normal file
|
After Width: | Height: | Size: 244 B |
BIN
gui/assets/circle.bin
Normal file
BIN
gui/assets/circle.png
Normal file
|
After Width: | Height: | Size: 337 B |
276
gui/assets/embed.go
Normal 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
@ -0,0 +1,3 @@
|
||||
package assets
|
||||
|
||||
//go:generate go run generator.go
|
||||
151
gui/assets/generator.go
Normal 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
BIN
gui/assets/hammer.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
gui/assets/icon-back.bin
Normal file
BIN
gui/assets/icon-back.png
Normal file
|
After Width: | Height: | Size: 563 B |
BIN
gui/assets/icon-backspace.bin
Normal file
BIN
gui/assets/icon-backspace.png
Normal file
|
After Width: | Height: | Size: 170 B |
BIN
gui/assets/icon-checkmark.bin
Normal file
BIN
gui/assets/icon-checkmark.png
Normal file
|
After Width: | Height: | Size: 186 B |
BIN
gui/assets/icon-discard.bin
Normal file
BIN
gui/assets/icon-discard.png
Normal file
|
After Width: | Height: | Size: 479 B |
BIN
gui/assets/icon-dot.bin
Normal file
BIN
gui/assets/icon-dot.png
Normal file
|
After Width: | Height: | Size: 233 B |
BIN
gui/assets/icon-edit.bin
Normal file
BIN
gui/assets/icon-edit.png
Normal file
|
After Width: | Height: | Size: 195 B |
BIN
gui/assets/icon-flip.bin
Normal file
BIN
gui/assets/icon-flip.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
gui/assets/icon-hammer.bin
Normal file
BIN
gui/assets/icon-hammer.png
Normal file
|
After Width: | Height: | Size: 512 B |
BIN
gui/assets/icon-info.bin
Normal file
BIN
gui/assets/icon-info.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
gui/assets/icon-left.bin
Normal file
BIN
gui/assets/icon-left.png
Normal file
|
After Width: | Height: | Size: 549 B |
BIN
gui/assets/icon-progress.bin
Normal file
BIN
gui/assets/icon-progress.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
gui/assets/icon-right.bin
Normal file
BIN
gui/assets/icon-right.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
gui/assets/icon-skip.bin
Normal file
BIN
gui/assets/icon-skip.png
Normal file
|
After Width: | Height: | Size: 175 B |
BIN
gui/assets/key-active.9.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
gui/assets/key-active.bin
Normal file
BIN
gui/assets/key-backspace.bin
Normal file
BIN
gui/assets/key-backspace.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
gui/assets/key.9.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
gui/assets/key.bin
Normal file
BIN
gui/assets/logo-small.bin
Normal file
BIN
gui/assets/logo-small.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
gui/assets/nav-btn-primary.bin
Normal file
BIN
gui/assets/nav-btn-primary.png
Normal file
|
After Width: | Height: | Size: 200 B |
BIN
gui/assets/nav-btn-secondary.bin
Normal file
BIN
gui/assets/nav-btn-secondary.png
Normal file
|
After Width: | Height: | Size: 257 B |
BIN
gui/assets/progress-circle.bin
Normal file
BIN
gui/assets/progress-circle.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
gui/assets/sh02.bin
Normal file
BIN
gui/assets/sh02.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
gui/assets/sh03.bin
Normal file
BIN
gui/assets/sh03.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
gui/assets/warning-box-bg.9.png
Normal file
|
After Width: | Height: | Size: 550 B |
BIN
gui/assets/warning-box-bg.bin
Normal file
BIN
gui/assets/warning-box-border.9.png
Normal file
|
After Width: | Height: | Size: 551 B |
BIN
gui/assets/warning-box-border.bin
Normal file
22
gui/doc.go
@ -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
960
gui/gui_test.go
Normal 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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
|
||||
}
|
||||