Add media components 346-365; input.SlideBase, guarded Pad

An audio player with transport, scrubber and volume; an equalizer and
a visualizer; a playlist; a cassette, cartridge and record that turn
while they play and hold where they stop; piano keys, a step sequencer
and a tracker you can type into; a waveform, a 16:9 video frame, an
image compare, a gallery, a filmstrip, a record button and an FM tuner.

input exports SlideBase, the undrawn rail its sliders share; the
gallery treats exported Bases as building blocks, not components.
pixel's micro font gains '#' for notes like C#4. pixel.Pad and the
slide rail read input only while their base is enabled, so Inert or a
fresh Disabled stops a drag or a key already sent. AGENTS records why
a drawn container keeps the DISABLED description: MyGo paints a
disabled subtree at half opacity.
This commit is contained in:
ZacharyZhang-NY committed 2026-10-12 06:17:12 -04:00
1 parent 455f5f2a05
commit 0e21870b64
39 files changed
+3066 -10

No files matched your search

+185
View File
@@ -0,0 +1,185 @@
package media_test
import (
"bytes"
"log/slog"
"strings"
"testing"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/media"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
// inertView builds under Inert while *inert holds.
func inertView(w, h int, inert *bool, build func(c *ui.Context)) *ui.Tester {
clk := newWorld().clk
return ui.NewTester(func(c *ui.Context) {
pixel.Use(c, pixel.Settings{Palette: pixel.SpringDay, Now: clk.now, Inert: *inert})
build(c)
}, w, h)
}
func TestScrubberKeepsAFractionalEnd(t *testing.T) {
pos, total := time.Duration(0), 1500*time.Millisecond
tt := view(400, 100, newWorld(), func(c *ui.Context) {
media.Scrubber(c, "Seek", &pos, total, media.ScrubberOptions{})
})
click(t, tt, "Seek")
tt.Key(0, ui.KeyEnd)
tt.Frame()
if pos != total {
t.Fatalf("End on a 1.5 s clip: at %v", pos)
}
}
func TestControlsHoldStillUnderInert(t *testing.T) {
inert := false
level, muted := 1, false
var struck []string
tt := inertView(500, 300, &inert, func(c *ui.Context) {
ui.Column(c).Children(func() {
media.Volume(c, "Volume", &level, &muted, media.VolumeOptions{})
struck = append(struck, media.PianoKeys(c, "Piano", media.PianoKeysOptions{}).Struck...)
})
})
// A press that began before Inert does not drag on.
r := find(t, tt, "Volume")
tt.Press(r.X+2, r.Y+r.H/2)
inert = true
tt.Frame()
tt.Move(r.X+r.W-1, r.Y+r.H/2)
tt.Release(r.X+r.W-1, r.Y+r.H/2)
if level != 0 {
t.Fatalf("an inert Volume dragged to %d", level)
}
p := find(t, tt, "Piano")
tt.Press(p.X+8, p.Y+p.H-6)
tt.Release(p.X+8, p.Y+p.H-6)
if len(struck) != 0 {
t.Fatalf("an inert piano struck %q", struck)
}
}
func TestStepSequencerIgnoresKeysOnceDisabled(t *testing.T) {
steps := make([][16]bool, 1)
disabled := false
tt := view(700, 200, newWorld(), func(c *ui.Context) {
media.StepSequencer(c, "Beat", []string{"KICK"}, steps, media.StepSequencerOptions{Disabled: disabled})
})
click(t, tt, "Beat")
steps[0] = [16]bool{}
// Disabled lands in the frame that reads Enter.
disabled = true
tt.Key(0, ui.KeyEnter)
if steps[0] != ([16]bool{}) {
t.Fatalf("a disabled sequencer toggled %v", steps[0])
}
}
func TestStepSequencerKeepsItsCursorAsTracksGo(t *testing.T) {
tracks, steps := []string{"KICK", "HAT"}, make([][16]bool, 2)
tt := view(700, 200, newWorld(), func(c *ui.Context) {
media.StepSequencer(c, "Beat", tracks, steps, media.StepSequencerOptions{})
})
click(t, tt, "Beat")
tt.Key(0, ui.KeyEnd)
tracks, steps = tracks[:1], steps[:1]
tt.Frame()
tt.Key(0, ui.KeyEnter)
if !steps[0][15] {
t.Fatalf("the cursor did not settle on the last step left: %v", steps[0])
}
}
func TestImageCompareRejectsASplitOutside(t *testing.T) {
for _, s := range []float64{-0.1, 2} {
mustPanic(t, "split outside 0–1", func() {
view(300, 200, newWorld(), func(c *ui.Context) {
media.ImageCompare(c, "C", solid(2, 2, pixel.Ink), solid(2, 2, pixel.Ink), &s, media.ImageCompareOptions{})
})
})
}
}
func TestComposedControlsLogTheirOwnEvents(t *testing.T) {
var buf bytes.Buffer
old := slog.Default()
slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})))
defer slog.SetDefault(old)
pos, sel, rec := time.Duration(0), 0, false
tt := view(600, 400, newWorld(), func(c *ui.Context) {
ui.Column(c).Children(func() {
media.Scrubber(c, "Seek", &pos, time.Minute, media.ScrubberOptions{})
media.Playlist(c, "List", songs, 0, &sel, media.PlaylistOptions{Height: 60})
media.RecordButton(c, "Record", &rec, media.RecordButtonOptions{})
})
})
click(t, tt, "Seek")
click(t, tt, "Two, Bob")
tt.Key(0, ui.KeyEnter)
click(t, tt, "Record, 0:00")
for _, want := range []string{"8bit: scrubber", "8bit: playlist", "submitted=true", "8bit: record button"} {
if !strings.Contains(buf.String(), want) {
t.Errorf("no %q in the log:\n%s", want, buf.String())
}
}
}
func TestTrackerHoldsStillUnderInert(t *testing.T) {
inert := false
rows, cur := make([]media.Row, 4), 0
rows[1] = media.Row{Note: "E-4", Instrument: 1, Volume: 64}
tt := inertView(300, 200, &inert, func(c *ui.Context) {
media.Tracker(c, "Pattern", rows, &cur, media.TrackerOptions{})
})
click(t, tt, "Pattern")
tt.Key(0, ui.KeyHome)
// Inert lands in the frame that reads the keys.
inert = true
tt.Key(0, ui.KeyA)
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyDelete)
if rows[0].Note != "" || rows[1].Note != "E-4" || cur != 0 {
t.Fatalf("an inert tracker took keys: %+v, cursor %d", rows, cur)
}
}
func TestScrubberStepsAShortClip(t *testing.T) {
pos, n := time.Duration(0), 0
tt := view(400, 100, newWorld(), func(c *ui.Context) {
if media.Scrubber(c, "Seek", &pos, 5*time.Second, media.ScrubberOptions{}).Changed {
n++
}
})
click(t, tt, "Seek")
tt.Key(0, ui.KeyHome)
n = 0
tt.Key(0, ui.KeyRight)
if pos != 250*time.Millisecond || n != 1 {
t.Fatalf("Right on 5 s: at %v after %d changes", pos, n)
}
for range 19 {
tt.Key(0, ui.KeyRight)
}
if pos != 5*time.Second || n != 20 {
t.Fatalf("twenty Rights: at %v after %d changes", pos, n)
}
}
func TestPlaylistAndVideoFrameRejectBadSizes(t *testing.T) {
mustPanic(t, "a track of negative length", func() {
view(500, 300, newWorld(), func(c *ui.Context) {
s := 0
media.Playlist(c, "List", []media.Track{{Title: "Back", Artist: "Time", Length: -time.Second}}, -1, &s, media.PlaylistOptions{})
})
})
mustPanic(t, "a screen too narrow for its scrubber", func() {
view(500, 300, newWorld(), func(c *ui.Context) {
p, at := false, time.Duration(0)
media.VideoFrame(c, "Clip", &p, &at, time.Second, media.VideoFrameOptions{Width: 100}, func() {})
})
})
}