Phase 4: Create sidebar UI components
Added history sidebar UI with tabs for completed and failed jobs.
Created reusable UI components and helpers for displaying history entries.
Changes:
- internal/ui/mainmenu.go:
* Added HistoryEntry type definition
* Added BuildHistorySidebar() for main sidebar UI with tabs
* Added buildHistoryList() and buildHistoryItem() helpers
* Added imports for queue and utils packages
- internal/ui/components.go:
* Moved GetStatusColor() and BuildModuleBadge() here as shared functions
* Added queue and utils imports for shared helpers
- internal/ui/queueview.go:
* Updated to use shared GetStatusColor() and BuildModuleBadge()
* Removed duplicate function definitions
- main.go:
* Updated to use ui.HistoryEntry type throughout
* Updated historyConfig, appState, and all methods to use ui.HistoryEntry
The sidebar displays history entries with:
- Status-colored indicators (green/red/orange)
- Module type badges with colors
- Shortened titles and formatted timestamps
- Separate tabs for "Completed" and "Failed" (includes cancelled)
- Empty state messages when no entries exist
🤖 Generated with Claude Code
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
d785e4dc91
commit
385c6f736d
|
|
@ -15,6 +15,8 @@ import (
|
||||||
"fyne.io/fyne/v2/theme"
|
"fyne.io/fyne/v2/theme"
|
||||||
"fyne.io/fyne/v2/widget"
|
"fyne.io/fyne/v2/widget"
|
||||||
"git.leaktechnologies.dev/stu/VideoTools/internal/logging"
|
"git.leaktechnologies.dev/stu/VideoTools/internal/logging"
|
||||||
|
"git.leaktechnologies.dev/stu/VideoTools/internal/queue"
|
||||||
|
"git.leaktechnologies.dev/stu/VideoTools/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|
@ -707,3 +709,64 @@ func (w *FFmpegCommandWidget) CreateRenderer() fyne.WidgetRenderer {
|
||||||
|
|
||||||
return widget.NewSimpleRenderer(content)
|
return widget.NewSimpleRenderer(content)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetStatusColor returns the color for a job status
|
||||||
|
func GetStatusColor(status queue.JobStatus) color.Color {
|
||||||
|
switch status {
|
||||||
|
case queue.JobStatusCompleted:
|
||||||
|
return utils.MustHex("#4CAF50") // Green
|
||||||
|
case queue.JobStatusFailed:
|
||||||
|
return utils.MustHex("#F44336") // Red
|
||||||
|
case queue.JobStatusCancelled:
|
||||||
|
return utils.MustHex("#FF9800") // Orange
|
||||||
|
default:
|
||||||
|
return utils.MustHex("#808080") // Gray
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildModuleBadge creates a small colored badge for the job type
|
||||||
|
func BuildModuleBadge(jobType queue.JobType) fyne.CanvasObject {
|
||||||
|
var badgeColor color.Color
|
||||||
|
var badgeText string
|
||||||
|
|
||||||
|
switch jobType {
|
||||||
|
case queue.JobTypeConvert:
|
||||||
|
badgeColor = utils.MustHex("#4A90E2")
|
||||||
|
badgeText = "CONVERT"
|
||||||
|
case queue.JobTypeMerge:
|
||||||
|
badgeColor = utils.MustHex("#E24A90")
|
||||||
|
badgeText = "MERGE"
|
||||||
|
case queue.JobTypeTrim:
|
||||||
|
badgeColor = utils.MustHex("#90E24A")
|
||||||
|
badgeText = "TRIM"
|
||||||
|
case queue.JobTypeFilter:
|
||||||
|
badgeColor = utils.MustHex("#E2904A")
|
||||||
|
badgeText = "FILTER"
|
||||||
|
case queue.JobTypeUpscale:
|
||||||
|
badgeColor = utils.MustHex("#9A4AE2")
|
||||||
|
badgeText = "UPSCALE"
|
||||||
|
case queue.JobTypeAudio:
|
||||||
|
badgeColor = utils.MustHex("#4AE290")
|
||||||
|
badgeText = "AUDIO"
|
||||||
|
case queue.JobTypeThumb:
|
||||||
|
badgeColor = utils.MustHex("#E2E24A")
|
||||||
|
badgeText = "THUMB"
|
||||||
|
case queue.JobTypeSnippet:
|
||||||
|
badgeColor = utils.MustHex("#4AE2E2")
|
||||||
|
badgeText = "SNIPPET"
|
||||||
|
default:
|
||||||
|
badgeColor = utils.MustHex("#808080")
|
||||||
|
badgeText = "OTHER"
|
||||||
|
}
|
||||||
|
|
||||||
|
rect := canvas.NewRectangle(badgeColor)
|
||||||
|
rect.CornerRadius = 3
|
||||||
|
rect.SetMinSize(fyne.NewSize(70, 20))
|
||||||
|
|
||||||
|
text := canvas.NewText(badgeText, color.White)
|
||||||
|
text.Alignment = fyne.TextAlignCenter
|
||||||
|
text.TextStyle = fyne.TextStyle{Monospace: true, Bold: true}
|
||||||
|
text.TextSize = 10
|
||||||
|
|
||||||
|
return container.NewMax(rect, container.NewCenter(text))
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"image/color"
|
"image/color"
|
||||||
"sort"
|
"sort"
|
||||||
|
"time"
|
||||||
|
|
||||||
"fyne.io/fyne/v2"
|
"fyne.io/fyne/v2"
|
||||||
"fyne.io/fyne/v2/canvas"
|
"fyne.io/fyne/v2/canvas"
|
||||||
|
|
@ -11,6 +12,8 @@ import (
|
||||||
"fyne.io/fyne/v2/layout"
|
"fyne.io/fyne/v2/layout"
|
||||||
"fyne.io/fyne/v2/widget"
|
"fyne.io/fyne/v2/widget"
|
||||||
"git.leaktechnologies.dev/stu/VideoTools/internal/logging"
|
"git.leaktechnologies.dev/stu/VideoTools/internal/logging"
|
||||||
|
"git.leaktechnologies.dev/stu/VideoTools/internal/queue"
|
||||||
|
"git.leaktechnologies.dev/stu/VideoTools/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ModuleInfo contains information about a module for display
|
// ModuleInfo contains information about a module for display
|
||||||
|
|
@ -22,6 +25,23 @@ type ModuleInfo struct {
|
||||||
Category string
|
Category string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HistoryEntry represents a completed job in the history
|
||||||
|
type HistoryEntry struct {
|
||||||
|
ID string
|
||||||
|
Type queue.JobType
|
||||||
|
Status queue.JobStatus
|
||||||
|
Title string
|
||||||
|
InputFile string
|
||||||
|
OutputFile string
|
||||||
|
LogPath string
|
||||||
|
Config map[string]interface{}
|
||||||
|
CreatedAt time.Time
|
||||||
|
StartedAt *time.Time
|
||||||
|
CompletedAt *time.Time
|
||||||
|
Error string
|
||||||
|
FFmpegCmd string
|
||||||
|
}
|
||||||
|
|
||||||
// BuildMainMenu creates the main menu view with module tiles grouped by category
|
// BuildMainMenu creates the main menu view with module tiles grouped by category
|
||||||
func BuildMainMenu(modules []ModuleInfo, onModuleClick func(string), onModuleDrop func(string, []fyne.URI), onQueueClick func(), onLogsClick func(), onBenchmarkClick func(), onBenchmarkHistoryClick func(), titleColor, queueColor, textColor color.Color, queueCompleted, queueTotal int) fyne.CanvasObject {
|
func BuildMainMenu(modules []ModuleInfo, onModuleClick func(string), onModuleDrop func(string, []fyne.URI), onQueueClick func(), onLogsClick func(), onBenchmarkClick func(), onBenchmarkHistoryClick func(), titleColor, queueColor, textColor color.Color, queueCompleted, queueTotal int) fyne.CanvasObject {
|
||||||
title := canvas.NewText("VIDEOTOOLS", titleColor)
|
title := canvas.NewText("VIDEOTOOLS", titleColor)
|
||||||
|
|
@ -119,3 +139,103 @@ func sortedKeys(m map[string][]fyne.CanvasObject) []string {
|
||||||
sort.Strings(keys)
|
sort.Strings(keys)
|
||||||
return keys
|
return keys
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BuildHistorySidebar creates the history sidebar with tabs
|
||||||
|
func BuildHistorySidebar(
|
||||||
|
entries []HistoryEntry,
|
||||||
|
onEntryClick func(HistoryEntry),
|
||||||
|
titleColor, bgColor, textColor color.Color,
|
||||||
|
) fyne.CanvasObject {
|
||||||
|
// Filter by status
|
||||||
|
var completedEntries, failedEntries []HistoryEntry
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.Status == queue.JobStatusCompleted {
|
||||||
|
completedEntries = append(completedEntries, entry)
|
||||||
|
} else {
|
||||||
|
failedEntries = append(failedEntries, entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build lists
|
||||||
|
completedList := buildHistoryList(completedEntries, onEntryClick, bgColor, textColor)
|
||||||
|
failedList := buildHistoryList(failedEntries, onEntryClick, bgColor, textColor)
|
||||||
|
|
||||||
|
// Tabs
|
||||||
|
tabs := container.NewAppTabs(
|
||||||
|
container.NewTabItem("Completed", container.NewVScroll(completedList)),
|
||||||
|
container.NewTabItem("Failed", container.NewVScroll(failedList)),
|
||||||
|
)
|
||||||
|
tabs.SetTabLocation(container.TabLocationTop)
|
||||||
|
|
||||||
|
// Header
|
||||||
|
title := canvas.NewText("HISTORY", titleColor)
|
||||||
|
title.TextStyle = fyne.TextStyle{Monospace: true, Bold: true}
|
||||||
|
title.TextSize = 18
|
||||||
|
|
||||||
|
header := container.NewVBox(
|
||||||
|
container.NewCenter(title),
|
||||||
|
widget.NewSeparator(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return container.NewBorder(header, nil, nil, nil, tabs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildHistoryList(
|
||||||
|
entries []HistoryEntry,
|
||||||
|
onEntryClick func(HistoryEntry),
|
||||||
|
bgColor, textColor color.Color,
|
||||||
|
) *fyne.Container {
|
||||||
|
if len(entries) == 0 {
|
||||||
|
return container.NewCenter(widget.NewLabel("No entries"))
|
||||||
|
}
|
||||||
|
|
||||||
|
var items []fyne.CanvasObject
|
||||||
|
for _, entry := range entries {
|
||||||
|
items = append(items, buildHistoryItem(entry, onEntryClick, bgColor, textColor))
|
||||||
|
}
|
||||||
|
return container.NewVBox(items...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildHistoryItem(
|
||||||
|
entry HistoryEntry,
|
||||||
|
onEntryClick func(HistoryEntry),
|
||||||
|
bgColor, textColor color.Color,
|
||||||
|
) fyne.CanvasObject {
|
||||||
|
// Badge
|
||||||
|
badge := BuildModuleBadge(entry.Type)
|
||||||
|
|
||||||
|
// Title
|
||||||
|
titleLabel := widget.NewLabel(utils.ShortenMiddle(entry.Title, 25))
|
||||||
|
titleLabel.TextStyle = fyne.TextStyle{Bold: true}
|
||||||
|
|
||||||
|
// Timestamp
|
||||||
|
timeStr := "Unknown"
|
||||||
|
if entry.CompletedAt != nil {
|
||||||
|
timeStr = entry.CompletedAt.Format("Jan 2, 15:04")
|
||||||
|
}
|
||||||
|
timeLabel := widget.NewLabel(timeStr)
|
||||||
|
timeLabel.TextStyle = fyne.TextStyle{Monospace: true}
|
||||||
|
|
||||||
|
// Status color bar
|
||||||
|
statusColor := GetStatusColor(entry.Status)
|
||||||
|
statusRect := canvas.NewRectangle(statusColor)
|
||||||
|
statusRect.SetMinSize(fyne.NewSize(4, 0))
|
||||||
|
|
||||||
|
content := container.NewBorder(
|
||||||
|
nil, nil, statusRect, nil,
|
||||||
|
container.NewVBox(
|
||||||
|
container.NewHBox(badge, layout.NewSpacer()),
|
||||||
|
titleLabel,
|
||||||
|
timeLabel,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
card := canvas.NewRectangle(bgColor)
|
||||||
|
card.CornerRadius = 4
|
||||||
|
|
||||||
|
item := container.NewPadded(container.NewMax(card, content))
|
||||||
|
|
||||||
|
// Capture entry for closure
|
||||||
|
capturedEntry := entry
|
||||||
|
return NewTappable(item, func() { onEntryClick(capturedEntry) })
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -216,7 +216,7 @@ func buildJobItem(
|
||||||
bgColor, textColor color.Color,
|
bgColor, textColor color.Color,
|
||||||
) fyne.CanvasObject {
|
) fyne.CanvasObject {
|
||||||
// Status color
|
// Status color
|
||||||
statusColor := getStatusColor(job.Status)
|
statusColor := GetStatusColor(job.Status)
|
||||||
|
|
||||||
// Status indicator
|
// Status indicator
|
||||||
statusRect := canvas.NewRectangle(statusColor)
|
statusRect := canvas.NewRectangle(statusColor)
|
||||||
|
|
@ -242,7 +242,7 @@ func buildJobItem(
|
||||||
progressWidget := progress
|
progressWidget := progress
|
||||||
|
|
||||||
// Module badge
|
// Module badge
|
||||||
badge := buildModuleBadge(job.Type)
|
badge := BuildModuleBadge(job.Type)
|
||||||
|
|
||||||
// Status text
|
// Status text
|
||||||
statusText := getStatusText(job)
|
statusText := getStatusText(job)
|
||||||
|
|
@ -329,26 +329,6 @@ func buildJobItem(
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// getStatusColor returns the color for a job status
|
|
||||||
func getStatusColor(status queue.JobStatus) color.Color {
|
|
||||||
switch status {
|
|
||||||
case queue.JobStatusPending:
|
|
||||||
return color.RGBA{R: 150, G: 150, B: 150, A: 255} // Gray
|
|
||||||
case queue.JobStatusRunning:
|
|
||||||
return color.RGBA{R: 68, G: 136, B: 255, A: 255} // Blue
|
|
||||||
case queue.JobStatusPaused:
|
|
||||||
return color.RGBA{R: 255, G: 193, B: 7, A: 255} // Yellow
|
|
||||||
case queue.JobStatusCompleted:
|
|
||||||
return color.RGBA{R: 76, G: 232, B: 112, A: 255} // Green
|
|
||||||
case queue.JobStatusFailed:
|
|
||||||
return color.RGBA{R: 255, G: 68, B: 68, A: 255} // Red
|
|
||||||
case queue.JobStatusCancelled:
|
|
||||||
return color.RGBA{R: 255, G: 136, B: 68, A: 255} // Orange
|
|
||||||
default:
|
|
||||||
return color.Gray{Y: 128}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// getStatusText returns a human-readable status string
|
// getStatusText returns a human-readable status string
|
||||||
func getStatusText(job *queue.Job) string {
|
func getStatusText(job *queue.Job) string {
|
||||||
switch job.Status {
|
switch job.Status {
|
||||||
|
|
@ -398,19 +378,6 @@ func getStatusText(job *queue.Job) string {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildModuleBadge renders a small colored pill to show which module created the job.
|
|
||||||
func buildModuleBadge(t queue.JobType) fyne.CanvasObject {
|
|
||||||
label := widget.NewLabel(string(t))
|
|
||||||
label.TextStyle = fyne.TextStyle{Bold: true}
|
|
||||||
label.Alignment = fyne.TextAlignCenter
|
|
||||||
|
|
||||||
bg := canvas.NewRectangle(moduleColor(t))
|
|
||||||
bg.CornerRadius = 6
|
|
||||||
bg.SetMinSize(fyne.NewSize(label.MinSize().Width+12, label.MinSize().Height+6))
|
|
||||||
|
|
||||||
return container.NewMax(bg, container.NewCenter(label))
|
|
||||||
}
|
|
||||||
|
|
||||||
// moduleColor maps job types to distinct colors matching the main module colors
|
// moduleColor maps job types to distinct colors matching the main module colors
|
||||||
func moduleColor(t queue.JobType) color.Color {
|
func moduleColor(t queue.JobType) color.Color {
|
||||||
switch t {
|
switch t {
|
||||||
|
|
|
||||||
29
main.go
29
main.go
|
|
@ -593,26 +593,9 @@ func saveBenchmarkConfig(cfg benchmarkConfig) error {
|
||||||
return os.WriteFile(path, data, 0o644)
|
return os.WriteFile(path, data, 0o644)
|
||||||
}
|
}
|
||||||
|
|
||||||
// HistoryEntry represents a completed job in the history
|
|
||||||
type HistoryEntry struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Type queue.JobType `json:"type"`
|
|
||||||
Status queue.JobStatus `json:"status"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
InputFile string `json:"input_file"`
|
|
||||||
OutputFile string `json:"output_file"`
|
|
||||||
LogPath string `json:"log_path,omitempty"`
|
|
||||||
Config map[string]interface{} `json:"config"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
StartedAt *time.Time `json:"started_at,omitempty"`
|
|
||||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
|
||||||
Error string `json:"error,omitempty"`
|
|
||||||
FFmpegCmd string `json:"ffmpeg_cmd,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// historyConfig holds conversion history
|
// historyConfig holds conversion history
|
||||||
type historyConfig struct {
|
type historyConfig struct {
|
||||||
Entries []HistoryEntry `json:"entries"`
|
Entries []ui.HistoryEntry `json:"entries"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func historyConfigPath() string {
|
func historyConfigPath() string {
|
||||||
|
|
@ -634,7 +617,7 @@ func loadHistoryConfig() (historyConfig, error) {
|
||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
return historyConfig{Entries: []HistoryEntry{}}, nil
|
return historyConfig{Entries: []ui.HistoryEntry{}}, nil
|
||||||
}
|
}
|
||||||
return historyConfig{}, err
|
return historyConfig{}, err
|
||||||
}
|
}
|
||||||
|
|
@ -767,7 +750,7 @@ type appState struct {
|
||||||
interlaceAnalyzing bool
|
interlaceAnalyzing bool
|
||||||
|
|
||||||
// History sidebar state
|
// History sidebar state
|
||||||
historyEntries []HistoryEntry
|
historyEntries []ui.HistoryEntry
|
||||||
sidebarVisible bool
|
sidebarVisible bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -799,7 +782,7 @@ func (s *appState) addToHistory(job *queue.Job) {
|
||||||
// Build FFmpeg command from job config
|
// Build FFmpeg command from job config
|
||||||
cmdStr := buildFFmpegCommandFromJob(job)
|
cmdStr := buildFFmpegCommandFromJob(job)
|
||||||
|
|
||||||
entry := HistoryEntry{
|
entry := ui.HistoryEntry{
|
||||||
ID: job.ID,
|
ID: job.ID,
|
||||||
Type: job.Type,
|
Type: job.Type,
|
||||||
Status: job.Status,
|
Status: job.Status,
|
||||||
|
|
@ -823,7 +806,7 @@ func (s *appState) addToHistory(job *queue.Job) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prepend to history (newest first)
|
// Prepend to history (newest first)
|
||||||
s.historyEntries = append([]HistoryEntry{entry}, s.historyEntries...)
|
s.historyEntries = append([]ui.HistoryEntry{entry}, s.historyEntries...)
|
||||||
|
|
||||||
// Save to disk
|
// Save to disk
|
||||||
cfg := historyConfig{Entries: s.historyEntries}
|
cfg := historyConfig{Entries: s.historyEntries}
|
||||||
|
|
@ -4716,7 +4699,7 @@ func runGUI() {
|
||||||
if historyCfg, err := loadHistoryConfig(); err == nil {
|
if historyCfg, err := loadHistoryConfig(); err == nil {
|
||||||
state.historyEntries = historyCfg.Entries
|
state.historyEntries = historyCfg.Entries
|
||||||
} else {
|
} else {
|
||||||
state.historyEntries = []HistoryEntry{}
|
state.historyEntries = []ui.HistoryEntry{}
|
||||||
logging.Debug(logging.CatSystem, "failed to load history config: %v", err)
|
logging.Debug(logging.CatSystem, "failed to load history config: %v", err)
|
||||||
}
|
}
|
||||||
state.sidebarVisible = false
|
state.sidebarVisible = false
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user