122 lines
2.7 KiB
Go
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")
|
|
}
|