Files
tia-love/internal/ui/components/toast.go
T
oxpoe 8933906102
CI / test (ubuntu-latest) (push) Successful in 12m35s
CI / test (macos-latest) (push) Canceled after 0s
chore: migrasi kanonik ke gitea.oxpoe.qzz.io/oxpoe/tia-love (module path, installer, Gitea Actions, goreleaser)
2026-10-15 07:41:41 +07:00

122 lines
2.7 KiB
Go

// Package components — komponen UI tia.
package components
import (
"math"
"strings"
"time"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/harmonica"
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/ui/animation"
)
// Toast — notifikasi melayang dari tengah atas layar (F-025).
// Versi spike T-0.4: slide masuk/keluar ditenagai spring harmonica.
const toastHeight = 4 // 2 baris konten + 2 border
type toastState int
const (
toastHidden toastState = iota
toastIn
toastVisible
toastOut
)
type Toast struct {
spring harmonica.Spring
pos, vel float64 // baris offset: 0 = terlihat penuh, -toastHeight = tersembunyi
state toastState
title string
body string
elapsed time.Duration
hold time.Duration // lama tampil sebelum slide keluar
}
func NewToast() Toast {
return Toast{
spring: harmonica.NewSpring(harmonica.FPS(animation.FPS), 6.0, 0.7),
pos: -toastHeight,
hold: 2500 * time.Millisecond,
}
}
// Show menampilkan toast baru (slide-in dari atas).
func (t *Toast) Show(title, body string) {
t.title, t.body = title, body
t.elapsed = 0
t.state = toastIn
}
// Active melaporkan ada toast yang tampil/beranimasi.
func (t Toast) Active() bool { return t.state != toastHidden }
// Update dipanggil tiap frame (30 FPS); dt dipakai untuk timer auto-dismiss.
func (t *Toast) Update(dt time.Duration) {
switch t.state {
case toastIn, toastOut:
target := 0.0
if t.state == toastOut {
target = -toastHeight
}
t.pos, t.vel = t.spring.Update(t.pos, t.vel, target)
settled := math.Abs(t.pos-target) < 0.05 && math.Abs(t.vel) < 0.05
if !settled {
return
}
if t.state == toastIn {
t.state = toastVisible
} else {
t.state = toastHidden
}
case toastVisible:
t.elapsed += dt
if t.elapsed >= t.hold {
t.state = toastOut
}
}
}
// View menggambar toast setinggi tetap (toastHeight baris, di-center
// horizontal); baris yang tergeser keluar area menjadi kosong.
func (t Toast) View(width int) string {
if t.state == toastHidden {
return ""
}
boxW := 46
if boxW > width-2 {
boxW = width - 2
}
inner := boxW - 4
if inner < 8 {
inner = 8
}
clip := func(s string) string {
r := []rune(s)
if len(r) > inner {
return string(r[:inner])
}
return s
}
style := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("#a6e3a1")).
Foreground(lipgloss.Color("#cdd6f4")).
Width(inner).
Padding(0, 1)
lines := strings.Split(style.Render(clip(t.title)+"\n"+clip(t.body)), "\n")
shift := int(math.Round(t.pos)) // -toastHeight..0
rows := make([]string, toastHeight)
for r := range rows {
src := r - shift
if src >= 0 && src < len(lines) {
rows[r] = lipgloss.PlaceHorizontal(width, lipgloss.Center, lines[src])
}
}
return strings.Join(rows, "\n")
}