├── 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 |

2 | Code Status 3 | Build Status 4 | Coverage Status 5 | Join us on Slack 6 |

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 | --------------------------------------------------------------------------------