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 {
+6 -7
View File
@@ -81,11 +81,11 @@ func installIconFor(o iconOptions) (path, source string, err error) {
}
for _, attempt := range attempts {
data, ext, err := attempt.load()
data, _, err := attempt.load()
if err != nil || len(data) == 0 {
continue
}
path, err := icon.SaveBytes(data, ext, o.Slug)
path, err := icon.Save(data, o.Slug)
if err != nil {
continue
}
@@ -102,22 +102,21 @@ var errSkipIcon = errors.New("icon source not available")
// RefreshIcon mengunduh ulang ikon shortcut dari sumber online-nya (sync manual).
func RefreshIcon(sc store.Shortcut) (string, error) {
var data []byte
var ext string
var err error
switch {
case sc.IconSource == "":
return "", errors.New("this icon has no online source")
case strings.HasPrefix(sc.IconSource, iconSourceFavicon):
data, ext, err = icon.Fetch(strings.TrimPrefix(sc.IconSource, iconSourceFavicon))
data, _, err = icon.Fetch(strings.TrimPrefix(sc.IconSource, iconSourceFavicon))
case strings.HasPrefix(sc.IconSource, iconSourceSearch):
result, ok := icon.Find(strings.TrimPrefix(sc.IconSource, iconSourceSearch))
if !ok {
return "", errors.New("no matching icon found online anymore")
}
data, ext, err = icon.FromURL(result.URL)
data, _, err = icon.FromURL(result.URL)
case strings.HasPrefix(sc.IconSource, "http"):
data, ext, err = icon.FromURL(sc.IconSource)
data, _, err = icon.FromURL(sc.IconSource)
default:
return "", errors.New("this icon comes from a local file, not the internet")
}
@@ -125,7 +124,7 @@ func RefreshIcon(sc store.Shortcut) (string, error) {
return "", err
}
path, err := icon.SaveBytes(data, ext, sc.Slug)
path, err := icon.Save(data, sc.Slug)
if err != nil {
return "", err
}
+2 -2
View File
@@ -22,7 +22,7 @@ var categories = []string{
}
var (
imageFilter = storage.NewExtensionFileFilter([]string{".png", ".svg", ".jpg", ".jpeg", ".xpm", ".ico"})
imageFilter = storage.NewExtensionFileFilter([]string{".png", ".svg", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".ico", ".xpm"})
stripExts = []string{".AppImage", ".appimage", ".sh", ".bin", ".run", ".desktop"}
)
@@ -113,7 +113,7 @@ func (ui *app) showExecutableDialog() {
install),
))
d = dialog.NewCustom(T("new_exec_title"), "", content, ui.win)
d = dialog.NewCustomWithoutButtons(T("new_exec_title"), content, ui.win)
showDialog(d, ui.win, 560, 520)
}
+3 -6
View File
@@ -97,7 +97,7 @@ func (ui *app) showIconSearch(query string, onPick func(icon.Result, []byte)) {
results,
)
d = dialog.NewCustom(T("icon_search_title"), "", content, ui.win)
d = dialog.NewCustomWithoutButtons(T("icon_search_title"), content, ui.win)
showDialog(d, ui.win, 560, 470) // sekaligus menampilkan dialog
search()
}
@@ -129,12 +129,9 @@ func (ui *app) iconChoice(result icon.Result, onPick func(icon.Result, []byte),
if err != nil {
return
}
thumb, err := icon.Rasterize(downloaded, 64)
thumb, err := icon.ToPNG(downloaded)
if err != nil {
if icon.Ext(downloaded) == ".svg" {
return
}
thumb = downloaded // PNG/JPEG bisa langsung dipakai
return // placeholder tetap tampil
}
mu.Lock()
+72
View File
@@ -0,0 +1,72 @@
package ui
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"fyne.io/fyne/v2/test"
"lazyrun/internal/icon"
)
// TestNoEmptyDismissButton memastikan modal tidak memakai dialog.NewCustom,
// yang selalu menambah tombol dismiss walau labelnya kosong (tombol hantu
// di bagian bawah modal). Kita punya baris tombol sendiri, jadi harus memakai
// NewCustomWithoutButtons.
func TestNoEmptyDismissButton(t *testing.T) {
files, err := filepath.Glob("*.go")
if err != nil {
t.Fatal(err)
}
for _, file := range files {
if strings.HasSuffix(file, "_test.go") {
continue
}
data, err := os.ReadFile(file)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(data), "dialog.NewCustom(") {
t.Errorf("%s memakai dialog.NewCustom (menambah tombol kosong); gunakan NewCustomWithoutButtons", file)
}
}
}
// TestDialogsOpenWithoutPanic membuka semua modal di test driver.
func TestDialogsOpenWithoutPanic(t *testing.T) {
a := test.NewApp()
defer a.Quit()
// pencarian ikon diarahkan ke port mati: tes tidak boleh menyentuh internet
// dan tidak boleh mengubah UI dari goroutine latar.
icon.SetSearchEndpoint("http://127.0.0.1:1")
defer icon.SetSearchEndpoint("https://api.iconify.design")
ui, err := build(a, "test")
if err != nil {
t.Fatal(err)
}
ui.win.Show()
ui.showExecutableDialog()
if top := ui.win.Canvas().Overlays().Top(); top == nil {
t.Fatal("modal executable tidak muncul")
}
ui.showWebAppDialog()
if len(ui.browsers) > 0 {
if top := ui.win.Canvas().Overlays().Top(); top == nil {
t.Fatal("modal web app tidak muncul")
}
}
ui.showIconSearch("whatsapp", func(icon.Result, []byte) {})
if top := ui.win.Canvas().Overlays().Top(); top == nil {
t.Fatal("modal pencarian ikon tidak muncul")
}
// tunggu goroutine pencarian selesai: test driver menjalankan fyne.Do inline,
// jadi sisa goroutine bisa bertabrakan dengan tes berikutnya kalau dibiarkan.
time.Sleep(300 * time.Millisecond)
}
+1 -1
View File
@@ -118,7 +118,7 @@ func (ui *app) showWebAppDialog() {
install),
))
d = dialog.NewCustom(T("new_web_title"), "", content, ui.win)
d = dialog.NewCustomWithoutButtons(T("new_web_title"), content, ui.win)
showDialog(d, ui.win, 560, 520)
}