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:
1 parent
6e6f9afea9
commit
cad41caaee
10 files changed
+269
-73
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user