VideoTools/internal/ui/queueview.go
Stu Leak 385c6f736d 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>
2025-12-17 19:34:22 -05:00

440 lines
12 KiB
Go

package ui
import (
"fmt"
"image"
"image/color"
"strings"
"time"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/canvas"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/layout"
"fyne.io/fyne/v2/widget"
"git.leaktechnologies.dev/stu/VideoTools/internal/queue"
"git.leaktechnologies.dev/stu/VideoTools/internal/utils"
)
// stripedProgress renders a progress bar with a tinted stripe pattern.
type stripedProgress struct {
widget.BaseWidget
progress float64
color color.Color
bg color.Color
offset float64
}
func newStripedProgress(col color.Color) *stripedProgress {
sp := &stripedProgress{
progress: 0,
color: col,
bg: color.RGBA{R: 34, G: 38, B: 48, A: 255}, // dark neutral
}
sp.ExtendBaseWidget(sp)
return sp
}
func (s *stripedProgress) SetProgress(p float64) {
if p < 0 {
p = 0
}
if p > 1 {
p = 1
}
s.progress = p
s.Refresh()
}
func (s *stripedProgress) CreateRenderer() fyne.WidgetRenderer {
bgRect := canvas.NewRectangle(s.bg)
fillRect := canvas.NewRectangle(applyAlpha(s.color, 200))
stripes := canvas.NewRaster(func(w, h int) image.Image {
img := image.NewRGBA(image.Rect(0, 0, w, h))
light := applyAlpha(s.color, 80)
dark := applyAlpha(s.color, 220)
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
// animate diagonal stripes using offset
if (((x + y) + int(s.offset)) / 4 % 2) == 0 {
img.Set(x, y, light)
} else {
img.Set(x, y, dark)
}
}
}
return img
})
objects := []fyne.CanvasObject{bgRect, fillRect, stripes}
r := &stripedProgressRenderer{
bar: s,
bg: bgRect,
fill: fillRect,
stripes: stripes,
objects: objects,
}
return r
}
type stripedProgressRenderer struct {
bar *stripedProgress
bg *canvas.Rectangle
fill *canvas.Rectangle
stripes *canvas.Raster
objects []fyne.CanvasObject
}
func (r *stripedProgressRenderer) Layout(size fyne.Size) {
r.bg.Resize(size)
r.bg.Move(fyne.NewPos(0, 0))
fillWidth := size.Width * float32(r.bar.progress)
fillSize := fyne.NewSize(fillWidth, size.Height)
r.fill.Resize(fillSize)
r.fill.Move(fyne.NewPos(0, 0))
r.stripes.Resize(fillSize)
r.stripes.Move(fyne.NewPos(0, 0))
}
func (r *stripedProgressRenderer) MinSize() fyne.Size {
return fyne.NewSize(120, 20)
}
func (r *stripedProgressRenderer) Refresh() {
// small drift to animate stripes
r.bar.offset += 2
r.Layout(r.bg.Size())
canvas.Refresh(r.bg)
canvas.Refresh(r.stripes)
}
func (r *stripedProgressRenderer) BackgroundColor() color.Color { return color.Transparent }
func (r *stripedProgressRenderer) Objects() []fyne.CanvasObject { return r.objects }
func (r *stripedProgressRenderer) Destroy() {}
func applyAlpha(c color.Color, alpha uint8) color.Color {
r, g, b, _ := c.RGBA()
return color.NRGBA{R: uint8(r >> 8), G: uint8(g >> 8), B: uint8(b >> 8), A: alpha}
}
// BuildQueueView creates the queue viewer UI
func BuildQueueView(
jobs []*queue.Job,
onBack func(),
onPause func(string),
onResume func(string),
onCancel func(string),
onRemove func(string),
onMoveUp func(string),
onMoveDown func(string),
onPauseAll func(),
onResumeAll func(),
onStart func(),
onClear func(),
onClearAll func(),
onCopyError func(string),
onViewLog func(string),
onCopyCommand func(string),
titleColor, bgColor, textColor color.Color,
) (fyne.CanvasObject, *container.Scroll) {
// Header
title := canvas.NewText("JOB QUEUE", titleColor)
title.TextStyle = fyne.TextStyle{Monospace: true, Bold: true}
title.TextSize = 24
backBtn := widget.NewButton("← Back", onBack)
backBtn.Importance = widget.LowImportance
startAllBtn := widget.NewButton("Start Queue", onStart)
startAllBtn.Importance = widget.MediumImportance
pauseAllBtn := widget.NewButton("Pause All", onPauseAll)
pauseAllBtn.Importance = widget.LowImportance
resumeAllBtn := widget.NewButton("Resume All", onResumeAll)
resumeAllBtn.Importance = widget.LowImportance
clearBtn := widget.NewButton("Clear Completed", onClear)
clearBtn.Importance = widget.LowImportance
clearAllBtn := widget.NewButton("Clear All", onClearAll)
clearAllBtn.Importance = widget.DangerImportance
buttonRow := container.NewHBox(startAllBtn, pauseAllBtn, resumeAllBtn, clearAllBtn, clearBtn)
header := container.NewBorder(
nil, nil,
backBtn,
buttonRow,
container.NewCenter(title),
)
// Job list
var jobItems []fyne.CanvasObject
if len(jobs) == 0 {
emptyMsg := widget.NewLabel("No jobs in queue")
emptyMsg.Alignment = fyne.TextAlignCenter
jobItems = append(jobItems, container.NewCenter(emptyMsg))
} else {
for _, job := range jobs {
jobItems = append(jobItems, buildJobItem(job, onPause, onResume, onCancel, onRemove, onMoveUp, onMoveDown, onCopyError, onViewLog, onCopyCommand, bgColor, textColor))
}
}
jobList := container.NewVBox(jobItems...)
// Use a scroll container anchored to the top to avoid jumpy scroll-to-content behavior.
scrollable := container.NewScroll(jobList)
scrollable.SetMinSize(fyne.NewSize(0, 0))
scrollable.Offset = fyne.NewPos(0, 0)
body := container.NewBorder(
header,
nil, nil, nil,
scrollable,
)
return container.NewPadded(body), scrollable
}
// buildJobItem creates a single job item in the queue list
func buildJobItem(
job *queue.Job,
onPause func(string),
onResume func(string),
onCancel func(string),
onRemove func(string),
onMoveUp func(string),
onMoveDown func(string),
onCopyError func(string),
onViewLog func(string),
onCopyCommand func(string),
bgColor, textColor color.Color,
) fyne.CanvasObject {
// Status color
statusColor := GetStatusColor(job.Status)
// Status indicator
statusRect := canvas.NewRectangle(statusColor)
statusRect.SetMinSize(fyne.NewSize(6, 0))
// Title and description
titleText := utils.ShortenMiddle(job.Title, 60)
descText := utils.ShortenMiddle(job.Description, 90)
titleLabel := widget.NewLabel(titleText)
titleLabel.TextStyle = fyne.TextStyle{Bold: true}
descLabel := widget.NewLabel(descText)
descLabel.TextStyle = fyne.TextStyle{Italic: true}
descLabel.Wrapping = fyne.TextWrapWord
// Progress bar (for running jobs)
progress := newStripedProgress(moduleColor(job.Type))
progress.SetProgress(job.Progress / 100.0)
if job.Status == queue.JobStatusCompleted {
progress.SetProgress(1.0)
}
progressWidget := progress
// Module badge
badge := BuildModuleBadge(job.Type)
// Status text
statusText := getStatusText(job)
statusLabel := widget.NewLabel(statusText)
statusLabel.TextStyle = fyne.TextStyle{Monospace: true}
statusLabel.Wrapping = fyne.TextWrapWord
// Control buttons
var buttons []fyne.CanvasObject
// Reorder arrows for pending/paused jobs
if job.Status == queue.JobStatusPending || job.Status == queue.JobStatusPaused {
buttons = append(buttons,
widget.NewButton("↑", func() { onMoveUp(job.ID) }),
widget.NewButton("↓", func() { onMoveDown(job.ID) }),
)
}
switch job.Status {
case queue.JobStatusRunning:
buttons = append(buttons,
widget.NewButton("Copy Command", func() { onCopyCommand(job.ID) }),
widget.NewButton("Pause", func() { onPause(job.ID) }),
widget.NewButton("Cancel", func() { onCancel(job.ID) }),
)
case queue.JobStatusPaused:
buttons = append(buttons,
widget.NewButton("Resume", func() { onResume(job.ID) }),
widget.NewButton("Cancel", func() { onCancel(job.ID) }),
)
case queue.JobStatusPending:
buttons = append(buttons,
widget.NewButton("Copy Command", func() { onCopyCommand(job.ID) }),
widget.NewButton("Cancel", func() { onCancel(job.ID) }),
)
case queue.JobStatusCompleted, queue.JobStatusFailed, queue.JobStatusCancelled:
if job.Status == queue.JobStatusFailed && strings.TrimSpace(job.Error) != "" && onCopyError != nil {
buttons = append(buttons,
widget.NewButton("Copy Error", func() { onCopyError(job.ID) }),
)
}
if job.LogPath != "" && onViewLog != nil {
buttons = append(buttons,
widget.NewButton("View Log", func() { onViewLog(job.ID) }),
)
}
buttons = append(buttons,
widget.NewButton("Remove", func() { onRemove(job.ID) }),
)
}
buttonBox := container.NewHBox(buttons...)
// Info section
infoBox := container.NewVBox(
container.NewHBox(titleLabel, layout.NewSpacer(), badge),
descLabel,
progressWidget,
statusLabel,
)
// Main content
content := container.NewBorder(
nil, nil,
statusRect,
buttonBox,
infoBox,
)
// Card background
card := canvas.NewRectangle(bgColor)
card.CornerRadius = 4
item := container.NewPadded(
container.NewMax(card, content),
)
// Wrap with draggable to allow drag-to-reorder (up/down by drag direction)
return newDraggableJobItem(job.ID, item, func(id string, dir int) {
if dir < 0 {
onMoveUp(id)
} else if dir > 0 {
onMoveDown(id)
}
})
}
// getStatusText returns a human-readable status string
func getStatusText(job *queue.Job) string {
switch job.Status {
case queue.JobStatusPending:
return fmt.Sprintf("Status: Pending | Priority: %d", job.Priority)
case queue.JobStatusRunning:
elapsed := ""
if job.StartedAt != nil {
elapsed = fmt.Sprintf(" | Elapsed: %s", time.Since(*job.StartedAt).Round(time.Second))
}
// Add FPS and speed info if available in Config
var extras string
if job.Config != nil {
if fps, ok := job.Config["fps"].(float64); ok && fps > 0 {
extras += fmt.Sprintf(" | %.0f fps", fps)
}
if speed, ok := job.Config["speed"].(float64); ok && speed > 0 {
extras += fmt.Sprintf(" | %.2fx", speed)
}
if etaDuration, ok := job.Config["eta"].(time.Duration); ok && etaDuration > 0 {
extras += fmt.Sprintf(" | ETA %s", etaDuration.Round(time.Second))
}
}
return fmt.Sprintf("Status: Running | Progress: %.1f%%%s%s", job.Progress, elapsed, extras)
case queue.JobStatusPaused:
return "Status: Paused"
case queue.JobStatusCompleted:
duration := ""
if job.StartedAt != nil && job.CompletedAt != nil {
duration = fmt.Sprintf(" | Duration: %s", job.CompletedAt.Sub(*job.StartedAt).Round(time.Second))
}
return fmt.Sprintf("Status: Completed%s", duration)
case queue.JobStatusFailed:
// Truncate error to prevent UI overflow
errMsg := job.Error
maxLen := 150
if len(errMsg) > maxLen {
errMsg = errMsg[:maxLen] + "… (see Copy Error button for full message)"
}
return fmt.Sprintf("Status: Failed | Error: %s", errMsg)
case queue.JobStatusCancelled:
return "Status: Cancelled"
default:
return fmt.Sprintf("Status: %s", job.Status)
}
}
// moduleColor maps job types to distinct colors matching the main module colors
func moduleColor(t queue.JobType) color.Color {
switch t {
case queue.JobTypeConvert:
return color.RGBA{R: 139, G: 68, B: 255, A: 255} // Violet (#8B44FF)
case queue.JobTypeMerge:
return color.RGBA{R: 68, G: 136, B: 255, A: 255} // Blue (#4488FF)
case queue.JobTypeTrim:
return color.RGBA{R: 68, G: 221, B: 255, A: 255} // Cyan (#44DDFF)
case queue.JobTypeFilter:
return color.RGBA{R: 68, G: 255, B: 136, A: 255} // Green (#44FF88)
case queue.JobTypeUpscale:
return color.RGBA{R: 170, G: 255, B: 68, A: 255} // Yellow-Green (#AAFF44)
case queue.JobTypeAudio:
return color.RGBA{R: 255, G: 215, B: 68, A: 255} // Yellow (#FFD744)
case queue.JobTypeThumb:
return color.RGBA{R: 255, G: 136, B: 68, A: 255} // Orange (#FF8844)
default:
return color.Gray{Y: 180}
}
}
// draggableJobItem allows simple drag up/down to reorder one slot at a time.
type draggableJobItem struct {
widget.BaseWidget
jobID string
content fyne.CanvasObject
onReorder func(string, int) // id, direction (-1 up, +1 down)
accumY float32
}
func newDraggableJobItem(id string, content fyne.CanvasObject, onReorder func(string, int)) *draggableJobItem {
d := &draggableJobItem{
jobID: id,
content: content,
onReorder: onReorder,
}
d.ExtendBaseWidget(d)
return d
}
func (d *draggableJobItem) CreateRenderer() fyne.WidgetRenderer {
return widget.NewSimpleRenderer(d.content)
}
func (d *draggableJobItem) Dragged(ev *fyne.DragEvent) {
// fyne.Delta is a struct with dx, dy fields
d.accumY += ev.Dragged.DY
}
func (d *draggableJobItem) DragEnd() {
const threshold float32 = 25
if d.accumY <= -threshold {
d.onReorder(d.jobID, -1)
} else if d.accumY >= threshold {
d.onReorder(d.jobID, 1)
}
d.accumY = 0
}