├── Icon.png ├── Screenshot.png ├── internal ├── ui │ ├── testdata │ │ └── 8x8.png │ ├── status.go │ ├── status_test.go │ ├── palette_test.go │ ├── history.go │ ├── raster_test.go │ ├── palette.go │ ├── raster.go │ ├── main.go │ ├── editor_test.go │ └── editor.go ├── data │ ├── gen.sh │ ├── pencil.svg │ ├── icon.go │ ├── dropper.svg │ ├── invert.svg │ ├── palette.svg │ └── bundled.go ├── api │ ├── tool.go │ └── editor.go └── tool │ ├── pencil_test.go │ ├── picker_test.go │ ├── picker.go │ ├── pencil.go │ └── util_test.go ├── .gitignore ├── README.md ├── main.go ├── .travis.yml ├── go.mod └── go.sum /Icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fyne-io/pixeledit/HEAD/Icon.png -------------------------------------------------------------------------------- /Screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fyne-io/pixeledit/HEAD/Screenshot.png -------------------------------------------------------------------------------- /internal/ui/testdata/8x8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fyne-io/pixeledit/HEAD/internal/ui/testdata/8x8.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .DS_Store 3 | 4 | pixeledit 5 | pixeledit.app 6 | pixeledit.exe 7 | pixeledit.tar.gz 8 | 9 | -------------------------------------------------------------------------------- /internal/ui/status.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "fyne.io/fyne/v2/widget" 5 | ) 6 | 7 | func newStatusBar() *widget.Label { 8 | return widget.NewLabel("Open a file") 9 | } 10 | -------------------------------------------------------------------------------- /internal/data/gen.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | DIR=`dirname "$0"` 4 | FILE=bundled.go 5 | BIN=`go env GOPATH`/bin 6 | 7 | cd $DIR 8 | rm $FILE 9 | 10 | $BIN/fyne bundle -package data -name pencil pencil.svg > $FILE 11 | $BIN/fyne bundle -package data -append -name dropper dropper.svg >> $FILE 12 | -------------------------------------------------------------------------------- /internal/data/pencil.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /internal/data/icon.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import "fyne.io/fyne/v2/theme" 4 | 5 | var ( 6 | // PencilIcon is the themed icon that shows for the pixel setting tool 7 | PencilIcon = theme.NewThemedResource(pencil) 8 | // DropperIcon is the themed icon for picking a color from an image 9 | DropperIcon = theme.NewThemedResource(dropper) 10 | ) 11 | -------------------------------------------------------------------------------- /internal/data/dropper.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /internal/data/invert.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /internal/ui/status_test.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestStatus(t *testing.T) { 11 | file := testFile("8x8") 12 | e := testEditorWithFile(file) 13 | 14 | assert.True(t, strings.Contains(e.(*editor).status.Text, "File: 8x8.png")) 15 | assert.True(t, strings.Contains(e.(*editor).status.Text, "Width: 8")) 16 | } 17 | -------------------------------------------------------------------------------- /internal/api/tool.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fyne.io/fyne/v2" 5 | ) 6 | 7 | // Tool represents any pixel editing tool that can be loaded into our editor 8 | type Tool interface { 9 | Name() string // Name is the human readable name of this tool 10 | Icon() fyne.Resource // Icon returns a resource that we can use for button icons 11 | Clicked(x, y int, edit Editor) // Clicked is called when the tool is active and the user interacts with the editor 12 | } 13 | -------------------------------------------------------------------------------- /internal/tool/pencil_test.go: -------------------------------------------------------------------------------- 1 | package tool 2 | 3 | import ( 4 | "image/color" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestPencil_Data(t *testing.T) { 11 | p := &Pencil{} 12 | 13 | assert.Equal(t, "Pencil", p.Name()) 14 | assert.NotNil(t, p.Icon()) 15 | } 16 | 17 | func TestPencil_Clicked(t *testing.T) { 18 | e := newTestEditor() 19 | col := color.RGBA{R: 0, G: 255, B: 0, A: 255} 20 | e.SetFGColor(col) 21 | 22 | p := &Pencil{} 23 | p.Clicked(0, 0, e) 24 | assert.Equal(t, col, e.PixelColor(0, 0)) 25 | } 26 | -------------------------------------------------------------------------------- /internal/tool/picker_test.go: -------------------------------------------------------------------------------- 1 | package tool 2 | 3 | import ( 4 | "image/color" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestPicker_Data(t *testing.T) { 11 | p := &Picker{} 12 | 13 | assert.Equal(t, "Picker", p.Name()) 14 | assert.NotNil(t, p.Icon()) 15 | } 16 | 17 | func TestPicker_Clicked(t *testing.T) { 18 | e := newTestEditor() 19 | col := color.RGBA{R: 255, G: 0, B: 0, A: 255} 20 | e.SetPixelColor(0, 0, col) 21 | 22 | p := &Picker{} 23 | p.Clicked(0, 0, e) 24 | assert.Equal(t, col, e.FGColor()) 25 | } 26 | -------------------------------------------------------------------------------- /internal/data/palette.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /internal/tool/picker.go: -------------------------------------------------------------------------------- 1 | package tool 2 | 3 | import ( 4 | "fyne.io/fyne/v2" 5 | 6 | "github.com/fyne-io/pixeledit/internal/api" 7 | "github.com/fyne-io/pixeledit/internal/data" 8 | ) 9 | 10 | // Picker allows setting the current image colors from a pixel in an image 11 | type Picker struct { 12 | } 13 | 14 | // Name returns the name of this tool 15 | func (p *Picker) Name() string { 16 | return "Picker" 17 | } 18 | 19 | // Icon returns the icon for this tool 20 | func (p *Picker) Icon() fyne.Resource { 21 | return data.DropperIcon 22 | } 23 | 24 | // Clicked will set the editor foreground color to the color of the pixel under the cursor 25 | func (p *Picker) Clicked(x, y int, edit api.Editor) { 26 | edit.SetFGColor(edit.PixelColor(x, y)) 27 | } 28 | -------------------------------------------------------------------------------- /internal/tool/pencil.go: -------------------------------------------------------------------------------- 1 | package tool 2 | 3 | import ( 4 | "fyne.io/fyne/v2" 5 | "fyne.io/fyne/v2/canvas" 6 | 7 | "github.com/fyne-io/pixeledit/internal/api" 8 | "github.com/fyne-io/pixeledit/internal/data" 9 | ) 10 | 11 | // Pencil is a pixel setting tool 12 | type Pencil struct { 13 | Out *canvas.Rectangle 14 | } 15 | 16 | // Name returns the name of this tool 17 | func (p *Pencil) Name() string { 18 | return "Pencil" 19 | } 20 | 21 | // Icon returns the icon of this tool 22 | func (p *Pencil) Icon() fyne.Resource { 23 | return data.PencilIcon 24 | } 25 | 26 | // Clicked will set the pixel under the cursor to the current editor foreground color 27 | func (p *Pencil) Clicked(x, y int, edit api.Editor) { 28 | edit.SetPixelColor(x, y, edit.FGColor()) 29 | } 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
7 | 8 | # Pixel Editor 9 | 10 | A pixel based image editor built using Fyne. 11 | 12 |
13 |
14 |
--------------------------------------------------------------------------------
/internal/api/editor.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "image/color"
5 |
6 | "fyne.io/fyne/v2"
7 | )
8 |
9 | // Editor describes the editing capabilities of a pixel editor
10 | type Editor interface {
11 | BuildUI(fyne.Window) // BuildUI Loads the main editor GUI
12 | LoadFile(fyne.URIReadCloser) // LoadFile specifies a data stream to load from
13 | Reload() // Reload will reset the image to its original state
14 | Save() // Save writes the image back to its source location
15 | SaveAs(fyne.URIWriteCloser) // SaveAs specifies a data stream to save to
16 |
17 | PixelColor(x, y int) color.Color // Get the color of a pixel in our image
18 | SetPixelColor(x, y int, col color.Color) // Set the color of the indicated pixel
19 |
20 | FGColor() color.Color
21 | SetFGColor(color.Color)
22 | }
23 |
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "os"
5 | "time"
6 |
7 | "fyne.io/fyne/v2"
8 | "fyne.io/fyne/v2/app"
9 | "fyne.io/fyne/v2/storage"
10 |
11 | "github.com/fyne-io/pixeledit/internal/api"
12 | "github.com/fyne-io/pixeledit/internal/ui"
13 | )
14 |
15 | func loadFileArgs(e api.Editor) {
16 | if len(os.Args) < 2 {
17 | return
18 | }
19 |
20 | time.Sleep(time.Second / 3) // wait for us to be shown before loading so scales are correct
21 | read, err := storage.Reader(storage.NewFileURI(os.Args[1]))
22 | if err != nil {
23 | fyne.LogError("Unable to open file \""+os.Args[1]+"\"", err)
24 | return
25 | }
26 | e.LoadFile(read)
27 | }
28 |
29 | func main() {
30 | e := ui.NewEditor()
31 |
32 | a := app.NewWithID("io.fyne.pixeledit")
33 | w := a.NewWindow("Pixel Editor")
34 | e.BuildUI(w)
35 | w.Resize(fyne.NewSize(520, 320))
36 |
37 | go loadFileArgs(e)
38 | w.ShowAndRun()
39 | }
40 |
--------------------------------------------------------------------------------
/internal/ui/palette_test.go:
--------------------------------------------------------------------------------
1 | package ui
2 |
3 | import (
4 | "testing"
5 |
6 | "fyne.io/fyne/v2"
7 | "fyne.io/fyne/v2/test"
8 | "fyne.io/fyne/v2/widget"
9 | "github.com/stretchr/testify/assert"
10 | )
11 |
12 | func TestDefaultZoom(t *testing.T) {
13 | file := testFile("8x8")
14 | e := testEditorWithFile(file)
15 |
16 | p := newPalette(e.(*editor))
17 | zoom := p.(*widget.Box).Children[0].(*fyne.Container).Objects[1].(*widget.Box).Children[1].(*widget.Label)
18 | assert.Equal(t, "100%", zoom.Text)
19 | }
20 |
21 | func TestZoomIn(t *testing.T) {
22 | file := testFile("8x8")
23 | e := testEditorWithFile(file)
24 | assert.Equal(t, 1, e.(*editor).zoom)
25 |
26 | p := newPalette(e.(*editor))
27 | zoomItems := p.(*widget.Box).Children[0].(*fyne.Container).Objects[1].(*widget.Box).Children
28 | zoom := zoomItems[1].(*widget.Label)
29 | zoomIn := zoomItems[2].(*widget.Button)
30 | test.Tap(zoomIn)
31 |
32 | assert.Equal(t, 2, e.(*editor).zoom)
33 | assert.Equal(t, "200%", zoom.Text)
34 | }
35 |
--------------------------------------------------------------------------------
/internal/ui/history.go:
--------------------------------------------------------------------------------
1 | package ui
2 |
3 | import (
4 | "fmt"
5 |
6 | "fyne.io/fyne/v2"
7 | "fyne.io/fyne/v2/storage"
8 | )
9 |
10 | const (
11 | recentCountKey = "recentCount"
12 | recentFormatKey = "recent-%d"
13 | )
14 |
15 | func (e *editor) loadRecent() []fyne.URI {
16 | pref := fyne.CurrentApp().Preferences()
17 | count := pref.Int(recentCountKey)
18 |
19 | var recent []fyne.URI
20 | for i := 0; i < count; i++ {
21 | key := fmt.Sprintf(recentFormatKey, i)
22 | u, _ := storage.ParseURI(pref.String(key))
23 | recent = append(recent, u)
24 | }
25 |
26 | return recent
27 | }
28 |
29 | func (e *editor) addRecent(u fyne.URI) {
30 | pref := fyne.CurrentApp().Preferences()
31 | recent := e.loadRecent()
32 |
33 | recent = append([]fyne.URI{u}, recent...)
34 | if len(recent) > 5 {
35 | recent = recent[:5]
36 | }
37 |
38 | pref.SetInt(recentCountKey, len(recent))
39 | for i, uri := range recent {
40 | key := fmt.Sprintf(recentFormatKey, i)
41 | pref.SetString(key, uri.String())
42 | }
43 |
44 | e.loadRecentMenu()
45 | e.win.SetMainMenu(e.win.MainMenu())
46 | }
47 |
--------------------------------------------------------------------------------
/internal/ui/raster_test.go:
--------------------------------------------------------------------------------
1 | package ui
2 |
3 | import (
4 | "testing"
5 |
6 | "fyne.io/fyne/v2"
7 | "fyne.io/fyne/v2/test"
8 | "github.com/stretchr/testify/assert"
9 | )
10 |
11 | func TestInteractiveRaster_MinSize(t *testing.T) {
12 | file := testFile("8x8")
13 | e := testEditorWithFile(file).(*editor)
14 |
15 | rast := newInteractiveRaster(e)
16 | e.drawSurface = rast
17 | e.setZoom(1)
18 | assert.Equal(t, fyne.NewSize(8, 8), rast.MinSize())
19 | assert.Equal(t, fyne.NewSize(8, 8), rast.Size())
20 |
21 | e.setZoom(2)
22 | assert.Equal(t, fyne.NewSize(16, 16), rast.MinSize())
23 | assert.Equal(t, fyne.NewSize(16, 16), rast.Size())
24 | }
25 |
26 | func TestInteractiveRaster_locationForPositon(t *testing.T) {
27 | file := testFile("8x8")
28 | e := testEditorWithFile(file).(*editor)
29 |
30 | r := newInteractiveRaster(e)
31 | x, y := r.locationForPosition(fyne.NewPos(2, 2))
32 | assert.Equal(t, 2, x)
33 | assert.Equal(t, 2, y)
34 |
35 | testCanvas := fyne.CurrentApp().Driver().CanvasForObject(r).(test.WindowlessCanvas)
36 | testCanvas.SetScale(2.0)
37 | x, y = r.locationForPosition(fyne.NewPos(2, 2))
38 | assert.Equal(t, 4, x)
39 | assert.Equal(t, 4, y)
40 | }
41 |
--------------------------------------------------------------------------------
/internal/tool/util_test.go:
--------------------------------------------------------------------------------
1 | package tool
2 |
3 | import (
4 | "image"
5 | "image/color"
6 |
7 | "fyne.io/fyne/v2"
8 | "fyne.io/fyne/v2/widget"
9 | )
10 |
11 | type testEditor struct {
12 | img *image.RGBA
13 |
14 | fg color.Color
15 | }
16 |
17 | func (t *testEditor) BuildUI(w fyne.Window) {
18 | w.SetContent(widget.NewLabel("Not used"))
19 | }
20 |
21 | func (t *testEditor) LoadFile(fyne.URIReadCloser) {
22 | // no-op
23 | }
24 |
25 | func (t *testEditor) Reload() {
26 | t.img = testImage()
27 | }
28 |
29 | func (t *testEditor) Save() {
30 | //no-op
31 | }
32 |
33 | func (t *testEditor) SaveAs(fyne.URIWriteCloser) {
34 | //no-op
35 | }
36 |
37 | func (t *testEditor) PixelColor(x, y int) color.Color {
38 | return t.img.At(x, y)
39 | }
40 |
41 | // TODO move out
42 | func colorToBytes(col color.Color) []uint8 {
43 | r, g, b, a := col.RGBA()
44 | return []uint8{uint8(r >> 8), uint8(g >> 8), uint8(b >> 8), uint8(a >> 8)}
45 | }
46 |
47 | func (t *testEditor) SetPixelColor(x, y int, col color.Color) {
48 | i := (y*t.img.Bounds().Dx() + x) * 4
49 | rgba := colorToBytes(col)
50 | t.img.Pix[i] = rgba[0]
51 | t.img.Pix[i+1] = rgba[1]
52 | t.img.Pix[i+2] = rgba[2]
53 | t.img.Pix[i+3] = rgba[3]
54 | }
55 |
56 | func (t *testEditor) FGColor() color.Color {
57 | return t.fg
58 | }
59 |
60 | func (t *testEditor) SetFGColor(col color.Color) {
61 | t.fg = col
62 | }
63 |
64 | func testImage() *image.RGBA {
65 | return image.NewRGBA(image.Rect(0, 0, 4, 4))
66 | }
67 |
68 | func newTestEditor() *testEditor {
69 | return &testEditor{
70 | img: testImage(),
71 | fg: color.Black,
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: go
2 |
3 | go:
4 | - "1.12.x"
5 | - "1.13.x"
6 |
7 | gobuild_args: "-v -tags ci"
8 |
9 | before_script:
10 | - NO_VENDOR=$(find . -iname '*.go' -type f | grep -v /vendor/)
11 | - go get golang.org/x/tools/cmd/goimports
12 | - go get golang.org/x/lint/golint
13 | - go get github.com/mattn/goveralls
14 | - go get fyne.io/fyne
15 |
16 | script:
17 | - test -z "$(goimports -e -d $NO_VENDOR | tee /dev/stderr)"
18 | - go test -tags ci -race ./...
19 | - go vet -tags ci ./...
20 | - golint -set_exit_status $(go list -tags ci)
21 | - 'if [ "$TRAVIS_PULL_REQUEST" != "false" ]; then exit; fi'
22 | - |
23 | set -e
24 | if [[ "$TRAVIS_PULL_REQUEST" == "false" && -n "$COVERALLS_TOKEN" ]]
25 | then
26 | go test -tags ci -covermode=atomic -coverprofile=coverage.out ./...
27 | $(go env GOPATH | awk 'BEGIN{FS=":"} {print $1}')/bin/goveralls -coverprofile=coverage.out -service=travis-ci -repotoken $COVERALLS_TOKEN
28 | fi
29 |
30 | env:
31 | global:
32 | secure: "TXvumQle32IDF7+ZA1639IYPJkzeu1h2w7dnEiuJeLyY84o9TXIjprLmoOai0LNhzjvzXna6mw+86INzD/pS571kLxuRm/aigL+UDlM8Xhl4KZ0h7Ierf5laEdBZjMApYdzjtYhUpY0kJW7WzcJP25exn7Onkd6jxcUx3B46oeXGXldVgyWnePxFtdu7H18qT9cwjw+VVFJNZ8Fb2DPrCL5K9f4nOEl3wgtmSkks11xucLeutvAsjXffGmesvyk5kdZScepTw4AlFTAqKBsKKGBKWY4yg+1evce6DhCew/ATfqEpiihRhbaXTyNhPg2MzIqANJ4IwYKIuOV2bky6Cf8VSec2ORTW1UznLdu8B9jgPYr49vhLFIdUjNT1b8mFDDK5lANq7B3jqz63XyH+HvXc8zzjeDBGMS45S8uHRoBDWcB2wkwmYET7uqwLezG5epzQIR7eCQCRUrRtFg0NdawIClt5JkAdBMqUoZVojknKH/9h3Tnem4xD/4I2MR1G0dD5mCz1bGaiIw80Kh7ef4IYnRhikLGGY2Cl4Or29mbBEsfWoXJJNV7EejqlcDsZj1jdt4PSdF2MFJwYy3gW19TkcXtgUzWOM3eGPKOsXdHXBF7EDGL7yIkjdIilDnyuaH4mOoJIlqX2LZAZWohmaG1q/C/Gk4+sCzgXHYupqSo="
33 |
34 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/fyne-io/pixeledit
2 |
3 | go 1.19
4 |
5 | require (
6 | fyne.io/fyne/v2 v2.7.0
7 | github.com/stretchr/testify v1.11.1
8 | golang.org/x/image v0.24.0
9 | )
10 |
11 | require (
12 | fyne.io/systray v1.11.1-0.20250603113521-ca66a66d8b58 // indirect
13 | github.com/BurntSushi/toml v1.5.0 // indirect
14 | github.com/davecgh/go-spew v1.1.1 // indirect
15 | github.com/fredbi/uri v1.1.1 // indirect
16 | github.com/fsnotify/fsnotify v1.9.0 // indirect
17 | github.com/fyne-io/gl-js v0.2.0 // indirect
18 | github.com/fyne-io/glfw-js v0.3.0 // indirect
19 | github.com/fyne-io/image v0.1.1 // indirect
20 | github.com/fyne-io/oksvg v0.2.0 // indirect
21 | github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 // indirect
22 | github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a // indirect
23 | github.com/go-text/render v0.2.0 // indirect
24 | github.com/go-text/typesetting v0.2.1 // indirect
25 | github.com/godbus/dbus/v5 v5.1.0 // indirect
26 | github.com/hack-pad/go-indexeddb v0.3.2 // indirect
27 | github.com/hack-pad/safejs v0.1.0 // indirect
28 | github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade // indirect
29 | github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect
30 | github.com/kr/text v0.2.0 // indirect
31 | github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
32 | github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect
33 | github.com/pmezard/go-difflib v1.0.0 // indirect
34 | github.com/rymdport/portal v0.4.2 // indirect
35 | github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect
36 | github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect
37 | github.com/yuin/goldmark v1.7.8 // indirect
38 | golang.org/x/net v0.35.0 // indirect
39 | golang.org/x/sys v0.30.0 // indirect
40 | golang.org/x/text v0.22.0 // indirect
41 | gopkg.in/yaml.v3 v3.0.1 // indirect
42 | )
43 |
--------------------------------------------------------------------------------
/internal/ui/palette.go:
--------------------------------------------------------------------------------
1 | package ui
2 |
3 | import (
4 | "fmt"
5 |
6 | "fyne.io/fyne/v2"
7 | "fyne.io/fyne/v2/container"
8 | "fyne.io/fyne/v2/theme"
9 | "fyne.io/fyne/v2/widget"
10 |
11 | "github.com/fyne-io/pixeledit/internal/api"
12 | "github.com/fyne-io/pixeledit/internal/tool"
13 | )
14 |
15 | type palette struct {
16 | edit *editor
17 |
18 | zoom *widget.Label
19 | }
20 |
21 | func (p *palette) updateZoom(val int) {
22 | if val < 1 {
23 | val = 1
24 | } else if val > 16 {
25 | val = 16
26 | }
27 | p.edit.setZoom(val)
28 |
29 | p.zoom.SetText(fmt.Sprintf("%d%%", p.edit.zoom*100))
30 | }
31 |
32 | func (p *palette) loadTools() []api.Tool {
33 | return []api.Tool{
34 | &tool.Picker{},
35 | &tool.Pencil{},
36 | }
37 | }
38 |
39 | func newPalette(edit *editor) fyne.CanvasObject {
40 | p := &palette{edit: edit, zoom: widget.NewLabel("100%")}
41 |
42 | tools := p.loadTools()
43 |
44 | var toolIcons []fyne.CanvasObject
45 | for _, item := range tools {
46 | var icon *widget.Button
47 | thisTool := item
48 | icon = widget.NewButtonWithIcon(item.Name(), item.Icon(), func() {
49 | for _, toolButton := range toolIcons {
50 | toolButton.(*widget.Button).Importance = widget.MediumImportance
51 | toolButton.Refresh()
52 | }
53 | icon.Importance = widget.HighImportance
54 | icon.Refresh()
55 | edit.setTool(thisTool)
56 | })
57 | toolIcons = append(toolIcons, icon)
58 | }
59 |
60 | edit.setTool(tools[0])
61 | toolIcons[0].(*widget.Button).Importance = widget.HighImportance
62 |
63 | zoom := container.NewHBox(
64 | widget.NewButtonWithIcon("", theme.ZoomOutIcon(), func() {
65 | p.updateZoom(p.edit.zoom / 2)
66 | }),
67 | p.zoom,
68 | widget.NewButtonWithIcon("", theme.ZoomInIcon(), func() {
69 | p.updateZoom(p.edit.zoom * 2)
70 | }))
71 |
72 | return container.NewVBox(append([]fyne.CanvasObject{container.NewGridWithColumns(1),
73 | widget.NewLabel("Tools"), zoom, edit.fgPreview}, toolIcons...)...)
74 | }
75 |
--------------------------------------------------------------------------------
/internal/data/bundled.go:
--------------------------------------------------------------------------------
1 | // auto-generated
2 |
3 | package data
4 |
5 | import "fyne.io/fyne/v2"
6 |
7 | var pencil = &fyne.StaticResource{
8 | StaticName: "pencil.svg",
9 | StaticContent: []byte{
10 | 60, 115, 118, 103, 32, 120, 109, 108, 110, 115, 61, 34, 104, 116, 116, 112, 58, 47, 47, 119, 119, 119, 46, 119, 51, 46, 111, 114, 103, 47, 50, 48, 48, 48, 47, 115, 118, 103, 34, 32, 104, 101, 105, 103, 104, 116, 61, 34, 50, 52, 34, 32, 118, 105, 101, 119, 66, 111, 120, 61, 34, 48, 32, 48, 32, 50, 52, 32, 50, 52, 34, 32, 119, 105, 100, 116, 104, 61, 34, 50, 52, 34, 62, 60, 112, 97, 116, 104, 32, 100, 61, 34, 77, 51, 32, 49, 55, 46, 50, 53, 86, 50, 49, 104, 51, 46, 55, 53, 76, 49, 55, 46, 56, 49, 32, 57, 46, 57, 52, 108, 45, 51, 46, 55, 53, 45, 51, 46, 55, 53, 76, 51, 32, 49, 55, 46, 50, 53, 122, 77, 50, 48, 46, 55, 49, 32, 55, 46, 48, 52, 99, 46, 51, 57, 45, 46, 51, 57, 46, 51, 57, 45, 49, 46, 48, 50, 32, 48, 45, 49, 46, 52, 49, 108, 45, 50, 46, 51, 52, 45, 50, 46, 51, 52, 99, 45, 46, 51, 57, 45, 46, 51, 57, 45, 49, 46, 48, 50, 45, 46, 51, 57, 45, 49, 46, 52, 49, 32, 48, 108, 45, 49, 46, 56, 51, 32, 49, 46, 56, 51, 32, 51, 46, 55, 53, 32, 51, 46, 55, 53, 32, 49, 46, 56, 51, 45, 49, 46, 56, 51, 122, 34, 47, 62, 60, 112, 97, 116, 104, 32, 100, 61, 34, 77, 48, 32, 48, 104, 50, 52, 118, 50, 52, 72, 48, 122, 34, 32, 102, 105, 108, 108, 61, 34, 110, 111, 110, 101, 34, 47, 62, 60, 47, 115, 118, 103, 62}}
11 |
12 | var dropper = &fyne.StaticResource{
13 | StaticName: "dropper.svg",
14 | StaticContent: []byte{
15 | 60, 115, 118, 103, 32, 120, 109, 108, 110, 115, 61, 34, 104, 116, 116, 112, 58, 47, 47, 119, 119, 119, 46, 119, 51, 46, 111, 114, 103, 47, 50, 48, 48, 48, 47, 115, 118, 103, 34, 32, 104, 101, 105, 103, 104, 116, 61, 34, 50, 52, 34, 32, 118, 105, 101, 119, 66, 111, 120, 61, 34, 48, 32, 48, 32, 50, 52, 32, 50, 52, 34, 32, 119, 105, 100, 116, 104, 61, 34, 50, 52, 34, 62, 60, 112, 97, 116, 104, 32, 100, 61, 34, 77, 48, 32, 48, 104, 50, 52, 118, 50, 52, 72, 48, 122, 34, 32, 102, 105, 108, 108, 61, 34, 110, 111, 110, 101, 34, 47, 62, 60, 112, 97, 116, 104, 32, 100, 61, 34, 77, 50, 48, 46, 55, 49, 32, 53, 46, 54, 51, 108, 45, 50, 46, 51, 52, 45, 50, 46, 51, 52, 99, 45, 46, 51, 57, 45, 46, 51, 57, 45, 49, 46, 48, 50, 45, 46, 51, 57, 45, 49, 46, 52, 49, 32, 48, 108, 45, 51, 46, 49, 50, 32, 51, 46, 49, 50, 45, 49, 46, 57, 51, 45, 49, 46, 57, 49, 45, 49, 46, 52, 49, 32, 49, 46, 52, 49, 32, 49, 46, 52, 50, 32, 49, 46, 52, 50, 76, 51, 32, 49, 54, 46, 50, 53, 86, 50, 49, 104, 52, 46, 55, 53, 108, 56, 46, 57, 50, 45, 56, 46, 57, 50, 32, 49, 46, 52, 50, 32, 49, 46, 52, 50, 32, 49, 46, 52, 49, 45, 49, 46, 52, 49, 45, 49, 46, 57, 50, 45, 49, 46, 57, 50, 32, 51, 46, 49, 50, 45, 51, 46, 49, 50, 99, 46, 52, 45, 46, 52, 46, 52, 45, 49, 46, 48, 51, 46, 48, 49, 45, 49, 46, 52, 50, 122, 77, 54, 46, 57, 50, 32, 49, 57, 76, 53, 32, 49, 55, 46, 48, 56, 108, 56, 46, 48, 54, 45, 56, 46, 48, 54, 32, 49, 46, 57, 50, 32, 49, 46, 57, 50, 76, 54, 46, 57, 50, 32, 49, 57, 122, 34, 47, 62, 60, 47, 115, 118, 103, 62}}
16 |
--------------------------------------------------------------------------------
/internal/ui/raster.go:
--------------------------------------------------------------------------------
1 | package ui
2 |
3 | import (
4 | "image/color"
5 |
6 | "fyne.io/fyne/v2"
7 | "fyne.io/fyne/v2/canvas"
8 | "fyne.io/fyne/v2/theme"
9 | "fyne.io/fyne/v2/widget"
10 | )
11 |
12 | type interactiveRaster struct {
13 | widget.BaseWidget
14 |
15 | edit *editor
16 | min fyne.Size
17 | img *canvas.Raster
18 | }
19 |
20 | func (r *interactiveRaster) SetMinSize(size fyne.Size) {
21 | pixWidth, _ := r.locationForPosition(fyne.NewPos(size.Width, size.Height))
22 | scale := float32(1.0)
23 | c := fyne.CurrentApp().Driver().CanvasForObject(r.img)
24 | if c != nil {
25 | scale = c.Scale()
26 | }
27 |
28 | texScale := float32(pixWidth) / size.Width * float32(r.edit.zoom) / scale
29 | size = fyne.NewSize(size.Width/texScale, size.Height/texScale)
30 | r.min = size
31 | r.Resize(size)
32 | }
33 |
34 | func (r *interactiveRaster) MinSize() fyne.Size {
35 | return r.min
36 | }
37 |
38 | func (r *interactiveRaster) CreateRenderer() fyne.WidgetRenderer {
39 | return &rasterWidgetRender{raster: r, bg: canvas.NewRasterWithPixels(bgPattern)}
40 | }
41 |
42 | func (r *interactiveRaster) Tapped(ev *fyne.PointEvent) {
43 | if r.edit.tool == nil || r.edit.img == nil {
44 | return
45 | }
46 |
47 | x, y := r.locationForPosition(ev.Position)
48 | if x >= r.edit.img.Bounds().Dx() || y >= r.edit.img.Bounds().Dy() {
49 | return
50 | }
51 |
52 | r.edit.tool.Clicked(x, y, r.edit)
53 | }
54 |
55 | func (r *interactiveRaster) TappedSecondary(*fyne.PointEvent) {
56 | }
57 |
58 | func (r *interactiveRaster) locationForPosition(pos fyne.Position) (int, int) {
59 | c := fyne.CurrentApp().Driver().CanvasForObject(r.img)
60 | x, y := int(pos.X), int(pos.Y)
61 | if c != nil {
62 | x, y = c.PixelCoordinateForPosition(pos)
63 | }
64 |
65 | return x / r.edit.zoom, y / r.edit.zoom
66 | }
67 |
68 | func newInteractiveRaster(edit *editor) *interactiveRaster {
69 | r := &interactiveRaster{img: canvas.NewRaster(edit.draw), edit: edit}
70 | r.ExtendBaseWidget(r)
71 | return r
72 | }
73 |
74 | type rasterWidgetRender struct {
75 | raster *interactiveRaster
76 | bg *canvas.Raster
77 | }
78 |
79 | func bgPattern(x, y, _, _ int) color.Color {
80 | const boxSize = 25
81 |
82 | if (x/boxSize)%2 == (y/boxSize)%2 {
83 | return color.Gray{Y: 58}
84 | }
85 |
86 | return color.Gray{Y: 84}
87 | }
88 |
89 | func (r *rasterWidgetRender) Layout(size fyne.Size) {
90 | r.bg.Resize(size)
91 | r.raster.img.Resize(size)
92 | }
93 |
94 | func (r *rasterWidgetRender) MinSize() fyne.Size {
95 | return r.MinSize()
96 | }
97 |
98 | func (r *rasterWidgetRender) Refresh() {
99 | canvas.Refresh(r.raster)
100 | }
101 |
102 | func (r *rasterWidgetRender) BackgroundColor() color.Color {
103 | return theme.BackgroundColor()
104 | }
105 |
106 | func (r *rasterWidgetRender) Objects() []fyne.CanvasObject {
107 | return []fyne.CanvasObject{r.bg, r.raster.img}
108 | }
109 |
110 | func (r *rasterWidgetRender) Destroy() {
111 | }
112 |
--------------------------------------------------------------------------------
/internal/ui/main.go:
--------------------------------------------------------------------------------
1 | package ui
2 |
3 | import (
4 | "path/filepath"
5 |
6 | "fyne.io/fyne/v2"
7 | "fyne.io/fyne/v2/dialog"
8 | "fyne.io/fyne/v2/layout"
9 | "fyne.io/fyne/v2/storage"
10 | "fyne.io/fyne/v2/theme"
11 | "fyne.io/fyne/v2/widget"
12 | )
13 |
14 | func (e *editor) fileOpen() {
15 | open := dialog.NewFileOpen(func(read fyne.URIReadCloser, err error) {
16 | if err != nil {
17 | dialog.ShowError(err, e.win)
18 | return
19 | }
20 | if read == nil {
21 | return
22 | }
23 |
24 | e.LoadFile(read)
25 | }, e.win)
26 |
27 | open.SetFilter(storage.NewExtensionFileFilter([]string{".png"}))
28 | open.Show()
29 | }
30 |
31 | func (e *editor) fileReset() {
32 | win := fyne.CurrentApp().Driver().AllWindows()[0]
33 | dialog.ShowConfirm("Reset content?", "Are you sure you want to re-load the image?",
34 | func(ok bool) {
35 | if !ok {
36 | return
37 | }
38 |
39 | e.Reload()
40 | }, win)
41 | }
42 |
43 | func (e *editor) fileSave() {
44 | e.Save()
45 | }
46 |
47 | func (e *editor) fileSaveAs() {
48 | open := dialog.NewFileSave(func(write fyne.URIWriteCloser, err error) {
49 | if err != nil {
50 | dialog.ShowError(err, e.win)
51 | return
52 | }
53 | if write == nil {
54 | return
55 | }
56 |
57 | e.SaveAs(write)
58 | }, e.win)
59 |
60 | open.SetFilter(storage.NewExtensionFileFilter([]string{".png"}))
61 | open.Show()
62 | }
63 |
64 | func buildToolbar(e *editor) fyne.CanvasObject {
65 | return widget.NewToolbar(
66 | &widget.ToolbarAction{Icon: theme.FolderOpenIcon(), OnActivated: e.fileOpen},
67 | &widget.ToolbarAction{Icon: theme.CancelIcon(), OnActivated: e.fileReset},
68 | &widget.ToolbarAction{Icon: theme.DocumentSaveIcon(), OnActivated: e.fileSave},
69 | )
70 | }
71 |
72 | func (e *editor) buildMainMenu() *fyne.MainMenu {
73 | recents := fyne.NewMenuItem("Open Recent", nil)
74 | recents.ChildMenu = e.loadRecentMenu()
75 |
76 | file := fyne.NewMenu("File",
77 | fyne.NewMenuItem("Open ...", e.fileOpen),
78 | recents,
79 | fyne.NewMenuItemSeparator(),
80 | fyne.NewMenuItem("Reset ...", e.fileReset),
81 | fyne.NewMenuItem("Save", e.fileSave),
82 | fyne.NewMenuItem("Save As ...", e.fileSaveAs),
83 | )
84 |
85 | return fyne.NewMainMenu(file)
86 | }
87 |
88 | func (e *editor) loadRecentMenu() *fyne.Menu {
89 | var items []*fyne.MenuItem
90 | for _, item := range e.loadRecent() {
91 | u := item
92 | label := filepath.Base(item.String())
93 |
94 | items = append(items, fyne.NewMenuItem(label, func() {
95 | read, err := storage.OpenFileFromURI(u)
96 | if err != nil {
97 | fyne.LogError("Unable to open file \""+u.String()+"\"", err)
98 | return
99 | }
100 | e.LoadFile(read)
101 | }))
102 | }
103 |
104 | if e.recentMenu == nil {
105 | e.recentMenu = fyne.NewMenu("Recent Items", items...)
106 | } else {
107 | e.recentMenu.Items = items
108 | }
109 |
110 | return e.recentMenu
111 | }
112 |
113 | // BuildUI creates the main window of our pixel edit application
114 | func (e *editor) BuildUI(w fyne.Window) {
115 | palette := newPalette(e)
116 | toolbar := buildToolbar(e)
117 | e.win = w
118 | w.SetMainMenu(e.buildMainMenu())
119 |
120 | content := fyne.NewContainerWithLayout(layout.NewBorderLayout(toolbar, e.status, palette, nil),
121 | toolbar, e.status, palette, e.buildUI())
122 | w.SetContent(content)
123 | }
124 |
--------------------------------------------------------------------------------
/internal/ui/editor_test.go:
--------------------------------------------------------------------------------
1 | package ui
2 |
3 | import (
4 | "image"
5 | "image/color"
6 | "io/ioutil"
7 | "os"
8 | "path/filepath"
9 | "testing"
10 |
11 | "fyne.io/fyne/v2"
12 | "fyne.io/fyne/v2/storage"
13 | "fyne.io/fyne/v2/test"
14 |
15 | "github.com/stretchr/testify/assert"
16 |
17 | "github.com/fyne-io/pixeledit/internal/api"
18 | )
19 |
20 | func uriForTestFile(name string) fyne.URI {
21 | path := filepath.Join(".", "testdata", name+".png")
22 |
23 | return storage.NewURI("file://" + path)
24 | }
25 |
26 | func testFile(name string) fyne.URIReadCloser {
27 | read, err := storage.OpenFileFromURI(uriForTestFile(name))
28 | if err != nil {
29 | fyne.LogError("Unable to open file \""+name+"\"", err)
30 | return nil
31 | }
32 |
33 | return read
34 | }
35 |
36 | func testFileWrite(name string) fyne.URIWriteCloser {
37 | write, err := storage.SaveFileToURI(uriForTestFile(name))
38 | if err != nil {
39 | fyne.LogError("Unable to save file \""+name+"\"", err)
40 | return nil
41 | }
42 |
43 | return write
44 | }
45 |
46 | func TestEditor_LoadFile(t *testing.T) {
47 | file := testFile("8x8")
48 | e := testEditorWithFile(file)
49 |
50 | assert.Equal(t, color.RGBA{A: 255}, e.PixelColor(0, 0))
51 | assert.Equal(t, color.RGBA{R: 255, G: 255, B: 255, A: 255}, e.PixelColor(1, 0))
52 | }
53 |
54 | func TestEditor_Reset(t *testing.T) {
55 | file := testFile("8x8")
56 | e := testEditorWithFile(file)
57 |
58 | assert.Equal(t, color.RGBA{A: 255}, e.PixelColor(0, 0))
59 |
60 | red := color.RGBA{255, 0, 0, 255}
61 | e.SetPixelColor(0, 0, red)
62 | assert.Equal(t, red, e.PixelColor(0, 0))
63 |
64 | e.Reload()
65 | assert.Equal(t, color.RGBA{A: 255}, e.PixelColor(0, 0))
66 | }
67 |
68 | func TestEditor_Save(t *testing.T) {
69 | origFile := testFile("8x8")
70 | outFile := testFileWrite("8x8-tmp")
71 | content, err := ioutil.ReadAll(origFile)
72 | if err != nil {
73 | t.Error("Failed to read test file")
74 | }
75 | _, err = outFile.Write([]byte(content))
76 | if err != nil {
77 | t.Error("Failed to write temporary file")
78 | }
79 | defer func() {
80 | err = os.Remove(outFile.URI().String()[7:])
81 | if err != nil {
82 | t.Error("Failed to remove temporary file")
83 | }
84 | }()
85 |
86 | file := testFile("8x8-tmp")
87 | e := testEditorWithFile(file)
88 |
89 | assert.Equal(t, color.RGBA{A: 255}, e.PixelColor(0, 0))
90 |
91 | red := color.RGBA{255, 0, 0, 255}
92 | e.SetPixelColor(0, 0, red)
93 | assert.Equal(t, red, e.PixelColor(0, 0))
94 |
95 | e.Save()
96 |
97 | e.LoadFile(file)
98 | assert.Equal(t, red, e.PixelColor(0, 0))
99 | }
100 |
101 | func TestEditorFGColor(t *testing.T) {
102 | e := NewEditor()
103 |
104 | assert.Equal(t, color.Black, e.FGColor())
105 | }
106 |
107 | func TestEditor_SetFGColor(t *testing.T) {
108 | e := NewEditor()
109 |
110 | fg := color.White
111 | e.SetFGColor(fg)
112 | assert.Equal(t, fg, e.FGColor())
113 | }
114 |
115 | func TestEditor_PixelColor(t *testing.T) {
116 | file := testFile("8x8")
117 | e := testEditorWithFile(file)
118 |
119 | assert.Equal(t, color.RGBA{A: 255}, e.PixelColor(0, 0))
120 | assert.Equal(t, color.RGBA{R: 0, G: 0, B: 0, A: 0}, e.PixelColor(9, 9))
121 | }
122 |
123 | func TestEditor_SetPixelColor(t *testing.T) {
124 | file := testFile("8x8")
125 | e := testEditorWithFile(file)
126 |
127 | assert.Equal(t, color.RGBA{A: 255}, e.PixelColor(0, 0))
128 | col := color.RGBA{R: 255, G: 255, B: 0, A: 128}
129 | e.SetPixelColor(1, 1, col)
130 | assert.Equal(t, col, e.PixelColor(1, 1))
131 | }
132 |
133 | func TestEditor_fixEncoding(t *testing.T) {
134 | size := 4
135 | nonRGBA := image.NewCMYK(image.Rect(0, 0, size, size))
136 |
137 | fixed := fixEncoding(nonRGBA)
138 | assert.Equal(t, image.Rect(0, 0, size, size), fixed.Bounds())
139 | }
140 |
141 | func TestEditor_isSupported(t *testing.T) {
142 | e := NewEditor().(*editor)
143 |
144 | assert.True(t, e.isSupported("test.png"))
145 | assert.False(t, e.isPNG("test.jpg"))
146 | }
147 |
148 | func TestEditor_isPNG(t *testing.T) {
149 | e := NewEditor().(*editor)
150 |
151 | assert.True(t, e.isPNG("test.png"))
152 | assert.True(t, e.isPNG("BIG.PNG"))
153 | assert.False(t, e.isPNG("wrong.ping"))
154 | }
155 |
156 | func testEditorWithFile(path fyne.URIReadCloser) api.Editor {
157 | e := NewEditor()
158 | e.(*editor).win = test.NewWindow(nil)
159 | e.LoadFile(path)
160 |
161 | return e
162 | }
163 |
--------------------------------------------------------------------------------
/internal/ui/editor.go:
--------------------------------------------------------------------------------
1 | package ui
2 |
3 | import (
4 | "fmt"
5 | "image"
6 | "image/color"
7 | "image/png"
8 | "path/filepath"
9 | "strings"
10 |
11 | "golang.org/x/image/draw"
12 |
13 | "fyne.io/fyne/v2"
14 | "fyne.io/fyne/v2/canvas"
15 | "fyne.io/fyne/v2/container"
16 | "fyne.io/fyne/v2/storage"
17 | "fyne.io/fyne/v2/widget"
18 |
19 | "github.com/fyne-io/pixeledit/internal/api"
20 | )
21 |
22 | type editor struct {
23 | drawSurface *interactiveRaster
24 | status *widget.Label
25 | cache *image.RGBA
26 | cacheWidth, cacheHeight int
27 | fgPreview *canvas.Rectangle
28 |
29 | uri string
30 | img *image.RGBA
31 | zoom int
32 | fg color.Color
33 | tool api.Tool
34 |
35 | win fyne.Window
36 | recentMenu *fyne.Menu
37 | }
38 |
39 | func (e *editor) PixelColor(x, y int) color.Color {
40 | return e.img.At(x, y)
41 | }
42 |
43 | func colorToBytes(col color.Color) []uint8 {
44 | r, g, b, a := col.RGBA()
45 | return []uint8{uint8(r >> 8), uint8(g >> 8), uint8(b >> 8), uint8(a >> 8)}
46 | }
47 |
48 | func (e *editor) SetPixelColor(x, y int, col color.Color) {
49 | i := (y*e.img.Bounds().Dx() + x) * 4
50 | rgba := colorToBytes(col)
51 | e.img.Pix[i] = rgba[0]
52 | e.img.Pix[i+1] = rgba[1]
53 | e.img.Pix[i+2] = rgba[2]
54 | e.img.Pix[i+3] = rgba[3]
55 |
56 | e.renderCache()
57 | }
58 |
59 | func (e *editor) FGColor() color.Color {
60 | return e.fg
61 | }
62 |
63 | func (e *editor) SetFGColor(col color.Color) {
64 | e.fg = col
65 | e.fgPreview.FillColor = col
66 | e.fgPreview.Refresh()
67 | }
68 |
69 | func (e *editor) buildUI() fyne.CanvasObject {
70 | return container.NewScroll(e.drawSurface)
71 | }
72 |
73 | func (e *editor) setZoom(zoom int) {
74 | e.zoom = zoom
75 | e.updateSizes()
76 | e.drawSurface.Refresh()
77 | }
78 |
79 | func (e *editor) setTool(tool api.Tool) {
80 | e.tool = tool
81 | }
82 |
83 | func (e *editor) draw(w, h int) image.Image {
84 | if e.cacheWidth == 0 || e.cacheHeight == 0 {
85 | return image.NewRGBA(image.Rect(0, 0, w, h))
86 | }
87 |
88 | if w > e.cacheWidth || h > e.cacheHeight {
89 | bigger := image.NewRGBA(image.Rect(0, 0, w, h))
90 | draw.Draw(bigger, e.cache.Bounds(), e.cache, image.Point{}, draw.Over)
91 | return bigger
92 | }
93 |
94 | return e.cache
95 | }
96 |
97 | func (e *editor) updateSizes() {
98 | if e.img == nil {
99 | return
100 | }
101 | e.cacheWidth = e.img.Bounds().Dx() * e.zoom
102 | e.cacheHeight = e.img.Bounds().Dy() * e.zoom
103 |
104 | c := fyne.CurrentApp().Driver().CanvasForObject(e.status)
105 | scale := float32(1.0)
106 | if c != nil {
107 | scale = c.Scale()
108 | }
109 | e.drawSurface.SetMinSize(fyne.NewSize(
110 | float32(e.cacheWidth)/scale,
111 | float32(e.cacheHeight)/scale))
112 |
113 | e.renderCache()
114 | }
115 |
116 | func (e *editor) pixAt(x, y int) []uint8 {
117 | ix := x / e.zoom
118 | iy := y / e.zoom
119 |
120 | if ix >= e.img.Bounds().Dx() || iy >= e.img.Bounds().Dy() {
121 | return []uint8{0, 0, 0, 0}
122 | }
123 |
124 | return colorToBytes(e.img.At(ix, iy))
125 | }
126 |
127 | func (e *editor) renderCache() {
128 | e.cache = image.NewRGBA(image.Rect(0, 0, e.cacheWidth, e.cacheHeight))
129 | for y := 0; y < e.cacheHeight; y++ {
130 | for x := 0; x < e.cacheWidth; x++ {
131 | i := (y*e.cacheWidth + x) * 4
132 | col := e.pixAt(x, y)
133 | e.cache.Pix[i] = col[0]
134 | e.cache.Pix[i+1] = col[1]
135 | e.cache.Pix[i+2] = col[2]
136 | e.cache.Pix[i+3] = col[3]
137 | }
138 | }
139 |
140 | e.drawSurface.Refresh()
141 | }
142 |
143 | func fixEncoding(img image.Image) *image.RGBA {
144 | if rgba, ok := img.(*image.RGBA); ok {
145 | return rgba
146 | }
147 |
148 | newImg := image.NewRGBA(img.Bounds())
149 | draw.Draw(newImg, newImg.Bounds(), img, img.Bounds().Min, draw.Over)
150 | return newImg
151 | }
152 |
153 | func (e *editor) LoadFile(read fyne.URIReadCloser) {
154 | defer read.Close()
155 | img, _, err := image.Decode(read)
156 | if err != nil {
157 | fyne.LogError("Unable to decode image", err)
158 | e.status.SetText(err.Error())
159 | return
160 | }
161 |
162 | e.addRecent(read.URI())
163 | e.uri = read.URI().String()
164 | e.img = fixEncoding(img)
165 | e.status.SetText(fmt.Sprintf("File: %s | Width: %d | Height: %d",
166 | filepath.Base(read.URI().String()), e.img.Bounds().Dx(), e.img.Bounds().Dy()))
167 | e.updateSizes()
168 | }
169 |
170 | func (e *editor) Reload() {
171 | if e.uri == "" {
172 | return
173 | }
174 |
175 | u, _ := storage.ParseURI(e.uri)
176 | read, err := storage.Reader(u)
177 | if err != nil {
178 | fyne.LogError("Unable to open file \""+e.uri+"\"", err)
179 | return
180 | }
181 | e.LoadFile(read)
182 | }
183 |
184 | func (e *editor) Save() {
185 | if e.uri == "" {
186 | return
187 | }
188 |
189 | uri, _ := storage.ParseURI(e.uri)
190 | if !e.isSupported(uri.Extension()) {
191 | fyne.LogError("Save only supports PNG", nil)
192 | return
193 | }
194 | write, err := storage.Writer(uri)
195 | if err != nil {
196 | fyne.LogError("Error opening file to replace", err)
197 | return
198 | }
199 |
200 | e.saveToWriter(write)
201 | }
202 |
203 | func (e *editor) saveToWriter(write fyne.URIWriteCloser) {
204 | defer write.Close()
205 | if e.isPNG(write.URI().Extension()) {
206 | err := png.Encode(write, e.img)
207 |
208 | if err != nil {
209 | fyne.LogError("Could not encode image", err)
210 | }
211 | }
212 | }
213 |
214 | func (e *editor) SaveAs(writer fyne.URIWriteCloser) {
215 | e.saveToWriter(writer)
216 | }
217 |
218 | func (e *editor) isSupported(path string) bool {
219 | return e.isPNG(path)
220 | }
221 |
222 | func (e *editor) isPNG(path string) bool {
223 | return strings.LastIndex(strings.ToLower(path), "png") == len(path)-3
224 | }
225 |
226 | // NewEditor creates a new pixel editor that is ready to have a file loaded
227 | func NewEditor() api.Editor {
228 | fgCol := color.Black
229 | preview := canvas.NewRectangle(fgCol)
230 | preview.SetMinSize(fyne.NewSize(1, 32))
231 | edit := &editor{zoom: 1, fg: fgCol, fgPreview: preview, status: newStatusBar()}
232 | edit.drawSurface = newInteractiveRaster(edit)
233 |
234 | return edit
235 | }
236 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | fyne.io/fyne/v2 v2.7.0 h1:GvZSpE3X0liU/fqstInVvRsaboIVpIWQ4/sfjDGIGGQ=
2 | fyne.io/fyne/v2 v2.7.0/go.mod h1:xClVlrhxl7D+LT+BWYmcrW4Nf+dJTvkhnPgji7spAwE=
3 | fyne.io/systray v1.11.1-0.20250603113521-ca66a66d8b58 h1:eA5/u2XRd8OUkoMqEv3IBlFYSruNlXD8bRHDiqm0VNI=
4 | fyne.io/systray v1.11.1-0.20250603113521-ca66a66d8b58/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
5 | github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
6 | github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
7 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
8 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
9 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
10 | github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g=
11 | github.com/fredbi/uri v1.1.1 h1:xZHJC08GZNIUhbP5ImTHnt5Ya0T8FI2VAwI/37kh2Ko=
12 | github.com/fredbi/uri v1.1.1/go.mod h1:4+DZQ5zBjEwQCDmXW5JdIjz0PUA+yJbvtBv+u+adr5o=
13 | github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
14 | github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
15 | github.com/fyne-io/gl-js v0.2.0 h1:+EXMLVEa18EfkXBVKhifYB6OGs3HwKO3lUElA0LlAjs=
16 | github.com/fyne-io/gl-js v0.2.0/go.mod h1:ZcepK8vmOYLu96JoxbCKJy2ybr+g1pTnaBDdl7c3ajI=
17 | github.com/fyne-io/glfw-js v0.3.0 h1:d8k2+Y7l+zy2pc7wlGRyPfTgZoqDf3AI4G+2zOWhWUk=
18 | github.com/fyne-io/glfw-js v0.3.0/go.mod h1:Ri6te7rdZtBgBpxLW19uBpp3Dl6K9K/bRaYdJ22G8Jk=
19 | github.com/fyne-io/image v0.1.1 h1:WH0z4H7qfvNUw5l4p3bC1q70sa5+YWVt6HCj7y4VNyA=
20 | github.com/fyne-io/image v0.1.1/go.mod h1:xrfYBh6yspc+KjkgdZU/ifUC9sPA5Iv7WYUBzQKK7JM=
21 | github.com/fyne-io/oksvg v0.2.0 h1:mxcGU2dx6nwjJsSA9PCYZDuoAcsZ/OuJlvg/Q9Njfo8=
22 | github.com/fyne-io/oksvg v0.2.0/go.mod h1:dJ9oEkPiWhnTFNCmRgEze+YNprJF7YRbpjgpWS4kzoI=
23 | github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 h1:5BVwOaUSBTlVZowGO6VZGw2H/zl9nrd3eCZfYV+NfQA=
24 | github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw=
25 | github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a h1:vxnBhFDDT+xzxf1jTJKMKZw3H0swfWk9RpWbBbDK5+0=
26 | github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
27 | github.com/go-text/render v0.2.0 h1:LBYoTmp5jYiJ4NPqDc2pz17MLmA3wHw1dZSVGcOdeAc=
28 | github.com/go-text/render v0.2.0/go.mod h1:CkiqfukRGKJA5vZZISkjSYrcdtgKQWRa2HIzvwNN5SU=
29 | github.com/go-text/typesetting v0.2.1 h1:x0jMOGyO3d1qFAPI0j4GSsh7M0Q3Ypjzr4+CEVg82V8=
30 | github.com/go-text/typesetting v0.2.1/go.mod h1:mTOxEwasOFpAMBjEQDhdWRckoLLeI/+qrQeBCTGEt6M=
31 | github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066 h1:qCuYC+94v2xrb1PoS4NIDe7DGYtLnU2wWiQe9a1B1c0=
32 | github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
33 | github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
34 | github.com/google/pprof v0.0.0-20211214055906-6f57359322fd h1:1FjCyPC+syAzJ5/2S8fqdZK1R22vvA0J7JZKcuOIQ7Y=
35 | github.com/hack-pad/go-indexeddb v0.3.2 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQbSBI91A=
36 | github.com/hack-pad/go-indexeddb v0.3.2/go.mod h1:QvfTevpDVlkfomY498LhstjwbPW6QC4VC/lxYb0Kom0=
37 | github.com/hack-pad/safejs v0.1.0 h1:qPS6vjreAqh2amUqj4WNG1zIw7qlRQJ9K10eDKMCnE8=
38 | github.com/hack-pad/safejs v0.1.0/go.mod h1:HdS+bKF1NrE72VoXZeWzxFOVQVUSqZJAG0xNCnb+Tio=
39 | github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade h1:FmusiCI1wHw+XQbvL9M+1r/C3SPqKrmBaIOYwVfQoDE=
40 | github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o=
41 | github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M=
42 | github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw=
43 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
44 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
45 | github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
46 | github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
47 | github.com/nicksnyder/go-i18n/v2 v2.5.1 h1:IxtPxYsR9Gp60cGXjfuR/llTqV8aYMsC472zD0D1vHk=
48 | github.com/nicksnyder/go-i18n/v2 v2.5.1/go.mod h1:DrhgsSDZxoAfvVrBVLXoxZn/pN5TXqaDbq7ju94viiQ=
49 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
50 | github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA=
51 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
52 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
53 | github.com/rymdport/portal v0.4.2 h1:7jKRSemwlTyVHHrTGgQg7gmNPJs88xkbKcIL3NlcmSU=
54 | github.com/rymdport/portal v0.4.2/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4=
55 | github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
56 | github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
57 | github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
58 | github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE=
59 | github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
60 | github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
61 | github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
62 | github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
63 | golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
64 | golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
65 | golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
66 | golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
67 | golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
68 | golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
69 | golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
70 | golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
71 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
72 | gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
73 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
74 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
75 |
--------------------------------------------------------------------------------