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.
|
// Command emulator is a browser-based SeedHammer v1 firmware runner.
|
||||||
//
|
//
|
||||||
// Phase 2 scaffolding stage. This binary boots a 240×240 canvas-backed
|
// Loads the upstream v1.3.0 gui package and drives it through a
|
||||||
// LCD mock and an 8-button input layer that maps keyboard events to the
|
// browser-side Platform implementation:
|
||||||
// v1's joystick + 3 keys. The real firmware GUI lift (upstream's gui/
|
// - Display: a 240×240 canvas, painted from Go RGBA via JS callback
|
||||||
// package + its assets/layout/op/saver/text/widget subpackages) lands in
|
// - Input: keyboard + on-screen button events through gui.ButtonEvent
|
||||||
// a follow-up commit; today this stage proves the build pipeline + the
|
// - Engraver: a no-op stub (the browser doesn't drive real hardware)
|
||||||
// platform/v1.Platform interface contract.
|
// - Camera: stub that emits empty FrameEvents (real QR-scan handoff
|
||||||
|
// lands once the SeedSigner sim wiring lands in Phase 2.5)
|
||||||
//
|
//
|
||||||
// Build:
|
// Build:
|
||||||
//
|
//
|
||||||
// GOOS=js GOARCH=wasm go build -o ./web/emulator/emulator.wasm ./cmd/emulator
|
// 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
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"errors"
|
||||||
"image"
|
"image"
|
||||||
"image/color"
|
"image/color"
|
||||||
"image/draw"
|
"image/draw"
|
||||||
"strconv"
|
"sync"
|
||||||
"syscall/js"
|
"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 (
|
const (
|
||||||
lcdWidth = 240
|
lcdWidth = 240
|
||||||
lcdHeight = 240
|
lcdHeight = 240
|
||||||
)
|
)
|
||||||
|
|
||||||
// browserPlatform implements v1.Platform against the JS host.
|
// browserPlatform implements gui.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.
|
|
||||||
type browserPlatform struct {
|
type browserPlatform struct {
|
||||||
frame *image.RGBA
|
frame *image.RGBA
|
||||||
events chan v1.Event
|
events chan v1.Event
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
pending []gui.Event
|
||||||
|
dirtyRect image.Rectangle
|
||||||
|
chunkSent bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func newBrowserPlatform() *browserPlatform {
|
func newBrowserPlatform() *browserPlatform {
|
||||||
return &browserPlatform{
|
return &browserPlatform{
|
||||||
frame: image.NewRGBA(image.Rect(0, 0, lcdWidth, lcdHeight)),
|
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) {
|
func (p *browserPlatform) Events(deadline time.Time) []gui.Event {
|
||||||
draw.Draw(p.frame, p.frame.Bounds(), frame, frame.Bounds().Min, draw.Src)
|
// Drain the v1.Event channel into gui.ButtonEvents. If no events
|
||||||
// Convert RGBA buffer → JS Uint8ClampedArray and call back into JS.
|
// 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))
|
jsBuf := js.Global().Get("Uint8ClampedArray").New(len(p.frame.Pix))
|
||||||
js.CopyBytesToJS(jsBuf, p.frame.Pix)
|
js.CopyBytesToJS(jsBuf, p.frame.Pix)
|
||||||
js.Global().Call("emulatorPaint", jsBuf, lcdWidth, lcdHeight)
|
js.Global().Call("emulatorPaint", jsBuf, lcdWidth, lcdHeight)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Push an event from JS into the events channel.
|
func (p *browserPlatform) ScanQR(qr *image.Gray) ([][]byte, error) {
|
||||||
func (p *browserPlatform) push(button v1.Button, pressed bool) {
|
// Stub: no decodes. Real implementation lands when SeedSigner sim
|
||||||
select {
|
// handoff wires up — the mock camera reads a sibling pane's canvas.
|
||||||
case p.events <- v1.Event{Button: button, Pressed: pressed}:
|
return nil, nil
|
||||||
default:
|
|
||||||
// Drop on full — shouldn't happen with the modest buffer the
|
|
||||||
// firmware needs, but better than blocking the JS thread.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
var plat *browserPlatform
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
plat = newBrowserPlatform()
|
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("emulatorVersion", js.FuncOf(exportVersion))
|
||||||
js.Global().Set("emulatorPushEvent", js.FuncOf(exportPushEvent))
|
js.Global().Set("emulatorPushEvent", js.FuncOf(exportPushEvent))
|
||||||
js.Global().Set("emulatorBootScreen", js.FuncOf(exportBootScreen))
|
|
||||||
js.Global().Set("emulatorLCDSize", js.ValueOf(map[string]any{
|
js.Global().Set("emulatorLCDSize", js.ValueOf(map[string]any{
|
||||||
"w": lcdWidth,
|
"w": lcdWidth, "h": lcdHeight,
|
||||||
"h": lcdHeight,
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Show the placeholder boot screen so the canvas isn't blank on load.
|
app, err := gui.NewApp(plat, emulatorVersion)
|
||||||
drawBootScreen(plat.frame)
|
if err != nil {
|
||||||
plat.Display(plat.frame)
|
js.Global().Get("console").Call("error", "gui.NewApp failed: "+err.Error())
|
||||||
|
select {}
|
||||||
|
}
|
||||||
|
|
||||||
// Future: wire plat to gui.Loop or whatever the lifted GUI exposes.
|
// Drive frames in a goroutine. Each Frame call processes events
|
||||||
// For now, just consume events so the channel doesn't fill up.
|
// and may render a new frame via Dirty + NextChunk.
|
||||||
go func() {
|
go func() {
|
||||||
for ev := range plat.events {
|
for {
|
||||||
// Echo to console for debugging; the real GUI will replace this.
|
app.Frame()
|
||||||
js.Global().Get("console").Call("log",
|
|
||||||
fmt.Sprintf("emu: %s %s",
|
|
||||||
buttonName(ev.Button),
|
|
||||||
boolStr(ev.Pressed, "press", "release"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
select {} // keep the runtime alive
|
select {}
|
||||||
}
|
}
|
||||||
|
|
||||||
func exportVersion(this js.Value, args []js.Value) any {
|
func exportVersion(this js.Value, args []js.Value) any {
|
||||||
return emulatorVersion
|
return emulatorVersion
|
||||||
}
|
}
|
||||||
|
|
||||||
// exportPushEvent: emulatorPushEvent(buttonId:number, pressed:boolean)
|
|
||||||
func exportPushEvent(this js.Value, args []js.Value) any {
|
func exportPushEvent(this js.Value, args []js.Value) any {
|
||||||
if len(args) != 2 {
|
if len(args) != 2 {
|
||||||
return nil
|
return nil
|
||||||
@ -125,66 +259,6 @@ func exportPushEvent(this js.Value, args []js.Value) any {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// exportBootScreen redraws the boot placeholder, useful for re-testing
|
func clearBlack(dst *image.RGBA) {
|
||||||
// after manual mucking.
|
draw.Draw(dst, dst.Bounds(), &image.Uniform{color.RGBA{0, 0, 0, 0xff}}, image.Point{}, draw.Src)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||