package ui import ( "fmt" "strings" "github.com/charmbracelet/lipgloss" "motd-assist/internal/motd" ) // View rendert das komplette Layout: Preset- und Script-Pane oben, // Live-Vorschau unten, Statuszeile am unteren Rand. func (m Model) View() string { if !m.ready { return "motd-assist startet…" } switch m.mode { case modeHelp: return m.helpView() case modeSaveName: return m.saveView() } if m.err != nil { return m.errorView() } const statusH = 1 previewH := max(6, (m.height-statusH)*2/5) topH := m.height - statusH - previewH if topH < 5 { topH = max(3, m.height-statusH-3) previewH = m.height - statusH - topH } presetsW := 26 if m.width < 90 { presetsW = 22 } scriptsW := m.width - presetsW - 1 top := lipgloss.JoinHorizontal(lipgloss.Top, m.presetsView(presetsW, topH), " ", m.scriptsView(scriptsW, topH), ) body := lipgloss.JoinVertical(lipgloss.Left, top, m.previewView(m.width, previewH), m.statusView(), ) return body } func (m Model) presetsView(w, h int) string { inner := w - 4 // Rahmen (2) + Padding (2) var b strings.Builder b.WriteString(focusedTitle.Render("Presets")) b.WriteString("\n") visible := h - 3 // Rahmen (2) + Titel (1) start := 0 if m.presetIdx >= visible { start = m.presetIdx - visible + 1 } for i := start; i < len(m.presets) && i < start+visible; i++ { p := m.presets[i] cursor := " " name := truncate(p.Name, inner-5) if m.focus == panePresets && i == m.presetIdx { cursor = "▸ " b.WriteString(cursorStyle.Render(cursor + name)) } else { b.WriteString(cursor + name) } if m.presetMatches[p.Name] { b.WriteString(" ") b.WriteString(goodStyle.Render("✓")) } b.WriteString("\n") } if len(m.presets) == 0 { b.WriteString(dimStyle.Render("(keine Presets)")) b.WriteString("\n") } return paneBox(m.focus == panePresets, w, h).Render(b.String()) } func (m Model) scriptsView(w, h int) string { inner := w - 4 var b strings.Builder title := "Scripte · /etc/update-motd.d" b.WriteString(focusedTitle.Render(truncate(title, inner))) b.WriteString("\n") markers := m.markersByRow() visible := h - 3 start := 0 if m.cursor >= visible { start = m.cursor - visible + 1 } for i := start; i < len(m.working) && i < start+visible; i++ { b.WriteString(m.renderRow(m.working[i], markers[m.working[i].Name], inner, m.focus == paneScripts && i == m.cursor)) b.WriteString("\n") } if len(m.working) == 0 { b.WriteString(dimStyle.Render("(leer)")) b.WriteString("\n") } return paneBox(m.focus == paneScripts, w, h).Render(b.String()) } func (m Model) renderRow(r row, marker string, inner int, selected bool) string { var icon string switch { case r.Remove: icon = badStyle.Render("✕") case r.Enabled: icon = goodStyle.Render("●") default: icon = dimStyle.Render("○") } name := r.Name suffix := "" if r.Install != "" { suffix = " " + dimStyle.Render("+neu") } else if r.Bundled { suffix = " " + dimStyle.Render("·motd-assist") } if selected { name = cursorStyle.Render(name) } else if !r.Enabled && !r.Remove { name = dimStyle.Render(name) } line := icon + " " + name + suffix // Marker rechtsbündig, ohne ANSI-Breiten zu verreißen: Marker ist // gefärbt, daher Breite ohne Escape-Codes einplanen. markerPlain := marker markerW := len([]rune(markerPlain)) used := lipgloss.Width(line) pad := inner - used - markerW - 1 if pad < 0 { pad = 0 } if marker != "" { style := warnStyle switch marker { case "neu", "an": style = goodStyle case "entfernt": style = badStyle default: style = dimStyle } line += strings.Repeat(" ", pad) + style.Render(marker) } return line } // markersByRow liefert die ausstehenden Änderungen pro Script-Name. func (m *Model) markersByRow() map[string]string { mk := make(map[string]string) plan := m.currentPlan() for _, op := range plan.Installs { if op.Enabled { mk[op.Name] = "neu" } else { mk[op.Name] = "neu (aus)" } } for _, op := range plan.Chmod { if op.Enable { mk[op.Name] = "an" } else { mk[op.Name] = "aus" } } for _, op := range plan.Renames { mk[op.From] = "→ " + op.To } for _, op := range plan.Removes { mk[op.Name] = "entfernt" } return mk } func (m Model) previewView(w, h int) string { inner := w - 4 var title string switch { case m.applying: title = fmt.Sprintf("Live-Vorschau %s sudo läuft…", m.spinner.View()) case m.previewBusy: title = fmt.Sprintf("Live-Vorschau %s aktualisiere…", m.spinner.View()) default: if n := m.currentPlan().NumOps(); n > 0 { title = fmt.Sprintf("Live-Vorschau · %s (a: anwenden, esc: verwerfen)", warnStyle.Render(fmt.Sprintf("%d Änderungen ausstehend", n))) } else { title = "Live-Vorschau · " + goodStyle.Render("entspricht dem System") } } var content string if m.preview == "" { content = dimStyle.Render("(keine Ausgabe – Scripte aktivieren mit Leertaste)") } else { lines := strings.Split(m.preview, "\n") off := min(m.previewOff, max(0, len(lines)-1)) end := off + h - 3 if end > len(lines) { end = len(lines) } content = strings.Join(lines[off:end], "\n") } return paneBox(m.focus == panePreview, w, h).Render( truncateAnsi(title, inner) + "\n" + content, ) } // truncateAnsi kürzt einen stilisierten Titel auf Anzeigebreite w. func truncateAnsi(s string, w int) string { if lipgloss.Width(s) <= w { return s } var b strings.Builder width := 0 inEsc := false for _, r := range s { switch { case r == '\x1b': inEsc = true b.WriteRune(r) case inEsc: b.WriteRune(r) if r == 'm' { inEsc = false } default: if width >= w { continue } b.WriteRune(r) width++ } } return b.String() } func (m Model) statusView() string { pam := warnStyle.Render("pam_motd ✗") if m.host.PAM { pam = goodStyle.Render("pam_motd ✓") } priv := warnStyle.Render("sudo nötig") if motd.IsRoot() { priv = goodStyle.Render("root") } enabled := 0 for _, r := range m.working { if r.Enabled && !r.Remove { enabled++ } } left := fmt.Sprintf("%s · %s · %s · %d/%d aktiv · %s", m.host.Hostname, m.host.Distro, pam, enabled, len(m.working), priv) line := statusStyle(left) if m.status != "" { line += statusStyle(" │ ") + statusStyle(m.status) } return lipgloss.NewStyle().MaxWidth(m.width).Render(line) } func statusStyle(s string) string { return lipgloss.NewStyle().Foreground(lipgloss.Color("250")).Render(s) } func (m Model) errorView() string { msg := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(colorBad). Padding(1, 2). Width(min(60, m.width-4)). Render( badStyle.Render("MOTD-Verzeichnis nicht gefunden") + "\n\n" + m.err.Error() + "\n\n" + dimStyle.Render("motd-assist verwaltet /etc/update-motd.d (Ubuntu/Debian). "+ "Auf diesem System liegt kein update-motd.d vor.") + "\n\n" + dimStyle.Render("q/esc: beenden · r: erneut versuchen"), ) return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, msg) } func (m Model) helpView() string { keys := [][2]string{ {"tab / shift+tab", "Panes wechseln (Presets · Scripte · Vorschau)"}, {"j / k, ↑ / ↓", "auswählen"}, {"enter / space", "Preset laden · Script an/aus"}, {"u / d", "Script in der Reihenfolge nach oben/unten"}, {"a", "Änderungen anwenden (sudo-Passwortabfrage)"}, {"esc", "Änderungen verwerfen"}, {"s", "aktuellen Zustand als Preset speichern"}, {"D", "ausgewähltes Preset löschen (nur eigene)"}, {"r", "Systemzustand neu einlesen"}, {"q", "beenden"}, } var b strings.Builder b.WriteString(focusedTitle.Render("Hilfe") + "\n\n") for _, k := range keys { b.WriteString(cursorStyle.Render(fmt.Sprintf("%-20s", k[0])) + " " + k[1] + "\n") } b.WriteString("\n" + dimStyle.Render("Presets: ~/.config/motd-assist/presets · Undo: motd-assist restore")) box := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(colorAccent). Padding(1, 2). Render(b.String()) return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, box) } func (m Model) saveView() string { box := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(colorAccent). Padding(1, 2). Render( focusedTitle.Render("Preset speichern") + "\n\n" + m.input.View() + "\n\n" + helpStyle.Render("enter: speichern · esc: abbrechen"), ) return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, box) }