feat(tui): add the lazygit-style three-pane interface
Topics, messages, and message detail in three panes, with filtering, publishing, adding topics, a raw JSON view, history restore, and open-link support.
This commit is contained in:
@@ -0,0 +1,609 @@
|
||||
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
|
||||
)
|
||||
Reference in New Issue
Block a user