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