Topics, messages, and message detail in three panes, with filtering, publishing, adding topics, a raw JSON view, history restore, and open-link support.
845 lines
16 KiB
Go
845 lines
16 KiB
Go
// 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)
|
|
}
|