package tui
import (
"fmt"
"os"
"path/filepath"
"strings"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"intake/internal/tags"
)
func (m Module) field(label, value string, width int) string {
inner := width - 4
if inner < 20 {
inner = 20
}
const labelW = 14
key := m.st.Label.Render(fmt.Sprintf(" %-*s ", labelW, label))
room := inner - labelW - 2
if room < 4 {
room = 4
}
line := key + m.st.Value.Render(truncateTo(value, room))
if displayWidth(line) > inner {
line = truncateTo(line, inner)
}
return line
}
func (m Module) stepHeader(title, blurb string) string {
return " " + m.st.Title.Render(title) + "\n" +
" " + m.st.Muted.Render(truncateTo(blurb, m.width-4)) + "\n"
}
func (m Module) viewReview() string {
switch m.step {
case StepMetadata:
return m.stepMetadata()
case StepTracks:
return m.stepTracks()
case StepCover:
return m.stepCover()
case StepConfirm:
return m.stepConfirm()
}
return ""
}
func (m Module) stepMetadata() string {
var b strings.Builder
b.WriteString(m.stepHeader("Metadata", "what intake read from the tags, and what it will write"))
b.WriteString("\n")
meta := strings.Join([]string{
m.field("album artist", orDash(m.pl.AlbumArt), m.width),
m.field("album", orDash(m.pl.AlbumName), m.width),
m.field("year", orDash(m.pl.Year), m.width),
m.field("tracks", fmt.Sprintf("%d", len(m.pl.Tracks)), m.width),
}, "\n")
b.WriteString(panelWith(m.st, "", meta, m.width, true))
b.WriteString("\n\n")
if m.existing != nil {
e := m.existing
what := "you already have this album"
if host, err := os.Hostname(); err == nil && host == "" {
_ = host
}
if !e.SamePath {
what = "you already have an album with this artist and name"
}
where := fmt.Sprintf("%s - %s\n%s", e.AlbumArtist, e.Album, e.ArchiveDir)
b.WriteString(" " + m.st.Danger.Render("⚠ "+what) + "\n")
b.WriteString(" " + m.st.Muted.Render(truncateTo(where, m.width-6)) + "\n")
choice := "keep both: file this one beside it as \"... (2)\""
switch m.collision {
case "replace":
choice = "replace it: the album already there is removed once this one has landed"
case "both":
}
b.WriteString(" " + m.st.Warning.Render(choice) + "\n")
b.WriteString(" " + m.st.Muted.Render("k keep both · r replace · s skip this album") + "\n\n")
}
if m.pl.VariousArt {
b.WriteString(" " + m.st.Warning.Render("compilation") + " " +
m.st.Muted.Render("per-track artists differ, so this files under various-artists/") + "\n\n")
}
dest := strings.Join([]string{
m.field("archive", orDash(m.pl.ArchiveDir), m.width),
m.field("library", orDash(m.pl.LibraryDir), m.width),
}, "\n")
b.WriteString(panelWith(m.st, "", dest, m.width, false))
b.WriteString("\n\n")
var tagLines []string
if len(m.pl.Dropped) > 0 {
tagLines = append(tagLines, m.st.Label.Render(" removing ")+
m.st.Warning.Render(truncateTo(strings.Join(m.pl.Dropped, ", "), m.width-16)))
} else {
tagLines = append(tagLines, m.st.Label.Render(" removing ")+
m.st.Muted.Render("nothing, no extra tags present"))
}
tagLines = append(tagLines, m.st.Label.Render(" keeping ")+
m.st.Muted.Render(truncateTo(strings.Join(tags.Keep, ", "), m.width-16)))
b.WriteString(strings.Join(tagLines, "\n"))
b.WriteString("\n")
b.WriteString(m.issueBlock())
return b.String()
}
func (m Module) stepTracks() string {
var b strings.Builder
b.WriteString(m.stepHeader("Track names", "the filenames intake will write into the archive and library"))
b.WriteString("\n")
const numW = 6
titleW := m.width / 4
if titleW < 12 {
titleW = 12
}
if titleW > 34 {
titleW = 34
}
fileW := m.width - numW - titleW - 6
if fileW < 10 {
fileW = 10
}
head := fmt.Sprintf(" %-*s %-*s %s", numW, "#", titleW, "title", "filename")
b.WriteString(m.st.Label.Render(truncateTo(head, m.width)) + "\n")
b.WriteString(m.st.Rule.Render(" "+strings.Repeat("─", maxInt(1, m.width-2))) + "\n")
width := 2
if m.mode() >= modeCompact {
width = 3
}
for _, t := range m.pl.Tracks {
title := t.Title
if title == "" {
title = "(no title)"
}
num := fmt.Sprintf("%d-%02d", t.Disc, t.Number)
if len(m.pl.Tracks) >= 100 {
num = fmt.Sprintf("%d-%03d", t.Disc, t.Number)
}
line := fmt.Sprintf(" %s %s %s",
m.st.Muted.Render(fmt.Sprintf("%-*s", numW, num)),
m.st.Value.Render(fmt.Sprintf("%-*s", titleW, truncateTo(title, titleW))),
m.st.Accent.Render(truncateTo(t.Target, fileW+width)),
)
b.WriteString(truncateTo(line, m.width) + "\n")
}
if len(m.pl.Tracks) == 0 {
b.WriteString(" " + m.st.Muted.Render("no tracks") + "\n")
}
b.WriteString("\n")
b.WriteString(m.issueBlock())
return b.String()
}
func (m Module) stepCover() string {
var b strings.Builder
b.WriteString(m.stepHeader("Cover art", "pick an image, or accept the placeholder and replace it later"))
b.WriteString("\n")
if m.hasCover {
b.WriteString(panelWith(m.st, "", strings.Join([]string{
m.field("source", m.cover.Source, m.width),
m.field("image", m.cover.Label, m.width),
m.field("size", fmt.Sprintf("%dx%d, %s", m.cover.Width, m.cover.Height, humanBytes(m.cover.Bytes)), m.width),
}, "\n"), m.width, true))
b.WriteString("\n\n" + " " + m.st.Success.Render("cover selected") + "\n")
} else {
b.WriteString(panelWith(m.st, "", strings.Join([]string{
m.field("cover", "not chosen yet", m.width),
}, "\n"), m.width, true))
if !m.coversFetched {
b.WriteString("\n\n " + m.st.Muted.Render("Press enter to look for cover art.") + "\n")
} else {
b.WriteString("\n\n " + m.st.Muted.Render("Press enter to choose from the candidates found.") + "\n")
}
}
b.WriteString("\n" + " " + m.st.Muted.Render("Press enter to continue.") + "\n")
return b.String()
}
func (m Module) stepConfirm() string {
var b strings.Builder
b.WriteString(m.stepHeader("Confirm", "this is the point of no return for the staging folder"))
b.WriteString("\n")
cover := "none"
switch {
case m.hasCover && m.cover.Placeholder:
cover = "placeholder (recorded so you can replace it later)"
case m.hasCover:
cover = fmt.Sprintf("%s (%dx%d)", m.cover.Source, m.cover.Width, m.cover.Height)
}
rows := strings.Join([]string{
m.field("album", orDash(m.pl.AlbumName), m.width),
m.field("artist", orDash(m.pl.AlbumArt), m.width),
m.field("tracks", fmt.Sprintf("%d", len(m.pl.Tracks)), m.width),
m.field("cover", cover, m.width),
m.field("archive", orDash(m.pl.ArchiveDir), m.width),
m.field("library", orDash(m.pl.LibraryDir), m.width),
m.field("staging", "removed after the archive verifies", m.width),
}, "\n")
b.WriteString(panelWith(m.st, "", rows, m.width, true))
b.WriteString("\n\n")
b.WriteString(" " + m.st.Success.Render("Press enter to file this album.") + "\n")
b.WriteString(" " + m.st.Muted.Render("Press esc to go back.") + "\n")
return b.String()
}
func (m Module) issueBlock() string {
if len(m.pl.Warnings) == 0 && len(m.pl.Issues) == 0 {
return " " + m.st.Dim.Render("no warnings") + "\n"
}
var b strings.Builder
for _, i := range m.pl.Issues {
if i.Hard {
b.WriteString(" " + m.st.Danger.Render("✗ ") + m.st.Danger.Render(truncateTo(i.Text, m.width-6)) + "\n")
} else {
b.WriteString(" " + m.st.Warning.Render("! ") + truncateTo(i.Text, m.width-6) + "\n")
}
}
for _, w := range m.pl.Warnings {
b.WriteString(" " + m.st.Warning.Render("! ") + m.st.Muted.Render(truncateTo(w, m.width-6)) + "\n")
}
return b.String()
}
func (m Module) viewCoverPicker() string {
if len(m.covers) == 0 {
return " " + m.st.Muted.Render("no cover candidates found") + "\n" +
" " + m.st.Muted.Render("press esc to go back and continue without one")
}
area := m.body()
listW := m.width / 3
if listW < 34 {
listW = 34
}
previewW := m.width - listW - 2
if previewW < 20 {
return m.coverList(m.width-2, area.H, true)
}
previewH := area.H - 1
if previewH < 5 {
previewH = 5
}
left := m.coverList(listW, previewH, false)
right := m.coverPreview(previewW, previewH)
left = clamp(fitHeight(left, previewH), listW, previewH)
right = clamp(fitHeight(right, previewH), previewW, previewH)
return lipgloss.JoinHorizontal(lipgloss.Top, left, " ", right)
}
func (m Module) coverList(width, height int, withDetail bool) string {
per := 2
if withDetail {
per = 3
}
rows := height - 1
if rows < per {
rows = per
}
capacity := rows / per
if capacity < 1 {
capacity = 1
}
off := 0
if m.coverCursor >= capacity {
off = m.coverCursor - capacity + 1
}
end := off + capacity
if end > len(m.covers) {
end = len(m.covers)
}
var b strings.Builder
head := "COVERS"
if len(m.covers) > capacity {
head = fmt.Sprintf("COVERS %d/%d", m.coverCursor+1, len(m.covers))
}
b.WriteString(" " + m.st.Heading.Render(head) + "\n")
for i := off; i < end; i++ {
c := m.covers[i]
name := string(c.Source)
if i == m.coverCursor {
b.WriteString(m.st.Marker.Render("\u258e ") + m.st.Selected.Render(" "+truncateTo(name, width-7)+" ") + "\n")
} else {
b.WriteString(" " + m.st.Value.Render(truncateTo(name, width-5)) + "\n")
}
detail := fmt.Sprintf("%dx%d \u00b7 %s", c.Width, c.Height, humanBytes(c.Bytes))
if withDetail && c.Label != "" {
detail += " \u00b7 " + c.Label
}
b.WriteString(" " + m.st.Muted.Render(truncateTo(detail, width-6)) + "\n")
}
if end < len(m.covers) {
b.WriteString(m.st.Dim.Render(fmt.Sprintf(" \u2026 %d more", len(m.covers)-end)))
}
return b.String()
}
func (m *Module) placeCover() tea.Cmd {
place, ok := m.prev.TakePlacement()
if !ok {
if m.placed == nil {
return nil
}
return drawImage(*m.placed)
}
place.id, place.placement = baseImageID, basePlacementID
m.placed = &place
return drawImage(place)
}
func (m Module) coverPreview(width, height int) string {
c := m.covers[m.coverCursor]
var b strings.Builder
b.WriteString(" " + m.st.Heading.Render("PREVIEW") + " " +
m.st.Dim.Render("("+m.prev.Mode()+")") + "\n")
if !m.prev.Available() {
b.WriteString(" " + m.st.Muted.Render("no image renderer available") + "\n")
b.WriteString(" " + m.st.Dim.Render("install chafa, or use a graphics-capable terminal") + "\n")
} else {
lines := m.prev.Render(c.Path, width-2, height-6)
if len(lines) == 0 {
b.WriteString(" " + m.st.Muted.Render("preview unavailable for this image") + "\n")
}
for _, l := range lines {
// don't truncate these, cutting a row unbinds the cells past the cut
if strings.ContainsRune(l, placeholderRune) {
b.WriteString(" " + l + "\n")
continue
}
b.WriteString(" " + truncateTo(l, width-2) + "\n")
}
}
b.WriteString("\n")
b.WriteString(" " + m.st.Value.Render(truncateTo(c.Label, width-2)) + "\n")
b.WriteString(" " + m.st.Muted.Render(fmt.Sprintf("%dx%d · %s", c.Width, c.Height, humanBytes(c.Bytes))) + "\n")
if c.Path != "" {
b.WriteString(" " + m.st.Dim.Render(truncateTo(filepath.Base(c.Path), width-2)) + "\n")
}
return b.String()
}
func (m Module) viewWorking() string {
var b strings.Builder
b.WriteString(" " + m.st.Title.Render("Filing "+m.current.Name) + "\n\n")
b.WriteString(" " + m.spin.View() + " " + m.st.Muted.Render(m.status) + "\n\n")
for _, line := range m.log {
b.WriteString(" " + m.st.Muted.Render(truncateTo(line, m.width-4)) + "\n")
}
if len(m.log) == 0 {
b.WriteString(" " + m.st.Dim.Render("the pipeline reports as it goes") + "\n")
}
return b.String()
}
// viewBatchDone is the summary of an unattended run: what was filed, what was
// parked, and what went wrong. The parked ones are the point -- they are the
// albums that still need a person, and they are listed by name so the next step
// is obvious.
func (m Module) viewBatchDone() string {
rep := m.batch
var b strings.Builder
head := fmt.Sprintf("filed %d album%s", len(rep.Filed), plural(len(rep.Filed)))
b.WriteString(" " + m.st.Success.Render("✓ "+head) + "\n\n")
rows := []string{
m.field("filed", fmt.Sprintf("%d", len(rep.Filed)), m.width),
m.field("need you", fmt.Sprintf("%d", len(rep.Parked)), m.width),
m.field("failed", fmt.Sprintf("%d", len(rep.Failed)), m.width),
}
b.WriteString(panelWith(m.st, "", strings.Join(rows, "\n"), m.width, true))
b.WriteString("\n\n")
if len(rep.Parked) > 0 {
b.WriteString(" " + m.st.Warning.Render("These need a decision; nothing was written for them:") + "\n")
for _, p := range rep.Parked {
b.WriteString(" " + m.st.Muted.Render("• "+truncateTo(p, m.width-8)) + "\n")
}
b.WriteString("\n " + m.st.Muted.Render("Press n on the album list to jump to the next one.") + "\n\n")
}
if len(rep.Failed) > 0 {
b.WriteString(" " + m.st.Danger.Render("These failed and may need looking at:") + "\n")
for _, f := range rep.Failed {
b.WriteString(" " + m.st.Muted.Render("• "+truncateTo(f, m.width-8)) + "\n")
}
b.WriteString("\n")
}
b.WriteString(" " + m.st.Muted.Render("Press enter to go back to the album list.") + "\n")
return b.String()
}
func (m Module) viewDone() string {
if m.batch != nil {
return m.viewBatchDone()
}
var b strings.Builder
b.WriteString(" " + m.st.Success.Render("✓ filed") + " " + m.st.Title.Render(m.current.Name) + "\n\n")
rows := []string{
m.field("archive", orDash(m.outcome.ArchiveDir), m.width),
}
if m.outcome.Library {
rows = append(rows, m.field("library", orDash(m.outcome.LibraryDir), m.width))
} else {
rows = append(rows, m.field("library", "not built", m.width))
}
rows = append(rows, m.field("files", fmt.Sprintf("%d (%s)", m.outcome.Files, humanBytes(m.outcome.Bytes)), m.width))
switch {
case m.outcome.Placeholder:
rows = append(rows, m.field("cover", "placeholder", m.width))
case m.outcome.Cover != "":
rows = append(rows, m.field("cover", m.outcome.Cover, m.width))
default:
rows = append(rows, m.field("cover", "none", m.width))
}
b.WriteString(panelWith(m.st, "", strings.Join(rows, "\n"), m.width, true))
b.WriteString("\n\n")
if m.outcome.Placeholder {
b.WriteString(" " + m.st.Warning.Render("This album has placeholder art.") + "\n")
b.WriteString(" " + m.st.Muted.Render("It will be listed by ") + m.st.Code.Render("intake library verify") + "\n")
b.WriteString(" " + m.st.Muted.Render("and can be replaced with ") + m.st.Code.Render("intake library re-cover") + "\n\n")
}
for _, w := range m.outcome.Warnings {
b.WriteString(" " + m.st.Warning.Render("! ") + m.st.Muted.Render(truncateTo(w, m.width-6)) + "\n")
}
b.WriteString(" " + m.st.Muted.Render("Press enter to pick the next album.") + "\n")
return b.String()
}
func orDash(v string) string {
if strings.TrimSpace(v) == "" {
return "—"
}
return v
}
func humanBytes(n int64) string {
const unit = 1024
if n < unit {
return fmt.Sprintf("%d B", n)
}
div, exp := int64(unit), 0
for v := n / unit; v >= unit; v /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %cB", float64(n)/float64(div), "KMGT"[exp])
}
// viewJournal lists what intake remembers and what is actually on disk beside it,
// because the two disagreeing is the thing worth seeing: a record whose archive
// has gone, and an album in the archive that no record names, both look fine in
// every other screen.
func (m Module) viewJournal() string {
if len(m.journal) == 0 {
return " " + m.st.Muted.Render("the journal is empty: nothing has been filed yet") + "\n"
}
rows := m.body().H
if rows < 6 {
rows = 6
}
per := 2
if m.journalMenu >= 0 {
per = 2 + len(m.journalActions())
}
capacity := (rows - 2) / per
if capacity < 1 {
capacity = 1
}
off := m.journalOffset
if m.journalCursor < off {
off = m.journalCursor
}
if m.journalCursor >= off+capacity {
off = m.journalCursor - capacity + 1
}
var b strings.Builder
head := fmt.Sprintf("JOURNAL %d", len(m.journal))
if m.journalMenu >= 0 {
head = "WHAT SHOULD HAPPEN TO THIS RECORD?"
}
b.WriteString(" " + m.st.Heading.Render(head) + "\n")
end := off + capacity
if end > len(m.journal) {
end = len(m.journal)
}
for i := off; i < end; i++ {
e := m.journal[i]
selected := i == m.journalCursor
title := strings.TrimSpace(e.AlbumArtist + " - " + e.Album)
if title == "" || title == "-" {
title = filepath.Base(e.ArchiveDir)
}
flags := m.journalFlags(e)
if selected {
b.WriteString(m.st.Marker.Render("\u258e ") + m.st.Selected.Render(" "+truncateTo(title, m.width-8)+" ") + "\n")
} else {
b.WriteString(" " + m.st.Value.Render(truncateTo(title, m.width-4)) + "\n")
}
detail := m.st.Dim.Render(" " + truncateTo(e.Phase+flags, m.width-6))
b.WriteString(detail + "\n")
if !selected || m.journalMenu < 0 {
continue
}
for j, a := range m.journalActions() {
marker := " "
style := m.st.Muted
if j == m.journalAction {
marker = m.st.Marker.Render("\u258e ")
style = m.st.Selected
}
label := a.label
if a.confirm != "" && m.journalConfirm && j == m.journalAction {
label = a.confirm
}
b.WriteString(marker + style.Render(" "+truncateTo(label, m.width-6)+" ") + "\n")
}
}
return b.String()
}
func (m Module) journalFlags(e JournalEntry) string {
var flags []string
switch {
case e.Unrecorded:
flags = append(flags, "in the archive, no record")
case e.Missing:
flags = append(flags, "archive missing")
}
if e.Placeholder {
flags = append(flags, "placeholder cover")
}
if !e.Library && !e.Unrecorded {
flags = append(flags, "no library copy")
}
if len(flags) == 0 {
flags = append(flags, "on disk, complete")
}
if e.Tracks > 0 {
flags = append(flags, fmt.Sprintf("%d track%s", e.Tracks, plural(e.Tracks)))
}
return " · " + strings.Join(flags, " · ")
}