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) }