Files
tia-love/internal/ui/components/toast.go
T

150 lines
3.3 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"
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/ui/theme"
)
// Toast — notifikasi melayang dari tengah atas layar (F-025).
// Versi spike T-0.4 + warna dari token tema; slide via spring harmonica.
const toastHeight = 4 // 2 baris konten + 2 border
// Level — kategori status toast (mewarnai border).
type Level int
const (
LevelSuccess Level = iota
LevelWarning
LevelError
LevelInfo
)
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
level Level
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(level Level, title, body string) {
t.level = level
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
}
}
}
func (t Toast) borderColor() theme.Color {
th := theme.Current()
switch t.level {
case LevelWarning:
return th.Warning
case LevelError:
return th.Danger
case LevelInfo:
return th.Info
default:
return th.Success
}
}
// 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
}
th := theme.Current()
style := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color(string(t.borderColor()))).
Foreground(lipgloss.Color(string(th.Text))).
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")
}