- 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>
274 lines
5.6 KiB
Go
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
|
|
}
|