UI: animasi dan branding tambahan
- 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
This commit is contained in:
1 parent
b12efefd6a
commit
70534bf6e2
6 files changed
+132
-17
No files matched your search
@@ -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()
|
||||||
|
}
|
||||||
@@ -96,7 +96,7 @@ func (ui *app) showExecutableDialog() {
|
|||||||
menu,
|
menu,
|
||||||
desktop,
|
desktop,
|
||||||
muted(T("exec_hint")),
|
muted(T("exec_hint")),
|
||||||
container.NewHBox(spacer(),
|
container.NewHBox(logoBox(newLogo(), 20), spacer(),
|
||||||
widget.NewButton(T("cancel"), func() { d.Hide() }),
|
widget.NewButton(T("cancel"), func() { d.Hide() }),
|
||||||
install),
|
install),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ func (ui *app) showWebAppDialog() {
|
|||||||
menu,
|
menu,
|
||||||
desktop,
|
desktop,
|
||||||
muted(T("web_hint")),
|
muted(T("web_hint")),
|
||||||
container.NewHBox(spacer(),
|
container.NewHBox(logoBox(newLogo(), 20), spacer(),
|
||||||
widget.NewButton(T("cancel"), func() { d.Hide() }),
|
widget.NewButton(T("cancel"), func() { d.Hide() }),
|
||||||
install),
|
install),
|
||||||
)
|
)
|
||||||
|
|||||||
+31
-11
@@ -6,6 +6,7 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
"fyne.io/fyne/v2"
|
"fyne.io/fyne/v2"
|
||||||
"fyne.io/fyne/v2/canvas"
|
"fyne.io/fyne/v2/canvas"
|
||||||
@@ -26,6 +27,7 @@ type app struct {
|
|||||||
store *store.Store
|
store *store.Store
|
||||||
browsers []browser.Browser
|
browsers []browser.Browser
|
||||||
rows *fyne.Container
|
rows *fyne.Container
|
||||||
|
cards map[string]*hoverCard
|
||||||
count *canvas.Text // angka jumlah shortcut di header daftar
|
count *canvas.Text // angka jumlah shortcut di header daftar
|
||||||
version string
|
version string
|
||||||
}
|
}
|
||||||
@@ -34,6 +36,7 @@ type app struct {
|
|||||||
func Run(a fyne.App, version string) {
|
func Run(a fyne.App, version string) {
|
||||||
ui, loadErr := build(a, version)
|
ui, loadErr := build(a, version)
|
||||||
ui.win.Show()
|
ui.win.Show()
|
||||||
|
ui.flashCards()
|
||||||
if loadErr != nil {
|
if loadErr != nil {
|
||||||
dialog.ShowError(loadErr, ui.win)
|
dialog.ShowError(loadErr, ui.win)
|
||||||
}
|
}
|
||||||
@@ -61,7 +64,7 @@ func build(a fyne.App, version string) (*app, error) {
|
|||||||
addExec.Importance = widget.HighImportance
|
addExec.Importance = widget.HighImportance
|
||||||
addWeb := widget.NewButtonWithIcon(T("btn_webapp"), theme.ContentAddIcon(), ui.showWebAppDialog)
|
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"),
|
heading("LazyRun"),
|
||||||
caption(T("tagline")),
|
caption(T("tagline")),
|
||||||
))
|
))
|
||||||
@@ -75,10 +78,12 @@ func build(a fyne.App, version string) (*app, error) {
|
|||||||
footer := container.NewBorder(
|
footer := container.NewBorder(
|
||||||
widget.NewSeparator(), nil,
|
widget.NewSeparator(), nil,
|
||||||
container.NewHBox(update, github, donate),
|
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))
|
container.NewHBox(sectionLabel(T("installed")), countChip))
|
||||||
body := container.NewBorder(
|
body := container.NewBorder(
|
||||||
top,
|
top,
|
||||||
@@ -106,12 +111,15 @@ func (ui *app) reload() {
|
|||||||
if len(ui.store.Items) == 0 {
|
if len(ui.store.Items) == 0 {
|
||||||
ui.rows.Add(emptyState())
|
ui.rows.Add(emptyState())
|
||||||
}
|
}
|
||||||
|
ui.cards = map[string]*hoverCard{}
|
||||||
for _, sc := range ui.store.Items {
|
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(
|
info := container.NewVBox(
|
||||||
subheading(sc.Name),
|
subheading(sc.Name),
|
||||||
caption(metaText(sc)),
|
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) }))
|
actions.Add(iconButton(theme.DeleteIcon(), func() { ui.confirmDelete(sc) }))
|
||||||
|
|
||||||
return container.NewVBox(
|
return newHoverCard(container.NewBorder(nil, nil,
|
||||||
card(container.NewBorder(nil, nil,
|
container.NewHBox(thumb(sc.IconPath, fallbackIcon(sc)), info),
|
||||||
container.NewHBox(thumb(sc.IconPath, fallbackIcon(sc)), info),
|
actions))
|
||||||
actions)),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// metaText menampilkan tipe shortcut dan penanda singkatnya.
|
// metaText menampilkan tipe shortcut dan penanda singkatnya.
|
||||||
@@ -169,12 +175,23 @@ func emptyState() fyne.CanvasObject {
|
|||||||
hint := muted(T("empty_hint"))
|
hint := muted(T("empty_hint"))
|
||||||
hint.Alignment = fyne.TextAlignCenter
|
hint.Alignment = fyne.TextAlignCenter
|
||||||
return container.NewCenter(container.NewVBox(
|
return container.NewCenter(container.NewVBox(
|
||||||
container.NewCenter(logoImage(88)),
|
container.NewCenter(logoBox(newLogo(), 88)),
|
||||||
subheading(T("empty_title")),
|
subheading(T("empty_title")),
|
||||||
hint,
|
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) {
|
func (ui *app) confirmDelete(sc store.Shortcut) {
|
||||||
content := container.NewVBox(widget.NewLabel(fmt.Sprintf(T("confirm_delete_msg"), sc.Name)))
|
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()
|
closeDialog()
|
||||||
ui.reload()
|
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)
|
dialog.ShowInformation(T("success_title"), fmt.Sprintf(T("success_msg"), sc.Name), ui.win)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ func chipLabel(text string, bg fyne.ThemeColorName) (fyne.CanvasObject, *canvas.
|
|||||||
}
|
}
|
||||||
|
|
||||||
// accentBar adalah garis gradien brand di bawah header.
|
// accentBar adalah garis gradien brand di bawah header.
|
||||||
func accentBar() fyne.CanvasObject {
|
func accentBar() *canvas.LinearGradient {
|
||||||
bar := canvas.NewLinearGradient(brandIndigo, brandCyan, 90)
|
bar := canvas.NewLinearGradient(brandIndigo, brandCyan, 90)
|
||||||
bar.SetMinSize(fyne.NewSize(0, 3))
|
bar.SetMinSize(fyne.NewSize(0, 3))
|
||||||
return bar
|
return bar
|
||||||
@@ -101,15 +101,20 @@ func thumb(path string, fallback fyne.Resource) fyne.CanvasObject {
|
|||||||
image = canvas.NewImageFromResource(theme.NewThemedResource(fallback))
|
image = canvas.NewImageFromResource(theme.NewThemedResource(fallback))
|
||||||
}
|
}
|
||||||
image.FillMode = canvas.ImageFillContain
|
image.FillMode = canvas.ImageFillContain
|
||||||
|
image.CornerRadius = 8
|
||||||
image.SetMinSize(fyne.NewSize(36, 36))
|
image.SetMinSize(fyne.NewSize(36, 36))
|
||||||
return container.NewGridWrap(fyne.NewSize(36, 36), image)
|
return container.NewGridWrap(fyne.NewSize(36, 36), image)
|
||||||
}
|
}
|
||||||
|
|
||||||
// logoImage menampilkan ikon LazyRun pada ukuran tertentu.
|
// newLogo mengembalikan ikon LazyRun (dipakai untuk keperluan animasi).
|
||||||
func logoImage(size float32) fyne.CanvasObject {
|
func newLogo() *canvas.Image {
|
||||||
image := canvas.NewImageFromResource(fyne.NewStaticResource("lazyrun.png", assets.Icon))
|
image := canvas.NewImageFromResource(fyne.NewStaticResource("lazyrun.png", assets.Icon))
|
||||||
image.FillMode = canvas.ImageFillContain
|
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)
|
return container.NewGridWrap(fyne.NewSize(size, size), image)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ var (
|
|||||||
// Nama warna tambahan di luar tema standar Fyne.
|
// Nama warna tambahan di luar tema standar Fyne.
|
||||||
const (
|
const (
|
||||||
colorNameCard fyne.ThemeColorName = "lazyrunCard"
|
colorNameCard fyne.ThemeColorName = "lazyrunCard"
|
||||||
|
colorNameCardHover fyne.ThemeColorName = "lazyrunCardHover"
|
||||||
colorNameMuted fyne.ThemeColorName = "lazyrunMuted"
|
colorNameMuted fyne.ThemeColorName = "lazyrunMuted"
|
||||||
colorNameChipMenu fyne.ThemeColorName = "lazyrunChipMenu"
|
colorNameChipMenu fyne.ThemeColorName = "lazyrunChipMenu"
|
||||||
colorNameChipDesk fyne.ThemeColorName = "lazyrunChipDesktop"
|
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})
|
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:
|
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})
|
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:
|
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})
|
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:
|
case theme.ColorNameButton, theme.ColorNameMenuBackground:
|
||||||
|
|||||||
Reference in new issue
Block a user