160 lines
3.8 KiB
Go
160 lines
3.8 KiB
Go
// ©AngelaMos | 2026
|
|
// view.go
|
|
|
|
package tui
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/charmbracelet/bubbles/key"
|
|
"github.com/charmbracelet/lipgloss"
|
|
)
|
|
|
|
const (
|
|
brandName = "NADEZHDA"
|
|
tagline = "THREAT.WIRE"
|
|
|
|
markOpen = "◤ "
|
|
markClose = " ◢"
|
|
markMid = "═◆═"
|
|
|
|
errWrapMax = 80
|
|
errWrapMin = 20
|
|
errWrapMargin = 6
|
|
|
|
minRenderWidth = 44
|
|
minRenderHeight = 10
|
|
)
|
|
|
|
func (m Model) View() string {
|
|
if m.width < minRenderWidth || m.height < minRenderHeight {
|
|
return m.tooSmallView()
|
|
}
|
|
switch m.state {
|
|
case stateLoading:
|
|
return m.loadingView()
|
|
case stateError:
|
|
return m.errorView()
|
|
case stateDetail:
|
|
return m.detailView()
|
|
default:
|
|
return m.listView()
|
|
}
|
|
}
|
|
|
|
func (m Model) tooSmallView() string {
|
|
w, h := m.width, m.height
|
|
if w < 1 {
|
|
w = 1
|
|
}
|
|
if h < 1 {
|
|
h = 1
|
|
}
|
|
msg := m.theme.Muted.Render(fmt.Sprintf("terminal too small — need at least %dx%d", minRenderWidth, minRenderHeight))
|
|
return lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, msg)
|
|
}
|
|
|
|
func (m Model) wordmark() string {
|
|
t := m.theme
|
|
return t.BrandMark.Render(markOpen) + t.Brand.Render(brandName) + t.BrandMark.Render(markClose) +
|
|
" " + t.fg(colorCyan).Render(markMid) + " " + t.fg(colorViolet).Render(tagline) + " " + t.fg(colorCyan).Render(markMid)
|
|
}
|
|
|
|
func (m Model) spread(left, right string) string {
|
|
gap := m.width - lipgloss.Width(left) - lipgloss.Width(right)
|
|
if gap < 1 {
|
|
gap = 1
|
|
}
|
|
return left + strings.Repeat(" ", gap) + right
|
|
}
|
|
|
|
func (m Model) keyHints(bindings ...key.Binding) string {
|
|
t := m.theme
|
|
parts := make([]string, 0, len(bindings))
|
|
for _, b := range bindings {
|
|
h := b.Help()
|
|
parts = append(parts, t.KeyGlyph.Render(h.Key)+" "+t.KeyDesc.Render(h.Desc))
|
|
}
|
|
return strings.Join(parts, t.Dim.Render(" · "))
|
|
}
|
|
|
|
func (m Model) severityLegend() string {
|
|
t := m.theme
|
|
item := func(color, label string) string {
|
|
return t.fg(color).Render("■") + " " + t.KeyDesc.Render(label)
|
|
}
|
|
return strings.Join([]string{
|
|
item(colorMagenta, "KEV/CRIT"),
|
|
item(colorAmber, "HIGH"),
|
|
item(colorYellow, "MED"),
|
|
item(colorCyan, "LOW"),
|
|
}, " ")
|
|
}
|
|
|
|
func (m Model) loadingView() string {
|
|
t := m.theme
|
|
brand := t.BrandMark.Render(markOpen) + t.Brand.Render(brandName) + t.BrandMark.Render(markClose)
|
|
tag := t.fg(colorViolet).Render(tagline)
|
|
line := m.spinner.View() + " " + t.Meta.Render("assembling threat wire") + " " + m.spinner.View()
|
|
block := lipgloss.JoinVertical(lipgloss.Center, brand, tag, "", line)
|
|
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, block)
|
|
}
|
|
|
|
func (m Model) errorView() string {
|
|
t := m.theme
|
|
title := t.fg(colorMagenta).Bold(true).Render("▚ WIRE FAULT ▞")
|
|
block := lipgloss.JoinVertical(lipgloss.Center,
|
|
title,
|
|
"",
|
|
m.errString(),
|
|
"",
|
|
t.KeyDesc.Render("press ")+t.KeyGlyph.Render("q")+t.KeyDesc.Render(" to quit"),
|
|
)
|
|
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, block)
|
|
}
|
|
|
|
func (m Model) errString() string {
|
|
if m.err == nil {
|
|
return m.theme.Text.Render("unknown fault")
|
|
}
|
|
w := m.width - errWrapMargin
|
|
if w > errWrapMax {
|
|
w = errWrapMax
|
|
}
|
|
if w < errWrapMin {
|
|
w = errWrapMin
|
|
}
|
|
return m.theme.Text.Width(w).Align(lipgloss.Center).Render(m.err.Error())
|
|
}
|
|
|
|
func (m Model) detailView() string {
|
|
t := m.theme
|
|
scroll := fmt.Sprintf("%3.0f%%", m.viewport.ScrollPercent()*100)
|
|
left := t.PanelTitle.Render(markOpen+"DOSSIER"+markClose) + " " +
|
|
t.Muted.Render(fmt.Sprintf("story %d of %d", m.cursor+1, len(m.scored)))
|
|
head := m.spread(left, t.Meta.Render(scroll))
|
|
foot := m.spread(
|
|
m.keyHints(m.keys.Back, m.keys.Browser, m.keys.Down, m.keys.Up, m.keys.Quit),
|
|
m.statusText(),
|
|
)
|
|
return lipgloss.JoinVertical(lipgloss.Left,
|
|
head,
|
|
t.rule(m.width),
|
|
m.viewport.View(),
|
|
t.rule(m.width),
|
|
foot,
|
|
)
|
|
}
|
|
|
|
func (m Model) statusText() string {
|
|
if m.status == "" {
|
|
return ""
|
|
}
|
|
color := colorCyan
|
|
if m.statusErr {
|
|
color = colorMagenta
|
|
}
|
|
return m.theme.fg(color).Render(m.status)
|
|
}
|