icns

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

commit f9884d4e2b3aabf4c749f28f62f4731751e88b69
parent 23ec0ba0840b4bef57b45266e6ea7482080dce46
Author: Jack Mordaunt <jackmordaunt.dev@gmail.com>
Date:   Fri, 18 Sep 2026 16:09:33 -0400

cmd/preview: move to Gio 0.10

The pinned 0.3.1 used an event loop and an input API that Gio has since
removed, so the GUI could not follow a fix or a fresh dependency. Thumbnails
carry their own click state now, which is what the new input model wants and
which retires the hand-rolled pointer handling.

Diffstat:
M.github/workflows/ci.yml | 2++
Mcmd/preview/go.mod | 22+++++++++++-----------
Mcmd/preview/go.sum | 44++++++++++++++++++++++----------------------
Mcmd/preview/main.go | 263++++++++++++++++++++++++++++++++++++++-----------------------------------------
Acmd/preview/main_test.go | 134+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mgo.work | 2+-
Mgo.work.sum | 3+++
7 files changed, 301 insertions(+), 169 deletions(-)

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml @@ -102,6 +102,8 @@ jobs: go-version-file: cmd/preview/go.mod cache-dependency-path: cmd/preview/go.sum - run: go build ./... + # The render test skips itself where the runner has no GPU to draw on. + - run: go test -count=1 ./... - run: go build ./... env: GOWORK: "off" diff --git a/cmd/preview/go.mod b/cmd/preview/go.mod @@ -1,26 +1,26 @@ module github.com/jackmordaunt/icns/cmd/preview -go 1.23.0 +go 1.24.0 require ( - gioui.org v0.3.1 - gioui.org/x v0.3.2 + gioui.org v0.10.2 + gioui.org/x v0.10.2 github.com/jackmordaunt/icns/v4 v4.0.0 github.com/ncruces/zenity v0.10.10 ) require ( - gioui.org/cpu v0.0.0-20220412190645-f1e9e8c3b1f7 // indirect - gioui.org/shader v1.0.8 // indirect + gioui.org/shader v1.0.9 // indirect github.com/akavel/rsrc v0.10.2 // indirect github.com/dchest/jsmin v0.0.0-20220218165748-59f39799265f // indirect - github.com/go-text/typesetting v0.0.0-20231126133128-3b7c9205d99e // indirect + github.com/go-text/typesetting v0.3.4 // indirect github.com/josephspurrier/goversioninfo v1.4.0 // indirect github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect github.com/randall77/makefat v0.0.0-20210315173500-7ddd0e42c844 // indirect - golang.org/x/exp v0.0.0-20231127185646-65229373498e // indirect - golang.org/x/exp/shiny v0.0.0-20231127185646-65229373498e // indirect - golang.org/x/image v0.14.0 // indirect - golang.org/x/sys v0.15.0 // indirect - golang.org/x/text v0.14.0 // indirect + golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect + golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0 // indirect + golang.org/x/image v0.26.0 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/text v0.32.0 // indirect ) diff --git a/cmd/preview/go.sum b/cmd/preview/go.sum @@ -1,24 +1,22 @@ eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d h1:ARo7NCVvN2NdhLlJE9xAbKweuI9L6UgfTbYb0YwPacY= eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d/go.mod h1:OYVuxibdk9OSLX8vAqydtRPP87PyTFcT9uH3MlEGBQA= -gioui.org v0.3.1 h1:hslYkrkIWvx28Mxe3A87opl+8s9mnWsnWmPDh11+zco= -gioui.org v0.3.1/go.mod h1:2atiYR4upH71/6ehnh6XsUELa7JZOrOHHNMDxGBZF0Q= +gioui.org v0.10.2 h1:bZU5CORROwc51sNha0zYdE2qWVaDncOp5EjV5nrZQZ8= +gioui.org v0.10.2/go.mod h1:iKILKNq6+LHMWhP/HjGDW/wDidUzRnb7B6c7ZD9y1Mg= gioui.org/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ= -gioui.org/cpu v0.0.0-20220412190645-f1e9e8c3b1f7 h1:tNJdnP5CgM39PRc+KWmBRRYX/zJ+rd5XaYxY5d5veqA= -gioui.org/cpu v0.0.0-20220412190645-f1e9e8c3b1f7/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ= -gioui.org/shader v1.0.8 h1:6ks0o/A+b0ne7RzEqRZK5f4Gboz2CfG+mVliciy6+qA= -gioui.org/shader v1.0.8/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM= -gioui.org/x v0.3.2 h1:bvrXyuKE+389UEJ6XKjmaK+WUt+jIW6s1gvi8dxqox4= -gioui.org/x v0.3.2/go.mod h1:RH5KfSS6NkQEYPxNX2uBPxuklKey96Pt3bQHf0/VBLQ= +gioui.org/shader v1.0.9 h1:XxnqIfmClWpN49kizxH2W0JcCFrrEP4q3jZmNYaltbs= +gioui.org/shader v1.0.9/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM= +gioui.org/x v0.10.2 h1:aTfVGiWJbG4d2yHbD/2AAefMz0DpNezBBHM6PCljx3I= +gioui.org/x v0.10.2/go.mod h1:YAeEotSoR0MT6qcB3CLvLAqid3FO4Jht7+ic8jChAZ8= github.com/akavel/rsrc v0.10.2 h1:Zxm8V5eI1hW4gGaYsJQUhxpjkENuG91ki8B4zCrvEsw= github.com/akavel/rsrc v0.10.2/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c= github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dchest/jsmin v0.0.0-20220218165748-59f39799265f h1:OGqDDftRTwrvUoL6pOG7rYTmWsTCvyEWFsMjg+HcOaA= github.com/dchest/jsmin v0.0.0-20220218165748-59f39799265f/go.mod h1:Dv9D0NUlAsaQcGQZa5kc5mqR9ua72SmA8VXi4cd+cBw= -github.com/go-text/typesetting v0.0.0-20231126133128-3b7c9205d99e h1:XYK2AGBUaDkE9yG8FMOWCm0v/fjmcbkccKm1t3IIK/M= -github.com/go-text/typesetting v0.0.0-20231126133128-3b7c9205d99e/go.mod h1:evDBbvNR/KaVFZ2ZlDSOWWXIUKq0wCOEtzLxRM8SG3k= -github.com/go-text/typesetting-utils v0.0.0-20230616150549-2a7df14b6a22 h1:LBQTFxP2MfsyEDqSKmUBZaDuDHN1vpqDyOZjcqS7MYI= -github.com/go-text/typesetting-utils v0.0.0-20230616150549-2a7df14b6a22/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o= +github.com/go-text/typesetting v0.3.4 h1:YYurUOtEb9kGSOz4uE3k4OpBGsp1dDL8+fjCeaFamAU= +github.com/go-text/typesetting v0.3.4/go.mod h1:4qZCQphq4KSgGTAeI0uMEkVbROgfah8BuyF5LRYr7XY= +github.com/go-text/typesetting-utils v0.0.0-20260223113751-2d88ac90dae3 h1:drBZzMgdYPbmyXqOto4YhhJGrFIQCX94FpR4MzTCsos= +github.com/go-text/typesetting-utils v0.0.0-20260223113751-2d88ac90dae3/go.mod h1:3/62I4La/HBRX9TcTpBj4eipLiwzf+vhI+7whTc9V7o= github.com/jackmordaunt/icns/v4 v4.0.0 h1:mAZ+5rI8WEV1k/DxcwQA4FPiWXuLJHjP0MlIuDdkt0M= github.com/jackmordaunt/icns/v4 v4.0.0/go.mod h1:HakgKguVAB9QWZWj7Fb9YZlhIaH/mpKXIhQycPygcRQ= github.com/josephspurrier/goversioninfo v1.4.0 h1:Puhl12NSHUSALHSuzYwPYQkqa2E1+7SrtAPJorKK0C8= @@ -36,16 +34,18 @@ github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= go.uber.org/goleak v1.2.1 h1:NBol2c7O1ZokfZ0LEU9K6Whx/KnwvepVetCUhtKja4A= go.uber.org/goleak v1.2.1/go.mod h1:qlT2yGI9QafXHhZZLxlSuNsMw3FFLxBr+tBRlmO1xH4= -golang.org/x/exp v0.0.0-20231127185646-65229373498e h1:Gvh4YaCaXNs6dKTlfgismwWZKyjVZXwOPfIyUaqU3No= -golang.org/x/exp v0.0.0-20231127185646-65229373498e/go.mod h1:iRJReGqOEeBhDZGkGbynYwcHlctCvnjTYIamk7uXpHI= -golang.org/x/exp/shiny v0.0.0-20231127185646-65229373498e h1:OcpyLYky9rjmUp6ZYOow6ky00AmhIM2pL3vTv1lQErg= -golang.org/x/exp/shiny v0.0.0-20231127185646-65229373498e/go.mod h1:UH99kUObWAZkDnWqppdQe5ZhPYESUw8I0zVV1uWBR+0= -golang.org/x/image v0.14.0 h1:tNgSxAFe3jC4uYqvZdTr84SZoM1KfwdC9SKIFrLjFn4= -golang.org/x/image v0.14.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE= -golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= -golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= +golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= +golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0 h1:tMSqXTK+AQdW3LpCbfatHSRPHeW6+2WuxaVQuHftn80= +golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:ygj7T6vSGhhm/9yTpOQQNvuAUFziTH7RUiH74EoE2C8= +golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY= +golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/cmd/preview/main.go b/cmd/preview/main.go @@ -13,13 +13,10 @@ import ( "strings" "gioui.org/app" + "gioui.org/io/event" "gioui.org/io/key" - "gioui.org/io/pointer" - "gioui.org/io/system" - "gioui.org/layout" l "gioui.org/layout" "gioui.org/op" - "gioui.org/op/clip" "gioui.org/op/paint" "gioui.org/unit" "gioui.org/widget" @@ -33,19 +30,17 @@ import ( func main() { ui := UI{ - Window: app.NewWindow(app.Title("icnsify"), app.MinSize(700, 250)), - Th: m.NewTheme(), + Window: new(app.Window), + Th: m.NewTheme(), + ProcessedIcon: make(chan ProcessedIconResult, 1), } + ui.Window.Option(app.Title("icnsify"), app.MinSize(unit.Dp(700), unit.Dp(250))) if len(os.Args) > 1 { if file := os.Args[1]; filepath.Ext(file) == ".icns" { - go func() { + ui.Load(func() (string, []image.Image, error) { imgs, err := LoadImage(file) - ui.ProcessedIcon <- ProcessedIconResult{ - Imgs: imgs, - File: filepath.Base(file), - Err: err, - } - }() + return file, imgs, err + }) } } go func() { @@ -62,25 +57,30 @@ type ( D = l.Dimensions ) +// thumbnail is one icon resolution in the sidebar. +type thumbnail struct { + widget.Image + Click widget.Clickable +} + // UI contains all state for the UI. type UI struct { *app.Window Th *m.Theme // Preview points to the currently selected icon to render in the preview area. - Preview *widget.Image + Preview *thumbnail // Icons contains all the different resolutions found in the icns file. - Icons []widget.Image + Icons []*thumbnail // FileName is the name of the source icon file on disk. FileName string // Source is the original image data. Source image.Image OpenBtn widget.Clickable - SideBar layout.List + SideBar l.List ProcessedIcon chan ProcessedIconResult - Processing bool } type ProcessedIconResult struct { @@ -89,112 +89,120 @@ type ProcessedIconResult struct { Err error } +// Load runs work off the UI goroutine and wakes the window once it has a +// result to collect. +func (ui *UI) Load(work func() (string, []image.Image, error)) { + go func() { + file, imgs, err := work() + ui.ProcessedIcon <- ProcessedIconResult{ + File: filepath.Base(file), + Imgs: imgs, + Err: err, + } + ui.Window.Invalidate() + }() +} + // Loop initializes UI state and starts the render loop. func (ui *UI) Loop() error { - ui.ProcessedIcon = make(chan ProcessedIconResult) - var ( - ops op.Ops - events = ui.Window.Events() - ) - for event := range events { - switch event := (event).(type) { - case system.DestroyEvent: + var ops op.Ops + for { + switch event := ui.Window.Event().(type) { + case app.DestroyEvent: return event.Err - case system.FrameEvent: - gtx := l.NewContext(&ops, event) + case app.FrameEvent: + gtx := app.NewContext(&ops, event) ui.Update(gtx) ui.Layout(gtx) event.Frame(gtx.Ops) } } - return nil } // Update the UI state. func (ui *UI) Update(gtx C) { - if ui.Processing { - op.InvalidateOp{}.Add(gtx.Ops) - } - for _, event := range gtx.Events(ui) { - if k, ok := event.(key.Event); ok { - if k.Name == "S" && k.Modifiers.Contain(key.ModShortcut) && ui.Source != nil { - if err := func() error { - file, err := zenity.SelectFileSave( - zenity.Title("Save as icns"), - zenity.Filename(UseExt(ui.FileName, ".icns"))) - if err != nil { - return fmt.Errorf("selecting file: %w", err) - } - if err := ui.SaveAs(file); err != nil { - return fmt.Errorf("saving to icns: %w", err) - } - return nil - }(); err != nil { - log.Printf("saving png as icns: %v", err) - } - } + for { + e, ok := gtx.Event(key.Filter{ + Focus: ui, + Name: "S", + Required: key.ModShortcut, + }) + if !ok { + break + } + k, ok := e.(key.Event) + if !ok || k.State != key.Press || ui.Source == nil { + continue + } + if err := ui.SaveAsPrompt(); err != nil { + log.Printf("saving png as icns: %v", err) } } - for ii := range ui.Icons { - for _, event := range gtx.Events(ui.Icons[ii]) { - if c, ok := event.(pointer.Event); ok && c.Type == pointer.Release { - ui.Preview = &ui.Icons[ii] - } + for _, icon := range ui.Icons { + if icon.Click.Clicked(gtx) { + ui.Preview = icon } } - if ui.OpenBtn.Clicked() { - ui.Processing = true - go func() { - imgs, file, err := func() ([]image.Image, string, error) { - file, err := zenity.SelectFile(zenity.Title("Select .icns file")) - if err != nil { - return nil, "", fmt.Errorf("selecting file: %w", err) - } - imgs, err := LoadImage(file) - if err != nil { - return nil, "", err - } - return imgs, file, nil - }() - ui.ProcessedIcon <- ProcessedIconResult{ - File: filepath.Base(file), - Imgs: imgs, - Err: err, + if ui.OpenBtn.Clicked(gtx) { + ui.Load(func() (string, []image.Image, error) { + file, err := zenity.SelectFile(zenity.Title("Select .icns file")) + if err != nil { + return "", nil, fmt.Errorf("selecting file: %w", err) + } + imgs, err := LoadImage(file) + if err != nil { + return "", nil, err } - }() + return file, imgs, nil + }) } select { case r := <-ui.ProcessedIcon: if r.Err != nil { // TODO(jfm): push to dismissable error stack. log.Printf("loading icns file: %v", r.Err) - } else { - ui.Icons = ui.Icons[:0] - for _, img := range r.Imgs { - ui.Icons = append(ui.Icons, widget.Image{ + break + } + ui.Icons = ui.Icons[:0] + for _, img := range r.Imgs { + ui.Icons = append(ui.Icons, &thumbnail{ + Image: widget.Image{ Src: paint.NewImageOp(img), Fit: widget.Contain, Position: l.Center, - }) - } - if len(r.Imgs) > 0 { - ui.Source = r.Imgs[0] - } - if len(ui.Icons) > 0 { - ui.Preview = &ui.Icons[0] - } - ui.FileName = r.File - ui.Processing = false + }, + }) + } + ui.Preview = nil + if len(ui.Icons) > 0 { + ui.Source = r.Imgs[0] + ui.Preview = ui.Icons[0] } + ui.FileName = r.File default: } } +// SaveAsPrompt asks for a destination and writes the previewed icon to it. +func (ui *UI) SaveAsPrompt() error { + file, err := zenity.SelectFileSave( + zenity.Title("Save as icns"), + zenity.Filename(UseExt(ui.FileName, ".icns"))) + if err != nil { + return fmt.Errorf("selecting file: %w", err) + } + if err := ui.SaveAs(file); err != nil { + return fmt.Errorf("saving to icns: %w", err) + } + return nil +} + // Layout the UI. func (ui *UI) Layout(gtx C) D { ui.SideBar.Axis = l.Vertical - key.InputOp{Tag: ui}.Add(gtx.Ops) - key.FocusOp{Tag: ui}.Add(gtx.Ops) + // The window itself takes the keyboard, for the save shortcut. + event.Op(gtx.Ops, ui) + gtx.Execute(key.FocusCmd{Tag: ui}) return l.Flex{ Axis: l.Horizontal, }.Layout( @@ -220,13 +228,13 @@ func (ui *UI) LayoutSideBar(gtx C) D { }.Layout( gtx, l.Rigid(func(gtx C) D { - return l.UniformInset((5)).Layout(gtx, func(gtx C) D { - return m.Label(ui.Th, (15), ui.FileName).Layout(gtx) + return l.UniformInset(unit.Dp(5)).Layout(gtx, func(gtx C) D { + return m.Label(ui.Th, unit.Sp(15), ui.FileName).Layout(gtx) }) }), l.Flexed(1, func(gtx C) D { return ui.SideBar.Layout(gtx, len(ui.Icons), func(gtx C, ii int) D { - return l.UniformInset((15)).Layout(gtx, func(gtx C) D { + return l.UniformInset(unit.Dp(15)).Layout(gtx, func(gtx C) D { cs := &gtx.Constraints cs.Max.X = gtx.Dp(ThumbnailWidth) return ui.LayoutThumbnail(gtx, ii) @@ -241,51 +249,46 @@ func (ui *UI) LayoutPreviewArea(gtx C) D { return l.Center.Layout(gtx, func(gtx C) D { if ui.Preview == nil { btn := m.Button(ui.Th, &ui.OpenBtn, "Open") - btn.TextSize = (25) + btn.TextSize = unit.Sp(25) return btn.Layout(gtx) } - return ui.Preview.Layout(gtx) + return ui.Preview.Image.Layout(gtx) }) } // LayoutThumbnail displays a specific icon thumbnail. func (ui *UI) LayoutThumbnail(gtx C, ii int) D { - return l.Stack{}.Layout( - gtx, - l.Stacked(func(gtx C) D { - return l.Flex{ - Axis: l.Vertical, - Alignment: l.Middle, - }.Layout( - gtx, - l.Rigid(func(gtx C) D { - return ui.Icons[ii].Layout(gtx) - }), - l.Rigid(func(gtx C) D { - return m.Label(ui.Th, (15), strconv.Itoa(ii+1)). - Layout(gtx) - }), - ) - }), - l.Expanded(func(gtx C) D { - if ui.Icons[ii] == *ui.Preview { + icon := ui.Icons[ii] + return icon.Click.Layout(gtx, func(gtx C) D { + return l.Stack{}.Layout( + gtx, + l.Stacked(func(gtx C) D { + return l.Flex{ + Axis: l.Vertical, + Alignment: l.Middle, + }.Layout( + gtx, + l.Rigid(func(gtx C) D { + return icon.Image.Layout(gtx) + }), + l.Rigid(func(gtx C) D { + return m.Label(ui.Th, unit.Sp(15), strconv.Itoa(ii+1)). + Layout(gtx) + }), + ) + }), + l.Expanded(func(gtx C) D { + if ui.Preview != icon { + return D{} + } return c.Rect{ Size: gtx.Constraints.Min, Color: SelectedHighlight, Radii: 4, }.Layout(gtx) - } - return D{} - }), - l.Expanded(func(gtx C) D { - defer clip.Rect(image.Rectangle{Max: gtx.Constraints.Min}).Push(gtx.Ops).Pop() - pointer.InputOp{ - Tag: ui.Icons[ii], - Types: pointer.Release, - }.Add(gtx.Ops) - return D{} - }), - ) + }), + ) + }) } // SaveAs saves the previewed image as an icns icon at the path specified. @@ -304,16 +307,6 @@ func (ui *UI) SaveAs(path string) error { return nil } -// LoadImages loads the specified images to preview. -// Safe for concurrent use. -func (ui *UI) LoadImages(name string, imgs []image.Image) { - ui.ProcessedIcon <- ProcessedIconResult{ - Imgs: imgs, - File: name, - Err: nil, - } -} - // LoadImage will load all icons from an icns file, or generate them from a png file. func LoadImage(path string) ([]image.Image, error) { path, err := filepath.Abs(path) diff --git a/cmd/preview/main_test.go b/cmd/preview/main_test.go @@ -0,0 +1,134 @@ +package main + +import ( + "bytes" + "image" + "image/color" + "image/png" + "os" + "path/filepath" + "testing" + + "gioui.org/gpu/headless" + l "gioui.org/layout" + "gioui.org/op" + "gioui.org/unit" + m "gioui.org/widget/material" + "github.com/jackmordaunt/icns/v4" +) + +// iconColour is what the test icon is painted, chosen so it cannot be +// confused with the theme's greys. +var iconColour = color.NRGBA{R: 0xFF, G: 0x20, B: 0x20, A: 0xFF} + +// testIcons encodes a flat coloured icns and decodes it back, the same route +// the window takes when it opens a file. +func testIcons(t *testing.T) []image.Image { + t.Helper() + src := image.NewNRGBA(image.Rect(0, 0, 512, 512)) + for i := 0; i < len(src.Pix); i += 4 { + src.Pix[i] = iconColour.R + src.Pix[i+1] = iconColour.G + src.Pix[i+2] = iconColour.B + src.Pix[i+3] = iconColour.A + } + var buf bytes.Buffer + if err := icns.Encode(&buf, src); err != nil { + t.Fatalf("encoding test icns: %v", err) + } + imgs, err := icns.DecodeAll(&buf) + if err != nil { + t.Fatalf("decoding test icns: %v", err) + } + return imgs +} + +const ( + frameWidth = 800 + frameHeight = 600 +) + +// render lays the window out and rasterises it offscreen, returning how many +// pixels of the icon's colour reached the preview area on the right. +func render(t *testing.T, window *headless.Window, ui *UI) (int, *image.RGBA) { + t.Helper() + var ops op.Ops + gtx := l.Context{ + Ops: &ops, + Metric: unit.Metric{PxPerDp: 1, PxPerSp: 1}, + Constraints: l.Exact(image.Pt(frameWidth, frameHeight)), + } + ui.Update(gtx) + ui.Layout(gtx) + if err := window.Frame(gtx.Ops); err != nil { + t.Fatalf("rendering frame: %v", err) + } + shot := image.NewRGBA(image.Rect(0, 0, frameWidth, frameHeight)) + if err := window.Screenshot(shot); err != nil { + t.Fatalf("reading frame: %v", err) + } + var painted int + for y := 0; y < frameHeight; y++ { + for x := frameWidth / 2; x < frameWidth; x++ { + c := color.NRGBAModel.Convert(shot.At(x, y)).(color.NRGBA) + if c.R > 0xC0 && c.G < 0x60 && c.B < 0x60 && c.A > 0x80 { + painted++ + } + } + } + return painted, shot +} + +// TestRender rasterises the window offscreen, which is the only way to tell +// that the event and input plumbing still produces a frame. +func TestRender(t *testing.T) { + window, err := headless.NewWindow(frameWidth, frameHeight) + if err != nil { + t.Skipf("headless rendering unavailable: %v", err) + } + defer window.Release() + + // An empty window shows the open button and none of the icon's colour, + // which is what makes the count below mean something. + empty := &UI{Th: m.NewTheme(), ProcessedIcon: make(chan ProcessedIconResult, 1)} + if painted, shot := render(t, window, empty); painted > 100 { + t.Errorf("an empty window drew %d pixels of the icon's colour", painted) + if path := keep(t, shot); path != "" { + t.Logf("frame written to %s", path) + } + } + + ui := &UI{Th: m.NewTheme(), ProcessedIcon: make(chan ProcessedIconResult, 1)} + // Hand the window a loaded file the way the loader goroutine does, so + // Update drains it into thumbnails. + ui.ProcessedIcon <- ProcessedIconResult{File: "test.icns", Imgs: testIcons(t)} + painted, shot := render(t, window, ui) + if len(ui.Icons) == 0 { + t.Fatal("the loaded icons did not reach the window") + } + if ui.Preview == nil { + t.Fatal("no icon was selected for the preview area") + } + t.Logf("icon pixels in the preview area: %d", painted) + if painted < 1000 { + t.Errorf("only %d pixels of the icon were drawn in the preview area", painted) + if path := keep(t, shot); path != "" { + t.Logf("frame written to %s", path) + } + } +} + +// keep writes the frame out so a failure can be looked at. +func keep(t *testing.T, img image.Image) string { + t.Helper() + path := filepath.Join(t.TempDir(), "frame.png") + f, err := os.Create(path) + if err != nil { + return "" + } + defer f.Close() + if err := png.Encode(f, img); err != nil { + return "" + } + return path +} diff --git a/go.work b/go.work @@ -1,4 +1,4 @@ -go 1.23.0 +go 1.24.0 use ( . diff --git a/go.work.sum b/go.work.sum @@ -1,10 +1,13 @@ golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ= +golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= +golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=