Hapus tombol kosong bawaan Fyne di modal + semua format ikon dikonversi ke PNG

- dialog.NewCustom selalu menambah tombol dismiss walau labelnya kosong;
  modal kita pakai baris tombol sendiri, jadi pindah ke NewCustomWithoutButtons
  (ditambah tes yang menolak pemakaian dialog.NewCustom lagi)
- Ikon: SVG/webp/gif/bmp/ico/jpeg semuanya dikonversi ke PNG saat dipasang
  (desktop environment tidak konsisten membaca format lain), gambar besar
  diperkecil ke maksimal 512px
- Filter file picker ikon menambah webp/gif/bmp
- Perbaiki data race pada endpoint pencarian ikon (iconifyBase) yang ditemukan
  oleh go test -race; tes UI menunggu goroutine pencarian selesai
- go test -race -count=1 ./... hijau untuk semua paket
This commit is contained in:
oxpoe committed 2026-10-13 08:00:12 +07:00
1 parent 6e6f9afea9
commit cad41caaee
10 files changed
+269 -73

No files matched your search

+90
View File
@@ -0,0 +1,90 @@
package icon
import (
"bytes"
"image"
"image/color"
"image/gif"
"image/jpeg"
"image/png"
"testing"
"golang.org/x/image/bmp"
)
func solidImg(size int) image.Image {
img := image.NewNRGBA(image.Rect(0, 0, size, size))
for x := 0; x < size; x++ {
for y := 0; y < size; y++ {
img.Set(x, y, color.NRGBA{R: 0x12, G: 0x9f, B: 0x55, A: 0xFF})
}
}
return img
}
func encode(t *testing.T, fn func(*bytes.Buffer) error) []byte {
t.Helper()
var buf bytes.Buffer
if err := fn(&buf); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
func TestToPNGConvertsFormats(t *testing.T) {
src := solidImg(64)
cases := map[string][]byte{
"png": encode(t, func(b *bytes.Buffer) error { return png.Encode(b, src) }),
"gif": encode(t, func(b *bytes.Buffer) error { return gif.Encode(b, src, nil) }),
"bmp": encode(t, func(b *bytes.Buffer) error { return bmp.Encode(b, src) }),
"jpg": encode(t, func(b *bytes.Buffer) error { return jpeg.Encode(b, src, nil) }),
"svg": []byte(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8"><rect width="8" height="8"/></svg>`),
"ico": icoWithPNG(encode(t, func(b *bytes.Buffer) error { return png.Encode(b, solidImg(16)) })),
}
for name, data := range cases {
t.Run(name, func(t *testing.T) {
out, err := ToPNG(data)
if err != nil {
t.Fatalf("ToPNG(%s) gagal: %v", name, err)
}
if !bytes.HasPrefix(out, pngMagic) {
t.Fatalf("hasil %s bukan PNG", name)
}
if _, err := png.Decode(bytes.NewReader(out)); err != nil {
t.Fatalf("hasil %s tidak bisa di-decode: %v", name, err)
}
})
}
}
func TestToPNGRejectsUnknown(t *testing.T) {
if _, err := ToPNG([]byte("halo dunia")); err == nil {
t.Error("data bukan gambar harus error")
}
}
func TestToPNGShrinksHugeImages(t *testing.T) {
out, err := ToPNG(encode(t, func(b *bytes.Buffer) error { return png.Encode(b, solidImg(1024)) }))
if err != nil {
t.Fatal(err)
}
if len(out) >= 1024*1024 {
t.Errorf("gambar besar tidak diperkecil: %d byte", len(out))
}
}
func TestSaveAlwaysWritesPNG(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("XDG_DATA_HOME", "")
// ikon webp tak disupport ditest lewat gif: hasil harus selalu .png
path, err := Save(encode(t, func(b *bytes.Buffer) error { return gif.Encode(b, solidImg(32), nil) }), "contoh")
if err != nil {
t.Fatal(err)
}
if got := path[len(path)-4:]; got != ".png" {
t.Errorf("path ikon = %s", path)
}
}
+11
View File
@@ -0,0 +1,11 @@
package icon
// decoder gambar tambahan: webp & bmp tidak ada di stdlib,
// jpeg/gif perlu di-import supaya terdaftar di image.Decode.
import (
_ "golang.org/x/image/bmp"
_ "golang.org/x/image/webp"
_ "image/gif"
_ "image/jpeg"
)
+62 -52
View File
@@ -2,38 +2,30 @@
package icon
import (
"bytes"
"errors"
"image"
"image/png"
"os"
"path/filepath"
"strings"
xdraw "golang.org/x/image/draw"
"lazyrun/assets"
"lazyrun/internal/xdg"
)
var exts = []string{".png", ".svg", ".jpg", ".jpeg", ".xpm"}
var nearbyNames = []string{"icon", "logo"}
var exts = []string{".png", ".svg", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".ico", ".xpm"}
// Save memasang ikon untuk shortcut dan mengembalikan path absolutnya.
// Urutan pilihan: ikon dari user, ikon di folder binary, lalu ikon default LazyRun.
func Save(userIcon, nearbyDir, nearbyBase, slug string) (string, error) {
if userIcon != "" {
return fromFile(userIcon, slug)
}
for _, name := range append([]string{nearbyBase}, nearbyNames...) {
if name == "" {
continue
}
if p := findNearby(nearbyDir, name); p != "" {
return fromFile(p, slug)
}
}
return Default(slug)
}
var pngMagic = []byte("\x89PNG\r\n\x1a\n")
// maxSide adalah sisi terpanjang ikon yang disimpan.
const maxSide = 512
// Default memasang ikon bawaan LazyRun.
func Default(slug string) (string, error) {
return SaveBytes(assets.Icon, ".png", slug)
return Save(assets.Icon, slug)
}
// Nearby mencari file ikon bernama base.* di folder dir (dipakai untuk auto-deteksi).
@@ -55,48 +47,66 @@ func findNearby(dir, base string) string {
return ""
}
func fromFile(src, slug string) (string, error) {
data, err := os.ReadFile(src)
// Save menulis ikon ke direktori ikon XDG sebagai PNG.
//
// Semua format (SVG, webp, gif, bmp, ico, jpeg) dikonversi dulu ke PNG karena
// desktop environment tidak konsisten membacanya, dan sebagian SVG dari internet
// punya fitur yang bikin proses menggambar berhenti di UI.
func Save(data []byte, slug string) (string, error) {
converted, err := ToPNG(data)
if err != nil {
return "", err
}
ext := strings.ToLower(filepath.Ext(src))
// ICO tidak dipakai desktop theme; pakai PNG di dalamnya kalau ada.
if ext == ".ico" {
png := largestPNGFromICO(data)
if png == nil {
return "", errors.New("this .ico has no PNG inside it, pick another icon")
}
return SaveBytes(png, ".png", slug)
}
return SaveBytes(data, ext, slug)
}
// SaveBytes menulis data ikon ke direktori ikon XDG.
// SVG diraster dulu ke PNG: sebagian ikon internet memakai fitur SVG yang membuat
// proses menggambar berhenti (panic) atau tidak tampil di UI/desktop.
func SaveBytes(data []byte, ext, slug string) (string, error) {
if ext == "" {
ext = ".png"
}
if ext == ".svg" {
raster, err := Rasterize(data, 256)
if err != nil {
return "", err
}
data, ext = raster, ".png"
}
if err := os.MkdirAll(xdg.IconsDir(), 0o755); err != nil {
return "", err
}
// shortcut: ikon disimpan apa adanya di hicolor/512x512 tanpa resize;
// desktop environment men-skalanya sendiri. Tambah resize kalau ikon kecil terlihat pecah.
dest := filepath.Join(xdg.IconsDir(), slug+ext)
return dest, os.WriteFile(dest, data, 0o644)
dest := filepath.Join(xdg.IconsDir(), slug+".png")
return dest, os.WriteFile(dest, converted, 0o644)
}
// Ext menebak ekstensi file dari isi data (dipakai untuk ikon yang sudah diunduh UI).
// Ext menebak ekstensi file dari isi data.
func Ext(data []byte) string { return sniffExt(data) }
// IconBytes adalah ikon default LazyRun.
func IconBytes() []byte { return assets.Icon }
// ToPNG mengubah data gambar apa pun yang didukung menjadi PNG.
// Data yang sudah PNG dikembalikan apa adanya.
func ToPNG(data []byte) ([]byte, error) {
if bytes.HasPrefix(data, pngMagic) {
return data, nil
}
if sniffExt(data) == ".svg" {
return Rasterize(data, 256)
}
// jpeg, gif, bmp, webp dikenali dari isi file
if img, _, err := image.Decode(bytes.NewReader(data)); err == nil {
return encodePNG(img)
}
// ico bisa berisi PNG langsung
if png := largestPNGFromICO(data); png != nil {
return png, nil
}
return nil, errors.New("format ikon tidak didukung")
}
// encodePNG menulis gambar jadi PNG, diperkecil kalau lebih besar dari maxSide.
func encodePNG(img image.Image) ([]byte, error) {
bounds := img.Bounds()
if bounds.Dx() > maxSide || bounds.Dy() > maxSide {
scale := float64(maxSide) / float64(max(bounds.Dx(), bounds.Dy()))
size := image.Rect(0, 0, int(float64(bounds.Dx())*scale), int(float64(bounds.Dy())*scale))
scaled := image.NewNRGBA(size)
xdraw.CatmullRom.Scale(scaled, size, img, bounds, xdraw.Over, nil)
img = scaled
}
var out bytes.Buffer
if err := png.Encode(&out, img); err != nil {
return nil, err
}
return out.Bytes(), nil
}
+21 -4
View File
@@ -7,14 +7,30 @@ import (
"net/url"
"sort"
"strings"
"sync"
"time"
"unicode"
)
var iconifyBase = "https://api.iconify.design"
var (
iconifyMu sync.RWMutex
iconifyBase = "https://api.iconify.design"
)
// SetSearchEndpoint mengarahkan pencarian ikon ke endpoint lain (dipakai tes).
func SetSearchEndpoint(base string) { iconifyBase = base }
func SetSearchEndpoint(base string) {
iconifyMu.Lock()
defer iconifyMu.Unlock()
iconifyBase = base
}
// searchEndpoint dibaca setiap kali mencari, jadi harus lewat lock
// (tes bisa mengganti endpoint sementara goroutine lain mencari).
func searchEndpoint() string {
iconifyMu.RLock()
defer iconifyMu.RUnlock()
return iconifyBase
}
// Result adalah satu ikon dari pencarian online.
type Result struct {
@@ -41,7 +57,8 @@ func Search(query string, limit int) ([]Result, error) {
limit = 32
}
endpoint := fmt.Sprintf("%s/search?query=%s&limit=%d", iconifyBase, url.QueryEscape(query), limit)
base := searchEndpoint()
endpoint := fmt.Sprintf("%s/search?query=%s&limit=%d", base, url.QueryEscape(query), limit)
data, err := get(endpoint)
if err != nil {
return nil, err
@@ -63,7 +80,7 @@ func Search(query string, limit int) ([]Result, error) {
// height=256 supaya SVG-nya punya ukuran eksplisit (bukan "1em")
results = append(results, Result{
Name: name,
URL: fmt.Sprintf("%s/%s/%s.svg?height=256", iconifyBase, url.PathEscape(prefix), url.PathEscape(icon)),
URL: fmt.Sprintf("%s/%s/%s.svg?height=256", base, url.PathEscape(prefix), url.PathEscape(icon)),
})
}
if len(results) == 0 {