feat(animasi): T-0.4 spike lolos — ticker 30 FPS + toast harmonica, CPU idle 2.75% (F-026 awal)
This commit is contained in:
1 parent
b8d2595ca9
commit
e5d5dba023
11 files changed
+501
-12
No files matched your search
+68
-10
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
Reference in new issue
Block a user