VideoTools/internal/ui/mainmenu.go
Stu Leak d240f1f773 feat(ui): Redesign main menu with compact 3x5 grid layout
Redesigned main menu for better space efficiency and visual organization:
- Changed from flexible GridWrap to fixed 3-column grid layout
- Organized modules into priority-based sections with category labels
- Category labels positioned above each section (Convert, Inspect, Disc, Playback)
- Reduced tile size from 150x65 to 135x58 for better spacing
- Removed excessive padding for more compact layout
- All 15 modules now visible in organized 3x5 grid

Layout organization:
- Row 1-2: Convert modules (Convert, Merge, Trim, Filters, Audio, Subtitles)
- Row 3: Inspect/Advanced (Compare, Inspect, Upscale)
- Row 4: Disc modules (Author, Rip, Blu-Ray)
- Row 5: Misc modules (Player, Thumb, Settings)

This addresses user feedback about wasted space and provides a more polished, professional appearance.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-31 08:43:49 -05:00

343 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package ui
import (
"fmt"
"image/color"
"sort"
"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/logging"
"git.leaktechnologies.dev/stu/VideoTools/internal/queue"
"git.leaktechnologies.dev/stu/VideoTools/internal/utils"
)
// ModuleInfo contains information about a module for display
type ModuleInfo struct {
ID string
Label string
Color color.Color
Enabled bool
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
Progress float64 // 0.0 to 1.0 for in-progress jobs
}
// 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(), onToggleSidebar func(), sidebarVisible bool, sidebar fyne.CanvasObject, titleColor, queueColor, textColor color.Color, queueCompleted, queueTotal int, hasBenchmark bool) fyne.CanvasObject {
title := canvas.NewText("VIDEOTOOLS", titleColor)
title.TextStyle = fyne.TextStyle{Monospace: true, Bold: true}
title.TextSize = 18
queueTile := buildQueueTile(queueCompleted, queueTotal, queueColor, textColor, onQueueClick)
sidebarToggleBtn := widget.NewButton("☰", onToggleSidebar)
sidebarToggleBtn.Importance = widget.LowImportance
benchmarkBtn := widget.NewButton("Benchmark", onBenchmarkClick)
// Highlight the benchmark button if no benchmark has been run
if !hasBenchmark {
benchmarkBtn.Importance = widget.HighImportance
} else {
benchmarkBtn.Importance = widget.LowImportance
}
viewResultsBtn := widget.NewButton("Results", onBenchmarkHistoryClick)
viewResultsBtn.Importance = widget.LowImportance
// Build header controls dynamically - only show logs button if callback is provided
headerControls := []fyne.CanvasObject{sidebarToggleBtn}
if onLogsClick != nil {
logsBtn := widget.NewButton("Logs", onLogsClick)
logsBtn.Importance = widget.LowImportance
headerControls = append(headerControls, logsBtn)
}
headerControls = append(headerControls, benchmarkBtn, viewResultsBtn, queueTile)
// Compact header - title on left, controls on right
header := container.NewBorder(
nil, nil,
title,
container.NewHBox(headerControls...),
nil,
)
// Create module map for quick lookup
moduleMap := make(map[string]ModuleInfo)
for _, mod := range modules {
moduleMap[mod.ID] = mod
}
// Helper to build a tile
buildTile := func(modID string) fyne.CanvasObject {
mod, exists := moduleMap[modID]
if !exists {
return layout.NewSpacer()
}
var tapFunc func()
var dropFunc func([]fyne.URI)
if mod.Enabled {
id := modID
tapFunc = func() { onModuleClick(id) }
dropFunc = func(items []fyne.URI) {
logging.Debug(logging.CatUI, "MainMenu dropFunc called for module=%s itemCount=%d", id, len(items))
onModuleDrop(id, items)
}
}
return buildModuleTile(mod, tapFunc, dropFunc)
}
// Helper to create category label
makeCatLabel := func(text string) *canvas.Text {
label := canvas.NewText(text, textColor)
label.TextSize = 10
label.Alignment = fyne.TextAlignLeading
return label
}
// Build rows with category labels above tiles
var rows []fyne.CanvasObject
// Convert section
rows = append(rows, makeCatLabel("Convert"))
rows = append(rows, container.NewGridWithColumns(3,
buildTile("convert"), buildTile("merge"), buildTile("trim"),
))
rows = append(rows, container.NewGridWithColumns(3,
buildTile("filters"), buildTile("audio"), buildTile("subtitles"),
))
// Inspect section
rows = append(rows, makeCatLabel("Inspect"))
rows = append(rows, container.NewGridWithColumns(3,
buildTile("compare"), buildTile("inspect"), buildTile("upscale"),
))
// Disc section
rows = append(rows, makeCatLabel("Disc"))
rows = append(rows, container.NewGridWithColumns(3,
buildTile("author"), buildTile("rip"), buildTile("bluray"),
))
// Playback section
rows = append(rows, makeCatLabel("Playback"))
rows = append(rows, container.NewGridWithColumns(3,
buildTile("player"), buildTile("thumb"), buildTile("settings"),
))
gridBox := container.NewVBox(rows...)
scroll := container.NewVScroll(gridBox)
scroll.SetMinSize(fyne.NewSize(0, 0))
body := container.NewBorder(
header,
nil, nil, nil,
scroll,
)
// Wrap with HSplit if sidebar is visible
if sidebarVisible && sidebar != nil {
split := container.NewHSplit(sidebar, body)
split.Offset = 0.2
return split
}
return body
}
// buildModuleTile creates a single module tile
func buildModuleTile(mod ModuleInfo, tapped func(), dropped func([]fyne.URI)) fyne.CanvasObject {
logging.Debug(logging.CatUI, "building tile %s color=%v enabled=%v", mod.ID, mod.Color, mod.Enabled)
return NewModuleTile(mod.Label, mod.Color, mod.Enabled, tapped, dropped)
}
// buildQueueTile creates the queue status tile
func buildQueueTile(completed, total int, queueColor, textColor color.Color, onClick func()) fyne.CanvasObject {
rect := canvas.NewRectangle(queueColor)
rect.CornerRadius = 6
rect.SetMinSize(fyne.NewSize(120, 40))
text := canvas.NewText(fmt.Sprintf("QUEUE: %d/%d", completed, total), textColor)
text.Alignment = fyne.TextAlignCenter
text.TextStyle = fyne.TextStyle{Monospace: true, Bold: true}
text.TextSize = 14
tile := container.NewMax(rect, container.NewCenter(text))
// Make it tappable
tappable := NewTappable(tile, onClick)
return tappable
}
// sortedKeys returns sorted keys for stable category ordering
func sortedKeys(m map[string][]fyne.CanvasObject) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
// BuildHistorySidebar creates the history sidebar with tabs
func BuildHistorySidebar(
entries []HistoryEntry,
activeJobs []HistoryEntry,
onEntryClick func(HistoryEntry),
onEntryDelete 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
inProgressList := buildHistoryList(activeJobs, onEntryClick, nil, bgColor, textColor) // No delete for active jobs
completedList := buildHistoryList(completedEntries, onEntryClick, onEntryDelete, bgColor, textColor)
failedList := buildHistoryList(failedEntries, onEntryClick, onEntryDelete, bgColor, textColor)
// Tabs - In Progress first for quick visibility
tabs := container.NewAppTabs(
container.NewTabItem("In Progress", container.NewVScroll(inProgressList)),
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),
onEntryDelete 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, onEntryDelete, bgColor, textColor))
}
return container.NewVBox(items...)
}
func buildHistoryItem(
entry HistoryEntry,
onEntryClick func(HistoryEntry),
onEntryDelete func(HistoryEntry),
bgColor, textColor color.Color,
) fyne.CanvasObject {
// Badge
badge := BuildModuleBadge(entry.Type)
// Capture entry for closures
capturedEntry := entry
// Build header row with badge and optional delete button
headerItems := []fyne.CanvasObject{badge, layout.NewSpacer()}
if onEntryDelete != nil {
// Delete button - small "×" button (only for completed/failed)
deleteBtn := widget.NewButton("×", func() {
onEntryDelete(capturedEntry)
})
deleteBtn.Importance = widget.LowImportance
headerItems = append(headerItems, deleteBtn)
}
// Title
titleLabel := widget.NewLabel(utils.ShortenMiddle(entry.Title, 25))
titleLabel.TextStyle = fyne.TextStyle{Bold: true}
// Timestamp or status info
var timeStr string
if entry.Status == queue.JobStatusRunning || entry.Status == queue.JobStatusPending {
// For in-progress jobs, show status
if entry.Status == queue.JobStatusRunning {
timeStr = "Running..."
} else {
timeStr = "Pending"
}
} else {
// For completed/failed jobs, show timestamp
if entry.CompletedAt != nil {
timeStr = entry.CompletedAt.Format("Jan 2, 15:04")
} else {
timeStr = "Unknown"
}
}
timeLabel := widget.NewLabel(timeStr)
timeLabel.TextStyle = fyne.TextStyle{Monospace: true}
// Progress bar for in-progress jobs
contentItems := []fyne.CanvasObject{
container.NewHBox(headerItems...),
titleLabel,
timeLabel,
}
if entry.Status == queue.JobStatusRunning || entry.Status == queue.JobStatusPending {
// Add progress bar for active jobs
moduleCol := ModuleColor(entry.Type)
progressBar := NewStripedProgress(moduleCol)
progressBar.SetProgress(entry.Progress)
contentItems = append(contentItems, progressBar)
}
// 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(contentItems...),
)
card := canvas.NewRectangle(bgColor)
card.CornerRadius = 4
item := container.NewPadded(container.NewMax(card, content))
return NewTappable(item, func() { onEntryClick(capturedEntry) })
}