diff options
Diffstat (limited to 'internal/app')
| -rw-r--r-- | internal/app/filters.go | 22 | ||||
| -rw-r--r-- | internal/app/loader.go | 26 | ||||
| -rw-r--r-- | internal/app/loader_test.go | 34 | ||||
| -rw-r--r-- | internal/app/messages.go | 7 | ||||
| -rw-r--r-- | internal/app/model.go | 168 | ||||
| -rw-r--r-- | internal/app/model_durations.go | 23 | ||||
| -rw-r--r-- | internal/app/model_keys.go | 9 | ||||
| -rw-r--r-- | internal/app/model_tags.go | 135 | ||||
| -rw-r--r-- | internal/app/model_test.go | 243 | ||||
| -rw-r--r-- | internal/app/tag_commands.go | 20 | ||||
| -rw-r--r-- | internal/app/video.go | 1 | ||||
| -rw-r--r-- | internal/app/view_helpers.go | 11 |
12 files changed, 665 insertions, 34 deletions
diff --git a/internal/app/filters.go b/internal/app/filters.go index 691be41..3e864a5 100644 --- a/internal/app/filters.go +++ b/internal/app/filters.go @@ -17,6 +17,7 @@ type filterState struct { minMinutes int maxEnabled bool maxMinutes int + tags string } type filterInputs struct { @@ -28,8 +29,9 @@ func (m *model) applyFilterInputs() error { name := strings.TrimSpace(m.inputs.fields[0].Value()) minText := strings.TrimSpace(m.inputs.fields[1].Value()) maxText := strings.TrimSpace(m.inputs.fields[2].Value()) + tags := strings.TrimSpace(m.inputs.fields[3].Value()) - filters := filterState{name: name} + filters := filterState{name: name, tags: tags} if err := populateMinFilter(&filters, minText); err != nil { return err } @@ -98,6 +100,9 @@ func (m model) describeFilters() string { if m.filters.name != "" { parts = append(parts, fmt.Sprintf("name contains %q", m.filters.name)) } + if m.filters.tags != "" { + parts = append(parts, fmt.Sprintf("tags contain %q", m.filters.tags)) + } if m.filters.minEnabled { parts = append(parts, fmt.Sprintf(">=%d min", m.filters.minMinutes)) } @@ -121,6 +126,19 @@ func (m *model) passesFilters(v video) bool { if m.filters.maxEnabled && (v.Duration == 0 || durMinutes > m.filters.maxMinutes) { return false } + if m.filters.tags != "" { + query := strings.ToLower(m.filters.tags) + matched := false + for _, tag := range v.Tags { + if strings.Contains(strings.ToLower(tag), query) { + matched = true + break + } + } + if !matched { + return false + } + } return true } @@ -128,7 +146,7 @@ func (m *model) renderFilterModal() string { var b strings.Builder b.WriteString("Filter videos\n") b.WriteString("(Enter to apply, Esc to cancel)\n\n") - labels := []string{"Name contains:", "Min length (minutes):", "Max length (minutes):"} + labels := []string{"Name contains:", "Min length (minutes):", "Max length (minutes):", "Tags contain:"} for i, field := range m.inputs.fields { line := fmt.Sprintf("%s %s", labels[i], field.View()) if i == m.inputs.focus { diff --git a/internal/app/loader.go b/internal/app/loader.go index 37c8c94..a11946a 100644 --- a/internal/app/loader.go +++ b/internal/app/loader.go @@ -3,6 +3,7 @@ package app import ( "context" "errors" + "fmt" "io/fs" "os" "os/exec" @@ -13,16 +14,18 @@ import ( "time" tea "github.com/charmbracelet/bubbletea" + + "yoga/internal/tags" ) func loadVideosCmd(root, cachePath string, progress *loadProgress) tea.Cmd { return func() tea.Msg { cache, cacheErr := loadDurationCache(cachePath) - videos, pending, err := loadVideos(root, cache, progress) + videos, pending, tagErr, err := loadVideos(root, cache, progress) if progress != nil { progress.MarkDone() } - return videosLoadedMsg{videos: videos, err: err, cacheErr: cacheErr, pending: pending, cache: cache} + return videosLoadedMsg{videos: videos, err: err, cacheErr: cacheErr, pending: pending, cache: cache, tagErr: tagErr} } } @@ -36,16 +39,17 @@ func progressTickerCmd(progress *loadProgress) tea.Cmd { }) } -func loadVideos(root string, cache *durationCache, progress *loadProgress) ([]video, []string, error) { +func loadVideos(root string, cache *durationCache, progress *loadProgress) ([]video, []string, error, error) { paths, err := collectVideoPaths(root) if err != nil { - return nil, nil, err + return nil, nil, nil, err } if progress != nil { progress.SetTotal(len(paths)) } videos := make([]video, 0, len(paths)) pending := make([]string, 0) + var tagErrors []string for _, path := range paths { info, statErr := os.Stat(path) if statErr != nil { @@ -57,16 +61,28 @@ func loadVideos(root string, cache *durationCache, progress *loadProgress) ([]vi if dur == 0 { pending = append(pending, path) } + tagList, tagErr := tags.Load(path) + if tagErr != nil { + tagErrors = append(tagErrors, fmt.Sprintf("%s: %v", filepath.Base(path), tagErr)) + } videos = append(videos, video{ Name: filepath.Base(path), Path: path, Duration: dur, ModTime: info.ModTime(), Size: info.Size(), + Tags: tagList, }) increment(progress) } - return videos, pending, nil + return videos, pending, joinErrors(tagErrors), nil +} + +func joinErrors(messages []string) error { + if len(messages) == 0 { + return nil + } + return errors.New(strings.Join(messages, "; ")) } func increment(progress *loadProgress) { diff --git a/internal/app/loader_test.go b/internal/app/loader_test.go index 538bca0..843e471 100644 --- a/internal/app/loader_test.go +++ b/internal/app/loader_test.go @@ -6,6 +6,8 @@ import ( "runtime" "testing" "time" + + "yoga/internal/tags" ) func TestCollectVideoPathsDetectsMP4(t *testing.T) { @@ -70,10 +72,13 @@ func TestLoadVideosWithCache(t *testing.T) { _ = cache.Record(video, info, time.Minute) progress := &loadProgress{} progress.Reset() - videos, pending, err := loadVideos(dir, cache, progress) + videos, pending, tagErr, err := loadVideos(dir, cache, progress) if err != nil { t.Fatalf("loadVideos: %v", err) } + if tagErr != nil { + t.Fatalf("unexpected tag error: %v", tagErr) + } if len(videos) != 1 || len(pending) != 0 { t.Fatalf("expected cached video without pending: videos=%d pending=%d", len(videos), len(pending)) } @@ -82,6 +87,28 @@ func TestLoadVideosWithCache(t *testing.T) { } } +func TestLoadVideosReadsTags(t *testing.T) { + dir := t.TempDir() + videoPath := filepath.Join(dir, "session.mp4") + if err := os.WriteFile(videoPath, []byte("x"), 0o644); err != nil { + t.Fatalf("write video: %v", err) + } + metaPath := tags.PathFor(videoPath) + if err := os.WriteFile(metaPath, []byte("[\"calm\", \"focus\"]"), 0o644); err != nil { + t.Fatalf("write tags: %v", err) + } + videos, _, tagErr, err := loadVideos(dir, nil, nil) + if err != nil { + t.Fatalf("loadVideos: %v", err) + } + if tagErr != nil { + t.Fatalf("unexpected tag error: %v", tagErr) + } + if len(videos) != 1 || len(videos[0].Tags) != 2 { + t.Fatalf("expected tags loaded, got %#v", videos) + } +} + func TestProbeDurationsCmdHandlesMissingBinary(t *testing.T) { cmd := probeDurationsCmd("/no/such/file.mp4", nil) msg := cmd() @@ -152,10 +179,13 @@ func TestLoadVideosHandlesStatError(t *testing.T) { if err := os.Symlink(filepath.Join(dir, "missing.mp4"), broken); err != nil { t.Skipf("symlink unsupported: %v", err) } - videos, _, err := loadVideos(dir, nil, nil) + videos, _, tagErr, err := loadVideos(dir, nil, nil) if err != nil { t.Fatalf("loadVideos: %v", err) } + if tagErr != nil { + t.Fatalf("unexpected tag error: %v", tagErr) + } if len(videos) != 1 || videos[0].Err == nil { t.Fatalf("expected stat error recorded, got %+v", videos) } diff --git a/internal/app/messages.go b/internal/app/messages.go index a905263..6c571f7 100644 --- a/internal/app/messages.go +++ b/internal/app/messages.go @@ -8,6 +8,7 @@ type videosLoadedMsg struct { cacheErr error pending []string cache *durationCache + tagErr error } type playVideoMsg struct { @@ -26,3 +27,9 @@ type durationUpdateMsg struct { duration time.Duration err error } + +type tagsSavedMsg struct { + path string + tags []string + err error +} diff --git a/internal/app/model.go b/internal/app/model.go index 8cdcddc..76ab358 100644 --- a/internal/app/model.go +++ b/internal/app/model.go @@ -18,6 +18,17 @@ const ( sortByAge ) +const ( + preferredNameColumnWidth = 40 + preferredDurationColumnWidth = 12 + preferredAgeColumnWidth = 14 + preferredTagsColumnWidth = 28 + nameColumnFloorWidth = 16 + durationColumnFloorWidth = 8 + ageColumnFloorWidth = 10 + tagsColumnFloorWidth = 12 +) + type model struct { table table.Model videos []video @@ -25,6 +36,7 @@ type model struct { filters filterState inputs filterInputs showFilters bool + editingTags bool sortField sortField sortAscending bool statusMessage string @@ -40,12 +52,18 @@ type model struct { durationInFlight int cropValue string cropEnabled bool + tagInput textinput.Model + tagEditPath string + baseStatus string + showHelp bool + viewportWidth int } func newModel(opts Options) (model, error) { tbl := buildTable() inputs := buildFilterInputs() inputs.fields[0].Focus() + tagInput := buildTagInput() progress := &loadProgress{} cachePath := filepath.Join(opts.Root, ".video_duration_cache.json") @@ -53,6 +71,7 @@ func newModel(opts Options) (model, error) { return model{ table: tbl, inputs: inputs, + tagInput: tagInput, sortField: sortByName, sortAscending: true, statusMessage: "Scanning for videos...", @@ -62,16 +81,17 @@ func newModel(opts Options) (model, error) { cachePath: cachePath, cropValue: opts.Crop, cropEnabled: opts.Crop != "", + showHelp: true, }, nil } func buildTable() table.Model { - columns := []table.Column{ - {Title: headerStyle.Render("Name"), Width: 50}, - {Title: headerStyle.Render("Duration"), Width: 12}, - {Title: headerStyle.Render("Age"), Width: 14}, - {Title: headerStyle.Render("Path"), Width: 40}, - } + columns := makeColumns( + preferredNameColumnWidth, + preferredDurationColumnWidth, + preferredAgeColumnWidth, + preferredTagsColumnWidth, + ) tbl := table.New( table.WithColumns(columns), table.WithFocused(true), @@ -97,12 +117,34 @@ func buildFilterInputs() filterInputs { maxInput.Prompt = "Max minutes: " maxInput.CharLimit = 4 + tagInput := textinput.New() + tagInput.Placeholder = "tag substring" + tagInput.Prompt = "Tags: " + tagInput.CharLimit = 256 + return filterInputs{ - fields: []textinput.Model{nameInput, minInput, maxInput}, + fields: []textinput.Model{nameInput, minInput, maxInput, tagInput}, focus: 0, } } +func buildTagInput() textinput.Model { + input := textinput.New() + input.Placeholder = "comma-separated tags" + input.Prompt = "Tags: " + input.CharLimit = 512 + return input +} + +func makeColumns(nameWidth, durationWidth, ageWidth, tagsWidth int) []table.Column { + return []table.Column{ + {Title: headerStyle.Render("Name"), Width: nameWidth}, + {Title: headerStyle.Render("Duration"), Width: durationWidth}, + {Title: headerStyle.Render("Age"), Width: ageWidth}, + {Title: headerStyle.Render("Tags"), Width: tagsWidth}, + } +} + func (m model) Init() tea.Cmd { if m.progress != nil { m.progress.Reset() @@ -126,6 +168,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.handleVideosLoaded(typed) case playVideoMsg: return m.handlePlayVideo(typed), nil + case tagsSavedMsg: + return m.handleTagsSaved(typed) + case tea.WindowSizeMsg: + return m.handleWindowSize(typed) default: return m.updateTable(msg) } @@ -136,6 +182,9 @@ func (m model) View() string { return statusStyle.Render("Loading videos, please wait...") } body := m.renderBody() + if m.editingTags { + return body + "\n\n" + m.renderTagModal() + } if m.showFilters { return body + "\n\n" + m.renderFilterModal() } @@ -144,20 +193,117 @@ func (m model) View() string { func (m model) renderBody() string { helpLines := []string{ - "↑/↓ navigate • enter play • s sort • / filter • c copy path • q quit", + "↑/↓ navigate • enter play • s sort • / filter • c crop • t edit tags • q quit", } - info := statusStyle.Render(m.statusMessage) + info := statusStyle.Render(m.statusText()) progressLine := m.renderProgressLine() content := tableStyle.Render(m.table.View()) - help := strings.Join(helpLines, "\n") parts := []string{content} if progressLine != "" { parts = append(parts, progressLine) } - parts = append(parts, info, help) + parts = append(parts, info) + if m.showHelp { + help := strings.Join(helpLines, "\n") + parts = append(parts, help) + } return strings.Join(parts, "\n") } +func (m model) statusText() string { + status := strings.TrimSpace(m.statusMessage) + base := strings.TrimSpace(m.baseStatus) + if base == "" { + return status + } + if status == "" || status == base { + return base + } + return fmt.Sprintf("%s • %s", base, status) +} + +func (m model) showHelpBar() (tea.Model, tea.Cmd) { + if m.showHelp { + return m, nil + } + m.showHelp = true + if strings.Contains(m.statusMessage, "Help hidden") { + m.statusMessage = "" + } + return m, nil +} + +func (m model) hideHelpBar() (tea.Model, tea.Cmd) { + if !m.showHelp { + return m, nil + } + m.showHelp = false + m.statusMessage = "Help hidden (press h to show)" + return m, nil +} + +func (m model) handleWindowSize(msg tea.WindowSizeMsg) (tea.Model, tea.Cmd) { + m.viewportWidth = msg.Width + m.resizeColumns(msg.Width) + tbl, cmd := m.table.Update(msg) + m.table = tbl + if cmd == nil { + return m, nil + } + return m, cmd +} + +func (m *model) resizeColumns(totalWidth int) { + if totalWidth <= 0 { + return + } + frame := tableStyle.GetHorizontalFrameSize() + contentWidth := totalWidth - frame + minWidth := nameColumnFloorWidth + durationColumnFloorWidth + ageColumnFloorWidth + tagsColumnFloorWidth + if contentWidth < minWidth { + contentWidth = minWidth + } + preferred := preferredNameColumnWidth + preferredDurationColumnWidth + preferredAgeColumnWidth + preferredTagsColumnWidth + nameWidth := preferredNameColumnWidth + durationWidth := preferredDurationColumnWidth + ageWidth := preferredAgeColumnWidth + tagsWidth := preferredTagsColumnWidth + if contentWidth >= preferred { + extra := contentWidth - preferred + nameWidth += extra + } else { + deficit := preferred - contentWidth + if deficit > 0 { + reduce := min(deficit, nameWidth-nameColumnFloorWidth) + nameWidth -= reduce + deficit -= reduce + } + if deficit > 0 { + reduce := min(deficit, tagsWidth-tagsColumnFloorWidth) + tagsWidth -= reduce + deficit -= reduce + } + if deficit > 0 { + reduce := min(deficit, ageWidth-ageColumnFloorWidth) + ageWidth -= reduce + deficit -= reduce + } + if deficit > 0 { + reduce := min(deficit, durationWidth-durationColumnFloorWidth) + durationWidth -= reduce + } + } + m.table.SetColumns(makeColumns(nameWidth, durationWidth, ageWidth, tagsWidth)) + m.table.SetWidth(contentWidth) +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} + func (m model) renderProgressLine() string { if m.durationTotal == 0 { return "" diff --git a/internal/app/model_durations.go b/internal/app/model_durations.go index b92e816..95fd6c7 100644 --- a/internal/app/model_durations.go +++ b/internal/app/model_durations.go @@ -162,20 +162,25 @@ func (m model) handleVideosLoaded(msg videosLoadedMsg) (tea.Model, tea.Cmd) { func (m *model) updateStatusAfterLoad(msg videosLoadedMsg) { if len(m.filtered) == 0 { - m.statusMessage = "No videos found" + m.baseStatus = "No videos found" + m.statusMessage = m.baseStatus return } + status := "" if len(msg.pending) > 0 { + status = fmt.Sprintf("Loaded %d videos, probing durations...", len(m.filtered)) if msg.cacheErr != nil { - m.statusMessage = fmt.Sprintf("Loaded %d videos (cache warning: %v), probing durations...", len(m.filtered), msg.cacheErr) - return + status = fmt.Sprintf("Loaded %d videos (cache warning: %v), probing durations...", len(m.filtered), msg.cacheErr) + } + } else { + status = fmt.Sprintf("Loaded %d videos", len(m.filtered)) + if msg.cacheErr != nil { + status = fmt.Sprintf("Loaded %d videos (cache warning: %v)", len(m.filtered), msg.cacheErr) } - m.statusMessage = fmt.Sprintf("Loaded %d videos, probing durations...", len(m.filtered)) - return } - if msg.cacheErr != nil { - m.statusMessage = fmt.Sprintf("Loaded %d videos (cache warning: %v)", len(m.filtered), msg.cacheErr) - return + if msg.tagErr != nil { + status = fmt.Sprintf("%s (tag warning: %v)", status, msg.tagErr) } - m.statusMessage = fmt.Sprintf("Loaded %d videos", len(m.filtered)) + m.baseStatus = status + m.statusMessage = status } diff --git a/internal/app/model_keys.go b/internal/app/model_keys.go index d02cf46..ee15d5d 100644 --- a/internal/app/model_keys.go +++ b/internal/app/model_keys.go @@ -13,6 +13,9 @@ func (m model) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if m.loading { return m, nil } + if m.editingTags { + return m.handleTagKey(msg) + } if m.showFilters { return m.handleFilterKey(msg) } @@ -83,6 +86,12 @@ func (m model) handleTableKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m.sortAndReport(sortByAge) case "c": return m.toggleCrop() + case "t": + return m.openTagEditor() + case "H": + return m.hideHelpBar() + case "h": + return m.showHelpBar() case "r": return m.resetFilterState() default: diff --git a/internal/app/model_tags.go b/internal/app/model_tags.go new file mode 100644 index 0000000..1ea7b3a --- /dev/null +++ b/internal/app/model_tags.go @@ -0,0 +1,135 @@ +package app + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" +) + +func (m model) openTagEditor() (tea.Model, tea.Cmd) { + if len(m.filtered) == 0 { + m.statusMessage = "No videos to edit" + return m, nil + } + cursor := m.table.Cursor() + if cursor < 0 || cursor >= len(m.filtered) { + m.statusMessage = "No selection" + return m, nil + } + video := m.filtered[cursor] + m.editingTags = true + m.tagEditPath = video.Path + m.tagInput = cloneInput(m.tagInput) + m.tagInput.SetValue(strings.Join(video.Tags, ", ")) + m.tagInput.CursorEnd() + m.tagInput.Focus() + m.statusMessage = fmt.Sprintf("Editing tags for %s", video.Name) + return m, nil +} + +func (m model) handleTagKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "esc": + m.editingTags = false + m.tagEditPath = "" + m.tagInput.Blur() + m.statusMessage = "Tag edit cancelled" + return m, nil + case "enter": + return m.commitTags() + } + var cmd tea.Cmd + m.tagInput, cmd = m.tagInput.Update(msg) + return m, cmd +} + +func (m model) commitTags() (tea.Model, tea.Cmd) { + if m.tagEditPath == "" { + m.editingTags = false + m.tagInput.Blur() + m.statusMessage = "No video selected" + return m, nil + } + value := m.tagInput.Value() + tags := parseTagInput(value) + m.editingTags = false + m.tagInput.Blur() + path := m.tagEditPath + m.tagEditPath = "" + name := filepath.Base(path) + m.statusMessage = fmt.Sprintf("Saving tags for %s", name) + return m, saveTagsCmd(path, tags) +} + +func (m model) handleTagsSaved(msg tagsSavedMsg) (tea.Model, tea.Cmd) { + if msg.err != nil { + m.editingTags = false + m.tagEditPath = "" + m.tagInput.Blur() + m.showHelp = true + m.statusMessage = fmt.Sprintf("Tag save error: %v", msg.err) + return m, nil + } + m.editingTags = false + m.tagEditPath = "" + m.tagInput.Blur() + m.showHelp = true + m.setVideoTags(msg.path, msg.tags) + m.applyFiltersAndSort() + m.restoreSelection(msg.path) + if len(msg.tags) == 0 { + m.statusMessage = "Tags cleared" + return m, nil + } + m.statusMessage = fmt.Sprintf("Tags updated (%d)", len(msg.tags)) + return m, nil +} + +func (m *model) setVideoTags(path string, tags []string) { + for i := range m.videos { + if m.videos[i].Path == path { + m.videos[i].Tags = append([]string{}, tags...) + return + } + } +} + +func parseTagInput(value string) []string { + if strings.TrimSpace(value) == "" { + return nil + } + parts := strings.Split(value, ",") + var tags []string + seen := make(map[string]struct{}, len(parts)) + for _, part := range parts { + trimmed := strings.TrimSpace(part) + if trimmed == "" { + continue + } + lower := strings.ToLower(trimmed) + if _, ok := seen[lower]; ok { + continue + } + seen[lower] = struct{}{} + tags = append(tags, trimmed) + } + return tags +} + +func cloneInput(in textinput.Model) textinput.Model { + copy := in + return copy +} + +func (m model) renderTagModal() string { + var b strings.Builder + b.WriteString("Edit tags\n") + b.WriteString("(comma separated)\n\n") + b.WriteString(m.tagInput.View()) + b.WriteString("\n\n") + b.WriteString("Enter to save, Esc to cancel") + return filterStyle.Render(b.String()) +} diff --git a/internal/app/model_test.go b/internal/app/model_test.go index 53e391e..6d1053b 100644 --- a/internal/app/model_test.go +++ b/internal/app/model_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "github.com/charmbracelet/bubbles/table" tea "github.com/charmbracelet/bubbletea" ) @@ -170,9 +171,9 @@ func TestDescribeFilters(t *testing.T) { if err != nil { t.Fatalf("newModel: %v", err) } - m.filters = filterState{name: "flow", minEnabled: true, minMinutes: 5, maxEnabled: true, maxMinutes: 20} + m.filters = filterState{name: "flow", minEnabled: true, minMinutes: 5, maxEnabled: true, maxMinutes: 20, tags: "calm"} desc := m.describeFilters() - if !strings.Contains(desc, "flow") || !strings.Contains(desc, ">=5") { + if !strings.Contains(desc, "flow") || !strings.Contains(desc, ">=5") || !strings.Contains(desc, "calm") { t.Fatalf("unexpected description %s", desc) } } @@ -335,13 +336,26 @@ func TestUpdateStatusAfterLoadCacheWarning(t *testing.T) { } } +func TestUpdateStatusAfterLoadTagWarning(t *testing.T) { + m, err := newModel(Options{Root: t.TempDir()}) + if err != nil { + t.Fatalf("newModel: %v", err) + } + msg := videosLoadedMsg{videos: []video{{Name: "a", Path: "a.mp4"}}, tagErr: errors.New("bad json")} + modelAny, _ := m.Update(msg) + m = modelAny.(model) + if !strings.Contains(m.statusMessage, "tag warning") { + t.Fatalf("expected tag warning, got %s", m.statusMessage) + } +} + func TestPassesFiltersBounds(t *testing.T) { m, err := newModel(Options{Root: t.TempDir()}) if err != nil { t.Fatalf("newModel: %v", err) } m.filters = filterState{minEnabled: true, minMinutes: 5, maxEnabled: true, maxMinutes: 15} - video := video{Name: "clip", Duration: 10 * time.Minute} + video := video{Name: "clip", Duration: 10 * time.Minute, Tags: []string{"calm", "focus"}} if !m.passesFilters(video) { t.Fatalf("expected video within bounds") } @@ -353,6 +367,14 @@ func TestPassesFiltersBounds(t *testing.T) { if m.passesFilters(video) { t.Fatalf("expected name filter to exclude video") } + m.filters = filterState{tags: "calm"} + if !m.passesFilters(video) { + t.Fatalf("expected tag filter to include video") + } + m.filters = filterState{tags: "power"} + if m.passesFilters(video) { + t.Fatalf("expected tag filter to exclude video") + } } func TestProgressTickerCmdTick(t *testing.T) { @@ -439,6 +461,221 @@ func TestResetFilters(t *testing.T) { } } +func TestHandleTagsSavedUpdatesModel(t *testing.T) { + root := t.TempDir() + m, err := newModel(Options{Root: root}) + if err != nil { + t.Fatalf("newModel: %v", err) + } + vid := video{Name: "clip.mp4", Path: filepath.Join(root, "clip.mp4")} + m.videos = []video{vid} + m.filtered = []video{vid} + m.table.SetRows([]table.Row{videoRow(vid)}) + modelAny, _ := m.handleTagsSaved(tagsSavedMsg{path: vid.Path, tags: []string{"calm", "focus"}}) + m = modelAny.(model) + if len(m.videos[0].Tags) != 2 { + t.Fatalf("expected tags recorded") + } + if len(m.filtered) != 1 || len(m.filtered[0].Tags) != 2 { + t.Fatalf("expected filtered list updated") + } + if !strings.Contains(m.statusMessage, "Tags updated") { + t.Fatalf("expected status update, got %s", m.statusMessage) + } +} + +func TestOpenTagEditorLoadsExistingTags(t *testing.T) { + root := t.TempDir() + m, err := newModel(Options{Root: root}) + if err != nil { + t.Fatalf("newModel: %v", err) + } + vid := video{Name: "clip.mp4", Path: filepath.Join(root, "clip.mp4"), Tags: []string{"calm"}} + m.videos = []video{vid} + m.applyFiltersAndSort() + modelAny, _ := m.openTagEditor() + m = modelAny.(model) + if !m.editingTags { + t.Fatalf("expected tag editor to open") + } + if m.tagInput.Value() != "calm" { + t.Fatalf("expected input prefilled, got %s", m.tagInput.Value()) + } +} + +func TestParseTagInput(t *testing.T) { + result := parseTagInput(" calm , Focus , focus , ") + if len(result) != 2 { + t.Fatalf("expected two tags, got %v", result) + } + if result[0] != "calm" || result[1] != "Focus" { + t.Fatalf("unexpected order or casing: %v", result) + } + if out := parseTagInput(" "); out != nil { + t.Fatalf("expected nil for blank input, got %v", out) + } +} + +func TestSaveTagsCmd(t *testing.T) { + dir := t.TempDir() + videoPath := filepath.Join(dir, "clip.mp4") + if err := os.WriteFile(videoPath, []byte("x"), 0o644); err != nil { + t.Fatalf("write video: %v", err) + } + msg := saveTagsCmd(videoPath, []string{" calm ", "calm", "Focus"})() + result, ok := msg.(tagsSavedMsg) + if !ok { + t.Fatalf("expected tagsSavedMsg, got %T", msg) + } + if result.err != nil { + t.Fatalf("unexpected error: %v", result.err) + } + if len(result.tags) != 2 { + t.Fatalf("expected deduped tags, got %v", result.tags) + } +} + +func TestHelpLineAfterTagEdit(t *testing.T) { + root := t.TempDir() + m, err := newModel(Options{Root: root}) + if err != nil { + t.Fatalf("newModel: %v", err) + } + vid := video{Name: "clip.mp4", Path: filepath.Join(root, "clip.mp4")} + loaded := videosLoadedMsg{videos: []video{vid}, cache: newDurationCache(filepath.Join(root, "cache.json"))} + modelAny, _ := m.handleVideosLoaded(loaded) + m = modelAny.(model) + if view := m.View(); !strings.Contains(view, "Loaded 1 videos") { + t.Fatalf("expected base status in view: %s", view) + } + modelAny, _ = m.openTagEditor() + m = modelAny.(model) + m.tagInput.SetValue("calm") + modelAny, cmd := m.handleTagKey(tea.KeyMsg{Type: tea.KeyEnter}) + m = modelAny.(model) + if cmd == nil { + t.Fatalf("expected save command") + } + if view := m.View(); !strings.Contains(view, "Loaded 1 videos") || !strings.Contains(view, "Saving tags") { + t.Fatalf("expected combined status while saving: %s", view) + } + msg := cmd().(tagsSavedMsg) + modelAny, _ = m.handleTagsSaved(msg) + m = modelAny.(model) + if view := m.View(); !strings.Contains(view, "Loaded 1 videos") { + t.Fatalf("expected base status after save: %s", view) + } + if view := m.View(); !strings.Contains(view, "↑/↓ navigate") { + t.Fatalf("expected help line after save: %s", view) + } + if !strings.Contains(m.statusMessage, "Tags updated") { + t.Fatalf("expected status message to report update, got %s", m.statusMessage) + } +} + +func TestToggleHelpKeys(t *testing.T) { + root := t.TempDir() + m, err := newModel(Options{Root: root}) + if err != nil { + t.Fatalf("newModel: %v", err) + } + vid := video{Name: "clip.mp4", Path: filepath.Join(root, "clip.mp4")} + loaded := videosLoadedMsg{videos: []video{vid}, cache: newDurationCache(filepath.Join(root, "cache.json"))} + modelAny, _ := m.handleVideosLoaded(loaded) + m = modelAny.(model) + helpLine := "↑/↓ navigate • enter play • s sort • / filter • c crop • t edit tags • q quit" + if view := m.View(); !strings.Contains(view, helpLine) { + t.Fatalf("expected help line visible: %s", view) + } + modelAny, _ = m.handleKeyMsg(keyMsg("H")) + m = modelAny.(model) + if m.showHelp { + t.Fatalf("expected help to be hidden") + } + if view := m.View(); strings.Contains(view, helpLine) { + t.Fatalf("expected help line hidden: %s", view) + } + if !strings.Contains(m.statusMessage, "Help hidden") { + t.Fatalf("expected help hidden status, got %s", m.statusMessage) + } + modelAny, _ = m.handleKeyMsg(keyMsg("h")) + m = modelAny.(model) + if !m.showHelp { + t.Fatalf("expected help to be shown") + } + if view := m.View(); !strings.Contains(view, helpLine) { + t.Fatalf("expected help line visible again: %s", view) + } +} + +func TestWindowResizeExpandsNameColumn(t *testing.T) { + m, err := newModel(Options{Root: t.TempDir()}) + if err != nil { + t.Fatalf("newModel: %v", err) + } + m.loading = false + initial := m.table.Columns()[0].Width + modelAny, _ := m.Update(tea.WindowSizeMsg{Width: 180, Height: 40}) + m = modelAny.(model) + resized := m.table.Columns()[0].Width + if resized <= initial { + t.Fatalf("expected name column to expand, initial=%d resized=%d", initial, resized) + } +} + +func TestWindowResizeShrinksColumnsGracefully(t *testing.T) { + m, err := newModel(Options{Root: t.TempDir()}) + if err != nil { + t.Fatalf("newModel: %v", err) + } + m.loading = false + modelAny, _ := m.Update(tea.WindowSizeMsg{Width: 60, Height: 40}) + m = modelAny.(model) + cols := m.table.Columns() + if len(cols) != 4 { + t.Fatalf("expected 4 columns") + } + if cols[0].Width < nameColumnFloorWidth { + t.Fatalf("expected name column >= floor, got %d", cols[0].Width) + } + if cols[3].Width < tagsColumnFloorWidth { + t.Fatalf("expected tags column >= floor, got %d", cols[3].Width) + } +} + +func TestFilterByDurationRange(t *testing.T) { + root := t.TempDir() + m, err := newModel(Options{Root: root}) + if err != nil { + t.Fatalf("newModel: %v", err) + } + m.loading = false + m.videos = []video{{Name: "short.mp4", Duration: 5 * time.Minute}, {Name: "long.mp4", Duration: 20 * time.Minute}} + m.applyFiltersAndSort() + modelAny, _ := m.handleKeyMsg(keyMsg("/")) + m = modelAny.(model) + if !m.showFilters { + t.Fatalf("expected filters to open") + } + // Move focus to min minutes field. + modelAny, _ = m.handleFilterKey(tea.KeyMsg{Type: tea.KeyTab}) + m = modelAny.(model) + // Enter "10". + modelAny, _ = m.handleFilterKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'1'}}) + m = modelAny.(model) + modelAny, _ = m.handleFilterKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'0'}}) + m = modelAny.(model) + // Apply filter. + modelAny, _ = m.handleFilterKey(tea.KeyMsg{Type: tea.KeyEnter}) + m = modelAny.(model) + if len(m.filtered) != 1 || m.filtered[0].Name != "long.mp4" { + t.Fatalf("expected only long video after filtering, got %+v", m.filtered) + } + if !strings.Contains(m.statusMessage, "Filters applied") { + t.Fatalf("expected status update, got %s", m.statusMessage) + } +} + func TestSyncFilterFocus(t *testing.T) { m, err := newModel(Options{Root: t.TempDir()}) if err != nil { diff --git a/internal/app/tag_commands.go b/internal/app/tag_commands.go new file mode 100644 index 0000000..c83fab7 --- /dev/null +++ b/internal/app/tag_commands.go @@ -0,0 +1,20 @@ +package app + +import tea "github.com/charmbracelet/bubbletea" + +import "yoga/internal/tags" + +func saveTagsCmd(path string, entries []string) tea.Cmd { + // Copy slice to avoid accidental mutation after scheduling command. + values := append([]string{}, entries...) + return func() tea.Msg { + if err := tags.Save(path, values); err != nil { + return tagsSavedMsg{path: path, err: err} + } + sanitized, err := tags.Load(path) + if err != nil { + return tagsSavedMsg{path: path, err: err} + } + return tagsSavedMsg{path: path, tags: sanitized} + } +} diff --git a/internal/app/video.go b/internal/app/video.go index f969ce6..9c85772 100644 --- a/internal/app/video.go +++ b/internal/app/video.go @@ -9,4 +9,5 @@ type video struct { ModTime time.Time Size int64 Err error + Tags []string } diff --git a/internal/app/view_helpers.go b/internal/app/view_helpers.go index b023d62..9613d63 100644 --- a/internal/app/view_helpers.go +++ b/internal/app/view_helpers.go @@ -15,11 +15,11 @@ func videoRow(v video) table.Row { duration = formatDuration(v.Duration) } age := humanizeAge(v.ModTime) - path := trimPath(v.Path) + tags := formatTags(v.Tags) if v.Err != nil { duration = "!" + v.Err.Error() } - return table.Row{v.Name, duration, age, path} + return table.Row{v.Name, duration, age, tags} } func renderProgressBar(done, total, width int) string { @@ -78,3 +78,10 @@ func trimPath(path string) string { } return path } + +func formatTags(tags []string) string { + if len(tags) == 0 { + return "--" + } + return strings.Join(tags, ", ") +} |
