VideoTools/internal/utils/utils.go
Stu Leak f558119f4f Add app icon support and window sizing improvements
- Update LoadAppIcon() to search for PNG first (better Linux support)
- Add FyneApp.toml for icon metadata and Windows embedding
- Create VideoTools.desktop for Linux application launcher integration
- Change default window size from 1200x700 to 800x600
- Icon now appears in taskbar, app switcher, and Windows title bar

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-20 14:13:18 -05:00

274 lines
5.6 KiB
Go

package utils
import (
"fmt"
"image/color"
"math"
"os"
"path/filepath"
"strconv"
"strings"
"unicode/utf8"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/widget"
"git.leaktechnologies.dev/stu/VideoTools/internal/logging"
)
// Color utilities
// MustHex parses a hex color string or exits on error
func MustHex(h string) color.NRGBA {
c, err := ParseHexColor(h)
if err != nil {
fmt.Fprintf(os.Stderr, "invalid color %q: %v\n", h, err)
os.Exit(1)
}
return c
}
// ParseHexColor parses a hex color string like "#RRGGBB"
func ParseHexColor(s string) (color.NRGBA, error) {
s = strings.TrimPrefix(s, "#")
if len(s) != 6 {
return color.NRGBA{}, fmt.Errorf("want 6 digits, got %q", s)
}
var r, g, b uint8
if _, err := fmt.Sscanf(s, "%02x%02x%02x", &r, &g, &b); err != nil {
return color.NRGBA{}, err
}
return color.NRGBA{R: r, G: g, B: b, A: 0xff}, nil
}
// String utilities
// FirstNonEmpty returns the first non-empty string or "--"
func FirstNonEmpty(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return v
}
}
return "--"
}
// ShortenMiddle shortens a string to max runes, keeping start and end with ellipsis in the middle.
func ShortenMiddle(s string, max int) string {
if max <= 0 {
return ""
}
if utf8.RuneCountInString(s) <= max {
return s
}
ellipsis := "…"
keep := max - utf8.RuneCountInString(ellipsis)
if keep <= 0 {
return s[:max]
}
left := keep / 2
right := keep - left
runes := []rune(s)
if left+right >= len(runes) {
return s
}
return string(runes[:left]) + ellipsis + string(runes[len(runes)-right:])
}
// Parsing utilities
// ParseFloat parses a float64 from a string
func ParseFloat(s string) (float64, error) {
return strconv.ParseFloat(strings.TrimSpace(s), 64)
}
// ParseInt parses an int from a string
func ParseInt(s string) (int, error) {
s = strings.TrimSpace(s)
if s == "" {
return 0, fmt.Errorf("empty")
}
return strconv.Atoi(s)
}
// ParseFraction parses a fraction string like "24000/1001" or "30"
func ParseFraction(s string) float64 {
s = strings.TrimSpace(s)
if s == "" || s == "0" {
return 0
}
parts := strings.Split(s, "/")
num, err := strconv.ParseFloat(parts[0], 64)
if err != nil {
return 0
}
if len(parts) == 1 {
return num
}
den, err := strconv.ParseFloat(parts[1], 64)
if err != nil || den == 0 {
return 0
}
return num / den
}
// Math utilities
// GCD returns the greatest common divisor of two integers
func GCD(a, b int) int {
if a < 0 {
a = -a
}
if b < 0 {
b = -b
}
for b != 0 {
a, b = b, a%b
}
if a == 0 {
return 1
}
return a
}
// SimplifyRatio simplifies a width/height ratio
func SimplifyRatio(w, h int) (int, int) {
if w <= 0 || h <= 0 {
return 0, 0
}
g := GCD(w, h)
return w / g, h / g
}
// MaxInt returns the maximum of two integers
func MaxInt(a, b int) int {
if a > b {
return a
}
return b
}
// Aspect ratio utilities
// AspectRatioFloat calculates the aspect ratio as a float
func AspectRatioFloat(w, h int) float64 {
if w <= 0 || h <= 0 {
return 0
}
return float64(w) / float64(h)
}
// ParseAspectValue parses an aspect ratio string like "16:9"
func ParseAspectValue(val string) float64 {
val = strings.TrimSpace(val)
switch val {
case "16:9":
return 16.0 / 9.0
case "4:3":
return 4.0 / 3.0
case "1:1":
return 1
case "9:16":
return 9.0 / 16.0
case "21:9":
return 21.0 / 9.0
}
parts := strings.Split(val, ":")
if len(parts) == 2 {
n, err1 := strconv.ParseFloat(parts[0], 64)
d, err2 := strconv.ParseFloat(parts[1], 64)
if err1 == nil && err2 == nil && d != 0 {
return n / d
}
}
return 0
}
// RatiosApproxEqual checks if two ratios are approximately equal
func RatiosApproxEqual(a, b, tol float64) bool {
if a == 0 || b == 0 {
return false
}
diff := math.Abs(a - b)
if b != 0 {
diff = diff / b
}
return diff <= tol
}
// Audio utilities
// ChannelLabel returns a human-readable label for a channel count
func ChannelLabel(ch int) string {
switch ch {
case 1:
return "Mono"
case 2:
return "Stereo"
case 6:
return "5.1"
case 8:
return "7.1"
default:
if ch <= 0 {
return ""
}
return fmt.Sprintf("%d ch", ch)
}
}
// Image utilities
// CopyRGBToRGBA expands packed RGB bytes into RGBA while forcing opaque alpha
func CopyRGBToRGBA(dst, src []byte) {
di := 0
for si := 0; si+2 < len(src) && di+3 < len(dst); si, di = si+3, di+4 {
dst[di] = src[si]
dst[di+1] = src[si+1]
dst[di+2] = src[si+2]
dst[di+3] = 0xff
}
}
// UI utilities
// MakeIconButton creates a low-importance button with a symbol
func MakeIconButton(symbol, tooltip string, tapped func()) *widget.Button {
btn := widget.NewButton(symbol, tapped)
btn.Importance = widget.LowImportance
return btn
}
// LoadAppIcon loads the application icon from standard locations
func LoadAppIcon() fyne.Resource {
// Try PNG first (better compatibility), then SVG
iconFiles := []string{"VT_Icon.png", "VT_Icon.svg"}
var search []string
// Search in current directory first
for _, iconFile := range iconFiles {
search = append(search, filepath.Join("assets", "logo", iconFile))
}
// Then search relative to executable
if exe, err := os.Executable(); err == nil {
dir := filepath.Dir(exe)
for _, iconFile := range iconFiles {
search = append(search, filepath.Join(dir, "assets", "logo", iconFile))
}
}
for _, p := range search {
if _, err := os.Stat(p); err == nil {
res, err := fyne.LoadResourceFromPath(p)
if err != nil {
logging.Debug(logging.CatUI, "failed to load icon %s: %v", p, err)
continue
}
logging.Debug(logging.CatUI, "loaded app icon from %s", p)
return res
}
}
logging.Debug(logging.CatUI, "no app icon found in search paths")
return nil
}