package tui
import (
"context"
"fmt"
"path/filepath"
"sort"
"strings"
"time"
"charm.land/bubbles/v2/spinner"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"intake/internal/config"
"intake/internal/plan"
"intake/internal/scan"
"intake/internal/sheet"
)
type Screen int
const (
ScreenBrowse Screen = iota
ScreenJournal
ScreenReview
ScreenCoverPicker
ScreenWorking
ScreenDone
)
type Step int
const (
StepMetadata Step = iota
StepTracks
StepCover
StepConfirm
stepCount
)
func (s Step) String() string {
switch s {
case StepMetadata:
return "metadata"
case StepTracks:
return "track names"
case StepCover:
return "cover art"
case StepConfirm:
return "confirm"
}
return "?"
}
type Options struct {
Config config.Config
StagingDir string
Prepare func(ctx context.Context, dir string) (plan.Plan, error)
Run func(ctx context.Context, album scan.Album, progress func(string)) (Outcome, error)
CoverOptions func(ctx context.Context, p plan.Plan) ([]CoverOption, error)
ApplyCover func(p *plan.Plan, choice CoverOption) error
Journal func(ctx context.Context) ([]JournalEntry, error)
ForgetSource func(source string) error
UnfileSource func(source string, progress func(string)) error
Batch func(ctx context.Context, progress func(string)) (BatchReport, error)
Attention func(ctx context.Context) (map[string]string, error)
Existing func(ctx context.Context, p plan.Plan) (Existing, bool)
Collide func(albumDir, mode string)
}
// Existing is an album intake already has, found before this one is filed.
type Existing struct {
ArchiveDir string
LibraryDir string
AlbumArtist string
Album string
Tracks int
SamePath bool
}
// BatchReport is what an unattended run did, for the summary at the end.
type BatchReport struct {
Filed []string
Parked []string
Failed []string
}
// JournalEntry is one line of the journal screen: what intake remembers about an
// album, and what is actually on disk.
type JournalEntry struct {
Source string
AlbumArtist string
Album string
Phase string
ArchiveDir string
LibraryDir string
Tracks int
Placeholder bool
Library bool
Missing bool
Unrecorded bool
}
// journalAction is one thing that can be done to a record.
type journalAction struct {
key string
label string
run func(m *Module, e JournalEntry) tea.Cmd
// confirm says the action changes something on disk and needs a second key.
confirm string
}
type Outcome struct {
ArchiveDir string
LibraryDir string
Files int
Bytes int64
Cover string
Placeholder bool
Library bool
Warnings []string
}
type CoverOption struct {
Path string
Source string
Label string
Width, Height int
Bytes int64
Placeholder bool
}
type Module struct {
opts Options
st Styles
prev *ImagePreviewer
screen Screen
step Step
albums []scan.Album
cursor int
filter string
filtering bool
offset int
current scan.Album
pl plan.Plan
cover CoverOption
hasCover bool
covers []CoverOption
coverCursor int
coversFetched bool
placed *kittyPlacement
attention map[string]string
existing *Existing
collision string
// restored is set when the album's saved edits were applied on opening it.
restored bool
batch *BatchReport
journal []JournalEntry
journalCursor int
journalOffset int
// journalMenu is -1 while the list itself has the keys, and otherwise the
// index of the action being chosen for the selected record.
journalMenu int
journalAction int
// journalConfirm is set while an action that moves files waits for a second
// enter.
journalConfirm bool
sheetPath string
sheetBefore *sheet.Sheet
events chan tea.Msg
log []string
follow bool
spin spinner.Model
outcome Outcome
width, height int
status string
err error
ready bool
cancel context.CancelFunc
scroll int
// helpOpen is the '?' overlay. It is a flag rather than a Screen because
// the screen underneath keeps all of its state, so closing the overlay
// puts the user back exactly where they were.
helpOpen bool
}
type albumsMsg struct {
albums []scan.Album
err error
}
type planMsg struct {
album scan.Album
plan plan.Plan
err error
}
type coversMsg struct {
covers []CoverOption
err error
}
type journalMsg struct {
entries []JournalEntry
err error
}
type journalDoneMsg struct {
note string
err error
}
func (m Module) journalCmd() tea.Cmd {
get := m.opts.Journal
return func() tea.Msg {
entries, err := get(context.Background())
return journalMsg{entries: entries, err: err}
}
}
type existingMsg struct {
existing Existing
found bool
}
func (m Module) existingCmd(p plan.Plan) tea.Cmd {
get := m.opts.Existing
return func() tea.Msg {
e, ok := get(context.Background(), p)
return existingMsg{existing: e, found: ok}
}
}
type attentionMsg struct {
reasons map[string]string
err error
}
func (m Module) attentionCmd() tea.Cmd {
get := m.opts.Attention
return func() tea.Msg {
reasons, err := get(context.Background())
return attentionMsg{reasons: reasons, err: err}
}
}
type doneMsg struct {
outcome Outcome
err error
// batch, when set, means the working screen was filing a whole staging
// directory rather than one album.
batch *BatchReport
}
type progressMsg string
type replantMsg struct{}
func waitForProgress(ch chan tea.Msg) tea.Cmd {
return func() tea.Msg {
msg, ok := <-ch
if !ok {
return nil
}
return msg
}
}
func New(opts Options) Module {
st := DefaultStyles()
sp := spinner.New(spinner.WithSpinner(spinner.Dot), spinner.WithStyle(st.ListSpinner))
m := Module{
opts: opts,
st: st,
prev: NewImagePreviewer(),
spin: sp,
status: "reading staging",
// -1 means the journal list itself has the keys; 0 would mean the first
// action menu is open before anything asked for it.
journalMenu: -1,
}
return m
}
func (m Module) Init() tea.Cmd {
return tea.Batch(m.spin.Tick, m.scanCmd())
}
func (m Module) scanCmd() tea.Cmd {
dir := m.opts.StagingDir
return func() tea.Msg {
albums, err := scan.StagingWith(context.Background(), dir, scan.Options{Probe: false})
return albumsMsg{albums: albums, err: err}
}
}
func (m Module) planCmd(a scan.Album) tea.Cmd {
prepare := m.opts.Prepare
return func() tea.Msg {
p, err := prepare(context.Background(), a.Dir)
return planMsg{album: a, plan: p, err: err}
}
}
func (m Module) coversCmd(p plan.Plan) tea.Cmd {
get := m.opts.CoverOptions
return func() tea.Msg {
covers, err := get(context.Background(), p)
return coversMsg{covers: covers, err: err}
}
}
func (m Module) runCmd(ctx context.Context, a scan.Album) tea.Cmd {
run := m.opts.Run
events := m.events
return func() tea.Msg {
go func() {
defer close(events)
outcome, err := run(ctx, a, func(line string) {
select {
case events <- progressMsg(line):
case <-ctx.Done():
}
})
select {
case events <- doneMsg{outcome: outcome, err: err}:
case <-ctx.Done():
}
}()
msg, ok := <-events
if !ok {
return nil
}
return msg
}
}
func (m Module) batchCmd(ctx context.Context) tea.Cmd {
run, events := m.opts.Batch, m.events
return func() tea.Msg {
go func() {
defer close(events)
rep, err := run(ctx, func(line string) {
select {
case events <- progressMsg(line):
case <-ctx.Done():
}
})
select {
case events <- doneMsg{err: err, batch: &rep}:
case <-ctx.Done():
}
}()
msg, ok := <-events
if !ok {
return nil
}
return msg
}
}
func (m Module) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
return m, nil
case albumsMsg:
m.ready = true
if msg.err != nil {
m.err = msg.err
m.status = "could not read staging"
return m, nil
}
sort.Slice(msg.albums, func(i, j int) bool { return msg.albums[i].Name < msg.albums[j].Name })
m.albums = msg.albums
m.cursor, m.offset = 0, 0
m.status = fmt.Sprintf("%d album(s) staged", len(m.albums))
if m.opts.Attention != nil {
return m, m.attentionCmd()
}
return m, nil
case planMsg:
if msg.err != nil {
m.err, m.status = msg.err, msg.err.Error()
return m, nil
}
clear := m.clearPlacedCover()
m.resetAlbum()
m.current, m.pl = msg.album, msg.plan
m.screen, m.step, m.scroll = ScreenReview, StepMetadata, 0
m.status = fmt.Sprintf("%d track%s read", len(msg.plan.Tracks), plural(len(msg.plan.Tracks)))
if m.restoreSavedEdits() {
m.status = "restored the edits you saved for this album; press D to discard them"
}
if m.opts.Existing != nil {
return m, tea.Batch(clear, m.existingCmd(msg.plan))
}
return m, clear
case coversMsg:
m.coversFetched = true
if msg.err != nil {
m.status = msg.err.Error()
return m, nil
}
m.covers = msg.covers
m.coverCursor = 0
if len(m.covers) == 0 {
m.status = "no cover candidates found; continuing without one"
m.screen = ScreenReview
m.step = StepConfirm
return m, nil
}
m.screen = ScreenCoverPicker
m.scroll = 0
m.status = fmt.Sprintf("%d candidate%s", len(m.covers), plural(len(m.covers)))
// Prepare every candidate in the background before the user starts
// moving through them, so a render rarely has to stage one itself.
paths := make([]string, 0, len(m.covers))
for _, c := range m.covers {
paths = append(paths, c.Path)
}
m.prev.Warm(paths)
m.renderCoverPreview(m.coverPreviewWidth(), m.coverPreviewHeight())
return m, tea.Batch(m.placeCover(), replantTick())
case existingMsg:
if msg.found {
e := msg.existing
m.existing = &e
// Keeping both is the default, because nothing else can lose an
// album, and the other two are one keypress away.
m.collision = "both"
m.status = "you already have this album"
return m, nil
}
m.existing, m.collision = nil, ""
return m, nil
case attentionMsg:
if msg.err == nil {
m.attention = msg.reasons
}
return m, nil
case doneMsg:
if msg.err != nil {
m.err, m.status = msg.err, msg.err.Error()
if msg.batch == nil {
m.screen = ScreenReview
return m, nil
}
}
if msg.batch == nil {
// The album is filed, so the edits it was built from have done their
// job; leaving them would apply last time's corrections to a fresh
// folder of the same name.
m.sheetStore().Delete(m.current.Dir)
m.restored = false
}
m.outcome, m.batch = msg.outcome, msg.batch
m.screen = ScreenDone
m.scroll = 0
switch {
case msg.batch != nil:
m.status = fmt.Sprintf("%d filed, %d parked, %d failed", len(msg.batch.Filed), len(msg.batch.Parked), len(msg.batch.Failed))
default:
m.status = "filed"
}
return m, nil
case journalMsg:
m.ready = true
if msg.err != nil {
m.status = msg.err.Error()
return m, nil
}
m.journal = msg.entries
m.journalCursor, m.journalOffset, m.journalMenu = 0, 0, -1
m.journalConfirm = false
m.screen = ScreenJournal
m.status = fmt.Sprintf("%d record%s", len(m.journal), plural(len(m.journal)))
return m, nil
case journalDoneMsg:
if msg.err != nil {
m.status = msg.err.Error()
return m, m.journalCmd()
}
m.status = msg.note
return m, m.journalCmd()
case editorFinishedMsg:
if msg.err != nil {
m.status = "editor exited with an error: " + msg.err.Error()
return m, nil
}
m.applyEditedSheet(msg)
return m, nil
case progressMsg:
m.log = append(m.log, string(msg))
if len(m.log) > 500 {
m.log = m.log[len(m.log)-500:]
}
if m.follow {
// applyScroll clamps this to the last screenful, so the line that
// just arrived is the one on screen.
m.scroll = 1 << 30
}
return m, waitForProgress(m.events)
case replantMsg:
if m.screen != ScreenCoverPicker || m.placed == nil {
return m, nil
}
return m, tea.Tick(replantInterval, func(time.Time) tea.Msg { return replantMsg{} })
case spinner.TickMsg:
var cmd tea.Cmd
m.spin, cmd = m.spin.Update(msg)
return m, cmd
case tea.KeyPressMsg:
return m.handleKey(msg)
}
return m, nil
}
func (m Module) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
key := msg.String()
if key == "ctrl+c" {
return m.quit()
}
// The overlay takes every key but the ones that close it, so nothing on
// the screen underneath fires by accident while somebody is reading it.
if m.helpOpen {
switch key {
case "?", "esc":
m.helpOpen = false
}
return m, nil
}
if key == "?" {
m.helpOpen = true
return m, nil
}
switch m.screen {
case ScreenBrowse:
return m.keyBrowse(key, msg)
case ScreenJournal:
return m.keyJournal(key)
case ScreenReview:
return m.keyReview(key)
case ScreenCoverPicker:
return m.keyCover(key)
case ScreenWorking:
switch key {
case "q", "esc":
return m.quit()
case "up", "k":
m.follow = false
if m.scroll > 0 {
m.scroll--
}
case "down", "j":
m.follow = true
m.scroll++
case "pgup":
m.follow = false
m.scroll -= m.body().H - 1
if m.scroll < 0 {
m.scroll = 0
}
case "pgdown":
m.scroll += m.body().H - 1
case "home", "g":
m.scroll = 0
}
return m, nil
case ScreenDone:
switch key {
case "q":
return m.quit()
case "up", "k":
if m.scroll > 0 {
m.scroll--
}
return m, nil
case "down", "j":
m.scroll++
return m, nil
case "pgup":
m.scroll -= m.body().H - 1
if m.scroll < 0 {
m.scroll = 0
}
return m, nil
case "pgdown":
m.scroll += m.body().H - 1
return m, nil
case "enter", "esc":
m.screen = ScreenBrowse
m.scroll, m.batch = 0, nil
return m, m.scanCmd()
}
return m, nil
}
return m, nil
}
func (m Module) keyBrowse(key string, msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
if m.filtering {
switch key {
case "enter":
m.filtering = false
m.cursor, m.offset = 0, 0
return m, nil
case "esc":
m.filtering, m.filter = false, ""
m.cursor, m.offset = 0, 0
return m, nil
case "backspace":
if m.filter != "" {
m.filter = m.filter[:len(m.filter)-1]
}
m.cursor, m.offset = 0, 0
return m, nil
default:
if len(msg.Text) > 0 {
m.filter += msg.Text
m.cursor, m.offset = 0, 0
}
return m, nil
}
}
visible := m.visibleAlbums()
switch key {
case "q":
return m.quit()
case "/":
m.filtering = true
return m, nil
case "r":
m.status = "refreshing"
return m, m.scanCmd()
case "J":
if m.opts.Journal == nil {
m.status = "this build cannot read the journal"
return m, nil
}
m.status = "reading the journal"
return m, m.journalCmd()
case "n":
return m.jumpToAttention()
case "b":
if m.opts.Batch == nil {
m.status = "this build cannot file a batch"
return m, nil
}
ready := 0
for _, a := range m.visibleAlbums() {
if a.Status == scan.StatusReady {
ready++
}
}
if ready == 0 {
m.status = "nothing ready to file"
return m, nil
}
ctx, cancel := context.WithCancel(context.Background())
m.cancel = cancel
m.events = make(chan tea.Msg, 64)
m.screen, m.log, m.follow, m.batch = ScreenWorking, nil, true, nil
m.status = fmt.Sprintf("filing %d album(s) unattended", ready)
return m, tea.Batch(m.spin.Tick, m.batchCmd(ctx))
case "up", "k":
if m.cursor > 0 {
m.cursor--
}
case "down", "j":
if m.cursor < len(visible)-1 {
m.cursor++
}
case "home", "g":
m.cursor = 0
case "end", "G":
m.cursor = len(visible) - 1
case "enter":
if m.cursor < len(visible) {
m.status = "reading " + visible[m.cursor].Name
return m, m.planCmd(visible[m.cursor])
}
}
return m, nil
}
// jumpToAttention moves the cursor to the next album an unattended run parked.
func (m Module) jumpToAttention() (tea.Model, tea.Cmd) {
visible := m.visibleAlbums()
if len(visible) == 0 || len(m.attention) == 0 {
m.status = "no album is waiting on you"
return m, nil
}
for i := 1; i <= len(visible); i++ {
j := (m.cursor + i) % len(visible)
if _, ok := m.attention[visible[j].Dir]; ok {
m.cursor = j
m.status = m.attention[visible[j].Dir]
return m, nil
}
}
m.status = "no album is waiting on you"
return m, nil
}
func (m Module) keyReview(key string) (tea.Model, tea.Cmd) {
// An album that is already in the library takes the keys first: 'k' would
// otherwise scroll, which is exactly the kind of small collision that makes
// somebody press the wrong thing at the wrong moment.
if m.existing != nil && m.step == StepMetadata {
switch key {
case "k":
m.collision = "both"
m.status = "filing beside " + filepath.Base(m.existing.ArchiveDir)
return m, nil
case "r":
m.collision = "replace"
m.status = "will replace " + filepath.Base(m.existing.ArchiveDir)
return m, nil
case "s":
m.screen, m.existing, m.collision = ScreenBrowse, nil, ""
m.status = "left it alone; you already have it"
return m, m.scanCmd()
}
}
switch key {
case "up", "k":
if m.scroll > 0 {
m.scroll--
}
return m, nil
case "down", "j":
m.scroll++
return m, nil
case "pgup":
m.scroll -= m.body().H - 1
if m.scroll < 0 {
m.scroll = 0
}
return m, nil
case "pgdown":
m.scroll += m.body().H - 1
return m, nil
case "home", "g":
m.scroll = 0
return m, nil
case "G":
m.scroll = 1 << 20
return m, nil
}
switch key {
case "q":
return m.quit()
case "esc":
if m.step > StepMetadata {
m.step--
m.scroll = 0
return m, nil
}
m.screen = ScreenBrowse
return m, nil
case "e":
return m.openSheetEditor()
case "D":
if m.restored {
m.sheetStore().Delete(m.current.Dir)
m.restored = false
m.status = "discarded the saved edits; reopen the album to start from the tags again"
return m, nil
}
case "enter":
return m.nextStep()
}
return m, nil
}
func (m Module) nextStep() (tea.Model, tea.Cmd) {
switch m.step {
case StepMetadata:
if m.pl.Blocked() {
m.status = "cannot continue: " + firstIssue(m.pl)
return m, nil
}
m.step, m.scroll = StepTracks, 0
return m, nil
case StepTracks:
m.step, m.scroll = StepCover, 0
return m, nil
case StepCover:
if m.opts.CoverOptions == nil {
m.step, m.scroll = StepConfirm, 0
return m, nil
}
if !m.coversFetched {
m.status = "finding cover art"
return m, m.coversCmd(m.pl)
}
if len(m.covers) == 0 {
m.step, m.scroll = StepConfirm, 0
return m, nil
}
m.screen, m.scroll = ScreenCoverPicker, 0
return m, nil
case StepConfirm:
if m.opts.Collide != nil && m.collision != "" {
m.opts.Collide(m.current.Dir, m.collision)
}
ctx, cancel := context.WithCancel(context.Background())
m.cancel = cancel
m.events = make(chan tea.Msg, 64)
m.screen, m.log = ScreenWorking, nil
m.follow = true
m.status = "working"
return m, tea.Batch(m.spin.Tick, m.runCmd(ctx, m.current))
}
return m, nil
}
func (m Module) keyCover(key string) (tea.Model, tea.Cmd) {
switch key {
case "q":
return m.quit()
case "e":
// The whole album stays editable while a cover is being chosen: this is
// where a wrong album artist is usually noticed.
return m.openSheetEditor()
case "esc":
m.screen = ScreenReview
m.scroll = 0
return m, m.clearPlacedCover()
case "up", "k":
if m.coverCursor > 0 {
m.coverCursor--
m.scroll = 0
m.renderCoverPreview(m.coverPreviewWidth(), m.coverPreviewHeight())
return m, m.placeCover()
}
return m, nil
case "down", "j":
if m.coverCursor < len(m.covers)-1 {
m.coverCursor++
m.scroll = 0
m.renderCoverPreview(m.coverPreviewWidth(), m.coverPreviewHeight())
return m, m.placeCover()
}
return m, nil
case "enter":
if m.coverCursor < len(m.covers) {
picked := m.covers[m.coverCursor]
if m.opts.ApplyCover != nil {
if err := m.opts.ApplyCover(&m.pl, picked); err != nil {
m.status = err.Error()
return m, nil
}
}
m.cover, m.hasCover = picked, true
}
m.screen, m.step, m.scroll = ScreenReview, StepConfirm, 0
return m, m.clearPlacedCover()
}
return m, nil
}
const replantInterval = 90 * time.Millisecond
func replantTick() tea.Cmd {
return tea.Tick(replantInterval, func(time.Time) tea.Msg { return replantMsg{} })
}
func (m *Module) renderCoverPreview(width, height int) {
if m.coverCursor < 0 || m.coverCursor >= len(m.covers) {
return
}
m.prev.Render(m.covers[m.coverCursor].Path, width, height)
}
func (m *Module) clearPlacedCover() tea.Cmd {
if m.placed == nil {
return nil
}
cmd := clearImage(*m.placed)
m.placed = nil
return cmd
}
// resetAlbum drops the review state carried over from the previous album.
//
// Only screen, step and scroll used to be reset when a new album opened, so a
// second album inherited the first one's chosen cover and its candidate list --
// and coversFetched stayed true, meaning the new album never fetched its own
// candidates at all and the picker offered up the previous album's art. Nothing
// downstream would catch that: the cover is a valid image, so every verification
// step passes and the album archives with the wrong sleeve.
func (m *Module) resetAlbum() {
m.cover, m.hasCover = CoverOption{}, false
m.covers, m.coverCursor, m.coversFetched = nil, 0, false
m.placed = nil
m.sheetPath, m.sheetBefore = "", nil
m.journalMenu, m.journalAction, m.journalConfirm = -1, 0, false
m.batch, m.existing, m.collision, m.restored = nil, nil, "", false
m.log, m.outcome = nil, Outcome{}
m.err, m.status = nil, ""
m.scroll, m.offset = 0, 0
}
func (m Module) quit() (tea.Model, tea.Cmd) {
if m.cancel != nil {
m.cancel()
}
return m, tea.Quit
}
func firstIssue(p plan.Plan) string {
for _, i := range p.Issues {
if i.Hard {
return i.Text
}
}
return "unknown issue"
}
func (m Module) visibleAlbums() []scan.Album {
if m.filter == "" {
return m.albums
}
q := strings.ToLower(m.filter)
var out []scan.Album
for _, a := range m.albums {
if strings.Contains(strings.ToLower(a.Name), q) {
out = append(out, a)
}
}
return out
}
func (m Module) View() tea.View {
v := tea.NewView(m.render())
v.AltScreen = true
if m.mode() == modeTiny {
v.AltScreen = false
v.MouseMode = tea.MouseModeNone
}
return v
}
func (m Module) render() string {
if m.mode() == modeTiny {
return m.renderTiny()
}
header := m.renderHeader()
footer := m.renderFooter()
bodyH := m.body().H
var body string
if m.helpOpen {
body = m.helpOverlay(bodyH)
} else {
body = m.renderBody()
body = m.applyScroll(body, m.width, bodyH)
body = clamp(fitHeight(body, bodyH), m.width, bodyH)
}
parts := []string{header}
if m.railShown() {
parts = append(parts, m.renderRail())
}
parts = append(parts, body, footer)
return lipgloss.JoinVertical(lipgloss.Left, parts...)
}
func (m Module) renderHeader() string {
left := m.st.AppName.Render("intake")
var crumbs []string
switch m.screen {
case ScreenBrowse:
crumbs = append(crumbs, m.st.CrumbDim.Render("staging"))
case ScreenJournal:
crumbs = append(crumbs, m.st.CrumbDim.Render("journal"))
case ScreenReview, ScreenCoverPicker, ScreenWorking, ScreenDone:
crumbs = append(crumbs, m.st.CrumbDim.Render("staging"))
if m.current.Name != "" {
crumbs = append(crumbs, m.st.CrumbDim.Render("›"))
crumbs = append(crumbs, m.st.Crumb.Render(m.current.Name))
}
if m.screen == ScreenReview {
crumbs = append(crumbs, m.st.CrumbDim.Render("›"))
crumbs = append(crumbs, m.st.Accent.Render(m.step.String()))
}
}
right := ""
if n := len(m.albums); n > 0 && m.screen == ScreenBrowse {
right = m.st.Status.Render(fmt.Sprintf("%d album%s", n, plural(n)))
}
mid := strings.Join(crumbs, " ")
if mid == "" {
mid = m.st.CrumbDim.Render("library intake")
}
line := left + m.st.Dim.Render(" │ ") + mid
if right != "" {
pad := m.width - displayWidth(line) - displayWidth(right) - 1
if pad < 1 {
return clamp(line, m.width, 1)
}
line += strings.Repeat(" ", pad) + right + " "
}
return clamp(line, m.width, 1)
}
func (m Module) railShown() bool {
return m.screen == ScreenReview && m.mode() >= modeCompact
}
func (m Module) renderRail() string {
cells := make([]string, 0, stepCount)
for s := Step(0); s < stepCount; s++ {
label := s.String()
switch {
case s < m.step:
cells = append(cells, m.st.Success.Render("✓ "+label))
case s == m.step:
cells = append(cells, m.st.Selected.Render(" "+label+" "))
default:
cells = append(cells, m.st.Dim.Render(" "+label))
}
}
return " " + clamp(strings.Join(cells, m.st.Dim.Render(" · ")), m.width-1, 1)
}
func (m Module) renderFooter() string {
hints := m.footerHelp()
parts := make([]string, 0, len(hints))
for _, h := range hints {
parts = append(parts, m.st.HintKey.Render(h.key)+" "+m.st.HintDesc.Render(h.desc))
}
left := " " + strings.Join(parts, m.st.Dim.Render(" · "))
status := ""
if m.err != nil {
status = m.st.Error.Render(m.err.Error())
} else if m.status != "" {
status = m.st.Status.Render(m.status)
}
line := left
if status != "" {
gap := m.width - displayWidth(left) - displayWidth(status) - 2
if gap < 1 {
return clamp(left, m.width, 1)
}
line = left + strings.Repeat(" ", gap) + status + " "
}
return clamp(line, m.width, 1)
}
// helpKey is one row of the help overlay: a key, or a few keys that do the same
// thing written as "a / b", and the one line that says what it does.
type helpKey struct {
key string
desc string
}
// helpKeys is every key that does something on the screen that is open. The
// overlay draws the whole list and the footer picks a few rows out of it, so
// the two cannot end up describing the same key differently.
//
// Filtering, the journal action menu and the review screen's collision choices
// are states of their screen rather than screens of their own, so their keys
// are only here while those states are on.
func (m Module) helpKeys() []helpKey {
switch m.screen {
case ScreenBrowse:
if m.filtering {
return []helpKey{
{"enter", "apply filter"},
{"esc", "cancel filter"},
{"backspace", "delete a character"},
}
}
return []helpKey{
{"↑↓ / k j", "move"},
{"g / home", "first"},
{"G / end", "last"},
{"/", "filter"},
{"enter", "review"},
{"b", "file all"},
{"n", "next that needs you"},
{"J", "journal"},
{"r", "refresh"},
{"q", "quit"},
}
case ScreenJournal:
if m.journalMenu >= 0 {
confirm := "do it"
if m.journalConfirm {
confirm = "confirm"
}
return []helpKey{
{"↑↓ / k j", "choose"},
{"enter", confirm},
{"esc / q", "close the menu"},
}
}
return []helpKey{
{"↑↓ / k j", "move"},
{"g / home", "first"},
{"G / end", "last"},
{"enter", "actions"},
{"r", "refresh"},
{"esc", "back"},
{"q", "quit"},
}
case ScreenReview:
if m.existing != nil && m.step == StepMetadata {
// The collision choices take k and r before the scroll keys get a
// chance, so the arrow is the only way left to scroll up.
return []helpKey{
{"k", "keep both"},
{"r", "replace it"},
{"s", "skip this album"},
{"↑", "scroll up"},
{"↓ / j", "scroll down"},
{"pgup / pgdown", "scroll a page"},
{"g / home", "top"},
{"G", "bottom"},
{"enter", "continue"},
{"e", "edit album"},
{"esc", "back"},
{"q", "quit"},
}
}
keys := []helpKey{
{"↑↓ / k j", "scroll"},
{"pgup / pgdown", "scroll a page"},
{"g / home", "top"},
{"G", "bottom"},
{"enter", "continue"},
{"e", "edit album"},
}
// Only offered when there is something to discard: an album whose saved
// edits were applied when it was opened.
if m.restored {
keys = append(keys, helpKey{"D", "discard the edits saved for this album"})
}
return append(keys, helpKey{"esc", "back"}, helpKey{"q", "quit"})
case ScreenCoverPicker:
return []helpKey{
{"↑↓ / k j", "choose"},
{"enter", "use this cover"},
{"e", "edit album"},
{"esc", "cancel"},
{"q", "quit"},
}
case ScreenWorking:
return []helpKey{
{"↑ / k", "scroll up, stop following"},
{"↓ / j", "scroll down, follow"},
{"pgup / pgdown", "scroll a page"},
{"g / home", "top"},
{"esc / q", "stop watching"},
}
case ScreenDone:
return []helpKey{
{"↑↓ / k j", "scroll"},
{"pgup / pgdown", "scroll a page"},
{"enter / esc", "file another"},
{"q", "quit"},
}
}
return nil
}
// helpToggle is the key that opens and closes the overlay. The footer says what
// it does from outside the overlay; helpGlobal says what it does from inside.
func helpToggle() helpKey {
return helpKey{"?", "help"}
}
// helpGlobal is what works on every screen whatever else is open.
func helpGlobal() []helpKey {
return []helpKey{
{"? / esc", "close help"},
{"ctrl+c", "quit"},
}
}
// footerKeys names the rows from helpKeys that the footer has room for. The
// overlay is the reference; the footer is the reminder.
func (m Module) footerKeys() []string {
switch m.screen {
case ScreenBrowse:
if m.filtering {
return []string{"enter", "esc"}
}
return []string{"↑↓ / k j", "/", "enter", "b", "n", "J", "r", "q"}
case ScreenJournal:
if m.journalMenu >= 0 {
return []string{"↑↓ / k j", "enter", "esc / q"}
}
return []string{"↑↓ / k j", "enter", "r", "esc", "q"}
case ScreenReview:
if m.existing != nil && m.step == StepMetadata {
return []string{"k", "r", "s"}
}
return []string{"↑↓ / k j", "enter", "e", "esc", "q"}
case ScreenCoverPicker:
return []string{"↑↓ / k j", "enter", "e", "esc"}
case ScreenWorking:
return []string{"esc / q"}
case ScreenDone:
return []string{"enter / esc", "q"}
}
return nil
}
// footerHelp is the footer's half of the help. The descriptions come from
// helpKeys, so the footer cannot claim something about a key that the overlay
// does not. The toggle leads, because the footer is the only place the overlay
// is advertised and a footer that runs long is cut off at the right.
func (m Module) footerHelp() []helpKey {
keys := m.helpKeys()
hints := make([]helpKey, 0, len(keys)+1)
hints = append(hints, helpToggle())
for _, want := range m.footerKeys() {
for _, k := range keys {
if k.key == want {
hints = append(hints, k)
break
}
}
}
return hints
}
// helpTitle says which screen the keys belong to, since the overlay covers the
// header that would otherwise say it.
func (m Module) helpTitle() string {
switch m.screen {
case ScreenBrowse:
return "Help · staging"
case ScreenJournal:
return "Help · journal"
case ScreenReview:
return "Help · review"
case ScreenCoverPicker:
return "Help · cover art"
case ScreenWorking:
return "Help · filing"
case ScreenDone:
return "Help · done"
}
return "Help"
}
// helpOverlay draws the keys for the screen that is open over the body it
// covers. It is sized to that body, and a list too long for the terminal is cut
// off rather than allowed to push the layout past the screen. The rows that
// close the overlay and quit are the last to be dropped, because they are the
// only way out of a list that does not fit.
func (m Module) helpOverlay(height int) string {
screen := m.helpKeys()
globals := helpGlobal()
// The panel spends three lines on its borders and title before any key is
// drawn.
avail := height - 3
if avail < 1 {
avail = 1
}
for len(screen)+len(globals) > avail && len(screen) > 0 {
screen = screen[:len(screen)-1]
}
for len(screen)+len(globals) > avail && len(globals) > 0 {
globals = globals[:len(globals)-1]
}
// The rule between the screen's keys and the global ones is cosmetic, so it
// is only drawn when there is a line to spare.
rule := len(screen) > 0 && len(globals) > 0 && len(screen)+len(globals) < avail
rows := append(append([]helpKey{}, screen...), globals...)
keyW := 0
for _, r := range rows {
if w := displayWidth(r.key); w > keyW {
keyW = w
}
}
contentW := keyW
for _, r := range rows {
if w := keyW + 2 + displayWidth(r.desc); w > contentW {
contentW = w
}
}
if contentW < 1 {
contentW = 1
}
var b strings.Builder
row := func(r helpKey) {
b.WriteString(m.st.HintKey.Render(r.key + strings.Repeat(" ", keyW-displayWidth(r.key))))
b.WriteString(" " + m.st.HintDesc.Render(r.desc) + "\n")
}
for _, r := range screen {
row(r)
}
if rule {
b.WriteString(m.st.Rule.Render(strings.Repeat("─", contentW)) + "\n")
}
for _, r := range globals {
row(r)
}
panel := panelWith(m.st, m.helpTitle(), strings.TrimRight(b.String(), "\n"), m.width, true)
return lipgloss.Place(m.width, height, lipgloss.Center, lipgloss.Center, clamp(panel, m.width, height))
}
func (m Module) applyScroll(content string, width, height int) string {
lines := strings.Split(content, "\n")
for i, l := range lines {
lines[i] = truncateTo(l, width)
}
if len(lines) <= height {
return strings.Join(lines, "\n")
}
off := m.scroll
maxOff := len(lines) - height
if off > maxOff {
off = maxOff
}
if off < 0 {
off = 0
}
window := lines[off : off+height]
if height >= 3 {
if off > 0 {
window[0] = m.st.Dim.Render(" ↑ more")
}
if off+height < len(lines) {
window[len(window)-1] = m.st.Dim.Render(fmt.Sprintf(" ↓ more (%d%%)", (off+height)*100/len(lines)))
}
}
return strings.Join(window, "\n")
}
func (m Module) coverPreviewWidth() int {
area := m.body()
listW := m.width / 3
if listW < 34 {
listW = 34
}
w := m.width - listW - 2
if w < 20 {
return 0
}
_ = area
return w
}
func (m Module) coverPreviewHeight() int {
area := m.body()
h := area.H - 1
if h < 5 {
h = 5
}
return h
}
func (m Module) renderBody() string {
switch m.screen {
case ScreenBrowse:
return m.viewBrowse()
case ScreenJournal:
return m.viewJournal()
case ScreenReview:
return m.viewReview()
case ScreenCoverPicker:
return m.viewCoverPicker()
case ScreenWorking:
return m.viewWorking()
case ScreenDone:
return m.viewDone()
}
return ""
}
func (m Module) renderTiny() string {
if m.helpOpen {
return clamp(" intake · help: ? or esc closes", m.width, 1)
}
what := "staging"
switch m.screen {
case ScreenJournal:
what = "journal"
case ScreenReview:
what = fmt.Sprintf("%s · %s", m.current.Name, m.step)
case ScreenWorking:
what = "working: " + m.current.Name
case ScreenDone:
what = "done: " + m.current.Name
case ScreenCoverPicker:
what = "choose cover"
}
return clamp(" intake · "+what, m.width, 1)
}
func (m Module) viewBrowse() string {
if !m.ready {
return m.st.Muted.Render(" " + m.spin.View() + " reading staging ...")
}
if m.err != nil {
return m.st.Error.Render(" " + m.err.Error())
}
if len(m.albums) == 0 {
return m.emptyStaging()
}
visible := m.visibleAlbums()
if len(visible) == 0 {
return m.st.Muted.Render(" no album matches " + fmt.Sprintf("%q", m.filter))
}
area := m.body()
rows := area.H - 1
if rows < 1 {
rows = 1
}
perItem := 2
if m.mode() == modeMini {
perItem = 1
}
capacity := rows / perItem
if capacity < 1 {
capacity = 1
}
if m.cursor < m.offset {
m.offset = m.cursor
}
if m.cursor >= m.offset+capacity {
m.offset = m.cursor - capacity + 1
}
end := m.offset + capacity
if end > len(visible) {
end = len(visible)
}
var b strings.Builder
head := "ALBUMS"
if m.filtering || m.filter != "" {
head = "FILTER " + m.filter
}
b.WriteString(" " + m.st.Heading.Render(head) + "\n")
for i := m.offset; i < end; i++ {
a := visible[i]
selected := i == m.cursor
name := a.Name
if selected {
b.WriteString(m.st.Marker.Render(" ▸ ") + m.st.Selected.Render(" "+truncateTo(name, m.width-8)+" ") + "\n")
} else {
b.WriteString(" " + m.st.Value.Render(truncateTo(name, m.width-6)) + "\n")
}
if perItem == 2 {
desc := m.albumSummary(a)
if selected {
b.WriteString(m.st.SelectedDim.Render(" "+truncateTo(desc, m.width-8)) + "\n")
} else {
b.WriteString(" " + m.st.Muted.Render(truncateTo(desc, m.width-6)) + "\n")
}
}
}
if end < len(visible) {
b.WriteString(m.st.Dim.Render(fmt.Sprintf(" … %d more", len(visible)-end)))
}
return b.String()
}
func (m Module) emptyStaging() string {
lines := []string{
"",
" " + m.st.Title.Render("Nothing is staged yet"),
"",
" " + m.st.Muted.Render("Put album folders in:"),
" " + m.st.Accent.Render(m.opts.StagingDir),
"",
" " + m.st.Muted.Render("intake expects one folder per album. Multi-disc"),
" " + m.st.Muted.Render("releases may keep their discs in subfolders."),
"",
" " + m.st.Muted.Render("Press r to look again."),
}
return strings.Join(lines, "\n")
}
func (m Module) albumSummary(a scan.Album) string {
if reason, ok := m.attention[a.Dir]; ok {
return "needs you: " + reason
}
parts := []string{string(a.Status)}
switch {
case len(a.Tracks) > 0:
parts = append(parts, fmt.Sprintf("%d track%s", len(a.Tracks), plural(len(a.Tracks))))
case a.FileCount > 0:
parts = append(parts, fmt.Sprintf("%d file%s", a.FileCount, plural(a.FileCount)))
}
if note := strings.Join(a.Notes, "; "); note != "" {
parts = append(parts, note)
}
return strings.Join(parts, " · ")
}
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}
func plural(n int) string {
if n == 1 {
return ""
}
return "s"
}
// journalActions is what can be done to a record, in the order the menu shows
// them. Nothing here deletes audio: forgetting a record leaves every file where
// it is, and un-filing moves the masters back rather than copying and deleting
// them, so an interruption can only leave the album in one place or the other.
func (m Module) journalActions() []journalAction {
return []journalAction{
{
key: "forget",
label: "forget it (leave every file where it is)",
run: func(m *Module, e JournalEntry) tea.Cmd {
if m.opts.ForgetSource == nil {
return nil
}
src := e.Source
return func() tea.Msg {
if err := m.opts.ForgetSource(src); err != nil {
return journalDoneMsg{err: err}
}
return journalDoneMsg{note: "forgotten; the files are untouched"}
}
},
},
{
key: "unfile",
label: "move the masters back to staging (removes the library copy)",
confirm: "press enter again to move it, or esc to cancel",
run: func(m *Module, e JournalEntry) tea.Cmd {
if m.opts.UnfileSource == nil {
return nil
}
src := e.Source
return func() tea.Msg {
var last string
err := m.opts.UnfileSource(src, func(line string) { last = line })
if err != nil {
return journalDoneMsg{err: err}
}
if last == "" {
last = "moved back to staging"
}
return journalDoneMsg{note: last}
}
},
},
}
}
func (m Module) keyJournal(keyp string) (tea.Model, tea.Cmd) {
if m.journalMenu >= 0 {
return m.keyJournalMenu(keyp)
}
switch keyp {
case "q":
return m.quit()
case "esc":
m.screen, m.scroll = ScreenBrowse, 0
return m, nil
case "up", "k":
if m.journalCursor > 0 {
m.journalCursor--
}
case "down", "j":
if m.journalCursor < len(m.journal)-1 {
m.journalCursor++
}
case "home", "g":
m.journalCursor = 0
case "end", "G":
m.journalCursor = len(m.journal) - 1
case "r":
m.status = "refreshing"
return m, m.journalCmd()
case "enter":
if m.journalCursor >= len(m.journal) {
return m, nil
}
if m.journal[m.journalCursor].Unrecorded {
// There is no record here to forget or to move: the album is on disk
// and the journal knows nothing about it. Saying so is more useful
// than offering actions that cannot work.
m.status = m.journal[m.journalCursor].Album + ": in the archive with no record, so there is nothing to forget or un-file"
return m, nil
}
m.journalMenu, m.journalAction, m.journalConfirm = 0, 0, false
}
return m, nil
}
func (m Module) keyJournalMenu(keyp string) (tea.Model, tea.Cmd) {
actions := m.journalActions()
if m.journalCursor >= len(m.journal) {
m.journalMenu = -1
return m, nil
}
switch keyp {
case "esc", "q":
m.journalMenu, m.journalAction, m.journalConfirm = -1, 0, false
m.batch, m.existing, m.collision, m.restored = nil, nil, "", false
return m, nil
case "up", "k":
if m.journalAction > 0 {
m.journalAction--
}
return m, nil
case "down", "j":
if m.journalAction < len(actions)-1 {
m.journalAction++
}
return m, nil
case "enter":
a := actions[m.journalAction]
if a.confirm != "" && !m.journalConfirm {
// One key to choose, one to confirm. This is the only place in the
// interface that moves an album already in the archive, so it does
// not happen on a single stray keypress.
m.journalConfirm = true
m.status = a.confirm
return m, nil
}
return m.runJournalAction(a)
}
return m, nil
}
func (m Module) runJournalAction(a journalAction) (tea.Model, tea.Cmd) {
entry := m.journal[m.journalCursor]
m.journalMenu, m.journalAction, m.journalConfirm = -1, 0, false
m.batch, m.existing, m.collision, m.restored = nil, nil, "", false
return m, a.run(&m, entry)
}