diff --git a/internal/tui/cmd.go b/internal/tui/cmd.go new file mode 100644 index 0000000..caee0e5 --- /dev/null +++ b/internal/tui/cmd.go @@ -0,0 +1,36 @@ +package tui + +import ( + "context" + "os/exec" + "time" + + tea "charm.land/bubbletea/v2" + + "lazyntfy/internal/config" + "lazyntfy/internal/ntfy" +) + +type publishDone struct{ err error } +type openDone struct{ err error } + +func publishCmd(client *ntfy.Client, sub config.Sub, req ntfy.PublishRequest) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + return publishDone{err: client.Publish(ctx, sub.Origin, sub.Name, sub.Auth, req)} + } +} + +func openCmd(raw string) tea.Cmd { + return func() tea.Msg { + return openDone{err: openURL(raw)} + } +} + +func openURL(raw string) error { + if err := validateURL(raw); err != nil { + return err + } + return exec.Command("xdg-open", raw).Start() +} diff --git a/internal/tui/model.go b/internal/tui/model.go new file mode 100644 index 0000000..81b541e --- /dev/null +++ b/internal/tui/model.go @@ -0,0 +1,844 @@ +// Package tui is the lazyntfy terminal UI. +package tui + +import ( + "bytes" + "encoding/json" + "strings" + "time" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + + "lazyntfy/internal/alert" + "lazyntfy/internal/config" + "lazyntfy/internal/history" + "lazyntfy/internal/ntfy" + "lazyntfy/internal/store" + "lazyntfy/internal/textutil" +) + +type focus int + +const ( + focusTopics focus = iota + focusMessages + focusDetail +) + +type mode int + +const ( + modeNormal mode = iota + modeFilter + modePublish + modeAdd + modeHelp +) + +// Model is the Bubble Tea root model. +type Model struct { + app config.App + subs []config.Sub + notices <-chan ntfy.Notice + client *ntfy.Client + mgr *ntfy.Manager + hist *history.DB + store *store.Store + selected map[string]string + up map[string]bool + + width int + height int + focus focus + mode mode + + topicIdx int + detailTop int + filter string + raw bool + status string + restored int + savedTopic string + filterInput textinput.Model + addInput textinput.Model + form [4]textinput.Model + formIdx int +} + +// New builds a model. notices, client, mgr, and hist may be nil in tests. +func New(app config.App, notices <-chan ntfy.Notice, client *ntfy.Client, mgr *ntfy.Manager, hist *history.DB) Model { + status := "connecting" + focusPane := focusMessages + if app.StartupStatus != "" { + status = app.StartupStatus + } + if len(app.Subs) == 0 { + focusPane = focusTopics + if app.StartupStatus == "" { + status = "no topics yet — press a to add one" + } + } + m := Model{ + app: app, + subs: append([]config.Sub(nil), app.Subs...), + notices: notices, + client: client, + mgr: mgr, + hist: hist, + store: store.New(), + selected: map[string]string{}, + up: map[string]bool{}, + status: status, + focus: focusPane, + } + m.filterInput = newField("", "filter") + m.filterInput.CharLimit = 200 + m.addInput = newField("", "topic or host/topic") + m.addInput.CharLimit = 200 + placeholders := [4]string{"optional title", "3", "comma separated", "message"} + for i, placeholder := range placeholders { + m.form[i] = newField("", placeholder) + m.form[i].CharLimit = 256 + } + m.form[3].CharLimit = 4000 + m.resizeInputs() + if hist != nil { + for _, sub := range m.subs { + msgs, err := hist.Messages(sub.Name, 0) + if err != nil { + continue + } + for _, msg := range msgs { + m.store.Put(msg) + m.restored++ + } + } + if name, ok := hist.State("topic"); ok && name != "" { + m.savedTopic = name + m = m.focusTopic(name) + } + } + return m +} + +func newField(prompt, placeholder string) textinput.Model { + ti := textinput.New() + ti.Prompt = prompt + ti.Placeholder = placeholder + ti.SetWidth(48) + return ti +} + +// Init starts the subscription listener. +func (m Model) Init() tea.Cmd { + if m.notices == nil { + return nil + } + return waitNotice(m.notices) +} + +func waitNotice(ch <-chan ntfy.Notice) tea.Cmd { + return func() tea.Msg { + n, ok := <-ch + if !ok { + return nil + } + return n + } +} + +// Update handles input, stream notices, and publish results. +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.resizeInputs() + return m, nil + case ntfy.Notice: + m = m.apply(msg) + return m, waitNotice(m.notices) + case publishDone: + if msg.err != nil { + m.status = msg.err.Error() + } else { + m.status = "published" + } + return m, nil + case openDone: + if msg.err != nil { + m.status = msg.err.Error() + } else { + m.status = "opened" + } + return m, nil + case tea.KeyPressMsg: + if keyString(msg) == "ctrl+c" { + return m, tea.Quit + } + switch m.mode { + case modeFilter: + return m.updateFilter(msg) + case modePublish: + return m.updatePublish(msg) + case modeAdd: + return m.updateAdd(msg) + case modeHelp: + m.mode = modeNormal + return m, nil + default: + return m.updateNormal(msg) + } + } + return m, nil +} + +func (m Model) updateNormal(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + switch keyString(msg) { + case "q": + return m, tea.Quit + case "j", "down": + return m.move(1), nil + case "k", "up": + return m.move(-1), nil + case "ctrl+d", "pgdown": + return m.move(m.page()), nil + case "ctrl+u", "pgup": + return m.move(-m.page()), nil + case "g", "home": + return m.moveEdge(true), nil + case "G", "end", "shift+g": + return m.moveEdge(false), nil + case "tab": + m.focus = (m.focus + 1) % 3 + return m, nil + case "shift+tab": + m.focus = (m.focus + 2) % 3 + return m, nil + case "1": + m.focus = focusTopics + return m, nil + case "2": + m.focus = focusMessages + return m, nil + case "3": + m.focus = focusDetail + return m, nil + case "enter": + switch m.focus { + case focusTopics: + m.focus = focusMessages + case focusMessages: + m.focus = focusDetail + default: + m.focus = focusMessages + } + return m, nil + case "/": + m.mode = modeFilter + m.filterInput.SetValue(m.filter) + return m, m.filterInput.Focus() + case "esc": + if m.filter != "" { + m.filter = "" + m.filterInput.SetValue("") + return m.ensureSelection(), nil + } + if m.focus == focusDetail { + m.focus = focusMessages + } + return m, nil + case "r": + if m.mgr != nil { + m.mgr.Reload() + } + m.status = "reconnecting" + return m, nil + case "p": + return m.beginPublish() + case "a": + return m.beginAdd() + case "v": + m.raw = !m.raw + m.detailTop = 0 + return m, nil + case "?": + m.mode = modeHelp + return m, nil + case "o": + return m.openClick() + default: + return m, nil + } +} + +func (m Model) updateFilter(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + switch keyString(msg) { + case "enter": + m.mode = modeNormal + m.filter = m.filterInput.Value() + m.filterInput.Blur() + return m.ensureSelection(), nil + case "esc": + m.mode = modeNormal + m.filter = "" + m.filterInput.SetValue("") + m.filterInput.Blur() + return m.ensureSelection(), nil + default: + var cmd tea.Cmd + m.filterInput, cmd = m.filterInput.Update(msg) + m.filter = m.filterInput.Value() + return m.ensureSelection(), cmd + } +} + +func (m Model) updateAdd(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + switch keyString(msg) { + case "enter": + return m.submitAdd(), nil + case "esc": + m.mode = modeNormal + m.addInput.Blur() + return m, nil + default: + var cmd tea.Cmd + m.addInput, cmd = m.addInput.Update(msg) + return m, cmd + } +} + +func (m Model) updatePublish(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + switch keyString(msg) { + case "esc": + m.mode = modeNormal + m.blurForm() + return m, nil + case "tab", "down": + return m.formStep(1) + case "shift+tab", "up": + return m.formStep(-1) + case "enter": + if m.formIdx < len(m.form)-1 { + return m.formStep(1) + } + return m.submitPublish() + default: + var cmd tea.Cmd + m.form[m.formIdx], cmd = m.form[m.formIdx].Update(msg) + return m, cmd + } +} + +func (m Model) formStep(delta int) (tea.Model, tea.Cmd) { + m.form[m.formIdx].Blur() + m.formIdx += delta + if m.formIdx < 0 { + m.formIdx = len(m.form) - 1 + } + if m.formIdx >= len(m.form) { + m.formIdx = 0 + } + return m, m.form[m.formIdx].Focus() +} + +func (m Model) beginPublish() (Model, tea.Cmd) { + if _, ok := m.currentSub(); !ok { + m.status = "no topic to publish to" + return m, nil + } + if m.client == nil { + m.status = "publish unavailable" + return m, nil + } + m.mode = modePublish + m.formIdx = 0 + for i := range m.form { + m.form[i].SetValue("") + m.form[i].Blur() + } + return m, m.form[0].Focus() +} + +func (m Model) submitPublish() (Model, tea.Cmd) { + sub, ok := m.currentSub() + if !ok { + m.status = "no topic to publish to" + m.mode = modeNormal + return m, nil + } + priority, err := ntfy.ParsePriority(m.form[1].Value()) + if err != nil { + m.status = err.Error() + return m, nil + } + message := strings.TrimSpace(m.form[3].Value()) + if message == "" { + m.status = "message is empty" + return m, nil + } + req := ntfy.PublishRequest{ + Title: strings.TrimSpace(m.form[0].Value()), + Priority: priority, + Tags: ntfy.ParseTags(m.form[2].Value()), + Message: message, + } + m.mode = modeNormal + m.blurForm() + m.status = "publishing" + return m, publishCmd(m.client, sub, req) +} + +func (m Model) beginAdd() (Model, tea.Cmd) { + m.mode = modeAdd + m.addInput.SetValue("") + return m, m.addInput.Focus() +} + +func (m Model) submitAdd() Model { + m.addInput.Blur() + m.mode = modeNormal + origin, name, err := config.Resolve(m.addInput.Value(), m.app.DefaultHost) + if err != nil { + m.status = err.Error() + return m + } + for _, sub := range m.subs { + if sub.Origin == origin && sub.Name == name { + m.status = name + " already open" + return m.focusTopic(name) + } + } + sub := config.Sub{Origin: origin, Name: name, Auth: m.app.Auth} + m.subs = append(m.subs, sub) + if m.mgr != nil { + m.mgr.Add(sub) + } + if m.hist != nil { + _ = m.hist.SaveTopic(config.Sub{Origin: origin, Name: name}) + } + m.status = "subscribed to " + name + return m.focusTopic(name) +} + +func (m Model) focusTopic(name string) Model { + names := m.topicNames() + for i, n := range names { + if n == name { + m.topicIdx = i + break + } + } + m.detailTop = 0 + return m.ensureSelection().persistTopic() +} + +func (m Model) blurForm() { + for i := range m.form { + m.form[i].Blur() + } +} + +func (m Model) openClick() (Model, tea.Cmd) { + msg, ok := m.selectedMessage() + if !ok || msg.Click == "" { + m.status = "no click URL" + return m, nil + } + return m, openCmd(msg.Click) +} + +func (m Model) apply(n ntfy.Notice) Model { + if n.UpSet { + for _, topic := range n.Topics { + for _, part := range strings.Split(topic, ",") { + part = strings.TrimSpace(part) + if part != "" { + m.up[part] = n.Up + } + } + } + } + if n.Status != "" { + m.status = n.Status + } + if n.Event != nil { + switch n.Event.Event { + case "message": + msg := toMessage(*n.Event) + m.store.Put(msg) + m.remember(msg) + case "message_delete": + m.store.Delete(n.Event.ID) + if m.hist != nil { + _ = m.hist.DeleteMessage(n.Event.ID) + } + case "message_clear": + m.store.Clear(n.Event.Topic) + if m.hist != nil { + _ = m.hist.ClearTopic(n.Event.Topic) + } + } + } + return m.ensureSelection() +} + +func toMessage(ev ntfy.Event) store.Message { + msg := store.Message{ + ID: ev.ID, + Time: ev.Time, + Expires: ev.Expires, + Topic: ev.Topic, + Title: ev.Title, + Body: ev.Message, + Tags: ev.Tags, + Priority: ev.Priority, + Click: ev.Click, + } + if ev.Attachment != nil { + msg.Attachment = &store.Attachment{ + Name: ev.Attachment.Name, + URL: ev.Attachment.URL, + Type: ev.Attachment.Type, + Size: ev.Attachment.Size, + } + } + if len(ev.Actions) > 0 { + var buf bytes.Buffer + if err := json.Indent(&buf, ev.Actions, "", " "); err == nil { + msg.Actions = buf.String() + } else { + msg.Actions = string(ev.Actions) + } + } + return msg +} + +func (m Model) originFor(topic string) string { + for _, sub := range m.subs { + if sub.Name == topic { + return sub.Origin + } + } + return "" +} + +func (m Model) remember(msg store.Message) { + if m.hist == nil || msg.ID == "" || msg.Topic == "" { + return + } + display := "" + for _, sub := range m.subs { + if sub.Name == msg.Topic { + display = sub.Display + break + } + } + _ = m.hist.SaveTopic(config.Sub{Origin: m.originFor(msg.Topic), Name: msg.Topic, Display: display}) + _ = m.hist.SaveMessage(m.originFor(msg.Topic), msg) +} + +func (m Model) persistTopic() Model { + if m.hist == nil { + return m + } + name := m.currentName() + if name == "" || name == m.savedTopic { + return m + } + if err := m.hist.SetState("topic", name); err == nil { + m.savedTopic = name + } + return m +} + +func (m Model) move(delta int) Model { + switch m.focus { + case focusTopics: + m.topicIdx += delta + m.clampTopic() + m.detailTop = 0 + return m.ensureSelection().persistTopic() + case focusDetail: + m.detailTop += delta + return m.clampDetail() + default: + msgs := m.filtered() + if len(msgs) == 0 { + return m + } + name := m.currentName() + idx := indexOf(msgs, m.selected[name]) + delta + if idx < 0 { + idx = 0 + } + if idx >= len(msgs) { + idx = len(msgs) - 1 + } + m.selected[name] = msgs[idx].ID + m.detailTop = 0 + return m + } +} + +func (m Model) moveEdge(top bool) Model { + switch m.focus { + case focusTopics: + if top { + m.topicIdx = 0 + } else { + m.topicIdx = len(m.topicNames()) - 1 + } + m.clampTopic() + m.detailTop = 0 + return m.ensureSelection().persistTopic() + case focusDetail: + if top { + m.detailTop = 0 + return m + } + m.detailTop = 1 << 30 + return m.clampDetail() + default: + msgs := m.filtered() + if len(msgs) == 0 { + return m + } + idx := 0 + if !top { + idx = len(msgs) - 1 + } + m.selected[m.currentName()] = msgs[idx].ID + m.detailTop = 0 + return m + } +} + +func (m Model) page() int { + n := m.bodyRows() - 1 + if n < 1 { + return 1 + } + return n +} + +func (m Model) resizeInputs() { + w := m.width / 2 + if w < 20 { + w = 20 + } + if w > 80 { + w = 80 + } + m.filterInput.SetWidth(w) + m.addInput.SetWidth(w) + for i := range m.form { + m.form[i].SetWidth(w) + } +} + +func keyString(msg tea.KeyPressMsg) string { + if s := msg.String(); s != "" { + return s + } + return msg.Text +} + +func formatTime(unix int64) string { + if unix == 0 { + return "?" + } + return time.Unix(unix, 0).Local().Format("2006-01-02 15:04:05") +} + +func formatShort(unix int64) string { + if unix == 0 { + return " ? " + } + return time.Unix(unix, 0).Local().Format("01-02 15:04") +} + +func priLabel(p int) string { + switch p { + case 1: + return "min" + case 2: + return "low" + case 3: + return "def" + case 4: + return "high" + case 5: + return "max" + default: + return "·" + } +} + +func indexOf(msgs []store.Message, id string) int { + for i, msg := range msgs { + if msg.ID == id { + return i + } + } + return 0 +} + +func (m Model) topicNames() []string { + seen := map[string]bool{} + var names []string + for _, sub := range m.subs { + if sub.Name == "" || seen[sub.Name] { + continue + } + seen[sub.Name] = true + names = append(names, sub.Name) + } + for _, topic := range m.store.Topics() { + if topic == "" || seen[topic] { + continue + } + seen[topic] = true + names = append(names, topic) + } + return names +} + +func (m Model) clampTopic() Model { + n := len(m.topicNames()) + if n == 0 { + m.topicIdx = 0 + return m + } + if m.topicIdx < 0 { + m.topicIdx = 0 + } + if m.topicIdx >= n { + m.topicIdx = n - 1 + } + return m +} + +func (m Model) currentName() string { + names := m.topicNames() + if len(names) == 0 { + return "" + } + m = m.clampTopic() + return names[m.topicIdx] +} + +func (m Model) currentSub() (config.Sub, bool) { + name := m.currentName() + for _, sub := range m.subs { + if sub.Name == name { + return sub, true + } + } + return config.Sub{}, false +} + +func (m Model) filtered() []store.Message { + var out []store.Message + for _, msg := range m.store.List(m.currentName()) { + title, _ := alert.TitleSeverity(msg.Title, msg.Body) + if store.Match(msg, title, m.filter) { + out = append(out, msg) + } + } + return out +} + +func (m Model) ensureSelection() Model { + msgs := m.filtered() + name := m.currentName() + if name == "" || len(msgs) == 0 { + delete(m.selected, name) + return m + } + id := m.selected[name] + for _, msg := range msgs { + if msg.ID == id { + return m + } + } + m.selected[name] = msgs[0].ID + m.detailTop = 0 + return m +} + +func (m Model) selectedMessage() (store.Message, bool) { + id := m.selected[m.currentName()] + if id == "" { + return store.Message{}, false + } + for _, msg := range m.filtered() { + if msg.ID == id { + return msg, true + } + } + return store.Message{}, false +} + +func (m Model) bodyRows() int { + n := m.height - 4 + if n < 1 { + return 1 + } + return n +} + +func (m Model) topicLabel(name string) string { + for _, sub := range m.subs { + if sub.Name == name { + return sub.Label() + } + } + return name +} + +func (m Model) clampDetail() Model { + _, _, _, _, _, dw := m.layout() + width := dw - 1 + if width < 8 { + width = 8 + } + visible := m.bodyRows() + if visible < 1 { + visible = 1 + } + maxTop := len(m.detailBlocks(width)) - visible + if maxTop < 0 { + maxTop = 0 + } + if m.detailTop < 0 { + m.detailTop = 0 + } + if m.detailTop > maxTop { + m.detailTop = maxTop + } + return m +} + +func formatListTime(unix int64) string { + if unix == 0 { + return "—" + } + t := time.Unix(unix, 0).Local() + now := time.Now() + if t.Year() == now.Year() && t.YearDay() == now.YearDay() { + return t.Format("15:04") + } + if t.Year() == now.Year() { + return t.Format("Jan 02") + } + return t.Format("2006-01-02") +} + +func wrapText(s string, width int) []string { + if strings.TrimSpace(s) == "" { + return []string{""} + } + return textutil.Wrap(s, width) +} diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go new file mode 100644 index 0000000..a98f897 --- /dev/null +++ b/internal/tui/model_test.go @@ -0,0 +1,139 @@ +package tui + +import ( + "path/filepath" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + + "lazyntfy/internal/config" + "lazyntfy/internal/history" + "lazyntfy/internal/ntfy" + "lazyntfy/internal/store" +) + +func sample() Model { + m := New(config.App{ + DefaultHost: "https://ntfy.example", + Auth: config.Auth{Token: "tk_test"}, + Subs: []config.Sub{{Origin: "https://ntfy.example", Name: "alerts", Auth: config.Auth{Token: "tk_test"}}}, + }, nil, ntfy.NewClient(), nil, nil) + m.width = 120 + m.height = 30 + m.store.Put(store.Message{ID: "new", Time: 200, Topic: "alerts", Title: "DiskFull", Body: `{"status":"firing","commonLabels":{"alertname":"DiskFull","severity":"critical"}}`}) + m.store.Put(store.Message{ID: "old", Time: 100, Topic: "alerts", Body: "backup finished"}) + m = m.ensureSelection() + return m +} + +func TestViewShowsNewestAlert(t *testing.T) { + view := sample().render() + if !strings.Contains(view, "lazyntfy") || !strings.Contains(view, "DiskFull") || !strings.Contains(view, "firing") { + t.Fatalf("%s", view) + } + lines := strings.Split(view, "\n") + if len(lines) != 30 { + t.Fatalf("height %d", len(lines)) + } +} + +func TestMoveKeepsSelectionByID(t *testing.T) { + m := sample() + if m.selected["alerts"] != "new" { + t.Fatalf("selected %s", m.selected["alerts"]) + } + m = m.move(1) + if m.selected["alerts"] != "old" { + t.Fatalf("after down %s", m.selected["alerts"]) + } + next, cmd := m.Update(tea.KeyPressMsg{Text: "q", Code: 'q'}) + if cmd == nil { + t.Fatal("expected quit") + } + if next.(Model).selected["alerts"] != "old" { + t.Fatal("quit changed selection") + } +} + +func TestFilterAndDelete(t *testing.T) { + m := sample() + m.filter = "backup" + m = m.ensureSelection() + if m.selected["alerts"] != "old" { + t.Fatalf("filter selected %s", m.selected["alerts"]) + } + if !strings.Contains(m.render(), "backup finished") { + t.Fatal(m.render()) + } + m = m.apply(ntfy.Notice{Event: &ntfy.Event{ID: "old", Event: "message_delete", Topic: "alerts"}}) + if len(m.store.List("alerts")) != 1 { + t.Fatal("delete") + } + m = m.apply(ntfy.Notice{Event: &ntfy.Event{Event: "message_clear", Topic: "alerts"}}) + if len(m.store.List("alerts")) != 0 { + t.Fatal("clear") + } +} + +func TestPublishRequiresMessage(t *testing.T) { + m := sample() + m.mode = modePublish + m.formIdx = 3 + got, _ := m.updatePublish(tea.KeyPressMsg{Text: "enter", Code: tea.KeyEnter}) + if got.(Model).status != "message is empty" { + t.Fatalf("status %q", got.(Model).status) + } +} + +func TestAddTopicUsesDefaultHost(t *testing.T) { + m := sample() + m.addInput.SetValue("backups") + m = m.submitAdd() + if len(m.subs) != 2 || m.subs[1].Name != "backups" || m.subs[1].Origin != "https://ntfy.example" { + t.Fatalf("%+v", m.subs) + } + if m.subs[1].Auth.Token != "tk_test" { + t.Fatal("auth not copied") + } +} + +func TestRejectsNonHTTPClick(t *testing.T) { + if err := validateURL("file:///etc/passwd"); err == nil { + t.Fatal("expected reject") + } + if err := validateURL("https://example.com/a"); err != nil { + t.Fatal(err) + } +} + +func TestRestoresMessagesAndTopicFromHistory(t *testing.T) { + db, err := history.Open(filepath.Join(t.TempDir(), "history.db")) + if err != nil { + t.Fatal(err) + } + defer db.Close() + if err := db.SaveMessage("https://ntfy.example", store.Message{ + ID: "h1", Topic: "alerts", Time: 50, Title: "from disk", + }); err != nil { + t.Fatal(err) + } + if err := db.SetState("topic", "alerts"); err != nil { + t.Fatal(err) + } + + m := New(config.App{ + DefaultHost: "https://ntfy.example", + Subs: []config.Sub{{Origin: "https://ntfy.example", Name: "alerts"}}, + }, nil, ntfy.NewClient(), nil, db) + if m.restored != 1 || len(m.store.List("alerts")) != 1 { + t.Fatalf("restored %d messages %d", m.restored, len(m.store.List("alerts"))) + } + if m.savedTopic != "alerts" || m.currentName() != "alerts" { + t.Fatalf("saved %q current %q", m.savedTopic, m.currentName()) + } + m.width, m.height = 120, 30 + if !strings.Contains(m.render(), "from disk") { + t.Fatalf("restored message missing from view:\n%s", m.render()) + } +} diff --git a/internal/tui/url.go b/internal/tui/url.go new file mode 100644 index 0000000..850d6e2 --- /dev/null +++ b/internal/tui/url.go @@ -0,0 +1,14 @@ +package tui + +import ( + "fmt" + "net/url" +) + +func validateURL(raw string) error { + u, err := url.Parse(raw) + if err != nil || u.Host == "" || (u.Scheme != "https" && u.Scheme != "http") { + return fmt.Errorf("only http(s) links can be opened") + } + return nil +} diff --git a/internal/tui/view.go b/internal/tui/view.go new file mode 100644 index 0000000..eebf519 --- /dev/null +++ b/internal/tui/view.go @@ -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 +)