feat(animasi): T-0.4 spike lolos — ticker 30 FPS + toast harmonica, CPU idle 2.75% (F-026 awal)

This commit is contained in:
oxpoe committed 2026-10-15 07:28:33 +07:00
1 parent b8d2595ca9
commit e5d5dba023
11 files changed
+501 -12

No files matched your search

+68 -10
View File
@@ -2,34 +2,66 @@ package app
import (
"fmt"
"strings"
"time"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/oxpoe/tia/internal/audio"
"github.com/oxpoe/tia/internal/ui/animation"
"github.com/oxpoe/tia/internal/ui/components"
)
// Model root sementara (T-0.2/T-0.3, Phase 0).
// Membuktikan Bubble Tea v2 (View struct, mouse, resize) + audio engine
// sebelum layout penuh Phase 1 menggantikannya.
// Model root sementara (T-0.2–T-0.4, Phase 0).
// Membuktikan Bubble Tea v2 (View struct, mouse, resize, ticker 30 FPS),
// audio engine, dan toast animasi sebelum layout penuh Phase 1.
type Model struct {
width, height int
clicks int
lastClick string
sound *audio.Engine
tickID int64
animating bool
phase float64
toast components.Toast
frames int
fps int
lastFPSAt time.Time
}
func New() Model {
return Model{
lastClick: "belum ada klik",
sound: audio.New(audio.Options{Enabled: true, Volume: 70, MuteOnSSH: true}),
tickID: animation.NewID(),
animating: true,
toast: components.NewToast(),
}
}
func (m Model) Init() tea.Cmd { return nil }
func (m Model) Init() tea.Cmd { return animation.Next(m.tickID) }
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case animation.TickMsg:
if msg.ID != m.tickID || !m.animating {
return m, nil // tick basi / animasi dimatikan oleh reduced-motion
}
m.phase += 0.9
m.toast.Update(animation.FrameInterval)
m.frames++
if m.lastFPSAt.IsZero() {
m.lastFPSAt = msg.Time
}
if d := msg.Time.Sub(m.lastFPSAt); d >= time.Second {
m.fps = int(float64(m.frames) / d.Seconds())
m.frames = 0
m.lastFPSAt = msg.Time
}
return m, animation.Next(m.tickID)
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
@@ -38,7 +70,19 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case "q", "ctrl+c", "esc":
m.sound.Play(audio.ModalClose)
return m, tea.Quit
case "a": // reduced motion: hentikan/nyalakan ticker
m.animating = !m.animating
if m.animating {
m.tickID = animation.NewID()
return m, animation.Next(m.tickID)
}
return m, nil
case "t":
m.toast.Show("✔ [ SUKSES ] Konversi Berhasil Disimpan",
"Stack 'web-app' tersimpan di ~/.tia/stacks/web-app/")
m.sound.Play(audio.NotifySuccess)
case "j", "down", "k", "up":
m.phase += 6
m.sound.Play(audio.Navigate)
}
@@ -46,6 +90,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.Button == tea.MouseLeft {
m.clicks++
m.lastClick = fmt.Sprintf("klik #%d di (%d, %d)", m.clicks, msg.X, msg.Y)
if m.toast.Active() {
m.toast = components.NewToast() // klik = tutup toast
}
m.sound.Play(audio.Click)
}
}
@@ -57,19 +104,30 @@ func (m Model) View() tea.View {
Bold(true).
Foreground(lipgloss.Color("#cba6f7")).
Render("tia — Terminal Is A My Way")
tagline := lipgloss.NewStyle().
fps := "—"
if m.animating {
fps = fmt.Sprintf("%d", m.fps)
}
statusline := lipgloss.NewStyle().
Foreground(lipgloss.Color("#a6adc8")).
Render("Just Work · Phase 0 · skeleton TUI + audio")
Render(fmt.Sprintf("Just Work · Phase 0 · animasi %s fps · audio: %s", fps, m.sound.Status()))
hint := lipgloss.NewStyle().
Foreground(lipgloss.Color("#6c7086")).
Render("[t] toast · [a] reduced-motion · klik / j / k = bunyi · q = keluar")
box := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("#6c7086")).
Padding(0, 2).
Render(m.lastClick + "\n\nklik / j / k = bunyi · q = keluar")
Render(fmt.Sprintf("%s\nukuran jendela: %dx%d", m.lastClick, m.width, m.height))
content := fmt.Sprintf("%s\n%s\n\n%s\n\nukuran jendela: %dx%d · audio: %s",
title, tagline, box, m.width, m.height, m.sound.Status())
parts := []string{title, "", animation.RenderLogo(m.phase), "", statusline, hint, "", box}
if t := m.toast.View(max(m.width, 20)); t != "" {
parts = append([]string{t, ""}, parts...)
}
v := tea.NewView(content)
v := tea.NewView(strings.Join(parts, "\n"))
v.AltScreen = true
v.MouseMode = tea.MouseModeCellMotion
return v
+73
View File
@@ -0,0 +1,73 @@
package animation
import (
"fmt"
"math"
"strings"
"charm.land/lipgloss/v2"
)
// Logo ASCII tia (PRD §5.1.0).
var logoLines = []string{
" _ _",
"| |_(_) __ _",
"| __| |/ _` |",
"|_| |_|\\__,_|",
}
const (
logoDim = "#7f849c" // overlay1 — bagian redup
logoBright = "#f5c2e7" // pink — puncak kilau
shimmerSpan = 7.0 // lebar kilau dalam karakter
)
// RenderLogo menggambar logo dengan kilau (shimmer) berjalan.
// phase = posisi kilau (bebas; di-mod total lebar logo).
func RenderLogo(phase float64) string {
total := 0
for _, l := range logoLines {
total += len([]rune(l))
}
head := math.Mod(phase, float64(total))
if head < 0 {
head += float64(total)
}
var b strings.Builder
idx := 0
for li, line := range logoLines {
if li > 0 {
b.WriteByte('\n')
}
for _, r := range line {
d := math.Abs(float64(idx) - head)
if d > float64(total)/2 { // jarak sirkular
d = float64(total) - d
}
t := math.Max(0, 1-d/shimmerSpan)
b.WriteString(lipgloss.NewStyle().
Foreground(lipgloss.Color(lerpHex(logoDim, logoBright, t))).
Render(string(r)))
idx++
}
}
return b.String()
}
// lerpHex meng-interpolasi dua warna hex (t 0..1).
func lerpHex(a, b string, t float64) string {
t = math.Max(0, math.Min(1, t))
ar, ag, ab := hexRGB(a)
br, bg, bb := hexRGB(b)
return fmt.Sprintf("#%02x%02x%02x",
int(float64(ar)+(float64(br)-float64(ar))*t),
int(float64(ag)+(float64(bg)-float64(ag))*t),
int(float64(ab)+(float64(bb)-float64(ab))*t))
}
func hexRGB(s string) (int, int, int) {
var r, g, b int
_, _ = fmt.Sscanf(strings.TrimPrefix(s, "#"), "%02x%02x%02x", &r, &g, &b)
return r, g, b
}
+34
View File
@@ -0,0 +1,34 @@
// Package animation — ticker render loop 30 FPS & animasi ASCII (F-026).
package animation
import (
"sync/atomic"
"time"
tea "charm.land/bubbletea/v2"
)
// FPS dibatasi 30 sesuai F-026 (target CPU idle < 3%).
const FPS = 30
// FrameInterval — durasi satu frame (diekspor untuk perhitungan fisika/timer).
const FrameInterval = time.Second / FPS
// TickMsg dibawa tiap frame. ID mencegah tick basi ikut diproses
// saat loop di-restart (mis. animasi di-toggle).
type TickMsg struct {
ID int64
Time time.Time
}
var nextID atomic.Int64
// NewID membuat identitas baru untuk satu sesi loop ticker.
func NewID() int64 { return nextID.Add(1) }
// Next menjadwalkan frame berikutnya; panggil lagi setiap TickMsg diterima.
func Next(id int64) tea.Cmd {
return tea.Tick(FrameInterval, func(t time.Time) tea.Msg {
return TickMsg{ID: id, Time: t}
})
}
+52
View File
@@ -0,0 +1,52 @@
package animation
import (
"io"
"os"
"testing"
"time"
tea "charm.land/bubbletea/v2"
)
type countModel struct {
id int64
ticks int
stopAt time.Time
}
func (m *countModel) Init() tea.Cmd { return Next(m.id) }
func (m *countModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if _, ok := msg.(TickMsg); ok {
m.ticks++
if time.Now().After(m.stopAt) {
return m, tea.Quit
}
return m, Next(m.id)
}
return m, nil
}
func (m *countModel) View() tea.View { return tea.NewView("tick") }
// TestTickerRate mengukur laju tick 30 FPS secara headless (spike T-0.4):
//
// TIA_ANIM_SPIKE=1 go test ./internal/ui/animation/ -run TestTickerRate -v -count=1
func TestTickerRate(t *testing.T) {
if os.Getenv("TIA_ANIM_SPIKE") != "1" {
t.Skip("set TIA_ANIM_SPIKE=1 untuk menjalankan")
}
m := &countModel{id: NewID(), stopAt: time.Now().Add(2 * time.Second)}
p := tea.NewProgram(m, tea.WithInput(nil), tea.WithOutput(io.Discard))
start := time.Now()
if _, err := p.Run(); err != nil {
t.Fatalf("program gagal: %v", err)
}
dur := time.Since(start)
fps := float64(m.ticks) / dur.Seconds()
t.Logf("ticks=%d durasi=%v → %.1f fps (target %d)", m.ticks, dur.Round(time.Millisecond), fps, FPS)
if fps < float64(FPS)*0.85 {
t.Errorf("laju tick terlalu rendah: %.1f fps", fps)
}
}
+121
View File
@@ -0,0 +1,121 @@
// Package components — komponen UI tia.
package components
import (
"math"
"strings"
"time"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/harmonica"
"github.com/oxpoe/tia/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")
}