- Updated .gitignore to reflect current project structure - Updated README with current build and installation instructions - Added cmd, internal, and scripts directories for project organization - Added build artifacts and installation scripts 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
443 lines
11 KiB
Go
443 lines
11 KiB
Go
package ui
|
||
|
||
import (
|
||
"fmt"
|
||
"image/color"
|
||
"os"
|
||
"path/filepath"
|
||
"sort"
|
||
"strings"
|
||
"sync"
|
||
|
||
"img2pdf/internal/convert"
|
||
"img2pdf/internal/input"
|
||
|
||
"fyne.io/fyne/v2"
|
||
"fyne.io/fyne/v2/canvas"
|
||
"fyne.io/fyne/v2/container"
|
||
"fyne.io/fyne/v2/dialog"
|
||
"fyne.io/fyne/v2/layout"
|
||
"fyne.io/fyne/v2/widget"
|
||
)
|
||
|
||
// Run launches the desktop UI.
|
||
func Run(app fyne.App) {
|
||
w := app.NewWindow("img2pdf")
|
||
w.Resize(fyne.NewSize(720, 520))
|
||
|
||
state := &uiState{
|
||
output: widget.NewEntry(),
|
||
status: widget.NewLabel("ready"),
|
||
autoConvert: true,
|
||
dark: false,
|
||
}
|
||
state.status.TextStyle = fyne.TextStyle{Monospace: true}
|
||
state.output.SetText("images.pdf")
|
||
state.spinner = widget.NewProgressBarInfinite()
|
||
state.spinner.Hide()
|
||
state.bg = canvas.NewRectangle(color.NRGBA{0xf4, 0xf1, 0xec, 0xff})
|
||
state.bg.SetMinSize(fyne.NewSize(820, 620))
|
||
state.blob1 = canvas.NewRectangle(color.NRGBA{0xff, 0xc7, 0x8f, 0x55})
|
||
state.blob1.SetMinSize(fyne.NewSize(140, 60))
|
||
state.blob1.Move(fyne.NewPos(48, 54))
|
||
state.blob2 = canvas.NewRectangle(color.NRGBA{0x8c, 0xd8, 0xc0, 0x55})
|
||
state.blob2.SetMinSize(fyne.NewSize(110, 96))
|
||
state.blob2.Move(fyne.NewPos(620, 80))
|
||
state.blob3 = canvas.NewRectangle(color.NRGBA{0xa7, 0xc5, 0xff, 0x55})
|
||
state.blob3.SetMinSize(fyne.NewSize(160, 48))
|
||
state.blob3.Move(fyne.NewPos(180, 430))
|
||
|
||
w.SetOnDropped(func(_ fyne.Position, uris []fyne.URI) {
|
||
var items []string
|
||
for _, u := range uris {
|
||
if u.Scheme() == "file" {
|
||
items = append(items, u.Path())
|
||
}
|
||
}
|
||
state.addPaths(items, w)
|
||
})
|
||
|
||
chooseOutput := widget.NewButton("...", func() {
|
||
save := dialog.NewFileSave(func(wc fyne.URIWriteCloser, err error) {
|
||
if err != nil || wc == nil {
|
||
return
|
||
}
|
||
state.output.SetText(wc.URI().Path())
|
||
wc.Close()
|
||
}, w)
|
||
if path := strings.TrimSpace(state.output.Text); path != "" {
|
||
save.SetFileName(filepath.Base(path))
|
||
} else {
|
||
save.SetFileName("images.pdf")
|
||
}
|
||
save.Show()
|
||
})
|
||
|
||
state.convertBtn = widget.NewButton("Convert", func() {
|
||
state.convert(w)
|
||
})
|
||
state.convertBtn.Importance = widget.MediumImportance
|
||
|
||
outputBar := makeOutputBar(state, state.output, chooseOutput)
|
||
mainCard := makeCard(state, outputBar, w)
|
||
|
||
state.applyPalette(false)
|
||
|
||
toggle := widget.NewButton("☾", nil)
|
||
toggle.OnTapped = func() {
|
||
icon := state.toggleTheme()
|
||
toggle.SetText(icon)
|
||
}
|
||
toggle.Importance = widget.LowImportance
|
||
|
||
closeBtn := widget.NewButton("×", func() { w.Close() })
|
||
closeBtn.Importance = widget.LowImportance
|
||
|
||
content := container.NewBorder(
|
||
container.NewHBox(closeBtn, layout.NewSpacer(), toggle),
|
||
nil, nil, nil,
|
||
container.NewMax(
|
||
state.bg,
|
||
container.NewWithoutLayout(state.blob1, state.blob2, state.blob3),
|
||
container.NewCenter(mainCard),
|
||
),
|
||
)
|
||
|
||
w.SetContent(content)
|
||
w.ShowAndRun()
|
||
}
|
||
|
||
func makeCard(state *uiState, outputBar fyne.CanvasObject, win fyne.Window) fyne.CanvasObject {
|
||
card := canvas.NewRectangle(color.NRGBA{0x00, 0x00, 0x00, 0x00})
|
||
card.StrokeWidth = 2
|
||
card.CornerRadius = 10
|
||
card.SetMinSize(fyne.NewSize(580, 400))
|
||
state.card = card
|
||
|
||
folder := makeFolderIcon(state, func() {
|
||
open := dialog.NewFolderOpen(func(uri fyne.ListableURI, err error) {
|
||
if err != nil || uri == nil {
|
||
return
|
||
}
|
||
state.addPaths([]string{uri.Path()}, win)
|
||
}, win)
|
||
open.Show()
|
||
})
|
||
|
||
tag := canvas.NewText("drop images or folders", color.NRGBA{0x1f, 0x1f, 0x1f, 0xff})
|
||
tag.TextSize = 16
|
||
tag.TextStyle = fyne.TextStyle{Monospace: true}
|
||
tag.Alignment = fyne.TextAlignCenter
|
||
state.tag = tag
|
||
|
||
convertWrap := container.NewMax()
|
||
state.convertBg = canvas.NewRectangle(color.NRGBA{0xf0, 0xee, 0xea, 0xff})
|
||
state.convertBg.CornerRadius = 6
|
||
state.convertBg.StrokeWidth = 2
|
||
state.convertBg.SetMinSize(fyne.NewSize(200, 46))
|
||
convertWrap.Add(state.convertBg)
|
||
convertWrap.Add(container.NewCenter(container.NewVBox(state.convertBtn, state.spinner)))
|
||
|
||
stack := container.NewVBox(
|
||
container.NewCenter(folder),
|
||
container.NewCenter(tag),
|
||
layout.NewSpacer(),
|
||
outputBar,
|
||
container.NewCenter(convertWrap),
|
||
container.NewCenter(state.status),
|
||
)
|
||
|
||
cardContainer := container.NewMax(
|
||
card,
|
||
container.NewPadded(stack),
|
||
)
|
||
|
||
return cardContainer
|
||
}
|
||
|
||
func makeOutputBar(state *uiState, entry *widget.Entry, chooseOutput fyne.CanvasObject) fyne.CanvasObject {
|
||
entry.SetPlaceHolder("out.pdf")
|
||
entry.TextStyle = fyne.TextStyle{Monospace: true}
|
||
entry.Refresh()
|
||
|
||
bg := canvas.NewRectangle(color.NRGBA{0xf0, 0xee, 0xea, 0xff})
|
||
bg.StrokeWidth = 2
|
||
bg.CornerRadius = 8
|
||
bg.SetMinSize(fyne.NewSize(520, 46))
|
||
state.outputBg = bg
|
||
|
||
bar := container.NewBorder(nil, nil, nil, chooseOutput, entry)
|
||
return container.NewMax(bg, container.NewPadded(bar))
|
||
}
|
||
|
||
func makeFolderIcon(state *uiState, onTap func()) fyne.CanvasObject {
|
||
body := canvas.NewRectangle(color.NRGBA{0xe3, 0xe0, 0xda, 0xff})
|
||
body.CornerRadius = 8
|
||
body.SetMinSize(fyne.NewSize(180, 110))
|
||
state.folderBody = body
|
||
|
||
outline := canvas.NewRectangle(color.Transparent)
|
||
outline.StrokeColor = color.NRGBA{0x28, 0x2a, 0x36, 0xff}
|
||
outline.StrokeWidth = 2
|
||
outline.CornerRadius = 10
|
||
outline.SetMinSize(fyne.NewSize(180, 110))
|
||
state.folderOutline = outline
|
||
|
||
label := canvas.NewText("folder", color.NRGBA{0x28, 0x2a, 0x36, 0xff})
|
||
label.TextSize = 13
|
||
label.TextStyle = fyne.TextStyle{Monospace: true}
|
||
label.Alignment = fyne.TextAlignCenter
|
||
state.folderLabel = label
|
||
|
||
icon := container.NewStack(body, outline, container.NewCenter(label))
|
||
|
||
btn := widget.NewButton("", func() {
|
||
if onTap != nil {
|
||
onTap()
|
||
}
|
||
})
|
||
btn.Importance = widget.LowImportance
|
||
btn.SetIcon(nil)
|
||
btn.SetText("")
|
||
|
||
return container.NewStack(icon, btn)
|
||
}
|
||
|
||
type uiState struct {
|
||
paths []string
|
||
output *widget.Entry
|
||
status *widget.Label
|
||
mu sync.Mutex
|
||
autoConvert bool
|
||
convertBtn *widget.Button
|
||
convertBg *canvas.Rectangle
|
||
spinner *widget.ProgressBarInfinite
|
||
busy bool
|
||
dark bool
|
||
palette palette
|
||
outputBg *canvas.Rectangle
|
||
bg *canvas.Rectangle
|
||
blob1 *canvas.Rectangle
|
||
blob2 *canvas.Rectangle
|
||
blob3 *canvas.Rectangle
|
||
card *canvas.Rectangle
|
||
folderBody *canvas.Rectangle
|
||
folderOutline *canvas.Rectangle
|
||
folderLabel *canvas.Text
|
||
tag *canvas.Text
|
||
}
|
||
|
||
type palette struct {
|
||
bg color.NRGBA
|
||
accent1 color.NRGBA
|
||
accent2 color.NRGBA
|
||
accent3 color.NRGBA
|
||
frame color.NRGBA
|
||
surface color.NRGBA
|
||
text color.NRGBA
|
||
}
|
||
|
||
func (s *uiState) paletteFor(dark bool) palette {
|
||
if dark {
|
||
return palette{
|
||
bg: color.NRGBA{0x1c, 0x1b, 0x1f, 0xff},
|
||
accent1: color.NRGBA{0xff, 0x9f, 0x6e, 0x66},
|
||
accent2: color.NRGBA{0x61, 0xb5, 0x9a, 0x66},
|
||
accent3: color.NRGBA{0x88, 0xa6, 0xf2, 0x66},
|
||
frame: color.NRGBA{0xee, 0xee, 0xee, 0xff},
|
||
surface: color.NRGBA{0x2a, 0x29, 0x30, 0xff},
|
||
text: color.NRGBA{0xee, 0xee, 0xee, 0xff},
|
||
}
|
||
}
|
||
return palette{
|
||
bg: color.NRGBA{0xf4, 0xf1, 0xec, 0xff},
|
||
accent1: color.NRGBA{0xff, 0xc7, 0x8f, 0x55},
|
||
accent2: color.NRGBA{0x8c, 0xd8, 0xc0, 0x55},
|
||
accent3: color.NRGBA{0xa7, 0xc5, 0xff, 0x55},
|
||
frame: color.NRGBA{0x28, 0x2a, 0x36, 0xff},
|
||
surface: color.NRGBA{0xe3, 0xe0, 0xda, 0xff},
|
||
text: color.NRGBA{0x1f, 0x1f, 0x1f, 0xff},
|
||
}
|
||
}
|
||
|
||
func (s *uiState) applyPalette(dark bool) {
|
||
p := s.paletteFor(dark)
|
||
s.palette = p
|
||
if s.bg != nil {
|
||
s.bg.FillColor = p.bg
|
||
}
|
||
if s.blob1 != nil {
|
||
s.blob1.FillColor = p.accent1
|
||
}
|
||
if s.blob2 != nil {
|
||
s.blob2.FillColor = p.accent2
|
||
}
|
||
if s.blob3 != nil {
|
||
s.blob3.FillColor = p.accent3
|
||
}
|
||
if s.card != nil {
|
||
s.card.StrokeColor = p.frame
|
||
}
|
||
if s.folderBody != nil {
|
||
s.folderBody.FillColor = p.surface
|
||
}
|
||
if s.folderOutline != nil {
|
||
s.folderOutline.StrokeColor = p.frame
|
||
}
|
||
if s.folderLabel != nil {
|
||
s.folderLabel.Color = p.text
|
||
}
|
||
if s.tag != nil {
|
||
s.tag.Color = p.text
|
||
}
|
||
if s.outputBg != nil {
|
||
s.outputBg.FillColor = p.surface
|
||
s.outputBg.StrokeColor = p.frame
|
||
}
|
||
if s.convertBg != nil {
|
||
s.convertBg.FillColor = p.accent2
|
||
s.convertBg.StrokeColor = p.frame
|
||
}
|
||
}
|
||
|
||
func (s *uiState) toggleTheme() string {
|
||
s.dark = !s.dark
|
||
s.applyPalette(s.dark)
|
||
if s.dark {
|
||
return "☀"
|
||
}
|
||
return "☾"
|
||
}
|
||
|
||
func (s *uiState) addPaths(inputs []string, win fyne.Window) {
|
||
files, err := input.Collect(inputs)
|
||
if err != nil {
|
||
dialog.ShowError(err, win)
|
||
return
|
||
}
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
seen := map[string]bool{}
|
||
for _, p := range s.paths {
|
||
seen[p] = true
|
||
}
|
||
for _, f := range files {
|
||
if !seen[f] {
|
||
s.paths = append(s.paths, f)
|
||
}
|
||
}
|
||
s.sortPaths()
|
||
if out := s.output.Text; strings.TrimSpace(out) == "" || out == "images.pdf" {
|
||
if def, err := input.DefaultOutput(s.paths[0]); err == nil {
|
||
s.output.SetText(def)
|
||
}
|
||
}
|
||
s.refresh()
|
||
if s.autoConvert {
|
||
s.runWithSpinner(win, true)
|
||
}
|
||
}
|
||
|
||
func (s *uiState) setPaths(paths []string) {
|
||
s.mu.Lock()
|
||
s.paths = paths
|
||
s.mu.Unlock()
|
||
s.refresh()
|
||
}
|
||
|
||
func (s *uiState) sortPaths() {
|
||
sort.Slice(s.paths, func(i, j int) bool {
|
||
return convert.NaturalLess(filepath.Base(s.paths[i]), filepath.Base(s.paths[j]))
|
||
})
|
||
}
|
||
|
||
func (s *uiState) refresh() {
|
||
if len(s.paths) == 0 {
|
||
s.status.SetText("Drop images or folders to begin.")
|
||
} else {
|
||
s.status.SetText(fmt.Sprintf("%d items queued", len(s.paths)))
|
||
}
|
||
}
|
||
|
||
func (s *uiState) convert(win fyne.Window) {
|
||
s.runWithSpinner(win, false)
|
||
}
|
||
|
||
func (s *uiState) convertInternal(win fyne.Window, auto bool) {
|
||
s.mu.Lock()
|
||
paths := append([]string(nil), s.paths...)
|
||
out := strings.TrimSpace(s.output.Text)
|
||
s.mu.Unlock()
|
||
|
||
if len(paths) == 0 {
|
||
if !auto {
|
||
dialog.ShowInformation("img2pdf", "No images to convert.", win)
|
||
}
|
||
return
|
||
}
|
||
if out == "" {
|
||
if def, err := input.DefaultOutput(paths[0]); err == nil {
|
||
out = def
|
||
} else {
|
||
out = "images.pdf"
|
||
}
|
||
s.output.SetText(out)
|
||
}
|
||
|
||
files, err := input.ReadSources(paths)
|
||
if err != nil {
|
||
dialog.ShowError(err, win)
|
||
return
|
||
}
|
||
|
||
s.status.SetText("Generating PDF...")
|
||
pdf, err := convert.ToPDF(files)
|
||
if err != nil {
|
||
dialog.ShowError(err, win)
|
||
s.refresh()
|
||
return
|
||
}
|
||
|
||
if err := os.WriteFile(out, pdf, 0644); err != nil {
|
||
dialog.ShowError(fmt.Errorf("write pdf: %w", err), win)
|
||
return
|
||
}
|
||
s.setPaths(nil)
|
||
s.output.SetText("images.pdf")
|
||
s.status.SetText(fmt.Sprintf("Saved %s", out))
|
||
if !auto {
|
||
dialog.ShowInformation("img2pdf", fmt.Sprintf("Saved %s", out), win)
|
||
}
|
||
}
|
||
|
||
func (s *uiState) runWithSpinner(win fyne.Window, auto bool) {
|
||
s.mu.Lock()
|
||
if s.busy {
|
||
s.mu.Unlock()
|
||
return
|
||
}
|
||
s.busy = true
|
||
s.mu.Unlock()
|
||
|
||
s.spinner.Show()
|
||
if s.convertBtn != nil {
|
||
s.convertBtn.SetText("Converting…")
|
||
s.convertBtn.Disable()
|
||
}
|
||
if s.convertBg != nil {
|
||
s.convertBg.FillColor = s.palette.accent1
|
||
}
|
||
s.convertInternal(win, auto)
|
||
s.spinner.Hide()
|
||
if s.convertBg != nil {
|
||
s.convertBg.FillColor = s.palette.surface
|
||
}
|
||
if s.convertBtn != nil {
|
||
s.convertBtn.SetText("Convert")
|
||
s.convertBtn.Enable()
|
||
}
|
||
s.mu.Lock()
|
||
s.busy = false
|
||
s.mu.Unlock()
|
||
}
|