You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
531 lines
17 KiB
531 lines
17 KiB
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"imaginator/core"
|
|
"path/filepath"
|
|
"strconv"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"fyne.io/fyne/v2"
|
|
"fyne.io/fyne/v2/app"
|
|
"fyne.io/fyne/v2/canvas"
|
|
"fyne.io/fyne/v2/container"
|
|
"fyne.io/fyne/v2/dialog"
|
|
"fyne.io/fyne/v2/layout"
|
|
"fyne.io/fyne/v2/theme"
|
|
"fyne.io/fyne/v2/widget"
|
|
)
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// App state
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
type appState struct {
|
|
win fyne.Window
|
|
cfg *core.Config
|
|
running atomic.Bool
|
|
|
|
// Config widgets
|
|
srcLabel *widget.Label
|
|
dstLabel *widget.Label
|
|
widthEntry *widget.Entry
|
|
heightEntry *widget.Entry
|
|
qualEntry *widget.Entry
|
|
formatSel *widget.Select
|
|
resampleSel *widget.Select
|
|
workersEntry *widget.Entry
|
|
|
|
prefixEntry *widget.Entry
|
|
suffixEntry *widget.Entry
|
|
lowercaseChk *widget.Check
|
|
uppercaseChk *widget.Check
|
|
nospaceChk *widget.Check
|
|
replaceEntry *widget.Entry
|
|
replaceiEntry *widget.Entry
|
|
replacepEntry *widget.Entry
|
|
|
|
filterEntry *widget.Entry
|
|
keepStrChk *widget.Check
|
|
dryRunChk *widget.Check
|
|
verboseChk *widget.Check
|
|
b64Chk *widget.Check
|
|
b64ExtEntry *widget.Entry
|
|
tsEntry *widget.Entry
|
|
|
|
// Progress
|
|
progressBar *widget.ProgressBar
|
|
statusLabel *widget.Label
|
|
logList *widget.List
|
|
logEntries []string
|
|
|
|
// Results
|
|
resultsTable *widget.Table
|
|
results []core.ProcessResult
|
|
|
|
// Preview
|
|
previewBefore *canvas.Image
|
|
previewAfter *canvas.Image
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Entry point
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
func main() {
|
|
a := app.New()
|
|
a.Settings().SetTheme(theme.DarkTheme())
|
|
|
|
w := a.NewWindow("Imaginator " + core.Version)
|
|
w.Resize(fyne.NewSize(1100, 760))
|
|
w.SetMaster()
|
|
|
|
state := &appState{
|
|
win: w,
|
|
cfg: core.DefaultConfig(),
|
|
}
|
|
|
|
tabs := container.NewAppTabs(
|
|
container.NewTabItem(" Configure ", state.buildConfigTab()),
|
|
container.NewTabItem(" Progress ", state.buildProgressTab()),
|
|
container.NewTabItem(" Results ", state.buildResultsTab()),
|
|
)
|
|
tabs.SetTabLocation(container.TabLocationTop)
|
|
|
|
w.SetContent(tabs)
|
|
w.ShowAndRun()
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Tab: Configure
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
func (s *appState) buildConfigTab() fyne.CanvasObject {
|
|
// ── Directories ──────────────────────────────────────────────────────────
|
|
s.srcLabel = widget.NewLabel("(not selected)")
|
|
s.srcLabel.Wrapping = fyne.TextWrapWord
|
|
srcBtn := widget.NewButtonWithIcon("Source folder", theme.FolderOpenIcon(), func() {
|
|
dialog.ShowFolderOpen(func(uri fyne.ListableURI, err error) {
|
|
if err != nil || uri == nil {
|
|
return
|
|
}
|
|
s.cfg.InputDir = uri.Path()
|
|
s.srcLabel.SetText(uri.Path())
|
|
}, s.win)
|
|
})
|
|
|
|
s.dstLabel = widget.NewLabel("(not selected)")
|
|
s.dstLabel.Wrapping = fyne.TextWrapWord
|
|
dstBtn := widget.NewButtonWithIcon("Output folder", theme.FolderOpenIcon(), func() {
|
|
dialog.ShowFolderOpen(func(uri fyne.ListableURI, err error) {
|
|
if err != nil || uri == nil {
|
|
return
|
|
}
|
|
s.cfg.OutputDir = uri.Path()
|
|
s.dstLabel.SetText(uri.Path())
|
|
}, s.win)
|
|
})
|
|
|
|
dirs := container.NewVBox(
|
|
sectionLabel("Directories"),
|
|
container.NewGridWithColumns(2, srcBtn, s.srcLabel),
|
|
container.NewGridWithColumns(2, dstBtn, s.dstLabel),
|
|
)
|
|
|
|
// ── Image options ─────────────────────────────────────────────────────────
|
|
s.widthEntry = widget.NewEntry()
|
|
s.widthEntry.SetPlaceHolder("0 = keep ratio")
|
|
|
|
s.heightEntry = widget.NewEntry()
|
|
s.heightEntry.SetPlaceHolder("0 = keep ratio")
|
|
|
|
s.qualEntry = widget.NewEntry()
|
|
s.qualEntry.SetText("85")
|
|
|
|
s.formatSel = widget.NewSelect(
|
|
[]string{"(keep original)", "jpg", "png", "tif"},
|
|
func(v string) {},
|
|
)
|
|
s.formatSel.SetSelected("(keep original)")
|
|
|
|
s.resampleSel = widget.NewSelect(
|
|
[]string{"lanczos", "cubic", "linear", "box"},
|
|
func(v string) {},
|
|
)
|
|
s.resampleSel.SetSelected("lanczos")
|
|
|
|
s.workersEntry = widget.NewEntry()
|
|
s.workersEntry.SetText("4")
|
|
|
|
s.b64Chk = widget.NewCheck("Base64 output", nil)
|
|
s.b64ExtEntry = widget.NewEntry()
|
|
s.b64ExtEntry.SetPlaceHolder("extension (default: b64)")
|
|
|
|
s.tsEntry = widget.NewEntry()
|
|
s.tsEntry.SetPlaceHolder("TypeScript output file path")
|
|
|
|
imageOpts := container.NewVBox(
|
|
sectionLabel("Image"),
|
|
formRow("Width", s.widthEntry),
|
|
formRow("Height", s.heightEntry),
|
|
formRow("JPEG quality", s.qualEntry),
|
|
formRow("Output format", s.formatSel),
|
|
formRow("Resample filter", s.resampleSel),
|
|
formRow("Workers", s.workersEntry),
|
|
container.NewGridWithColumns(2, s.b64Chk, s.b64ExtEntry),
|
|
formRow("TypeScript file", s.tsEntry),
|
|
)
|
|
|
|
// ── Naming options ────────────────────────────────────────────────────────
|
|
s.prefixEntry = widget.NewEntry()
|
|
s.prefixEntry.SetPlaceHolder("prefix_")
|
|
s.suffixEntry = widget.NewEntry()
|
|
s.suffixEntry.SetPlaceHolder("_suffix")
|
|
|
|
s.lowercaseChk = widget.NewCheck("Lowercase", nil)
|
|
s.uppercaseChk = widget.NewCheck("Uppercase", nil)
|
|
s.lowercaseChk.OnChanged = func(v bool) {
|
|
if v {
|
|
s.uppercaseChk.SetChecked(false)
|
|
}
|
|
}
|
|
s.uppercaseChk.OnChanged = func(v bool) {
|
|
if v {
|
|
s.lowercaseChk.SetChecked(false)
|
|
}
|
|
}
|
|
|
|
s.nospaceChk = widget.NewCheck("Replace spaces with _", nil)
|
|
|
|
s.replaceEntry = widget.NewEntry()
|
|
s.replaceEntry.SetPlaceHolder("old/new,old2/new2 (case sensitive)")
|
|
s.replaceiEntry = widget.NewEntry()
|
|
s.replaceiEntry.SetPlaceHolder("old/new (case insensitive)")
|
|
s.replacepEntry = widget.NewEntry()
|
|
s.replacepEntry.SetPlaceHolder("old/new (insensitive + preserve case)")
|
|
|
|
namingOpts := container.NewVBox(
|
|
sectionLabel("Naming"),
|
|
formRow("Prefix", s.prefixEntry),
|
|
formRow("Suffix", s.suffixEntry),
|
|
container.NewGridWithColumns(3, s.lowercaseChk, s.uppercaseChk, s.nospaceChk),
|
|
formRow("-replace", s.replaceEntry),
|
|
formRow("-replacei", s.replaceiEntry),
|
|
formRow("-replacep", s.replacepEntry),
|
|
)
|
|
|
|
// ── Filter / execution ────────────────────────────────────────────────────
|
|
s.filterEntry = widget.NewEntry()
|
|
s.filterEntry.SetPlaceHolder("*.jpg or DSC_????.png")
|
|
|
|
s.keepStrChk = widget.NewCheck("Keep directory structure", nil)
|
|
s.dryRunChk = widget.NewCheck("Dry run (no files written)", nil)
|
|
s.verboseChk = widget.NewCheck("Verbose", nil)
|
|
|
|
execOpts := container.NewVBox(
|
|
sectionLabel("Filter & Execution"),
|
|
formRow("Filename filter (glob)", s.filterEntry),
|
|
container.NewGridWithColumns(3, s.keepStrChk, s.dryRunChk, s.verboseChk),
|
|
)
|
|
|
|
// ── Run button ────────────────────────────────────────────────────────────
|
|
runBtn := widget.NewButtonWithIcon(" Process images", theme.MediaPlayIcon(), s.startProcessing)
|
|
runBtn.Importance = widget.HighImportance
|
|
|
|
dryBtn := widget.NewButton(" Dry run", func() {
|
|
s.dryRunChk.SetChecked(true)
|
|
s.startProcessing()
|
|
})
|
|
|
|
buttons := container.NewHBox(layout.NewSpacer(), dryBtn, runBtn)
|
|
|
|
left := container.NewVBox(dirs, imageOpts, buttons)
|
|
right := container.NewVBox(namingOpts, execOpts)
|
|
|
|
return container.NewHSplit(
|
|
container.NewVScroll(left),
|
|
container.NewVScroll(right),
|
|
)
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Tab: Progress
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
func (s *appState) buildProgressTab() fyne.CanvasObject {
|
|
s.progressBar = widget.NewProgressBar()
|
|
s.statusLabel = widget.NewLabel("Ready.")
|
|
|
|
s.logEntries = []string{}
|
|
s.logList = widget.NewList(
|
|
func() int { return len(s.logEntries) },
|
|
func() fyne.CanvasObject { return widget.NewLabel("") },
|
|
func(i widget.ListItemID, o fyne.CanvasObject) {
|
|
o.(*widget.Label).SetText(s.logEntries[i])
|
|
},
|
|
)
|
|
|
|
return container.NewBorder(
|
|
container.NewVBox(
|
|
sectionLabel("Progress"),
|
|
s.progressBar,
|
|
s.statusLabel,
|
|
widget.NewSeparator(),
|
|
sectionLabel("Log"),
|
|
),
|
|
nil, nil, nil,
|
|
s.logList,
|
|
)
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Tab: Results
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
func (s *appState) buildResultsTab() fyne.CanvasObject {
|
|
headers := []string{"Input", "Output", "In size", "Out size", "Time", "Status"}
|
|
|
|
s.results = []core.ProcessResult{}
|
|
s.resultsTable = widget.NewTable(
|
|
func() (int, int) { return len(s.results) + 1, len(headers) },
|
|
func() fyne.CanvasObject { return widget.NewLabel("") },
|
|
func(id widget.TableCellID, o fyne.CanvasObject) {
|
|
lbl := o.(*widget.Label)
|
|
if id.Row == 0 {
|
|
lbl.TextStyle = fyne.TextStyle{Bold: true}
|
|
lbl.SetText(headers[id.Col])
|
|
return
|
|
}
|
|
r := s.results[id.Row-1]
|
|
switch id.Col {
|
|
case 0:
|
|
lbl.SetText(r.Filename)
|
|
case 1:
|
|
lbl.SetText(filepath.Base(r.OutputPath))
|
|
case 2:
|
|
lbl.SetText(formatBytes(r.InputSize))
|
|
case 3:
|
|
lbl.SetText(formatBytes(r.OutputSize))
|
|
case 4:
|
|
lbl.SetText(r.Duration.Round(time.Millisecond).String())
|
|
case 5:
|
|
if r.Success {
|
|
lbl.SetText("✓")
|
|
} else {
|
|
lbl.SetText("✗ " + r.Error.Error())
|
|
}
|
|
}
|
|
},
|
|
)
|
|
|
|
s.resultsTable.SetColumnWidth(0, 220)
|
|
s.resultsTable.SetColumnWidth(1, 220)
|
|
s.resultsTable.SetColumnWidth(2, 80)
|
|
s.resultsTable.SetColumnWidth(3, 80)
|
|
s.resultsTable.SetColumnWidth(4, 80)
|
|
s.resultsTable.SetColumnWidth(5, 300)
|
|
|
|
// Preview panel
|
|
s.previewBefore = canvas.NewImageFromFile("")
|
|
s.previewBefore.FillMode = canvas.ImageFillContain
|
|
s.previewBefore.SetMinSize(fyne.NewSize(240, 180))
|
|
|
|
s.previewAfter = canvas.NewImageFromFile("")
|
|
s.previewAfter.FillMode = canvas.ImageFillContain
|
|
s.previewAfter.SetMinSize(fyne.NewSize(240, 180))
|
|
|
|
s.resultsTable.OnSelected = func(id widget.TableCellID) {
|
|
if id.Row < 1 || id.Row > len(s.results) {
|
|
return
|
|
}
|
|
r := s.results[id.Row-1]
|
|
// Before: original input path (reconstruct from output path + cfg)
|
|
s.previewBefore.File = filepath.Join(
|
|
s.cfg.InputDir,
|
|
r.Filename,
|
|
)
|
|
s.previewBefore.Refresh()
|
|
|
|
if r.Success && !s.cfg.B64Enabled && s.cfg.TSFile == "" {
|
|
s.previewAfter.File = r.OutputPath
|
|
s.previewAfter.Refresh()
|
|
}
|
|
}
|
|
|
|
preview := container.NewVBox(
|
|
sectionLabel("Preview"),
|
|
container.NewGridWithColumns(2,
|
|
container.NewVBox(widget.NewLabel("Before"), s.previewBefore),
|
|
container.NewVBox(widget.NewLabel("After"), s.previewAfter),
|
|
),
|
|
)
|
|
|
|
return container.NewHSplit(
|
|
s.resultsTable,
|
|
container.NewVScroll(preview),
|
|
)
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Processing
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
func (s *appState) startProcessing() {
|
|
if s.running.Load() {
|
|
return
|
|
}
|
|
|
|
// Build config from widgets
|
|
if err := s.applyWidgets(); err != nil {
|
|
dialog.ShowError(err, s.win)
|
|
return
|
|
}
|
|
|
|
if err := core.Validate(s.cfg); err != nil {
|
|
dialog.ShowError(err, s.win)
|
|
return
|
|
}
|
|
|
|
summary, err := core.CollectImageFiles(s.cfg.InputDir, s.cfg.FileFilter)
|
|
if err != nil {
|
|
dialog.ShowError(err, s.win)
|
|
return
|
|
}
|
|
|
|
if len(summary.Files) == 0 {
|
|
dialog.ShowInformation("No images", "No images found in the selected source directory.", s.win)
|
|
return
|
|
}
|
|
|
|
// Reset UI state
|
|
s.running.Store(true)
|
|
s.results = nil
|
|
s.logEntries = nil
|
|
s.progressBar.SetValue(0)
|
|
s.statusLabel.SetText(fmt.Sprintf("Processing %d images...", len(summary.Files)))
|
|
s.logList.Refresh()
|
|
s.resultsTable.Refresh()
|
|
|
|
go func() {
|
|
defer s.running.Store(false)
|
|
|
|
total := len(summary.Files)
|
|
var done int
|
|
|
|
for p := range core.Run(summary, s.cfg) {
|
|
r := p.Result
|
|
done++
|
|
|
|
progress := float64(done) / float64(total)
|
|
var logLine string
|
|
if r.Success {
|
|
logLine = fmt.Sprintf("✓ %s → %s (%s)",
|
|
r.Filename,
|
|
filepath.Base(r.OutputPath),
|
|
r.Duration.Round(time.Millisecond))
|
|
} else {
|
|
logLine = fmt.Sprintf("✗ %s: %v", r.Filename, r.Error)
|
|
}
|
|
|
|
// All UI updates must happen on the main goroutine via fyne's thread-safe calls
|
|
s.progressBar.SetValue(progress)
|
|
s.statusLabel.SetText(fmt.Sprintf("[%d/%d] %s", done, total, r.Filename))
|
|
s.logEntries = append(s.logEntries, logLine)
|
|
s.logList.Refresh()
|
|
s.logList.ScrollToBottom()
|
|
|
|
if !s.cfg.DryRun {
|
|
s.results = append(s.results, r)
|
|
s.resultsTable.Refresh()
|
|
}
|
|
}
|
|
|
|
s.progressBar.SetValue(1)
|
|
s.statusLabel.SetText(fmt.Sprintf("Done — %d images processed.", done))
|
|
}()
|
|
}
|
|
|
|
// applyWidgets reads all widget values into s.cfg.
|
|
func (s *appState) applyWidgets() error {
|
|
w, _ := strconv.Atoi(s.widthEntry.Text)
|
|
h, _ := strconv.Atoi(s.heightEntry.Text)
|
|
q, _ := strconv.Atoi(s.qualEntry.Text)
|
|
workers, _ := strconv.Atoi(s.workersEntry.Text)
|
|
if workers < 1 {
|
|
workers = 4
|
|
}
|
|
if q == 0 {
|
|
q = 85
|
|
}
|
|
|
|
s.cfg.Width = w
|
|
s.cfg.Height = h
|
|
s.cfg.Quality = q
|
|
s.cfg.Workers = workers
|
|
|
|
s.cfg.Format = s.formatSel.Selected
|
|
if s.cfg.Format == "(keep original)" {
|
|
s.cfg.Format = ""
|
|
}
|
|
s.cfg.Resample = s.resampleSel.Selected
|
|
|
|
s.cfg.Prefix = s.prefixEntry.Text
|
|
s.cfg.Suffix = s.suffixEntry.Text
|
|
s.cfg.Lowercase = s.lowercaseChk.Checked
|
|
s.cfg.Uppercase = s.uppercaseChk.Checked
|
|
|
|
s.cfg.NoSpacesOn = s.nospaceChk.Checked
|
|
if s.cfg.NoSpacesOn {
|
|
s.cfg.NoSpaces = "_"
|
|
}
|
|
|
|
s.cfg.Replace = s.replaceEntry.Text
|
|
s.cfg.ReplaceI = s.replaceiEntry.Text
|
|
s.cfg.ReplaceP = s.replacepEntry.Text
|
|
|
|
s.cfg.FileFilter = s.filterEntry.Text
|
|
s.cfg.KeepStructure = s.keepStrChk.Checked
|
|
s.cfg.DryRun = s.dryRunChk.Checked
|
|
s.cfg.Verbose = s.verboseChk.Checked
|
|
|
|
s.cfg.B64Enabled = s.b64Chk.Checked
|
|
s.cfg.B64Ext = s.b64ExtEntry.Text
|
|
if s.cfg.B64Enabled && s.cfg.B64Ext == "" {
|
|
s.cfg.B64Ext = "b64"
|
|
}
|
|
s.cfg.TSFile = s.tsEntry.Text
|
|
|
|
return nil
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// UI helpers
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
func sectionLabel(text string) *widget.Label {
|
|
l := widget.NewLabel(text)
|
|
l.TextStyle = fyne.TextStyle{Bold: true}
|
|
return l
|
|
}
|
|
|
|
func formRow(label string, w fyne.CanvasObject) *fyne.Container {
|
|
return container.NewGridWithColumns(2,
|
|
widget.NewLabel(label),
|
|
w,
|
|
)
|
|
}
|
|
|
|
func formatBytes(b int64) string {
|
|
if b == 0 {
|
|
return "—"
|
|
}
|
|
if b < 1024 {
|
|
return fmt.Sprintf("%d B", b)
|
|
}
|
|
if b < 1024*1024 {
|
|
return fmt.Sprintf("%.1f KB", float64(b)/1024)
|
|
}
|
|
return fmt.Sprintf("%.2f MB", float64(b)/1024/1024)
|
|
}
|