package tui import ( "fmt" "strings" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "lazyntfy/internal/alert" "lazyntfy/internal/store" "lazyntfy/internal/textutil" "lazyntfy/internal/theme" ) type row struct { text string style lipgloss.Style raw bool } // View draws the alternate-screen interface. func (m Model) View() tea.View { v := tea.NewView(m.render()) v.AltScreen = true v.WindowTitle = "lazyntfy" return v } func (m Model) render() string { if m.width < 40 || m.height < 8 { return "terminal too small\n" } switch m.mode { case modeHelp: return m.fit(m.modal("help", m.helpRows())) case modePublish: return m.fit(m.modal("publish", m.publishRows())) case modeAdd: return m.fit(m.modal("add topic", m.addRows())) default: return m.fit(m.renderPanes()) } } func (m Model) fit(body string) string { lines := strings.Split(strings.TrimRight(body, "\n"), "\n") for len(lines) < m.height { lines = append(lines, "") } if len(lines) > m.height { if m.height >= 2 { tail := lines[len(lines)-1] lines = append(lines[:m.height-1], tail) } else { lines = lines[:m.height] } } return strings.Join(lines, "\n") } func (m Model) renderPanes() string { panelH := m.height - 2 showT, showM, showD, tw, mw, dw := m.layout() var parts []string if showT { parts = append(parts, box("topics", m.focus == focusTopics, tw, panelH, m.topicRows(tw-2))) } if showM { parts = append(parts, box("messages", m.focus == focusMessages, mw, panelH, m.messageRows(mw-2))) } if showD { parts = append(parts, box(m.detailTitle(), m.focus == focusDetail, dw, panelH, m.detailRows(dw-2))) } gapped := make([]string, 0, len(parts)*2-1) for i, part := range parts { if i > 0 { gapped = append(gapped, gapColumn(panelH)) } gapped = append(gapped, part) } mid := lipgloss.JoinHorizontal(lipgloss.Top, gapped...) return m.headerLine() + "\n" + mid + "\n" + m.footerLine() } func (m Model) headerLine() string { left := badgeStyle.Render(" lazyntfy ") if name := m.currentName(); name != "" { left += " " + lipgloss.NewStyle().Bold(true).Render(m.topicLabel(name)) left += dimStyle.Render(fmt.Sprintf(" %d/%d", len(m.filtered()), len(m.store.List(name)))) if m.restored > 0 { left += dimStyle.Render(fmt.Sprintf(" history %d", m.restored)) } } if m.filter != "" { left += " " + filterStyle.Render(" /"+m.filter+" ") } right := m.statusStyle().Render(statusMark(m.status) + m.status + " ") return spread(left, right, m.width) } func statusMark(status string) string { s := strings.ToLower(status) switch { case strings.Contains(s, "connected") || strings.HasPrefix(s, "published"): return "● " case strings.Contains(s, "disconnect") || strings.Contains(s, "retry") || strings.Contains(s, "not authorized"): return "○ " default: return "" } } func (m Model) footerLine() string { if m.mode == modeFilter { return spread(filterStyle.Render(" filter ")+" "+m.filterInput.View(), dimStyle.Render("enter apply esc clear "), m.width) } var keys [][2]string switch m.focus { case focusTopics: keys = [][2]string{{"j/k", "select"}, {"enter", "open"}, {"a", "add"}, {"1/2/3", "pane"}, {"?", "help"}, {"q", "quit"}} case focusDetail: keys = [][2]string{{"j/k", "scroll"}, {"v", "raw"}, {"o", "open link"}, {"esc", "back"}, {"?", "help"}, {"q", "quit"}} default: keys = [][2]string{{"j/k", "move"}, {"enter", "detail"}, {"/", "filter"}, {"p", "publish"}, {"r", "reload"}, {"q", "quit"}} } return spread(chips(keys), dimStyle.Render("? help "), m.width) } func chips(keys [][2]string) string { var b strings.Builder for i, key := range keys { if i > 0 { b.WriteString(" ") } b.WriteString(keyStyle.Render(" " + key[0] + " ")) b.WriteString(dimStyle.Render(" " + key[1])) } return b.String() } func (m Model) layout() (showT, showM, showD bool, tw, mw, dw int) { panels := 1 switch { case m.width >= 100: showT, showM, showD = true, true, true panels = 3 tw = 28 case m.width >= 72: showT = true panels = 2 tw = 24 if m.focus == focusDetail { showD = true } else { showM = true } default: switch m.focus { case focusTopics: showT = true case focusDetail: showD = true default: showM = true } } rest := m.width - (panels - 1) if showT && panels > 1 { if tw > rest-24 { tw = 20 } rest -= tw } else if showT { tw = rest rest = 0 } switch { case showM && showD: mw = rest * 42 / 100 if mw < 24 { mw = 24 } dw = rest - mw if dw < 16 { dw = 16 mw = rest - dw } case showM: mw = rest case showD: dw = rest } if tw < 0 { tw = 0 } if mw < 0 { mw = 0 } if dw < 0 { dw = 0 } return showT, showM, showD, tw, mw, dw } func (m Model) topicRows(width int) []row { names := m.topicNames() if len(names) == 0 { return []row{ {text: "", style: lipgloss.NewStyle()}, {text: " No topics yet", style: dimStyle}, {text: "", style: lipgloss.NewStyle()}, {text: " Press a to add one", style: accentStyle}, } } rows := make([]row, 0, len(names)) for i, name := range names { mark := "○" markStyle := dimStyle if m.up[name] { mark = "●" markStyle = okStyle } count := "" if n := len(m.store.List(name)); n > 0 { count = fmt.Sprintf("%d", n) } label := m.topicLabel(name) if i == m.topicIdx { rows = append(rows, row{ text: splitLine(" "+mark+" "+label, count, width), style: selectedStyle, }) continue } left := " " + markStyle.Render(mark) + " " + theme.Text.Render(label) right := dimStyle.Render(count) rows = append(rows, row{text: splitStyled(left, right, width), raw: true}) } return rows } func (m Model) messageRows(width int) []row { if m.currentName() == "" { return []row{ {text: "", style: lipgloss.NewStyle()}, {text: " Topics from your account", style: lipgloss.NewStyle().Bold(true)}, {text: " show up in the list.", style: dimStyle}, {text: "", style: lipgloss.NewStyle()}, {text: " a add a topic", style: accentStyle}, {text: " ? keyboard help", style: dimStyle}, } } msgs := m.filtered() if len(msgs) == 0 { hint := " Waiting for messages" if m.filter != "" { hint = " Nothing matches /" + m.filter } return []row{{text: hint, style: dimStyle}} } body := m.bodyRows() selected := indexOf(msgs, m.selected[m.currentName()]) start := 0 if selected >= body { start = selected - body + 1 } var rows []row for i := start; i < len(msgs) && len(rows) < body; i++ { rows = append(rows, messageRow(msgs[i], width, msgs[i].ID == m.selected[m.currentName()])) } return rows } func messageRow(msg store.Message, width int, selected bool) row { title, sev := alert.TitleSeverity(msg.Title, msg.Body) when := textutil.Pad(formatListTime(msg.Time), 6) badge := sev if badge == "" { badge = priLabel(msg.Priority) } badge = textutil.Pad(textutil.Clip(badge, 8), 8) room := width - 1 - 6 - 1 - 8 - 1 if room < 8 { room = 8 } title = textutil.Clip(title, room) plain := " " + when + " " + badge + " " + title if selected { return row{text: plain, style: selectedStyle} } colored := " " + dimStyle.Render(when) + " " + toneStyle(alert.Tone(sev, msg.Priority)).Render(badge) + " " + theme.Text.Render(title) return row{text: colored, raw: true} } func (m Model) detailTitle() string { n := len(m.detailBlocks(40)) visible := m.bodyRows() maxTop := n - visible if maxTop < 1 { return "detail" } top := m.detailTop if top > maxTop { top = maxTop } return fmt.Sprintf("detail %d/%d", top+1, maxTop+1) } func (m Model) detailRows(width int) []row { if width < 8 { width = 8 } blocks := m.detailBlocks(width) visible := m.bodyRows() maxTop := len(blocks) - visible if maxTop < 0 { maxTop = 0 } top := m.detailTop if top > maxTop { top = maxTop } if len(blocks) == 0 { return []row{{text: " Nothing selected", style: dimStyle}} } if top >= len(blocks) { return nil } end := top + visible if end > len(blocks) { end = len(blocks) } return blocks[top:end] } func (m Model) detailBlocks(width int) []row { msg, ok := m.selectedMessage() if !ok { return []row{ {text: " Nothing selected", style: dimStyle}, {text: " Move through the list and press enter", style: dimStyle}, } } if width < 8 { width = 8 } title, sev := alert.TitleSeverity(msg.Title, msg.Body) var rows []row if sev != "" { rows = append(rows, row{text: " " + strings.ToUpper(sev), style: toneStyle(alert.Tone(sev, msg.Priority)).Bold(true)}) } rows = append(rows, row{text: " " + title, style: lipgloss.NewStyle().Bold(true)}) rows = append(rows, row{text: " " + formatTime(msg.Time) + " " + msg.ID, style: dimStyle}) var meta []string if msg.Priority > 0 { meta = append(meta, priLabel(msg.Priority)) } if len(msg.Tags) > 0 { meta = append(meta, strings.Join(msg.Tags, " ")) } if len(meta) > 0 { rows = append(rows, row{text: " " + strings.Join(meta, " · "), style: dimStyle}) } rows = append(rows, row{text: "", style: lipgloss.NewStyle()}) for _, line := range wrapText(alert.BodyView(msg.Body, m.raw), width-2) { rows = append(rows, row{text: " " + line, style: lipgloss.NewStyle()}) } if msg.Click != "" { rows = append(rows, row{text: "", style: lipgloss.NewStyle()}, row{text: " link", style: dimStyle}, row{text: " " + msg.Click, style: accentStyle}, ) } if msg.Attachment != nil { rows = append(rows, row{text: "", style: lipgloss.NewStyle()}, row{text: " attachment", style: dimStyle}, row{text: fmt.Sprintf(" %s (%d bytes)", msg.Attachment.Name, msg.Attachment.Size), style: lipgloss.NewStyle()}, ) if msg.Attachment.URL != "" { rows = append(rows, row{text: " " + msg.Attachment.URL, style: dimStyle}) } } if msg.Expires > 0 { rows = append(rows, row{text: " expires " + formatTime(msg.Expires), style: dimStyle}) } return rows } func (m Model) helpRows() []row { pairs := [][2]string{ {"j / k", "move in the focused pane"}, {"g / G", "first and last"}, {"enter", "topics → messages → detail"}, {"esc", "back from detail, or clear filter"}, {"1 2 3", "jump to a pane"}, {"/", "filter title, body, tags, id"}, {"a", "add a topic and remember it"}, {"p", "publish to the focused topic"}, {"r", "reconnect"}, {"v", "toggle raw JSON"}, {"o", "open the click link"}, {"?", "close this help"}, {"q", "quit"}, } rows := []row{{text: " Keys follow the focused pane.", style: dimStyle}, {text: "", style: lipgloss.NewStyle()}} for _, pair := range pairs { rows = append(rows, row{ text: splitLine(" "+pair[0], pair[1], 52), style: lipgloss.NewStyle(), }) } return rows } func (m Model) publishRows() []row { sub, _ := m.currentSub() labels := []string{"Title", "Priority", "Tags", "Message"} rows := []row{{text: " to " + sub.Label(), style: dimStyle}, {text: "", style: lipgloss.NewStyle()}} for i, label := range labels { style := dimStyle if i == m.formIdx { style = accentStyle } rows = append(rows, row{text: " " + label, style: style}) rows = append(rows, row{text: " " + m.form[i].View(), raw: true}) rows = append(rows, row{text: "", style: lipgloss.NewStyle()}) } rows = append(rows, row{text: " enter sends tab moves esc cancels", style: dimStyle}) return rows } func (m Model) addRows() []row { return []row{ {text: " Short names use your default host.", style: dimStyle}, {text: "", style: lipgloss.NewStyle()}, {text: " Topic", style: accentStyle}, {text: " " + m.addInput.View(), raw: true}, {text: "", style: lipgloss.NewStyle()}, {text: " enter subscribes esc cancels", style: dimStyle}, } } func (m Model) modal(title string, rows []row) string { width := m.width - 8 if width > 68 { width = 68 } if width < 36 { width = m.width } if width > m.width { width = m.width } height := len(rows) + 2 if height > m.height-2 { height = m.height - 2 } if height < 5 { height = 5 } card := box(title, true, width, height, rows) return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, card) } func (m Model) statusStyle() lipgloss.Style { s := strings.ToLower(m.status) switch { case strings.Contains(s, "not authorized") || strings.Contains(s, "http 4") || strings.Contains(s, "http 5"): return badStyle case strings.Contains(s, "disconnect") || strings.Contains(s, "no topic") || strings.Contains(s, "retry"): return warnStyle case strings.Contains(s, "connected") || strings.HasPrefix(s, "published"): return okStyle default: return dimStyle } } func box(title string, active bool, width, height int, rows []row) string { if width < 2 || height < 2 { return strings.Repeat("\n", max(0, height-1)) } innerW := width - 2 innerH := height - 2 border := dimStyle if active { border = accentStyle } label := " " + title + " " if len([]rune(label)) > innerW { label = textutil.Clip(title, innerW) } pad := innerW - len([]rune(label)) if pad < 0 { pad = 0 } left := 1 if pad == 0 { left = 0 } right := pad - left top := border.Render("╭"+strings.Repeat("─", left)) + titleStyle.Render(label) + border.Render(strings.Repeat("─", right)+"╮") lines := []string{top} for i := 0; i < innerH; i++ { var painted string if i < len(rows) && rows[i].raw { painted = fitWidth(rows[i].text, innerW) } else { text := "" style := lipgloss.NewStyle() if i < len(rows) { text = rows[i].text style = rows[i].style } painted = style.Render(textutil.Pad(textutil.Clip(text, innerW), innerW)) } lines = append(lines, border.Render("│")+painted+border.Render("│")) } lines = append(lines, border.Render("╰"+strings.Repeat("─", innerW)+"╯")) return strings.Join(lines, "\n") } func gapColumn(height int) string { if height < 1 { return "" } lines := make([]string, height) for i := range lines { lines[i] = " " } return strings.Join(lines, "\n") } func spread(left, right string, width int) string { gap := width - lipgloss.Width(left) - lipgloss.Width(right) if gap < 1 { return left } return left + strings.Repeat(" ", gap) + right } func splitLine(left, right string, width int) string { if width <= 0 { return left } gap := width - len([]rune(left)) - len([]rune(right)) if gap < 1 { return textutil.Clip(left+" "+right, width) } return left + strings.Repeat(" ", gap) + right } func splitStyled(left, right string, width int) string { gap := width - lipgloss.Width(left) - lipgloss.Width(right) if gap < 1 { return fitWidth(left, width) } return left + strings.Repeat(" ", gap) + right } func fitWidth(s string, width int) string { w := lipgloss.Width(s) if w > width && width > 0 { // Styled input lines are already width-limited by the text field. return s } if gap := width - w; gap > 0 { return s + strings.Repeat(" ", gap) } return s } func max(a, b int) int { if a > b { return a } return b } func toneStyle(tone string) lipgloss.Style { switch tone { case "bad": return badStyle case "ok": return okStyle case "warn": return warnStyle case "dim": return dimStyle default: return lipgloss.NewStyle() } } var ( titleStyle = theme.Title accentStyle = theme.Accent badgeStyle = theme.Badge keyStyle = theme.Key filterStyle = theme.Filter dimStyle = theme.Dim okStyle = theme.OK warnStyle = theme.Warn badStyle = theme.Bad selectedStyle = theme.Selected )