330 lines
8.1 KiB
Go
330 lines
8.1 KiB
Go
/*
|
|
©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
|
|
}
|