Changed button and input backgrounds to use the brighter hover color as their default appearance instead of dull grey. This makes dropdowns and buttons more visually appealing by default.
931 lines
24 KiB
Go
931 lines
24 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/dialog"
|
|
"fyne.io/fyne/v2/driver/desktop"
|
|
"fyne.io/fyne/v2/layout"
|
|
"fyne.io/fyne/v2/theme"
|
|
"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"
|
|
)
|
|
|
|
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 and swaps hover/selection colors
|
|
type MonoTheme struct{}
|
|
|
|
func (m *MonoTheme) Color(name fyne.ThemeColorName, variant fyne.ThemeVariant) color.Color {
|
|
switch name {
|
|
case theme.ColorNameSelection:
|
|
// Use the default hover color for selection
|
|
return theme.DefaultTheme().Color(theme.ColorNameHover, variant)
|
|
case theme.ColorNameHover:
|
|
// Use the default selection color for hover
|
|
return theme.DefaultTheme().Color(theme.ColorNameSelection, variant)
|
|
case theme.ColorNameButton:
|
|
// Use hover color as default button background
|
|
return theme.DefaultTheme().Color(theme.ColorNameHover, variant)
|
|
case theme.ColorNameInputBackground:
|
|
// Use hover color as default input background (for dropdowns/entries)
|
|
return theme.DefaultTheme().Color(theme.ColorNameHover, variant)
|
|
}
|
|
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
|
|
missingDependencies bool
|
|
onTapped func()
|
|
onDropped func([]fyne.URI)
|
|
flashing bool
|
|
draggedOver bool
|
|
}
|
|
|
|
// NewModuleTile creates a new module tile
|
|
func NewModuleTile(label string, col color.Color, enabled bool, missingDeps bool, tapped func(), dropped func([]fyne.URI)) *ModuleTile {
|
|
m := &ModuleTile{
|
|
label: strings.ToUpper(label),
|
|
color: col,
|
|
missingDependencies: missingDeps,
|
|
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)
|
|
if m.enabled {
|
|
m.draggedOver = true
|
|
m.Refresh()
|
|
}
|
|
}
|
|
|
|
// DraggedOut is called when drag leaves the tile
|
|
func (m *ModuleTile) DraggedOut() {
|
|
logging.Debug(logging.CatUI, "DraggedOut tile=%s", m.label)
|
|
m.draggedOver = false
|
|
m.Refresh()
|
|
}
|
|
|
|
// Dropped implements desktop.Droppable interface
|
|
func (m *ModuleTile) Dropped(pos fyne.Position, items []fyne.URI) {
|
|
fmt.Printf("[DROPTILE] Dropped on tile=%s enabled=%v itemCount=%d\n", m.label, m.enabled, len(items))
|
|
logging.Debug(logging.CatUI, "Dropped on tile=%s enabled=%v items=%v", m.label, m.enabled, items)
|
|
// Reset dragged over state
|
|
m.draggedOver = false
|
|
|
|
if m.enabled && m.onDropped != nil {
|
|
fmt.Printf("[DROPTILE] Calling callback for %s\n", m.label)
|
|
logging.Debug(logging.CatUI, "Calling onDropped callback for %s", m.label)
|
|
// Trigger flash animation
|
|
m.flashing = true
|
|
m.Refresh()
|
|
// Reset flash after 300ms
|
|
time.AfterFunc(300*time.Millisecond, func() {
|
|
m.flashing = false
|
|
m.Refresh()
|
|
})
|
|
m.onDropped(items)
|
|
} else {
|
|
fmt.Printf("[DROPTILE] Drop IGNORED on %s: enabled=%v hasCallback=%v\n", m.label, m.enabled, m.onDropped != nil)
|
|
logging.Debug(logging.CatUI, "Drop ignored: enabled=%v hasCallback=%v", m.enabled, m.onDropped != nil)
|
|
}
|
|
}
|
|
|
|
// getContrastColor returns black or white text color based on background brightness
|
|
func getContrastColor(bgColor color.Color) color.Color {
|
|
r, g, b, _ := bgColor.RGBA()
|
|
// Convert from 16-bit to 8-bit
|
|
r8 := float64(r >> 8)
|
|
g8 := float64(g >> 8)
|
|
b8 := float64(b >> 8)
|
|
|
|
// Calculate relative luminance (WCAG formula)
|
|
luminance := (0.2126*r8 + 0.7152*g8 + 0.0722*b8) / 255.0
|
|
|
|
// If bright background, use dark text; if dark background, use light text
|
|
if luminance > 0.5 {
|
|
return color.NRGBA{R: 20, G: 20, B: 20, A: 255} // Dark text
|
|
}
|
|
return TextColor // Light text
|
|
}
|
|
|
|
func (m *ModuleTile) CreateRenderer() fyne.WidgetRenderer {
|
|
tileColor := m.color
|
|
labelColor := TextColor // White text for all modules
|
|
|
|
// Orange background for modules missing dependencies
|
|
if m.missingDependencies {
|
|
tileColor = color.NRGBA{R: 255, G: 152, B: 0, A: 255} // Orange
|
|
} else if !m.enabled {
|
|
// Grey background for not implemented modules
|
|
tileColor = color.NRGBA{R: 80, G: 80, B: 80, A: 255}
|
|
}
|
|
|
|
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
|
|
|
|
// Lock icon for disabled modules
|
|
lockIcon := canvas.NewText("🔒", color.NRGBA{R: 200, G: 200, B: 200, A: 255})
|
|
lockIcon.TextSize = 16
|
|
lockIcon.Alignment = fyne.TextAlignCenter
|
|
if m.enabled {
|
|
lockIcon.Hide()
|
|
}
|
|
|
|
// Diagonal stripe overlay for disabled modules
|
|
disabledStripe := canvas.NewRaster(func(w, h int) image.Image {
|
|
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
|
|
|
// Only draw stripes if disabled
|
|
if !m.enabled {
|
|
// Semi-transparent dark stripes
|
|
darkStripe := color.NRGBA{R: 0, G: 0, B: 0, A: 100}
|
|
lightStripe := color.NRGBA{R: 0, G: 0, B: 0, A: 30}
|
|
|
|
for y := 0; y < h; y++ {
|
|
for x := 0; x < w; x++ {
|
|
// Thicker diagonal stripes (dividing by 8 instead of 4)
|
|
if ((x + y) / 8 % 2) == 0 {
|
|
img.Set(x, y, darkStripe)
|
|
} else {
|
|
img.Set(x, y, lightStripe)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Return transparent image for enabled modules
|
|
return img
|
|
})
|
|
|
|
return &moduleTileRenderer{
|
|
tile: m,
|
|
bg: bg,
|
|
label: txt,
|
|
lockIcon: lockIcon,
|
|
disabledStripe: disabledStripe,
|
|
}
|
|
}
|
|
|
|
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
|
|
lockIcon *canvas.Text
|
|
disabledStripe *canvas.Raster
|
|
}
|
|
|
|
func (r *moduleTileRenderer) Layout(size fyne.Size) {
|
|
r.bg.Resize(size)
|
|
r.bg.Move(fyne.NewPos(0, 0))
|
|
|
|
// Stripe overlay covers entire tile
|
|
if r.disabledStripe != nil {
|
|
r.disabledStripe.Resize(size)
|
|
r.disabledStripe.Move(fyne.NewPos(0, 0))
|
|
}
|
|
|
|
// 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))
|
|
|
|
// Position lock icon in top-right corner
|
|
if r.lockIcon != nil {
|
|
lockSize := r.lockIcon.MinSize()
|
|
r.lockIcon.Resize(lockSize)
|
|
lockX := size.Width - lockSize.Width - 4
|
|
lockY := float32(4)
|
|
r.lockIcon.Move(fyne.NewPos(lockX, lockY))
|
|
}
|
|
}
|
|
|
|
func (r *moduleTileRenderer) MinSize() fyne.Size {
|
|
return fyne.NewSize(135, 58)
|
|
}
|
|
|
|
func (r *moduleTileRenderer) Refresh() {
|
|
// Update tile color and text color based on enabled state
|
|
if r.tile.enabled {
|
|
r.bg.FillColor = r.tile.color
|
|
r.label.Color = getContrastColor(r.tile.color)
|
|
if r.lockIcon != nil {
|
|
r.lockIcon.Hide()
|
|
}
|
|
} else {
|
|
// Dim disabled tiles
|
|
if c, ok := r.tile.color.(color.NRGBA); ok {
|
|
r.bg.FillColor = color.NRGBA{R: c.R / 3, G: c.G / 3, B: c.B / 3, A: c.A}
|
|
}
|
|
r.label.Color = color.NRGBA{R: 100, G: 100, B: 100, A: 255}
|
|
if r.lockIcon != nil {
|
|
r.lockIcon.Show()
|
|
}
|
|
}
|
|
|
|
// Apply visual feedback based on state
|
|
if r.tile.flashing {
|
|
// Flash animation - white outline
|
|
r.bg.StrokeColor = color.White
|
|
r.bg.StrokeWidth = 3
|
|
} else if r.tile.draggedOver {
|
|
// Dragging over - cyan/blue outline to indicate drop zone
|
|
r.bg.StrokeColor = color.NRGBA{R: 0, G: 200, B: 255, A: 255}
|
|
r.bg.StrokeWidth = 3
|
|
} else {
|
|
// Normal state
|
|
r.bg.StrokeColor = GridColor
|
|
r.bg.StrokeWidth = 1
|
|
}
|
|
|
|
r.bg.Refresh()
|
|
r.label.Text = r.tile.label
|
|
r.label.Refresh()
|
|
if r.lockIcon != nil {
|
|
r.lockIcon.Refresh()
|
|
}
|
|
if r.disabledStripe != nil {
|
|
r.disabledStripe.Refresh()
|
|
}
|
|
}
|
|
|
|
func (r *moduleTileRenderer) Destroy() {}
|
|
|
|
func (r *moduleTileRenderer) Objects() []fyne.CanvasObject {
|
|
return []fyne.CanvasObject{r.bg, r.disabledStripe, r.label, r.lockIcon}
|
|
}
|
|
|
|
// 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}
|
|
}
|
|
|
|
// Droppable wraps any canvas object and makes it a drop target (files/URIs)
|
|
type Droppable struct {
|
|
widget.BaseWidget
|
|
content fyne.CanvasObject
|
|
onDropped func([]fyne.URI)
|
|
}
|
|
|
|
// NewDroppable creates a new droppable wrapper
|
|
func NewDroppable(content fyne.CanvasObject, onDropped func([]fyne.URI)) *Droppable {
|
|
d := &Droppable{
|
|
content: content,
|
|
onDropped: onDropped,
|
|
}
|
|
d.ExtendBaseWidget(d)
|
|
return d
|
|
}
|
|
|
|
// CreateRenderer creates the renderer for the droppable
|
|
func (d *Droppable) CreateRenderer() fyne.WidgetRenderer {
|
|
return &droppableRenderer{
|
|
droppable: d,
|
|
content: d.content,
|
|
}
|
|
}
|
|
|
|
// DraggedOver highlights when drag is over (optional)
|
|
func (d *Droppable) DraggedOver(pos fyne.Position) {
|
|
_ = pos
|
|
}
|
|
|
|
// DraggedOut clears highlight (optional)
|
|
func (d *Droppable) DraggedOut() {
|
|
}
|
|
|
|
// Dropped handles drop events
|
|
func (d *Droppable) Dropped(_ fyne.Position, items []fyne.URI) {
|
|
if d.onDropped != nil && len(items) > 0 {
|
|
d.onDropped(items)
|
|
}
|
|
}
|
|
|
|
type droppableRenderer struct {
|
|
droppable *Droppable
|
|
content fyne.CanvasObject
|
|
}
|
|
|
|
func (r *droppableRenderer) Layout(size fyne.Size) {
|
|
r.content.Resize(size)
|
|
}
|
|
|
|
func (r *droppableRenderer) MinSize() fyne.Size {
|
|
return r.content.MinSize()
|
|
}
|
|
|
|
func (r *droppableRenderer) Refresh() {
|
|
r.content.Refresh()
|
|
}
|
|
|
|
func (r *droppableRenderer) Destroy() {}
|
|
|
|
func (r *droppableRenderer) 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}
|
|
}
|
|
|
|
// ConversionStatsBar shows current conversion status with live updates
|
|
type ConversionStatsBar struct {
|
|
widget.BaseWidget
|
|
running int
|
|
pending int
|
|
completed int
|
|
failed int
|
|
cancelled int
|
|
progress float64
|
|
jobTitle string
|
|
fps float64
|
|
speed float64
|
|
eta string
|
|
onTapped func()
|
|
}
|
|
|
|
// NewConversionStatsBar creates a new conversion stats bar
|
|
func NewConversionStatsBar(onTapped func()) *ConversionStatsBar {
|
|
c := &ConversionStatsBar{
|
|
onTapped: onTapped,
|
|
}
|
|
c.ExtendBaseWidget(c)
|
|
return c
|
|
}
|
|
|
|
// UpdateStats updates the stats display
|
|
func (c *ConversionStatsBar) UpdateStats(running, pending, completed, failed, cancelled int, progress float64, jobTitle string) {
|
|
c.updateStats(func() {
|
|
c.running = running
|
|
c.pending = pending
|
|
c.completed = completed
|
|
c.failed = failed
|
|
c.cancelled = cancelled
|
|
c.progress = progress
|
|
c.jobTitle = jobTitle
|
|
})
|
|
}
|
|
|
|
// UpdateStatsWithDetails updates the stats display with detailed conversion info
|
|
func (c *ConversionStatsBar) UpdateStatsWithDetails(running, pending, completed, failed, cancelled int, progress, fps, speed float64, eta, jobTitle string) {
|
|
c.updateStats(func() {
|
|
c.running = running
|
|
c.pending = pending
|
|
c.completed = completed
|
|
c.failed = failed
|
|
c.cancelled = cancelled
|
|
c.progress = progress
|
|
c.fps = fps
|
|
c.speed = speed
|
|
c.eta = eta
|
|
c.jobTitle = jobTitle
|
|
})
|
|
}
|
|
|
|
func (c *ConversionStatsBar) updateStats(update func()) {
|
|
app := fyne.CurrentApp()
|
|
if app == nil || app.Driver() == nil {
|
|
update()
|
|
c.Refresh()
|
|
return
|
|
}
|
|
app.Driver().DoFromGoroutine(func() {
|
|
update()
|
|
c.Refresh()
|
|
}, false)
|
|
}
|
|
|
|
// CreateRenderer creates the renderer for the stats bar
|
|
func (c *ConversionStatsBar) CreateRenderer() fyne.WidgetRenderer {
|
|
// Transparent background so the parent tinted bar color shows through
|
|
bg := canvas.NewRectangle(color.Transparent)
|
|
bg.CornerRadius = 0
|
|
bg.StrokeWidth = 0
|
|
|
|
statusText := canvas.NewText("", color.NRGBA{R: 230, G: 236, B: 245, A: 255})
|
|
statusText.TextStyle = fyne.TextStyle{Monospace: true}
|
|
statusText.TextSize = 11
|
|
|
|
progressBar := widget.NewProgressBar()
|
|
|
|
return &conversionStatsRenderer{
|
|
bar: c,
|
|
bg: bg,
|
|
statusText: statusText,
|
|
progressBar: progressBar,
|
|
}
|
|
}
|
|
|
|
// Tapped handles tap events
|
|
func (c *ConversionStatsBar) Tapped(*fyne.PointEvent) {
|
|
if c.onTapped != nil {
|
|
c.onTapped()
|
|
}
|
|
}
|
|
|
|
// Enable full-width tap target across the bar
|
|
func (c *ConversionStatsBar) MouseIn(*desktop.MouseEvent) {}
|
|
func (c *ConversionStatsBar) MouseMoved(*desktop.MouseEvent) {}
|
|
func (c *ConversionStatsBar) MouseOut() {}
|
|
|
|
type conversionStatsRenderer struct {
|
|
bar *ConversionStatsBar
|
|
bg *canvas.Rectangle
|
|
statusText *canvas.Text
|
|
progressBar *widget.ProgressBar
|
|
}
|
|
|
|
func (r *conversionStatsRenderer) Layout(size fyne.Size) {
|
|
r.bg.Resize(size)
|
|
|
|
// Layout text and progress bar
|
|
textSize := r.statusText.MinSize()
|
|
padding := float32(10)
|
|
|
|
// Position progress bar on right side
|
|
barWidth := float32(120)
|
|
barHeight := float32(20)
|
|
barX := size.Width - barWidth - padding
|
|
barY := (size.Height - barHeight) / 2
|
|
|
|
r.progressBar.Resize(fyne.NewSize(barWidth, barHeight))
|
|
r.progressBar.Move(fyne.NewPos(barX, barY))
|
|
|
|
// Position text on left
|
|
r.statusText.Move(fyne.NewPos(padding, (size.Height-textSize.Height)/2))
|
|
}
|
|
|
|
func (r *conversionStatsRenderer) MinSize() fyne.Size {
|
|
// Only constrain height, allow width to flex
|
|
return fyne.NewSize(0, 36)
|
|
}
|
|
|
|
func (r *conversionStatsRenderer) Refresh() {
|
|
// Update status text
|
|
if r.bar.running > 0 {
|
|
statusStr := ""
|
|
if r.bar.jobTitle != "" {
|
|
// Truncate job title if too long
|
|
title := r.bar.jobTitle
|
|
if len(title) > 30 {
|
|
title = title[:27] + "..."
|
|
}
|
|
statusStr = title
|
|
} else {
|
|
statusStr = "Processing"
|
|
}
|
|
|
|
// Always show progress percentage when running (even if 0%)
|
|
statusStr += " • " + formatProgress(r.bar.progress)
|
|
|
|
// Show FPS if available
|
|
if r.bar.fps > 0 {
|
|
statusStr += fmt.Sprintf(" • %.0f fps", r.bar.fps)
|
|
}
|
|
|
|
// Show speed if available
|
|
if r.bar.speed > 0 {
|
|
statusStr += fmt.Sprintf(" • %.2fx", r.bar.speed)
|
|
}
|
|
|
|
// Show ETA if available
|
|
if r.bar.eta != "" {
|
|
statusStr += " • ETA " + r.bar.eta
|
|
}
|
|
|
|
if r.bar.pending > 0 {
|
|
statusStr += " • " + formatCount(r.bar.pending, "pending")
|
|
}
|
|
|
|
r.statusText.Text = "▶ " + statusStr
|
|
r.statusText.Color = color.NRGBA{R: 100, G: 220, B: 100, A: 255} // Green
|
|
|
|
// Update progress bar (show even at 0%)
|
|
r.progressBar.SetValue(r.bar.progress / 100.0)
|
|
r.progressBar.Show()
|
|
} else if r.bar.pending > 0 {
|
|
r.statusText.Text = "⏸ " + formatCount(r.bar.pending, "queued")
|
|
r.statusText.Color = color.NRGBA{R: 255, G: 200, B: 100, A: 255} // Yellow
|
|
r.progressBar.Hide()
|
|
} else if r.bar.completed > 0 || r.bar.failed > 0 || r.bar.cancelled > 0 {
|
|
statusStr := "✓ "
|
|
parts := []string{}
|
|
if r.bar.completed > 0 {
|
|
parts = append(parts, formatCount(r.bar.completed, "completed"))
|
|
}
|
|
if r.bar.failed > 0 {
|
|
parts = append(parts, formatCount(r.bar.failed, "failed"))
|
|
}
|
|
if r.bar.cancelled > 0 {
|
|
parts = append(parts, formatCount(r.bar.cancelled, "cancelled"))
|
|
}
|
|
statusStr += strings.Join(parts, " • ")
|
|
r.statusText.Text = statusStr
|
|
r.statusText.Color = color.NRGBA{R: 150, G: 150, B: 150, A: 255} // Gray
|
|
r.progressBar.Hide()
|
|
} else {
|
|
r.statusText.Text = "○ No active jobs"
|
|
r.statusText.Color = color.NRGBA{R: 100, G: 100, B: 100, A: 255} // Dim gray
|
|
r.progressBar.Hide()
|
|
}
|
|
|
|
r.statusText.Refresh()
|
|
r.progressBar.Refresh()
|
|
r.bg.Refresh()
|
|
}
|
|
|
|
func (r *conversionStatsRenderer) Destroy() {}
|
|
|
|
func (r *conversionStatsRenderer) Objects() []fyne.CanvasObject {
|
|
return []fyne.CanvasObject{r.bg, r.statusText, r.progressBar}
|
|
}
|
|
|
|
// Helper functions for formatting
|
|
func formatProgress(progress float64) string {
|
|
return fmt.Sprintf("%.1f%%", progress)
|
|
}
|
|
|
|
func formatCount(count int, label string) string {
|
|
if count == 1 {
|
|
return fmt.Sprintf("1 %s", label)
|
|
}
|
|
return fmt.Sprintf("%d %s", count, label)
|
|
}
|
|
|
|
// FFmpegCommandWidget displays an FFmpeg command with copy button
|
|
type FFmpegCommandWidget struct {
|
|
widget.BaseWidget
|
|
command string
|
|
commandLabel *widget.Label
|
|
copyButton *widget.Button
|
|
window fyne.Window
|
|
}
|
|
|
|
// NewFFmpegCommandWidget creates a new FFmpeg command display widget
|
|
func NewFFmpegCommandWidget(command string, window fyne.Window) *FFmpegCommandWidget {
|
|
w := &FFmpegCommandWidget{
|
|
command: command,
|
|
window: window,
|
|
}
|
|
w.ExtendBaseWidget(w)
|
|
|
|
w.commandLabel = widget.NewLabel(command)
|
|
w.commandLabel.Wrapping = fyne.TextWrapBreak
|
|
w.commandLabel.TextStyle = fyne.TextStyle{Monospace: true}
|
|
|
|
w.copyButton = widget.NewButton("Copy Command", func() {
|
|
window.Clipboard().SetContent(w.command)
|
|
dialog.ShowInformation("Copied", "FFmpeg command copied to clipboard", window)
|
|
})
|
|
w.copyButton.Importance = widget.LowImportance
|
|
|
|
return w
|
|
}
|
|
|
|
// SetCommand updates the displayed command
|
|
func (w *FFmpegCommandWidget) SetCommand(command string) {
|
|
w.command = command
|
|
w.commandLabel.SetText(command)
|
|
w.Refresh()
|
|
}
|
|
|
|
// CreateRenderer creates the widget renderer
|
|
func (w *FFmpegCommandWidget) CreateRenderer() fyne.WidgetRenderer {
|
|
scroll := container.NewVScroll(w.commandLabel)
|
|
scroll.SetMinSize(fyne.NewSize(0, 80))
|
|
|
|
content := container.NewBorder(
|
|
nil,
|
|
container.NewHBox(layout.NewSpacer(), w.copyButton),
|
|
nil, nil,
|
|
scroll,
|
|
)
|
|
|
|
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("#673AB7") // Deep Purple
|
|
badgeText = "CONVERT"
|
|
case queue.JobTypeMerge:
|
|
badgeColor = utils.MustHex("#4CAF50") // Green
|
|
badgeText = "MERGE"
|
|
case queue.JobTypeTrim:
|
|
badgeColor = utils.MustHex("#FFEB3B") // Yellow
|
|
badgeText = "TRIM"
|
|
case queue.JobTypeFilter:
|
|
badgeColor = utils.MustHex("#00BCD4") // Cyan
|
|
badgeText = "FILTER"
|
|
case queue.JobTypeUpscale:
|
|
badgeColor = utils.MustHex("#9C27B0") // Purple
|
|
badgeText = "UPSCALE"
|
|
case queue.JobTypeAudio:
|
|
badgeColor = utils.MustHex("#FFC107") // Amber
|
|
badgeText = "AUDIO"
|
|
case queue.JobTypeThumb:
|
|
badgeColor = utils.MustHex("#00ACC1") // Dark Cyan
|
|
badgeText = "THUMB"
|
|
case queue.JobTypeSnippet:
|
|
badgeColor = utils.MustHex("#00BCD4") // Cyan (same as Convert)
|
|
badgeText = "SNIPPET"
|
|
case queue.JobTypeAuthor:
|
|
badgeColor = utils.MustHex("#FF5722") // Deep Orange
|
|
badgeText = "AUTHOR"
|
|
case queue.JobTypeRip:
|
|
badgeColor = utils.MustHex("#FF9800") // Orange
|
|
badgeText = "RIP"
|
|
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))
|
|
}
|
|
|
|
// SectionHeader creates a color-coded section header for better visual separation
|
|
// Helps fix usability issue where settings sections blend together
|
|
func SectionHeader(title string, accentColor color.Color) fyne.CanvasObject {
|
|
// Left accent bar (Memphis geometric style)
|
|
accent := canvas.NewRectangle(accentColor)
|
|
accent.SetMinSize(fyne.NewSize(4, 20))
|
|
|
|
// Title text
|
|
label := widget.NewLabel(title)
|
|
label.TextStyle = fyne.TextStyle{Bold: true}
|
|
label.Importance = widget.HighImportance
|
|
|
|
// Combine accent bar + title with padding
|
|
content := container.NewBorder(
|
|
nil, nil,
|
|
accent,
|
|
nil,
|
|
container.NewPadded(label),
|
|
)
|
|
|
|
return content
|
|
}
|
|
|
|
// SectionSpacer creates vertical spacing between sections for better readability
|
|
func SectionSpacer() fyne.CanvasObject {
|
|
spacer := canvas.NewRectangle(color.Transparent)
|
|
spacer.SetMinSize(fyne.NewSize(0, 12))
|
|
return spacer
|
|
}
|
|
|
|
// ColoredDivider creates a thin horizontal divider with accent color
|
|
func ColoredDivider(accentColor color.Color) fyne.CanvasObject {
|
|
divider := canvas.NewRectangle(accentColor)
|
|
divider.SetMinSize(fyne.NewSize(0, 2))
|
|
return divider
|
|
}
|