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 := >x.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 }