img2pdf/internal/ui/ui.go
stu f00ca45f59 Update .gitignore, README, and add build outputs and tooling
- 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>
2025-12-01 09:28:38 -05:00

443 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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