VideoTools/internal/ui/components.go
Stu Leak b09ab8d8b4 Add job queue system with batch processing support
Implements a comprehensive job queue system for batch video processing:
- Job queue with priority-based processing
- Queue persistence (saves/restores across app restarts)
- Pause/resume/cancel individual jobs
- Real-time progress tracking
- Queue viewer UI with job management controls
- Clickable queue tile on main menu showing completed/total
- "View Queue" button in convert module

Batch processing features:
- Drag multiple video files to convert tile → auto-add to queue
- Drag folders → recursively scans and adds all videos
- Batch add confirmation dialog
- Supports 14 common video formats

Convert module improvements:
- "Add to Queue" button for queuing single conversions
- "CONVERT NOW" button (renamed for clarity)
- "View Queue" button for quick queue access

Technical implementation:
- internal/queue package with job management
- Job executor with FFmpeg integration
- Progress callbacks for live updates
- Tappable widget component for clickable UI elements

WIP: Queue system functional, tabs feature pending

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-26 17:19:40 -05:00

329 lines
7.7 KiB
Go

package ui
import (
"image/color"
"strings"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/canvas"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
"git.leaktechnologies.dev/stu/VideoTools/internal/logging"
)
var (
// GridColor is the color used for grid lines and borders
GridColor color.Color
// TextColor is the main text color
TextColor color.Color
)
// SetColors sets the UI colors
func SetColors(grid, text color.Color) {
GridColor = grid
TextColor = text
}
// MonoTheme ensures all text uses a monospace font
type MonoTheme struct{}
func (m *MonoTheme) Color(name fyne.ThemeColorName, variant fyne.ThemeVariant) color.Color {
return theme.DefaultTheme().Color(name, variant)
}
func (m *MonoTheme) Font(style fyne.TextStyle) fyne.Resource {
style.Monospace = true
return theme.DefaultTheme().Font(style)
}
func (m *MonoTheme) Icon(name fyne.ThemeIconName) fyne.Resource {
return theme.DefaultTheme().Icon(name)
}
func (m *MonoTheme) Size(name fyne.ThemeSizeName) float32 {
return theme.DefaultTheme().Size(name)
}
// ModuleTile is a clickable tile widget for module selection
type ModuleTile struct {
widget.BaseWidget
label string
color color.Color
enabled bool
onTapped func()
onDropped func([]fyne.URI)
}
// NewModuleTile creates a new module tile
func NewModuleTile(label string, col color.Color, enabled bool, tapped func(), dropped func([]fyne.URI)) *ModuleTile {
m := &ModuleTile{
label: strings.ToUpper(label),
color: col,
enabled: enabled,
onTapped: tapped,
onDropped: dropped,
}
m.ExtendBaseWidget(m)
return m
}
// DraggedOver implements desktop.Droppable interface
func (m *ModuleTile) DraggedOver(pos fyne.Position) {
logging.Debug(logging.CatUI, "DraggedOver tile=%s enabled=%v pos=%v", m.label, m.enabled, pos)
}
// Dropped implements desktop.Droppable interface
func (m *ModuleTile) Dropped(pos fyne.Position, items []fyne.URI) {
logging.Debug(logging.CatUI, "Dropped on tile=%s enabled=%v items=%v", m.label, m.enabled, items)
if m.enabled && m.onDropped != nil {
logging.Debug(logging.CatUI, "Calling onDropped callback for %s", m.label)
m.onDropped(items)
} else {
logging.Debug(logging.CatUI, "Drop ignored: enabled=%v hasCallback=%v", m.enabled, m.onDropped != nil)
}
}
func (m *ModuleTile) CreateRenderer() fyne.WidgetRenderer {
tileColor := m.color
labelColor := TextColor
// Dim disabled tiles
if !m.enabled {
// Reduce opacity by mixing with dark background
if c, ok := m.color.(color.NRGBA); ok {
tileColor = color.NRGBA{R: c.R / 3, G: c.G / 3, B: c.B / 3, A: c.A}
}
if c, ok := TextColor.(color.NRGBA); ok {
labelColor = color.NRGBA{R: c.R / 2, G: c.G / 2, B: c.B / 2, A: c.A}
}
}
bg := canvas.NewRectangle(tileColor)
bg.CornerRadius = 8
bg.StrokeColor = GridColor
bg.StrokeWidth = 1
txt := canvas.NewText(m.label, labelColor)
txt.TextStyle = fyne.TextStyle{Monospace: true, Bold: true}
txt.Alignment = fyne.TextAlignCenter
txt.TextSize = 20
return &moduleTileRenderer{
tile: m,
bg: bg,
label: txt,
}
}
func (m *ModuleTile) Tapped(*fyne.PointEvent) {
if m.enabled && m.onTapped != nil {
m.onTapped()
}
}
type moduleTileRenderer struct {
tile *ModuleTile
bg *canvas.Rectangle
label *canvas.Text
}
func (r *moduleTileRenderer) Layout(size fyne.Size) {
r.bg.Resize(size)
// Center the label by positioning it in the middle
labelSize := r.label.MinSize()
r.label.Resize(labelSize)
x := (size.Width - labelSize.Width) / 2
y := (size.Height - labelSize.Height) / 2
r.label.Move(fyne.NewPos(x, y))
}
func (r *moduleTileRenderer) MinSize() fyne.Size {
return fyne.NewSize(220, 110)
}
func (r *moduleTileRenderer) Refresh() {
r.bg.FillColor = r.tile.color
r.bg.Refresh()
r.label.Text = r.tile.label
r.label.Refresh()
}
func (r *moduleTileRenderer) Destroy() {}
func (r *moduleTileRenderer) Objects() []fyne.CanvasObject {
return []fyne.CanvasObject{r.bg, r.label}
}
// TintedBar creates a colored bar container
func TintedBar(col color.Color, body fyne.CanvasObject) fyne.CanvasObject {
rect := canvas.NewRectangle(col)
rect.SetMinSize(fyne.NewSize(0, 48))
padded := container.NewPadded(body)
return container.NewMax(rect, padded)
}
// Tappable wraps any canvas object and makes it tappable
type Tappable struct {
widget.BaseWidget
content fyne.CanvasObject
onTapped func()
}
// NewTappable creates a new tappable wrapper
func NewTappable(content fyne.CanvasObject, onTapped func()) *Tappable {
t := &Tappable{
content: content,
onTapped: onTapped,
}
t.ExtendBaseWidget(t)
return t
}
// CreateRenderer creates the renderer for the tappable
func (t *Tappable) CreateRenderer() fyne.WidgetRenderer {
return &tappableRenderer{
tappable: t,
content: t.content,
}
}
// Tapped handles tap events
func (t *Tappable) Tapped(*fyne.PointEvent) {
if t.onTapped != nil {
t.onTapped()
}
}
type tappableRenderer struct {
tappable *Tappable
content fyne.CanvasObject
}
func (r *tappableRenderer) Layout(size fyne.Size) {
r.content.Resize(size)
}
func (r *tappableRenderer) MinSize() fyne.Size {
return r.content.MinSize()
}
func (r *tappableRenderer) Refresh() {
r.content.Refresh()
}
func (r *tappableRenderer) Destroy() {}
func (r *tappableRenderer) Objects() []fyne.CanvasObject {
return []fyne.CanvasObject{r.content}
}
// DraggableVScroll creates a vertical scroll container with draggable track
type DraggableVScroll struct {
widget.BaseWidget
content fyne.CanvasObject
scroll *container.Scroll
}
// NewDraggableVScroll creates a new draggable vertical scroll container
func NewDraggableVScroll(content fyne.CanvasObject) *DraggableVScroll {
d := &DraggableVScroll{
content: content,
scroll: container.NewVScroll(content),
}
d.ExtendBaseWidget(d)
return d
}
// CreateRenderer creates the renderer for the draggable scroll
func (d *DraggableVScroll) CreateRenderer() fyne.WidgetRenderer {
return &draggableScrollRenderer{
scroll: d.scroll,
}
}
// Dragged handles drag events on the scrollbar track
func (d *DraggableVScroll) Dragged(ev *fyne.DragEvent) {
// Calculate the scroll position based on drag position
size := d.scroll.Size()
contentSize := d.content.MinSize()
if contentSize.Height <= size.Height {
return // No scrolling needed
}
// Calculate scroll ratio (0.0 to 1.0)
ratio := ev.Position.Y / size.Height
if ratio < 0 {
ratio = 0
}
if ratio > 1 {
ratio = 1
}
// Calculate target offset
maxOffset := contentSize.Height - size.Height
targetOffset := ratio * maxOffset
// Apply scroll offset
d.scroll.Offset = fyne.NewPos(0, targetOffset)
d.scroll.Refresh()
}
// DragEnd handles the end of a drag event
func (d *DraggableVScroll) DragEnd() {
// Nothing needed
}
// Tapped handles tap events on the scrollbar track
func (d *DraggableVScroll) Tapped(ev *fyne.PointEvent) {
// Jump to tapped position
size := d.scroll.Size()
contentSize := d.content.MinSize()
if contentSize.Height <= size.Height {
return
}
ratio := ev.Position.Y / size.Height
if ratio < 0 {
ratio = 0
}
if ratio > 1 {
ratio = 1
}
maxOffset := contentSize.Height - size.Height
targetOffset := ratio * maxOffset
d.scroll.Offset = fyne.NewPos(0, targetOffset)
d.scroll.Refresh()
}
// Scrolled handles scroll events (mouse wheel)
func (d *DraggableVScroll) Scrolled(ev *fyne.ScrollEvent) {
d.scroll.Scrolled(ev)
}
type draggableScrollRenderer struct {
scroll *container.Scroll
}
func (r *draggableScrollRenderer) Layout(size fyne.Size) {
r.scroll.Resize(size)
}
func (r *draggableScrollRenderer) MinSize() fyne.Size {
return r.scroll.MinSize()
}
func (r *draggableScrollRenderer) Refresh() {
r.scroll.Refresh()
}
func (r *draggableScrollRenderer) Destroy() {}
func (r *draggableScrollRenderer) Objects() []fyne.CanvasObject {
return []fyne.CanvasObject{r.scroll}
}