From 9675c05ff1b3b924ffa69c598bd9582413880fe5 Mon Sep 17 00:00:00 2001 From: mineracks <134782215+mineracks@users.noreply.github.com> Date: Thu, 28 May 2026 20:53:06 +1000 Subject: [PATCH] =?UTF-8?q?emulator:=20real=20gui=20=E2=80=94=20lift=20ups?= =?UTF-8?q?tream=20v1.3.0=20gui/=20+=20wire=20Platform?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- cmd/emulator/main.go | 298 ++- gui/assets/arrow-down.bin | Bin 0 -> 141 bytes gui/assets/arrow-down.png | Bin 0 -> 152 bytes gui/assets/arrow-left.bin | Bin 0 -> 141 bytes gui/assets/arrow-left.png | Bin 0 -> 159 bytes gui/assets/arrow-right.bin | Bin 0 -> 141 bytes gui/assets/arrow-right.png | Bin 0 -> 159 bytes gui/assets/arrow-up.bin | Bin 0 -> 141 bytes gui/assets/arrow-up.png | Bin 0 -> 152 bytes gui/assets/button-focused.9.png | Bin 0 -> 4876 bytes gui/assets/button-focused.bin | Bin 0 -> 883 bytes gui/assets/camera-corners.9.png | Bin 0 -> 578 bytes gui/assets/camera-corners.bin | Bin 0 -> 3261 bytes gui/assets/circle-filled.bin | Bin 0 -> 214 bytes gui/assets/circle-filled.png | Bin 0 -> 244 bytes gui/assets/circle.bin | Bin 0 -> 262 bytes gui/assets/circle.png | Bin 0 -> 337 bytes gui/assets/embed.go | 276 +++ gui/assets/gen.go | 3 + gui/assets/generator.go | 151 ++ gui/assets/hammer.bin | Bin 0 -> 15528 bytes gui/assets/hammer.png | Bin 0 -> 2718 bytes gui/assets/icon-back.bin | Bin 0 -> 276 bytes gui/assets/icon-back.png | Bin 0 -> 563 bytes gui/assets/icon-backspace.bin | Bin 0 -> 266 bytes gui/assets/icon-backspace.png | Bin 0 -> 170 bytes gui/assets/icon-checkmark.bin | Bin 0 -> 420 bytes gui/assets/icon-checkmark.png | Bin 0 -> 186 bytes gui/assets/icon-discard.bin | Bin 0 -> 586 bytes gui/assets/icon-discard.png | Bin 0 -> 479 bytes gui/assets/icon-dot.bin | Bin 0 -> 160 bytes gui/assets/icon-dot.png | Bin 0 -> 233 bytes gui/assets/icon-edit.bin | Bin 0 -> 447 bytes gui/assets/icon-edit.png | Bin 0 -> 195 bytes gui/assets/icon-flip.bin | Bin 0 -> 486 bytes gui/assets/icon-flip.png | Bin 0 -> 1643 bytes gui/assets/icon-hammer.bin | Bin 0 -> 692 bytes gui/assets/icon-hammer.png | Bin 0 -> 512 bytes gui/assets/icon-info.bin | Bin 0 -> 161 bytes gui/assets/icon-info.png | Bin 0 -> 1350 bytes gui/assets/icon-left.bin | Bin 0 -> 276 bytes gui/assets/icon-left.png | Bin 0 -> 549 bytes gui/assets/icon-progress.bin | Bin 0 -> 652 bytes gui/assets/icon-progress.png | Bin 0 -> 1625 bytes gui/assets/icon-right.bin | Bin 0 -> 279 bytes gui/assets/icon-right.png | Bin 0 -> 1363 bytes gui/assets/icon-skip.bin | Bin 0 -> 276 bytes gui/assets/icon-skip.png | Bin 0 -> 175 bytes gui/assets/key-active.9.png | Bin 0 -> 4828 bytes gui/assets/key-active.bin | Bin 0 -> 136 bytes gui/assets/key-backspace.bin | Bin 0 -> 196 bytes gui/assets/key-backspace.png | Bin 0 -> 1909 bytes gui/assets/key.9.png | Bin 0 -> 4834 bytes gui/assets/key.bin | Bin 0 -> 145 bytes gui/assets/logo-small.bin | Bin 0 -> 630 bytes gui/assets/logo-small.png | Bin 0 -> 2151 bytes gui/assets/nav-btn-primary.bin | Bin 0 -> 1255 bytes gui/assets/nav-btn-primary.png | Bin 0 -> 200 bytes gui/assets/nav-btn-secondary.bin | Bin 0 -> 1276 bytes gui/assets/nav-btn-secondary.png | Bin 0 -> 257 bytes gui/assets/progress-circle.bin | Bin 0 -> 16918 bytes gui/assets/progress-circle.png | Bin 0 -> 2402 bytes gui/assets/sh02.bin | Bin 0 -> 5888 bytes gui/assets/sh02.png | Bin 0 -> 2868 bytes gui/assets/sh03.bin | Bin 0 -> 5909 bytes gui/assets/sh03.png | Bin 0 -> 2871 bytes gui/assets/warning-box-bg.9.png | Bin 0 -> 550 bytes gui/assets/warning-box-bg.bin | Bin 0 -> 414 bytes gui/assets/warning-box-border.9.png | Bin 0 -> 551 bytes gui/assets/warning-box-border.bin | Bin 0 -> 411 bytes gui/doc.go | 22 - gui/gui.go | 2951 +++++++++++++++++++++++++++ gui/gui_test.go | 960 +++++++++ gui/layout/layout.go | 160 ++ gui/op/op.go | 372 ++++ gui/saver/saver.go | 495 +++++ gui/text/text.go | 118 ++ gui/theme.go | 120 ++ gui/widget/label.go | 28 + 79 files changed, 5820 insertions(+), 134 deletions(-) create mode 100644 gui/assets/arrow-down.bin create mode 100644 gui/assets/arrow-down.png create mode 100644 gui/assets/arrow-left.bin create mode 100644 gui/assets/arrow-left.png create mode 100644 gui/assets/arrow-right.bin create mode 100644 gui/assets/arrow-right.png create mode 100644 gui/assets/arrow-up.bin create mode 100644 gui/assets/arrow-up.png create mode 100644 gui/assets/button-focused.9.png create mode 100644 gui/assets/button-focused.bin create mode 100644 gui/assets/camera-corners.9.png create mode 100644 gui/assets/camera-corners.bin create mode 100644 gui/assets/circle-filled.bin create mode 100644 gui/assets/circle-filled.png create mode 100644 gui/assets/circle.bin create mode 100644 gui/assets/circle.png create mode 100644 gui/assets/embed.go create mode 100644 gui/assets/gen.go create mode 100644 gui/assets/generator.go create mode 100644 gui/assets/hammer.bin create mode 100644 gui/assets/hammer.png create mode 100644 gui/assets/icon-back.bin create mode 100644 gui/assets/icon-back.png create mode 100644 gui/assets/icon-backspace.bin create mode 100644 gui/assets/icon-backspace.png create mode 100644 gui/assets/icon-checkmark.bin create mode 100644 gui/assets/icon-checkmark.png create mode 100644 gui/assets/icon-discard.bin create mode 100644 gui/assets/icon-discard.png create mode 100644 gui/assets/icon-dot.bin create mode 100644 gui/assets/icon-dot.png create mode 100644 gui/assets/icon-edit.bin create mode 100644 gui/assets/icon-edit.png create mode 100644 gui/assets/icon-flip.bin create mode 100644 gui/assets/icon-flip.png create mode 100644 gui/assets/icon-hammer.bin create mode 100644 gui/assets/icon-hammer.png create mode 100644 gui/assets/icon-info.bin create mode 100644 gui/assets/icon-info.png create mode 100644 gui/assets/icon-left.bin create mode 100644 gui/assets/icon-left.png create mode 100644 gui/assets/icon-progress.bin create mode 100644 gui/assets/icon-progress.png create mode 100644 gui/assets/icon-right.bin create mode 100644 gui/assets/icon-right.png create mode 100644 gui/assets/icon-skip.bin create mode 100644 gui/assets/icon-skip.png create mode 100644 gui/assets/key-active.9.png create mode 100644 gui/assets/key-active.bin create mode 100644 gui/assets/key-backspace.bin create mode 100644 gui/assets/key-backspace.png create mode 100644 gui/assets/key.9.png create mode 100644 gui/assets/key.bin create mode 100644 gui/assets/logo-small.bin create mode 100644 gui/assets/logo-small.png create mode 100644 gui/assets/nav-btn-primary.bin create mode 100644 gui/assets/nav-btn-primary.png create mode 100644 gui/assets/nav-btn-secondary.bin create mode 100644 gui/assets/nav-btn-secondary.png create mode 100644 gui/assets/progress-circle.bin create mode 100644 gui/assets/progress-circle.png create mode 100644 gui/assets/sh02.bin create mode 100644 gui/assets/sh02.png create mode 100644 gui/assets/sh03.bin create mode 100644 gui/assets/sh03.png create mode 100644 gui/assets/warning-box-bg.9.png create mode 100644 gui/assets/warning-box-bg.bin create mode 100644 gui/assets/warning-box-border.9.png create mode 100644 gui/assets/warning-box-border.bin delete mode 100644 gui/doc.go create mode 100644 gui/gui.go create mode 100644 gui/gui_test.go create mode 100644 gui/layout/layout.go create mode 100644 gui/op/op.go create mode 100644 gui/saver/saver.go create mode 100644 gui/text/text.go create mode 100644 gui/theme.go create mode 100644 gui/widget/label.go diff --git a/cmd/emulator/main.go b/cmd/emulator/main.go index ef19873..0376679 100644 --- a/cmd/emulator/main.go +++ b/cmd/emulator/main.go @@ -2,116 +2,250 @@ // Command emulator is a browser-based SeedHammer v1 firmware runner. // -// Phase 2 scaffolding stage. This binary boots a 240×240 canvas-backed -// LCD mock and an 8-button input layer that maps keyboard events to the -// v1's joystick + 3 keys. The real firmware GUI lift (upstream's gui/ -// package + its assets/layout/op/saver/text/widget subpackages) lands in -// a follow-up commit; today this stage proves the build pipeline + the -// platform/v1.Platform interface contract. +// Loads the upstream v1.3.0 gui package and drives it through a +// browser-side Platform implementation: +// - Display: a 240×240 canvas, painted from Go RGBA via JS callback +// - Input: keyboard + on-screen button events through gui.ButtonEvent +// - Engraver: a no-op stub (the browser doesn't drive real hardware) +// - Camera: stub that emits empty FrameEvents (real QR-scan handoff +// lands once the SeedSigner sim wiring lands in Phase 2.5) // // Build: // // GOOS=js GOARCH=wasm go build -o ./web/emulator/emulator.wasm ./cmd/emulator -// -// The static shell (HTML/CSS/JS) lives under web/emulator/. JS exports -// documented in web/emulator/app.js. package main import ( - "fmt" + "errors" "image" "image/color" "image/draw" - "strconv" + "sync" "syscall/js" + "time" - "github.com/mineracks/seedhammer-v1-companion/platform/v1" + "github.com/mineracks/seedhammer-v1-companion/backup" + "github.com/mineracks/seedhammer-v1-companion/engrave" + "github.com/mineracks/seedhammer-v1-companion/gui" + v1 "github.com/mineracks/seedhammer-v1-companion/platform/v1" ) -const emulatorVersion = "v0.1-phase2-stub" +const emulatorVersion = "v0.2-phase2-gui" -// v1 hardware native LCD resolution. const ( lcdWidth = 240 lcdHeight = 240 ) -// browserPlatform implements v1.Platform against the JS host. -// -// Display() draws into an *image.RGBA buffer then ships it to JS via a -// Uint8ClampedArray that the JS shell paints onto a . Events() -// returns a channel fed by exportPushEvent calls from JS. +// browserPlatform implements gui.Platform against the JS host. type browserPlatform struct { frame *image.RGBA events chan v1.Event + + mu sync.Mutex + pending []gui.Event + dirtyRect image.Rectangle + chunkSent bool } func newBrowserPlatform() *browserPlatform { return &browserPlatform{ frame: image.NewRGBA(image.Rect(0, 0, lcdWidth, lcdHeight)), - events: make(chan v1.Event, 32), + events: make(chan v1.Event, 64), } } -func (p *browserPlatform) Events() <-chan v1.Event { return p.events } +// ─── gui.Platform impl ──────────────────────────────────────────────────── -func (p *browserPlatform) Display(frame image.Image) { - draw.Draw(p.frame, p.frame.Bounds(), frame, frame.Bounds().Min, draw.Src) - // Convert RGBA buffer → JS Uint8ClampedArray and call back into JS. +func (p *browserPlatform) Events(deadline time.Time) []gui.Event { + // Drain the v1.Event channel into gui.ButtonEvents. If no events + // pending, block (briefly) waiting for one or until deadline. + wait := time.Until(deadline) + if wait < 0 { + wait = 0 + } + out := p.drainPending() + if len(out) > 0 { + return out + } + if wait == 0 { + return nil + } + timer := time.NewTimer(wait) + defer timer.Stop() + select { + case ev := <-p.events: + out = append(out, p.toGuiEvent(ev)) + case <-timer.C: + } + // Drain any extras that piled up while we were waiting. + for { + select { + case ev := <-p.events: + out = append(out, p.toGuiEvent(ev)) + default: + return out + } + } +} + +func (p *browserPlatform) drainPending() []gui.Event { + p.mu.Lock() + out := p.pending + p.pending = nil + p.mu.Unlock() + return out +} + +func (p *browserPlatform) toGuiEvent(ev v1.Event) gui.Event { + return gui.ButtonEvent{ + Button: gui.Button(ev.Button), // enum order matches by construction + Pressed: ev.Pressed, + }.Event() +} + +// push is called from the JS bridge — feeds the buffered channel. +func (p *browserPlatform) push(button v1.Button, pressed bool) { + select { + case p.events <- v1.Event{Button: button, Pressed: pressed}: + default: + } +} + +func (p *browserPlatform) Wakeup() { + // no-op — JS-driven runtime; nothing to wake from. +} + +func (p *browserPlatform) PlateSizes() []backup.PlateSize { + // Mirror what's defined in backup.PlateSize. v1.3.0 ships + // SquarePlate and LargePlate. + return []backup.PlateSize{backup.SquarePlate, backup.LargePlate} +} + +func (p *browserPlatform) Engraver() (gui.Engraver, error) { + // The browser can't engrave anything. Return an Engraver that + // politely says "no" if the GUI ever tries to drive it. + return nullEngraver{}, nil +} + +func (p *browserPlatform) EngraverParams() engrave.Params { + // Values copied from upstream driver/mjolnir.Params at v1.3.0. + // We can't import the mjolnir package here because it transitively + // pulls in tarm/serial, which doesn't compile to GOOS=js (uses + // OS-specific syscalls). The layout math doesn't need the serial + // driver — just these two constants. + return engrave.Params{ + StrokeWidth: 38, + Millimeter: 126, + } +} + +func (p *browserPlatform) CameraFrame(size image.Point) { + // Stub: no camera in the browser yet. Emit an error FrameEvent so + // the gui's QR-scan screen stays in its "no camera" state instead + // of waiting forever. + p.mu.Lock() + p.pending = append(p.pending, gui.FrameEvent{Error: errCameraStubbed}.Event()) + p.mu.Unlock() +} + +func (p *browserPlatform) Now() time.Time { return time.Now() } + +func (p *browserPlatform) DisplaySize() image.Point { + return image.Pt(lcdWidth, lcdHeight) +} + +func (p *browserPlatform) Dirty(r image.Rectangle) error { + p.mu.Lock() + p.dirtyRect = r.Intersect(p.frame.Bounds()) + p.chunkSent = false + p.mu.Unlock() + return nil +} + +func (p *browserPlatform) NextChunk() (draw.RGBA64Image, bool) { + p.mu.Lock() + if p.chunkSent || p.dirtyRect.Empty() { + p.mu.Unlock() + // One-chunk model: we ship the whole frame to JS in a single + // JS callback when the gui completes a Dirty/NextChunk cycle. + p.flushFrame() + return nil, false + } + p.chunkSent = true + r := p.dirtyRect + p.mu.Unlock() + // gui writes into the returned RGBA64Image — sub-image of our frame + // for the dirty rect. Our buffer is RGBA which satisfies + // draw.RGBA64Image via the standard image package. + return p.frame.SubImage(r).(*image.RGBA), true +} + +// flushFrame ships the current frame buffer to JS. Called after each +// Dirty/NextChunk render cycle. +func (p *browserPlatform) flushFrame() { jsBuf := js.Global().Get("Uint8ClampedArray").New(len(p.frame.Pix)) js.CopyBytesToJS(jsBuf, p.frame.Pix) js.Global().Call("emulatorPaint", jsBuf, lcdWidth, lcdHeight) } -// Push an event from JS into the events channel. -func (p *browserPlatform) push(button v1.Button, pressed bool) { - select { - case p.events <- v1.Event{Button: button, Pressed: pressed}: - default: - // Drop on full — shouldn't happen with the modest buffer the - // firmware needs, but better than blocking the JS thread. - } +func (p *browserPlatform) ScanQR(qr *image.Gray) ([][]byte, error) { + // Stub: no decodes. Real implementation lands when SeedSigner sim + // handoff wires up — the mock camera reads a sibling pane's canvas. + return nil, nil } +func (p *browserPlatform) Debug() bool { return false } + +var errCameraStubbed = errors.New("camera not implemented in browser stub") + +// ─── nullEngraver ───────────────────────────────────────────────────────── + +type nullEngraver struct{} + +func (nullEngraver) Engrave(_ backup.PlateSize, _ engrave.Plan, _ <-chan struct{}) error { + return errors.New("engraver not connected (browser emulator)") +} +func (nullEngraver) Close() {} + +// ─── JS bridge ──────────────────────────────────────────────────────────── + var plat *browserPlatform func main() { plat = newBrowserPlatform() + // Initial paint so the canvas isn't blank during gui bring-up. + clearBlack(plat.frame) + plat.flushFrame() + js.Global().Set("emulatorVersion", js.FuncOf(exportVersion)) js.Global().Set("emulatorPushEvent", js.FuncOf(exportPushEvent)) - js.Global().Set("emulatorBootScreen", js.FuncOf(exportBootScreen)) js.Global().Set("emulatorLCDSize", js.ValueOf(map[string]any{ - "w": lcdWidth, - "h": lcdHeight, + "w": lcdWidth, "h": lcdHeight, })) - // Show the placeholder boot screen so the canvas isn't blank on load. - drawBootScreen(plat.frame) - plat.Display(plat.frame) + app, err := gui.NewApp(plat, emulatorVersion) + if err != nil { + js.Global().Get("console").Call("error", "gui.NewApp failed: "+err.Error()) + select {} + } - // Future: wire plat to gui.Loop or whatever the lifted GUI exposes. - // For now, just consume events so the channel doesn't fill up. + // Drive frames in a goroutine. Each Frame call processes events + // and may render a new frame via Dirty + NextChunk. go func() { - for ev := range plat.events { - // Echo to console for debugging; the real GUI will replace this. - js.Global().Get("console").Call("log", - fmt.Sprintf("emu: %s %s", - buttonName(ev.Button), - boolStr(ev.Pressed, "press", "release"), - ), - ) + for { + app.Frame() } }() - select {} // keep the runtime alive + select {} } func exportVersion(this js.Value, args []js.Value) any { return emulatorVersion } -// exportPushEvent: emulatorPushEvent(buttonId:number, pressed:boolean) func exportPushEvent(this js.Value, args []js.Value) any { if len(args) != 2 { return nil @@ -125,66 +259,6 @@ func exportPushEvent(this js.Value, args []js.Value) any { return nil } -// exportBootScreen redraws the boot placeholder, useful for re-testing -// after manual mucking. -func exportBootScreen(this js.Value, args []js.Value) any { - drawBootScreen(plat.frame) - plat.Display(plat.frame) - return nil -} - -// drawBootScreen fills the frame with a minimal welcome image so users see -// SOMETHING the moment the WASM finishes loading. Future: this is replaced -// by gui.Loop()'s first frame. -func drawBootScreen(dst *image.RGBA) { - // Background. - draw.Draw(dst, dst.Bounds(), &image.Uniform{color.RGBA{0x12, 0x12, 0x12, 0xff}}, image.Point{}, draw.Src) - - // Frame border to make the LCD area unambiguous. - border := color.RGBA{0xff, 0x88, 0x00, 0xff} // accent orange - for x := 0; x < lcdWidth; x++ { - dst.SetRGBA(x, 0, border) - dst.SetRGBA(x, lcdHeight-1, border) - } - for y := 0; y < lcdHeight; y++ { - dst.SetRGBA(0, y, border) - dst.SetRGBA(lcdWidth-1, y, border) - } - - // Eight tick marks around the perimeter to show the button positions. - // Just a visual cue that this is real hardware-resolution rendering. - tick := color.RGBA{0xaa, 0xaa, 0xaa, 0xff} - for i := 0; i < 16; i++ { - dst.SetRGBA(lcdWidth/2-1+i-8, lcdHeight/2, tick) - dst.SetRGBA(lcdWidth/2, lcdHeight/2-1+i-8, tick) - } -} - -func buttonName(b v1.Button) string { - switch b { - case v1.ButtonUp: - return "Up" - case v1.ButtonDown: - return "Down" - case v1.ButtonLeft: - return "Left" - case v1.ButtonRight: - return "Right" - case v1.ButtonCenter: - return "Center" - case v1.Button1: - return "Button1" - case v1.Button2: - return "Button2" - case v1.Button3: - return "Button3" - } - return "btn-" + strconv.Itoa(int(b)) -} - -func boolStr(b bool, t, f string) string { - if b { - return t - } - return f +func clearBlack(dst *image.RGBA) { + draw.Draw(dst, dst.Bounds(), &image.Uniform{color.RGBA{0, 0, 0, 0xff}}, image.Point{}, draw.Src) } diff --git a/gui/assets/arrow-down.bin b/gui/assets/arrow-down.bin new file mode 100644 index 0000000000000000000000000000000000000000..9709edd261c7e0033a441de6006979101090ce43 GIT binary patch literal 141 tcmZQ%WIzB$5P)+*Yy=;|g7Hyl7>@z09>D|ikxYch!z}GuIQF5Tf1yL$ q!9z`s%NiQrGcx~XWP8TO=ElYlyoR;Rt<9bdWQM1!pUXO@geCw(7$_(J literal 0 HcmV?d00001 diff --git a/gui/assets/arrow-left.bin b/gui/assets/arrow-left.bin new file mode 100644 index 0000000000000000000000000000000000000000..bba91767bae6821b4f7d94d17211fd1c0c8788a6 GIT binary patch literal 141 ecmZQz00Txw7!6@CLIsGW85m$H$zy?S{|^8S;{huG literal 0 HcmV?d00001 diff --git a/gui/assets/arrow-left.png b/gui/assets/arrow-left.png new file mode 100644 index 0000000000000000000000000000000000000000..feb3d495ac6eeea372f40ea87f9e03f3b39d39fe GIT binary patch literal 159 zcmeAS@N?(olHy`uVBq!ia0vp^oIuRa$P6TT*vkEY6id3JuOkD)#(wTUiL5|AV{wqX z6T`Z5GB1G~wg8_HS0D`pOVf9r2U3hBL4Lsu4$p3+0XbTpE{-7_*I5rb3Nk2gFl;Ea y`!TUW;g}LrVDBTfYmuH8W7>`vWOhASbAvyFnPv7D&7uh)qdZ;xT-G@yGywppaw^RL literal 0 HcmV?d00001 diff --git a/gui/assets/arrow-right.bin b/gui/assets/arrow-right.bin new file mode 100644 index 0000000000000000000000000000000000000000..dff3f16f03c1d2137c85e4f25d33e5d46f4debdd GIT binary patch literal 141 acmZQ%WPkuhM#@<*Gl-=b;C6!D@E-sU5dkXz literal 0 HcmV?d00001 diff --git a/gui/assets/arrow-right.png b/gui/assets/arrow-right.png new file mode 100644 index 0000000000000000000000000000000000000000..4d01f411649733b0860f61cfca1ef5df88987186 GIT binary patch literal 159 zcmeAS@N?(olHy`uVBq!ia0vp^oIuRa$P6TT*vkEY6id3JuOkD)#(wTUiL5|AV{wqX z6T`Z5GB1G~wg8_HS0D`pOVf9r2U3hBL4Lsu4$p3+0XbTpE{-7_*I5r5@-i53Fl x{0L=Ycr@qYjep#duZ literal 0 HcmV?d00001 diff --git a/gui/assets/arrow-up.png b/gui/assets/arrow-up.png new file mode 100644 index 0000000000000000000000000000000000000000..5b966061cbb112e8b359e160f4b40784670911f2 GIT binary patch literal 152 zcmeAS@N?(olHy`uVBq!ia0vp^{6Ngf$P6T}J3bKsQY`6?zK#qG8~eHcB(ehejKx9j zP7LeL$-D$|*aCb)T!AzYEKT2e9!N2k1o;IsI6S+N2IMGvx;Tb#L?XK*Dpu_i1QD=U+q(8kK*jTV-aDT2`k#|Clli`T@9(?!ckg%4 zeA(*w7$5Kc-Vg-&C>2r-_-BB-pQk%`;z8XvLXg{hGCEqVjE;tFl+{S45D+wcMdk{L z;)J^Qf^pg+MxW6iFBTcL#QRHS1I>MM)I+a$7o+({ANMTa2aRAYo0nWx_SLCvSxffr z?6Gqm{JZo3OZPJIIKi*u~pl$Vc@5FdCkQcYXk>xO67K)nvViJ`J*_GL6 zR4Du?@M@pDpG1A9_&bazFBH~g2cFdL&RWyM7_1H47_w|zLHr+Ms_`|g{z=6eyx9KB zfQ7A3&vCDkxp1~@)aZGhT?DJg&&>^Zk*XP0NJifIc}Us|-$T8rHDzaa&k5z9-8tmB z&li=|p>e7V<()@7*c!-VQEB3eJy$cE zYA%aQW`8!Z^_knF9+W+A6-y4%GE^O>lR;1!c>OOIHMl+jh}~#SycUY8y=HU{^szBg zXmOW83NY}pDJIwfJv{EJWsO#hf4eQ$aEC+_5POTZbL|TKTzfM0) z&$1CP;UEE`BKzUm*gXSAc3)VxgAr>|dS>5iF>TwEH7YWapSJAbs=3XBiq5Hh61N0z z>Sf;slmfH>9@~5Ykmq^AB8AjcF@}i<@nZhWw-z?d5)W>U@-#(w!$rEDES*ER1S8o5Q zc^X;jXY{j*ub*Qd_qqPSJF@(9?~1tz^Zx}IB76&Xl%)*H30k&WF?HpnAjR4AS%>@n zTfDU2jOPjUbI)ZymTqslGCyhJijti0U~$be>%F;;$uUhY|JYu4|Fe^MRHN(|<8Yr> ztxGRv?hxR8f$U0ETqldgT!BJrPSX}wtfOKr# zGUdGFqxyZi3H|OuVis??TB)C&rR|!?34M|$J2N;mwEri9ty`0;R|cIb91$Gbsu>mH zpS3lZXFs1XIs9-2W&f=)*zP~Mpg?<|QCz3Fy|yskRmnIDGl@CNO?Ya|~M}2!>_jgyq zwp{$@=_OZm`#u&ZzPE&!rVc~by9F##z!pC@A%C24k#=c#g~}Ln`qyFSJT=4i#;*$S zJiRK>qvl$d?-s{Xua->O^?PaVtC5G0i^r5rYi^`pdHTHjepw^BfVXwg?Z?>5$K_LN z&omYFoV~$O@+#oz>t9UwT+W!UX1|*4KEEh*WiVU92VF6P1m&NoibHX$nS~jwdV=LJ z+rUvS z8mu^JKwYm~M94K7F+G!qbHq$OArdl0m{CykM0f`VuOvzW6`0f1{B1cOFX1V&paoz!9WngE~(mxm%; z6cOn-B9tda5k3VqT8)TIvt~k(MfyX3Q|3ON)OQA!X0d{S3pnZXE$bQof+evdap1<*GPsZQ40)YOo$b0eo zfvyj9y%z)TrTn3~KG5}E47``}hwA#j(dGU2Kt))xNh2 z(;;`(jyY~_`H8znVnnC0+)kWseFv9zN+@5@cg||$&Zp&`x^lVNtMAn25GDaJ|Ta3oIkqY^_W1PI415h@5MAu1K(%nP8f Vgrs#)hQJ=OAQ>q5&%l5a0RSaE4HEzW literal 0 HcmV?d00001 diff --git a/gui/assets/camera-corners.9.png b/gui/assets/camera-corners.9.png new file mode 100644 index 0000000000000000000000000000000000000000..989a8b0b2dc0f20b1ae2ed127dc18e1918a09c94 GIT binary patch literal 578 zcmV-I0=@l-P)EX>4Tx04R}tl08VnKp2MKrbAFB6^c+H)C#RSm|Xe?O&XFE z7e~Rh;NZ_<)xpJCR|i)?5c~mgb#YR3krKa43N2zhIPT%Sdtbi$?f{`)W}4M84rsb< zrV?>6lU)@ZLMN=c5B95w>PWeK{ zW0mt3XRTai%{uuF!#RCrnd>x%kia6AAVGwJDoQBBMvPXS6bmWZkNfxsUB5&wgweph_UQ##?biO#w#|RMG1sXNS`95}>#t9I72Cnp$zfuQgK1r{& zwCEAgyA51iw=`uBxZD8-o($QPT`5RY$mfCgGkT^h(02=TuX%lI?&I_UNK>zpH^9Lm zeU}1dulwA)tG#dko@w^)2Xk?9r>t%L(zopzjG(3V5GhD z=PC~bL6wSFH)2Wal(IIJ?N*UUB=TdK?s&&?c2}4u5{X21rXca Ze4rf+Z~_jncZE6eT~ literal 0 HcmV?d00001 diff --git a/gui/assets/circle-filled.bin b/gui/assets/circle-filled.bin new file mode 100644 index 0000000000000000000000000000000000000000..82089e8d0f2e1a805d10aa020874051b4add1d72 GIT binary patch literal 214 zcmY+8Ne)0z5CyCMz&G`UI3_E5E=J-CR)!F1Z&j7IDZoh)3vpa)f<6lwq?c6_)yJw; vZA5Y--mu2sBjyrk#8G}$LS_r@HXr@dcK6ND!3qQH5MhH67U-dc2?87^Cv*=2 literal 0 HcmV?d00001 diff --git a/gui/assets/circle-filled.png b/gui/assets/circle-filled.png new file mode 100644 index 0000000000000000000000000000000000000000..c0b8e6bcc0fa0960f5da789f3e37c4ba4170cda9 GIT binary patch literal 244 zcmeAS@N?(olHy`uVBq!ia0vp@Ak4xHBpZ(Y^aoNb>5jgR3=A9lx&I`x0{M)^LGDfr z>(0r%1afo(d_r7-H1Xiz0u4K$YQB;nzhH*tHczMiWPLIF$rS5lEGzTd<^g5AJzX3_ zIIbrrBuwfNNlHj~@U{B;ySdWL%>+*B*ikin5g_W$^PEtH@Ds1-8?s_ zyPSGJKc0m_6oj*upk*t{;+L(p3sy}iTb8WZ@kqrx37@>MXTvK`G(SIhGZ zx^prwft=U?pAc6dO*^oYUE~AMbh(lszhDO0bhfE-8Ov>WpR)ekv3<(J+59h_SU-9~Kq2HS1CjTBxqi**tgekvI9v?aHfWzopr0K6Mg%m4rY literal 0 HcmV?d00001 diff --git a/gui/assets/embed.go b/gui/assets/embed.go new file mode 100644 index 0000000..c239e3e --- /dev/null +++ b/gui/assets/embed.go @@ -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 +) diff --git a/gui/assets/gen.go b/gui/assets/gen.go new file mode 100644 index 0000000..7686a54 --- /dev/null +++ b/gui/assets/gen.go @@ -0,0 +1,3 @@ +package assets + +//go:generate go run generator.go diff --git a/gui/assets/generator.go b/gui/assets/generator.go new file mode 100644 index 0000000..4846788 --- /dev/null +++ b/gui/assets/generator.go @@ -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() +} diff --git a/gui/assets/hammer.bin b/gui/assets/hammer.bin new file mode 100644 index 0000000000000000000000000000000000000000..c2095c8866a1689e1ef439ebbb594fd91b2ef9f4 GIT binary patch literal 15528 zcmeI0dt4OP6~{;M0cztK(X6RjuxPb4Et{AY+O%D4ZPmthv2_GSmt8j~3Q16~)z}tX zgs|XaL0;;rs9CnP#rKm!VqGN?tx+*P(vaYzl0*|tZEIiY?YXlv?A)2l?hFt6N14xj zn3;3W{eADb_ntGeFieMzr$#*){={tYcq07CaZhzfM@y&CsbYXZZN7=ROk3TI!0k#tWHj~*LRW?2$~`FCDn@R9Q!Y{&^R)i<;5=NCM^se zadJjZ*K?DS7R#cOKU%FTyM>#y^!a&mmj5|9d0w}1fBLh9FIi~|J!j5rOZRer@yq9b z{=%YEYqG`S8^D~obC=JZ`*6667cF~nMMkQ1Ub1EOoH;#Qbu~eaSN|k9mcqW`*SBY$ zd-k)MT@&1=XJ7Tyt8RS{_SCEY>-XRb!Ap=Pj}a>0gJU(Rs&K}IGWh$qtACckzyKVS z8>b=w%`P!t561nWW=(Y+#svD#N&b;Kk&bvh_sRS8diBBGn@c((sIt#4>-f?KQG^_a6LXM}#!?(eK&whnkv9JkKA#cj9mL7cRWAzPjbu?@LQxfB4+j2=3s4Lqo#)uyBM84efkvGzB4Efjg@W zh@t$^dl0Q#r)?znPa;in?LD~D5Q@N$ed{a4OXISAx^$`s4htC))|U9V1_EJ`I%-Dpl8JZ^)`pnxz(1V1M3AyS9I?g3AU><3R&MM*0d(`w&Wt8g4uF zWAM0(YUUyZm;>G)Qe@6X`CGx><6Zo+Xy5tOmern^P8IT%WPZO3Qh(dDUbL{ zVlz23;i=KJ`x-=8U_O=Z0O0YI>fAgt$C1;_IxFvF-C>|E^PA@`!M?f42QGA>n+YBmqnJwn49gy z&TU7U5@;^qDvR>VNI#Y>;cA6mo?HA9oG&bfC2qz`tT;$8=E}hri6yNpO5bgJi)CFY zx%p;39KWQQYr+qXx+d5PcvRHf`@R^2EP z+PQ{FvPl5%CtY`AV4(oyXBZ~3sHBX~slye=3GcMc<>`=QwOk=#raVaNN9Nq&^*rMQIY5n*rAFwQqt8p&SlGt4EAA-wbu>0fMrV@|i7Uw40luyGX5kKYwlVzcr zQYNWYi`~!Fx=YNJ@Ye9A%L=KIOQi8Pd9bX0R6x}d>#_(;p{7eoo-YBE6pDoaKTZQ* zg%^Q2&tE2SP89_Wh=L+b(llni#)HW;J}*sT*kX9aVThufvB2K5ES+A36Bf zkxHE3O4BwXejzBDGjKj4kdaTr+VQ0A0x0c2k^ZPI~m8l_xDs2{}a+3Fm8 z)e>_GzIDX+l(5AE>C@V>)~(y;B)E+4Z7i!Fr<6(T%PE>@0Nc8(_3IlN5)u*`*0VA$ zBh(Ms>s_2-F{c8E)0zNysbMt$g@eLnqAB&G@k$4QSu&uXn?lYr!_eyoEs8+4c7Or6 z%z$V3h{-e##30%N0|RjxYW*O@X%7J8_lzJN@B-^=lg~3e3t%FRX(*p9i&YO4Ml(5n z5(UeMX&eNcwNEp!*Jt~EuCMefT80BUs(s?~Deb?ia?y@|G*aQC@tgKmKhDX*`_-9d&kH#FPo2PMKg@o7oaNIcK}h z)DU|vDzeh6&zR&sW7krs)VOsJ93#T%oqGohm+q7O$!I*i6oA{f0j^qB~~ zY1;IpxR{+OhG*lZR(VbCm(1r1HOMf7Te**G8I`&DDf=z@tx2Fn$QFe;GAc`f1_6kU5rLF zs|HyTP)R8YcjLZySD^BP$g~;{1I7k|OKY$o3;@R25SUXtgGw?K!yBYC~qFo0!fM=C0>N1o+q{x)>~0NS||JL4#v=)dpKfgk_? literal 0 HcmV?d00001 diff --git a/gui/assets/hammer.png b/gui/assets/hammer.png new file mode 100644 index 0000000000000000000000000000000000000000..0e06f4aa07cc3047e0ce4d48ca1c84db603cf13b GIT binary patch literal 2718 zcmYjS2{@E%8y;DPF{`mnh8#&w#=h_Sc1Q?mLPJEBV#socK}0xbjD2i_#-4rbF_T@$ zQnGXyI;BmGI%Q6B`oI5o{r~kp*Zbbz{oLR4ywCeS@AX|@rn8eR400R-0)b%mc2=&y zGZvWfg1i7`sB}4jK)fJlM>lK7YY__z3lsk}fCGlZDNRpL1KRD|w~yYCNF;y&;t@GQ zYip}Vj~)RCxNb1$S!palfOH_z!onQT0qzHd188h)bflYMk00rP8wE!K2pAB5Bm(I` zML#$caDg#3HPzG81BieDTlydVM<5-=0EK|X=jP_Lv^0S|*a!=nnwl6u_>YD+0s<(O zmKL_cf@Wr>00ClVW@Z5FsH7!`h$Nmm{r}a0Dsgc!Ha6BaHa0mqSwOs%l_ij9Zf@S& z(*vy8-`D4j1p6StKowP06L zm2oO6%KG|xwzf7}T3SX%hMJn1YHF(L>T1AY4Gj%UOpGms`5PMQJrUsCTr!PDI}aB~ zOibwP?99kW|LwP&`T2PVs6cabGtiL!{{Ga||Au5$xUtgb@nGps@w6?aUphc39 zBAG~$JP|P}T5NQ5w5F!!21+;-fdKBJNJOl-xQNAKdBcT*kiz~5hzAVnhk*OQVMG*? z$z-OAisa|#m6eqa4-e1I&W56($;ruug#|7!p)drTfPhq1R@g(qAt>RYp`ofOT2|Iw zcbJe18d`jrkdW|eF8Oy|tfalMVGs&3GB{XUTU%9CnUX?smcx3=Na#bs&g#lW2#BsS z?vfZR4vh$szzlYFChF?lQ&neFR5ZDJq^71eH#d<*L}h|DBY;oBBg)n3Jc!95&6|NS5s>f3a4?%^JF%gO&LPb%oL8BwIO8>o!KPk z`Qq>yg3ETknaj3JPv)Maku$#0kw!1p@HPf22!-2Sj0Aze^Z%YaeOWt|AdtYey_LCJ zQ1b3dj2Ajk63pSvht#_<-50t&b{Cgj2KF}wR9~<`mf^_LZ#q&v_%x>lCDR4k&)joB znPbM*MDEd|W$OvnW3JDmKkmKRQAr)#AME4n&8keKZklEr?I%X=Z+ylkzrAubyIe-& z)C~3KpO#L~zJBN068QWcSWz$EsyzJx!=e>8dE&Ro=kjIAxR)>1U9U2_#>|f!A;wR$L^fm zF)-aJw!UEpvb9kW5UJ?x)ukt&tgJK@4l=Hj(sVH3rb+Cm>p{r*AEQc={+o2PEIsrY_GnRUpDi_A?^VxCJ15=nBj8};fz|aYr z8CauyZsq9K?&JOmx?=EJu+(7Kdbbkm9pxqEY14&Uy`bm~lZ##>pp;826vBi3Wr zl$EdjdJ#2U6Gv1et;-oOiJ?>|y*xYJLp=w=z@eT_Ik>uFsISU>JpRGF#N?k+a`H{m zSb*H_Ad2nUxH%SW7tqSr<>WMCiyFJ~7A{)(qqQB)?>1BRKXmv@qmr5^A&rzbPG5JT z3$W7e3yxwI{BwVH3l2SBVP(#Kf<8F3E|jSCFJgx<7XJuj2z)iHnR}n#^yhP_Y0Z+T z2JReZy(BHKvyEb1yL&PZ+EfCQ6#q=G_3VimE!RkHr{3Nl-1(ut7$1=|a%roZOb9+xRbIOGP4P@z z^(mOX9h~q<7G*D*^7D^NLc2JMqF^34Q%Nr7MK2{CE6*GHdl`@TX`4(h3FW?nhIgRg z%{eFkmxVrAK35V{W$eDS&E)-k0^96Y%dOTfj2^#ZRqNU}T(m9bQ*;17Ay_DpadA!; zgYpI0$>B#Bk;KF}+v^MBZOvvad;M!M>;QuPvk@3&gLx=^fA7S$TtY$7M|yR}G9N7B zSY);64=94j7oU@B*L79?M0B6@fz*$NtHojP{S4#1O8q4kVb@mi?l+0>z*Xntd&F13 PFA!vJ?PS$rK}h)*^bG0U literal 0 HcmV?d00001 diff --git a/gui/assets/icon-back.bin b/gui/assets/icon-back.bin new file mode 100644 index 0000000000000000000000000000000000000000..3cdb255a8693a1ecc8d013d8b48096d996ab89cb GIT binary patch literal 276 zcmZvWK@tEU2mEX>4Tx04R}tl08VnKp2MKrbAFB6^c+H)C#RSm|Xe?O&XFE z7e~Rh;NZ_<)xpJCR|i)?5c~mgb#YR3krKa43N2zhIPT%Sdtbi$?f{`)W}4M84rsb< zrV?>6lU)@ZLMN=c5B95w>PWeK{ zW0mt3XRTai%{uuF!#RCrnd>x%kia6AAVGwJDoQBBMvPXS6bmWZkNfxsUB5&wgweph_UQ##?biO#w#|RMG1sXNS`95}>#t9I72Cnp$zfuQgK1r{& zwCEAgyA51iw=`uBxZD8-o($QPT`5RY$mfCgGkT^h(02=TuX%lI?&I_UNK>zpH^9Lm zeU}1dulwA)tG#dko@w^)2Xk?9r9{@!Zw{qh*4kWg0jmH2 literal 0 HcmV?d00001 diff --git a/gui/assets/icon-backspace.png b/gui/assets/icon-backspace.png new file mode 100644 index 0000000000000000000000000000000000000000..ad229107a3d7ac0744c87b525e7278d718bdc778 GIT binary patch literal 170 zcmeAS@N?(olHy`uVBq!ia0vp^${@_h3?x;=6{Ua_OS+@4BLl<6e(pbstUx|vage(c z!@6@aFM%Ak0G|+7APodd(|4W+Qj8@*e!&b5&u*jvIi{X2jv*Y;$q5T&HvG3|Q27ZY zCKZ_~Tsp_(a*ey`#Q(DnlRo}0w2;=~3uF%DzsPOa4s=70s(47&`OSwxrh2;ixvXUcGlA{YMZ+THGhMZJIj(b_Z?aP4x3F5mCYbZD ZiqRlNSinGewGhxi22WQ%mvv4FO#o@BGAIB5 literal 0 HcmV?d00001 diff --git a/gui/assets/icon-discard.bin b/gui/assets/icon-discard.bin new file mode 100644 index 0000000000000000000000000000000000000000..87780fbef118f1fcddfd75a7105f39ffb3e88e3f GIT binary patch literal 586 zcma)&OD=>#6h=?K`|sBJ>DBH1{v@n{33E%p7MLh~5<6f4A|Xc9SHI-f zGj~v_dv4XOQx_b)yh7xAG5K90k`~g`_5Z}*4%t2#zM8mF9A-tck~Cmapeg!N$*d-| zXq&ZZNj0+xX`X4b8Rn!hvvH|I*Q`rN8Zj$KWpZXY%F?1))wNNxS?W^NtRgjNnl))i z6*H2U1h&Sql8Az|F9qfnQQ6XN9+;ns%7mgN0$XP_RkpNc1G9QmwzNML*a}Okr*9PE z=L7R73MmWHSw!V1ov~Co9KMAw*o9}fgZ0OH6uTmS$7 literal 0 HcmV?d00001 diff --git a/gui/assets/icon-discard.png b/gui/assets/icon-discard.png new file mode 100644 index 0000000000000000000000000000000000000000..6a5018ca8029603163d3892fb32e921cef13af78 GIT binary patch literal 479 zcmeAS@N?(olHy`uVBq!ia0vp^${@_a3?wz#owI=yOS+@4BLl<6e(pbstUx|vage(c z!@6@aFM*u80G|+7AU%MfXJ=;O6lw_sizM6TN{$< zzkTQ6ym)DjuHI+&C!Ny!${h7}8La_oI_2r&7-Dhy>6PPohYdtnFHTGmjo!5C@x4pN z|Nl4Nymi1^Am#P$r;OsvO&>U-m#%vG%6#&wSH(418~)GOpIcXd(#B|WwQ;x4I`(Z% z*578ir!BN?e&Dda`$W!{Q-L3jxXxGFd~Kpz2j< literal 0 HcmV?d00001 diff --git a/gui/assets/icon-dot.bin b/gui/assets/icon-dot.bin new file mode 100644 index 0000000000000000000000000000000000000000..55744ac94dfc42246509cafbb570bafae83c663a GIT binary patch literal 160 zcmYj~OASkfJR9T^xl_H+M9WCijWi-X*q z7}lMWc?smG2l#}z0%>BvU7?>_f$Dflg8YIRK6xlSl>hl?=JXjKuYA1H2^9D6ba4#P zIG>y#VX1KGtT97qi|4a5l8PaUuZ`2s$(T3@zGCE>JF`J4gjuM_LGTnKr>Z~@tJ!Ks p*AoJl-E`h1b!e^Tl5m{J%5Yss@#?GP(I8JTc)I$ztaD0e0svFAJV5{e literal 0 HcmV?d00001 diff --git a/gui/assets/icon-edit.bin b/gui/assets/icon-edit.bin new file mode 100644 index 0000000000000000000000000000000000000000..70245dadcbd0586ef40406668f7eddc7303be704 GIT binary patch literal 447 zcmc)FNe%!Y2m?U)|D%Zm|vUxv^^8`6a*gnRm{AZu#ZTqP7lC5+Y27#clJY-< hO%eb2U%~2;(Ie)JR>2x43E?h~b)K$%F6*2UngCt$H*x>~ literal 0 HcmV?d00001 diff --git a/gui/assets/icon-flip.bin b/gui/assets/icon-flip.bin new file mode 100644 index 0000000000000000000000000000000000000000..ce6c1432897af57fa8339956b2c17fe0e72876e7 GIT binary patch literal 486 zcmZ9JOELsO5QaZ9qkE>Gqnl|AGun8h2#+I(K)92Qd$6>|6)YXW0vBK-gatMutE#&L z8BrBgUu6E7^%t0!X|+3@c5BvN?B@AWh3_d4UEQ7pVHC$v7)Y;+DDx6Y`>v=zCy7>& zBtE4>09( F`UB0$DenLP literal 0 HcmV?d00001 diff --git a/gui/assets/icon-flip.png b/gui/assets/icon-flip.png new file mode 100644 index 0000000000000000000000000000000000000000..26661bee3453a903d412068bfd7811d0b00b4862 GIT binary patch literal 1643 zcmeAS@N?(olHy`uVBq!ia0vp^${@_a3?wz#owFGj7;7_~odYtHlNlH^Ce}`}^*HPx z(RP2Cm#Y?^n4sX9fVnyyT^3oQT>*+$xYkX?(-2LFz2M^9CT|Ia-GMqi! zQQ!|_VQFDd$wZ<3-dws#HWu$5?yZjB_xl~g!nm|qR}J|xcI7S;Dm0uNE9$(SBU$1v zgIj8vWbvdaE$@Hqa&K5)ZaD90{k!uQ7eCWsV{v&DnKr}hBiqL>%W@0usrPMIv_@#Y zaR1B@g^$YDMf>kth{uRkq#f~D{;X5^NYcXIsb*=e!OHWL`c}>I`{{HndFp4e^tCam zXRUtn9=5Dao%!0NeZm2yX$p=lO>t+huVFmC&V_GQ-5q^49=*GZUb$)Nu>SgT{nVU#FQ6XAByV>Y%~RW3Re&7! z5>H=O_UAnE;?^d71+7{@=>t_E5hX#1>w za}mp?kDD$1_)UV|)i+A;e7<$lV|IkoiqiplvtNDLeeFwji_nFJ03gz)!jte|DPnyV>&d9j#1OLUnuL7o+U*NbMdy`-J?}wV; zFvCk4Qwk6AoRnsEe(bSkTH}>7>MYXw?hj_hzJK^Q;>S+6M$?}b&n1*E%8AE)SbeXs z`p|ZTnD8Qc41uxkL2xN`yQRy(DFd(Ksn>f_gtTT z1}JRkN)(yxs?lX&J@MN|=O3r|SwH+xotI=GagXQCydb4R>CR~@WXzYw*0L--ldm0| zn9r(KXZ`uk?Nu&o{T98_veUdEH}~Sj<_V|Vm!4SKzNqfZ^&Oc{`&%M%p1qw>Z8wW~ z)?KsjX6zT|*{nMLEak-ar*6~n|w_M0}>-egD5dKqy2^zmwmtXtJ8^$xP#!l8Vp%SaP2(lerz`Cw=Lt_PQJamyb0pi6X}X zB@TRN=};D4v1RWvZ54$+vxwLH8V2dj3a>e?oZKj@dH)>W?}<*YB$E!_7XJRwpw4BV z{h~PqR(yqz{Mv6yik61#Z*RM1Jm;vZ!1B47wYsm$mL19LGkJg4Ajroxys~n){U@(( zOZ(@XJeNe+cy;!=0UfSd;u=wsl30>zm0Xkxq!^40jEr>+jdYC-LyU~AOwFxK%(M** ztPBi37D#lUXvob^$xN%nt)W48feBE92Hb{{%-q!ClEmBsbUl_X8QN z0pH6Ahz*%9LQrQc{bPi!uvJGV}8o+&z5* z6p~6ab4qkG^Aw7M+?{;V9fg6aMPaIQ6Z1;b5|c|xi&Bfg%6;ZdI0aO|2Un1vlA6N+ V(i)xM=?s)%@O1TaS?83{1OS3Wawq@* literal 0 HcmV?d00001 diff --git a/gui/assets/icon-hammer.bin b/gui/assets/icon-hammer.bin new file mode 100644 index 0000000000000000000000000000000000000000..ca322c01fcf12cdc04a74def3c9b1fcb8f0f727a GIT binary patch literal 692 zcmd^*yG{a86h#*>1B|>>d@G9Y2cRG#C>}dv!XJ?E2{zhl;TKrhnAjL&`~f?9V`6J* zYb>mcjT&EPW|WNI;3@9TS=qUF-wp1FJ3m`n{weMX*$sF;Zz>3Oi*)?KVUf;6vPXT& z+is;|seee-gLUh3dNtxAtCnkD5Qz-fbhx-2k6G0f2QOx(W+J13UKm_bP$61YiOrLp z7U}n?eY~(C5+tuUfNySc%y6#OV{N@;;FlC;ZZ`T0Z4ISI@I!dQ3)9emO{hT`GEjsH zJVF&dKqJ4y3zXne--R>C!M*kYr*I2R(5Oe)*K3Y#ID~VkgJvzjCp^F_yuk@Pfri+D geK6q$uHg#uumyXNg%;ewE<8g@dw^p&fD8Eg2Ev|7Qvd(} literal 0 HcmV?d00001 diff --git a/gui/assets/icon-hammer.png b/gui/assets/icon-hammer.png new file mode 100644 index 0000000000000000000000000000000000000000..7b364a78cd7a474f9d0771de67e834db920fe09b GIT binary patch literal 512 zcmeAS@N?(olHy`uVBq!ia0vp^${@_a3?wz#owI=yOS+@4BLl<6e(pbstUx|vage(c z!@6@aFM*t00X`wFKzfLR)hbz^fljwA3GxeOP>8g1HP<$_cy0Yx?9UI=EsBeEpRz5y z5+gc=i6y6RsTZT+zvn-H&H1QQSi^ey#4!W+YMtG8iayIq&TLpNGo!o~XwYm=7sn8b z({C>y6mPdM>s&&i0c&d9Eg z^n;I@_?anoNc;_2~PWs=4*G;&( zR<3E-{6k&AaN>dO2Uzw8f+Doc>*5j~) zMBDvkUanevVuFHa0_N&;bXjDHb_FP2;aY1oBjy*ouhb+Farc8;A3QjpboJoT$Z+;> zM}a?#g{6f-B@>1Advoa~*;u@LxVJif-|u$}3**veT{Yy(*p<6XsL*h7tf=#Pj%10y z3~s4ulEsszw7mbZ%e`TJx#7I0_3zGKT>MOjjm70rWZDd~k8B^mEXyspr{1?=(Hf!o z!u>Nt6h10n7wx}qAs!=Ek#@vq`Lj;tBS{N;r<$d?1}o1~>RUC-@2AtT?f6_6Y}+rYShKG--Kh9Cqson=8Vtd8_~T5BAvoE_$soss=?m3z&E} zy=Jd(zQ1?tr^&)`K^A-^`z+2K=McEXDrI{7r(M~cW1IptZ0o*E+aJW9X6Lzn>y{US z7k2Z;#B4W7<==6Kk!>E&%_*^(9>FJ`H-0y+TBdWMoV#RwWo`Dm$6pvdg4a(nKX6d7 zb~;n)`rmhUuil&ge_rOd_w2=mMa#cP=U)NFheEPPkS_y6l^O#>Lkk1LFQ8Dv3kHT# z0|tgy2@DKYGZ+}e3+C(!v;j(R2Ka=y0%;)l$G~7?xm^m#Vk`;r3ubV5b|VeMN%D4g zVM^n!Nda=$OFVsD*3XcYiQ5_Wi@h5kGdiHJbjkcrKxQ zQBFMW!|Hp5)rYn#9A_8&eYz#&%pR?V_#Zv>#df?a+ipFYUDi}9me+8#X?69%%E#NH zA3rHDz4$%+mVRXD7a_r0;$gQqYg!H*o!&j+W7gt8SwG?XwhQYrcqDKC+V|+phL#6P z2g(^=zUTV%GeBWOSE9&lSB)+M>xth!I{!Gu&-&qq>bxWiiF-V6<^?GoN_S3MA!EKY zww7h#nSAZw#C%q@I_uAOZm)7#>$m8YmYwDWxw#iFHcvR^zVyV>_C&O@OeUlYJr}o?Y21c)nr;B5V#O36K1&kbx zh7!&nHwwJkz<4ddM~r!D+Oi2~KDmbgZgq$HN4S|t~y0x1R~ z10xe%14AGRF)*+KA|n%RFk}AR5~x7~ZbM0CZfbE!Vr~Hz zJ?0QgI%-{31NBIQ^Z*l}RdRkoWl?5&MhQbeX;MyRvVu!~ZenKMwuy$(KxIlWWhtq} z$wiq3C7Jno4DO!30SZZ_nK>o8nRyDuLGDgI>5jrc)uJ%fxruqDX^F`trA4VlVC6pZ iCIE{+20plg{FKxj29Vb11W#w641=eupUXO@geCx<;~u#H literal 0 HcmV?d00001 diff --git a/gui/assets/icon-left.bin b/gui/assets/icon-left.bin new file mode 100644 index 0000000000000000000000000000000000000000..e88c1c5e121c006255af16cd4fe47fd118922eac GIT binary patch literal 276 zcmZvWOBMhy1Owy#hi-!fO={sYZ+L);=rTE!gc5?+Nw5M~L$wA};Ox%lj-mWJkInzp LIiV#E7loV)OZWkX literal 0 HcmV?d00001 diff --git a/gui/assets/icon-left.png b/gui/assets/icon-left.png new file mode 100644 index 0000000000000000000000000000000000000000..1eba395afe05abccb9bbbbd697abecdcf545f305 GIT binary patch literal 549 zcmV+=0^0qFP)EX>4Tx04R}tl08VnKp2MKrbAFB6^c+H)C#RSm|Xe?O&XFE z7e~Rh;NZ_<)xpJCR|i)?5c~mgb#YR3krKa43N2zhIPT%Sdtbi$?f{`)W}4M84rsb< zrV?>6lU)@ZLMN=c5B95w>PWeK{ zW0mt3XRTai%{uuF!#RCrnd>x%kia6AAVGwJDoQBBMvPXS6bmWZkNfxsUB5&wgweph_UQ##?biO#w#|RMG1sXNS`95}>#t9I72Cnp$zfuQgK1r{& zwCEAgyA51iw=`uBxZD8-o($QPT`5RY$mfCgGkT^h(02=TuX%lI?&I_UNK>zpH^9Lm zeU}1dulwA)tG#dko@w^)2Xk?9r5Wd#;iQBZ`wg*T%YeKPf=H(x-b&!K2yq7R{WPTsIvB?O$E zLz{k0Ce!Ia9#N^Rw9%?us)Tb`He%%#QLSzMUDO*33%hLwPqtRnxmJ>Udrh5dd+q#F z=Q^ZafxBresB^Iu+4e;4X_2)N*<>PT+am82R8mm4_{x4N_M}$WL6Y6IV)jD684Oo^ zzn=eskAu7bFNtVZr^93Ow=^R%W4GZk+ilrr|Q!p**F zi#gK1HVaA*vkRYM<@{TMDilMp+1@I)brMYV&%lnw-W;Bl!<$=fAt%&DV)<|5XCDof zO&W=d5?_0;^DP#{7lai-AusjP>MvI-UQ|jia6vEVJXc8{!>M_xo?v&`Uzt3s)8$i| znrp!(IqUGX(<%!cS8VPvb3dy3)p)TU;8$et?v7&>%$S|Y=+M4^Ch(FB59r^uJAbG| zS=vC5zP60?zD?Zuv)6V^n9-$8ve##j@u$Yx8b4$!9j4>txb3)yJrW86F%9#c73<^0 z_aqf!#cPEZZqt!$YieENZ|_ipt+HNTO=c|ln=cUJe3_9PZv%?+{i(zy)94DL!ANOt zT~yq$*$E+n;#`+y+0nTzE^ia7ZtX>DRU2oW4=v6i!$ZTa2sJrR;Ci5$6uJWd(YgR6 z@BmnauM*w?5P<<;`X~VSd;ru!id&qlU?GNP??Qs(|1!P+uu0n{0M4z)r8_zUl=bQCsp^_y2kD{SslNWQ_8!O z78-Ut%A3(=;}g|oJ_<%CMea6N`)NCiriHD~gvWRqy6TB~{;tDpQG2D~io9P-(V99* zTJ$$W&^qu~1s!=rig1bAkd=w>Lm+DB<%im)X*rgI5~JQD@;aaAW1SCShxKzpJ7sz` zMQkT1)fW<<4Csk!?jnyDc+b6>a9?h=OEh1>O{wb)ZB;uwU-dFH=1vp32Z>ti=eZ2r zGE6wKd_F$hTK3v$!Rw;qNpY&d2~U%%VmhNHZg@_-2{9j9aue+Ga1|<|1Zsx_lCivw z?!0pu3jt+LTO5_f_LiRXm)ShJ+WvZ=o;R-D5s7#|C-q^4hOSRxC>N3RQ!sln7Z+@o z`{c#um$&nMaOy8*{@~NK@A%oWuWDJAd&h~E4sdojGdN=O&08&*TdW`BZ&}>8)O})|APnddBHOdkuDR(} zYjAllQi%i?T>snQUd20T1(Fj5bgQEt@EY#@vc4|w?ID=2rZ+XuG`uGyOa9p41(mt- zA6O?hy9xuHj8Fz{LcZsfE>3dOUuUoB*QQV%AagwjY5P$OQ<7B>I*4QA{qbxwv%<|H zsSMryO8Fy%hV`gg&Z5Xt?0C%SCBrW_TjzU{i-*@yQ_?!m75C26#D8v>p4+5{v=mpe zv!q}Xx5nFBGpbFE{NS%{=aSsHz6>r4&kSS10hnM+OpUQ7#->;o6H~l74sT+%1B1b1 zFiz(@tNsN9aeN_m)PDo$XA^2Lp#MDs7Ybs9a~VM)8;qGT1`AJ@uW#ecO#eyJ;tlPC z8O;p_?tMIy6A~2$`T27JEy5=dVxozhAO;kCBNOWh+qA#gd|BblFerozae@Jv>Oe#L zL_mRDV<;FMPAA*g`w^62x5_tn5F1%eIK^C;C8 ORsfacNUXIvn)El8Id0eh literal 0 HcmV?d00001 diff --git a/gui/assets/icon-right.bin b/gui/assets/icon-right.bin new file mode 100644 index 0000000000000000000000000000000000000000..76d104ca5aa3626e166086f3c29c9e14d67234a9 GIT binary patch literal 279 zcmZXOK@I>w1OxT|AKsf)2|-<2lWLgNpiCc>*5j~) zMBDvkUanevVuFHa0_N&;bXjDHb_FP2;aY1oBjy*ouhb+Farc8;A3QjpboJoT$Z+;> zM}a?#g{6f-B@>1Advoa~*;u@LxVJif-|u$}3**veT{Yy(*p<6XsL*h7tf=#Pj%10y z3~s4ulEsszw7mbZ%e`TJx#7I0_3zGKT>MOjjm70rWZDd~k8B^mEXyspr{1?=(Hf!o z!u>Nt6h10n7wx}qAs!=Ek#@vq`Lj;tBS{N;r<$d?1}o1~>RUC-@2AtT?f6_6Y}+rYShKG--Kh9Cqson=8Vtd8_~T5BAvoE_$soss=?m3z&E} zy=Jd(zQ1?tr^&)`K^A-^`z+2K=McEXDrI{7r(M~cW1IptZ0o*E+aJW9X6Lzn>y{US z7k2Z;#B4W7<==6Kk!>E&%_*^(9>FJ`H-0y+TBdWMoV#RwWo`Dm$6pvdg4a(nKX6d7 zb~;n)`rmhUuil&ge_rOd_w2=mMa#cP=U)NFheEPPkS_y6l^O#>Lkk1LFQ8Dv3kHT# z0|tgy2@DKYGZ+}e3+C(!v;j(R2Ka=y0%;)l$G~7?xm^m#Vk`;r3ubV5b|VeMN%D4g zVM^n!Nda=$OFVsD*XPASgue|l%JNFld4csS&*s#P6rmf zOM|2PWE6S+JQuNS`ncKBkKZKdU45ej&*xh=J!VHZtvDT^H~ZC>-PgWkw@9f;G^%HW zUP@!}5x%r+`o@I{(#lOTK~G#5cAvQ{s!$%!<+#9e^Q4K4>5Po)KJZ`M`zm0H`2~*K zu{ZgZ|9+?m4l}%@F{SVz&q--!=f@svrZrwUqs}6&@BUzB?E8n0BYy03Yc&07@mxar zqMUf#ht>BAs}F5gILt{2prCg0klA;b1K>iNN- zQ#-X-3a9^?`~KU8`}1VK*?&J;@!#=F){!sl`z9-dPVKk(4UAqxPZ!4!iOb0e3xpF~ zHpo0!!(gG!;?&*N)}|D}R}jW9N3_ZH45P39AsZ2Efp3>iUuHesCR7vucTJ zL`h0wNvc(HQ7VvPFfuSQ)-^QJH8u<}GPW|cure{yHZZUfEqO5Hk4%MrWThZ<`#hTnCKc90#OLqY%3EBh$TCsY>xumdKI;Vst0LT(0A^-pY literal 0 HcmV?d00001 diff --git a/gui/assets/icon-skip.bin b/gui/assets/icon-skip.bin new file mode 100644 index 0000000000000000000000000000000000000000..f9498ea38d0ec98277501fa17763fbd290d39574 GIT binary patch literal 276 zcmZXPK@I>R3^8@FbV(w literal 0 HcmV?d00001 diff --git a/gui/assets/key-active.9.png b/gui/assets/key-active.9.png new file mode 100644 index 0000000000000000000000000000000000000000..7030e5c0c1cfee11422d2a4cd641dcf6f9111ce1 GIT binary patch literal 4828 zcmeHKc~Dd577t4Wv7oIWs3^Aq6_A_UtR#`JNFs?6h=71Rt=`;w0|By-g+vgo-~!Zr zL($d+EDs%+qN7zTB8qiG!KVeE3R+r0(85!Eec<}O1VorR^TwIi|F|9VR)DNF+zGNDu{{_TYB1w*kM?a?6TIB&*em&`_y3G!)Wk)pA8DK_Yo? z$=bpfotHYViHtr(b_to4%fbI1F+m`7SGwd&zqsX4#@qGd^Rb0YFCWUr?39|CeHRYr ze0{9ksXQC%wQ22aEq+9v)>7b{P-{Bx>CxV-Gj#_BC&lcN8ziJc)t(M~c3oTSg@&27 zwn*@jc=^uH3W}YVKYsjeMroHU;A(YueP?vf1RHDeQ+G!A9#f7Djj?Jsj zyC~s7Pd%GlHr?mAE}meL*VYxc=Xuu3Dspx>$)`noemi~R;lhZ%*^Stao(YL%QCOMo z+2pl7FRsv=6dRyCp*k@~cPs>(5U)Yj@OKs`$#6d8ypv zmE(%~M&EErhWHoThS#kekD}lt*QCZN?JsYq70w9$*Tj?mxcesAX4h-4s+PusVZm?N znzZJ|?feH?JGTrv9-V#&f)7YZE%lFfYtKG_8&0=fD?7gFrX3V|Y;H5jZr$1VEk~QO zx|(lte^{BB(DTyjsgqW>X&Yq@NQ;{^JVpqfAG~7y-F<<;N*@&wO$u(iEjKS@^TGtA z+)t*6z+$vUv`|MPjTvvgtw@D?-N2@uUMvyXwOfy#FxK^6)!j-G$+{;aCSD(f8sB9d z(l65x5MfdPL{Z-FJLhlxZThEc?{hCqt=T&6^NTf04{LMWSFS`Wjg^Vdkjv4BM_mcd z@f)3GZLECrCgr;&-Z4%Q3$M+C9|m7$#i`tS(+W-`@SY`iKZ`GF>eDaETt%Dqh`3q+ zzui?*l6^YGt;0^(@}F~kmC~NZZgqZdmsddk)7$x-v0lrm4Y3Vf$|;8`v+kWcW&Hkr z^$(pDEM{N*nYg~^cgIy7_};UwV6%IYus1!pb&HqM@nG3FFCVvU-U(kG)Z6`bgu|b= zCXBJ@+7gE zBxIF1xiXgIIoh;cKRSK!`kBVjE4aQAb}79!q=NcnVQyb=Yvi#9GjzX5WEYtMew=5)B66x5kHy1XXb^Gce)*E+MsH{#Ib zwBpr%4b~yTmHR@DR=7X{q)fBc);8F&eU-zMpHc<(#WvEM$9}sb$3Y9GYPKE|Sxs(7 zHpH#p={*zUp;HScio7FsuafK-70;dd?BY?+b%puz1#og_!f_X$Q%OD-<}S*=R;#5S zm&sK>7k-@>M^$xwS+$@%dtP)(X>3*Fok^+eB$erO$o}KoBDp=K9eyR>tY|!DD2}XO zoY~P>)zmU-?8K8nb0)Hv-?~Am+&nk#%yZv@#VdY3A}PMJo!N71;dhdKh7&VbcP{=> zwIXMdGWkc*n*IDzwDHM->{i~5rK6Vi=8wz$8x%ZShs@fmZG&9vHm7l1u4tli{<$9- zmEA{f6pu~MQy&cJ3QC)|bIHl4)zb>BoBc0mwf*68{Ih)r&g3?iAlVuA5gv_H^%FQS zecy}ckz?-vzS8-%_lx<@iypl_kktCq`sy>)R^AUE&)T!j>N*&P846JT@se;JrdCo= zTrDFgCZz_n3W*dDWYVBm3ZaK&M502)CwHH&CPNCGPmW_r5Q!#~NK%M0v_y19L=2XZ zf^l(jP@qGAi3b3bgdT-V%2bt(XX2ABxIFM~-lmcvi;6ylPmY&Jp-{D!fS42}1%ZVo zMLL}v=l}(1aXBwaFlPV)toY<4yIhbSj+=0}WWWOr=Ln zuuA7?hUmu-5IRh&(C8Iv6=cRlWom<-PbLF7^v*w}Mj{!6SLp^=0DMqQsD?_TAXKH2 zI@Cg^7p4P{0f#V z695#UGk6G{hj3$PY#xop1C4>OdB_lbHLj2^`(N7T?tudOb1qWo!1>E8Tm3B+O{Dd& z`j@E+OD{o?r7L(S)*pfnO($@Rp8%_0ge9S>L;{SDfqH$nulPVM$Z#4?_os7!{}>Bq zA|Q)ggpk1uf`i~J9K|>s_HcBaTCO*uS|TJ7@Cdj9^=aV>^6t-+&v12P5@AjQU<^h8 zOM{GorG+u-K*Ut@)OZ(i0QFC#1XvV?j2U3pzX!$_n1`W z$RQua?+9HZbbS;9AEi7}T_bdT6aycnJW^f%H@Y0&e^3!B@HfZ^K9_RdT)qT8wYvbqY#vk1xdi@>dhm+HOO$Db^+cHkHu9JzCA^1aQ+)CAO?=hd*p1n*Qx1xFfAk$*YYR GI8zrop#p;d literal 0 HcmV?d00001 diff --git a/gui/assets/key-backspace.png b/gui/assets/key-backspace.png new file mode 100644 index 0000000000000000000000000000000000000000..95875dc301ccdd36d64541dcc91eb22bac1a0c72 GIT binary patch literal 1909 zcma)7OKjXk7$b6A}5Yiq6av^6>)(Bs5cM~i3^;1fPcJe6Vf2XlI?kX|KpqgG4BpHRu?Z{ zy(|dAVt=i-3I8Q{AGvrAzMm|aci?wvxbCjNfA+jXfB6zd3+3jo=zvp*yS;JvIuHj`YX9FC;Pa zJJKu001vV*8;5HLIomqeaOuGgwSDR44e|Qa0R=HDi8zg;q;RGksnYAfm@mtcSV792 zjPj}Z&35ysBH&Y*MJxtb0oIhp)M|e)Y|xzWD2%g80%FjhVBCOCb*DC6hQYt* z_a7wRsd(1VY@C*9F;4$SbF4xF!$-R1 zd&namM!xNtDzR*|Z@ zitS+AQS9fi;@}#b`eCqtCTL}ooq#GX6scx8idJjond88LL)ZzgFJLB#O?o)pG)nV9 z>T}nQulKv%VV(wI1RE70Z#Rre6eeP^xq6G&?JO16!UBlz&pW{udwo8NLb0kthtQeT zg6uJ0nlI8ZNk$Ah8Pv!O7M_w)%rJb4HH>Vlts_0440)!Bk);}nPl%;3BdFzzG$-BinFyTb?%66}M)!ml4=2M6 zc7oNcT@UNh7rR~Fp6!ZbnDBnk&^&g+oRQ4C>MT0)$-q^6$2xEghqlg#&3yus=0;)( z1TTmC=G#ZVdLM498~xt0d-1zB-h1f@0iQqj^$&l4^W)L4KO0Y-zaqS~xPElykbH3A zXc(avKRXPT9(?@ao5B6h!{7fDuHL=>+uenUD?IpX{Pbh|-s`Vy^gg}y+S~sCZe~za literal 0 HcmV?d00001 diff --git a/gui/assets/key.9.png b/gui/assets/key.9.png new file mode 100644 index 0000000000000000000000000000000000000000..f5f2cf40e9847351ba7ac069f2852bd13ebf2f7a GIT binary patch literal 4834 zcmeHKX;c$g7A`;qX<<-7QB;bcEK2r;q$C2eMhr%hK?HlCQmKRzvX}%C>9J`+QN|;< zvADJh_JA|ssHkX9+cMw=GB8bxC=DViZfB%LT4)(?UIGH1=`(XYXZjy?PF3D}_ulW_ z``!EQ$;%c+hS}N9wS^$aP7od(1%7S7Yj0x(zKgb(6+@8479uD}BnS$EHCnZtNX8+^ zYxkzz{P1d#WaSF zxMb2KT0ne^Jb!vYvE%Pwyf~6p`cmpw_icB>bMfn0R+gk!^O!3S8Z(XZCJwiIW>}0M zI=dnJx)6b1x!-Zhz0aNf=`5qX=2r2OZ1)=J>C8NP`699V5s#dc2O@u7-i+nFo~0;@ z!pd|#E?KYN)-vu8JK^jQpTG>8$?ScrH}7;FOo{R-Cj1{fbWI(canVtmS9Rm`7mHap z%3c3u_jyC};+4WQ!GlSSgPmO0qJYHNiOut$476-G;2H4aoXYQ7ht^va4lewrz4>@p zz&nm+LtFDceuviDIgKF`r=Ebx$3#$j!_)oRD;?yjlCP>nD~|o#+-x&+8f>$g(F*5~Gho z4e!$r>o;m}7&j6CF+BU9`K$Ll@|c!&pLcaZ)gFf#*Q*jvYBT4hr=yn)mlQp;o8psG zY6Cu5YqQDHaA{~L>8p4ziGAeS`UvvlfSc?!D(C(U1?OXtp7q^5g+*&NBIW!lhpB0I z-{LR)AN8^{20Qn`gc}1#xbdFr+usbw9QSp6Ve@*?LhIP+m*?*?E)>LNCN6OlI6m}A5iGdoutV;I|yNhLrw?wRiir$3GS~Q;oe| zLr?bW`qzDSjS@?-Nx6G4EmS#Y|HGOYrz<=wqwTAUXy~7!C1KHq;=gg)iN5;BBXluuaq|b|DeW%Lxxj zXlSh|)w}2H+ci1%Xvx!VW;o}(#TJ{0C5PNBEJW3%N0)D-yG*xy27TXwM0CxZ6rN{& z=#fv_lc;T%`tG%MxLNgG_o~jyMec>nj(S#G@Vm!n>Q1Gd%a$D6BR_)FRXjWU z_cz6%11*>Ds6|D>owG|t6`t(y`UM_dtQLbAXI!(_t)Q&U_!q}Jjvi^8JN z$RS1|l|fo&3;SthawIA^bOZv-_@qR=UV~7n27`fOU{cgt1(nX@@u)NgmBAnb4YF>d zN{<@JDxJFtVi+SB*I`;hqbJlV*o28n)hT*Di3H^Ed;gRgp>Pylr5j-Z@If`A8Y-Pa zqbilu@fJFLNGbpsap)H zssp>yKiD!%AQX*Sm?%&XN{!hHko|$Co{*1`^?`3Di8-9{jsWvf+z+(h$8J^zT0$Wb ztj1DI?g@hVB-8$gOpOsTWO&F$F_cNi$tccYl36kiPUdo?G_st|#^qAXS4NYgnbtMP`7F$t)It;xbt>GLME~JUWnYQHB{xh9RM9tr7+4B$TKEr)pFR zv%o|+5-1YzNel{YY(gB+IPZFXSyh=PKkq}B;tVd0B(mB3to-dEdqH{TPy07n; zQZ%mBfl@Sq(rFatun(rZARrpRENUuL0ARKN!5~3e9M!9}616&+Pcr!gn=D7gLU5jB zs2&YQ^*8{fF_;LAfzWsoIvZi}5E_k4%=eA0t4q_zARrE zS&nk)Ac0Ianag0~WHyWGi%OYXjK`IlqZ&omspWbDs>K5pfJeX;s82Iju-9;=ygyVo zB;uwt0LI8P26>Dz`Uqpxk%+0LuJJx(Kk9#w;%8PE*Jgm-@ET}epc_(0+u;alz~Nu> z8A-;kxdjYQZ1Pe3PS7<$*GDn%QOXn5H9^-$G4N5!6V>&9qsw;eLWQfq-yj3HF5Pjc ziv?FL4?%FC#3l|)NJ!}DltNY<3WeiPKZ)c#=UC@HX2+Ba8`@+Hg2GlnKUJ2rmY8m5 Nf{@7I^5qE`{|2q|10DbX literal 0 HcmV?d00001 diff --git a/gui/assets/key.bin b/gui/assets/key.bin new file mode 100644 index 0000000000000000000000000000000000000000..5223b7f4a9de4fc9a1266ac9d2c5dd5177e65441 GIT binary patch literal 145 jcmZQzU|?iq00BlI08vP6m>3x}%pAD6a5I4B|Hp#>qQ43& literal 0 HcmV?d00001 diff --git a/gui/assets/logo-small.bin b/gui/assets/logo-small.bin new file mode 100644 index 0000000000000000000000000000000000000000..aa8a5e19e33f3fc1bd36f7c69ec4d0d80af0b772 GIT binary patch literal 630 zcmdsy2?_up2n6;2kFNGm=?N8)%(Mzd@`R2-B`<0By~NUI*V&yywxy7vPTF;=!x$## VpMt8)6*x@9BOZ%UCiJaG?*{YhKD+<` literal 0 HcmV?d00001 diff --git a/gui/assets/logo-small.png b/gui/assets/logo-small.png new file mode 100644 index 0000000000000000000000000000000000000000..e2f35ea69b1438c3371de789d6a2bb9f2da9f46b GIT binary patch literal 2151 zcmdT_&2Jk;6kjNcN}wl%2p@;-mO~J%clN{kF{%?%J0C`I>L#WQw~S|I?Nz)V-QC1a z#G&d1dg&j45GsNLQY9o#AS#47B5~rcKth5G7r4Ni^?FG|kRownC3|OQe)D_p_uiW~ z_qto_m*!uZmn7*@d!w}t@8`wu;syBs==--nh1WOT&E6`!|G&@AKW0CG_sjYA){gY& ze|k1XFMJ17PbVAiWRmpEbK>`e^udPu4&hb`{9`93+Da%Xp)d>cIkmp36grg|K zlZI08Vi=2QO_2*ozTZ$*gh6??)0LZX%H?{^R;g~)Wyh=OwrP3Z8?u3PS3|C*o2qVO z3t_{M%SQogDf97mYpt{eUkzoL=Ly!d@pxPto3%I{Xu9Wl8ZtD)PywN4M^R2DYLs1> zF|>F_(;&%%IFbb;>Bk3oLjg~x62hd@sfeSj3>1V+n~+4)Ye)-2p{qb=`RXBTF5Ng2 zo%N0qu5I%yK1eBFJ>*e-Www32-RV}lg@{2ACWQ=$Yh{Gd3X>e9qhg&=jgNQ;WEuFR zS6J9rfzNbjW;zf2zvK59Qmhm@XV7FA=W#ZS|Ao0)t~NmgaVw?=!o7B@p$Ju&#WY~J z7+TyUu0x3GpuVkIhU2TA)kmshGPjQUdYuth7#BS4D9cGix!{4BS^y-(M?P>kDz{y# zS{`SrYj}=IJ&qjensvi6X8W(F0TeVDo%BcWFyQfQk21!Q+DAlJElxa@ILK2S$7YV# zcZg${iY$_ZSGw(nV$@Li*&UJGkJGTBbO@EN@08QMAmlqa5joIp$2A?-_Ubyabi*qV zH+h;tAQDEbx!c1ubmye?XCo4GSZ8#wscYL%<>J zM4T^(f+APtft&FtPCGFZuAQ83H=Erw_Ja{D6oUE(!6;XQNY1v`uZeRzErpFB1K~$y zC)nY)%h4#1i*twxom$PvA!kZCNrxmFaOh-kMy9afjFLi3caRGu>GqkcTGVo&q$pLH zVOfrE+a{~?+5Rl{^D#;J$^e9d%TV1#4&=pYF}yYdj)z=ix=aJ|L>hD#>va{`A`RNP z1f1pW`A)!8?&DsZQ4CYqDOJ;U-JKnMx!Dx$*()>#k?04N%o7n7iZszxr`gd?C$3mK z(Sc`hXzOBF-6!x--AFuw(R%B_-DGKV;g@&kq~D&D{_sE9{9W98 N?bWT;=hyb`{sjpChoAre literal 0 HcmV?d00001 diff --git a/gui/assets/nav-btn-primary.bin b/gui/assets/nav-btn-primary.bin new file mode 100644 index 0000000000000000000000000000000000000000..44da849d781904ddccc4852a6f850a914ba634e2 GIT binary patch literal 1255 zcmeH{!3}^g3SkfJR9T^xl_H+M9WCijWi-X*q z7}lMWc?smm1^9%x0%<&8yGB097|xO)zhH)azMon?`}urnG(P176g2j9aSYKopPUfT zGpCbD&0xcsIh}eToti40nkk){CdDBcJGdf+rmC43wr*IW-*&{$M3Ldf-@^5kDjuC6 OD?MHPT-G@yGywpwI5Rx} literal 0 HcmV?d00001 diff --git a/gui/assets/nav-btn-secondary.bin b/gui/assets/nav-btn-secondary.bin new file mode 100644 index 0000000000000000000000000000000000000000..571fe30634d1cc1c26f8399cfe2936c002c47b21 GIT binary patch literal 1276 zcmeH{xeJVTBe(*kFPh_5;EY4P-A z;uvCa`t8|`f(-^d&Vi9fIAvJh?9bqGN?`6@{B8YxPqvUvN~aQ@z1e6vug-Ccnd$+@ z%z~^>iK@P{Oze42Eh|5DC2iMham+c{5tImWtEa1<%Q~loCIIs^ BMd1Jd literal 0 HcmV?d00001 diff --git a/gui/assets/progress-circle.bin b/gui/assets/progress-circle.bin new file mode 100644 index 0000000000000000000000000000000000000000..bae0a5354eda8935400aba0d49fdc7d90b742bf1 GIT binary patch literal 16918 zcmc(nXLMA@6~}+7tA>O?NFWI$)KCQ^0YWqjgd{9cESqA{F&#`XU^*B#FvT{GOA^~j zoaWx%N&A*|a&pphlAM#?zNGhhQclXK?99BkZ+BWxDSEo#?|NRn@;Vjh(b~1MV@G>yt%+~eHPrTYN; z4Ou18%t*)EU34{0_vN}_`+2#(>5VSM+K>AMjDHW0570AspD#On`E1{Pg8@ay9}Zto zm&krOpzR+Vn5RpGqUQ}f=(HfK!N7E>GyipG`TmF3$y@_s-NXG(4SlYI##PsP=ajSM zTF72^Q<8gai^GDt^=@)5yd8V&CEuPi*F`EUYwnyByPiEe!j>5pXx(Zr);rUvn2XR% z$HK9-)kVn3+FoFTPdO6Fz;P(cK4NczbK4yPfysIH#!qVt6p4gWYo9`M!o5RNZ?V<> zx!WQEbCbQT#-{L=scu4&m%Ut<&Uz?Q=0r_oK4hCpmWS-l89IFWsi;A11wUY0L}%FY z?1>iL)YO((OCicQ8T)aYVG>Si6DA1^c62Pj_}Z}w?KQ4 z<+wyMGCgjMKU!IzJ_agX8=ze4{H)tuMPgvyu4{MO5lz(Ry$dlQ1u1$#Yuy5uJz<`f zzdR1_z0-`$J`Etxnaf^nrcNsg$c_V}NSlgN_Bs{btE@}k@63!(x>g&56C5fJDR}E{ zM_$~LcWWSr>{_*Dr#IvbZ7&vCYRw0g0d=;bOI1e7OSYW2Cf=#Ngto)!oeFw?`YFXuq-5AwgPtz5Wy#AI#Dp!));BQu3FQ{gPf3MWPPj$=5agyr^G47-^AZwo= zp-J+rXZ<{{e%C6jNn;pm3MH@DVhL8bX4HU9l4ITDRkfAgE`?S-%H1G2=}VNH zSJV~$WU&@daporz4fxXWg{CQpG;eE-t_3Gigx0uJ@*37W`2bZ*~)!AGS@1X z@^oFyJ9@fo@EifML#7hb{GfHfBDhQ9uXNd!dC>~FD<$MzaH~vIc>(68vr0nV3XjV~ zshg}9yiy7I4l>0R&e#*z6Hh3+XZaDWg_+YNJ=xqr6-v25UcPeGPTMK*(+o<+NkkoW zcB&0QXPlH9sHu^aZf=mIe>pZNAFD{71Z{XslCxvgYF>V}1nt}=$xY_vW0hPcLAP~C zvNl$&=H+W8Xh)$Wr2lON%7v3gvPVcR7iA)=7K?ZzT$R7)Ro3 zL~`&|10<*YlRf?lWe+6h{Y&=zqvqdbi5r00{G06cCn@XiBsu6BlD+>(%Kl4vLU$&6 z|C#Llce2kP$vA%^XDRIOmfcOB;5b-imdUl{{eOFl_391 zzr-&Y#@E~tN%22v{Yq1zj$Au z23!1z2ls&YXr1pCf5H@X3v4AJ{st;F3)5ExwoojbL)zH0#ID=zvBzI^yNN{0JC>@| ztv%R2|AcZzS=P~G(Sik*JEpu$mK8Mq3_OazTcy|DIvi-9KNAhOt|=4 z*nZAtK+3ygSdOz_(^eIs_J!>RR!?)pU)M#wA(`ojW8Z4#V%=fy0xKm>@#i_>w%h1g zukBMtI%CW3h~r|$H9O<)=bOggLw=ua42ZDvqJB#X zOP82haJ{t9UrPQ)^!Wb<-NgcXO`o0PDT-! zFh4F1wWX&cfYd}!FO9?YVWg3g`~B|al#Zc(dZFtRYO$Et2>;d>LCnc7!*%#IT!w?N4#Y-JtnqurNfg@Q z8*l^0gK$)S3%&%e!LQ)YPzGW@QUqUsb07|@D?ki75kyf9Uxv5fk8l%y2^Zn-;thgt zLb}-8z&}7l`-nZ&ZsB);Vh|32ufp%)XCfjHUVz`gkKvOb{B$BRPq@nlpaTxTx1kP1 zxZqs)82kjzz;aj*r{Pa<9>hRsfJXQT{1mprS3vwl!l&Vf@F}vK{x~xVIPR=@mJyV@EMo_pM!1iS$G@13!P94Ct(k~0nft>=!2{9BWQvx f@O7w%W>^Wc;2Jy)FTyE!6TSyK;kWQ__}~8l0c_t} literal 0 HcmV?d00001 diff --git a/gui/assets/progress-circle.png b/gui/assets/progress-circle.png new file mode 100644 index 0000000000000000000000000000000000000000..b4dac18bdd503de5a7001dcf71256143bd7c49c6 GIT binary patch literal 2402 zcmeHI`9IT-1Ae>jqa1x&j^%10w=~LZ3=>mxB}A!hq~jy^R!q4Ip*eGvB%wodOB1=- znDavjGYUDR$@;?d#_B zWA?uBTM7P z?F8&>jkAS#toQgvgyRLpCN`MWB2sY+$-D4~RSVIh2U5rsyVpjYk6tD%#a?&OF~@-o zX%D642D{2x>qi7+I|vT8XWpuISFNQk-us25^Gp8rkhmr;U(mZUk1AZAYE}SUIBViR ztf6B3;cK{+YuHcX>EoL3r!Vy=xBT<*ZfRMcn%j@4pc?nh^EYJhh*#~O4uAHhz8Qt8 zhhQu6j@cwkYI*fTwC!~hpS%dI9P|DDr$Il90|0_2P*!l~u<>3GoQKR=S#z~ibv3nE zdAJ@45JW6CL6G%eyL6T4==k{2x7&d?wwD+agw<=@nO$&yUk}z~%)pS)U)p;UJGJq> zFYn|kkDdx?zQXqR!s2)PTnMGNCwWTThC=3RL0+=CUv&|Q^t&tfuo)|wukw5FGk@B` zA$O=^+sdJ$4>>KTTEYskCpM{G-zUq~aSb#^ z)~uW74yCw{m_p~CVLwHlY?GKVxtgOhe7wiy5~OxkB2Xk|UbJ;dXXy*9K^2>08Oo2F zC81~fin5B#`j#Gq?twOTl*LW_6(>(|zzc>M(5-30UWTLICLYtzpPmvsXShkImMTb_ z$bZ)F838L=9y?#>l0!EQVW~mazwQXCme&@UC*(2b(kLWjF@y9=enZL5U3nddN96NK zm#&c036 zjdS2ksk?BK{=kkPiBU=%#i6or9Bgk_piq;ySu$GlrxXRiNDZR3CBMDbYPZd<6=+1l z-q*)Cthyx1O#a3O54g3^Qg~{83#zWTPUN?#)b&tb2h#m}zIBiCdithMk$uGUE%$Eo z5n#9rCEGZ?REaUe9-%F$`|;arLfYmk%E6Zln1@2-k{#bP*?3~n$19NF%i=$F%Ty{;KD3NApX{XB=DugTgZS7nKlK(m4u?; zEVl!<(^NzPr2+HQd-FdrV8>{VbPG*m_tk8P#S{Lqg{aAwdrLDVQ9>@kp{*ja5BW^s z&;b!`1b(K}LSLSn(N>T975(mwL6~=a>}AB1)d({MRg<{6sZ;= zjwVlQ7()af@UTpi3V7WQE&_%1L#JQ@8Z?w3ctlu1!19A9PdAJ-pER(317CL(@uGGD zYEN>^RkBbCIc}bOOvCj#wlrZ#{4CmwxaQO`S3SA3nHNc# zHF;-2QY8PaNdpJQaJlE7Q@es{!Xax7jrKwtu5y~pnp-ggW~@2CnCF;^Yi&!(7q+$0 zZ(OHu#RQ%i;Y$h*@*&aC`opuL9s;gduDumL~u~?-~V-L@*m^w=sHMA}}h|gVTTL!4A8QRhPzbSrgbt8(1!`ii! z60g9ni+(W?jRf0ji}y+OA5(d2QQ2ms6a_|K1r;TIB*|n8GN{+0yBL--TweG*MuMw* z;3`E-_NS21g(o6}xCtSMXL)p}{e3$0bsYWpu@CYo*=-{ze*wIj zNA{ipYPmw)uSxkjxI=deoX}g4ee@(3elYi=*N}mK&BJjo|Ge=Ns-3!c?RK3`P*)n{ z@xdu2o>n9lr!EAA5mL?V2eP!H=7@lJ| z!VGWDnzAUMj02I8g(Ce7=U_j-n=uE<*1z7 z8rkUI{E`u%(43979}H&v#bN09vtrsDssNyAMd8evxydBO8;D9CXdG^5vZZ(1nV~xE z^x@STekp=q$y&adA0@0ONJ;$UuU6EN+(2wVF+R7`uU;v+QGaxgt6cvw_L-A5RO8fj~^<)(Vi|M!Hqz-KW?UA6GQ&EBcn+fZgOOS?IqI?4 z@e%RbzqNUtoj57;{xEhb<&!Iut#^*c)s6I_RCSYNCRkrKM=}O$2;NsFW&D5xIE)T845#;O3z`iK8iM9tWgQ=G+(%%^<`OxD<2N zVtBxA%>)p+4p3F0R1vsk%vp8tkgyeX3V?G7P&12HBDZQVx`{5(h>PQ~Al`4sFXm$x z@Ji%PD~xWV3p7&1@ldesz$E8-4&5~4@@R8S&Yb`_;Ma3lj4&>bHrMvJ*y~uFtLfDW zI%9q-grL>~`5KhXpI8spbQHMXD5a?NP`=cAy5hVUSY>IXA$PwXI({|G*2Dg(GX<+G zf;7x*zaBb%ZS-3Jz%oQ`1!64Ms=#IS;Id(ISS#pU%r^=StA%NS%j%)XhQVAF8vykq zx8!M}y1?z!Z(E_r#rei4TLQOJzx+Qof5_DgmynFEr3qonju2dExXfI(-b@}MS5=CX z<}EcsDO+}w;`-_=GxndoKW})|zoS!U$dU+_uT+Fwt|{~odJq@P|56VD?Jt7b4+XV< zgMjv%?FWM7kNkd964*X1XQ|Uzj)b%tmrNx%qLH^lsHor=Yv<&h2LHto!oCU90#TOcwk_G15c2l`+`zm^t&na3WzUlZHYidN^jzjI+#~X=Y@&A+n{7idu2= zjMlAW%WF55oy#q?6iF;1>MGL%yAey}p7HwZ?x*f!&*yW_`Tx)V`+IzUug^KY&2zJ^ z&I%nI4yVg@XL)09f9!VAo{v4Z-zZUS@M8DfQXFo|ZEF%WN0z~9Q8m#<*L_~N_q+PZgDo5-F2P5v064)ow z)0Z0=!gb)0oYxZ_ z2~2dL(8ydWl}=nMh$V1LTkxpi!tQR#QM8cMc~GlY~wg=Wziz6c)lF znH0m)=CI;mREA1}(NCzJJN}&kEV>-doQ;qAA{5SC;!hR)_{+pNn(x-G?)xRwKP5s`|7#cxDl79@k6NLb!% z$W%Krjp<9LfixOuOSht838YR#IVca|NBjY5?~CE6RFL8DJ1AB+JV*xpS1^wY@==Kp z!YCs`NB{$3k$^;;3nb`4a9})c(d{>lpChX?W{cS|CINVAXs}rp?_MBs_9fk z0lFhnEL{;ZJ>df%m|2A)#Hl&}A+D;vok(0&VquKI^k?KxM_^h$JQ&85{)co=!=xx* z77j^Z7XcQn|Dm7&%zZ%Bi_=ViKa!j}`%T}@!m(kXTFwn3Y%@2?U=cRIB-o%@!mO#o z;WVGISuVcvjMo|a6MT29taEm5H5S)Sm+7BJ2rgISbuL~a_|qV+t$*RKuY2DOMDi;b4tm||Dl-Us z2C4-2-e*4kwObnf{1GGkmKnrvV3`>W*^B=|^($T%@owQe{MLIGooDovS|8?BRaRfUw?>zL?Op>zo=pNdMpE5)xS}zte0N2sKi%a1=>%Yyd6JaV%-o5+&{+;T{G4y3^5WeS{L(IWMApm z$BZcS{N0-&v)l!wk6FfCGFIFv&b-}g++#n)Y|zV%QIF1<=zjU#!b4v2SpGBTj(G%3 zdck4KCJ#Z#D<2JA z%MHd`8`5sXWhcvcNo2WWwlGhHs$7*2+K{hx6w-QUi&%2)miDUM~0!mpTd?i zODr0_)$VN>Un*~Sa9P1Rt5wtSEBq~uvoEnb^|&?S+nkf0sgY%_-KNLo?@YE?t0c4Y z_FbX{Hoti_ih{|2>F#l|Pt`vvUW9e2+tl4v(ni@^j;eUze9wan?UZ%Y3^5+MAUlP+)%VA@s;#)#~+@+p9g2qqKWT>l*o% z#~KPPFKua2|0O>9q1J_qis$j0A0@_Syu137M&hFCz$%T5x3T&~kFTxbm*2EvZb@_V z8W+@eSP!P{Dn8X@`UKg9o-ZHJx@W-G_t|haz^a9CXz8GnY0D|Kus$p^pIz(FOwTrt z99>~9c+nhpH|4GTO1D9KN8*XJN97Z zM!Gn1>%E41OG?6`I)h@J6L?qkM{ahN$ERl|8C9sGq78Z)*%qs5uQuD0val&<^Lqczs&O|-gVS@j$Xc{=p9_xNOgz^&oQ&65jE WD!|=2{lKv5zm~nxlU3vz824{$p@0Ga literal 0 HcmV?d00001 diff --git a/gui/assets/sh03.bin b/gui/assets/sh03.bin new file mode 100644 index 0000000000000000000000000000000000000000..82b594cdb0d776eadfaa19ae92a285b9540b4a66 GIT binary patch literal 5909 zcmeI0F-#Lt7=VA^0aXgNz(IMS7KC2!we)(Ww0E>%Br?FFA+8Q+9GrDwHR`~oA;uWv zU<4Bf2MuhJ7!ufUAaM{=78BfbFxE&S{_kF?4`SqXXyS1HaD93A?t6c}_rJT|0aOw- zwRJ=x>Khs>p#u0Cj~!RwHbFyWTz_+m0+x^y^%ZiJsC>Nt|>%&TPp{vb2~ai3Ro@F*-;s{i*~DUyJ=U2Too!G<8g&4&x7Yd7?3^moUB*4 z>6|(p?vk(lS6kFsZ{HaOZcqO}g@k->5>YQ$%3pA3$`FLP*cr5$H ze5^EHiQF-h(=Bv?Mlv`b3bq^=7F^e1F3PzgN?b#5eEw$cAo3EdgFG-B~aYeXaKc%SkP`=cCy5hR&SY=V9{mrk2*}B+29!#vV zB+`((Uk_!!7Wz#CU>PDe4?$iFqY{_bLyk)x#LUpSoUb1op6i&z<@J!^(_pTq1%UdI zn{_qW5s6!_-;zR^i|dWS5)!vuzv3KUKjdn9jrBaGT}$eltYKTw-P@ zO(jT@Qoijd#r;*@X8fGBzixQd(NV55WGMvCS1CNM(BygWJjfg7f2oHmy){Zl}z4vpsu)S6I5Qc_xzNXbs*SeF7j!(RpzI4|bn(?)e z-o8a^cy9XQ-1KyQ=hNonhueEU?$6(QQvABSw7eP$trUwhzUD{oH?L1!*m2HA$Inj~ z>}fzFz2V8J$^PDfmEyPAjd#0+kFVzEU*5R+VCnVcFQ1dQ@A$7?S$9UlP(NW9p|!WG PA$ltixMrB?g=fD3o&Gz3 literal 0 HcmV?d00001 diff --git a/gui/assets/sh03.png b/gui/assets/sh03.png new file mode 100644 index 0000000000000000000000000000000000000000..dc86abc08e737e950afe87447adb37bb2270b6f6 GIT binary patch literal 2871 zcmbVO2|Sc}AD?j~M@5Pv%#OUJj5(b1WD*|m#gv2hp*=Jmzt#{vRpU>xc=Kp`5@BjP#-JePI^;tVd!$1Rt zLe24Tr}`nk!N~12dlvHC@I$d;qb_g{5}{CrC|3K&cd2xB6^C^nyh{ZLtj#jsfv>_!`Jg15j0jApyX3qk*Q9~v{B z!z8n?j;k>aQUD>~fiMFj<#G8UKuW<*@B+wM@fwfCOh8}`1?!{`!~}Z#Vq730h#^}N zaZCb%gs~%AMq1fegON--f(3>|AX(vwL_C3vBa#4u4Pax1nYyruppX>__)*=aJ`!{t_*s#R)*XA1H#vLMG@c zLCiE&cq0H@gdhWkgfs}^PUp&Z+GGsL#?l6}+?&B<^A%2*|LF)wWxyZ>i{#B3N3g{a z?Pw$-KqLY-q?H6DfrLq@H^gE`#(f2~ry)25WKH-2iqs8@0WIfP$^i%YfCnp1w@i;$@Y%_QL%5hOcXkQHgqvbUZ-PlcFbMQ*3hv;NQX>xFEjy%^lT&7){KMNt6m zY!Q;KxT&7-2VYUR5z_yV?n#&kiiBes zLeMD+iPry6P_3bnq83u~w%pgKGGouXTBlAm$4653!gf%FX z>dzijCz>?7H+y|_sOyqy$Et&~ZL=&Rx~p7vc^p>p4Bl`R$6FKsy)>mMJ*b!;kvui&iQxs*gt06o#o~4e;x zH(YTgB}nIm#t<5=cs!z9udL5?T(6b)u3ml!wTUO`8b52uu_3b_e}LoD`xYI1oayeo z7OQTQx)feIxrx}mBwsetdGCDO`4!mlt1YPk-AcZVV)<>kXSg_o5?m|p*dq&Dka?+B z(&aOd!=wo29^>WW*Ade{@)9bI*s3N#0BZfA`eWA@m#c>Ii%oDpmfYEXI?!pg;TnH< zn<|hzBlU&u%P{RxQvWx7$=xmBw&$)VsK z5A}ISEn|0q68MvWI44=@l3dGIDL!#;;BQOZR7bSEUJP=?BrPX(bz}-z8D(J5Mk|sh;26 zMAMC|+N+b!y&*@$?V*Vq-lE&`?XngEU9R`UM-IwHJ=>!jf~xj7q;NX(-g#6C%tE2O z;Go-*&{+?$ex+^Cw9bSqX3AsE=E-g})ZH7%Elp74oRR@t{l1V;Xi-Vq52upbVs70j zBxacnd4|^DUIjP0Wp|t|I(gVI&S3X0LR*3DSlgrJCA;-2f{azKY444}{VEmQSkyEA z^uDk5+)}j%A$6Zse72dS7Y4=cts7DPGmXfWXc}Q+fqAtud*Ev3P#%;ga z0)y<6nk(#^FztT(Ldut?-sfPi=t-0Jo$02s3RAcPXTati+|;2<0c}@Xb=&gl517Gd zsT+OmwrdMASIU=)iOny!6pmXwc`-QuXS48!J;O0kRXW4UReA3&N^+^x)%Qy7oLH1E zTV=8TTh+^-B-^T}qX3<9UdGf5mOPINB)Pq+7&|M=a%J7(2OiCvqiia!u70=T39mDE z!Rt!nrjgpz`$1zqCYq)PBh8zK%{yG_rxs?S(~nmpST%JwqcwG$c{iepw7*6Bv`CKf z=Cw#xSsw1WdAA#)iL=6^kDCo0ZcT|7xSSI-HTH;13#G{`N%jXmgRe4w-TNEvE0B)OdCVs7~2u zxOQXWq17Aczbm)D`A4>qUt`TPS(H*p!m*SvRtvt<`EY&N#ndaPHf1;O10_kP!on?v z7>j%S8*{s!ge2uBSX}M5yR7HcKQ|-!oK4PUi=5{z)H`L)g5naL(Dc5!FJrtl=QV7c z)!uSJw*A@jjP&aXH)Hoe{+*FcY~9RxiH;ULRGD6b@;^0R4#QNwAUFS_U(WHehAmGZp#k; z8co-1D2}UAa}R$g3~hV4=-TmRqe)`^gbt^JANTDNBQ4aL~&@ zO#K&bWa#v%=3FYTel&PrYq&JRPoo=!_|jc;2gg54-p-QIsgCw literal 0 HcmV?d00001 diff --git a/gui/assets/warning-box-bg.9.png b/gui/assets/warning-box-bg.9.png new file mode 100644 index 0000000000000000000000000000000000000000..41eb74d2f7f2355d7208ab140b98e849314dd2da GIT binary patch literal 550 zcmV+>0@?kEP)AFB6^c+H)C#RSm|Xe?O&XFE7e~Rh;NZ_< z)xpJCR|i)?5c~mgb#YR3krKa43N2zhIPT%Sdtbi$?f{`)W}4M84rsb6lU)@< zuLxrp{QyQ~W*Kvml!Wj2x<`PocQKyjf9}uGqvk9I1VrLlW|%hd2J!T!ZE)TvjZLMN=c5B95w>PWeK{W0mt3XRTai z%{uuF!#RCrnd>x%kia6AAVGwJDoQBBMvPXS6bmWZkNfxsUB5&wgweph_UQ##?biO#w#|RMG1sXNS`95}>#t9I72Cnp$zfuQgK1r{&wCEAgyA51i zw=`uBxZD8-o($QPT`5RY$mfCgGkT^h(02=TuX%lI?&I_UNK>zpH^9LmeU}1dulwA) ztG#dko@w^)2Xk?9r}P_d)Bpeg2~bQ_MF0Q*00000004ty>A(O000DGTPE!Ct=GbNc z0004EOGiWihy@);0000rNkl;x&;f24h%6H>DJCXDa!fb@UO6T* o_2HEx*AIBZ1#fsFg*gfj03Lb*f7!l`hyVZp07*qoM6N<$f|(H7F#rGn literal 0 HcmV?d00001 diff --git a/gui/assets/warning-box-bg.bin b/gui/assets/warning-box-bg.bin new file mode 100644 index 0000000000000000000000000000000000000000..f5c533e260829724e146409259d4a25067400721 GIT binary patch literal 414 zcmaKoQ4#?BbZfmqQun!k513mx% literal 0 HcmV?d00001 diff --git a/gui/assets/warning-box-border.9.png b/gui/assets/warning-box-border.9.png new file mode 100644 index 0000000000000000000000000000000000000000..e8f9b9c15f4a6251193b6154cf6632a87accf9e2 GIT binary patch literal 551 zcmV+?0@(eDP)AFB6^c+H)C#RSm|Xe?O&XFE7e~Rh;NZ_< z)xpJCR|i)?5c~mgb#YR3krKa43N2zhIPT%Sdtbi$?f{`)W}4M84rsb6lU)@< zuLxrp{QyQ~W*Kvml!Wj2x<`PocQKyjf9}uGqvk9I1VrLlW|%hd2J!T!ZE)TvjZLMN=c5B95w>PWeK{W0mt3XRTai z%{uuF!#RCrnd>x%kia6AAVGwJDoQBBMvPXS6bmWZkNfxsUB5&wgweph_UQ##?biO#w#|RMG1sXNS`95}>#t9I72Cnp$zfuQgK1r{&wCEAgyA51i zw=`uBxZD8-o($QPT`5RY$mfCgGkT^h(02=TuX%lI?&I_UNK>zpH^9LmeU}1dulwA) ztG#dko@w^)2Xk?9r}P_d)Bpeg22e~?MF0Q*0001`XSvS+0004WQchC^#%002ovPDHLkV1jtm;6wlb literal 0 HcmV?d00001 diff --git a/gui/assets/warning-box-border.bin b/gui/assets/warning-box-border.bin new file mode 100644 index 0000000000000000000000000000000000000000..a0e6ab9cf6ea2d7d63bec9cd624a79167a0514e3 GIT binary patch literal 411 zcmah_K@I>Q2-5#Qx>T0BG;zQ7Qpe literal 0 HcmV?d00001 diff --git a/gui/doc.go b/gui/doc.go deleted file mode 100644 index e5e3ec9..0000000 --- a/gui/doc.go +++ /dev/null @@ -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 diff --git a/gui/gui.go b/gui/gui.go new file mode 100644 index 0000000..f5cb866 --- /dev/null +++ b/gui/gui.go @@ -0,0 +1,2951 @@ +// package gui implements the SeedHammer controller user interface. +package gui + +import ( + "errors" + "fmt" + "image" + "image/color" + "image/draw" + "log" + "math" + "strings" + "time" + "unicode/utf8" + + "github.com/btcsuite/btcd/btcutil/hdkeychain" + "github.com/btcsuite/btcd/chaincfg" + "github.com/mineracks/seedhammer-v1-companion/address" + "github.com/mineracks/seedhammer-v1-companion/backup" + "github.com/mineracks/seedhammer-v1-companion/bc/ur" + "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/engrave" + "github.com/mineracks/seedhammer-v1-companion/font/constant" + "github.com/mineracks/seedhammer-v1-companion/gui/assets" + "github.com/mineracks/seedhammer-v1-companion/gui/layout" + "github.com/mineracks/seedhammer-v1-companion/gui/op" + "github.com/mineracks/seedhammer-v1-companion/gui/saver" + "github.com/mineracks/seedhammer-v1-companion/gui/text" + "github.com/mineracks/seedhammer-v1-companion/gui/widget" + "github.com/mineracks/seedhammer-v1-companion/nonstandard" + "github.com/mineracks/seedhammer-v1-companion/seedqr" +) + +const nbuttons = 8 + +type Context struct { + Platform Platform + Styles Styles + Wakeup time.Time + Frame func() + + // Global UI state. + Version string + Calibrated bool + EmptySDSlot bool + RotateCamera bool + LastDescriptor *urtypes.OutputDescriptor + + events []Event +} + +func NewContext(pl Platform) *Context { + c := &Context{ + Platform: pl, + Styles: NewStyles(), + } + return c +} + +func (c *Context) WakeupAt(t time.Time) { + if c.Wakeup.IsZero() || t.Before(c.Wakeup) { + c.Wakeup = t + } +} + +const repeatStartDelay = 400 * time.Millisecond +const repeatDelay = 100 * time.Millisecond + +func isRepeatButton(b Button) bool { + switch b { + case Up, Down, Right, Left: + return true + } + return false +} + +func (c *Context) Reset() { + c.events = c.events[:0] + c.Wakeup = time.Time{} +} + +func (c *Context) Events(evts ...Event) { + c.events = append(c.events, evts...) +} + +func (c *Context) FrameEvent() (FrameEvent, bool) { + for i, e := range c.events { + if e, ok := e.AsFrame(); ok { + c.events = append(c.events[:i], c.events[i+1:]...) + return e, true + } + } + return FrameEvent{}, false +} + +func (c *Context) Next(btns ...Button) (ButtonEvent, bool) { + for i, e := range c.events { + e, ok := e.AsButton() + if !ok { + continue + } + for _, btn := range btns { + if e.Button == btn { + c.events = append(c.events[:i], c.events[i+1:]...) + return e, true + } + } + } + return ButtonEvent{}, false +} + +type InputTracker struct { + Pressed [nbuttons]bool + clicked [nbuttons]bool + repeats [nbuttons]time.Time +} + +func (t *InputTracker) Next(c *Context, btns ...Button) (ButtonEvent, bool) { + now := c.Platform.Now() + for _, b := range btns { + if !isRepeatButton(b) { + continue + } + if !t.Pressed[b] { + t.repeats[b] = time.Time{} + continue + } + wakeup := t.repeats[b] + if wakeup.IsZero() { + wakeup = now.Add(repeatStartDelay) + } + repeat := !now.Before(wakeup) + if repeat { + wakeup = now.Add(repeatDelay) + } + t.repeats[b] = wakeup + c.WakeupAt(wakeup) + if repeat { + return ButtonEvent{Button: b, Pressed: true}, true + } + } + + e, ok := c.Next(btns...) + if !ok { + return ButtonEvent{}, false + } + if int(e.Button) < len(t.clicked) { + t.clicked[e.Button] = !e.Pressed && t.Pressed[e.Button] + t.Pressed[e.Button] = e.Pressed + } + return e, true +} + +func (t *InputTracker) Clicked(b Button) bool { + c := t.clicked[b] + t.clicked[b] = false + return c +} + +const longestWord = "TOMORROW" + +type program int + +const ( + backupWallet program = iota +) + +type linePos struct { + W op.CallOp + Y int +} + +type richText struct { + Lines []linePos + Y int +} + +func (r *richText) Add(ops op.Ctx, style text.Style, width int, col color.NRGBA, txt string) { + lines, _ := text.Style{ + Face: style.Face, + Alignment: style.Alignment, + LineHeight: style.LineHeight, + }.Layout(width, txt) + for _, line := range lines { + doty := line.Dot.Y + r.Y + (&op.TextOp{ + Src: image.NewUniform(col), + Face: style.Face, + Dot: image.Pt(line.Dot.X, doty), + Txt: line.Text, + }).Add(ops.Begin()) + r.Lines = append(r.Lines, linePos{ops.End(), doty}) + } + r.Y += lines[len(lines)-1].Dot.Y +} + +type AddressesScreen struct { + addresses [2][]string + page int + scroll int +} + +func NewAddressesScreen(desc urtypes.OutputDescriptor) *AddressesScreen { + s := new(AddressesScreen) + for i := 0; i < 20; i++ { + addr, err := address.Receive(desc, uint32(i)) + if err != nil { + // Very unlikely. + continue + } + const addrLen = 12 + s.addresses[0] = append(s.addresses[0], shortenAddress(addrLen, addr)) + change, err := address.Change(desc, uint32(i)) + if err != nil { + continue + } + s.addresses[1] = append(s.addresses[1], shortenAddress(addrLen, change)) + } + return s +} + +func (s *AddressesScreen) Show(ctx *Context, ops op.Ctx, th *Colors) { + const linesPerPage = 8 + const linesPerScroll = linesPerPage - 3 + + const maxPage = len(s.addresses) + inp := new(InputTracker) + for { + for { + e, ok := inp.Next(ctx, Button1, Left, Right, Up, Down) + if !ok { + break + } + switch e.Button { + case Button1: + if inp.Clicked(e.Button) { + return + } + case Left: + if e.Pressed { + s.page = (s.page - 1 + maxPage) % maxPage + s.scroll = 0 + } + case Right: + if e.Pressed { + s.page = (s.page + 1) % maxPage + s.scroll = 0 + } + case Up: + if e.Pressed { + s.scroll -= linesPerScroll + } + case Down: + if e.Pressed { + s.scroll += linesPerScroll + } + } + } + op.ColorOp(ops, th.Background) + dims := ctx.Platform.DisplaySize() + + // Title. + r := layout.Rectangle{Max: dims} + title := "Receive" + if s.page == 1 { + title = "Change" + } + layoutTitle(ctx, ops, dims.X, th.Text, title) + + op.MaskOp(ops.Begin(), assets.ArrowLeft) + op.ColorOp(ops, th.Text) + left := ops.End() + + op.MaskOp(ops.Begin(), assets.ArrowRight) + op.ColorOp(ops, th.Text) + right := ops.End() + + leftsz := assets.ArrowLeft.Bounds().Size() + rightsz := assets.ArrowRight.Bounds().Size() + + content := r.Shrink(0, 12, 0, 12) + body := content.Shrink(leadingSize, rightsz.X+12, 0, leftsz.X+12) + inner := body.Shrink(scrollFadeDist, 0, scrollFadeDist, 0) + + bodyst := ctx.Styles.body + var bodytxt richText + addrs := s.addresses[s.page] + for i, addr := range addrs { + bodytxt.Add(ops, bodyst, body.Dx(), th.Text, fmt.Sprintf("%d: %s", i+1, addr)) + } + + op.Position(ops, left, content.W(leftsz)) + op.Position(ops, right, content.E(rightsz)) + maxScroll := len(bodytxt.Lines) - linesPerPage + if s.scroll > maxScroll { + s.scroll = maxScroll + } + if s.scroll < 0 { + s.scroll = 0 + } + off := bodytxt.Lines[s.scroll].Y - bodytxt.Lines[0].Y + ops.Begin() + for _, l := range bodytxt.Lines { + op.Position(ops, l.W, inner.Min.Sub(image.Pt(0, off))) + } + fadeClip(ops, ops.End(), image.Rectangle(body)) + + layoutNavigation(inp, ops, th, dims, []NavButton{{Button: Button1, Style: StyleSecondary, Icon: assets.IconBack}}...) + ctx.Frame() + } +} + +func shortenAddress(n int, addr string) string { + if len(addr) <= n { + return addr + } + return addr[:n/2] + "......" + addr[len(addr)-n/2:] +} + +func descriptorKeyIdx(desc urtypes.OutputDescriptor, m bip39.Mnemonic, pass string) (int, bool) { + if len(desc.Keys) == 0 { + return 0, false + } + network := desc.Keys[0].Network + seed := bip39.MnemonicSeed(m, pass) + mk, err := hdkeychain.NewMaster(seed, network) + if err != nil { + return 0, false + } + for i, k := range desc.Keys { + _, xpub, err := bip32.Derive(mk, k.DerivationPath) + if err != nil { + // A derivation that generates an invalid key is by itself very unlikely, + // but also means that the seed doesn't match this xpub. + continue + } + if k.String() == xpub.String() { + return i, true + } + } + return 0, false +} + +func deriveMasterKey(m bip39.Mnemonic, net *chaincfg.Params) (*hdkeychain.ExtendedKey, bool) { + seed := bip39.MnemonicSeed(m, "") + mk, err := hdkeychain.NewMaster(seed, net) + // Err is only non-nil if the seed generates an invalid key, or we made a mistake. + // According to [0] the odds of encountering a seed that generates + // an invalid key by chance is 1 in 2^127. + // + // [0] https://bitcoin.stackexchange.com/questions/53180/bip-32-seed-resulting-in-an-invalid-private-key + return mk, err == nil +} + +type ScanScreen struct { + Title string + Lead string +} + +func (s *ScanScreen) Scan(ctx *Context, ops op.Ctx) (any, bool) { + var ( + feed *image.Gray + cameraErr error + decoder QRDecoder + ) + inp := new(InputTracker) + for { + const cameraFrameScale = 3 + for { + e, ok := inp.Next(ctx, Button1, Button2) + if !ok { + break + } + if !inp.Clicked(e.Button) { + continue + } + switch e.Button { + case Button1: + return nil, false + case Button2: + ctx.RotateCamera = !ctx.RotateCamera + } + } + + dims := ctx.Platform.DisplaySize() + if feed == nil || dims != feed.Bounds().Size() { + feed = image.NewGray(image.Rectangle{Max: dims}) + } + ctx.Platform.CameraFrame(dims.Mul(cameraFrameScale)) + for { + f, ok := ctx.FrameEvent() + if !ok { + break + } + cameraErr = f.Error + if cameraErr == nil { + ycbcr := f.Image.(*image.YCbCr) + gray := &image.Gray{Pix: ycbcr.Y, Stride: ycbcr.YStride, Rect: ycbcr.Bounds()} + + scaleRot(feed, gray, ctx.RotateCamera) + // Re-create image (but not backing store) to ensure redraw. + copy := *feed + feed = © + results, _ := ctx.Platform.ScanQR(gray) + for _, res := range results { + if v, ok := decoder.parseQR(res); ok { + return v, true + } + } + } + } + th := &cameraTheme + r := layout.Rectangle{Max: dims} + + op.ImageOp(ops, feed) + + corners := assets.CameraCorners.For(image.Rect(0, 0, 132, 132)) + op.ImageOp(ops.Begin(), corners) + op.Position(ops, ops.End(), r.Center(corners.Bounds().Size())) + + underlay := assets.ButtonFocused + background := func(ops op.Ctx, w op.CallOp, dst image.Rectangle, pos image.Point) { + op.MaskOp(ops.Begin(), underlay.For(dst)) + op.ColorOp(ops, color.NRGBA{A: theme.overlayMask}) + op.Position(ops, ops.End(), image.Point{}) + op.Position(ops, w, pos) + } + + title := layoutTitle(ctx, ops.Begin(), dims.X, th.Text, s.Title) + title.Min.Y += 4 + title.Max.Y -= 4 + background(ops, ops.End(), title, image.Point{}) + + // Camera error, if any. + if err := cameraErr; err != nil { + sz := widget.LabelW(ops.Begin(), ctx.Styles.body, dims.X-2*16, th.Text, err.Error()) + op.Position(ops, ops.End(), r.Center(sz)) + } + + width := dims.X - 2*8 + // Lead text. + sz := widget.LabelW(ops.Begin(), ctx.Styles.lead, width, th.Text, s.Lead) + top, footer := r.CutBottom(sz.Y + 2*12) + pos := footer.Center(sz) + background(ops, ops.End(), image.Rectangle{Min: pos, Max: pos.Add(sz)}, pos) + + // Progress + if progress := decoder.Progress(); progress > 0 { + sz = widget.LabelW(ops.Begin(), ctx.Styles.lead, width, th.Text, fmt.Sprintf("%d%%", progress)) + _, percent := top.CutBottom(sz.Y) + pos := percent.Center(sz) + background(ops, ops.End(), image.Rectangle{Min: pos, Max: pos.Add(sz)}, pos) + } + + nav := func(btn Button, icn image.RGBA64Image) { + nav := layoutNavigation(inp, ops.Begin(), th, dims, []NavButton{{Button: btn, Style: StyleSecondary, Icon: icn}}...) + nav = image.Rectangle(layout.Rectangle(nav).Shrink(underlay.Padding()).Shrink(-2, -4, -2, -2)) + background(ops, ops.End(), nav, image.Point{}) + } + nav(Button1, assets.IconBack) + nav(Button2, assets.IconFlip) + ctx.Frame() + } +} + +// scaleRot is a specialized function for fast scaling and rotation of +// the camera frames for display. +func scaleRot(dst, src *image.Gray, rot180 bool) { + db := dst.Bounds() + sb := src.Bounds() + if db.Empty() { + return + } + scale := sb.Dx() / db.Dx() + for y := 0; y < db.Dy(); y++ { + sx := sb.Max.X - 1 - y*scale + dy := db.Max.Y - y + if rot180 { + dy = y + db.Min.Y + } + for x := 0; x < db.Dx(); x++ { + sy := x*scale + sb.Min.Y + c := src.GrayAt(sx, sy) + dx := db.Max.X - 1 - x + if rot180 { + dx = x + db.Min.X + } + dst.SetGray(dx, dy, c) + } + } +} + +type QRDecoder struct { + decoder ur.Decoder + nsdecoder nonstandard.Decoder +} + +func (d *QRDecoder) Progress() int { + progress := int(100 * d.decoder.Progress()) + if progress == 0 { + progress = int(100 * d.nsdecoder.Progress()) + } + return progress +} + +func (d *QRDecoder) parseNonStandard(qr []byte) (any, bool) { + if err := d.nsdecoder.Add(string(qr)); err != nil { + d.nsdecoder = nonstandard.Decoder{} + return qr, true + } + enc := d.nsdecoder.Result() + if enc == nil { + return nil, false + } + return enc, true +} + +func (d *QRDecoder) parseQR(qr []byte) (any, bool) { + uqr := strings.ToUpper(string(qr)) + if !strings.HasPrefix(uqr, "UR:") { + d.decoder = ur.Decoder{} + return d.parseNonStandard(qr) + } + d.nsdecoder = nonstandard.Decoder{} + if err := d.decoder.Add(uqr); err != nil { + // Incompatible fragment. Reset decoder and try again. + d.decoder = ur.Decoder{} + d.decoder.Add(uqr) + } + typ, enc, err := d.decoder.Result() + if err != nil { + d.decoder = ur.Decoder{} + return nil, false + } + if enc == nil { + return nil, false + } + d.decoder = ur.Decoder{} + v, err := urtypes.Parse(typ, enc) + if err != nil { + return nil, true + } + return v, true +} + +type ErrorScreen struct { + Title string + Body string + w Warning + inp InputTracker +} + +func (s *ErrorScreen) Layout(ctx *Context, ops op.Ctx, th *Colors, dims image.Point) bool { + for { + e, ok := s.inp.Next(ctx, Button3) + if !ok { + break + } + switch e.Button { + case Button3: + if s.inp.Clicked(e.Button) { + return true + } + } + } + s.w.Layout(ctx, ops, th, dims, s.Title, s.Body) + layoutNavigation(&s.inp, ops, th, dims, []NavButton{{Button: Button3, Style: StylePrimary, Icon: assets.IconCheckmark}}...) + return false +} + +type ConfirmWarningScreen struct { + Title string + Body string + Icon image.RGBA64Image + + warning Warning + confirm ConfirmDelay + inp InputTracker +} + +type Warning struct { + scroll int + txtclip int + inp InputTracker +} + +type ConfirmResult int + +const ( + ConfirmNone ConfirmResult = iota + ConfirmNo + ConfirmYes +) + +type ConfirmDelay struct { + timeout time.Time +} + +func (c *ConfirmDelay) Start(ctx *Context, delay time.Duration) { + c.timeout = ctx.Platform.Now().Add(delay) +} + +func (c *ConfirmDelay) Progress(ctx *Context) float32 { + if c.timeout.IsZero() { + return 0. + } + now := ctx.Platform.Now() + d := c.timeout.Sub(now) + if d <= 0 { + return 1. + } + ctx.Platform.Wakeup() + return 1. - float32(d.Seconds()/confirmDelay.Seconds()) +} + +const confirmDelay = 1 * time.Second + +func (w *Warning) Layout(ctx *Context, ops op.Ctx, th *Colors, dims image.Point, title, txt string) image.Point { + for { + e, ok := w.inp.Next(ctx, Up, Down) + if !ok { + break + } + switch e.Button { + case Up: + if e.Pressed { + w.scroll -= w.txtclip / 2 + } + case Down: + if e.Pressed { + w.scroll += w.txtclip / 2 + } + } + } + const btnMargin = 4 + const boxMargin = 6 + + op.ColorOp(ops, color.NRGBA{A: theme.overlayMask}) + + wbbg := assets.WarningBoxBg + wbout := assets.WarningBoxBorder + ptop, pend, pbottom, pstart := wbbg.Padding() + r := image.Rectangle{ + Min: image.Pt(pstart+boxMargin, ptop+boxMargin), + Max: image.Pt(dims.X-pend-boxMargin, dims.Y-pbottom-boxMargin), + } + box := wbbg.For(r) + op.MaskOp(ops, box) + op.ColorOp(ops, th.Background) + op.MaskOp(ops, wbout.For(r)) + op.ColorOp(ops, th.Text) + + btnOff := assets.NavBtnPrimary.Bounds().Dx() + btnMargin + titlesz := widget.LabelW(ops.Begin(), ctx.Styles.warning, dims.X-btnOff*2, th.Text, strings.ToTitle(title)) + titlew := ops.End() + op.Position(ops, titlew, image.Pt((dims.X-titlesz.X)/2, r.Min.Y)) + + bodyClip := image.Rectangle{ + Min: image.Pt(pstart+boxMargin, ptop+titlesz.Y), + Max: image.Pt(dims.X-btnOff, dims.Y-pbottom-boxMargin), + } + bodysz := widget.LabelW(ops.Begin(), ctx.Styles.body, bodyClip.Dx(), th.Text, txt) + body := ops.End() + innerCtx := ops.Begin() + w.txtclip = bodyClip.Dy() + maxScroll := bodysz.Y - (bodyClip.Dy() - 2*scrollFadeDist) + if w.scroll > maxScroll { + w.scroll = maxScroll + } + if w.scroll < 0 { + w.scroll = 0 + } + op.Position(innerCtx, body, image.Pt(bodyClip.Min.X, bodyClip.Min.Y+scrollFadeDist-w.scroll)) + fadeClip(ops, ops.End(), image.Rectangle(bodyClip)) + + return box.Bounds().Size() +} + +func (s *ConfirmWarningScreen) Layout(ctx *Context, ops op.Ctx, th *Colors, dims image.Point) ConfirmResult { + var progress float32 + for { + progress = s.confirm.Progress(ctx) + if progress == 1 { + return ConfirmYes + } + e, ok := s.inp.Next(ctx, Button3, Button1) + if !ok { + break + } + switch e.Button { + case Button1: + if s.inp.Clicked(e.Button) { + return ConfirmNo + } + case Button3: + if e.Pressed { + s.confirm.Start(ctx, confirmDelay) + } else { + s.confirm = ConfirmDelay{} + } + } + } + s.warning.Layout(ctx, ops, th, dims, s.Title, s.Body) + layoutNavigation(&s.inp, ops, th, dims, []NavButton{ + {Button: Button1, Style: StyleSecondary, Icon: assets.IconBack}, + {Button: Button3, Style: StylePrimary, Icon: s.Icon, Progress: progress}, + }...) + return ConfirmNone +} + +type ProgressImage struct { + Progress float32 + Src image.RGBA64Image +} + +func (p ProgressImage) ColorModel() color.Model { + return color.AlphaModel +} + +func (p ProgressImage) Bounds() image.Rectangle { + return p.Src.Bounds() +} + +func (p ProgressImage) At(x, y int) color.Color { + return p.RGBA64At(x, y) +} + +func (p ProgressImage) RGBA64At(x, y int) color.RGBA64 { + c := p.Bounds().Max.Add(p.Bounds().Min).Div(2) + d := image.Pt(x, y).Sub(c) + angle := float32(math.Atan2(float64(d.X), float64(d.Y))) + angle = math.Pi - angle + if angle > 2*math.Pi*p.Progress { + return color.RGBA64{} + } + return p.Src.RGBA64At(x, y) +} + +type errDuplicateKey struct { + Fingerprint uint32 +} + +func (e *errDuplicateKey) Error() string { + return fmt.Sprintf("descriptor contains a duplicate share: %.8x", e.Fingerprint) +} + +func (e *errDuplicateKey) Is(target error) bool { + _, ok := target.(*errDuplicateKey) + return ok +} + +func NewErrorScreen(err error) *ErrorScreen { + var errDup *errDuplicateKey + switch { + case errors.As(err, &errDup): + return &ErrorScreen{ + Title: "Duplicated Share", + Body: fmt.Sprintf("The share %.8x is listed more than once in the wallet.", errDup.Fingerprint), + } + case errors.Is(err, backup.ErrDescriptorTooLarge): + return &ErrorScreen{ + Title: "Too Large", + Body: "The descriptor cannot fit any plate size.", + } + default: + return &ErrorScreen{ + Title: "Error", + Body: err.Error(), + } + } +} + +func validateDescriptor(params engrave.Params, desc urtypes.OutputDescriptor) error { + keys := make(map[string]bool) + for _, k := range desc.Keys { + xpub := k.String() + if keys[xpub] { + return &errDuplicateKey{ + Fingerprint: k.MasterFingerprint, + } + } + keys[xpub] = true + } + // Do a dummy engrave to see whether the backup fits any plate. + descPlate := backup.Descriptor{ + Descriptor: desc, + KeyIdx: 0, + Font: constant.Font, + Size: backup.LargePlate, + } + _, err := backup.EngraveDescriptor(params, descPlate) + if err != nil { + return err + } + // Verify that every permutation of desc.Threshold shares can recover the + // descriptor. Note that this is impossible by construction and by exhaustive + // tests, but it's good to be paranoid. + if !backup.Recoverable(desc) { + return errors.New("Descriptor is not recoverable. This is a bug in the program; please report it.") + } + return nil +} + +type Plate struct { + Size backup.PlateSize + MasterFingerprint uint32 + Sides []engrave.Plan +} + +func engraveSeed(sizes []backup.PlateSize, params engrave.Params, m bip39.Mnemonic) (Plate, error) { + mfp, err := masterFingerprintFor(m, &chaincfg.MainNetParams) + if err != nil { + return Plate{}, err + } + var lastErr error + for _, sz := range sizes { + seedDesc := backup.Seed{ + KeyIdx: 0, + Mnemonic: m, + Keys: 1, + MasterFingerprint: mfp, + Font: constant.Font, + Size: sz, + } + seedSide, err := backup.EngraveSeed(params, seedDesc) + if err != nil { + lastErr = err + continue + } + return Plate{ + Sides: []engrave.Plan{seedSide}, + Size: sz, + MasterFingerprint: mfp, + }, nil + } + return Plate{}, lastErr +} + +func masterFingerprintFor(m bip39.Mnemonic, network *chaincfg.Params) (uint32, error) { + mk, ok := deriveMasterKey(m, network) + if !ok { + return 0, errors.New("failed to derive mnemonic master key") + } + mfp, _, err := bip32.Derive(mk, urtypes.Path{0}) + if err != nil { + return 0, err + } + return mfp, nil +} + +func engravePlate(sizes []backup.PlateSize, params engrave.Params, desc urtypes.OutputDescriptor, keyIdx int, m bip39.Mnemonic) (Plate, error) { + mfp, err := masterFingerprintFor(m, desc.Keys[keyIdx].Network) + if err != nil { + return Plate{}, err + } + var lastErr error + for _, sz := range sizes { + descPlate := backup.Descriptor{ + Descriptor: desc, + KeyIdx: keyIdx, + Font: constant.Font, + Size: sz, + } + descSide, err := backup.EngraveDescriptor(params, descPlate) + if err != nil { + lastErr = err + continue + } + seedDesc := backup.Seed{ + Title: desc.Title, + KeyIdx: keyIdx, + Mnemonic: m, + Keys: len(desc.Keys), + MasterFingerprint: mfp, + Font: constant.Font, + Size: sz, + } + seedSide, err := backup.EngraveSeed(params, seedDesc) + if err != nil { + lastErr = err + continue + } + return Plate{ + Size: sz, + MasterFingerprint: mfp, + Sides: []engrave.Plan{descSide, seedSide}, + }, nil + } + return Plate{}, lastErr +} + +func plateImage(p backup.PlateSize) image.RGBA64Image { + switch p { + case backup.SquarePlate: + return assets.Sh02 + case backup.LargePlate: + return assets.Sh03 + default: + panic("unsupported plate") + } +} + +func plateName(p backup.PlateSize) string { + switch p { + case backup.SquarePlate: + return "SH02" + case backup.LargePlate: + return "SH03" + default: + panic("unsupported plate") + } +} + +type InstructionType int + +const ( + PrepareInstruction InstructionType = iota + ConnectInstruction + EngraveInstruction +) + +type Instruction struct { + Body string + Lead string + Type InstructionType + Side int + Image image.RGBA64Image + + resolvedBody string +} + +var ( + EngraveFirstSideA = []Instruction{ + { + Body: "Make sure the fingerprint above represents the intended share.", + Lead: "github.com/mineracks/seedhammer-v1-companion/tip#1", + }, + { + Body: "Turn off the engraver and disconnect it from this device.", + Lead: "github.com/mineracks/seedhammer-v1-companion/tip#2", + }, + { + Body: "Manually move the hammerhead to the far upper left position.", + Lead: "github.com/mineracks/seedhammer-v1-companion/tip#3", + }, + { + Body: "Place a {{.Name}} on the machine.", + Image: assets.Sh02, + Lead: "github.com/mineracks/seedhammer-v1-companion/tip#4", + }, + { + Body: "Tighten the nuts firmly.", + Lead: "github.com/mineracks/seedhammer-v1-companion/tip#4", + }, + { + Body: "Loosen the hammerhead finger screw. Adjust needle distance to ~1.5 mm above the plate.", + Lead: "github.com/mineracks/seedhammer-v1-companion/tip#5", + }, + { + Body: "Tighten the hammerhead finger screw and make sure the depth selector is set to \"Strong\".", + Lead: "github.com/mineracks/seedhammer-v1-companion/tip#6", + }, + { + Body: "Turn on the engraving machine and connect this device via the middle port.", + Lead: "github.com/mineracks/seedhammer-v1-companion/tip#7", + }, + { + Body: "Hold button to start the engraving process. The process is loud, use hearing protection.", + Type: ConnectInstruction, + Lead: "github.com/mineracks/seedhammer-v1-companion/tip#8", + }, + { + Lead: "Engraving plate", + Type: EngraveInstruction, + Side: 0, + }, + } + + EngraveSideA = []Instruction{ + { + Body: "Make sure the fingerprint above represents the intended share.", + Lead: "github.com/mineracks/seedhammer-v1-companion/tip#1", + }, + { + Body: "Place a {{.Name}} on the machine.", + Image: assets.Sh02, + Lead: "github.com/mineracks/seedhammer-v1-companion/tip#4", + }, + { + Body: "Tighten the nuts firmly.", + Lead: "github.com/mineracks/seedhammer-v1-companion/tip#4", + }, + { + Body: "Hold button to start the engraving process. The process is loud, use hearing protection.", + Type: ConnectInstruction, + Lead: "github.com/mineracks/seedhammer-v1-companion/tip#8", + }, + { + Lead: "Engraving plate", + Type: EngraveInstruction, + Side: 0, + }, + } + + EngraveSideB = []Instruction{ + { + Body: "Unscrew the 4 nuts and flip the top metal plate horizontally.", + }, + { + Body: "Tighten the nuts firmly.", + }, + { + Body: "Hold button to start the engraving process. The process is loud, use hearing protection.", + Type: ConnectInstruction, + }, + { + Lead: "Engraving plate", + Type: EngraveInstruction, + Side: 1, + }, + } + + EngraveSuccess = []Instruction{ + { + Body: "Engraving completed successfully.", + }, + } +) + +func isEmptyMnemonic(m bip39.Mnemonic) bool { + for _, w := range m { + if w != -1 { + return false + } + } + return true +} + +func emptyMnemonic(nwords int) bip39.Mnemonic { + m := make(bip39.Mnemonic, nwords) + for i := range m { + m[i] = -1 + } + return m +} + +const scrollFadeDist = 16 + +func fadeClip(ops op.Ctx, w op.CallOp, r image.Rectangle) { + op.MaskOp(ops, scrollMask(r)) + op.Position(ops, w, image.Pt(0, 0)) +} + +type scrollMask image.Rectangle + +func (n scrollMask) At(x, y int) color.Color { + return n.RGBA64At(x, y) +} + +func (n scrollMask) RGBA64At(x, y int) color.RGBA64 { + alpha := 0xffff + b := n.Bounds() + if d := y - b.Min.Y; d < scrollFadeDist { + alpha = 0xffff * d / scrollFadeDist + } else if d := b.Max.Y - y; d < scrollFadeDist { + alpha = 0xffff * d / scrollFadeDist + } + a16 := uint16(alpha) + return color.RGBA64{A: a16} +} + +func (n scrollMask) Bounds() image.Rectangle { + return image.Rectangle(n) +} + +func (_ scrollMask) ColorModel() color.Model { + return color.AlphaModel +} + +func inputWordsFlow(ctx *Context, ops op.Ctx, th *Colors, mnemonic bip39.Mnemonic, selected int) { + kbd := NewKeyboard(ctx) + inp := new(InputTracker) + for { + for { + kbd.Update(ctx) + e, ok := inp.Next(ctx, Button1, Button2) + if !ok { + break + } + switch e.Button { + case Button1: + if inp.Clicked(e.Button) { + return + } + case Button2: + if !inp.Clicked(e.Button) { + break + } + w, complete := kbd.Complete() + if !complete { + break + } + kbd.Clear() + mnemonic[selected] = w + for { + selected++ + if selected == len(mnemonic) { + return + } + if mnemonic[selected] == -1 { + break + } + } + } + } + dims := ctx.Platform.DisplaySize() + completedWord, complete := kbd.Complete() + op.ColorOp(ops, th.Background) + layoutTitle(ctx, ops, dims.X, th.Text, "Input Words") + + screen := layout.Rectangle{Max: dims} + _, content := screen.CutTop(leadingSize) + content, _ = content.CutBottom(8) + + kbdsz := kbd.Layout(ctx, ops.Begin(), th) + op.Position(ops, ops.End(), content.S(kbdsz)) + + layoutWord := func(ops op.Ctx, n int, word string) image.Point { + style := ctx.Styles.word + txt := fmt.Sprintf("%2d: %s", n, word) + return widget.Label(ops, style, th.Background, txt) + } + + longest := layoutWord(op.Ctx{}, 24, longestWord) + hint := kbd.Word + if complete { + hint = strings.ToUpper(bip39.LabelFor(completedWord)) + } + layoutWord(ops.Begin(), selected+1, hint) + word := ops.End() + r := image.Rectangle{Max: longest} + r.Min.Y -= 3 + op.MaskOp(ops.Begin(), assets.ButtonFocused.For(r)) + op.ColorOp(ops, th.Text) + word.Add(ops) + top, _ := content.CutBottom(kbdsz.Y) + op.Position(ops, ops.End(), top.Center(longest)) + + layoutNavigation(inp, ops, th, dims, []NavButton{{Button: Button1, Style: StyleSecondary, Icon: assets.IconBack}}...) + if complete { + layoutNavigation(inp, ops, th, dims, []NavButton{{Button: Button2, Style: StylePrimary, Icon: assets.IconCheckmark}}...) + } + ctx.Frame() + } +} + +var kbdKeys = [...][]rune{ + []rune("QWERTYUIOP"), + []rune("ASDFGHJKL"), + []rune("ZXCVBNM⌫"), +} + +type Keyboard struct { + Word string + + nvalid int + positions [len(kbdKeys)][]image.Point + bginact image.Image + bgact image.Image + bsinact image.Image + bsact image.Image + widest image.Point + backspace image.Point + size image.Point + + mask uint32 + row, col int + inp InputTracker +} + +func NewKeyboard(ctx *Context) *Keyboard { + k := new(Keyboard) + _, k.widest = ctx.Styles.keyboard.Layout(math.MaxInt, "W") + bsb := assets.KeyBackspace.Bounds() + bsWidth := bsb.Min.X*2 + bsb.Dx() + k.backspace = image.Pt(bsWidth, k.widest.Y) + k.bginact = assets.Key.For(image.Rectangle{Max: k.widest}) + k.bgact = assets.KeyActive.For(image.Rectangle{Max: k.widest}) + k.bsinact = assets.Key.For(image.Rectangle{Max: k.backspace}) + k.bsact = assets.KeyActive.For(image.Rectangle{Max: k.backspace}) + bgbnds := k.bginact.Bounds() + const margin = 2 + bgsz := bgbnds.Size().Add(image.Pt(margin, margin)) + longest := 0 + for _, row := range kbdKeys { + if n := len(row); n > longest { + longest = n + } + } + maxw := longest*bgsz.X - margin + for i, row := range kbdKeys { + n := len(row) + if i == len(kbdKeys)-1 { + // Center row without the backspace key. + n-- + } + w := bgsz.X*n - margin + off := image.Pt((maxw-w)/2, 0) + for j := range row { + pos := image.Pt(j*bgsz.X, i*bgsz.Y) + pos = pos.Add(off) + pos = pos.Sub(bgbnds.Min) + k.positions[i] = append(k.positions[i], pos) + } + } + k.size = image.Point{ + X: maxw, + Y: len(kbdKeys)*bgsz.Y - margin, + } + k.Clear() + return k +} + +func (k *Keyboard) Complete() (bip39.Word, bool) { + word := strings.ToLower(k.Word) + w, ok := bip39.ClosestWord(word) + if !ok { + return -1, false + } + // The word is complete if it's in the word list or is the only option. + return w, k.nvalid == 1 || word == bip39.LabelFor(w) +} + +func (k *Keyboard) Clear() { + k.Word = "" + k.updateMask() + k.row = len(kbdKeys) / 2 + k.col = len(kbdKeys[k.row]) / 2 + k.adjust(false) +} + +func (k *Keyboard) updateMask() { + k.mask = ^uint32(0) + word := strings.ToLower(k.Word) + w, valid := bip39.ClosestWord(word) + if !valid { + return + } + k.nvalid = 0 + for ; w < bip39.NumWords; w++ { + bip39w := bip39.LabelFor(w) + if !strings.HasPrefix(bip39w, word) { + break + } + k.nvalid++ + suffix := bip39w[len(word):] + if len(suffix) > 0 { + r := rune(strings.ToUpper(suffix)[0]) + idx, valid := k.idxForRune(r) + if !valid { + panic("valid by construction") + } + k.mask &^= 1 << idx + } + } + if k.nvalid == 1 { + k.mask = ^uint32(0) + } +} + +func (k *Keyboard) idxForRune(r rune) (int, bool) { + idx := int(r - 'A') + if idx < 0 || idx >= 32 { + return 0, false + } + return idx, true +} + +func (k *Keyboard) Valid(r rune) bool { + if r == '⌫' { + return len(k.Word) > 0 + } + idx, valid := k.idxForRune(r) + return valid && k.mask&(1< 0 { + s.choice-- + } + } + case Down, CW: + if e.Pressed { + if s.choice < len(s.Choices)-1 { + s.choice++ + } + } + } + } + + dims := ctx.Platform.DisplaySize() + s.Draw(ctx, ops, th, dims) + + layoutNavigation(inp, ops, th, dims, []NavButton{ + {Button: Button1, Style: StyleSecondary, Icon: assets.IconBack}, + {Button: Button3, Style: StylePrimary, Icon: assets.IconCheckmark}, + }...) + ctx.Frame() + } +} + +func (s *ChoiceScreen) Draw(ctx *Context, ops op.Ctx, th *Colors, dims image.Point) { + r := layout.Rectangle{Max: dims} + op.ColorOp(ops, th.Background) + + layoutTitle(ctx, ops, dims.X, th.Text, s.Title) + + _, bottom := r.CutTop(leadingSize) + sz := widget.LabelW(ops.Begin(), ctx.Styles.lead, dims.X-2*8, th.Text, s.Lead) + content, lead := bottom.CutBottom(leadingSize) + op.Position(ops, ops.End(), lead.Center(sz)) + + content = content.Shrink(16, 0, 16, 0) + + children := make([]struct { + Size image.Point + W op.CallOp + }, len(s.Choices)) + maxW := 0 + for i, c := range s.Choices { + style := ctx.Styles.button + col := th.Text + if i == s.choice { + col = th.Background + } + sz := widget.Label(ops.Begin(), style, col, c) + ch := ops.End() + children[i].Size = sz + children[i].W = ch + if sz.X > maxW { + maxW = sz.X + } + } + + inner := ops.Begin() + h := 0 + for i, c := range children { + xoff := (maxW - c.Size.X) / 2 + pos := image.Pt(xoff, h) + txt := c.W + if i == s.choice { + bg := image.Rectangle{Max: c.Size} + bg.Min.X -= xoff + bg.Max.X += xoff + op.MaskOp(inner.Begin(), assets.ButtonFocused.For(bg)) + op.ColorOp(inner, th.Text) + txt.Add(inner) + txt = inner.End() + } + op.Position(inner, txt, pos) + h += c.Size.Y + } + op.Position(ops, ops.End(), content.Center(image.Pt(maxW, h))) +} + +func mainFlow(ctx *Context, ops op.Ctx) { + var page program + inp := new(InputTracker) + for { + dims := ctx.Platform.DisplaySize() + events: + for { + e, ok := inp.Next(ctx, Button3, Center, Left, Right) + if !ok { + break + } + switch e.Button { + case Button3, Center: + if !inp.Clicked(e.Button) { + break + } + ws := &ConfirmWarningScreen{ + Title: "Remove SD card", + Body: "Remove SD card to continue.\n\nHold button to ignore this warning.", + Icon: assets.IconRight, + } + th := mainScreenTheme(page) + loop: + for !ctx.EmptySDSlot { + res := ws.Layout(ctx, ops.Begin(), th, dims) + dialog := ops.End() + switch res { + case ConfirmYes: + break loop + case ConfirmNo: + continue events + } + drawMainScreen(ctx, ops, dims, page) + dialog.Add(ops) + ctx.Frame() + } + ctx.EmptySDSlot = true + switch page { + case backupWallet: + backupWalletFlow(ctx, ops, th) + } + case Left: + if !e.Pressed { + break + } + page-- + if page < 0 { + page = backupWallet + } + case Right: + if !e.Pressed { + break + } + page++ + if page > backupWallet { + page = 0 + } + } + } + drawMainScreen(ctx, ops, dims, page) + layoutNavigation(inp, ops, mainScreenTheme(page), dims, []NavButton{ + {Button: Button3, Style: StylePrimary, Icon: assets.IconCheckmark}, + }...) + ctx.Frame() + } +} + +func mainScreenTheme(page program) *Colors { + switch page { + case backupWallet: + return &descriptorTheme + default: + panic("invalid page") + } +} + +func drawMainScreen(ctx *Context, ops op.Ctx, dims image.Point, page program) { + var th *Colors + var title string + th = mainScreenTheme(page) + switch page { + case backupWallet: + title = "Backup Wallet" + } + op.ColorOp(ops, th.Background) + + layoutTitle(ctx, ops, dims.X, th.Text, title) + + r := layout.Rectangle{Max: dims} + sz := layoutMainPage(ops.Begin(), th, dims.X, page) + op.Position(ops, ops.End(), r.Center(sz)) + + sz = layoutMainPager(ops.Begin(), th, page) + _, footer := r.CutBottom(leadingSize) + op.Position(ops, ops.End(), footer.Center(sz)) + + versz := widget.LabelW(ops.Begin(), ctx.Styles.debug, 100, th.Text, ctx.Version) + op.Position(ops, ops.End(), r.SE(versz.Add(image.Pt(4, 0)))) + shsz := widget.LabelW(ops.Begin(), ctx.Styles.debug, 100, th.Text, "SeedHammer") + op.Position(ops, ops.End(), r.SW(shsz).Add(image.Pt(3, 0))) +} + +func layoutTitle(ctx *Context, ops op.Ctx, width int, col color.NRGBA, title string) image.Rectangle { + const margin = 8 + sz := widget.LabelW(ops.Begin(), ctx.Styles.title, width-2*16, col, title) + pos := image.Pt((width-sz.X)/2, margin) + op.Position(ops, ops.End(), pos) + return image.Rectangle{ + Min: pos, + Max: pos.Add(sz), + } +} + +type ButtonStyle int + +const ( + StyleNone ButtonStyle = iota + StyleSecondary + StylePrimary +) + +type NavButton struct { + Button Button + Style ButtonStyle + Icon image.Image + Progress float32 +} + +func layoutNavigation(inp *InputTracker, ops op.Ctx, th *Colors, dims image.Point, btns ...NavButton) image.Rectangle { + navsz := assets.NavBtnPrimary.Bounds().Size() + button := func(ops op.Ctx, b NavButton) { + if b.Style == StyleNone { + return + } + switch b.Style { + case StyleSecondary: + op.MaskOp(ops, assets.NavBtnPrimary) + op.ColorOp(ops, th.Background) + op.MaskOp(ops, assets.NavBtnSecondary) + op.ColorOp(ops, th.Text) + case StylePrimary: + op.MaskOp(ops, assets.NavBtnPrimary) + op.ColorOp(ops, th.Primary) + } + icn := b.Icon + if b.Progress > 0 { + icn = ProgressImage{ + Progress: b.Progress, + Src: assets.IconProgress, + } + } + op.MaskOp(ops, icn) + switch b.Style { + case StyleSecondary: + op.ColorOp(ops, th.Text) + case StylePrimary: + op.ColorOp(ops, th.Text) + } + if b.Progress == 0 && inp.Pressed[b.Button] { + op.MaskOp(ops, assets.NavBtnPrimary) + op.ColorOp(ops, color.NRGBA{A: theme.activeMask}) + } + } + btnsz := assets.NavBtnPrimary.Bounds().Size() + ys := [3]int{ + leadingSize, + (dims.Y - btnsz.Y) / 2, + dims.Y - leadingSize - btnsz.Y, + } + var r image.Rectangle + for _, b := range btns { + idx := int(b.Button - Button1) + button(ops.Begin(), b) + y := ys[idx] + pos := image.Pt(dims.X-btnsz.X, y) + op.Position(ops, ops.End(), pos) + r = r.Union(image.Rectangle{ + Min: pos, + Max: pos.Add(navsz), + }) + } + return r +} + +func layoutMainPage(ops op.Ctx, th *Colors, width int, page program) image.Point { + var h layout.Align + + op.MaskOp(ops.Begin(), assets.ArrowLeft) + op.ColorOp(ops, th.Text) + left := ops.End() + leftsz := h.Add(assets.ArrowLeft.Bounds().Size()) + + op.MaskOp(ops.Begin(), assets.ArrowRight) + op.ColorOp(ops, th.Text) + right := ops.End() + rightsz := h.Add(assets.ArrowRight.Bounds().Size()) + + contentsz := h.Add(layoutMainPlates(ops.Begin(), page)) + content := ops.End() + + const margin = 16 + + op.Position(ops, content, image.Pt((width-contentsz.X)/2, 8+h.Y(contentsz))) + const npage = int(backupWallet) + 1 + if npage > 1 { + op.Position(ops, left, image.Pt(margin, h.Y(leftsz))) + op.Position(ops, right, image.Pt(width-margin-rightsz.X, h.Y(rightsz))) + } + + return image.Pt(width, h.Size.Y) +} + +func layoutMainPlates(ops op.Ctx, page program) image.Point { + switch page { + case backupWallet: + img := assets.Hammer + op.ImageOp(ops, img) + return img.Bounds().Size() + } + panic("invalid page") +} + +func layoutMainPager(ops op.Ctx, th *Colors, page program) image.Point { + const npages = int(backupWallet) + 1 + const space = 4 + if npages <= 1 { + return image.Point{} + } + sz := assets.CircleFilled.Bounds().Size() + for i := 0; i < npages; i++ { + op.Offset(ops, image.Pt((sz.X+space)*i, 0)) + mask := assets.Circle + if i == int(page) { + mask = assets.CircleFilled + } + op.MaskOp(ops, mask) + op.ColorOp(ops, th.Text) + } + return image.Pt((sz.X+space)*npages-space, sz.Y) +} + +func backupWalletFlow(ctx *Context, ops op.Ctx, th *Colors) { + mnemonic, ok := newMnemonicFlow(ctx, ops, th) + if !ok { + return + } + ss := new(SeedScreen) + for { + if !ss.Confirm(ctx, ops, th, mnemonic) { + return + } + desc, ok := inputDescriptorFlow(ctx, ops, th, mnemonic) + if !ok { + continue + } + if desc == nil { + plate, err := engraveSeed(ctx.Platform.PlateSizes(), ctx.Platform.EngraverParams(), mnemonic) + if err != nil { + errScr := NewErrorScreen(err) + for { + dims := ctx.Platform.DisplaySize() + dismissed := errScr.Layout(ctx, ops.Begin(), th, dims) + d := ops.End() + if dismissed { + break + } + ss.Draw(ctx, ops, th, dims, mnemonic) + d.Add(ops) + ctx.Frame() + } + continue + } + completed := NewEngraveScreen(ctx, plate).Engrave(ctx, ops, &engraveTheme) + if completed { + return + } + continue + } + + ds := &DescriptorScreen{ + Descriptor: *desc, + Mnemonic: mnemonic, + } + for { + keyIdx, ok := ds.Confirm(ctx, ops, th) + if !ok { + break + } + plate, err := engravePlate(ctx.Platform.PlateSizes(), ctx.Platform.EngraverParams(), *desc, keyIdx, mnemonic) + if err != nil { + errScr := NewErrorScreen(err) + for { + dims := ctx.Platform.DisplaySize() + dismissed := errScr.Layout(ctx, ops.Begin(), th, dims) + d := ops.End() + if dismissed { + break + } + ss.Draw(ctx, ops, th, dims, mnemonic) + d.Add(ops) + ctx.Frame() + } + continue + } + completed := NewEngraveScreen(ctx, plate).Engrave(ctx, ops, &engraveTheme) + if completed { + return + } + } + } +} + +func newMnemonicFlow(ctx *Context, ops op.Ctx, th *Colors) (bip39.Mnemonic, bool) { + cs := &ChoiceScreen{ + Title: "Input Seed", + Lead: "Choose input method", + Choices: []string{"KEYBOARD", "CAMERA"}, + } + showErr := func(errScreen *ErrorScreen) { + for { + dims := ctx.Platform.DisplaySize() + dismissed := errScreen.Layout(ctx, ops.Begin(), th, dims) + d := ops.End() + if dismissed { + break + } + cs.Draw(ctx, ops, th, dims) + d.Add(ops) + ctx.Frame() + } + } +outer: + for { + choice, ok := cs.Choose(ctx, ops, th) + if !ok { + return nil, false + } + switch choice { + case 0: // Keyboard. + cs := &ChoiceScreen{ + Title: "Input Seed", + Lead: "Choose number of words", + Choices: []string{"12 WORDS", "24 WORDS"}, + } + for { + choice, ok := cs.Choose(ctx, ops, th) + if !ok { + continue outer + } + mnemonic := emptyMnemonic([]int{12, 24}[choice]) + inputWordsFlow(ctx, ops, th, mnemonic, 0) + if !isEmptyMnemonic(mnemonic) { + return mnemonic, true + } + } + case 1: // Camera. + res, ok := (&ScanScreen{ + Title: "Scan", + Lead: "SeedQR or Mnemonic", + }).Scan(ctx, ops) + if !ok { + continue + } + if b, ok := res.([]byte); ok { + if sqr, ok := seedqr.Parse(b); ok { + res = sqr + } else if sqr, err := bip39.ParseMnemonic(strings.ToLower(string(b))); err == nil || errors.Is(err, bip39.ErrInvalidChecksum) { + res = sqr + } + } + seed, ok := res.(bip39.Mnemonic) + if !ok { + showErr(&ErrorScreen{ + Title: "Invalid Seed", + Body: "The scanned data does not represent a seed.", + }) + continue + } + return seed, true + } + } +} + +type SeedScreen struct { + selected int +} + +func (s *SeedScreen) Confirm(ctx *Context, ops op.Ctx, th *Colors, mnemonic bip39.Mnemonic) bool { + inp := new(InputTracker) + for { + events: + for { + e, ok := inp.Next(ctx, Button1, Button2, Center, Button3, Up, Down) + if !ok { + break + } + switch e.Button { + case Button1: + if !inp.Clicked(e.Button) { + break + } + if isEmptyMnemonic(mnemonic) { + return false + } + confirm := &ConfirmWarningScreen{ + Title: "Discard Seed?", + Body: "Going back will discard the seed.\n\nHold button to confirm.", + Icon: assets.IconDiscard, + } + for { + dims := ctx.Platform.DisplaySize() + res := confirm.Layout(ctx, ops.Begin(), th, dims) + d := ops.End() + switch res { + case ConfirmNo: + continue events + case ConfirmYes: + return false + } + s.Draw(ctx, ops, th, dims, mnemonic) + d.Add(ops) + ctx.Frame() + } + case Button2, Center: + if !inp.Clicked(e.Button) { + break + } + inputWordsFlow(ctx, ops, th, mnemonic, s.selected) + continue + case Button3: + if !inp.Clicked(e.Button) || !isMnemonicComplete(mnemonic) { + break + } + showErr := func(scr *ErrorScreen) { + for { + dims := ctx.Platform.DisplaySize() + dismissed := scr.Layout(ctx, ops.Begin(), th, dims) + d := ops.End() + if dismissed { + break + } + s.Draw(ctx, ops, th, dims, mnemonic) + d.Add(ops) + ctx.Frame() + } + } + if !mnemonic.Valid() { + scr := &ErrorScreen{ + Title: "Invalid Seed", + } + var words []string + for _, w := range mnemonic { + words = append(words, bip39.LabelFor(w)) + } + if nonstandard.ElectrumSeed(strings.Join(words, " ")) { + scr.Body = "Electrum seeds are not supported." + } else { + scr.Body = "The seed phrase is invalid.\n\nCheck the words and try again." + } + showErr(scr) + break + } + _, ok = deriveMasterKey(mnemonic, &chaincfg.MainNetParams) + if !ok { + showErr(&ErrorScreen{ + Title: "Invalid Seed", + Body: "The seed is invalid.", + }) + break + } + return true + case Down: + if e.Pressed && s.selected < len(mnemonic)-1 { + s.selected++ + } + case Up: + if e.Pressed && s.selected > 0 { + s.selected-- + } + } + } + + dims := ctx.Platform.DisplaySize() + s.Draw(ctx, ops, th, dims, mnemonic) + + layoutNavigation(inp, ops, th, dims, []NavButton{ + {Button: Button1, Style: StyleSecondary, Icon: assets.IconBack}, + {Button: Button2, Style: StyleSecondary, Icon: assets.IconEdit}, + }...) + if isMnemonicComplete(mnemonic) { + layoutNavigation(inp, ops, th, dims, []NavButton{ + {Button: Button3, Style: StylePrimary, Icon: assets.IconCheckmark}, + }...) + } + ctx.Frame() + } +} + +func isMnemonicComplete(m bip39.Mnemonic) bool { + for _, w := range m { + if w == -1 { + return false + } + } + return len(m) > 0 +} + +func (s *SeedScreen) Draw(ctx *Context, ops op.Ctx, th *Colors, dims image.Point, mnemonic bip39.Mnemonic) { + op.ColorOp(ops, th.Background) + layoutTitle(ctx, ops, dims.X, th.Text, "Confirm Seed") + + style := ctx.Styles.word + _, longestPrefix := style.Layout(math.MaxInt, "24: ") + layoutWord := func(ops op.Ctx, col color.NRGBA, n int, word string) image.Point { + prefix := widget.Label(ops.Begin(), style, col, fmt.Sprintf("%d: ", n)) + op.Position(ops, ops.End(), image.Pt(longestPrefix.X-prefix.X, 0)) + txt := widget.Label(ops.Begin(), style, col, word) + op.Position(ops, ops.End(), image.Pt(longestPrefix.X, 0)) + return image.Pt(longestPrefix.X+txt.X, txt.Y) + } + + y := 0 + longest := layoutWord(op.Ctx{}, color.NRGBA{}, 24, longestWord) + r := layout.Rectangle{Max: dims} + navw := assets.NavBtnPrimary.Bounds().Dx() + list := r.Shrink(leadingSize, 0, 0, 0) + content := list.Shrink(scrollFadeDist, navw, scrollFadeDist, navw) + lineHeight := longest.Y + 2 + linesPerPage := content.Dy() / lineHeight + scroll := s.selected - linesPerPage/2 + maxScroll := len(mnemonic) - linesPerPage + if scroll > maxScroll { + scroll = maxScroll + } + if scroll < 0 { + scroll = 0 + } + off := content.Min.Add(image.Pt(0, -scroll*lineHeight)) + { + ops := ops.Begin() + for i, w := range mnemonic { + ops.Begin() + col := th.Text + if i == s.selected { + col = th.Background + r := image.Rectangle{Max: longest} + r.Min.Y -= 3 + op.MaskOp(ops, assets.ButtonFocused.For(r)) + op.ColorOp(ops, th.Text) + } + word := strings.ToUpper(bip39.LabelFor(w)) + layoutWord(ops, col, i+1, word) + pos := image.Pt(0, y).Add(off) + op.Position(ops, ops.End(), pos) + y += lineHeight + } + } + fadeClip(ops, ops.End(), image.Rectangle(list)) +} + +func inputDescriptorFlow(ctx *Context, ops op.Ctx, th *Colors, mnemonic bip39.Mnemonic) (*urtypes.OutputDescriptor, bool) { + cs := &ChoiceScreen{ + Title: "Descriptor", + Lead: "Choose input method", + Choices: []string{"SCAN", "SKIP"}, + } + if ctx.LastDescriptor != nil { + if _, match := descriptorKeyIdx(*ctx.LastDescriptor, mnemonic, ""); match { + cs.Choices = append(cs.Choices, "RE-USE") + } + } + showErr := func(errScreen *ErrorScreen) { + for { + dims := ctx.Platform.DisplaySize() + dismissed := errScreen.Layout(ctx, ops.Begin(), th, dims) + d := ops.End() + if dismissed { + break + } + cs.Draw(ctx, ops, th, dims) + d.Add(ops) + ctx.Frame() + } + } + for { + choice, ok := cs.Choose(ctx, ops, th) + if !ok { + return nil, false + } + switch choice { + case 0: // Scan. + res, ok := (&ScanScreen{ + Title: "Scan", + Lead: "Wallet Output Descriptor", + }).Scan(ctx, ops) + if !ok { + continue + } + desc, ok := res.(urtypes.OutputDescriptor) + if !ok { + if b, isbytes := res.([]byte); isbytes { + d, err := nonstandard.OutputDescriptor(b) + desc, ok = d, err == nil + } + } + if !ok { + showErr(&ErrorScreen{ + Title: "Invalid Descriptor", + Body: "The scanned data does not represent a wallet output descriptor or XPUB key.", + }) + continue + } + if !address.Supported(desc) { + showErr(&ErrorScreen{ + Title: "Invalid Descriptor", + Body: "The scanned descriptor is not supported.", + }) + continue + } + if len(desc.Keys) == 1 && desc.Keys[0].MasterFingerprint == 0 { + mfp, _ := masterFingerprintFor(mnemonic, &chaincfg.MainNetParams) + desc.Keys[0].MasterFingerprint = mfp + } + desc.Title = backup.TitleString(constant.Font, desc.Title) + ctx.LastDescriptor = &desc + return &desc, true + case 1: // Skip descriptor. + return nil, true + case 2: // Re-use. + return ctx.LastDescriptor, true + } + } +} + +type DescriptorScreen struct { + Descriptor urtypes.OutputDescriptor + Mnemonic bip39.Mnemonic +} + +func (s *DescriptorScreen) Confirm(ctx *Context, ops op.Ctx, th *Colors) (int, bool) { + showErr := func(errScreen *ErrorScreen) { + for { + dims := ctx.Platform.DisplaySize() + dismissed := errScreen.Layout(ctx, ops.Begin(), th, dims) + d := ops.End() + if dismissed { + break + } + s.Draw(ctx, ops, th, dims) + d.Add(ops) + ctx.Frame() + } + } + inp := new(InputTracker) + for { + for { + e, ok := inp.Next(ctx, Button1, Button2, Button3) + if !ok { + break + } + switch e.Button { + case Button1: + if inp.Clicked(e.Button) { + return 0, false + } + case Button2: + if !inp.Clicked(e.Button) { + break + } + NewAddressesScreen(s.Descriptor).Show(ctx, ops, th) + case Button3: + if !inp.Clicked(e.Button) { + break + } + if err := validateDescriptor(ctx.Platform.EngraverParams(), s.Descriptor); err != nil { + showErr(NewErrorScreen(err)) + continue + } + keyIdx, ok := descriptorKeyIdx(s.Descriptor, s.Mnemonic, "") + if !ok { + // Passphrase protected seeds don't match the descriptor, so + // allow the user to ignore the mismatch. Don't allow this for + // multisig descriptors where we can't know which key the seed + // belongs to. + if len(s.Descriptor.Keys) == 1 { + confirm := &ConfirmWarningScreen{ + Title: "Unknown Wallet", + Body: "The wallet does not match the seed.\n\nIf it is passphrase protected, long press to confirm.", + Icon: assets.IconCheckmark, + } + loop: + for { + dims := ctx.Platform.DisplaySize() + res := confirm.Layout(ctx, ops.Begin(), th, dims) + d := ops.End() + switch res { + case ConfirmYes: + return 0, true + case ConfirmNo: + break loop + } + s.Draw(ctx, ops, th, dims) + d.Add(ops) + ctx.Frame() + } + } else { + showErr(&ErrorScreen{ + Title: "Unknown Wallet", + Body: "The wallet does not match the seed or is passphrase protected.", + }) + } + continue + } + return keyIdx, true + } + } + + dims := ctx.Platform.DisplaySize() + s.Draw(ctx, ops, th, dims) + layoutNavigation(inp, ops, th, dims, []NavButton{ + {Button: Button1, Style: StyleSecondary, Icon: assets.IconBack}, + {Button: Button2, Style: StyleSecondary, Icon: assets.IconInfo}, + {Button: Button3, Style: StylePrimary, Icon: assets.IconCheckmark}, + }...) + ctx.Frame() + } +} + +func (s *DescriptorScreen) Draw(ctx *Context, ops op.Ctx, th *Colors, dims image.Point) { + const infoSpacing = 8 + + desc := s.Descriptor + op.ColorOp(ops, th.Background) + + // Title. + r := layout.Rectangle{Max: dims} + layoutTitle(ctx, ops, dims.X, th.Text, "Confirm Wallet") + + btnw := assets.NavBtnPrimary.Bounds().Dx() + body := r.Shrink(leadingSize, btnw, 0, btnw) + + type linePos struct { + w op.CallOp + y int + } + var bodytxt richText + + bodyst := ctx.Styles.body + subst := ctx.Styles.subtitle + if desc.Title != "" { + bodytxt.Add(ops, subst, body.Dx(), th.Text, "Title") + bodytxt.Add(ops, bodyst, body.Dx(), th.Text, desc.Title) + bodytxt.Y += infoSpacing + } + bodytxt.Add(ops, subst, body.Dx(), th.Text, "Type") + var typetxt string + switch desc.Type { + case urtypes.Singlesig: + typetxt = "Singlesig" + default: + typetxt = fmt.Sprintf("%d-of-%d multisig", desc.Threshold, len(desc.Keys)) + } + if len(desc.Keys) > 0 && desc.Keys[0].Network != &chaincfg.MainNetParams { + typetxt += " (testnet)" + } + bodytxt.Add(ops, bodyst, body.Dx(), th.Text, typetxt) + bodytxt.Y += infoSpacing + bodytxt.Add(ops, subst, body.Dx(), th.Text, "Script") + bodytxt.Add(ops, bodyst, body.Dx(), th.Text, desc.Script.String()) + + ops.Begin() + for _, l := range bodytxt.Lines { + l.W.Add(ops) + } + op.Position(ops, ops.End(), body.Min.Add(image.Pt(0, scrollFadeDist))) +} + +func NewEngraveScreen(ctx *Context, plate Plate) *EngraveScreen { + var ins []Instruction + if !ctx.Calibrated { + ins = append(ins, EngraveFirstSideA...) + } else { + ins = append(ins, EngraveSideA...) + } + if len(plate.Sides) > 1 { + ins = append(ins, EngraveSideB...) + } + ins = append(ins, EngraveSuccess...) + s := &EngraveScreen{ + plate: plate, + instructions: ins, + } + for i, ins := range s.instructions { + repl := strings.NewReplacer( + "{{.Name}}", plateName(plate.Size), + ) + s.instructions[i].resolvedBody = repl.Replace(ins.Body) + // As a special case, the Sh02 image is a placeholder for the plate-specific image. + if ins.Image == assets.Sh02 { + s.instructions[i].Image = plateImage(plate.Size) + } + } + return s +} + +type EngraveScreen struct { + instructions []Instruction + plate Plate + + step int + dryRun struct { + timeout time.Time + enabled bool + } + engrave engraveState +} + +type engraveState struct { + dev Engraver + cancel chan struct{} + progress chan float32 + errs chan error + lastProgress float32 +} + +func (s *EngraveScreen) showError(ctx *Context, ops op.Ctx, th *Colors, errScr *ErrorScreen) { + for { + dims := ctx.Platform.DisplaySize() + dismissed := errScr.Layout(ctx, ops.Begin(), th, dims) + d := ops.End() + if dismissed { + break + } + s.draw(ctx, ops, th, dims) + d.Add(ops) + ctx.Frame() + } +} + +func (s *EngraveScreen) moveStep(ctx *Context, ops op.Ctx, th *Colors) bool { + ins := s.instructions[s.step] + if ins.Type == ConnectInstruction { + if s.engrave.dev != nil { + return false + } + s.engrave = engraveState{} + dev, err := ctx.Platform.Engraver() + if err != nil { + log.Printf("gui: failed to connect to engraver: %v", err) + s.showError(ctx, ops, th, &ErrorScreen{ + Title: "Connection Error", + Body: fmt.Sprintf("Ensure the engraver is turned on and verify that it is connected to the middle port of this device.\n\nError details: %v", err), + }) + return false + } + s.engrave.dev = dev + } + s.step++ + if s.step == len(s.instructions) { + return true + } + ins = s.instructions[s.step] + if ins.Type == EngraveInstruction { + plan := s.plate.Sides[ins.Side] + if s.dryRun.enabled { + plan = engrave.DryRun(plan) + } + totalDist := 0 + pen := image.Point{} + plan(func(cmd engrave.Command) { + totalDist += engrave.ManhattanDist(pen, cmd.Coord) + pen = cmd.Coord + }) + cancel := make(chan struct{}) + errs := make(chan error, 1) + progress := make(chan float32, 1) + s.engrave.cancel = cancel + s.engrave.errs = errs + s.engrave.progress = progress + dev := s.engrave.dev + wakeup := ctx.Platform.Wakeup + go func() { + defer wakeup() + defer dev.Close() + pplan := func(yield func(cmd engrave.Command)) { + dist := 0 + completed := 0 + pen := image.Point{} + plan(func(cmd engrave.Command) { + yield(cmd) + completed++ + dist += engrave.ManhattanDist(pen, cmd.Coord) + pen = cmd.Coord + // Don't spam the progress channel. + if completed%10 != 0 && dist < totalDist { + return + } + select { + case <-progress: + default: + } + p := float32(dist) / float32(totalDist) + progress <- p + wakeup() + }) + } + errs <- dev.Engrave(s.plate.Size, pplan, cancel) + }() + } + return false +} + +func (s *EngraveScreen) canPrev() bool { + return s.step > 0 && s.instructions[s.step-1].Type == PrepareInstruction +} + +func (s *EngraveScreen) Engrave(ctx *Context, ops op.Ctx, th *Colors) bool { + defer func() { + if s.engrave.cancel != nil { + close(s.engrave.cancel) + } + s.engrave = engraveState{} + }() + inp := new(InputTracker) + for { + loop: + for { + select { + case p := <-s.engrave.progress: + s.engrave.lastProgress = p + case err := <-s.engrave.errs: + s.engrave = engraveState{} + if err != nil { + log.Printf("gui: connection lost to engraver: %v", err) + s.step-- + s.showError(ctx, ops, th, &ErrorScreen{ + Title: "Connection Error", + Body: fmt.Sprintf("Turn off the engraver and disconnect this device from it. Wait 10 seconds, then turn on the engraver and reconnect.\n\nError details: %v", err), + }) + break + } + ctx.Calibrated = true + s.step++ + if s.step == len(s.instructions) { + return true + } + default: + break loop + } + } + + outer: + for { + ins := s.instructions[s.step] + if !s.dryRun.timeout.IsZero() { + now := ctx.Platform.Now() + d := s.dryRun.timeout.Sub(now) + if d <= 0 { + s.dryRun.timeout = time.Time{} + s.dryRun.enabled = !s.dryRun.enabled + } + } + e, ok := inp.Next(ctx, Button1, Button2, Button3) + if !ok { + break + } + switch e.Button { + case Button1: + if !inp.Clicked(e.Button) { + break + } + if s.canPrev() { + s.step-- + } else { + confirm := &ConfirmWarningScreen{ + Title: "Cancel?", + Body: "This will cancel the engraving process.\n\nHold button to confirm.", + Icon: assets.IconDiscard, + } + loop2: + for { + dims := ctx.Platform.DisplaySize() + res := confirm.Layout(ctx, ops.Begin(), th, dims) + d := ops.End() + switch res { + case ConfirmNo: + break loop2 + case ConfirmYes: + return false + } + s.draw(ctx, ops, th, dims) + d.Add(ops) + ctx.Frame() + } + } + case Button2: + if e.Pressed { + t := ctx.Platform.Now().Add(confirmDelay) + s.dryRun.timeout = t + ctx.WakeupAt(t) + } else { + s.dryRun.timeout = time.Time{} + } + case Button3: + switch ins.Type { + case ConnectInstruction: + if !e.Pressed { + continue + } + confirm := new(ConfirmDelay) + confirm.Start(ctx, confirmDelay) + inp.Pressed[e.Button] = false + for { + p := confirm.Progress(ctx) + if p == 1. { + break + } + for { + e, ok := inp.Next(ctx, Button3) + if !ok { + break + } + if e.Button == Button3 && !e.Pressed { + continue outer + } + } + dims := ctx.Platform.DisplaySize() + s.draw(ctx, ops, th, dims) + s.drawNav(inp, ops, th, dims, p) + ctx.Frame() + } + case EngraveInstruction: + continue + default: + if !inp.Clicked(e.Button) { + continue + } + } + if s.moveStep(ctx, ops, th) { + return true + } + } + } + + dims := ctx.Platform.DisplaySize() + s.draw(ctx, ops, th, dims) + s.drawNav(inp, ops, th, dims, 0) + + ctx.Frame() + } +} + +func (s *EngraveScreen) draw(ctx *Context, ops op.Ctx, th *Colors, dims image.Point) { + op.ColorOp(ops, th.Background) + layoutTitle(ctx, ops, dims.X, th.Text, fmt.Sprintf("Engrave Plate")) + + r := layout.Rectangle{Max: dims} + _, subt := r.CutTop(leadingSize) + subtsz := widget.Label(ops.Begin(), ctx.Styles.body, th.Text, fmt.Sprintf("%.8x", s.plate.MasterFingerprint)) + op.Position(ops, ops.End(), subt.N(subtsz).Sub(image.Pt(0, 4))) + + const margin = 8 + _, content := r.CutTop(leadingSize) + ins := s.instructions[s.step] + if ins.Type == EngraveInstruction { + progress := fmt.Sprintf("%d%%", int(s.engrave.lastProgress*100)) + _, content = subt.CutTop(subtsz.Y) + middle, _ := content.CutBottom(leadingSize) + op.Offset(ops, middle.Center(assets.ProgressCircle.Bounds().Size())) + op.MaskOp(ops, ProgressImage{ + Progress: s.engrave.lastProgress, + Src: assets.ProgressCircle, + }) + op.ColorOp(ops, th.Text) + sz := widget.Label(ops.Begin(), ctx.Styles.progress, th.Text, progress) + op.Position(ops, ops.End(), middle.Center(sz)) + } + content = content.Shrink(0, margin, 0, margin) + content, lead := content.CutBottom(leadingSize) + bodysz := widget.LabelW(ops.Begin(), ctx.Styles.lead, content.Dx(), th.Text, ins.resolvedBody) + if img := ins.Image; img != nil { + sz := img.Bounds().Size() + op.Offset(ops, image.Pt((bodysz.X-sz.X)/2, bodysz.Y)) + op.ImageOp(ops, img) + if sz.X > bodysz.X { + bodysz.X = sz.X + } + bodysz.Y += sz.Y + } + op.Position(ops, ops.End(), content.Center(bodysz)) + leadsz := widget.LabelW(ops.Begin(), ctx.Styles.lead, dims.X-2*margin, th.Text, ins.Lead) + op.Position(ops, ops.End(), lead.Center(leadsz)) + + progressw := dims.X * (s.step + 1) / len(s.instructions) + op.ClipOp(image.Rectangle{Max: image.Pt(progressw, 2)}).Add(ops) + op.ColorOp(ops, th.Text) + + if s.dryRun.enabled { + sz := widget.Label(ops.Begin(), ctx.Styles.debug, th.Text, "dry-run") + op.Position(ops, ops.End(), r.SE(sz).Sub(image.Pt(4, 0))) + } +} + +func (s *EngraveScreen) drawNav(inp *InputTracker, ops op.Ctx, th *Colors, dims image.Point, progress float32) { + icnBack := assets.IconBack + if s.canPrev() { + icnBack = assets.IconLeft + } + layoutNavigation(inp, ops, th, dims, []NavButton{{Button: Button1, Style: StyleSecondary, Icon: icnBack}}...) + ins := s.instructions[s.step] + switch ins.Type { + case EngraveInstruction: + case ConnectInstruction: + layoutNavigation(inp, ops, th, dims, []NavButton{{Button: Button3, Style: StylePrimary, Icon: assets.IconHammer, Progress: progress}}...) + default: + layoutNavigation(inp, ops, th, dims, []NavButton{{ + Button: Button3, + Style: StylePrimary, + Icon: assets.IconRight, + Progress: progress, + }}...) + } +} + +type Platform interface { + Events(deadline time.Time) []Event + Wakeup() + PlateSizes() []backup.PlateSize + Engraver() (Engraver, error) + EngraverParams() engrave.Params + CameraFrame(size image.Point) + Now() time.Time + 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) + ScanQR(qr *image.Gray) ([][]byte, error) + Debug() bool +} + +type Engraver interface { + Engrave(sz backup.PlateSize, plan engrave.Plan, quit <-chan struct{}) error + Close() +} + +type FrameEvent struct { + Error error + Image image.Image +} + +type Event struct { + typ int + data [4]uint32 + refs [2]any +} + +const ( + buttonEvent = 1 + iota + sdcardEvent + frameEvent +) + +type ButtonEvent struct { + Button Button + Pressed bool + // Rune is only valid if Button is Rune. + Rune rune +} + +type SDCardEvent struct { + Inserted bool +} + +type Button int + +const ( + Up Button = iota + Down + Left + Right + Center + Button1 + Button2 + Button3 + CCW + CW + // Synthetic keys only generated in debug mode. + Rune // Enter rune. +) + +func (b Button) String() string { + switch b { + case Up: + return "up" + case Down: + return "down" + case Left: + return "left" + case Right: + return "right" + case Center: + return "center" + case Button1: + return "b1" + case Button2: + return "b2" + case Button3: + return "b3" + case CCW: + return "ccw" + case CW: + return "cw" + case Rune: + return "rune" + default: + panic("invalid button") + } +} + +type App struct { + root op.Ops + ctx *Context + idle struct { + start time.Time + active bool + state saver.State + } +} + +func NewApp(pl Platform, version string) (*App, error) { + ctx := NewContext(pl) + ctx.Version = version + a := &App{ + ctx: ctx, + } + a.idle.start = pl.Now() + frameCh := make(chan struct{}) + ctx.Frame = func() { + frameCh <- struct{}{} + <-frameCh + } + go func() { + <-frameCh + mainFlow(ctx, a.root.Context()) + }() + return a, nil +} + +const idleTimeout = 3 * time.Minute + +func (a *App) Frame() { + wakeup := a.ctx.Wakeup + a.ctx.Reset() + now := a.ctx.Platform.Now() + for _, e := range a.ctx.Platform.Events(wakeup) { + a.idle.start = now + if se, ok := e.AsSDCard(); ok { + a.ctx.EmptySDSlot = !se.Inserted + } else { + a.ctx.Events(e) + } + wakeup = time.Time{} + } + a.ctx.WakeupAt(a.idle.start.Add(idleTimeout)) + idle := now.Sub(a.idle.start) >= idleTimeout + if a.idle.active != idle { + a.idle.active = idle + if idle { + a.idle.state = saver.State{} + } else { + // The screen saver has invalidated the cached + // frame content. + a.root = op.Ops{} + } + } + if a.idle.active { + a.idle.state.Draw(a.ctx.Platform) + return + } + dims := a.ctx.Platform.DisplaySize() + start := time.Now() + a.root.Reset() + a.ctx.Frame() + dirty := a.root.Clip(image.Rectangle{Max: dims}) + layoutTime := time.Now() + renderTime := time.Now() + if err := a.ctx.Platform.Dirty(dirty); err != nil { + panic(err) + } + for { + fb, ok := a.ctx.Platform.NextChunk() + if !ok { + break + } + a.root.Draw(fb) + } + drawTime := time.Now() + if a.ctx.Platform.Debug() { + log.Printf("frame: %v layout: %v render: %v draw: %v %v", + drawTime.Sub(start), layoutTime.Sub(start), renderTime.Sub(layoutTime), drawTime.Sub(renderTime), dirty) + } +} + +func rgb(c uint32) color.NRGBA { + return argb(0xff000000 | c) +} + +func argb(c uint32) color.NRGBA { + return color.NRGBA{A: uint8(c >> 24), R: uint8(c >> 16), G: uint8(c >> 8), B: uint8(c)} +} + +func (f FrameEvent) Event() Event { + e := Event{typ: frameEvent} + e.refs[0] = f.Error + e.refs[1] = f.Image + return e +} + +func (b ButtonEvent) Event() Event { + pressed := uint32(0) + if b.Pressed { + pressed = 1 + } + e := Event{typ: buttonEvent} + e.data[0] = uint32(b.Button) + e.data[1] = pressed + e.data[2] = uint32(b.Rune) + return e +} + +func (s SDCardEvent) Event() Event { + e := Event{typ: sdcardEvent} + if s.Inserted { + e.data[0] = 1 + } + return e +} + +func (e Event) AsFrame() (FrameEvent, bool) { + if e.typ != frameEvent { + return FrameEvent{}, false + } + f := FrameEvent{} + if r := e.refs[0]; r != nil { + f.Error = r.(error) + } + if r := e.refs[1]; r != nil { + f.Image = r.(image.Image) + } + return f, true +} + +func (e Event) AsButton() (ButtonEvent, bool) { + if e.typ != buttonEvent { + return ButtonEvent{}, false + } + return ButtonEvent{ + Button: Button(e.data[0]), + Pressed: e.data[1] != 0, + Rune: rune(e.data[2]), + }, true +} + +func (e Event) AsSDCard() (SDCardEvent, bool) { + if e.typ != sdcardEvent { + return SDCardEvent{}, false + } + return SDCardEvent{ + Inserted: e.data[0] != 0, + }, true +} diff --git a/gui/gui_test.go b/gui/gui_test.go new file mode 100644 index 0000000..b1cf712 --- /dev/null +++ b/gui/gui_test.go @@ -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, + }, + }, + }, +} diff --git a/gui/layout/layout.go b/gui/layout/layout.go new file mode 100644 index 0000000..88b362c --- /dev/null +++ b/gui/layout/layout.go @@ -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 +} diff --git a/gui/op/op.go b/gui/op/op.go new file mode 100644 index 0000000..2eac3ea --- /dev/null +++ b/gui/op/op.go @@ -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) +} diff --git a/gui/saver/saver.go b/gui/saver/saver.go new file mode 100644 index 0000000..a91eaa3 --- /dev/null +++ b/gui/saver/saver.go @@ -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, + }) +} diff --git a/gui/text/text.go b/gui/text/text.go new file mode 100644 index 0000000..6330833 --- /dev/null +++ b/gui/text/text.go @@ -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(), + } +} diff --git a/gui/theme.go b/gui/theme.go new file mode 100644 index 0000000..c416229 --- /dev/null +++ b/gui/theme.go @@ -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, + }, + } +} diff --git a/gui/widget/label.go b/gui/widget/label.go new file mode 100644 index 0000000..4c8ca2f --- /dev/null +++ b/gui/widget/label.go @@ -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 +}