icns

Easily create .icns files (Mac Icons) with this Go library or the included CLI.
Log | Files | Refs | LICENSE

main.go (7958B)


      1 // Previwer GUI for `.icns` icons.
      2 package main
      3 
      4 import (
      5 	"fmt"
      6 	"image"
      7 	"image/color"
      8 	"image/png"
      9 	"log"
     10 	"os"
     11 	"path/filepath"
     12 	"strconv"
     13 	"strings"
     14 
     15 	"gioui.org/app"
     16 	"gioui.org/io/event"
     17 	"gioui.org/io/key"
     18 	l "gioui.org/layout"
     19 	"gioui.org/op"
     20 	"gioui.org/op/paint"
     21 	"gioui.org/unit"
     22 	"gioui.org/widget"
     23 	m "gioui.org/widget/material"
     24 	c "gioui.org/x/component"
     25 	"github.com/jackmordaunt/icns/v4"
     26 	"github.com/ncruces/zenity"
     27 )
     28 
     29 // BUG(jfm): macOS file dialog returns "no such file or directory". Could be permissions issue.
     30 
     31 func main() {
     32 	ui := UI{
     33 		Window:        new(app.Window),
     34 		Th:            m.NewTheme(),
     35 		ProcessedIcon: make(chan ProcessedIconResult, 1),
     36 	}
     37 	ui.Window.Option(app.Title("icnsify"), app.MinSize(unit.Dp(700), unit.Dp(250)))
     38 	if len(os.Args) > 1 {
     39 		if file := os.Args[1]; filepath.Ext(file) == ".icns" {
     40 			ui.Load(func() (string, []image.Image, error) {
     41 				imgs, err := LoadImage(file)
     42 				return file, imgs, err
     43 			})
     44 		}
     45 	}
     46 	go func() {
     47 		if err := ui.Loop(); err != nil {
     48 			log.Fatalf("error: %v", err)
     49 		}
     50 		os.Exit(0)
     51 	}()
     52 	app.Main()
     53 }
     54 
     55 type (
     56 	C = l.Context
     57 	D = l.Dimensions
     58 )
     59 
     60 // thumbnail is one icon resolution in the sidebar.
     61 type thumbnail struct {
     62 	widget.Image
     63 	Click widget.Clickable
     64 }
     65 
     66 // UI contains all state for the UI.
     67 type UI struct {
     68 	*app.Window
     69 	Th *m.Theme
     70 
     71 	// Preview points to the currently selected icon to render in the preview area.
     72 	Preview *thumbnail
     73 	// Icons contains all the different resolutions found in the icns file.
     74 	Icons []*thumbnail
     75 	// FileName is the name of the source icon file on disk.
     76 	FileName string
     77 	// Source is the original image data.
     78 	Source image.Image
     79 
     80 	OpenBtn widget.Clickable
     81 	SideBar l.List
     82 
     83 	ProcessedIcon chan ProcessedIconResult
     84 }
     85 
     86 type ProcessedIconResult struct {
     87 	File string
     88 	Imgs []image.Image
     89 	Err  error
     90 }
     91 
     92 // Load runs work off the UI goroutine and wakes the window once it has a
     93 // result to collect.
     94 func (ui *UI) Load(work func() (string, []image.Image, error)) {
     95 	go func() {
     96 		file, imgs, err := work()
     97 		ui.ProcessedIcon <- ProcessedIconResult{
     98 			File: filepath.Base(file),
     99 			Imgs: imgs,
    100 			Err:  err,
    101 		}
    102 		ui.Window.Invalidate()
    103 	}()
    104 }
    105 
    106 // Loop initializes UI state and starts the render loop.
    107 func (ui *UI) Loop() error {
    108 	var ops op.Ops
    109 	for {
    110 		switch event := ui.Window.Event().(type) {
    111 		case app.DestroyEvent:
    112 			return event.Err
    113 		case app.FrameEvent:
    114 			gtx := app.NewContext(&ops, event)
    115 			ui.Update(gtx)
    116 			ui.Layout(gtx)
    117 			event.Frame(gtx.Ops)
    118 		}
    119 	}
    120 }
    121 
    122 // Update the UI state.
    123 func (ui *UI) Update(gtx C) {
    124 	for {
    125 		e, ok := gtx.Event(key.Filter{
    126 			Focus:    ui,
    127 			Name:     "S",
    128 			Required: key.ModShortcut,
    129 		})
    130 		if !ok {
    131 			break
    132 		}
    133 		k, ok := e.(key.Event)
    134 		if !ok || k.State != key.Press || ui.Source == nil {
    135 			continue
    136 		}
    137 		if err := ui.SaveAsPrompt(); err != nil {
    138 			log.Printf("saving png as icns: %v", err)
    139 		}
    140 	}
    141 	for _, icon := range ui.Icons {
    142 		if icon.Click.Clicked(gtx) {
    143 			ui.Preview = icon
    144 		}
    145 	}
    146 	if ui.OpenBtn.Clicked(gtx) {
    147 		ui.Load(func() (string, []image.Image, error) {
    148 			file, err := zenity.SelectFile(zenity.Title("Select .icns file"))
    149 			if err != nil {
    150 				return "", nil, fmt.Errorf("selecting file: %w", err)
    151 			}
    152 			imgs, err := LoadImage(file)
    153 			if err != nil {
    154 				return "", nil, err
    155 			}
    156 			return file, imgs, nil
    157 		})
    158 	}
    159 	select {
    160 	case r := <-ui.ProcessedIcon:
    161 		if r.Err != nil {
    162 			// TODO(jfm): push to dismissable error stack.
    163 			log.Printf("loading icns file: %v", r.Err)
    164 			break
    165 		}
    166 		ui.Icons = ui.Icons[:0]
    167 		for _, img := range r.Imgs {
    168 			ui.Icons = append(ui.Icons, &thumbnail{
    169 				Image: widget.Image{
    170 					Src:      paint.NewImageOp(img),
    171 					Fit:      widget.Contain,
    172 					Position: l.Center,
    173 				},
    174 			})
    175 		}
    176 		ui.Preview = nil
    177 		if len(ui.Icons) > 0 {
    178 			ui.Source = r.Imgs[0]
    179 			ui.Preview = ui.Icons[0]
    180 		}
    181 		ui.FileName = r.File
    182 	default:
    183 	}
    184 }
    185 
    186 // SaveAsPrompt asks for a destination and writes the previewed icon to it.
    187 func (ui *UI) SaveAsPrompt() error {
    188 	file, err := zenity.SelectFileSave(
    189 		zenity.Title("Save as icns"),
    190 		zenity.Filename(UseExt(ui.FileName, ".icns")))
    191 	if err != nil {
    192 		return fmt.Errorf("selecting file: %w", err)
    193 	}
    194 	if err := ui.SaveAs(file); err != nil {
    195 		return fmt.Errorf("saving to icns: %w", err)
    196 	}
    197 	return nil
    198 }
    199 
    200 // Layout the UI.
    201 func (ui *UI) Layout(gtx C) D {
    202 	ui.SideBar.Axis = l.Vertical
    203 	// The window itself takes the keyboard, for the save shortcut.
    204 	event.Op(gtx.Ops, ui)
    205 	gtx.Execute(key.FocusCmd{Tag: ui})
    206 	return l.Flex{
    207 		Axis: l.Horizontal,
    208 	}.Layout(
    209 		gtx,
    210 		l.Rigid(func(gtx C) D { return ui.LayoutSideBar(gtx) }),
    211 		l.Flexed(1, func(gtx C) D { return ui.LayoutPreviewArea(gtx) }),
    212 	)
    213 }
    214 
    215 var (
    216 	// ThumbnailWidth specifies how wide the sidebar thumbnails should be.
    217 	ThumbnailWidth = unit.Dp(125)
    218 	// SelectedHighlight specifies the color to render behind the selected thumbnail.
    219 	SelectedHighlight = color.NRGBA{A: 50}
    220 )
    221 
    222 // LayoutSideBar displays a sidebar which contains a list of thumbnails for the various icns
    223 // resolutions.
    224 func (ui *UI) LayoutSideBar(gtx C) D {
    225 	return l.Flex{
    226 		Axis:      l.Vertical,
    227 		Alignment: l.Middle,
    228 	}.Layout(
    229 		gtx,
    230 		l.Rigid(func(gtx C) D {
    231 			return l.UniformInset(unit.Dp(5)).Layout(gtx, func(gtx C) D {
    232 				return m.Label(ui.Th, unit.Sp(15), ui.FileName).Layout(gtx)
    233 			})
    234 		}),
    235 		l.Flexed(1, func(gtx C) D {
    236 			return ui.SideBar.Layout(gtx, len(ui.Icons), func(gtx C, ii int) D {
    237 				return l.UniformInset(unit.Dp(15)).Layout(gtx, func(gtx C) D {
    238 					cs := &gtx.Constraints
    239 					cs.Max.X = gtx.Dp(ThumbnailWidth)
    240 					return ui.LayoutThumbnail(gtx, ii)
    241 				})
    242 			})
    243 		}),
    244 	)
    245 }
    246 
    247 // LayoutPreviewArea displays the selected icon resultion scaled to the size of the area.
    248 func (ui *UI) LayoutPreviewArea(gtx C) D {
    249 	return l.Center.Layout(gtx, func(gtx C) D {
    250 		if ui.Preview == nil {
    251 			btn := m.Button(ui.Th, &ui.OpenBtn, "Open")
    252 			btn.TextSize = unit.Sp(25)
    253 			return btn.Layout(gtx)
    254 		}
    255 		return ui.Preview.Image.Layout(gtx)
    256 	})
    257 }
    258 
    259 // LayoutThumbnail displays a specific icon thumbnail.
    260 func (ui *UI) LayoutThumbnail(gtx C, ii int) D {
    261 	icon := ui.Icons[ii]
    262 	return icon.Click.Layout(gtx, func(gtx C) D {
    263 		return l.Stack{}.Layout(
    264 			gtx,
    265 			l.Stacked(func(gtx C) D {
    266 				return l.Flex{
    267 					Axis:      l.Vertical,
    268 					Alignment: l.Middle,
    269 				}.Layout(
    270 					gtx,
    271 					l.Rigid(func(gtx C) D {
    272 						return icon.Image.Layout(gtx)
    273 					}),
    274 					l.Rigid(func(gtx C) D {
    275 						return m.Label(ui.Th, unit.Sp(15), strconv.Itoa(ii+1)).
    276 							Layout(gtx)
    277 					}),
    278 				)
    279 			}),
    280 			l.Expanded(func(gtx C) D {
    281 				if ui.Preview != icon {
    282 					return D{}
    283 				}
    284 				return c.Rect{
    285 					Size:  gtx.Constraints.Min,
    286 					Color: SelectedHighlight,
    287 					Radii: 4,
    288 				}.Layout(gtx)
    289 			}),
    290 		)
    291 	})
    292 }
    293 
    294 // SaveAs saves the previewed image as an icns icon at the path specified.
    295 func (ui *UI) SaveAs(path string) error {
    296 	if ui.Source == nil {
    297 		return nil
    298 	}
    299 	f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0644)
    300 	if err != nil {
    301 		return fmt.Errorf("creating file: %w", err)
    302 	}
    303 	defer f.Close()
    304 	if err := icns.Encode(f, ui.Source); err != nil {
    305 		return fmt.Errorf("encoding icns: %w", err)
    306 	}
    307 	return nil
    308 }
    309 
    310 // LoadImage will load all icons from an icns file, or generate them from a png file.
    311 func LoadImage(path string) ([]image.Image, error) {
    312 	path, err := filepath.Abs(path)
    313 	if err != nil {
    314 		return nil, fmt.Errorf("resolving file path: %w", err)
    315 	}
    316 	f, err := os.OpenFile(path, os.O_RDONLY, 0644)
    317 	if err != nil {
    318 		return nil, err
    319 	}
    320 	defer f.Close()
    321 	switch filepath.Ext(path) {
    322 	case ".icns":
    323 		imgs, err := icns.DecodeAll(f)
    324 		if err != nil {
    325 			return nil, fmt.Errorf("decoding icns: %w", err)
    326 		}
    327 		return imgs, nil
    328 	case ".png":
    329 		img, err := png.Decode(f)
    330 		if err != nil {
    331 			return nil, fmt.Errorf("decoding png: %w", err)
    332 		}
    333 		return []image.Image{img}, nil
    334 	}
    335 	return nil, nil
    336 }
    337 
    338 // UseExt replaces any existing file extension with the provided one.
    339 func UseExt(s, ext string) string {
    340 	return strings.TrimSuffix(s, filepath.Ext(s)) + ext
    341 }