diff --git a/cmd/tui.go b/cmd/tui.go index 164deb3..0de1bb1 100644 --- a/cmd/tui.go +++ b/cmd/tui.go @@ -9,6 +9,7 @@ import ( "path/filepath" "sort" "strings" + "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -28,6 +29,19 @@ 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 ──────────────────────────────────────────────────────────────── @@ -35,7 +49,7 @@ var ( type pkgStatus int const ( - statusUntied pkgStatus = iota + statusUntied pkgStatus = iota statusTied statusPartial statusConflict @@ -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 { @@ -107,7 +163,7 @@ type pkgRow struct { type tuiPhase int const ( - phaseList tuiPhase = iota + phaseList tuiPhase = iota phaseConfirm phaseApply phaseResult @@ -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 @@ -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 { @@ -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 · " + 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 { @@ -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() @@ -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) { @@ -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 @@ -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 @@ -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") @@ -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 { @@ -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{} @@ -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())) } diff --git a/cmd/tui_test.go b/cmd/tui_test.go index 39e11cc..a0d752a 100644 --- a/cmd/tui_test.go +++ b/cmd/tui_test.go @@ -319,15 +319,16 @@ 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) } } @@ -335,9 +336,9 @@ func TestListHeaderLines_WithoutBranch(t *testing.T) { 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) } } @@ -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) } } @@ -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") } }