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