package tui import ( "fmt" "strings" "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/glamour" "github.com/lerko/nib/internal/db" "github.com/lerko/nib/internal/display" ) type detailMode int const ( detailPreview detailMode = iota detailRun detailFill ) type detailModel struct { entity *db.Entity backlinks []db.Backlink scroll int height int width int mode detailMode run runModel fill fillModel } func newDetailModel() detailModel { return detailModel{} } func (d *detailModel) setEntity(e *db.Entity) { d.entity = e d.backlinks = nil d.scroll = 0 d.mode = detailPreview } func (d *detailModel) setSize(width, height int) { d.width = width d.height = height } func (d detailModel) update(msg tea.KeyMsg) (detailModel, tea.Cmd) { switch d.mode { case detailRun: d.run = d.run.update(msg.String()) return d, nil case detailFill: var cmd tea.Cmd d.fill, cmd = d.fill.update(msg) return d, cmd default: switch msg.String() { case "up", "k": if d.scroll > 0 { d.scroll-- } case "down", "j": d.scroll++ case "pgdown", "ctrl+d": d.scroll += d.height case "pgup", "ctrl+u": d.scroll -= d.height if d.scroll < 0 { d.scroll = 0 } case "home", "g": d.scroll = 0 case "end", "G": d.scroll = 1<<31 - 1 } return d, nil } } func (d detailModel) view(width int) string { switch d.mode { case detailRun: return d.run.view(width) case detailFill: return d.fill.view(width) default: return d.previewView(width) } } func (d detailModel) previewView(width int) string { if d.entity == nil { return "" } e := d.entity var b strings.Builder glyph := display.DisplayGlyph(e.Glyph, e.CardType) header := fmt.Sprintf("%s %s", glyph, display.FormatID(e.ID)) if e.CardType != nil { header += " " + affordanceStyle.Render(string(*e.CardType)) } b.WriteString(detailHeaderStyle.Render(header)) b.WriteString("\n\n") if e.Title != nil { b.WriteString(detailBodyStyle.Render("title: " + *e.Title)) b.WriteString("\n") } bodyWidth := width - 4 if bodyWidth < 20 { bodyWidth = 20 } r, _ := glamour.NewTermRenderer( glamour.WithStylePath(glamourStyle()), glamour.WithWordWrap(bodyWidth), ) rendered, err := r.Render(e.Body) if err != nil { rendered = e.Body } rendered = strings.TrimRight(rendered, "\n") b.WriteString(detailBodyStyle.Render(rendered)) b.WriteString("\n") if e.CardType != nil { cardSection := renderCardData(e) if cardSection != "" { b.WriteString("\n") b.WriteString(cardSection) } } if len(e.Tags) > 0 { tagParts := make([]string, len(e.Tags)) for i, t := range e.Tags { tagParts[i] = tagStyle.Render("#" + t) } b.WriteString("\n") b.WriteString(detailBodyStyle.Render(strings.Join(tagParts, " "))) b.WriteString("\n") } if len(d.backlinks) > 0 { b.WriteString("\n") b.WriteString(detailLabelStyle.Render(" ← backlinks")) b.WriteString("\n") for _, bl := range d.backlinks { label := bl.Body if bl.Title != nil { label = *bl.Title } else if len(label) > 40 { label = label[:40] + "…" } line := " " + backlinkStyle.Render(label) if bl.LinkText != "" { line += " " + hintDescStyle.Render("(as \""+bl.LinkText+"\")") } b.WriteString(line + "\n") } } b.WriteString("\n") meta := fmt.Sprintf("created %s", e.CreatedAt.Format(time.DateTime)) if e.ModifiedAt != e.CreatedAt { meta += fmt.Sprintf("\nmodified %s", e.ModifiedAt.Format(time.DateTime)) } if e.TimeAnchor != nil { meta += fmt.Sprintf("\nanchored @%s", *e.TimeAnchor) } if e.Pinned { meta += "\n" + pinnedStyle.Render("pinned") } if e.CardType != nil { meta += fmt.Sprintf("\ncard %s", *e.CardType) } if e.UseCount > 0 { meta += fmt.Sprintf("\nused %d×", e.UseCount) } if e.CompletedAt != nil { meta += fmt.Sprintf("\ndone %s", e.CompletedAt.Format(time.DateTime)) } b.WriteString(idStyle.Render(meta)) lines := strings.Split(b.String(), "\n") totalLines := len(lines) maxScroll := totalLines - d.height if maxScroll < 0 { maxScroll = 0 } scroll := d.scroll if scroll > maxScroll { scroll = maxScroll } if totalLines > d.height && d.height > 0 && len(lines) > 0 { indicator := idStyle.Render(fmt.Sprintf(" %d/%d", scroll+1, totalLines)) lines[0] = lines[0] + indicator } if scroll > 0 && scroll < totalLines { lines = lines[scroll:] } if d.height > 0 && len(lines) > d.height { lines = lines[:d.height] } return strings.Join(lines, "\n") } func renderCardData(e *db.Entity) string { if e.CardData == nil { return "" } data, err := e.CardDataJSON() if err != nil || data == nil { return "" } var b strings.Builder switch *e.CardType { case db.CardChecklist: steps, ok := data["steps"].([]interface{}) if !ok { break } done := 0 for _, s := range steps { step, ok := s.(map[string]interface{}) if !ok { continue } text, _ := step["text"].(string) isDone, _ := step["done"].(bool) if isDone { done++ b.WriteString(" " + checkDoneStyle.Render("[✓] "+text) + "\n") } else { b.WriteString(" " + checkPendingStyle.Render("[ ] "+text) + "\n") } } progress := fmt.Sprintf(" %d/%d steps", done, len(steps)) b.WriteString(detailLabelStyle.Render(progress)) b.WriteString(" " + helpStyle.Render("r:run")) case db.CardTemplate: slots, ok := data["slots"].([]interface{}) if !ok { break } b.WriteString(detailLabelStyle.Render(" slots:") + "\n") for _, s := range slots { slot, ok := s.(map[string]interface{}) if !ok { continue } name, _ := slot["name"].(string) def, _ := slot["default"].(string) line := " ${" + name + "}" if def != "" { line += " " + detailValueStyle.Render("default: "+def) } b.WriteString(line + "\n") } b.WriteString(" " + helpStyle.Render("f:fill")) case db.CardDecision: if chose, ok := data["chose"].(string); ok && chose != "" { b.WriteString(" " + detailLabelStyle.Render("chose: ") + detailValueStyle.Render(chose) + "\n") } if why, ok := data["why"].(string); ok && why != "" { b.WriteString(" " + detailLabelStyle.Render("why: ") + detailValueStyle.Render(why) + "\n") } if rejected, ok := data["rejected"].([]interface{}); ok && len(rejected) > 0 { items := make([]string, 0, len(rejected)) for _, r := range rejected { if s, ok := r.(string); ok { items = append(items, s) } } if len(items) > 0 { b.WriteString(" " + detailLabelStyle.Render("rejected: ") + detailValueStyle.Render(strings.Join(items, ", ")) + "\n") } } case db.CardLink: if url, ok := data["url"].(string); ok && url != "" { b.WriteString(" " + detailLabelStyle.Render("↗ ") + detailValueStyle.Render(url) + "\n") } } return b.String() }