From 70534bf6e2fc7972dd595e4bd8b8237498baf26c Mon Sep 17 00:00:00 2001 From: oxpoe Date: Tue, 13 Oct 2026 07:14:41 +0700 Subject: [PATCH] UI: animasi dan branding tambahan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Garis aksen gradien berdenyut terus (indigo <-> cyan) - Kartu shortcut: transisi warna halus saat disorot mouse (hoverCard) - Kartu baru berkedip warna brand saat daftar dimuat / setelah install - Logo LazyRun + brand mark di dialog, footer 'LazyRun vX · oxpoe' - Thumbnail shortcut bersudut membulat - Versi logo fade-in dibuang: canvas.Image.Translucency tidak ter-render di Fyne 2.8 --- internal/ui/animation.go | 87 ++++++++++++++++++++++++++++++++ internal/ui/dialog_executable.go | 2 +- internal/ui/dialog_webapp.go | 2 +- internal/ui/main_window.go | 42 +++++++++++---- internal/ui/style.go | 13 +++-- internal/ui/theme.go | 3 ++ 6 files changed, 132 insertions(+), 17 deletions(-) create mode 100644 internal/ui/animation.go diff --git a/internal/ui/animation.go b/internal/ui/animation.go new file mode 100644 index 0000000..b927136 --- /dev/null +++ b/internal/ui/animation.go @@ -0,0 +1,87 @@ +package ui + +import ( + "image/color" + "time" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/canvas" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/driver/desktop" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" +) + +// Animasi di sini sengaja pendek dan tidak menyentuh posisi/ukuran objek, +// supaya tidak bertabrakan dengan layout container. + +// pulseGradient membuat garis aksen bergerak bolak-balik antara indigo dan cyan. +func pulseGradient(bar *canvas.LinearGradient) { + anim := canvas.NewColorRGBAAnimation(brandIndigo, brandCyan, 3*time.Second, func(c color.Color) { + bar.EndColor = c + canvas.Refresh(bar) + }) + anim.AutoReverse = true + anim.RepeatCount = fyne.AnimationRepeatForever + anim.Start() +} + +// hoverCard adalah kartu yang warnanya berubah halus saat disorot mouse, +// dan bisa dipakai untuk animasi kedatangan baris baru. +type hoverCard struct { + widget.BaseWidget + bg *canvas.Rectangle + content fyne.CanvasObject + hover bool +} + +func newHoverCard(content fyne.CanvasObject) *hoverCard { + c := &hoverCard{ + bg: canvas.NewRectangle(theme.Color(colorNameCard)), + content: content, + } + c.bg.CornerRadius = 12 + c.ExtendBaseWidget(c) + return c +} + +func (c *hoverCard) CreateRenderer() fyne.WidgetRenderer { + return widget.NewSimpleRenderer(container.NewStack(c.bg, container.NewPadded(c.content))) +} + +func (c *hoverCard) MouseIn(*desktop.MouseEvent) { c.setHover(true) } +func (c *hoverCard) MouseMoved(*desktop.MouseEvent) {} +func (c *hoverCard) MouseOut() { c.setHover(false) } + +func (c *hoverCard) setHover(on bool) { + if c.hover == on { + return + } + c.hover = on + c.animate(theme.Color(pick(on, colorNameCardHover, colorNameCard)), 150*time.Millisecond) +} + +// flash membuat kartu berkedip warna brand sebentar (baris baru muncul). +func (c *hoverCard) flash(delay time.Duration) { + start := func() { c.animate(theme.Color(colorNameCard), 450*time.Millisecond) } + if delay <= 0 { + c.bg.FillColor = theme.Color(colorNameChipMenu) + start() + return + } + time.AfterFunc(delay, func() { + fyne.Do(func() { + c.bg.FillColor = theme.Color(colorNameChipMenu) + start() + }) + }) +} + +// animate memindahkan warna latar kartu secara halus dari warna sekarang. +func (c *hoverCard) animate(goal color.Color, d time.Duration) { + from := c.bg.FillColor + canvas.NewColorRGBAAnimation(from, goal, d, func(col color.Color) { + c.bg.FillColor = col + canvas.Refresh(c.bg) + }).Start() +} diff --git a/internal/ui/dialog_executable.go b/internal/ui/dialog_executable.go index 4e2e52c..3022937 100644 --- a/internal/ui/dialog_executable.go +++ b/internal/ui/dialog_executable.go @@ -96,7 +96,7 @@ func (ui *app) showExecutableDialog() { menu, desktop, muted(T("exec_hint")), - container.NewHBox(spacer(), + container.NewHBox(logoBox(newLogo(), 20), spacer(), widget.NewButton(T("cancel"), func() { d.Hide() }), install), ) diff --git a/internal/ui/dialog_webapp.go b/internal/ui/dialog_webapp.go index 9a86c87..0c55629 100644 --- a/internal/ui/dialog_webapp.go +++ b/internal/ui/dialog_webapp.go @@ -102,7 +102,7 @@ func (ui *app) showWebAppDialog() { menu, desktop, muted(T("web_hint")), - container.NewHBox(spacer(), + container.NewHBox(logoBox(newLogo(), 20), spacer(), widget.NewButton(T("cancel"), func() { d.Hide() }), install), ) diff --git a/internal/ui/main_window.go b/internal/ui/main_window.go index b3d7e4a..3c17a2a 100644 --- a/internal/ui/main_window.go +++ b/internal/ui/main_window.go @@ -6,6 +6,7 @@ import ( "os/exec" "path/filepath" "strconv" + "time" "fyne.io/fyne/v2" "fyne.io/fyne/v2/canvas" @@ -26,6 +27,7 @@ type app struct { store *store.Store browsers []browser.Browser rows *fyne.Container + cards map[string]*hoverCard count *canvas.Text // angka jumlah shortcut di header daftar version string } @@ -34,6 +36,7 @@ type app struct { func Run(a fyne.App, version string) { ui, loadErr := build(a, version) ui.win.Show() + ui.flashCards() if loadErr != nil { dialog.ShowError(loadErr, ui.win) } @@ -61,7 +64,7 @@ func build(a fyne.App, version string) (*app, error) { addExec.Importance = widget.HighImportance addWeb := widget.NewButtonWithIcon(T("btn_webapp"), theme.ContentAddIcon(), ui.showWebAppDialog) - leftHeader := container.NewHBox(logoImage(40), container.NewVBox( + leftHeader := container.NewHBox(logoBox(newLogo(), 40), container.NewVBox( heading("LazyRun"), caption(T("tagline")), )) @@ -75,10 +78,12 @@ func build(a fyne.App, version string) (*app, error) { footer := container.NewBorder( widget.NewSeparator(), nil, container.NewHBox(update, github, donate), - caption("v"+version), + caption("LazyRun v"+version+" · oxpoe"), ) - top := container.NewVBox(header, accentBar(), + accent := accentBar() + pulseGradient(accent) + top := container.NewVBox(header, accent, container.NewHBox(sectionLabel(T("installed")), countChip)) body := container.NewBorder( top, @@ -106,12 +111,15 @@ func (ui *app) reload() { if len(ui.store.Items) == 0 { ui.rows.Add(emptyState()) } + ui.cards = map[string]*hoverCard{} for _, sc := range ui.store.Items { - ui.rows.Add(ui.row(sc)) + row := ui.row(sc) + ui.rows.Add(row) + ui.cards[sc.Slug] = row } } -func (ui *app) row(sc store.Shortcut) fyne.CanvasObject { +func (ui *app) row(sc store.Shortcut) *hoverCard { info := container.NewVBox( subheading(sc.Name), caption(metaText(sc)), @@ -137,11 +145,9 @@ func (ui *app) row(sc store.Shortcut) fyne.CanvasObject { })) actions.Add(iconButton(theme.DeleteIcon(), func() { ui.confirmDelete(sc) })) - return container.NewVBox( - card(container.NewBorder(nil, nil, - container.NewHBox(thumb(sc.IconPath, fallbackIcon(sc)), info), - actions)), - ) + return newHoverCard(container.NewBorder(nil, nil, + container.NewHBox(thumb(sc.IconPath, fallbackIcon(sc)), info), + actions)) } // metaText menampilkan tipe shortcut dan penanda singkatnya. @@ -169,12 +175,23 @@ func emptyState() fyne.CanvasObject { hint := muted(T("empty_hint")) hint.Alignment = fyne.TextAlignCenter return container.NewCenter(container.NewVBox( - container.NewCenter(logoImage(88)), + container.NewCenter(logoBox(newLogo(), 88)), subheading(T("empty_title")), hint, )) } +// flashCards memberi kilatan berurutan saat daftar dimuat/diubah. +func (ui *app) flashCards() { + for i, sc := range ui.store.Items { + card, ok := ui.cards[sc.Slug] + if !ok { + continue + } + card.flash(time.Duration(min(i, 6)) * 70 * time.Millisecond) + } +} + func (ui *app) confirmDelete(sc store.Shortcut) { content := container.NewVBox(widget.NewLabel(fmt.Sprintf(T("confirm_delete_msg"), sc.Name))) @@ -231,6 +248,9 @@ func (ui *app) added(sc store.Shortcut, err error, closeDialog func()) { } closeDialog() ui.reload() + if card, ok := ui.cards[sc.Slug]; ok { + card.flash(0) + } dialog.ShowInformation(T("success_title"), fmt.Sprintf(T("success_msg"), sc.Name), ui.win) } diff --git a/internal/ui/style.go b/internal/ui/style.go index 2b52868..ad298cd 100644 --- a/internal/ui/style.go +++ b/internal/ui/style.go @@ -76,7 +76,7 @@ func chipLabel(text string, bg fyne.ThemeColorName) (fyne.CanvasObject, *canvas. } // accentBar adalah garis gradien brand di bawah header. -func accentBar() fyne.CanvasObject { +func accentBar() *canvas.LinearGradient { bar := canvas.NewLinearGradient(brandIndigo, brandCyan, 90) bar.SetMinSize(fyne.NewSize(0, 3)) return bar @@ -101,15 +101,20 @@ func thumb(path string, fallback fyne.Resource) fyne.CanvasObject { image = canvas.NewImageFromResource(theme.NewThemedResource(fallback)) } image.FillMode = canvas.ImageFillContain + image.CornerRadius = 8 image.SetMinSize(fyne.NewSize(36, 36)) return container.NewGridWrap(fyne.NewSize(36, 36), image) } -// logoImage menampilkan ikon LazyRun pada ukuran tertentu. -func logoImage(size float32) fyne.CanvasObject { +// newLogo mengembalikan ikon LazyRun (dipakai untuk keperluan animasi). +func newLogo() *canvas.Image { image := canvas.NewImageFromResource(fyne.NewStaticResource("lazyrun.png", assets.Icon)) image.FillMode = canvas.ImageFillContain - image.SetMinSize(fyne.NewSize(size, size)) + return image +} + +// logoBox menampilkan ikon LazyRun pada ukuran tetap. +func logoBox(image *canvas.Image, size float32) fyne.CanvasObject { return container.NewGridWrap(fyne.NewSize(size, size), image) } diff --git a/internal/ui/theme.go b/internal/ui/theme.go index 942369f..339d460 100644 --- a/internal/ui/theme.go +++ b/internal/ui/theme.go @@ -16,6 +16,7 @@ var ( // Nama warna tambahan di luar tema standar Fyne. const ( colorNameCard fyne.ThemeColorName = "lazyrunCard" + colorNameCardHover fyne.ThemeColorName = "lazyrunCardHover" colorNameMuted fyne.ThemeColorName = "lazyrunMuted" colorNameChipMenu fyne.ThemeColorName = "lazyrunChipMenu" colorNameChipDesk fyne.ThemeColorName = "lazyrunChipDesktop" @@ -34,6 +35,8 @@ func (b brand) Color(name fyne.ThemeColorName, variant fyne.ThemeVariant) color. return pick(dark, color.NRGBA{R: 0x13, G: 0x13, B: 0x1B, A: 0xFF}, color.NRGBA{R: 0xF5, G: 0xF5, B: 0xFA, A: 0xFF}) case colorNameCard: return pick(dark, color.NRGBA{R: 0x1D, G: 0x1D, B: 0x28, A: 0xFF}, color.NRGBA{R: 0xFF, G: 0xFF, B: 0xFF, A: 0xFF}) + case colorNameCardHover: + return withAlpha(brandIndigo, pick[uint8](dark, 0x2E, 0x14)) case theme.ColorNameInputBackground: return pick(dark, color.NRGBA{R: 0x1A, G: 0x1A, B: 0x24, A: 0xFF}, color.NRGBA{R: 0xFF, G: 0xFF, B: 0xFF, A: 0xFF}) case theme.ColorNameButton, theme.ColorNameMenuBackground: