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

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)
}

Powered by TurnKey Linux.