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, " · ") }