Tooltip hint, loading bar, edit shortcut, export/import .lazy

- Keterangan tombol saat hover: baris hint di footer window dan di dalam dialog
  (bukan popup: overlay Fyne menelan klik pertama, tombol jadi perlu 2x klik)
- Dialog proses sekarang pakai progress bar berjalan (bukan spinner diam)
- Tombol edit per shortcut: ganti binary/URL, ikon (file/online), nama, kategori,
  dan lokasi; berkas .desktop + salinan Desktop ditulis ulang, berkas lama dibuang,
  jenis aplikasi dideteksi ulang, binary lama dihapus kalau nama berubah
- Export/Import daftar shortcut ke berkas SQLite .lazy (driver murni Go modernc),
  skema + meta format versi, impor tidak menimpa shortcut yang sudah ada
- Tes: detect kind, edit (rename/ganti binary/ikon/target), db roundtrip, merge impor,
  tooltip (hint muncul/hilang + klik tetap sampai ke tombol), semua paket hijau
- README: bagian backup + hint tombol
This commit is contained in:
oxpoe committed 2026-10-13 09:12:39 +07:00
1 parent c1aed1e286
commit deaa30689c
19 files changed
+1362 -61

No files matched your search

+277
View File
@@ -0,0 +1,277 @@
package ui
import (
"fmt"
"path/filepath"
"strings"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/storage"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
"lazyrun/internal/browser"
"lazyrun/internal/db"
"lazyrun/internal/icon"
"lazyrun/internal/shortcut"
"lazyrun/internal/store"
)
var lazyFilter = storage.NewExtensionFileFilter([]string{".lazy"})
// exportShortcuts menyimpan daftar shortcut ke satu berkas SQLite (.lazy),
// mis. "db.lazy", supaya bisa dipindah ke komputer lain.
func (ui *app) exportShortcuts() {
save := dialog.NewFileSave(func(w fyne.URIWriteCloser, err error) {
if err != nil {
dialog.ShowError(err, ui.win)
return
}
if w == nil {
return
}
path := w.URI().Path()
w.Close() // berkas kosong dari dialog akan diganti basis data
if !strings.HasSuffix(strings.ToLower(path), ".lazy") {
path += ".lazy"
}
if err := db.Export(path, ui.version, ui.store.Items); err != nil {
dialog.ShowError(err, ui.win)
return
}
dialog.ShowInformation(T("export"), fmt.Sprintf(T("export_done"), len(ui.store.Items), path), ui.win)
}, ui.win)
save.SetFileName(db.DefaultName)
save.SetFilter(lazyFilter)
save.Show()
}
// importShortcuts menambahkan shortcut dari berkas .lazy tanpa menimpa yang sudah ada.
func (ui *app) importShortcuts() {
open := dialog.NewFileOpen(func(r fyne.URIReadCloser, err error) {
if err != nil {
dialog.ShowError(err, ui.win)
return
}
if r == nil {
return
}
path := r.URI().Path()
r.Close()
items, err := db.Import(path)
if err != nil {
dialog.ShowError(err, ui.win)
return
}
if len(items) == 0 {
dialog.ShowInformation(T("import"), T("import_empty"), ui.win)
return
}
added, skipped := mergeImported(ui.store, items)
if err := ui.store.Save(); err != nil {
dialog.ShowError(err, ui.win)
return
}
ui.reload()
dialog.ShowInformation(T("import"), fmt.Sprintf(T("import_done"), added, skipped), ui.win)
}, ui.win)
open.SetFilter(lazyFilter)
open.Show()
}
// showEditDialog mengubah shortcut yang sudah ada: binary/URL, ikon, nama, lokasi.
func (ui *app) showEditDialog(sc store.Shortcut) {
isWeb := sc.Type == store.TypeWebApp
var iconPath, iconURL, binPath string
var iconData []byte
nameEntry := widget.NewEntry()
nameEntry.SetText(sc.Name)
iconLabel := muted(iconLabelOf(sc))
menu := widget.NewCheck(T("check_menu"), nil)
menu.SetChecked(sc.Targets.Menu)
desktopChk := widget.NewCheck(T("check_desktop"), nil)
desktopChk.SetChecked(sc.Targets.Desktop)
browseIcon := newButton(T("browse"), theme.FolderOpenIcon(), T("tip_browse"), widget.MediumImportance, func() {
pickFile(ui.win, imageFilter, func(path string) {
iconPath, iconURL, iconData = path, "", nil
iconLabel.SetText(filepath.Base(path))
})
})
onlineIcon := newButton(T("online"), theme.SearchIcon(), T("tip_online"), widget.MediumImportance, func() {
ui.showIconSearch(nameEntry.Text, func(result icon.Result, data []byte) {
iconURL, iconPath, iconData = result.URL, "", data
iconLabel.SetText(result.Name)
})
})
// widget khusus executable
binLabel := muted(sc.BinaryPath)
browseBin := newButton(T("browse"), theme.FolderOpenIcon(), T("tip_browse"), widget.MediumImportance, func() {
pickFile(ui.win, nil, func(path string) {
binPath = path
binLabel.SetText(filepath.Base(path) + " · " + detectedKind(path))
})
})
category := widget.NewSelect(categories, nil)
category.SetSelected(categoryOf(sc))
// widget khusus web app
urlEntry := widget.NewEntry()
urlEntry.SetText(sc.URL)
browserSel := widget.NewSelect(browserNames(ui.browsers), nil)
browserSel.SetSelected(currentBrowser(ui.browsers, sc.Browser))
iconItem := widget.NewFormItem(T("field_icon"), container.NewBorder(nil, nil, nil,
container.NewHBox(onlineIcon, browseIcon), iconLabel))
items := []*widget.FormItem{}
if isWeb {
items = append(items,
widget.NewFormItem(T("field_url"), urlEntry),
widget.NewFormItem(T("field_name"), nameEntry),
iconItem,
widget.NewFormItem(T("field_browser"), browserSel),
)
} else {
items = append(items,
widget.NewFormItem(T("field_binary"), container.NewBorder(nil, nil, nil, browseBin, binLabel)),
widget.NewFormItem(T("field_name"), nameEntry),
iconItem,
widget.NewFormItem(T("field_category"), category),
)
}
var d *dialog.CustomDialog
save := newButton(T("save"), theme.DocumentSaveIcon(), T("tip_save"), widget.HighImportance, func() {
switch {
case strings.TrimSpace(nameEntry.Text) == "":
dialog.ShowInformation(T("err_title"), T("need_name"), ui.win)
return
case !menu.Checked && !desktopChk.Checked:
dialog.ShowInformation(T("err_title"), T("need_target"), ui.win)
return
}
ui.install(func() (store.Shortcut, error) {
if isWeb {
b := ui.browsers[indexOf(ui.browsers, browserSel.Selected)]
return shortcut.EditWebApp(sc, shortcut.WebOptions{
URL: urlEntry.Text,
Name: nameEntry.Text,
Browser: shortcut.BrowserArg{Name: b.Name, Exec: b.Exec, Firefox: b.Kind == browser.Firefox},
IconPath: iconPath,
IconURL: iconURL,
IconData: iconData,
Menu: menu.Checked,
Desktop: desktopChk.Checked,
})
}
path := binPath
if path == "" {
path = sc.BinaryPath
}
return shortcut.EditExecutable(sc, shortcut.ExecOptions{
BinaryPath: path,
IconPath: iconPath,
IconURL: iconURL,
IconData: iconData,
Name: nameEntry.Text,
Category: category.Selected,
Menu: menu.Checked,
Desktop: desktopChk.Checked,
})
}, func(updated store.Shortcut, err error) {
if err != nil {
dialog.ShowError(err, ui.win)
return
}
ui.store.Add(updated)
if err := ui.store.Save(); err != nil {
dialog.ShowError(err, ui.win)
return
}
d.Hide()
ui.reload()
if card, ok := ui.cards[updated.Slug]; ok {
card.flash(0)
}
dialog.ShowInformation(T("edit_title"), fmt.Sprintf(T("edit_saved"), updated.Name), ui.win)
})
})
hint := textLine(T("edit_hint"), 12, false, colorNameMuted)
content := container.NewVScroll(container.NewVBox(
closeRow(func() { d.Hide() }),
widget.NewForm(items...),
widget.NewSeparator(),
sectionTitle(T("section_location")),
menu,
desktopChk,
hint,
container.NewHBox(logoBox(newLogo(), 20), spacer(),
newButton(T("cancel"), nil, T("tip_cancel"), widget.MediumImportance, func() { d.Hide() }),
save),
))
d = dialog.NewCustomWithoutButtons(T("edit_title"), content, ui.win)
d.SetOnClosed(pushHintTarget(hint))
showDialog(d, ui.win, 560, 520)
}
// mergeImported menambahkan shortcut hasil impor tanpa menimpa yang sudah ada.
func mergeImported(target *store.Store, items []store.Shortcut) (added, skipped int) {
for _, item := range items {
item.Name = strings.TrimSpace(item.Name)
if item.Name == "" || item.Slug == "" {
skipped++
continue
}
if _, exists := target.Get(item.Slug); exists {
skipped++
continue
}
target.Add(item)
added++
}
return added, skipped
}
// iconLabelOf menampilkan ikon yang sedang dipakai shortcut.
func iconLabelOf(sc store.Shortcut) string {
if sc.IconPath == "" {
return T("icon_auto")
}
return filepath.Base(sc.IconPath)
}
// currentBrowser mengembalikan nama browser yang cocok dengan yang tersimpan.
func currentBrowser(list []browser.Browser, saved string) string {
for _, b := range list {
if strings.Join(b.Exec, " ") == saved {
return b.Name
}
}
if len(list) > 0 {
return list[0].Name
}
return ""
}
// categoryOf mengambil kategori pertama yang tersimpan.
func categoryOf(sc store.Shortcut) string {
first, _, _ := strings.Cut(strings.TrimSuffix(sc.Categories, ";"), ";")
for _, c := range categories {
if c == first {
return c
}
}
return "Utility"
}
+8 -8
View File
@@ -41,7 +41,7 @@ func (ui *app) showExecutableDialog() {
desktop := widget.NewCheck(T("check_desktop"), nil)
desktop.SetChecked(true)
browseBin := widget.NewButtonWithIcon(T("browse"), theme.FolderOpenIcon(), func() {
browseBin := newButton(T("browse"), theme.FolderOpenIcon(), T("tip_browse"), widget.MediumImportance, func() {
pickFile(ui.win, nil, func(path string) {
binPath = path
binLabel.SetText(filepath.Base(path) + " · " + detectedKind(path))
@@ -50,13 +50,13 @@ func (ui *app) showExecutableDialog() {
}
})
})
browseIcon := widget.NewButtonWithIcon(T("browse"), theme.FolderOpenIcon(), func() {
browseIcon := newButton(T("browse"), theme.FolderOpenIcon(), T("tip_browse"), widget.MediumImportance, func() {
pickFile(ui.win, imageFilter, func(path string) {
iconPath, iconURL, iconData = path, "", nil
iconLabel.SetText(filepath.Base(path))
})
})
onlineIcon := widget.NewButtonWithIcon(T("online"), theme.SearchIcon(), func() {
onlineIcon := newButton(T("online"), theme.SearchIcon(), T("tip_online"), widget.MediumImportance, func() {
ui.showIconSearch(nameEntry.Text, func(result icon.Result, data []byte) {
iconURL, iconPath, iconData = result.URL, "", data
iconLabel.SetText(result.Name)
@@ -64,7 +64,7 @@ func (ui *app) showExecutableDialog() {
})
var d *dialog.CustomDialog
install := widget.NewButtonWithIcon(T("install"), theme.DownloadIcon(), func() {
install := newButton(T("install"), theme.DownloadIcon(), T("tip_install"), widget.HighImportance, func() {
switch {
case binPath == "":
dialog.ShowInformation(T("err_title"), T("need_binary"), ui.win)
@@ -91,8 +91,6 @@ func (ui *app) showExecutableDialog() {
func(sc store.Shortcut, err error) { ui.added(sc, err, d.Hide) },
)
})
install.Importance = widget.HighImportance
form := widget.NewForm(
widget.NewFormItem(T("field_binary"), container.NewBorder(nil, nil, nil, browseBin, binLabel)),
widget.NewFormItem(T("field_icon"), container.NewBorder(nil, nil, nil,
@@ -100,6 +98,7 @@ func (ui *app) showExecutableDialog() {
widget.NewFormItem(T("field_name"), nameEntry),
widget.NewFormItem(T("field_category"), category),
)
hint := textLine(T("exec_hint"), 12, false, colorNameMuted)
content := container.NewVScroll(container.NewVBox(
closeRow(func() { d.Hide() }),
form,
@@ -107,13 +106,14 @@ func (ui *app) showExecutableDialog() {
sectionTitle(T("section_location")),
menu,
desktop,
muted(T("exec_hint")),
hint,
container.NewHBox(logoBox(newLogo(), 20), spacer(),
widget.NewButton(T("cancel"), func() { d.Hide() }),
newButton(T("cancel"), nil, T("tip_cancel"), widget.MediumImportance, func() { d.Hide() }),
install),
))
d = dialog.NewCustomWithoutButtons(T("new_exec_title"), content, ui.win)
d.SetOnClosed(pushHintTarget(hint)) // keterangan tombol muncul di baris hint dialog ini
showDialog(d, ui.win, 560, 520)
}
+7 -5
View File
@@ -87,10 +87,11 @@ func (ui *app) showIconSearch(query string, onPick func(icon.Result, []byte)) {
entry.OnSubmitted = func(string) { search() }
// status di barisnya sendiri: supaya tidak bertabrakan dengan tombol
// di layar sempit atau ber-penskalaan besar.
bottom := container.NewVBox(status, container.NewHBox(
hint := textLine(T("icon_search_hint"), 12, false, colorNameMuted)
bottom := container.NewVBox(hint, status, container.NewHBox(
spacer(),
widget.NewButtonWithIcon(T("back"), theme.NavigateBackIcon(), func() { d.Hide() }),
widget.NewButtonWithIcon(T("search"), theme.SearchIcon(), search),
newButton(T("back"), theme.NavigateBackIcon(), T("tip_back"), widget.MediumImportance, func() { d.Hide() }),
newButton(T("search"), theme.SearchIcon(), T("tip_search"), widget.HighImportance, search),
))
content := container.NewBorder(
container.NewVBox(closeRow(func() { d.Hide() }), entry),
@@ -99,6 +100,7 @@ func (ui *app) showIconSearch(query string, onPick func(icon.Result, []byte)) {
)
d = dialog.NewCustomWithoutButtons(T("icon_search_title"), content, ui.win)
d.SetOnClosed(pushHintTarget(hint))
showDialog(d, ui.win, 560, 470) // sekaligus menampilkan dialog
search()
}
@@ -126,7 +128,7 @@ func (ui *app) iconChoice(result icon.Result, onPick func(icon.Result, []byte),
var mu sync.Mutex
var raw []byte
button := widget.NewButton("", func() {
button := newButton("", nil, result.Name, widget.LowImportance, func() {
mu.Lock()
picked := raw
mu.Unlock()
@@ -135,8 +137,8 @@ func (ui *app) iconChoice(result icon.Result, onPick func(icon.Result, []byte),
d.Hide()
}
})
button.Importance = widget.LowImportance
button.Icon = theme.NewThemedResource(theme.FileImageIcon()) // placeholder selama diunduh
button.Refresh()
go func() {
slot <- struct{}{}
+36
View File
@@ -11,6 +11,7 @@ import (
"fyne.io/fyne/v2/test"
"lazyrun/internal/icon"
"lazyrun/internal/store"
)
// TestNoEmptyDismissButton memastikan modal tidak memakai dialog.NewCustom,
@@ -62,6 +63,17 @@ func TestDialogsOpenWithoutPanic(t *testing.T) {
t.Fatal("modal web app tidak muncul")
}
}
ui.showEditDialog(store.Shortcut{Slug: "winbox", Name: "Winbox", Type: store.TypeExecutable, Kind: "windows",
BinaryPath: "/tmp/winbox", IconPath: "", Categories: "Network;", Targets: store.Targets{Menu: true, Desktop: true}})
if top := ui.win.Canvas().Overlays().Top(); top == nil {
t.Fatal("modal edit executable tidak muncul")
}
ui.showEditDialog(store.Shortcut{Slug: "wa", Name: "WhatsApp Web", Type: store.TypeWebApp, Kind: "pwa",
URL: "https://web.whatsapp.com", Browser: "/usr/bin/vivaldi", Targets: store.Targets{Menu: true}})
if top := ui.win.Canvas().Overlays().Top(); top == nil {
t.Fatal("modal edit webapp 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")
@@ -108,3 +120,27 @@ func TestStringsCoverUsedKeys(t *testing.T) {
}
}
}
func TestMergeImported(t *testing.T) {
target := &store.Store{}
target.Add(store.Shortcut{Slug: "winbox", Name: "Winbox"})
added, skipped := mergeImported(target, []store.Shortcut{
{Slug: "winbox", Name: "Winbox lama"}, // sudah ada -> dilewati
{Slug: "figma", Name: "Figma"}, // baru
{Slug: "", Name: "Tanpa slug"}, // rusak -> dilewati
{Slug: "kosong", Name: " "}, // tanpa nama -> dilewati
{Slug: "deploy", Name: " deploy.sh "}, // nama dirapikan
})
if added != 2 || skipped != 3 {
t.Errorf("added=%d skipped=%d, mau 2 dan 3", added, skipped)
}
if len(target.Items) != 3 {
t.Errorf("jumlah item = %d", len(target.Items))
}
dep, ok := target.Get("deploy")
if !ok || target.Items[dep].Name != "deploy.sh" {
t.Errorf("nama item tidak dirapikan: %+v", target.Items)
}
}
+7 -7
View File
@@ -54,13 +54,13 @@ func (ui *app) showWebAppDialog() {
}
}
browseIcon := widget.NewButtonWithIcon(T("browse"), theme.FolderOpenIcon(), func() {
browseIcon := newButton(T("browse"), theme.FolderOpenIcon(), T("tip_browse"), widget.MediumImportance, func() {
pickFile(ui.win, imageFilter, func(path string) {
iconPath, iconURL, iconData = path, "", nil
iconLabel.SetText(filepath.Base(path))
})
})
onlineIcon := widget.NewButtonWithIcon(T("online"), theme.SearchIcon(), func() {
onlineIcon := newButton(T("online"), theme.SearchIcon(), T("tip_online"), widget.MediumImportance, func() {
ui.showIconSearch(nameEntry.Text, func(result icon.Result, data []byte) {
iconURL, iconPath, iconData = result.URL, "", data
iconLabel.SetText(result.Name)
@@ -68,7 +68,7 @@ func (ui *app) showWebAppDialog() {
})
var d *dialog.CustomDialog
install := widget.NewButtonWithIcon(T("install"), theme.DownloadIcon(), func() {
install := newButton(T("install"), theme.DownloadIcon(), T("tip_install"), widget.HighImportance, func() {
switch {
case strings.TrimSpace(urlEntry.Text) == "":
dialog.ShowInformation(T("err_title"), T("need_url"), ui.win)
@@ -96,8 +96,6 @@ func (ui *app) showWebAppDialog() {
func(sc store.Shortcut, err error) { ui.added(sc, err, d.Hide) },
)
})
install.Importance = widget.HighImportance
form := widget.NewForm(
widget.NewFormItem(T("field_url"), urlEntry),
widget.NewFormItem(T("field_name"), nameEntry),
@@ -105,6 +103,7 @@ func (ui *app) showWebAppDialog() {
widget.NewFormItem(T("field_icon"), container.NewBorder(nil, nil, nil,
container.NewHBox(onlineIcon, browseIcon), iconLabel)),
)
hint := textLine(T("web_hint"), 12, false, colorNameMuted)
content := container.NewVScroll(container.NewVBox(
closeRow(func() { d.Hide() }),
form,
@@ -112,13 +111,14 @@ func (ui *app) showWebAppDialog() {
sectionTitle(T("section_location")),
menu,
desktop,
muted(T("web_hint")),
hint,
container.NewHBox(logoBox(newLogo(), 20), spacer(),
widget.NewButton(T("cancel"), func() { d.Hide() }),
newButton(T("cancel"), nil, T("tip_cancel"), widget.MediumImportance, func() { d.Hide() }),
install),
))
d = dialog.NewCustomWithoutButtons(T("new_web_title"), content, ui.win)
d.SetOnClosed(pushHintTarget(hint))
showDialog(d, ui.win, 560, 520)
}
+23 -19
View File
@@ -32,6 +32,7 @@ type app struct {
icons *iconCache
count *canvas.Text // angka jumlah shortcut di header daftar
versionText *canvas.Text // versi di footer; berubah kalau ada update
hint *canvas.Text // keterangan tombol yang sedang disorot
accent *canvas.LinearGradient
version string
}
@@ -40,6 +41,7 @@ type app struct {
func Run(a fyne.App, version string) {
ui, loadErr := build(a, version)
ui.win.Show()
pushHintTarget(ui.hint) // target default: baris hint di footer
ui.flashCards()
ui.scheduleUpdateCheck()
if loadErr != nil {
@@ -67,9 +69,8 @@ func build(a fyne.App, version string) (*app, error) {
st, err := store.Load(xdg.MetadataPath())
ui.store = st
addExec := widget.NewButtonWithIcon(T("btn_executable"), theme.ContentAddIcon(), ui.showExecutableDialog)
addExec.Importance = widget.HighImportance
addWeb := widget.NewButtonWithIcon(T("btn_webapp"), theme.ContentAddIcon(), ui.showWebAppDialog)
addExec := newButton(T("btn_executable"), theme.ContentAddIcon(), T("tip_executable"), widget.HighImportance, ui.showExecutableDialog)
addWeb := newButton(T("btn_webapp"), theme.ContentAddIcon(), T("tip_webapp"), widget.MediumImportance, ui.showWebAppDialog)
leftHeader := container.NewHBox(logoBox(newLogo(), 40), container.NewVBox(
heading("LazyRun"),
@@ -78,14 +79,17 @@ func build(a fyne.App, version string) (*app, error) {
rightHeader := container.NewHBox(addExec, addWeb)
header := container.NewBorder(nil, nil, leftHeader, rightHeader)
update := widget.NewButtonWithIcon(T("check_updates"), theme.DownloadIcon(), ui.checkUpdates)
github := widget.NewButton(T("github"), func() { ui.openURL(repoURL) })
donate := widget.NewButtonWithIcon(T("donate"),
fyne.NewStaticResource("heart.png", assets.Heart), func() { ui.openURL(donateURL) })
update := newButton(T("check_updates"), theme.DownloadIcon(), T("tip_check_updates"), widget.MediumImportance, ui.checkUpdates)
github := newButton(T("github"), nil, T("tip_github"), widget.MediumImportance, func() { ui.openURL(repoURL) })
donate := newButton(T("donate"), fyne.NewStaticResource("heart.png", assets.Heart), T("tip_donate"), widget.MediumImportance, func() { ui.openURL(donateURL) })
export := newIconButton(theme.DocumentSaveIcon(), T("tip_export"), ui.exportShortcuts)
importBtn := newIconButton(theme.StorageIcon(), T("tip_import"), ui.importShortcuts)
ui.hint = caption("")
footer := container.NewBorder(
widget.NewSeparator(), nil,
container.NewHBox(update, github, donate),
container.NewHBox(update, export, importBtn, github, donate),
ui.versionText,
ui.hint,
)
ui.accent = accentBar()
@@ -145,16 +149,17 @@ func (ui *app) row(sc store.Shortcut) *hoverCard {
}
actions := container.NewHBox(chips...)
actions.Add(iconButton(theme.MediaPlayIcon(), func() { ui.launch(sc) }))
actions.Add(newIconButton(theme.MediaPlayIcon(), T("tip_run"), func() { ui.launch(sc) }))
if syncable(sc) {
actions.Add(iconButton(theme.ViewRefreshIcon(), func() { ui.syncIcon(sc) }))
actions.Add(newIconButton(theme.ViewRefreshIcon(), T("tip_sync"), func() { ui.syncIcon(sc) }))
}
actions.Add(iconButton(theme.FolderOpenIcon(), func() {
actions.Add(newIconButton(theme.DocumentCreateIcon(), T("tip_edit"), func() { ui.showEditDialog(sc) }))
actions.Add(newIconButton(theme.FolderOpenIcon(), T("tip_folder"), func() {
if err := exec.Command("xdg-open", filepath.Dir(sc.DesktopPath)).Start(); err != nil {
dialog.ShowError(err, ui.win)
}
}))
actions.Add(iconButton(theme.DeleteIcon(), func() { ui.confirmDelete(sc) }))
actions.Add(newIconButton(theme.DeleteIcon(), T("tip_delete"), func() { ui.confirmDelete(sc) }))
return newHoverCard(container.NewBorder(nil, nil,
container.NewHBox(thumb(sc.IconPath, fallbackIcon(sc)), info),
@@ -239,12 +244,11 @@ func syncable(sc store.Shortcut) bool {
// syncIcon mengunduh ulang ikon shortcut dari sumber online-nya.
func (ui *app) syncIcon(sc store.Shortcut) {
progress := dialog.NewProgressInfinite(T("sync_icon"), T("working"), ui.win)
progress.Show()
hide := ui.showLoading(T("sync_icon"), T("working"))
go func() {
path, err := shortcut.RefreshIcon(sc)
fyne.Do(func() {
progress.Hide()
hide()
if err != nil {
dialog.ShowError(err, ui.win)
return
@@ -292,14 +296,14 @@ func (ui *app) confirmDelete(sc store.Shortcut) {
d.Show()
}
// install menjalankan pekerjaan berat (salin file besar / fetch favicon) di luar UI thread.
// install menjalankan pekerjaan berat (salin file besar / unduh ikon) di luar UI thread,
// dengan dialog proses berisi progress bar.
func (ui *app) install(work func() (store.Shortcut, error), done func(store.Shortcut, error)) {
progress := dialog.NewProgressInfinite(T("install"), T("working"), ui.win)
progress.Show()
hide := ui.showLoading(T("install"), T("working"))
go func() {
sc, err := work()
fyne.Do(func() {
progress.Hide()
hide()
done(sc, err)
})
}()
+36
View File
@@ -25,12 +25,14 @@ var tr = map[string][2]string{
"searching": {"Searching...", "Mencari..."},
"search_failed": {"Search failed. Check your connection.", "Pencarian gagal. Cek koneksi kamu."},
"search_found": {"%d icons found - click one to use it.", "%d ikon ditemukan - klik satu untuk dipakai."},
"icon_search_hint": {"Hover a button to see what it does.", "Sorot tombol untuk melihat fungsinya."},
"search_hint": {"Type a name and press Search.", "Tulis nama lalu tekan Cari."},
"icon_search_title": {"Icon from the internet", "Ikon dari internet"},
"sync_icon": {"Sync icon", "Sinkron ikon"},
"browse": {"Browse...", "Pilih..."},
"install": {"Install", "Install"},
"cancel": {"Cancel", "Batal"},
"save": {"Save", "Simpan"},
"back": {"Back", "Kembali"},
"delete": {"Delete", "Hapus"},
"working": {"Working...", "Sedang diproses..."},
@@ -78,6 +80,40 @@ var tr = map[string][2]string{
"update_restart": {"Restart now", "Restart sekarang"},
"up_to_date": {"LazyRun %s is the latest version.", "LazyRun %s adalah versi terbaru."},
"dev_build": {"This is a development build (%s), update check is skipped.", "Ini build development (%s), cek update dilewati."},
"export": {"Export", "Ekspor"},
"import": {"Import", "Impor"},
"edit_title": {"Edit shortcut", "Edit shortcut"},
"edit_saved": {"%s updated.", "%s diperbarui."},
"edit_hint": {"Change the binary, icon, name, or where the shortcut appears.", "Ubah binary, ikon, nama, atau lokasi shortcutnya."},
"import_done": {"%d shortcuts imported (%d skipped).", "%d shortcut diimpor (%d dilewati)."},
"import_empty": {"No shortcuts found in this file.", "Tidak ada shortcut di berkas ini."},
"export_done": {"Exported %d shortcuts to\n%s", "%d shortcut diekspor ke\n%s"},
"tip_executable": {"Create a shortcut from a binary", "Buat shortcut dari berkas binary"},
"tip_webapp": {"Create a shortcut from a web address", "Buat shortcut dari alamat web"},
"tip_run": {"Run this shortcut", "Jalankan shortcut ini"},
"tip_sync": {"Download the icon again from its source", "Unduh ulang ikonnya dari sumber"},
"tip_edit": {"Edit: binary, icon, name, location", "Edit: binary, ikon, nama, lokasi"},
"tip_folder": {"Open the .desktop folder", "Buka folder .desktop"},
"tip_delete": {"Remove this shortcut", "Hapus shortcut ini"},
"tip_check_updates": {"Check for a newer release", "Periksa rilis terbaru"},
"tip_github": {"Open the LazyRun repository", "Buka repo LazyRun"},
"tip_donate": {"Support LazyRun development", "Dukung pengembangan LazyRun"},
"tip_export": {"Export the shortcut list to a .lazy database", "Ekspor daftar shortcut ke basis data .lazy"},
"tip_import": {"Import shortcuts from a .lazy database", "Impor shortcut dari basis data .lazy"},
"tip_close": {"Close this dialog", "Tutup dialog ini"},
"tip_back": {"Back to the previous dialog", "Kembali ke dialog sebelumnya"},
"tip_search": {"Search icons online", "Cari ikon di internet"},
"tip_browse": {"Pick a file", "Pilih berkas"},
"tip_online": {"Pick an icon from the internet", "Pilih ikon dari internet"},
"tip_install": {"Install the shortcut", "Pasang shortcutnya"},
"tip_cancel": {"Cancel without saving", "Batal tanpa menyimpan"},
"tip_save": {"Save the changes", "Simpan perubahan"},
"tip_update_now": {"Download and install the update", "Unduh dan pasang update"},
"tip_update_page": {"Open the release page in the browser", "Buka halaman rilis di browser"},
"tip_later": {"Ask me later", "Ingatkan nanti"},
"tip_restart": {"Restart LazyRun with the new version", "Jalankan ulang LazyRun dengan versi baru"},
}
var lang = detectLang()
+1 -8
View File
@@ -83,13 +83,6 @@ func accentBar() *canvas.LinearGradient {
return bar
}
// iconButton adalah tombol ikon tanpa label, gaya datar.
func iconButton(icon fyne.Resource, tapped func()) *widget.Button {
button := widget.NewButtonWithIcon("", icon, tapped)
button.Importance = widget.LowImportance
return button
}
// thumb menampilkan ikon shortcut, atau fallback kalau file ikonnya hilang.
func thumb(path string, fallback fyne.Resource) fyne.CanvasObject {
var image *canvas.Image
@@ -122,7 +115,7 @@ func logoBox(image *canvas.Image, size float32) fyne.CanvasObject {
// closeRow menempatkan tombol tutup di kanan atas isi dialog, supaya
// selalu ada jalan keluar yang terlihat walau tombol bawah terpotong.
func closeRow(hide func()) fyne.CanvasObject {
return container.NewHBox(spacer(), iconButton(theme.CancelIcon(), hide))
return container.NewHBox(spacer(), newIconButton(theme.CancelIcon(), T("tip_close"), hide))
}
// showDialog menampilkan dialog dengan ukuran yang disesuaikan ke window.
+107
View File
@@ -0,0 +1,107 @@
package ui
import (
"sync"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/canvas"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/driver/desktop"
"fyne.io/fyne/v2/widget"
)
// Keterangan tombol muncul di satu baris hint (bawah window atau dalam dialog),
// bukan sebagai popup: popup Fyne selalu jadi overlay, dan selama overlay ada
// semua klik ke konten di bawahnya tidak diteruskan (tombol jadi perlu 2x klik).
var (
hintMu sync.Mutex
hintTarget *canvas.Text
)
// pushHintTarget mengarahkan keterangan hover ke label tertentu (dipakai dialog).
// Fungsi yang dikembalikan mengembalikan target sebelumnya.
func pushHintTarget(label *canvas.Text) func() {
hintMu.Lock()
previous := hintTarget
hintTarget = label
hintMu.Unlock()
return func() {
hintMu.Lock()
hintTarget = previous
hintMu.Unlock()
}
}
func showHint(text string) {
hintMu.Lock()
target := hintTarget
hintMu.Unlock()
if target == nil || text == "" {
return
}
target.Text = text
target.Refresh()
}
// clearHint menghapus hint hanya kalau isinya memang milik tombol ini.
func clearHint(text string) {
hintMu.Lock()
target := hintTarget
hintMu.Unlock()
if target == nil || target.Text != text {
return
}
target.Text = ""
target.Refresh()
}
// tipButton adalah tombol yang menampilkan fungsinya saat disorot mouse.
type tipButton struct {
widget.Button
tip string
}
// newButton membuat tombol (dengan ikon kalau diberikan) plus keterangan hover.
func newButton(text string, icon fyne.Resource, tip string, importance widget.Importance, tapped func()) *tipButton {
button := &tipButton{tip: tip}
button.Text = text
button.Icon = icon
button.Importance = importance
button.OnTapped = tapped
button.ExtendBaseWidget(button)
return button
}
// newIconButton adalah tombol ikon saja (tanpa teks), gaya datar.
func newIconButton(icon fyne.Resource, tip string, tapped func()) *tipButton {
return newButton("", icon, tip, widget.LowImportance, tapped)
}
func (b *tipButton) MouseIn(event *desktop.MouseEvent) {
b.Button.MouseIn(event)
showHint(b.tip)
}
func (b *tipButton) MouseOut() {
b.Button.MouseOut()
clearHint(b.tip)
}
// showLoading menampilkan dialog proses dengan progress bar yang berjalan,
// dan mengembalikan fungsi untuk menutupnya.
func (ui *app) showLoading(title, message string) func() {
bar := widget.NewProgressBarInfinite()
var d *dialog.CustomDialog
d = dialog.NewCustomWithoutButtons(title, container.NewBorder(
container.NewVBox(logoBox(newLogo(), 22), muted(message)),
nil, nil, nil,
bar,
), ui.win)
showDialog(d, ui.win, 420, 170)
return d.Hide
}
+89
View File
@@ -0,0 +1,89 @@
package ui
import (
"testing"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/test"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
)
func TestTipButtonShowsAndHidesHint(t *testing.T) {
a := test.NewApp()
a.Settings().SetTheme(brand{theme.DefaultTheme()})
defer a.Quit()
hint := textLine("", 12, false, colorNameMuted)
button := newIconButton(theme.MediaPlayIcon(), "jalankan shortcut", func() {})
w := a.NewWindow("tip")
w.SetContent(container.NewBorder(nil, hint, nil, nil, button))
w.Resize(fyne.NewSize(300, 200))
w.Show()
restore := pushHintTarget(hint)
defer restore()
if hint.Text != "" {
t.Fatal("hint muncul tanpa hover")
}
test.MoveMouse(w.Canvas(), fyne.NewPos(20, 20))
if hint.Text != "jalankan shortcut" {
t.Errorf("hint saat hover = %q", hint.Text)
}
// keluar dari tombol: hint dibersihkan
test.MoveMouse(w.Canvas(), fyne.NewPos(280, 180))
if hint.Text != "" {
t.Errorf("hint masih %q setelah kursor keluar", hint.Text)
}
}
// klik saat tooltip terbuka harus tetap kena tombolnya (dulu PopUp menelan klik).
// klik saat hint tampil harus tetap kena tombolnya (dulu pakai popup yang
// jadi overlay, sehingga klik pertama selalu tertelan).
func TestTipButtonClickGoesThrough(t *testing.T) {
a := test.NewApp()
a.Settings().SetTheme(brand{theme.DefaultTheme()})
defer a.Quit()
tapped := false
hint := textLine("", 12, false, colorNameMuted)
button := newIconButton(theme.MediaPlayIcon(), "jalankan shortcut", func() { tapped = true })
w := a.NewWindow("tip")
w.SetContent(container.NewBorder(nil, hint, nil, nil, button))
w.Resize(fyne.NewSize(300, 200))
w.Show()
restore := pushHintTarget(hint)
defer restore()
test.MoveMouse(w.Canvas(), fyne.NewPos(20, 20))
test.TapCanvas(w.Canvas(), fyne.NewPos(20, 20))
if !tapped {
t.Error("klik pertama tidak sampai ke tombol")
}
if top := w.Canvas().Overlays().Top(); top != nil {
t.Error("tidak boleh ada overlay untuk keterangan tombol")
}
}
func TestTipButtonWithoutTipDoesNothing(t *testing.T) {
a := test.NewApp()
a.Settings().SetTheme(brand{theme.DefaultTheme()})
defer a.Quit()
hint := textLine("", 12, false, colorNameMuted)
button := newButton("Tanpa tip", nil, "", widget.MediumImportance, func() {})
w := a.NewWindow("tip")
w.SetContent(container.NewBorder(nil, hint, nil, nil, button))
w.Resize(fyne.NewSize(300, 200))
w.Show()
restore := pushHintTarget(hint)
defer restore()
test.MoveMouse(w.Canvas(), fyne.NewPos(20, 20))
if hint.Text != "" {
t.Error("tombol tanpa tip tidak boleh menulis hint")
}
}
+10 -12
View File
@@ -110,18 +110,18 @@ func (ui *app) showUpdateDialog(rel update.Release) {
buttons := container.NewHBox(
logoBox(newLogo(), 20),
spacer(),
widget.NewButton(T("update_later"), func() { d.Hide() }),
widget.NewButton(T("update_page"), func() { ui.openURL(rel.PageURL) }),
newButton(T("update_later"), nil, T("tip_later"), widget.MediumImportance, func() { d.Hide() }),
newButton(T("update_page"), nil, T("tip_update_page"), widget.MediumImportance, func() { ui.openURL(rel.PageURL) }),
)
if updates := ui.updaterBinary(); updates != "" {
// AppImageUpdate yang memasang (dan memverifikasi) sendiri
buttons.Add(widget.NewButtonWithIcon(T("update_now"), theme.DownloadIcon(), func() {
buttons.Add(newButton(T("update_now"), theme.DownloadIcon(), T("tip_update_now"), widget.HighImportance, func() {
d.Hide()
ui.runUpdater(updates, rel)
}))
} else {
buttons.Add(widget.NewButtonWithIcon(T("update_now"), theme.DownloadIcon(), func() {
buttons.Add(newButton(T("update_now"), theme.DownloadIcon(), T("tip_update_now"), widget.HighImportance, func() {
d.Hide()
ui.downloadUpdate(rel)
}))
@@ -151,12 +151,11 @@ func (ui *app) downloadUpdate(rel update.Release) {
return
}
progress := dialog.NewProgressInfinite(T("check_updates"), T("update_downloading"), ui.win)
progress.Show()
hide := ui.showLoading(T("check_updates"), T("update_downloading"))
go func() {
err := update.Download(rel, appImage)
fyne.Do(func() {
progress.Hide()
hide()
if err != nil {
dialog.ShowError(err, ui.win)
return
@@ -174,12 +173,11 @@ func (ui *app) runUpdater(updater string, rel update.Release) {
return
}
progress := dialog.NewProgressInfinite(T("check_updates"), T("update_downloading"), ui.win)
progress.Show()
hide := ui.showLoading(T("check_updates"), T("update_downloading"))
go func() {
err := exec.Command(updater, appImage).Run()
fyne.Do(func() {
progress.Hide()
hide()
if err != nil {
dialog.ShowError(err, ui.win)
return
@@ -195,8 +193,8 @@ func (ui *app) offerRestart() {
content := container.NewVBox(
widget.NewLabel(T("update_installed")),
container.NewHBox(spacer(),
widget.NewButton(T("update_later"), func() { d.Hide() }),
widget.NewButtonWithIcon(T("update_restart"), theme.ViewRefreshIcon(), ui.restart),
newButton(T("update_later"), nil, T("tip_later"), widget.MediumImportance, func() { d.Hide() }),
newButton(T("update_restart"), theme.ViewRefreshIcon(), T("tip_restart"), widget.HighImportance, ui.restart),
),
)
d = dialog.NewCustomWithoutButtons(T("check_updates"), content, ui.win)