feat(crypha): add bubbletea tui wizard over the shared engine (M8)

Guided hide/reveal/capacity wizard in internal/tui as a pure view over internal/engine (no carrier logic): HCL gradient engine, live capacity meter, embed animation, and a secure-options form. Bare crypha on a TTY launches it; piped or --help prints help. Adds engine.Overhead and engine.EnvelopeSize for exact payload-fit preflight (flate pass only, no KDF).
This commit is contained in:
CarterPerez-dev 2026-07-16 10:11:33 -04:00
parent 3b790e5956
commit f0e90271e7
24 changed files with 3122 additions and 0 deletions

View File

@ -3,8 +3,12 @@ module github.com/CarterPerez-dev/crypha
go 1.25.0
require (
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/go-audio/audio v1.0.0
github.com/go-audio/wav v1.1.0
github.com/lucasb-eyer/go-colorful v1.3.0
github.com/makiuchi-d/gozxing v0.1.1
github.com/mewkiz/flac v1.0.13
github.com/pdfcpu/pdfcpu v0.13.0
@ -17,18 +21,35 @@ require (
)
require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.9.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/go-audio/riff v1.0.0 // indirect
github.com/hhrutter/lzw v1.0.0 // indirect
github.com/hhrutter/pkcs7 v0.2.2 // indirect
github.com/hhrutter/tiff v1.0.3 // indirect
github.com/icza/bitio v1.1.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.24 // indirect
github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d // indirect
github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect

View File

@ -1,6 +1,32 @@
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/go-audio/audio v1.0.0 h1:zS9vebldgbQqktK4H0lUqWrG8P0NxCJVqcj7ZpNnwd4=
github.com/go-audio/audio v1.0.0/go.mod h1:6uAu0+H2lHkwdGsAY+j2wHPNPpPoeg5AaEFh9FlA+Zs=
github.com/go-audio/riff v1.0.0 h1:d8iCGbDvox9BfLagY94fBynxSPHO80LmZCaOsmKxokA=
@ -19,8 +45,14 @@ github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6 h1:8UsGZ2rr2ksmEru6lTo
github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6/go.mod h1:xQig96I1VNBDIWGCdTt54nHt6EeI639SmHycLYL7FkA=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/makiuchi-d/gozxing v0.1.1 h1:xxqijhoedi+/lZlhINteGbywIrewVdVv2wl9r5O9S1I=
github.com/makiuchi-d/gozxing v0.1.1/go.mod h1:eRIHbOjX7QWxLIDJoQuMLhuXg9LAuw6znsUtRkNw9DU=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU=
github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mewkiz/flac v1.0.13 h1:6wF8rRQKBFW159Daqx6Ro7K5ZnlVhHUKfS5aTsC4oXs=
@ -29,10 +61,18 @@ github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d h1:IL2tii4jXLdhCeQN69HN
github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d/go.mod h1:SIpumAnUWSy0q9RzKD3pyH3g1t5vdawUAPcW5tQrUtI=
github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985 h1:h8O1byDZ1uk6RUXMhj1QJU3VXFKXHDZxr4TXRPGeBa8=
github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985/go.mod h1:uiPmbdUbdt1NkGApKl7htQjZ8S7XaGUAVulJUJ9v6q4=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/pdfcpu/pdfcpu v0.13.0 h1:7maI7K0w4pJsgX9u7eeCsK4+An/+xEVkJwAwyd7/n3M=
github.com/pdfcpu/pdfcpu v0.13.0/go.mod h1:Pz8elxcY3MHc3W65HeeDbuSBvsq+OK+enMVdBsvKCj4=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
@ -41,11 +81,17 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=

View File

@ -15,6 +15,7 @@ const (
cmdCapacity = "capacity"
cmdFormats = "formats"
cmdVersion = "version"
cmdTUI = "tui"
flagJSON = "json"
flagFormat = "format"

View File

@ -13,6 +13,7 @@ import (
"strings"
"github.com/CarterPerez-dev/crypha/internal/config"
"github.com/CarterPerez-dev/crypha/internal/tui"
"github.com/spf13/cobra"
)
@ -27,12 +28,19 @@ func newRootCmd() *cobra.Command {
}
root.SetVersionTemplate(config.BinaryName + " {{.Version}}\n")
root.PersistentFlags().Bool(flagJSON, false, "emit machine-readable JSON")
root.RunE = func(cmd *cobra.Command, args []string) error {
if len(args) == 0 && launchInteractive() {
return tui.Run()
}
return cmd.Help()
}
root.AddCommand(
newHideCmd(),
newRevealCmd(),
newCapacityCmd(),
newFormatsCmd(),
newVersionCmd(),
newTuiCmd(),
)
return root
}

View File

@ -0,0 +1,31 @@
/*
©AngelaMos | 2026
tui.go
The tui command plus the terminal check that launches the wizard on a bare invocation
*/
package cli
import (
"os"
"github.com/CarterPerez-dev/crypha/internal/tui"
"github.com/spf13/cobra"
"golang.org/x/term"
)
var launchInteractive = func() bool {
return term.IsTerminal(int(os.Stdin.Fd())) && term.IsTerminal(int(os.Stdout.Fd()))
}
func newTuiCmd() *cobra.Command {
return &cobra.Command{
Use: cmdTUI,
Short: "Launch the interactive terminal wizard",
Args: cobra.NoArgs,
RunE: func(_ *cobra.Command, _ []string) error {
return tui.Run()
},
}
}

View File

@ -0,0 +1,40 @@
/*
©AngelaMos | 2026
tui_test.go
Tests for the tui command registration and the bare-invocation help fallback
*/
package cli
import (
"strings"
"testing"
)
func TestBareInvocationPrintsHelpWhenNonInteractive(t *testing.T) {
old := launchInteractive
launchInteractive = func() bool { return false }
defer func() { launchInteractive = old }()
out, _, err := run(t, "")
if err != nil {
t.Fatalf("bare invocation errored: %v", err)
}
if !strings.Contains(out, cmdTUI) {
t.Fatalf("help output missing the tui command:\n%s", out)
}
if !strings.Contains(out, "Available Commands") {
t.Fatalf("bare invocation did not print help:\n%s", out)
}
}
func TestTuiCommandRegistered(t *testing.T) {
root := newRootCmd()
for _, c := range root.Commands() {
if c.Name() == cmdTUI {
return
}
}
t.Fatalf("tui command not registered on root")
}

View File

@ -173,6 +173,14 @@ func Techniques(format string) []string {
}
}
func Overhead(encrypted bool) int {
return payload.Overhead(encrypted)
}
func EnvelopeSize(data []byte, opts payload.Options) (int, error) {
return payload.EnvelopeSize(data, opts)
}
func locate(format string, stego []byte) (carrier.Carrier, []byte, error) {
if format != "" {
c, err := ResolveCarrier(format, "")

View File

@ -0,0 +1,19 @@
/*
©AngelaMos | 2026
overhead_test.go
Known-answer test tying the engine overhead helper to the documented envelope sizes
*/
package engine
import "testing"
func TestOverheadKnownValues(t *testing.T) {
if got := Overhead(false); got != 14 {
t.Fatalf("Overhead(false) = %d, want 14", got)
}
if got := Overhead(true); got != 68 {
t.Fatalf("Overhead(true) = %d, want 68", got)
}
}

View File

@ -0,0 +1,57 @@
/*
©AngelaMos | 2026
envelopesize_test.go
Known-answer tests proving EnvelopeSize equals the real packed envelope length
*/
package payload
import (
"bytes"
"testing"
)
func TestEnvelopeSizeMatchesPack(t *testing.T) {
data := bytes.Repeat([]byte("crypha steganography "), 8)
cases := []Options{
{},
{Compress: true},
{Passphrase: []byte("pw"), Cipher: CipherChaCha20, Strength: StrengthDefault},
{Passphrase: []byte("pw"), Cipher: CipherAES256GCM, Strength: StrengthDefault, Compress: true},
}
for i, opts := range cases {
env, err := Pack(data, opts)
if err != nil {
t.Fatalf("case %d Pack: %v", i, err)
}
size, err := EnvelopeSize(data, opts)
if err != nil {
t.Fatalf("case %d EnvelopeSize: %v", i, err)
}
if size != len(env) {
t.Fatalf("case %d: EnvelopeSize=%d, len(Pack)=%d", i, size, len(env))
}
}
}
func TestEnvelopeSizeOverhead(t *testing.T) {
data := []byte("hello")
plain, _ := EnvelopeSize(data, Options{})
if plain != len(data)+Overhead(false) {
t.Fatalf("plaintext size = %d, want %d", plain, len(data)+Overhead(false))
}
enc, _ := EnvelopeSize(data, Options{Passphrase: []byte("x")})
if enc != len(data)+Overhead(true) {
t.Fatalf("encrypted size = %d, want %d", enc, len(data)+Overhead(true))
}
}
func TestEnvelopeSizeCompressionShrinks(t *testing.T) {
data := bytes.Repeat([]byte("A"), 1024)
plain, _ := EnvelopeSize(data, Options{})
comp, _ := EnvelopeSize(data, Options{Compress: true})
if comp >= plain {
t.Fatalf("compression did not shrink the envelope: comp=%d plain=%d", comp, plain)
}
}

View File

@ -79,3 +79,15 @@ func Overhead(encrypted bool) int {
}
return base + cipherLen + paramsLen + saltLen + nonceLen + tagLen
}
func EnvelopeSize(data []byte, opts Options) (int, error) {
body := data
if opts.Compress {
c, err := compress(data)
if err != nil {
return 0, err
}
body = c
}
return Overhead(len(opts.Passphrase) > 0) + len(body), nil
}

View File

@ -0,0 +1,94 @@
/*
©AngelaMos | 2026
chrome.go
Header wordmark, wizard stepper, footer keybinds, and shared panel primitives
*/
package tui
import (
"strings"
"github.com/charmbracelet/lipgloss"
)
const (
wordmarkRows = 3
wordmarkGap = " "
labelWidth = 12
tagline = "multi-carrier steganography"
)
var wordmarkGlyphs = [][wordmarkRows]string{
{"█▀▀", "█ ", "▀▀▀"},
{"█▀▄", "█▀▄", "▀ ▀"},
{"█ █", "▀█▀", " ▀ "},
{"█▀▄", "█▀▀", "█ "},
{"█ █", "█▀█", "▀ ▀"},
{"▄▀▄", "█▀█", "▀ ▀"},
}
type keyHint struct {
key string
desc string
}
func wordmarkLines() []string {
lines := make([]string, wordmarkRows)
for row := range wordmarkRows {
parts := make([]string, len(wordmarkGlyphs))
for i, g := range wordmarkGlyphs {
parts[i] = g[row]
}
lines[row] = strings.Join(parts, wordmarkGap)
}
return lines
}
func renderHeader(width int) string {
mark := gradientBlock(wordmarkLines(), brandStops, true)
tag := styleTagline.Render(tagline)
rule := gradientRule(width, brandStops)
return lipgloss.JoinVertical(lipgloss.Left, mark, tag, "", rule)
}
func renderStepper(labels []string, current int) string {
parts := make([]string, 0, len(labels)*2)
for i, label := range labels {
var token string
switch {
case i < current:
token = styleStepDone.Render(label)
case i == current:
token = styleCursor.Render(accentBar) + styleStepCurrent.Render(label)
default:
token = styleStepFuture.Render(label)
}
parts = append(parts, token)
if i < len(labels)-1 {
parts = append(parts, styleHelpSep.Render(" "+connector+" "))
}
}
return strings.Join(parts, "")
}
func renderFooter(hints []keyHint) string {
parts := make([]string, 0, len(hints))
for _, h := range hints {
parts = append(parts, styleHelpKey.Render(h.key)+" "+styleHelpDesc.Render(h.desc))
}
return strings.Join(parts, styleHelpSep.Render(" │ "))
}
func sectionTitle(text string) string {
return stylePanelTitle.Render(accentBar) + " " + styleValueBold.Render(text)
}
func kv(label, value string) string {
return styleLabel.Width(labelWidth).Render(label) + styleValue.Render(value)
}
func kvStyled(label, value string, valueStyle lipgloss.Style) string {
return styleLabel.Width(labelWidth).Render(label) + valueStyle.Render(value)
}

View File

@ -0,0 +1,84 @@
/*
©AngelaMos | 2026
commands.go
Bubbletea commands and messages for engine calls, file IO, and the embed animation
*/
package tui
import (
"bytes"
"os"
"time"
"github.com/CarterPerez-dev/crypha/internal/engine"
tea "github.com/charmbracelet/bubbletea"
)
type fileLoadedMsg struct {
origin stage
path string
data []byte
err error
}
type hideDoneMsg struct {
res engine.HideResult
data []byte
err error
}
type revealDoneMsg struct {
res engine.RevealResult
err error
}
type savedMsg struct {
path string
err error
}
type tickMsg time.Time
func loadFileCmd(origin stage, path string) tea.Cmd {
return func() tea.Msg {
data, err := os.ReadFile(path)
return fileLoadedMsg{origin: origin, path: path, data: data, err: err}
}
}
func hideCmd(req engine.HideRequest, outPath string) tea.Cmd {
return func() tea.Msg {
var buf bytes.Buffer
req.Out = &buf
res, err := engine.Hide(req)
if err != nil {
return hideDoneMsg{err: err}
}
if err := os.WriteFile(outPath, buf.Bytes(), outFilePerm); err != nil {
return hideDoneMsg{err: err}
}
return hideDoneMsg{res: res, data: buf.Bytes()}
}
}
func revealCmd(format string, stego, pass []byte) tea.Cmd {
return func() tea.Msg {
res, err := engine.Reveal(engine.RevealRequest{Format: format, Stego: stego, Passphrase: pass})
return revealDoneMsg{res: res, err: err}
}
}
func saveCmd(path string, data []byte) tea.Cmd {
return func() tea.Msg {
err := os.WriteFile(path, data, outFilePerm)
return savedMsg{path: path, err: err}
}
}
func tick() tea.Cmd {
return tea.Tick(animInterval, func(t time.Time) tea.Msg {
return tickMsg(t)
})
}

View File

@ -0,0 +1,224 @@
/*
©AngelaMos | 2026
flows_test.go
Coverage for the file-mode payload, save flow, reset, and command plumbing
*/
package tui
import (
"os"
"path/filepath"
"strings"
"testing"
)
type nopMsg struct{}
func hideStego(t *testing.T, secret string) []byte {
t.Helper()
out := filepath.Join(t.TempDir(), "s.png")
cover := makePNG(t, 64, 64)
m := ready()
m, _ = step(m, keyEnter())
m = pickFormat(t, m, "image")
m, _ = step(m, keyEnter())
m, _ = step(m, fileLoadedMsg{origin: stageCover, path: "c.png", data: cover})
m, _ = step(m, typeText(secret))
m, _ = step(m, keyEnter())
m, _ = step(m, keyEnter())
m.outPath.SetValue(out)
m, _ = step(m, keyEnter())
m, cmd := step(m, keyEnter())
m = finishRun(t, m, cmd)
if m.engineErr != nil {
t.Fatalf("hide errored: %v", m.engineErr)
}
data, err := os.ReadFile(out)
if err != nil {
t.Fatalf("read stego: %v", err)
}
return data
}
func TestInitReturnsCmd(t *testing.T) {
if New().Init() == nil {
t.Fatalf("Init returned nil")
}
}
func TestLoadFileCmd(t *testing.T) {
p := filepath.Join(t.TempDir(), "f")
if err := os.WriteFile(p, []byte("hi"), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
msg, ok := loadFileCmd(stageCover, p)().(fileLoadedMsg)
if !ok || msg.err != nil || string(msg.data) != "hi" || msg.origin != stageCover {
t.Fatalf("loadFileCmd = %+v ok=%v", msg, ok)
}
miss, _ := loadFileCmd(stageCover, filepath.Join(t.TempDir(), "nope"))().(fileLoadedMsg)
if miss.err == nil {
t.Fatalf("expected an error for a missing file")
}
}
func TestPayloadFileMode(t *testing.T) {
cover := makePNG(t, 48, 48)
m := ready()
m, _ = step(m, keyEnter())
m = pickFormat(t, m, "image")
m, _ = step(m, keyEnter())
m, _ = step(m, fileLoadedMsg{origin: stageCover, path: "c.png", data: cover})
if m.stage != stagePayload {
t.Fatalf("stage = %v", m.stage)
}
m, _ = step(m, keyTab())
if m.payloadMode != payloadFile {
t.Fatalf("tab did not switch to file mode")
}
m, _ = step(m, fileLoadedMsg{origin: stagePayload, path: "/x/secret.bin", data: []byte("payloaddata")})
if m.stage != stageSecure {
t.Fatalf("after payload file: stage = %v", m.stage)
}
if m.payloadLabel != "secret.bin" {
t.Fatalf("payload label = %q", m.payloadLabel)
}
if string(m.payloadBytes) != "payloaddata" {
t.Fatalf("payload bytes = %q", m.payloadBytes)
}
}
func TestRevealSaveFlow(t *testing.T) {
stego := hideStego(t, "save me")
saveTo := filepath.Join(t.TempDir(), "recovered.txt")
r := ready()
r, _ = step(r, keyDown())
r, _ = step(r, keyEnter())
r, _ = step(r, keyEnter())
r, cmd := step(r, fileLoadedMsg{origin: stageCover, path: "s.png", data: stego})
r = finishRun(t, r, cmd)
if r.stage != stageResult || r.engineErr != nil {
t.Fatalf("reveal: stage=%v err=%v", r.stage, r.engineErr)
}
if !r.canSaveReveal() {
t.Fatalf("revealed payload should be saveable")
}
r, _ = step(r, typeText("s"))
if !r.saving {
t.Fatalf("s did not enter saving mode")
}
r.outPath.SetValue(saveTo)
r, cmd = step(r, keyEnter())
for _, msg := range drain(cmd) {
r, _ = step(r, msg)
}
if r.savedAt != saveTo {
t.Fatalf("savedAt = %q, want %q", r.savedAt, saveTo)
}
got, err := os.ReadFile(saveTo)
if err != nil || string(got) != "save me" {
t.Fatalf("saved file = %q err=%v", got, err)
}
}
func TestResetOnNewRun(t *testing.T) {
m := ready()
m.stage = stageResult
m, _ = step(m, typeText("n"))
if m.stage != stageOperation {
t.Fatalf("new run did not reset, stage = %v", m.stage)
}
}
func TestCoverKeyForwarding(t *testing.T) {
m := ready()
m, _ = step(m, keyEnter())
m = pickFormat(t, m, "image")
m, _ = step(m, keyEnter())
if m.stage != stageCover {
t.Fatalf("stage = %v", m.stage)
}
browse, _ := step(m, keyDown())
if browse.stage != stageCover {
t.Fatalf("browsing changed the stage to %v", browse.stage)
}
back, _ := step(m, keyEsc())
if back.stage != stageFormat {
t.Fatalf("esc from cover went to %v", back.stage)
}
}
func TestCapacityReviewKeys(t *testing.T) {
cover := makePNG(t, 32, 32)
m := ready()
m, _ = step(m, keyDown())
m, _ = step(m, keyDown())
m, _ = step(m, keyEnter())
m, _ = step(m, fileLoadedMsg{origin: stageCover, path: "c.png", data: cover})
if m.stage != stageReview {
t.Fatalf("stage = %v", m.stage)
}
back, _ := step(m, keyEsc())
if back.stage != stageCover {
t.Fatalf("esc from capacity review went to %v", back.stage)
}
fresh, _ := step(m, typeText("n"))
if fresh.stage != stageOperation {
t.Fatalf("n from capacity review went to %v", fresh.stage)
}
}
func TestForwardNonKeyMsgs(t *testing.T) {
for _, s := range []stage{stageCover, stagePayload, stageSecure, stageSave, stagePassphrase, stageResult} {
m := ready()
m.stage = s
m.saving = s == stageResult
m, _ = step(m, nopMsg{})
if got := m.View(); got == "" {
t.Fatalf("empty view after nop msg at stage %v", s)
}
}
}
func TestPassPromptsResetOnCoverLoad(t *testing.T) {
m := ready()
m.op = opReveal
m.stage = stageCover
m.passPrompts = 2
m, _ = step(m, fileLoadedMsg{origin: stageCover, path: "x", data: []byte("not a stego")})
if m.passPrompts != 0 {
t.Fatalf("passPrompts = %d after loading a fresh file, want 0", m.passPrompts)
}
}
func TestHideFitsUsesCompressedSize(t *testing.T) {
cover := makePNG(t, 16, 16)
big := strings.Repeat("A", 200)
m := ready()
m, _ = step(m, keyEnter())
m = pickFormat(t, m, "image")
m, _ = step(m, keyEnter())
m, _ = step(m, fileLoadedMsg{origin: stageCover, path: "c.png", data: cover})
m, _ = step(m, typeText(big))
m, _ = step(m, keyEnter())
m, _ = step(m, keyDown())
m, _ = step(m, keySpace())
if !m.secure.compress {
t.Fatalf("compression not enabled")
}
m, _ = step(m, keyEnter())
m.outPath.SetValue(filepath.Join(t.TempDir(), "o.png"))
m, _ = step(m, keyEnter())
if m.stage != stageReview {
t.Fatalf("stage = %v", m.stage)
}
if len(big)+14 <= m.capValue {
t.Fatalf("precondition broken: raw payload already fits (cap=%d)", m.capValue)
}
if !m.hideFits() {
t.Fatalf("compressible payload should fit: envelope=%d cap=%d", m.envelopeSize, m.capValue)
}
}

View File

@ -0,0 +1,134 @@
/*
©AngelaMos | 2026
meter.go
The spectral capacity meter: payload-vs-carrier fill for hide, comparative table for capacity
*/
package tui
import (
"fmt"
"math"
"strings"
"github.com/charmbracelet/lipgloss"
)
const (
fullPercent = 100
highWaterPercent = 80
formatColumn = 7
unboundedTag = "unbounded"
)
type capRow struct {
format string
applicable bool
unbounded bool
capacity int
maxPlaintext int
maxEncrypted int
note string
}
func renderHideMeter(payloadLen, envelope, capacity int, capErr error, barWidth int) string {
if capErr != nil {
return styleWarn.Render(reasonText(capErr))
}
if capacity >= math.MaxInt32 {
bar := gradientText(strings.Repeat(blockFull, barWidth), unboundedStops, false)
return lipgloss.JoinVertical(lipgloss.Left,
bar+" "+styleSuccess.Render(unboundedTag),
"",
kv("payload", fmt.Sprintf("%d B", payloadLen)),
kv("envelope", fmt.Sprintf("%d B", envelope)),
styleSuccess.Render("this carrier has effectively unbounded room; your payload always fits"),
)
}
if capacity <= 0 {
return styleWarn.Render("this cover is too small to hold a payload")
}
frac := float64(envelope) / float64(capacity)
over := envelope > capacity
pct := int(math.Round(math.Min(frac, 1) * fullPercent))
bar := spectralBar(barWidth, frac, capacityStops)
pctStyle := styleSuccess
if over {
pctStyle = styleError
} else if pct >= highWaterPercent {
pctStyle = styleWarn
}
lines := []string{
bar + " " + pctStyle.Render(fmt.Sprintf("%d%%", pct)),
"",
kv("payload", fmt.Sprintf("%d B", payloadLen)),
kv("envelope", fmt.Sprintf("%d B", envelope)),
kv("capacity", fmt.Sprintf("%d B", capacity)),
}
if over {
lines = append(lines, "", styleError.Render(
fmt.Sprintf("payload exceeds capacity by %d B: shorten it, enable compression, or pick a roomier carrier", envelope-capacity)))
} else {
lines = append(lines, "", styleSuccess.Render(
fmt.Sprintf("fits with %d B of headroom", capacity-envelope)))
}
return lipgloss.JoinVertical(lipgloss.Left, lines...)
}
func renderCapacityTable(rows []capRow, barWidth int) string {
maxCap := 0
for _, r := range rows {
if r.applicable && !r.unbounded && r.capacity > maxCap {
maxCap = r.capacity
}
}
out := make([]string, 0, len(rows))
for _, r := range rows {
name := styleValueBold.Width(formatColumn).Render(r.format)
switch {
case !r.applicable:
out = append(out, name+styleHint.Render(r.note))
case r.unbounded:
bar := gradientText(strings.Repeat(blockFull, barWidth), unboundedStops, false)
out = append(out, name+bar+" "+styleHint.Render(unboundedTag))
default:
frac := 0.0
if maxCap > 0 {
frac = float64(r.capacity) / float64(maxCap)
}
bar := spectralBar(barWidth, frac, capacityStops)
out = append(out, name+bar+" "+capacityFacts(r))
}
}
return lipgloss.JoinVertical(lipgloss.Left, out...)
}
func capacityFacts(r capRow) string {
plain := styleValue.Render(fmt.Sprintf("%d B", r.maxPlaintext)) + styleLabel.Render(" plain")
var enc string
if r.maxEncrypted <= 0 {
enc = styleWarn.Render("encrypted does not fit")
} else {
enc = styleValue.Render(fmt.Sprintf("%d B", r.maxEncrypted)) + styleLabel.Render(" encrypted")
}
return plain + styleHelpSep.Render(" · ") + enc
}
func reasonText(err error) string {
msg := err.Error()
if i := strings.Index(msg, ": "); i >= 0 {
return msg[i+2:]
}
return msg
}
func clampZero(n int) int {
if n < 0 {
return 0
}
return n
}

View File

@ -0,0 +1,404 @@
/*
©AngelaMos | 2026
model.go
The wizard model: state, flow definition, stage navigation, and engine-facing helpers
*/
package tui
import (
"bytes"
"errors"
"math"
"os"
"path/filepath"
"strings"
"time"
"github.com/CarterPerez-dev/crypha/internal/config"
"github.com/CarterPerez-dev/crypha/internal/engine"
"github.com/CarterPerez-dev/crypha/internal/payload"
"github.com/charmbracelet/bubbles/filepicker"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
)
const (
appMaxWidth = 88
appMinWidth = 46
meterBarWidth = 44
capBarWidth = 24
embedBarWidth = 44
filepickerHeight = 12
messageWidth = 58
passFieldWidth = 34
outFieldWidth = 50
passLimit = 256
pathLimit = 512
maxRevealPrompts = 3
animStep = 0.06
animInterval = 45 * time.Millisecond
outFilePerm = 0o600
inlineLabel = "inline message"
)
var (
errNeedPayload = errors.New("enter a message, or press tab to choose a payload file")
errEmptyFile = errors.New("that file is empty; choose a file with contents")
errNeedPassphrase = errors.New("encryption is on: enter a passphrase or turn encryption off")
errNeedOutput = errors.New("enter an output path")
errPayloadTooBig = errors.New("payload does not fit: go back and shorten it, enable compression, or pick a roomier carrier")
)
type stage int
const (
stageOperation stage = iota
stageFormat
stageTechnique
stageCover
stagePayload
stageSecure
stageSave
stageReview
stagePassphrase
stageRunning
stageResult
)
type operation int
const (
opHide operation = iota
opReveal
opCapacity
)
type payloadMode int
const (
payloadMessage payloadMode = iota
payloadFile
)
type Model struct {
width int
height int
ready bool
quitting bool
stage stage
op operation
opPick picker
fmtPick picker
techPick picker
files filepicker.Model
message textinput.Model
secure secureForm
outPath textinput.Model
revPass textinput.Model
format string
technique string
coverPath string
coverBytes []byte
payloadBytes []byte
payloadMode payloadMode
payloadLabel string
pass []byte
capValue int
capErr error
capRows []capRow
envelopeSize int
envelopeErr error
hideRes engine.HideResult
revealRes engine.RevealResult
stego []byte
outputAt string
savedAt string
saving bool
passPrompts int
engineDone bool
engineErr error
animFrac float64
err error
}
func New() Model {
fp := filepicker.New()
if wd, err := os.Getwd(); err == nil {
fp.CurrentDirectory = wd
}
fp.ShowPermissions = false
fp.AutoHeight = false
fp.SetHeight(filepickerHeight)
msg := textinput.New()
msg.Prompt = ""
msg.Placeholder = "type a secret message"
msg.Width = messageWidth
out := textinput.New()
out.Prompt = ""
out.CharLimit = pathLimit
out.Width = outFieldWidth
rev := textinput.New()
rev.Prompt = ""
rev.EchoMode = textinput.EchoPassword
rev.CharLimit = passLimit
rev.Width = passFieldWidth
return Model{
files: fp,
message: msg,
outPath: out,
revPass: rev,
secure: newSecureForm(),
opPick: newPicker(operationItems()),
}
}
func (m Model) Init() tea.Cmd {
return textinput.Blink
}
func (m Model) flow() []stage {
switch m.op {
case opReveal:
return []stage{stageOperation, stageFormat, stageCover, stageRunning, stageResult}
case opCapacity:
return []stage{stageOperation, stageCover, stageReview}
default:
steps := []stage{stageOperation, stageFormat}
if hasTechniques(m.format) {
steps = append(steps, stageTechnique)
}
return append(steps, stageCover, stagePayload, stageSecure, stageSave, stageReview, stageRunning, stageResult)
}
}
func (m Model) adjacentStage(dir int) (stage, bool) {
f := m.flow()
for i, s := range f {
if s != m.stage {
continue
}
j := i + dir
if j < 0 || j >= len(f) {
return m.stage, false
}
return f[j], true
}
return m.stage, false
}
func (m Model) advance() (tea.Model, tea.Cmd) {
next, ok := m.adjacentStage(1)
if !ok {
return m, nil
}
m.err = nil
cmd := m.enter(next)
return m, cmd
}
func (m Model) back() (tea.Model, tea.Cmd) {
prev, ok := m.adjacentStage(-1)
if !ok {
return m, nil
}
m.err = nil
cmd := m.enter(prev)
return m, cmd
}
func (m *Model) enter(s stage) tea.Cmd {
m.stage = s
switch s {
case stageFormat:
m.fmtPick = newPicker(formatItems(m.op == opReveal))
case stageTechnique:
m.techPick = newPicker(techniqueItems(m.format))
case stageCover:
return m.files.Init()
case stagePayload:
m.payloadMode = payloadMessage
return m.message.Focus()
case stageSave:
if strings.TrimSpace(m.outPath.Value()) == "" {
m.outPath.SetValue(suggestOutput(m.coverPath, m.format))
}
return m.outPath.Focus()
case stageReview:
m.prepareReview()
case stageRunning:
return m.startRun()
}
return nil
}
func (m *Model) prepareReview() {
switch m.op {
case opHide:
m.capValue, m.capErr = engine.Capacity(m.format, bytes.NewReader(m.coverBytes))
m.envelopeSize, m.envelopeErr = engine.EnvelopeSize(m.payloadBytes, m.buildOptions())
case opCapacity:
m.capRows = buildCapRows(engine.CapacityAll(m.coverBytes))
}
}
func (m *Model) startRun() tea.Cmd {
m.animFrac = 0
m.engineDone = false
m.engineErr = nil
switch m.op {
case opReveal:
return tea.Batch(revealCmd(m.format, m.coverBytes, m.revPassValue()), tick())
default:
req := engine.HideRequest{
Format: m.format,
Technique: m.technique,
Cover: bytes.NewReader(m.coverBytes),
Payload: m.payloadBytes,
Options: m.buildOptions(),
}
return tea.Batch(hideCmd(req, m.outPath.Value()), tick())
}
}
func (m Model) reset() (tea.Model, tea.Cmd) {
next := New()
next.width = m.width
next.height = m.height
next.ready = m.ready
return next, textinput.Blink
}
func (m Model) buildOptions() payload.Options {
return payload.Options{
Passphrase: m.secure.passphrase(),
Compress: m.secure.compress,
Cipher: payload.Cipher(m.secure.cipherValue()),
Strength: payload.Strength(m.secure.strengthValue()),
}
}
func (m Model) revPassValue() []byte {
v := m.revPass.Value()
if v == "" {
return nil
}
return []byte(v)
}
func (m Model) encrypted() bool {
return len(m.pass) > 0
}
func (m Model) hideFits() bool {
if m.capErr != nil || m.envelopeErr != nil {
return false
}
if m.capValue >= math.MaxInt32 {
return true
}
return m.envelopeSize <= m.capValue
}
func hasTechniques(format string) bool {
return len(engine.Techniques(format)) > 0
}
func suggestOutput(coverPath, format string) string {
base := strings.TrimSuffix(filepath.Base(coverPath), filepath.Ext(coverPath))
if base == "" || base == "." {
base = config.BinaryName
}
return base + ".stego" + outputExt(format)
}
func (m Model) suggestReveal() string {
base := strings.TrimSuffix(filepath.Base(m.coverPath), filepath.Ext(m.coverPath))
if base == "" || base == "." {
base = config.BinaryName
}
return base + ".revealed"
}
func outputExt(format string) string {
switch strings.ToLower(config.FormatDetails[format].Output) {
case "png":
return ".png"
case "wav":
return ".wav"
case "pdf":
return ".pdf"
case "text":
return ".txt"
default:
return ".out"
}
}
func buildCapRows(rows []engine.CapacityRow) []capRow {
out := make([]capRow, 0, len(rows))
for _, r := range rows {
cr := capRow{format: r.Format}
switch {
case r.Err != nil:
cr.note = reasonText(r.Err)
case r.Capacity >= math.MaxInt32:
cr.applicable = true
cr.unbounded = true
default:
cr.applicable = true
cr.capacity = r.Capacity
cr.maxPlaintext = clampZero(r.Capacity - engine.Overhead(false))
cr.maxEncrypted = clampZero(r.Capacity - engine.Overhead(true))
}
out = append(out, cr)
}
return out
}
func operationItems() []pickItem {
return []pickItem{
{title: "hide", desc: "embed an encrypted payload inside a cover file"},
{title: "reveal", desc: "extract and decrypt a payload hidden in a stego file"},
{title: "capacity", desc: "measure how many payload bytes each carrier can hold for a cover"},
}
}
func formatItems(withAuto bool) []pickItem {
items := make([]pickItem, 0)
if withAuto {
items = append(items, pickItem{title: "auto-detect", value: "", desc: "let crypha identify the carrier by inspecting the file"})
}
for _, fi := range engine.Catalog() {
d := config.FormatDetails[fi.Name]
desc := d.Blurb + " · cover: " + d.CoverInput + " · output: " + d.Output
items = append(items, pickItem{title: fi.Name, value: fi.Name, desc: desc})
}
return items
}
func techniqueItems(format string) []pickItem {
blurbs := map[string]string{
"attachment": "embed the payload as a lossless PDF file attachment (default)",
"metadata": "stash the payload inside the PDF Info dictionary",
"append": "append the payload after the PDF end-of-file marker",
}
items := make([]pickItem, 0)
for _, t := range engine.Techniques(format) {
items = append(items, pickItem{title: t, value: t, desc: blurbs[t]})
}
return items
}

View File

@ -0,0 +1,65 @@
/*
©AngelaMos | 2026
picker.go
A compact keyboard-driven selection list with an inline blurb for the highlighted item
*/
package tui
import (
"github.com/charmbracelet/lipgloss"
)
type pickItem struct {
title string
desc string
value string
}
type picker struct {
items []pickItem
cursor int
}
func newPicker(items []pickItem) picker {
return picker{items: items}
}
func (p *picker) up() {
if len(p.items) == 0 {
return
}
p.cursor = (p.cursor - 1 + len(p.items)) % len(p.items)
}
func (p *picker) down() {
if len(p.items) == 0 {
return
}
p.cursor = (p.cursor + 1) % len(p.items)
}
func (p picker) selected() pickItem {
if len(p.items) == 0 {
return pickItem{}
}
return p.items[p.cursor]
}
func (p picker) view(width int) string {
rows := make([]string, 0, len(p.items))
for i, it := range p.items {
if i == p.cursor {
rows = append(rows, styleCursor.Render(accentBar)+" "+styleSelected.Render(it.title))
} else {
rows = append(rows, " "+styleUnselected.Render(it.title))
}
}
list := lipgloss.JoinVertical(lipgloss.Left, rows...)
if sel := p.selected(); sel.desc != "" {
blurb := styleHint.Width(width).Render(sel.desc)
return lipgloss.JoinVertical(lipgloss.Left, list, "", blurb)
}
return list
}

View File

@ -0,0 +1,224 @@
/*
©AngelaMos | 2026
secure.go
The encryption sub-form: a focusable list of toggle, choice, and secret controls
*/
package tui
import (
"strings"
"github.com/CarterPerez-dev/crypha/internal/payload"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type secField int
const (
secEncrypt secField = iota
secPass
secCipher
secStrength
secCompress
)
var (
cipherChoices = []string{string(payload.CipherChaCha20), string(payload.CipherAES256GCM)}
strengthChoices = []string{string(payload.StrengthDefault), string(payload.StrengthHigh)}
)
type secureForm struct {
encrypt bool
pass textinput.Model
cipher int
strength int
compress bool
focus int
}
func newSecureForm() secureForm {
ti := textinput.New()
ti.Prompt = ""
ti.Placeholder = "passphrase"
ti.EchoMode = textinput.EchoPassword
ti.CharLimit = passLimit
ti.Width = passFieldWidth
return secureForm{pass: ti}
}
func (f secureForm) fields() []secField {
if f.encrypt {
return []secField{secEncrypt, secPass, secCipher, secStrength, secCompress}
}
return []secField{secEncrypt, secCompress}
}
func (f secureForm) focusField() secField {
fs := f.fields()
i := f.focus
if i < 0 {
i = 0
}
if i >= len(fs) {
i = len(fs) - 1
}
return fs[i]
}
func (f *secureForm) move(delta int) {
n := len(f.fields())
f.focus += delta
if f.focus < 0 {
f.focus = 0
}
if f.focus >= n {
f.focus = n - 1
}
f.syncFocus()
}
func (f *secureForm) syncFocus() tea.Cmd {
if f.focusField() == secPass {
return f.pass.Focus()
}
f.pass.Blur()
return nil
}
func (f secureForm) cipherValue() string {
return cipherChoices[f.cipher]
}
func (f secureForm) strengthValue() string {
return strengthChoices[f.strength]
}
func (f secureForm) passphrase() []byte {
if !f.encrypt {
return nil
}
v := f.pass.Value()
if v == "" {
return nil
}
return []byte(v)
}
func (f secureForm) update(msg tea.Msg) (secureForm, tea.Cmd) {
km, ok := msg.(tea.KeyMsg)
if !ok {
var cmd tea.Cmd
f.pass, cmd = f.pass.Update(msg)
return f, cmd
}
switch km.String() {
case "up", "shift+tab":
cmd := f.moveWith(-1)
return f, cmd
case "down", "tab":
cmd := f.moveWith(1)
return f, cmd
}
switch f.focusField() {
case secEncrypt:
if isToggleKey(km) {
f.encrypt = !f.encrypt
f.syncFocus()
}
case secCompress:
if isToggleKey(km) {
f.compress = !f.compress
}
case secCipher:
f.cipher = cycleChoice(f.cipher, len(cipherChoices), km)
case secStrength:
f.strength = cycleChoice(f.strength, len(strengthChoices), km)
case secPass:
var cmd tea.Cmd
f.pass, cmd = f.pass.Update(msg)
return f, cmd
}
return f, nil
}
func (f *secureForm) moveWith(delta int) tea.Cmd {
f.move(delta)
return f.syncFocus()
}
func isToggleKey(km tea.KeyMsg) bool {
switch km.String() {
case " ", "left", "right":
return true
}
return false
}
func cycleChoice(idx, n int, km tea.KeyMsg) int {
switch km.String() {
case "left":
return (idx - 1 + n) % n
case "right", " ":
return (idx + 1) % n
}
return idx
}
func (f secureForm) view(width int) string {
rows := make([]string, 0, len(f.fields()))
for i, fld := range f.fields() {
rows = append(rows, f.fieldRow(fld, i == f.focus))
}
note := styleHint.Width(width).Render("up/down move · space or left/right change · enter continues")
return lipgloss.JoinVertical(lipgloss.Left, lipgloss.JoinVertical(lipgloss.Left, rows...), "", note)
}
func (f secureForm) fieldRow(fld secField, focused bool) string {
prefix := " "
labelStyle := styleLabel
if focused {
prefix = styleCursor.Render(accentBar) + " "
labelStyle = styleValueBold
}
label, control := f.labelControl(fld)
return prefix + labelStyle.Width(labelWidth).Render(label) + control
}
func (f secureForm) labelControl(fld secField) (string, string) {
switch fld {
case secEncrypt:
return "encrypt", toggleControl(f.encrypt)
case secPass:
return "passphrase", f.pass.View()
case secCipher:
return "cipher", choiceControl(cipherChoices, f.cipher)
case secStrength:
return "key strength", choiceControl(strengthChoices, f.strength)
case secCompress:
return "compress", toggleControl(f.compress)
}
return "", ""
}
func toggleControl(on bool) string {
return pill("off", !on) + " " + pill("on", on)
}
func choiceControl(choices []string, idx int) string {
parts := make([]string, len(choices))
for i, c := range choices {
parts[i] = pill(c, i == idx)
}
return strings.Join(parts, " ")
}
func pill(text string, active bool) string {
if active {
return badge(text, hexViolet)
}
return lipgloss.NewStyle().Foreground(lipgloss.Color(hexFaint)).Padding(0, 1).Render(text)
}

View File

@ -0,0 +1,96 @@
/*
©AngelaMos | 2026
steps.go
Per-operation wizard step labels, current-step mapping, and contextual footer hints
*/
package tui
func (m Model) stepLabels() []string {
switch m.op {
case opReveal:
return []string{"operation", "carrier", "stego", "reveal"}
case opCapacity:
return []string{"operation", "cover", "report"}
default:
labels := []string{"operation", "carrier"}
if hasTechniques(m.format) {
labels = append(labels, "technique")
}
return append(labels, "cover", "payload", "secure", "save", "embed")
}
}
func (m Model) stepStages() []stage {
switch m.op {
case opReveal:
return []stage{stageOperation, stageFormat, stageCover, stageRunning}
case opCapacity:
return []stage{stageOperation, stageCover, stageReview}
default:
stages := []stage{stageOperation, stageFormat}
if hasTechniques(m.format) {
stages = append(stages, stageTechnique)
}
return append(stages, stageCover, stagePayload, stageSecure, stageSave, stageReview)
}
}
func (m Model) stepIndex() int {
target := m.stage
switch m.stage {
case stagePassphrase:
target = stageRunning
case stageRunning, stageResult:
if m.op == opReveal {
target = stageRunning
} else {
target = stageReview
}
}
for i, s := range m.stepStages() {
if s == target {
return i
}
}
return 0
}
func (m Model) footerHints() []keyHint {
switch m.stage {
case stageOperation:
return []keyHint{{"up/down", "move"}, {"enter", "select"}, {"q", "quit"}}
case stageFormat, stageTechnique:
return []keyHint{{"up/down", "move"}, {"enter", "select"}, {"esc", "back"}, {"q", "quit"}}
case stageCover:
return []keyHint{{"up/down", "browse"}, {"enter", "open or select"}, {"esc", "back"}}
case stagePayload:
if m.payloadMode == payloadFile {
return []keyHint{{"up/down", "browse"}, {"enter", "choose"}, {"tab", "message"}, {"esc", "back"}}
}
return []keyHint{{"type", "message"}, {"enter", "continue"}, {"tab", "file"}, {"esc", "back"}}
case stageSecure:
return []keyHint{{"up/down", "field"}, {"space", "change"}, {"enter", "continue"}, {"esc", "back"}}
case stageSave:
return []keyHint{{"type", "path"}, {"enter", "continue"}, {"esc", "back"}}
case stageReview:
if m.op == opCapacity {
return []keyHint{{"esc", "back"}, {"n", "new run"}, {"q", "quit"}}
}
return []keyHint{{"enter", "embed"}, {"esc", "back"}, {"q", "quit"}}
case stagePassphrase:
return []keyHint{{"enter", "unlock"}, {"esc", "cancel"}}
case stageResult:
if m.saving {
return []keyHint{{"enter", "write"}, {"esc", "cancel"}}
}
if m.canSaveReveal() {
return []keyHint{{"s", "save"}, {"n", "new run"}, {"q", "quit"}}
}
return []keyHint{{"n", "new run"}, {"q", "quit"}}
case stageRunning:
return []keyHint{{"working", "..."}}
}
return nil
}

View File

@ -0,0 +1,217 @@
/*
©AngelaMos | 2026
theme.go
Spectral cybercore palette, HCL gradient engine, and lipgloss styles for the TUI
*/
package tui
import (
"math"
"strings"
"github.com/charmbracelet/lipgloss"
"github.com/lucasb-eyer/go-colorful"
)
const (
hexVioletLight = "#A78BFA"
hexViolet = "#8B5CF6"
hexVioletDeep = "#6D4AFF"
hexBlue = "#4457E8"
hexBlueBright = "#6E86FF"
hexBlueDim = "#5A63A8"
hexRed = "#CE2417"
hexBright = "#E9E6F5"
hexNormal = "#C4C0D6"
hexDim = "#8B86A6"
hexFaint = "#46425F"
hexFrame = "#39346B"
hexInk = "#0C0A16"
)
const (
blockFull = "█"
blockEmpty = "░"
ruleGlyph = "━"
accentBar = "▌"
connector = "─"
)
var (
brandStops = mustStops(hexVioletLight, hexViolet, hexVioletDeep, hexBlue)
capacityStops = mustStops(hexBlue, hexVioletLight, hexViolet, hexVioletDeep)
unboundedStops = mustStops(hexBlue, hexViolet)
)
var (
styleTagline = lipgloss.NewStyle().Foreground(lipgloss.Color(hexDim)).Italic(true)
styleLabel = lipgloss.NewStyle().Foreground(lipgloss.Color(hexBlueDim))
styleValue = lipgloss.NewStyle().Foreground(lipgloss.Color(hexBright))
styleValueBold = lipgloss.NewStyle().Foreground(lipgloss.Color(hexBright)).Bold(true)
stylePanelTitle = lipgloss.NewStyle().Foreground(lipgloss.Color(hexViolet)).Bold(true)
styleCursor = lipgloss.NewStyle().Foreground(lipgloss.Color(hexRed)).Bold(true)
styleStepCurrent = lipgloss.NewStyle().Foreground(lipgloss.Color(hexBright)).Bold(true)
styleStepDone = lipgloss.NewStyle().Foreground(lipgloss.Color(hexBlue))
styleStepFuture = lipgloss.NewStyle().Foreground(lipgloss.Color(hexFaint))
styleSelected = lipgloss.NewStyle().Foreground(lipgloss.Color(hexBright)).Bold(true)
styleUnselected = lipgloss.NewStyle().Foreground(lipgloss.Color(hexNormal))
styleHint = lipgloss.NewStyle().Foreground(lipgloss.Color(hexDim))
styleError = lipgloss.NewStyle().Foreground(lipgloss.Color(hexRed)).Bold(true)
styleSuccess = lipgloss.NewStyle().Foreground(lipgloss.Color(hexBlueBright)).Bold(true)
styleWarn = lipgloss.NewStyle().Foreground(lipgloss.Color(hexRed))
styleHelpKey = lipgloss.NewStyle().Foreground(lipgloss.Color(hexBlue)).Bold(true)
styleHelpDesc = lipgloss.NewStyle().Foreground(lipgloss.Color(hexDim))
styleHelpSep = lipgloss.NewStyle().Foreground(lipgloss.Color(hexFaint))
styleTrack = lipgloss.NewStyle().Foreground(lipgloss.Color(hexFaint))
)
const (
framePadX = 3
framePadY = 1
frameBorder = 1
horizontalGutter = 2
)
func frameStyle(width int) lipgloss.Style {
return lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color(hexFrame)).
Padding(framePadY, framePadX).
Width(width)
}
func badge(text, hex string) string {
return lipgloss.NewStyle().
Foreground(lipgloss.Color(hexInk)).
Background(lipgloss.Color(hex)).
Bold(true).
Padding(0, 1).
Render(text)
}
func mustStops(hexes ...string) []colorful.Color {
stops := make([]colorful.Color, len(hexes))
for i, h := range hexes {
c, err := colorful.Hex(h)
if err != nil {
panic("tui: invalid palette hex " + h)
}
stops[i] = c
}
return stops
}
func ramp(stops []colorful.Color, n int) []lipgloss.Color {
out := make([]lipgloss.Color, n)
if n == 0 {
return out
}
if n == 1 || len(stops) == 1 {
for i := range out {
out[i] = lipgloss.Color(stops[0].Hex())
}
return out
}
segments := len(stops) - 1
for i := range out {
x := float64(i) / float64(n-1) * float64(segments)
idx := int(x)
if idx >= segments {
idx = segments - 1
}
local := x - float64(idx)
out[i] = lipgloss.Color(stops[idx].BlendHcl(stops[idx+1], local).Clamped().Hex())
}
return out
}
func gradientText(text string, stops []colorful.Color, bold bool) string {
runes := []rune(text)
cols := ramp(stops, len(runes))
var b strings.Builder
for i, r := range runes {
s := lipgloss.NewStyle().Foreground(cols[i])
if bold {
s = s.Bold(true)
}
b.WriteString(s.Render(string(r)))
}
return b.String()
}
func gradientBlock(lines []string, stops []colorful.Color, bold bool) string {
width := 0
for _, ln := range lines {
if w := len([]rune(ln)); w > width {
width = w
}
}
cols := ramp(stops, width)
out := make([]string, len(lines))
for li, ln := range lines {
var b strings.Builder
for i, r := range []rune(ln) {
s := lipgloss.NewStyle().Foreground(cols[i])
if bold {
s = s.Bold(true)
}
b.WriteString(s.Render(string(r)))
}
out[li] = b.String()
}
return strings.Join(out, "\n")
}
func gradientRule(width int, stops []colorful.Color) string {
if width <= 0 {
return ""
}
return gradientText(strings.Repeat(ruleGlyph, width), stops, false)
}
func spectralBar(width int, frac float64, stops []colorful.Color) string {
if width <= 0 {
return ""
}
over := frac > 1
if frac < 0 {
frac = 0
}
if frac > 1 {
frac = 1
}
filled := int(math.Round(frac * float64(width)))
if filled > width {
filled = width
}
cols := ramp(capacityStops, width)
if stops != nil {
cols = ramp(stops, width)
}
var b strings.Builder
for i := range width {
if i < filled {
col := cols[i]
if over {
col = lipgloss.Color(hexRed)
}
b.WriteString(lipgloss.NewStyle().Foreground(col).Render(blockFull))
} else {
b.WriteString(styleTrack.Render(blockEmpty))
}
}
return b.String()
}

View File

@ -0,0 +1,149 @@
/*
©AngelaMos | 2026
theme_test.go
Unit tests for the gradient engine, meter geometry, and pure view helpers
*/
package tui
import (
"testing"
"unicode/utf8"
"github.com/charmbracelet/lipgloss"
)
func TestRampLength(t *testing.T) {
for _, n := range []int{0, 1, 2, 5, 30, 88} {
if got := len(ramp(brandStops, n)); got != n {
t.Fatalf("ramp(%d) length = %d", n, got)
}
}
}
func TestSpectralBarWidthInvariant(t *testing.T) {
for _, w := range []int{1, 8, 24, 44} {
for _, frac := range []float64{-0.5, 0, 0.33, 0.5, 1, 2.5} {
bar := spectralBar(w, frac, capacityStops)
if got := lipgloss.Width(bar); got != w {
t.Fatalf("spectralBar(w=%d frac=%.2f) width = %d", w, frac, got)
}
}
}
}
func TestGradientTextPreservesWidth(t *testing.T) {
if got := lipgloss.Width(gradientText("crypha", brandStops, true)); got != 6 {
t.Fatalf("gradientText width = %d, want 6", got)
}
}
func TestWordmarkRowsAligned(t *testing.T) {
lines := wordmarkLines()
if len(lines) != wordmarkRows {
t.Fatalf("wordmark has %d rows, want %d", len(lines), wordmarkRows)
}
width := utf8.RuneCountInString(lines[0])
for i, ln := range lines {
if got := utf8.RuneCountInString(ln); got != width {
t.Fatalf("row %d width = %d, want %d", i, got, width)
}
}
}
func TestPickerWraps(t *testing.T) {
p := newPicker([]pickItem{{title: "a"}, {title: "b"}, {title: "c"}})
p.up()
if p.cursor != 2 {
t.Fatalf("up from 0 = %d, want 2", p.cursor)
}
p.down()
if p.cursor != 0 {
t.Fatalf("down from 2 = %d, want 0", p.cursor)
}
}
func TestSecureFormFieldsAndToggle(t *testing.T) {
f := newSecureForm()
if len(f.fields()) != 2 {
t.Fatalf("plaintext fields = %d, want 2", len(f.fields()))
}
f, _ = f.update(keySpace())
if !f.encrypt {
t.Fatalf("space did not enable encryption")
}
if len(f.fields()) != 5 {
t.Fatalf("encrypted fields = %d, want 5", len(f.fields()))
}
f, _ = f.update(keyDown())
f, _ = f.update(typeText("s3cret"))
if string(f.passphrase()) != "s3cret" {
t.Fatalf("passphrase = %q", f.passphrase())
}
if f.cipherValue() != "chacha20" {
t.Fatalf("default cipher = %q", f.cipherValue())
}
}
func TestSecureFormChoiceCycles(t *testing.T) {
f := newSecureForm()
f, _ = f.update(keySpace())
f, _ = f.update(keyDown())
f, _ = f.update(keyDown())
if f.focusField() != secCipher {
t.Fatalf("focus = %d, want secCipher", f.focusField())
}
before := f.cipherValue()
f, _ = f.update(keySpace())
if f.cipherValue() == before {
t.Fatalf("cipher did not cycle from %q", before)
}
}
func TestOutputExtensions(t *testing.T) {
cases := map[string]string{"image": ".png", "audio": ".wav", "qr": ".png", "text": ".txt", "pdf": ".pdf"}
for format, want := range cases {
if got := outputExt(format); got != want {
t.Fatalf("outputExt(%q) = %q, want %q", format, got, want)
}
}
}
func TestSuggestOutput(t *testing.T) {
if got := suggestOutput("/home/x/photo.png", "image"); got != "photo.stego.png" {
t.Fatalf("suggestOutput = %q", got)
}
if got := suggestOutput("", "audio"); got != "crypha.stego.wav" {
t.Fatalf("suggestOutput empty = %q", got)
}
}
func TestIsPrintable(t *testing.T) {
if !isPrintable([]byte("hello\nworld\t!")) {
t.Fatalf("printable text rejected")
}
if isPrintable([]byte{0x00, 0x01, 0xff}) {
t.Fatalf("binary accepted as printable")
}
}
func TestHexPreviewTruncates(t *testing.T) {
data := make([]byte, 100)
got := hexPreview(data, 4)
if got != "00 00 00 00 ..." {
t.Fatalf("hexPreview = %q", got)
}
}
func TestFormatLabel(t *testing.T) {
if formatLabel("", "") != "auto-detect" {
t.Fatalf("empty format label")
}
if formatLabel("pdf", "append") != "pdf (append)" {
t.Fatalf("technique label")
}
if formatLabel("image", "") != "image" {
t.Fatalf("plain format label")
}
}

View File

@ -0,0 +1,17 @@
/*
©AngelaMos | 2026
tui.go
Program entry that constructs and runs the interactive bubbletea wizard
*/
package tui
import (
tea "github.com/charmbracelet/bubbletea"
)
func Run() error {
_, err := tea.NewProgram(New(), tea.WithAltScreen()).Run()
return err
}

View File

@ -0,0 +1,443 @@
/*
©AngelaMos | 2026
tui_test.go
Model navigation, engine round-trips through the wizard, and view smoke tests
*/
package tui
import (
"bytes"
"image"
"image/color"
"image/png"
"os"
"path/filepath"
"testing"
"github.com/CarterPerez-dev/crypha/internal/engine"
tea "github.com/charmbracelet/bubbletea"
)
func ready() Model {
m := New()
nm, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 44})
return nm.(Model)
}
func step(m Model, msg tea.Msg) (Model, tea.Cmd) {
nm, cmd := m.Update(msg)
return nm.(Model), cmd
}
func keyEnter() tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyEnter} }
func keyEsc() tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyEsc} }
func keyDown() tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyDown} }
func keyTab() tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyTab} }
func keySpace() tea.KeyMsg { return tea.KeyMsg{Type: tea.KeySpace} }
func typeText(s string) tea.KeyMsg {
return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)}
}
func drain(cmd tea.Cmd) []tea.Msg {
if cmd == nil {
return nil
}
msg := cmd()
if batch, ok := msg.(tea.BatchMsg); ok {
var out []tea.Msg
for _, c := range batch {
out = append(out, drain(c)...)
}
return out
}
return []tea.Msg{msg}
}
func finishRun(t *testing.T, m Model, cmd tea.Cmd) Model {
t.Helper()
for _, msg := range drain(cmd) {
m, _ = step(m, msg)
}
for i := 0; i < 60 && m.stage == stageRunning; i++ {
m, _ = step(m, tickMsg{})
}
return m
}
func makePNG(t *testing.T, w, h int) []byte {
t.Helper()
img := image.NewNRGBA(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
img.SetNRGBA(x, y, color.NRGBA{R: uint8(x), G: uint8(y), B: uint8(x + y), A: 0xFF})
}
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
t.Fatalf("encode png: %v", err)
}
return buf.Bytes()
}
func pickFormat(t *testing.T, m Model, name string) Model {
t.Helper()
for i := 0; i <= len(m.fmtPick.items); i++ {
if m.fmtPick.selected().value == name {
return m
}
m, _ = step(m, keyDown())
}
t.Fatalf("format %q not present in picker", name)
return m
}
func TestHideRevealRoundTripPlaintext(t *testing.T) {
tmp := t.TempDir()
out := filepath.Join(tmp, "out.png")
cover := makePNG(t, 64, 64)
const secret = "attack at dawn"
m := ready()
m, _ = step(m, keyEnter())
if m.stage != stageFormat {
t.Fatalf("after operation: stage = %v", m.stage)
}
m = pickFormat(t, m, "image")
m, _ = step(m, keyEnter())
if m.stage != stageCover {
t.Fatalf("after format: stage = %v", m.stage)
}
m, _ = step(m, fileLoadedMsg{origin: stageCover, path: "cover.png", data: cover})
if m.stage != stagePayload {
t.Fatalf("after cover: stage = %v", m.stage)
}
m, _ = step(m, typeText(secret))
m, _ = step(m, keyEnter())
if m.stage != stageSecure {
t.Fatalf("after payload: stage = %v", m.stage)
}
m, _ = step(m, keyEnter())
if m.stage != stageSave {
t.Fatalf("after secure: stage = %v", m.stage)
}
m.outPath.SetValue(out)
m, _ = step(m, keyEnter())
if m.stage != stageReview {
t.Fatalf("after save: stage = %v", m.stage)
}
if !m.hideFits() {
t.Fatalf("payload should fit a 64x64 cover")
}
m, cmd := step(m, keyEnter())
if m.stage != stageRunning {
t.Fatalf("after review: stage = %v", m.stage)
}
m = finishRun(t, m, cmd)
if m.stage != stageResult {
t.Fatalf("run did not finish: stage = %v", m.stage)
}
if m.engineErr != nil {
t.Fatalf("hide errored: %v", m.engineErr)
}
if m.hideRes.Format != "image" {
t.Fatalf("hide format = %q", m.hideRes.Format)
}
if _, err := os.Stat(out); err != nil {
t.Fatalf("stego file not written: %v", err)
}
stego, err := os.ReadFile(out)
if err != nil {
t.Fatalf("read stego: %v", err)
}
r := ready()
r, _ = step(r, keyDown())
r, _ = step(r, keyEnter())
if r.op != opReveal || r.stage != stageFormat {
t.Fatalf("reveal setup: op=%v stage=%v", r.op, r.stage)
}
r, _ = step(r, keyEnter())
if r.format != "" || r.stage != stageCover {
t.Fatalf("reveal auto-detect setup: format=%q stage=%v", r.format, r.stage)
}
r, cmd = step(r, fileLoadedMsg{origin: stageCover, path: out, data: stego})
if r.stage != stageRunning {
t.Fatalf("reveal after stego: stage = %v", r.stage)
}
r = finishRun(t, r, cmd)
if r.stage != stageResult {
t.Fatalf("reveal did not finish: stage = %v", r.stage)
}
if r.engineErr != nil {
t.Fatalf("reveal errored: %v", r.engineErr)
}
if got := string(r.revealRes.Data); got != secret {
t.Fatalf("revealed %q, want %q", got, secret)
}
}
func TestHideRevealRoundTripEncrypted(t *testing.T) {
tmp := t.TempDir()
out := filepath.Join(tmp, "enc.png")
cover := makePNG(t, 64, 64)
const secret = "the eagle lands at noon"
const pass = "correct horse battery staple"
m := ready()
m, _ = step(m, keyEnter())
m = pickFormat(t, m, "image")
m, _ = step(m, keyEnter())
m, _ = step(m, fileLoadedMsg{origin: stageCover, path: "cover.png", data: cover})
m, _ = step(m, typeText(secret))
m, _ = step(m, keyEnter())
if m.stage != stageSecure {
t.Fatalf("stage = %v", m.stage)
}
m, _ = step(m, keySpace())
if !m.secure.encrypt {
t.Fatalf("encrypt toggle did not engage")
}
m, _ = step(m, keyDown())
m, _ = step(m, typeText(pass))
m, _ = step(m, keyEnter())
if m.stage != stageSave {
t.Fatalf("after secure: stage = %v", m.stage)
}
if len(m.pass) == 0 {
t.Fatalf("passphrase not captured")
}
m.outPath.SetValue(out)
m, _ = step(m, keyEnter())
m, cmd := step(m, keyEnter())
m = finishRun(t, m, cmd)
if m.engineErr != nil {
t.Fatalf("encrypted hide errored: %v", m.engineErr)
}
if !m.hideRes.Encrypted {
t.Fatalf("result not marked encrypted")
}
stego, err := os.ReadFile(out)
if err != nil {
t.Fatalf("read stego: %v", err)
}
r := ready()
r, _ = step(r, keyDown())
r, _ = step(r, keyEnter())
r = pickFormat(t, r, "image")
r, _ = step(r, keyEnter())
if r.format != "image" {
t.Fatalf("reveal format = %q", r.format)
}
r, cmd = step(r, fileLoadedMsg{origin: stageCover, path: out, data: stego})
r = finishRun(t, r, cmd)
if r.stage != stagePassphrase {
t.Fatalf("encrypted reveal should prompt for a passphrase, stage = %v", r.stage)
}
r, _ = step(r, typeText(pass))
r, cmd = step(r, keyEnter())
if r.stage != stageRunning {
t.Fatalf("after passphrase: stage = %v", r.stage)
}
r = finishRun(t, r, cmd)
if r.stage != stageResult || r.engineErr != nil {
t.Fatalf("encrypted reveal failed: stage=%v err=%v", r.stage, r.engineErr)
}
if got := string(r.revealRes.Data); got != secret {
t.Fatalf("revealed %q, want %q", got, secret)
}
if !r.revealRes.Encrypted {
t.Fatalf("revealed payload not marked encrypted")
}
}
func TestWrongPassphraseReprompts(t *testing.T) {
tmp := t.TempDir()
out := filepath.Join(tmp, "wp.png")
cover := makePNG(t, 64, 64)
m := ready()
m, _ = step(m, keyEnter())
m = pickFormat(t, m, "image")
m, _ = step(m, keyEnter())
m, _ = step(m, fileLoadedMsg{origin: stageCover, path: "c.png", data: cover})
m, _ = step(m, typeText("classified"))
m, _ = step(m, keyEnter())
m, _ = step(m, keySpace())
m, _ = step(m, keyDown())
m, _ = step(m, typeText("realpass"))
m, _ = step(m, keyEnter())
m.outPath.SetValue(out)
m, _ = step(m, keyEnter())
m, cmd := step(m, keyEnter())
m = finishRun(t, m, cmd)
if m.engineErr != nil {
t.Fatalf("hide errored: %v", m.engineErr)
}
stego, _ := os.ReadFile(out)
r := ready()
r, _ = step(r, keyDown())
r, _ = step(r, keyEnter())
r = pickFormat(t, r, "image")
r, _ = step(r, keyEnter())
r, cmd = step(r, fileLoadedMsg{origin: stageCover, path: out, data: stego})
r = finishRun(t, r, cmd)
if r.stage != stagePassphrase {
t.Fatalf("stage = %v", r.stage)
}
r, _ = step(r, typeText("wrongpass"))
r, cmd = step(r, keyEnter())
r = finishRun(t, r, cmd)
if r.stage != stagePassphrase {
t.Fatalf("wrong passphrase should re-prompt, stage = %v", r.stage)
}
if r.passPrompts < 2 {
t.Fatalf("passPrompts = %d, want >= 2", r.passPrompts)
}
}
func TestCapacityFlow(t *testing.T) {
cover := makePNG(t, 48, 48)
m := ready()
m, _ = step(m, keyDown())
m, _ = step(m, keyDown())
m, _ = step(m, keyEnter())
if m.op != opCapacity || m.stage != stageCover {
t.Fatalf("capacity setup: op=%v stage=%v", m.op, m.stage)
}
m, _ = step(m, fileLoadedMsg{origin: stageCover, path: "cover.png", data: cover})
if m.stage != stageReview {
t.Fatalf("after cover: stage = %v", m.stage)
}
if len(m.capRows) == 0 {
t.Fatalf("capacity rows not built")
}
var haveImage bool
for _, r := range m.capRows {
if r.format == "image" {
haveImage = true
if !r.applicable || r.maxPlaintext <= 0 {
t.Fatalf("image row = %+v", r)
}
}
}
if !haveImage {
t.Fatalf("no image row in capacity report")
}
}
func TestPdfTechniqueBranch(t *testing.T) {
m := ready()
m, _ = step(m, keyEnter())
m = pickFormat(t, m, "pdf")
m, _ = step(m, keyEnter())
if m.format != "pdf" || m.stage != stageTechnique {
t.Fatalf("pdf branch: format=%q stage=%v", m.format, m.stage)
}
m, _ = step(m, keyEnter())
if m.technique != "attachment" || m.stage != stageCover {
t.Fatalf("technique branch: technique=%q stage=%v", m.technique, m.stage)
}
}
func TestTechniqueClearedOnFormatChange(t *testing.T) {
m := ready()
m, _ = step(m, keyEnter())
m = pickFormat(t, m, "pdf")
m, _ = step(m, keyEnter())
if m.stage != stageTechnique {
t.Fatalf("stage = %v", m.stage)
}
m, _ = step(m, keyDown())
m, _ = step(m, keyEnter())
if m.technique == "" {
t.Fatalf("technique should be set after choosing one")
}
m, _ = step(m, keyEsc())
m, _ = step(m, keyEsc())
if m.stage != stageFormat {
t.Fatalf("back to format: stage = %v", m.stage)
}
m = pickFormat(t, m, "image")
m, _ = step(m, keyEnter())
if m.technique != "" {
t.Fatalf("technique not cleared after switching to image: %q", m.technique)
}
if m.stage != stageCover {
t.Fatalf("stage = %v", m.stage)
}
}
func TestBackNavigation(t *testing.T) {
m := ready()
m, _ = step(m, keyEnter())
if m.stage != stageFormat {
t.Fatalf("stage = %v", m.stage)
}
m, _ = step(m, keyEsc())
if m.stage != stageOperation {
t.Fatalf("back did not return to operation: %v", m.stage)
}
}
func TestEmptyMessageBlocks(t *testing.T) {
cover := makePNG(t, 32, 32)
m := ready()
m, _ = step(m, keyEnter())
m, _ = step(m, keyEnter())
m, _ = step(m, fileLoadedMsg{origin: stageCover, path: "c.png", data: cover})
m, _ = step(m, keyEnter())
if m.stage != stagePayload {
t.Fatalf("empty message should not advance, stage = %v", m.stage)
}
if m.err == nil {
t.Fatalf("expected a validation error")
}
}
func TestViewAllStagesNoPanic(t *testing.T) {
cover := makePNG(t, 16, 16)
stages := []stage{
stageOperation, stageFormat, stageTechnique, stageCover, stagePayload,
stageSecure, stageSave, stageReview, stagePassphrase, stageRunning, stageResult,
}
for _, op := range []operation{opHide, opReveal, opCapacity} {
for _, s := range stages {
for _, w := range []int{50, 100} {
m := ready()
m.width = w
m.op = op
m.stage = s
m.format = "image"
m.coverPath = "cover.png"
m.payloadBytes = []byte("preview")
m.payloadLabel = inlineLabel
m.capValue = 4096
m.envelopeSize = 200
m.capRows = buildCapRows(engine.CapacityAll(cover))
m.fmtPick = newPicker(formatItems(op == opReveal))
m.techPick = newPicker(techniqueItems("pdf"))
m.revealRes = engine.RevealResult{Format: "image", Data: []byte("hi")}
m.hideRes = engine.HideResult{Format: "image", PayloadBytes: 7, EnvelopeBytes: 21}
if got := m.View(); got == "" {
t.Fatalf("empty view at op=%d stage=%d", op, s)
}
}
}
}
}
func TestQuitFromResult(t *testing.T) {
m := ready()
m.stage = stageResult
_, cmd := step(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("q")})
if cmd == nil {
t.Fatalf("q on result should return a quit command")
}
}

View File

@ -0,0 +1,399 @@
/*
©AngelaMos | 2026
update.go
Message dispatch, per-stage key handling, and async engine result wiring
*/
package tui
import (
"errors"
"path/filepath"
"strings"
"github.com/CarterPerez-dev/crypha/internal/payload"
tea "github.com/charmbracelet/bubbletea"
)
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
return m.onResize(msg)
case tea.KeyMsg:
if msg.String() == "ctrl+c" {
m.quitting = true
return m, tea.Quit
}
return m.onKey(msg)
case fileLoadedMsg:
return m.onFileLoaded(msg)
case hideDoneMsg:
return m.onHideDone(msg)
case revealDoneMsg:
return m.onRevealDone(msg)
case savedMsg:
return m.onSaved(msg)
case tickMsg:
return m.onTick()
default:
return m.forward(msg)
}
}
func (m Model) onResize(msg tea.WindowSizeMsg) (tea.Model, tea.Cmd) {
m.width = msg.Width
m.height = msg.Height
m.ready = true
var cmd tea.Cmd
m.files, cmd = m.files.Update(msg)
return m, cmd
}
func (m Model) onKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.err = nil
switch m.stage {
case stageOperation:
return m.keyOperation(msg)
case stageFormat:
return m.keyFormat(msg)
case stageTechnique:
return m.keyTechnique(msg)
case stageCover:
return m.keyCover(msg)
case stagePayload:
return m.keyPayload(msg)
case stageSecure:
return m.keySecure(msg)
case stageSave:
return m.keySave(msg)
case stageReview:
return m.keyReview(msg)
case stagePassphrase:
return m.keyPassphrase(msg)
case stageResult:
return m.keyResult(msg)
}
return m, nil
}
func (m Model) keyOperation(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "q":
m.quitting = true
return m, tea.Quit
case "up", "k":
m.opPick.up()
case "down", "j":
m.opPick.down()
case "enter":
m.op = operation(m.opPick.cursor)
return m.advance()
}
return m, nil
}
func (m Model) keyFormat(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "q":
m.quitting = true
return m, tea.Quit
case "esc":
return m.back()
case "up", "k":
m.fmtPick.up()
case "down", "j":
m.fmtPick.down()
case "enter":
m.format = m.fmtPick.selected().value
m.technique = ""
return m.advance()
}
return m, nil
}
func (m Model) keyTechnique(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "q":
m.quitting = true
return m, tea.Quit
case "esc":
return m.back()
case "up", "k":
m.techPick.up()
case "down", "j":
m.techPick.down()
case "enter":
m.technique = m.techPick.selected().value
return m.advance()
}
return m, nil
}
func (m Model) keyCover(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if msg.String() == "esc" {
return m.back()
}
var cmd tea.Cmd
m.files, cmd = m.files.Update(msg)
if ok, path := m.files.DidSelectFile(msg); ok {
return m, loadFileCmd(stageCover, path)
}
return m, cmd
}
func (m Model) keyPayload(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.payloadMode == payloadFile {
switch msg.String() {
case "esc", "tab":
m.payloadMode = payloadMessage
return m, m.message.Focus()
}
var cmd tea.Cmd
m.files, cmd = m.files.Update(msg)
if ok, path := m.files.DidSelectFile(msg); ok {
return m, loadFileCmd(stagePayload, path)
}
return m, cmd
}
switch msg.String() {
case "esc":
return m.back()
case "tab":
m.payloadMode = payloadFile
m.message.Blur()
return m, m.files.Init()
case "enter":
if strings.TrimSpace(m.message.Value()) == "" {
m.err = errNeedPayload
return m, nil
}
m.payloadBytes = []byte(m.message.Value())
m.payloadLabel = inlineLabel
return m.advance()
}
var cmd tea.Cmd
m.message, cmd = m.message.Update(msg)
return m, cmd
}
func (m Model) keySecure(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "esc":
return m.back()
case "enter":
if m.secure.encrypt && len(m.secure.passphrase()) == 0 {
m.err = errNeedPassphrase
return m, nil
}
m.pass = m.secure.passphrase()
return m.advance()
}
var cmd tea.Cmd
m.secure, cmd = m.secure.update(msg)
return m, cmd
}
func (m Model) keySave(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "esc":
return m.back()
case "enter":
if strings.TrimSpace(m.outPath.Value()) == "" {
m.err = errNeedOutput
return m, nil
}
return m.advance()
}
var cmd tea.Cmd
m.outPath, cmd = m.outPath.Update(msg)
return m, cmd
}
func (m Model) keyReview(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "q":
m.quitting = true
return m, tea.Quit
case "esc":
return m.back()
case "n":
return m.reset()
}
if m.op == opCapacity {
return m, nil
}
if msg.String() == "enter" {
if !m.hideFits() {
m.err = errPayloadTooBig
return m, nil
}
return m.advance()
}
return m, nil
}
func (m Model) keyPassphrase(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "esc":
m.revPass.Blur()
cmd := m.enter(stageCover)
return m, cmd
case "enter":
return m, m.enter(stageRunning)
}
var cmd tea.Cmd
m.revPass, cmd = m.revPass.Update(msg)
return m, cmd
}
func (m Model) keyResult(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.saving {
return m.keyResultSaving(msg)
}
switch msg.String() {
case "q", "esc", "enter":
m.quitting = true
return m, tea.Quit
case "n":
return m.reset()
case "s":
if m.canSaveReveal() {
m.saving = true
m.outPath.SetValue(m.suggestReveal())
return m, m.outPath.Focus()
}
}
return m, nil
}
func (m Model) keyResultSaving(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "esc":
m.saving = false
m.outPath.Blur()
return m, nil
case "enter":
if strings.TrimSpace(m.outPath.Value()) == "" {
m.err = errNeedOutput
return m, nil
}
return m, saveCmd(strings.TrimSpace(m.outPath.Value()), m.revealRes.Data)
}
var cmd tea.Cmd
m.outPath, cmd = m.outPath.Update(msg)
return m, cmd
}
func (m Model) onFileLoaded(msg fileLoadedMsg) (tea.Model, tea.Cmd) {
if msg.err != nil {
m.err = msg.err
return m, nil
}
switch msg.origin {
case stageCover:
m.coverPath = msg.path
m.coverBytes = msg.data
m.passPrompts = 0
return m.advance()
case stagePayload:
if len(msg.data) == 0 {
m.err = errEmptyFile
return m, nil
}
m.payloadBytes = msg.data
m.payloadLabel = filepath.Base(msg.path)
m.payloadMode = payloadMessage
return m.advance()
}
return m, nil
}
func (m Model) onHideDone(msg hideDoneMsg) (tea.Model, tea.Cmd) {
m.engineDone = true
if msg.err != nil {
m.engineErr = msg.err
} else {
m.hideRes = msg.res
m.stego = msg.data
m.outputAt = m.outPath.Value()
}
return m.maybeFinish()
}
func (m Model) onRevealDone(msg revealDoneMsg) (tea.Model, tea.Cmd) {
m.engineDone = true
m.revealRes = msg.res
m.engineErr = msg.err
return m.maybeFinish()
}
func (m Model) onSaved(msg savedMsg) (tea.Model, tea.Cmd) {
m.saving = false
m.outPath.Blur()
if msg.err != nil {
m.err = msg.err
return m, nil
}
m.savedAt = msg.path
return m, nil
}
func (m Model) onTick() (tea.Model, tea.Cmd) {
if m.stage != stageRunning {
return m, nil
}
m.animFrac += animStep
if m.animFrac >= 1 {
m.animFrac = 1
return m.maybeFinish()
}
return m, tick()
}
func (m Model) maybeFinish() (tea.Model, tea.Cmd) {
if m.stage != stageRunning || !m.engineDone || m.animFrac < 1 {
return m, nil
}
if m.op == opReveal && needsPassphrase(m.engineErr) && m.passPrompts < maxRevealPrompts {
m.passPrompts++
m.revPass.Reset()
m.stage = stagePassphrase
return m, m.revPass.Focus()
}
m.stage = stageResult
return m, nil
}
func (m Model) forward(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch m.stage {
case stageCover:
m.files, cmd = m.files.Update(msg)
case stagePayload:
if m.payloadMode == payloadFile {
m.files, cmd = m.files.Update(msg)
} else {
m.message, cmd = m.message.Update(msg)
}
case stageSecure:
m.secure, cmd = m.secure.update(msg)
case stageSave:
m.outPath, cmd = m.outPath.Update(msg)
case stagePassphrase:
m.revPass, cmd = m.revPass.Update(msg)
case stageResult:
if m.saving {
m.outPath, cmd = m.outPath.Update(msg)
}
}
return m, cmd
}
func (m Model) canSaveReveal() bool {
return m.op == opReveal && m.engineErr == nil && len(m.revealRes.Data) > 0
}
func needsPassphrase(err error) bool {
return errors.Is(err, payload.ErrPassphraseRequired) || errors.Is(err, payload.ErrDecrypt)
}

View File

@ -0,0 +1,329 @@
/*
©AngelaMos | 2026
view.go
The render layer: layout composition and one render per wizard stage
*/
package tui
import (
"fmt"
"math"
"path/filepath"
"strings"
"unicode/utf8"
"github.com/charmbracelet/lipgloss"
)
const (
previewMaxLines = 6
previewHexBytes = 32
startingMessage = "\n starting crypha ...\n"
farewell = "crypha out\n"
)
func (m Model) View() string {
if m.quitting {
return styleHint.Render(farewell)
}
if !m.ready {
return startingMessage
}
inner := m.contentWidth()
parts := []string{
renderHeader(inner),
"",
renderStepper(m.stepLabels(), m.stepIndex()),
"",
m.stageView(inner),
}
if m.err != nil {
parts = append(parts, "", styleError.Width(inner).Render(m.err.Error()))
}
parts = append(parts, "", renderFooter(m.footerHints()))
framed := frameStyle(inner).Render(lipgloss.JoinVertical(lipgloss.Left, parts...))
return lipgloss.PlaceHorizontal(m.width, lipgloss.Center, framed)
}
func (m Model) boxWidth() int {
w := m.width - horizontalGutter
if w > appMaxWidth {
w = appMaxWidth
}
if w < appMinWidth {
w = appMinWidth
}
return w
}
func (m Model) contentWidth() int {
return m.boxWidth() - framePadX*2 - frameBorder*2
}
func (m Model) stageView(w int) string {
switch m.stage {
case stageOperation:
return section("choose an operation", m.opPick.view(w))
case stageFormat:
return section(m.formatPrompt(), m.fmtPick.view(w))
case stageTechnique:
return section("choose a pdf technique", m.techPick.view(w))
case stageCover:
return section(m.coverPrompt(), m.files.View())
case stagePayload:
return m.payloadView()
case stageSecure:
return section("secure the payload", m.secure.view(w))
case stageSave:
return m.saveView()
case stageReview:
return m.reviewView(w)
case stagePassphrase:
return m.passphraseView()
case stageRunning:
return m.runningView()
case stageResult:
return m.resultView(w)
}
return ""
}
func (m Model) formatPrompt() string {
if m.op == opReveal {
return "choose a carrier, or auto-detect"
}
return "choose a carrier"
}
func (m Model) coverPrompt() string {
if m.op == opReveal {
return "select a stego file to inspect"
}
return "select a cover file"
}
func (m Model) payloadView() string {
if m.payloadMode == payloadFile {
body := lipgloss.JoinVertical(lipgloss.Left,
styleHint.Render("choose a payload file · tab or esc to type a message instead"),
"",
m.files.View())
return section("payload from a file", body)
}
body := lipgloss.JoinVertical(lipgloss.Left,
m.message.View(),
"",
styleHint.Render("enter continues · tab loads a file instead"))
return section("payload as an inline message", body)
}
func (m Model) saveView() string {
body := lipgloss.JoinVertical(lipgloss.Left,
m.outPath.View(),
"",
styleHint.Render("where to write the stego file · enter continues"))
return section("save the stego file as", body)
}
func (m Model) reviewView(w int) string {
if m.op == opCapacity {
title := "capacity for " + filepath.Base(m.coverPath)
return section(title, renderCapacityTable(m.capRows, capBarWidth))
}
meter := renderHideMeter(len(m.payloadBytes), m.envelopeSize, m.capValue, m.capErr, meterBarWidth)
body := lipgloss.JoinVertical(lipgloss.Left,
m.hideSummary(),
"",
meter,
"",
styleHint.Render("enter to embed · esc to go back"))
return section("review", body)
}
func (m Model) hideSummary() string {
lines := []string{
kv("carrier", formatLabel(m.format, m.technique)),
kv("payload", fmt.Sprintf("%s (%d B)", m.payloadLabel, len(m.payloadBytes))),
kv("security", m.securitySummary()),
kv("output", m.outPath.Value()),
}
return lipgloss.JoinVertical(lipgloss.Left, lines...)
}
func (m Model) securitySummary() string {
var parts []string
if m.encrypted() {
parts = append(parts, badge("encrypted", hexViolet),
styleHint.Render(m.secure.cipherValue()+" · "+m.secure.strengthValue()))
} else {
parts = append(parts, styleHint.Render("plaintext"))
}
if m.secure.compress {
parts = append(parts, badge("compressed", hexBlue))
}
return strings.Join(parts, " ")
}
func (m Model) runningView() string {
verb := "embedding payload"
if m.op == opReveal {
verb = "revealing payload"
}
bar := spectralBar(embedBarWidth, m.animFrac, brandStops)
pct := int(math.Round(m.animFrac * fullPercent))
body := lipgloss.JoinVertical(lipgloss.Left,
bar+" "+styleValueBold.Render(fmt.Sprintf("%d%%", pct)),
"",
styleHint.Render(verb+" ..."))
return section(verb, body)
}
func (m Model) passphraseView() string {
prompt := "this payload is encrypted; enter the passphrase to unlock it"
if m.passPrompts > 1 {
prompt = "that passphrase did not work, try again"
}
body := lipgloss.JoinVertical(lipgloss.Left,
styleWarn.Render(prompt),
"",
m.revPass.View(),
"",
styleHint.Render("enter to unlock · esc to cancel"))
return section("unlock", body)
}
func (m Model) resultView(w int) string {
if m.engineErr != nil {
return section("failed", styleError.Width(w).Render(reasonText(m.engineErr)))
}
if m.op == opReveal {
return m.revealResultView(w)
}
return m.hideResultView()
}
func (m Model) hideResultView() string {
r := m.hideRes
lines := []string{
kvStyled("status", "payload hidden", styleSuccess),
kv("carrier", formatLabel(r.Format, r.Technique)),
kv("payload", fmt.Sprintf("%d B", r.PayloadBytes)),
kv("envelope", fmt.Sprintf("%d B", r.EnvelopeBytes)),
kv("security", boolBadges(r.Encrypted, r.Compressed)),
kv("output", m.outputAt),
}
body := lipgloss.JoinVertical(lipgloss.Left,
lipgloss.JoinVertical(lipgloss.Left, lines...),
"",
styleHint.Render("n new run · q quit"))
return section("done", body)
}
func (m Model) revealResultView(w int) string {
data := m.revealRes.Data
lines := []string{
kvStyled("status", "payload revealed", styleSuccess),
kv("carrier", m.revealRes.Format),
kv("encrypted", boolText(m.revealRes.Encrypted)),
kv("size", fmt.Sprintf("%d B", len(data))),
}
if m.savedAt != "" {
lines = append(lines, kvStyled("saved", m.savedAt, styleSuccess))
}
segments := []string{
lipgloss.JoinVertical(lipgloss.Left, lines...),
"",
revealPreview(data, w),
}
if m.saving {
segments = append(segments, "",
styleLabel.Render("save as"),
m.outPath.View(),
styleHint.Render("enter to write · esc to cancel"))
} else {
segments = append(segments, "", styleHint.Render(m.revealHints()))
}
return section("revealed", lipgloss.JoinVertical(lipgloss.Left, segments...))
}
func (m Model) revealHints() string {
if m.canSaveReveal() {
return "s save to file · n new run · q quit"
}
return "n new run · q quit"
}
func section(title, body string) string {
return lipgloss.JoinVertical(lipgloss.Left, sectionTitle(title), "", body)
}
func revealPreview(data []byte, w int) string {
if len(data) == 0 {
return styleHint.Render("(empty payload)")
}
if isPrintable(data) {
text := lipgloss.NewStyle().Foreground(lipgloss.Color(hexBright)).Width(w).MaxHeight(previewMaxLines).Render(string(data))
return lipgloss.JoinVertical(lipgloss.Left, styleLabel.Render("message"), text)
}
return lipgloss.JoinVertical(lipgloss.Left, styleLabel.Render("hex preview"), styleValue.Width(w).Render(hexPreview(data, previewHexBytes)))
}
func formatLabel(format, technique string) string {
if format == "" {
return "auto-detect"
}
if technique == "" {
return format
}
return format + " (" + technique + ")"
}
func boolText(b bool) string {
if b {
return "yes"
}
return "no"
}
func boolBadges(encrypted, compressed bool) string {
var parts []string
if encrypted {
parts = append(parts, badge("encrypted", hexViolet))
} else {
parts = append(parts, styleHint.Render("plaintext"))
}
if compressed {
parts = append(parts, badge("compressed", hexBlue))
}
return strings.Join(parts, " ")
}
func isPrintable(data []byte) bool {
if !utf8.Valid(data) {
return false
}
for _, r := range string(data) {
if r == '\n' || r == '\t' || r == '\r' {
continue
}
if r < 0x20 || r == 0x7f {
return false
}
}
return true
}
func hexPreview(data []byte, n int) string {
truncated := false
if len(data) > n {
data = data[:n]
truncated = true
}
s := fmt.Sprintf("% x", data)
if truncated {
s += " ..."
}
return s
}