Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
216 changes: 174 additions & 42 deletions cmd/tui.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"path/filepath"
"sort"
"strings"
"time"

tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
Expand All @@ -28,14 +29,27 @@ var (
styleCyan = lipgloss.NewStyle().Foreground(lipgloss.Color("6"))
styleCursor = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("6"))
stylePending = lipgloss.NewStyle().Foreground(lipgloss.Color("3"))

// header ASCII art gradient: light-green (top) → dark-green (bottom)
styleArt = [6]lipgloss.Style{
lipgloss.NewStyle().Foreground(lipgloss.Color("120")).Bold(true),
lipgloss.NewStyle().Foreground(lipgloss.Color("83")).Bold(true),
lipgloss.NewStyle().Foreground(lipgloss.Color("46")).Bold(true),
lipgloss.NewStyle().Foreground(lipgloss.Color("40")).Bold(true),
lipgloss.NewStyle().Foreground(lipgloss.Color("34")).Bold(true),
lipgloss.NewStyle().Foreground(lipgloss.Color("28")).Bold(true),
}
styleMascotNormal = lipgloss.NewStyle().Foreground(lipgloss.Color("2"))
styleMascotConflict = lipgloss.NewStyle().Foreground(lipgloss.Color("1")).Bold(true)
styleMascotMissing = lipgloss.NewStyle().Foreground(lipgloss.Color("3"))
)

// ── pkg status ────────────────────────────────────────────────────────────────

type pkgStatus int

const (
statusUntied pkgStatus = iota
statusUntied pkgStatus = iota
statusTied
statusPartial
statusConflict
Expand Down Expand Up @@ -96,6 +110,48 @@ func computeStatus(actions []linker.LinkAction) pkgStatus {
return statusPartial
}

// ── header art & mascot ───────────────────────────────────────────────────────

// knotArt is "KNOT" in 6-row block-letter style; each row is 37 visual columns wide.
var knotArt = [6]string{
`██╗ ██╗███╗ ██╗ ██████╗ ████████╗`,
`██║ ██╔╝████╗ ██║██╔═══██╗╚══██╔══╝`,
`█████╔╝ ██╔██╗ ██║██║ ██║ ██║ `,
`██╔═██╗ ██║╚██╗██║██║ ██║ ██║ `,
`██║ ██╗██║ ╚████║╚██████╔╝ ██║ `,
`╚═╝ ╚═╝╚═╝ ╚═══╝ ╚═════╝ ╚═╝ `,
}

type mascotState int

const (
mascotNormal mascotState = iota // idle
mascotConflict // package conflict detected
mascotMissing // no packages / no git repo
)

// mascotFrames[state][frame][line] — each line is exactly 8 visual columns.
var mascotFrames = [3][3][6]string{
// mascotNormal: green, slow blink
{
{` ▄████▄ `, ` █ oo █ `, ` ▀████▀ `, ` ████ `, ` ██ ██ `, `██ ██`},
{` ▄████▄ `, ` █ -- █ `, ` ▀████▀ `, ` ████ `, ` ██ ██ `, `██ ██`},
{` ▄████▄ `, ` █ oo █ `, ` ▀████▀ `, `▐ ████ ▌`, ` ██ ██ `, `██ ██`},
},
// mascotConflict: red, frantic
{
{` ▄████▄ `, ` █ !! █ `, ` ▀████▀ `, ` ████ `, ` ██ ██ `, ` / \ `},
{` ▄████▄ `, ` █ ** █ `, ` ▀████▀ `, ` ████ `, `▌██ ██▐`, ` \ / `},
{` ▄████▄ `, ` █ XX █ `, ` ▀████▀ `, ` ████ `, ` ██ ██ `, `▌/ \▐`},
},
// mascotMissing: yellow, looking side-to-side
{
{` ▄████▄ `, ` █ ?? █ `, ` ▀████▀ `, ` ██ `, ` ████ `, ` █ █ `},
{` ▄████▄ `, ` █?? █`, ` ▀████▀ `, ` ██ `, ` ████ `, ` █ █ `},
{` ▄████▄ `, ` █ ??█`, ` ▀████▀ `, ` ██ `, ` ████ `, ` █ █ `},
},
}

// ── model types ───────────────────────────────────────────────────────────────

type pkgRow struct {
Expand All @@ -107,7 +163,7 @@ type pkgRow struct {
type tuiPhase int

const (
phaseList tuiPhase = iota
phaseList tuiPhase = iota
phaseConfirm
phaseApply
phaseResult
Expand Down Expand Up @@ -148,10 +204,13 @@ type model struct {
statusMsg string // inline error for editor failure etc.

width, height int
headerFrame int // incremented every 600ms for mascot animation
}

// ── message types ─────────────────────────────────────────────────────────────

type headerTickMsg struct{}

type gitInfoMsg struct {
branch string
sha string
Expand Down Expand Up @@ -363,11 +422,8 @@ func (m *model) toggleTag(tr *tagRow) {
}

func (m *model) listHeaderLines() int {
// title + git-info (if available) + divider = 2 or 3
if m.gitBranch != "" {
return 3
}
return 2
// brand box (11 lines) + tab header (1 line) = 12
return 12
}

func (m *model) visibleHeight() int {
Expand Down Expand Up @@ -443,6 +499,74 @@ func (m *model) adjustTagOffset() {
}
}

func (m model) renderBrandHeader() string {
state := m.currentMascotState()
var frame int
if state == mascotNormal {
frame = (m.headerFrame / 2) % 3
} else {
frame = m.headerFrame % 3
}
mascotLines := mascotFrames[state][frame]

var mascotStyle lipgloss.Style
switch state {
case mascotConflict:
mascotStyle = styleMascotConflict
case mascotMissing:
mascotStyle = styleMascotMissing
default:
mascotStyle = styleMascotNormal
}

const leftPad = 2
const gap = 4

innerW := max(m.width, 62) - 2
hLine := strings.Repeat("─", innerW)

var b strings.Builder

// top border
b.WriteString("╭" + hLine + "╮\n")
// empty line
b.WriteString("│" + strings.Repeat(" ", innerW) + "│\n")
// 6 lines of KNOT art + knotman side-by-side; pad each row individually
// so mismatched art/mascot visual widths don't break the right border.
for i := 0; i < 6; i++ {
art := styleArt[i].Render(knotArt[i])
mascot := mascotStyle.Render(mascotLines[i])
content := " " + art + strings.Repeat(" ", gap) + mascot
rowRightPad := strings.Repeat(" ", max(innerW-lipgloss.Width(content), 0))
b.WriteString("│" + content + rowRightPad + "│\n")
}
// empty line
b.WriteString("│" + strings.Repeat(" ", innerW) + "│\n")
// subtitle / git info
subtitle := styleDim.Render("dotfiles manager")
if m.gitBranch != "" {
commitInfo := m.gitSHA
if m.gitCommitMsg != "" {
// reserve space for "dotfiles manager · on <branch> · <sha> "
overhead := len("dotfiles manager · on · ") + len(m.gitBranch) + len(m.gitSHA) + 1
maxMsgLen := max(innerW-leftPad-overhead, 10)
msg := []rune(m.gitCommitMsg)
if len(msg) > maxMsgLen {
msg = append(msg[:maxMsgLen-1], '…')
}
commitInfo = m.gitSHA + " " + string(msg)
}
subtitle += styleDim.Render(" · on ") + styleCyan.Render(m.gitBranch) + styleDim.Render(" · "+commitInfo)
}
subtitleVisW := lipgloss.Width(subtitle)
subRightPad := strings.Repeat(" ", max(innerW-leftPad-subtitleVisW, 0))
b.WriteString("│ " + subtitle + subRightPad + "│\n")
// bottom border
b.WriteString("╰" + hLine + "╯\n")

return b.String()
}

func (m model) renderTabHeader() string {
var pkgTab, tagTab string
if m.activeTab == tabPackages {
Expand All @@ -455,12 +579,37 @@ func (m model) renderTabHeader() string {
return " " + pkgTab + styleDim.Render(" │ ") + tagTab
}

func (m model) hasConflicts() bool {
for _, r := range m.rows {
if r.status == statusConflict {
return true
}
}
return false
}

func (m model) currentMascotState() mascotState {
if m.hasConflicts() {
return mascotConflict
}
if len(m.rows) == 0 || m.gitBranch == "" {
return mascotMissing
}
return mascotNormal
}

func dotfilesDir(cfgPath string) string {
return filepath.Dir(cfgPath)
}

// ── tea.Cmds ─────────────────────────────────────────────────────────────────

func headerTickCmd() tea.Cmd {
return tea.Tick(600*time.Millisecond, func(time.Time) tea.Msg {
return headerTickMsg{}
})
}

func fetchGitInfoCmd(dir string) tea.Cmd {
return func() tea.Msg {
shaOut, err := exec.Command("git", "-C", dir, "log", "-1", "--pretty=format:%h").Output()
Expand Down Expand Up @@ -582,7 +731,10 @@ func editorCmd(cfgPath string) tea.Cmd {
// ── bubbletea interface ───────────────────────────────────────────────────────

func (m model) Init() tea.Cmd {
return fetchGitInfoCmd(dotfilesDir(m.cfgPath))
return tea.Batch(
fetchGitInfoCmd(dotfilesDir(m.cfgPath)),
headerTickCmd(),
)
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
Expand All @@ -595,6 +747,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.adjustBranchOffset()
return m, nil

case headerTickMsg:
m.headerFrame++
return m, headerTickCmd()

case gitInfoMsg:
if msg.err == nil {
m.gitBranch = msg.branch
Expand Down Expand Up @@ -890,20 +1046,7 @@ func (m model) viewList() string {
var b strings.Builder

// Header
b.WriteString(styleBold.Render("knot") + styleDim.Render(" — interactive mode") + "\n")
if m.gitBranch != "" {
commitInfo := m.gitSHA
if m.gitCommitMsg != "" {
maxMsgLen := max(m.width-len(m.gitBranch)-len(m.gitSHA)-10, 20)
msg := m.gitCommitMsg
if len(msg) > maxMsgLen {
msg = msg[:maxMsgLen-1] + "…"
}
commitInfo = m.gitSHA + " " + msg
}
b.WriteString(styleDim.Render("on ") + styleCyan.Render(m.gitBranch) + styleDim.Render(" · "+commitInfo) + "\n")
}
b.WriteString(strings.Repeat("─", max(m.width, 30)) + "\n")
b.WriteString(m.renderBrandHeader())
b.WriteString(m.renderTabHeader() + "\n")

// Package list
Expand Down Expand Up @@ -961,21 +1104,8 @@ func (m model) viewList() string {
func (m model) viewTags() string {
var b strings.Builder

// Header (same as viewList)
b.WriteString(styleBold.Render("knot") + styleDim.Render(" — interactive mode") + "\n")
if m.gitBranch != "" {
commitInfo := m.gitSHA
if m.gitCommitMsg != "" {
maxMsgLen := max(m.width-len(m.gitBranch)-len(m.gitSHA)-10, 20)
msg := m.gitCommitMsg
if len(msg) > maxMsgLen {
msg = msg[:maxMsgLen-1] + "…"
}
commitInfo = m.gitSHA + " " + msg
}
b.WriteString(styleDim.Render("on ") + styleCyan.Render(m.gitBranch) + styleDim.Render(" · "+commitInfo) + "\n")
}
b.WriteString(strings.Repeat("─", max(m.width, 30)) + "\n")
// Header
b.WriteString(m.renderBrandHeader())
b.WriteString(m.renderTabHeader() + "\n")
b.WriteString("\n")

Expand Down Expand Up @@ -1240,8 +1370,10 @@ func buildGitURL(provider, protocol, username, repo string) string {
return ""
}

type cloneDoneMsg struct{ err error }
type knotfileReadyMsg struct{ err error }
type (
cloneDoneMsg struct{ err error }
knotfileReadyMsg struct{ err error }
)

func cloneRepoCmd(url, dir string) tea.Cmd {
return func() tea.Msg {
Expand All @@ -1256,10 +1388,10 @@ func cloneRepoCmd(url, dir string) tea.Cmd {
func writeKnotfileCmd(dir string) tea.Cmd {
return func() tea.Msg {
knotfilePath := filepath.Join(dir, config.KnotfileName)
if err := os.MkdirAll(dir, 0755); err != nil {
if err := os.MkdirAll(dir, 0o755); err != nil {
return knotfileReadyMsg{err: fmt.Errorf("creating directory: %w", err)}
}
if err := os.WriteFile(knotfilePath, exampleKnotfile, 0644); err != nil {
if err := os.WriteFile(knotfilePath, exampleKnotfile, 0o644); err != nil {
return knotfileReadyMsg{err: fmt.Errorf("writing Knotfile: %w", err)}
}
return knotfileReadyMsg{}
Expand Down Expand Up @@ -1509,7 +1641,7 @@ func (m setupModel) View() string {
b.WriteString("Enter the repository name:\n\n")
b.WriteString(" " + styleCyan.Render(m.inputBuf) + "█\n")
b.WriteString("\n" + styleDim.Render("enter to confirm · esc to go back · ctrl+c to quit"))
b.WriteString("\n" + styleDim.Render("Leave empty to use the default: ")+styleCyan.Render(".dotfiles"))
b.WriteString("\n" + styleDim.Render("Leave empty to use the default: ") + styleCyan.Render(".dotfiles"))
if m.err != nil {
b.WriteString("\n\n" + styleRed.Render(m.err.Error()))
}
Expand Down
25 changes: 13 additions & 12 deletions cmd/tui_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -319,25 +319,26 @@ func TestTogglePackage_ConflictIsNoop(t *testing.T) {

func TestListHeaderLines_WithBranch(t *testing.T) {
m := &model{gitBranch: "main"}
if got := m.listHeaderLines(); got != 3 {
t.Errorf("expected 3 header lines with git branch, got %d", got)
// brand box (11) + tab header (1) = 12, regardless of git branch
if got := m.listHeaderLines(); got != 12 {
t.Errorf("expected 12 header lines with git branch, got %d", got)
}
}

func TestListHeaderLines_WithoutBranch(t *testing.T) {
m := &model{}
if got := m.listHeaderLines(); got != 2 {
t.Errorf("expected 2 header lines without git branch, got %d", got)
if got := m.listHeaderLines(); got != 12 {
t.Errorf("expected 12 header lines without git branch, got %d", got)
}
}

// ── visibleHeight ─────────────────────────────────────────────────────────────

func TestVisibleHeight_Normal(t *testing.T) {
m := &model{height: 20}
// overhead = 2 (no branch) + 3 = 5; visible = 20 - 5 = 15
if got := m.visibleHeight(); got != 15 {
t.Errorf("expected 15 visible rows, got %d", got)
// overhead = 12 (header) + 3 (blank+status+help) = 15; visible = 20 - 15 = 5
if got := m.visibleHeight(); got != 5 {
t.Errorf("expected 5 visible rows, got %d", got)
}
}

Expand All @@ -350,9 +351,9 @@ func TestVisibleHeight_Minimum(t *testing.T) {

func TestVisibleHeight_WithBranch(t *testing.T) {
m := &model{height: 20, gitBranch: "main"}
// overhead = 3 (with branch) + 3 = 6; visible = 20 - 6 = 14
if got := m.visibleHeight(); got != 14 {
t.Errorf("expected 14 visible rows with git branch, got %d", got)
// overhead = 12 (header) + 3 = 15; visible = 20 - 15 = 5
if got := m.visibleHeight(); got != 5 {
t.Errorf("expected 5 visible rows with git branch, got %d", got)
}
}

Expand Down Expand Up @@ -640,8 +641,8 @@ func TestViewList_NonEmpty(t *testing.T) {
if out == "" {
t.Error("viewList() returned empty string")
}
if !containsSubstr(out, "knot") {
t.Error("viewList() should contain 'knot' header")
if !containsSubstr(out, "dotfiles manager") {
t.Error("viewList() should contain brand header")
}
}

Expand Down
Loading