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:
39 files changed
+3066
-10
No files matched your search
@@ -0,0 +1,65 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/layout"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
// Player is what an AudioPlayer holds: play, place and volume.
|
||||
type Player struct {
|
||||
Playing bool
|
||||
At time.Duration
|
||||
// Level is 0–8 volume bars.
|
||||
Level int
|
||||
Muted bool
|
||||
}
|
||||
|
||||
type AudioPlayerOptions struct {
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
type AudioPlayerResult struct {
|
||||
Element *ui.Element
|
||||
// Changed is play, seek or volume; others are presses.
|
||||
Changed, Stopped, Previous, Next bool
|
||||
}
|
||||
|
||||
// AudioPlayer shows a track over transport, scrubber and volume.
|
||||
func AudioPlayer(c *ui.Context, text string, t Track, pl *Player, o AudioPlayerOptions) AudioPlayerResult {
|
||||
k := pixel.K(c)
|
||||
var res AudioPlayerResult
|
||||
res.Element = layout.Panel(c, layout.PanelOptions{Style: pixel.Raised}, func() {
|
||||
// An LCD shows what plays, ♪ while it does.
|
||||
ui.Box(c).FillWidth().Height(k.D(2*pixel.LineHeight + 6)).Role(ui.RoleText).Label(t.Title + ", " + t.Artist).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
g := k.Canvas(p, r)
|
||||
g.Frame(0, 0, g.W, g.H, pixel.Sunken, pixel.Well)
|
||||
x := 4
|
||||
if pl.Playing {
|
||||
g.Text(x, 3, "♪", pixel.Accent, 1)
|
||||
x += 10
|
||||
}
|
||||
g.Text(x, 3, pixel.Wrap(t.Title, g.W-x-4, 1)[0], pixel.Ink, 1)
|
||||
g.Text(4, 3+pixel.LineHeight, pixel.Wrap(t.Artist, g.W-8, 1)[0], pixel.Faint, 1)
|
||||
})
|
||||
seek := Scrubber(c, k.Msg("seek"), &pl.At, t.Length, ScrubberOptions{Disabled: o.Disabled}).Changed
|
||||
ui.Row(c).Gap(k.D(8)).AlignItems(ui.Center).Children(func() {
|
||||
tr := Transport(c, text, &pl.Playing, TransportOptions{Disabled: o.Disabled})
|
||||
// Stop goes back to the start.
|
||||
if tr.Stopped {
|
||||
pl.At = 0
|
||||
}
|
||||
vol := Volume(c, k.Msg("volume"), &pl.Level, &pl.Muted, VolumeOptions{Disabled: o.Disabled}).Changed
|
||||
res.Changed = seek || tr.Changed || vol || tr.Stopped
|
||||
res.Stopped, res.Previous, res.Next = tr.Stopped, tr.Previous, tr.Next
|
||||
})
|
||||
}).Gap(k.D(4)).Role(ui.RoleGroup).Label(text)
|
||||
if res.Changed || res.Previous || res.Next {
|
||||
slog.Debug("8bit: audio player", "label", text, "track", t.Title, "playing", pl.Playing, "at", pl.At)
|
||||
}
|
||||
return res
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
type CartridgeOptions struct {
|
||||
// Title is up to two micro lines on the sticker.
|
||||
Title string
|
||||
// Art is the sticker's picture, up to 38 by 22.
|
||||
Art *pixel.Sprite
|
||||
}
|
||||
|
||||
// Cartridge is a game cart with a sticker: art, title.
|
||||
func Cartridge(c *ui.Context, text string, o CartridgeOptions) *ui.Element {
|
||||
k := pixel.K(c)
|
||||
if o.Art != nil && (o.Art.W > 38 || o.Art.H > 22) {
|
||||
panic("media: Cartridge Art is larger than 38 by 22")
|
||||
}
|
||||
title := words(strings.ToUpper(o.Title), 38)
|
||||
if len(title) > 2 {
|
||||
panic(fmt.Sprintf("media: Cartridge Title %q needs %d lines", o.Title, len(title)))
|
||||
}
|
||||
return art(c, k, text, 56, 64).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
g := k.Canvas(p, r)
|
||||
// A handheld cart's top right corner is cut.
|
||||
for y := range 64 {
|
||||
w := 56 - max(0, 6-y)
|
||||
g.HLine(0, y, w, pixel.Face)
|
||||
g.Dot(w-1, y, pixel.Edge)
|
||||
}
|
||||
g.HLine(0, 0, 50, pixel.Edge)
|
||||
g.HLine(0, 63, 56, pixel.Edge)
|
||||
g.VLine(0, 0, 64, pixel.Edge)
|
||||
g.HLine(1, 1, 49, pixel.Lit)
|
||||
g.VLine(1, 1, 62, pixel.Lit)
|
||||
g.HLine(1, 62, 54, pixel.Shade)
|
||||
for _, y := range []int{4, 6, 8} {
|
||||
g.HLine(5, y, 36, pixel.Shade)
|
||||
}
|
||||
g.Frame(6, 11, 44, 40, pixel.Sunken, pixel.Lit)
|
||||
g.Rect(8, 13, 40, 23, pixel.Accent)
|
||||
if o.Art != nil {
|
||||
g.Sprite(28-o.Art.W/2, 24-o.Art.H/2, o.Art, 0, pixel.AccentInk, 1)
|
||||
}
|
||||
for i, l := range title {
|
||||
g.Micro(28-pixel.MicroWidth(l)/2, 37+i*6+(2-len(title))*3, l, pixel.Ink, 1)
|
||||
}
|
||||
// An arrow pressed into the plastic: this end goes in.
|
||||
for i := range 4 {
|
||||
g.HLine(25+i, 54+i, 7-2*i, pixel.Shade)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// words fills micro lines w dots wide; long words panic.
|
||||
func words(s string, w int) []string {
|
||||
var lines []string
|
||||
for _, word := range strings.Fields(s) {
|
||||
if !pixel.MicroOK(word) || pixel.MicroWidth(word) > w {
|
||||
panic(fmt.Sprintf("media: %q does not fit %d dots", word, w))
|
||||
}
|
||||
if n := len(lines); n > 0 && pixel.MicroWidth(lines[n-1]+" "+word) <= w {
|
||||
lines[n-1] += " " + word
|
||||
continue
|
||||
}
|
||||
lines = append(lines, word)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
type CassetteOptions struct {
|
||||
// Title is written on the label in the micro font.
|
||||
Title string
|
||||
Playing bool
|
||||
// Progress, 0 to 1, is tape on the right reel.
|
||||
Progress float64
|
||||
}
|
||||
|
||||
// Cassette turns its reels while playing; tape winds right.
|
||||
func Cassette(c *ui.Context, text string, o CassetteOptions) *ui.Element {
|
||||
k := pixel.K(c)
|
||||
if !(o.Progress >= 0 && o.Progress <= 1) {
|
||||
panic(fmt.Sprintf("media: Cassette Progress %v outside 0–1", o.Progress))
|
||||
}
|
||||
title := strings.ToUpper(o.Title)
|
||||
if !pixel.MicroOK(title) || pixel.MicroWidth(title) > 52 {
|
||||
panic(fmt.Sprintf("media: Cassette Title %q does not fit the label", o.Title))
|
||||
}
|
||||
e := art(c, k, text, 64, 40)
|
||||
frame := turned(c, k, e, o.Playing, 8) % 2
|
||||
return e.Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
g := k.Canvas(p, r)
|
||||
body, line := dark(k)
|
||||
g.Frame(0, 0, 64, 40, pixel.Raised, pixel.Face)
|
||||
g.Rect(4, 3, 56, 26, pixel.Lit)
|
||||
g.Micro(7, 5, title, pixel.Ink, 1)
|
||||
g.Rect(4, 11, 56, 2, pixel.Accent)
|
||||
g.Rect(14, 14, 36, 13, body)
|
||||
// Packs trade tape as it plays.
|
||||
wound := int(math.Round(o.Progress * 3))
|
||||
for i, cx := range []int{23, 40} {
|
||||
pack := 3 + wound
|
||||
if i == 0 {
|
||||
pack = 6 - wound
|
||||
}
|
||||
g.Disc(cx, 20, pack, line)
|
||||
g.Disc(cx, 20, 2, pixel.Lit)
|
||||
for _, d := range [2][4][2]int{{{1, 0}, {-1, 0}, {0, 1}, {0, -1}}, {{1, 1}, {-1, -1}, {1, -1}, {-1, 1}}}[frame] {
|
||||
g.Dot(cx+d[0], 20+d[1], body)
|
||||
}
|
||||
}
|
||||
// The head opening, with its pins.
|
||||
for y := range 8 {
|
||||
g.HLine(18-y/2, 32+y, 28+y, pixel.Shade)
|
||||
}
|
||||
for _, x := range []int{21, 30, 33, 42} {
|
||||
g.Rect(x, 35, 2, 2, body)
|
||||
}
|
||||
for _, x := range []int{4, 59} {
|
||||
g.HLine(x-1, 34, 3, pixel.Shade)
|
||||
g.VLine(x, 33, 3, pixel.Shade)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
type EqualizerOptions struct {
|
||||
// Height is each fader's travel; zero means 64.
|
||||
Height int
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
type EqualizerResult struct {
|
||||
Element *ui.Element
|
||||
// Band is the band changed this frame, else -1.
|
||||
Band int
|
||||
}
|
||||
|
||||
// Equalizer sets each band's gain, ±12 dB, on faders.
|
||||
func Equalizer(c *ui.Context, text string, bands []string, gains []float64, o EqualizerOptions) EqualizerResult {
|
||||
k := pixel.K(c)
|
||||
if len(bands) == 0 || len(bands) != len(gains) {
|
||||
panic(fmt.Sprintf("media: Equalizer has %d bands and %d gains", len(bands), len(gains)))
|
||||
}
|
||||
for i, g := range gains {
|
||||
if !(g >= -12 && g <= 12) {
|
||||
panic(fmt.Sprintf("media: Equalizer band %q gain %v outside ±12", bands[i], g))
|
||||
}
|
||||
}
|
||||
res := EqualizerResult{Band: -1}
|
||||
res.Element = ui.Row(c).Gap(k.D(6)).Role(ui.RoleGroup).Label(text).Children(func() {
|
||||
for i, b := range bands {
|
||||
ui.Column(c).Gap(k.D(2)).AlignItems(ui.Center).Children(func() {
|
||||
if input.Fader(c, b, &gains[i], input.FaderOptions{Min: -12, Max: 12, Height: o.Height, Disabled: o.Disabled}).Changed {
|
||||
res.Band = i
|
||||
slog.Debug("8bit: equalizer", "label", text, "band", b, "gain", gains[i])
|
||||
}
|
||||
ui.Box(c).Size(k.D(pixel.MicroWidth(b)), k.D(5)).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
k.Canvas(p, r).Micro(0, 0, b, pixel.Faint, 1)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
return res
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
type FilmstripOptions struct {
|
||||
// Scale is dots per sprite dot, 1–4; zero means 2.
|
||||
Scale int
|
||||
}
|
||||
|
||||
// Filmstrip lays a sprite's frames on film with sprocket holes.
|
||||
func Filmstrip(c *ui.Context, text string, sp *pixel.Sprite, o FilmstripOptions) *ui.Element {
|
||||
k := pixel.K(c)
|
||||
s := scale("Filmstrip", o.Scale, 2)
|
||||
n := sp.Frames()
|
||||
fw, fh := sp.W*s+4, sp.H*s+2
|
||||
w, h := n*fw+4, fh+14
|
||||
return art(c, k, text, w, h).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
g := k.Canvas(p, r)
|
||||
body, line := dark(k)
|
||||
g.Rect(0, 0, w, h, body)
|
||||
for x := 2; x+3 <= w; x += 6 {
|
||||
g.Rect(x, 2, 3, 3, line)
|
||||
g.Rect(x, h-5, 3, 3, line)
|
||||
}
|
||||
for i := range n {
|
||||
x := 3 + i*fw
|
||||
g.Rect(x, 7, fw-2, fh, pixel.Lit)
|
||||
g.Sprite(x+1, 8, sp, i, pixel.Ink, s)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
type GalleryOptions struct {
|
||||
// Scale is the large picture's, 1–4; zero means 3.
|
||||
Scale int
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
type GalleryResult struct {
|
||||
Element *ui.Element
|
||||
Changed bool
|
||||
}
|
||||
|
||||
// Gallery shows the chosen picture large over its thumbnails.
|
||||
func Gallery(c *ui.Context, text string, pics []*pixel.Sprite, names []string, chosen *int, o GalleryOptions) GalleryResult {
|
||||
k := pixel.K(c)
|
||||
sameSize("Gallery", pics)
|
||||
if len(names) != len(pics) || *chosen < 0 || *chosen >= len(pics) {
|
||||
panic(fmt.Sprintf("media: Gallery chose %d of %d pictures with %d names", *chosen, len(pics), len(names)))
|
||||
}
|
||||
s := scale("Gallery", o.Scale, 3)
|
||||
pw, ph := pics[0].W, pics[0].H
|
||||
res := GalleryResult{}
|
||||
res.Element = ui.Column(c).Gap(k.D(4)).Children(func() {
|
||||
big := pics[*chosen]
|
||||
art(c, k, names[*chosen], pw*s+4, ph*s+4).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
g := k.Canvas(p, r)
|
||||
g.Frame(0, 0, g.W, g.H, pixel.Sunken, pixel.Well)
|
||||
g.Sprite(2, 2, big, 0, pixel.Ink, s)
|
||||
})
|
||||
old := *chosen
|
||||
// Arrows choose at once; the cursor is the choice.
|
||||
shell, base, st, _ := pixel.Pad(c, text, len(pics), len(pics), pw+6, ph+6, chosen, o.Disabled)
|
||||
base.Description(names[*chosen])
|
||||
if res.Changed = *chosen != old; res.Changed {
|
||||
slog.Debug("8bit: gallery", "label", text, "chosen", names[*chosen])
|
||||
}
|
||||
at := *chosen
|
||||
shell.Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
g := k.Canvas(p, r)
|
||||
for i, sp := range pics {
|
||||
x := i * (pw + 6)
|
||||
if i == at {
|
||||
g.Cursor(x, 0, pw+6, ph+6)
|
||||
}
|
||||
g.Sprite(x+3, 3, sp, 0, pixel.Ink, 1)
|
||||
}
|
||||
g.Finish(st)
|
||||
})
|
||||
})
|
||||
return res
|
||||
}
|
||||
@@ -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() {})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
type ImageCompareOptions struct {
|
||||
// Scale is dots per picture dot, 1–4; zero means 2.
|
||||
Scale int
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
type ImageCompareResult struct {
|
||||
Element *ui.Element
|
||||
Changed bool
|
||||
}
|
||||
|
||||
// ImageCompare shows before left of *split, after right.
|
||||
func ImageCompare(c *ui.Context, text string, before, after *pixel.Sprite, split *float64, o ImageCompareOptions) ImageCompareResult {
|
||||
k := pixel.K(c)
|
||||
sameSize("ImageCompare", []*pixel.Sprite{before, after})
|
||||
if !(*split >= 0 && *split <= 1) {
|
||||
panic(fmt.Sprintf("media: ImageCompare split %v outside 0–1", *split))
|
||||
}
|
||||
s := scale("ImageCompare", o.Scale, 2)
|
||||
w, h := before.W*s, before.H*s
|
||||
shell, st, changed := input.SlideBase(c, text, split, input.RailOptions{Disabled: o.Disabled})
|
||||
if changed {
|
||||
slog.Debug("8bit: image compare", "label", text, "split", *split)
|
||||
}
|
||||
at := *split
|
||||
shell.Size(k.D(w), k.D(h)).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
g := k.Canvas(p, r)
|
||||
x := int(math.Round(at * float64(w)))
|
||||
g.Sprite(0, 0, before, 0, pixel.Ink, s)
|
||||
g.Clip(x, 0, w-x, h, func() { g.Sprite(0, 0, after, 0, pixel.Ink, s) })
|
||||
g.VLine(min(x, w-1), 0, h, pixel.Edge)
|
||||
// The grip stays whole at either edge.
|
||||
gx := max(0, min(w-7, x-3))
|
||||
g.Frame(gx, h/2-5, 7, 11, pixel.Raised, pixel.Face)
|
||||
for _, d := range []int{-2, 0, 2} {
|
||||
g.HLine(gx+2, h/2+d, 3, pixel.Shade)
|
||||
}
|
||||
g.Finish(st)
|
||||
})
|
||||
return ImageCompareResult{Element: shell, Changed: changed}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
package media_test
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/media"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
func TestPianoKeysStrikeByPointerAndLetters(t *testing.T) {
|
||||
wd := newWorld()
|
||||
var struck []string
|
||||
disabled := false
|
||||
tt := view(300, 200, wd, func(c *ui.Context) {
|
||||
struck = append(struck, media.PianoKeys(c, "Piano", media.PianoKeysOptions{Disabled: disabled}).Struck...)
|
||||
})
|
||||
x, y, _, _ := dots(find(t, tt, "Piano"))
|
||||
px := func(dx, dy int) (float32, float32) { return float32(2*(x+dx) + 1), float32(2*(y+dy) + 1) }
|
||||
key := func(dx int) pixel.Slot { return at(tt.Image(), wd.pal, x+dx, y+10) }
|
||||
expect := func(what string, want ...string) {
|
||||
t.Helper()
|
||||
if !slices.Equal(struck, want) {
|
||||
t.Fatalf("%s struck %q, want %q", what, struck, want)
|
||||
}
|
||||
struck = nil
|
||||
}
|
||||
tt.Press(px(4, 35))
|
||||
expect("pressing C", "C4")
|
||||
if key(1) != pixel.AccentLit {
|
||||
t.Fatal("a held key does not light")
|
||||
}
|
||||
// Sliding along strikes each new key once.
|
||||
tt.Move(px(14, 35))
|
||||
tt.Move(px(15, 35))
|
||||
expect("sliding to D", "D4")
|
||||
tt.Release(px(15, 35))
|
||||
tt.Press(px(9, 10))
|
||||
tt.Release(px(9, 10))
|
||||
expect("a black key on top", "C#4")
|
||||
for _, k := range []struct {
|
||||
key ui.Key
|
||||
note string
|
||||
}{{ui.KeyA, "C4"}, {ui.KeyW, "C#4"}, {ui.KeyH, "A4"}, {ui.KeyK, "C5"}} {
|
||||
tt.Key(0, k.key)
|
||||
expect("typing", k.note)
|
||||
}
|
||||
// A typed key lights for a quarter second.
|
||||
if key(71) != pixel.AccentLit {
|
||||
t.Fatal("typed K does not light C5")
|
||||
}
|
||||
later(tt, wd, time.Second/4+time.Millisecond)
|
||||
if key(71) != pixel.Lit {
|
||||
t.Fatal("typed C5 stays lit")
|
||||
}
|
||||
disabled = true
|
||||
tt.Frame()
|
||||
tt.Key(0, ui.KeyA)
|
||||
tt.Press(px(4, 35))
|
||||
tt.Release(px(4, 35))
|
||||
expect("disabled")
|
||||
}
|
||||
|
||||
func TestStepSequencerTogglesAndPlays(t *testing.T) {
|
||||
wd := newWorld()
|
||||
steps := make([][16]bool, 2)
|
||||
steps[0][0] = true
|
||||
playing, changed := false, false
|
||||
var step int
|
||||
tt := view(700, 200, wd, func(c *ui.Context) {
|
||||
r := media.StepSequencer(c, "Beat", []string{"KICK", "HAT"}, steps, media.StepSequencerOptions{Playing: playing})
|
||||
changed, step = r.Changed || changed, r.Step
|
||||
})
|
||||
x, y, _, _ := dots(find(t, tt, "Beat"))
|
||||
cell := func(col, row int) pixel.Slot { return at(tt.Image(), wd.pal, x+col*12+5, y+row*12+5) }
|
||||
tt.ClickAt(float32(2*(x+4*12+6)), float32(2*(y+12+6)))
|
||||
if !steps[1][4] || !changed || cell(4, 1) != pixel.Warn {
|
||||
t.Fatalf("clicking HAT 5: %v, changed %v; the second beat is Warn", steps[1], changed)
|
||||
}
|
||||
tt.Key(0, ui.KeyRight)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if !steps[1][5] {
|
||||
t.Fatal("Right then Enter did not toggle HAT 6")
|
||||
}
|
||||
led := func(i int) pixel.Slot { return at(tt.Image(), wd.pal, x+i*12+4, y-5) }
|
||||
if step != -1 || led(0) != pixel.Shade {
|
||||
t.Fatalf("stopped, the playhead is at %d", step)
|
||||
}
|
||||
// Playing starts at step one; the hit flashes Lit.
|
||||
playing = true
|
||||
tt.Frame()
|
||||
if step != 0 || led(0) != pixel.Bad || at(tt.Image(), wd.pal, x+5, y+5) != pixel.Lit {
|
||||
t.Fatalf("playing: step %d, LED %v", step, led(0))
|
||||
}
|
||||
// At 120 BPM, four steps a beat: 125 ms each.
|
||||
later(tt, wd, 125*time.Millisecond)
|
||||
if step != 1 || led(1) != pixel.Bad || led(0) != pixel.Shade {
|
||||
t.Fatalf("after 125 ms: step %d", step)
|
||||
}
|
||||
later(tt, wd, 2*time.Second)
|
||||
if step != 1 {
|
||||
t.Fatalf("sixteen steps on: step %d, want 1 again", step)
|
||||
}
|
||||
playing = false
|
||||
tt.Frame()
|
||||
playing = true
|
||||
later(tt, wd, time.Millisecond)
|
||||
if step != 0 {
|
||||
t.Fatalf("play again starts over, at %d", step)
|
||||
}
|
||||
mustPanic(t, "tracks without rows", func() {
|
||||
view(700, 200, newWorld(), func(c *ui.Context) {
|
||||
media.StepSequencer(c, "Beat", []string{"A", "B"}, make([][16]bool, 1), media.StepSequencerOptions{})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestStepSequencerRunsUnderReduceMotion(t *testing.T) {
|
||||
wd := newWorld()
|
||||
wd.reduce = true
|
||||
var step int
|
||||
tt := view(700, 200, wd, func(c *ui.Context) {
|
||||
step = media.StepSequencer(c, "Beat", []string{"KICK"}, make([][16]bool, 1), media.StepSequencerOptions{Playing: true, Tempo: 60}).Step
|
||||
})
|
||||
later(tt, wd, 250*time.Millisecond)
|
||||
if step != 1 {
|
||||
t.Fatalf("at 60 BPM a step is 250 ms; reduced, step %d", step)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrackerWritesNotesAndRests(t *testing.T) {
|
||||
wd := newWorld()
|
||||
rows := make([]media.Row, 8)
|
||||
rows[0] = media.Row{Note: "E-4", Instrument: 3, Volume: 40}
|
||||
cur, changed := 0, false
|
||||
tt := view(300, 200, wd, func(c *ui.Context) {
|
||||
changed = media.Tracker(c, "Pattern", rows, &cur, media.TrackerOptions{}).Changed || changed
|
||||
})
|
||||
click(t, tt, "Pattern")
|
||||
tt.Key(0, ui.KeyHome)
|
||||
// S plays D; notes keep the row's voice, step down.
|
||||
tt.Key(0, ui.KeyS)
|
||||
if rows[0] != (media.Row{Note: "D-4", Instrument: 3, Volume: 40}) || cur != 1 || !changed {
|
||||
t.Fatalf("S on row 0: %+v, cursor %d, changed %v", rows[0], cur, changed)
|
||||
}
|
||||
// A rest borrows the voice above.
|
||||
tt.Key(0, ui.KeyW)
|
||||
if rows[1] != (media.Row{Note: "C#4", Instrument: 3, Volume: 40}) || cur != 2 {
|
||||
t.Fatalf("W on row 1: %+v, cursor %d", rows[1], cur)
|
||||
}
|
||||
tt.Key(0, ui.KeyUp)
|
||||
tt.Key(0, ui.KeyDelete)
|
||||
if rows[1] != (media.Row{}) || cur != 1 {
|
||||
t.Fatalf("Delete: %+v, cursor %d", rows[1], cur)
|
||||
}
|
||||
tt.Key(0, ui.KeyEnd)
|
||||
tt.Key(0, ui.KeyK)
|
||||
if rows[7].Note != "C-5" || cur != 7 {
|
||||
t.Fatalf("K on the last row: %+v, cursor %d", rows[7], cur)
|
||||
}
|
||||
x, y, _, _ := dots(find(t, tt, "Pattern"))
|
||||
img := tt.Image()
|
||||
// The cursor row is Accent; every fourth row is Face.
|
||||
if at(img, wd.pal, x+1, y+7*7+3) != pixel.Accent || at(img, wd.pal, x+1, y+4*7+3) != pixel.Face ||
|
||||
at(img, wd.pal, x+1, y+3*7+3) != pixel.Well || at(img, wd.pal, x+1, y+2*7+3) != pixel.Well {
|
||||
t.Fatal("cursor and beat rows are not marked")
|
||||
}
|
||||
for name, bad := range map[string]media.Row{
|
||||
"a note off the scale": {Note: "H-4", Instrument: 1},
|
||||
"no instrument": {Note: "C-4"},
|
||||
"too loud": {Note: "C-4", Instrument: 1, Volume: 65},
|
||||
} {
|
||||
mustPanic(t, name, func() {
|
||||
view(300, 200, newWorld(), func(c *ui.Context) {
|
||||
r := 0
|
||||
media.Tracker(c, "P", []media.Row{bad}, &r, media.TrackerOptions{})
|
||||
})
|
||||
})
|
||||
}
|
||||
mustPanic(t, "a cursor past the rows", func() {
|
||||
view(300, 200, newWorld(), func(c *ui.Context) {
|
||||
r := 1
|
||||
media.Tracker(c, "P", []media.Row{{}}, &r, media.TrackerOptions{})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestTrackerStartsAnEmptyPatternAtFullVolume(t *testing.T) {
|
||||
rows := make([]media.Row, 4)
|
||||
cur := 0
|
||||
tt := view(300, 200, newWorld(), func(c *ui.Context) {
|
||||
media.Tracker(c, "Pattern", rows, &cur, media.TrackerOptions{})
|
||||
})
|
||||
click(t, tt, "Pattern")
|
||||
tt.Key(0, ui.KeyHome)
|
||||
tt.Key(0, ui.KeyA)
|
||||
if rows[0] != (media.Row{Note: "C-4", Instrument: 1, Volume: 64}) {
|
||||
t.Fatalf("A on an empty pattern: %+v", rows[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunerSetsWhatThePointerPoints(t *testing.T) {
|
||||
wd := newWorld()
|
||||
mhz, changed := 98.3, false
|
||||
tt := view(500, 200, wd, func(c *ui.Context) {
|
||||
changed = media.Tuner(c, "Radio", &mhz, media.TunerOptions{}).Changed || changed
|
||||
})
|
||||
r := find(t, tt, "Radio")
|
||||
x, y, _, _ := dots(r)
|
||||
// 98.3 is 10.8 of 20.5 MHz: dot 84 of 160.
|
||||
if at(tt.Image(), wd.pal, x+84, y+10) != pixel.Bad {
|
||||
t.Fatal("the needle is not at 98.3")
|
||||
}
|
||||
// The 100 tick is dot 97; pressing it tunes 100.
|
||||
tt.Press(r.X+2*97+1, r.Y+r.H/2)
|
||||
tt.Release(r.X+2*97+1, r.Y+r.H/2)
|
||||
if mhz != 100 || !changed {
|
||||
t.Fatalf("pressing the 100 tick tuned %v", mhz)
|
||||
}
|
||||
tt.Key(0, ui.KeyRight)
|
||||
if mhz != 100.1 {
|
||||
t.Fatalf("Right tuned %v, want 100.1 exactly", mhz)
|
||||
}
|
||||
tt.Key(0, ui.KeyEnd)
|
||||
changed = false
|
||||
tt.Key(0, ui.KeyRight)
|
||||
if mhz != 108 || changed {
|
||||
t.Fatalf("Right at the top: %v, changed %v", mhz, changed)
|
||||
}
|
||||
mustPanic(t, "AM on an FM dial", func() {
|
||||
view(500, 200, newWorld(), func(c *ui.Context) {
|
||||
f := 1.0
|
||||
media.Tuner(c, "Radio", &f, media.TunerOptions{})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestRecordButtonBlinksAndTimesTheTake(t *testing.T) {
|
||||
for _, reduce := range []bool{false, true} {
|
||||
wd := newWorld()
|
||||
wd.reduce = reduce
|
||||
rec, changed := false, false
|
||||
tt := view(300, 100, wd, func(c *ui.Context) {
|
||||
changed = media.RecordButton(c, "Record", &rec, media.RecordButtonOptions{}).Changed || changed
|
||||
})
|
||||
red := func(label string) int {
|
||||
x, y, w, h := dots(find(t, tt, label))
|
||||
return count(tt.Image(), wd.pal, x, y, w, h, pixel.Bad)
|
||||
}
|
||||
if red("Record, 0:00") != 37 {
|
||||
t.Fatal("idle, the dot is not whole")
|
||||
}
|
||||
click(t, tt, "Record, 0:00")
|
||||
if !rec || !changed {
|
||||
t.Fatalf("click: rec %v, changed %v", rec, changed)
|
||||
}
|
||||
// Half a second on, the dot blinks off unless reduced.
|
||||
later(tt, wd, time.Second/2+time.Millisecond)
|
||||
if n := red("Record, 0:00"); (n == 0) == reduce {
|
||||
t.Fatalf("reduce %v: %d red dots half a second in", reduce, n)
|
||||
}
|
||||
later(tt, wd, time.Second/2)
|
||||
find(t, tt, "Record, 0:01")
|
||||
click(t, tt, "Record, 0:01")
|
||||
later(tt, wd, 3*time.Second)
|
||||
if rec || red("Record, 0:01") != 37 {
|
||||
t.Fatal("stopped, the take's time and a whole dot stay")
|
||||
}
|
||||
// The caller may start it too, in a frame.
|
||||
rec = true
|
||||
tt.Frame()
|
||||
later(tt, wd, time.Second)
|
||||
find(t, tt, "Record, 0:01")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// Package media plays, shows and records sound and pictures.
|
||||
package media
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
// Track is a song: title, artist and length.
|
||||
type Track struct {
|
||||
Title, Artist string
|
||||
Length time.Duration
|
||||
}
|
||||
|
||||
// mmss prints d as m:ss, or h:mm:ss past an hour.
|
||||
func mmss(d time.Duration) string {
|
||||
s := int(d / time.Second)
|
||||
if s >= 3600 {
|
||||
return fmt.Sprintf("%d:%02d:%02d", s/3600, s/60%60, s%60)
|
||||
}
|
||||
return fmt.Sprintf("%d:%02d", s/60, s%60)
|
||||
}
|
||||
|
||||
// prev is Next turned around.
|
||||
var prev = icon.Next.Mirror()
|
||||
|
||||
// scale is s, or def for zero; above 4 panics.
|
||||
func scale(name string, s, def int) int {
|
||||
s = pixel.Or(name+" Scale", s, def)
|
||||
if s > 4 {
|
||||
panic(fmt.Sprintf("media: %s Scale %d outside 1–4", name, s))
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// art is a named picture w by h dots.
|
||||
func art(c *ui.Context, k pixel.Kit, label string, w, h int) *ui.Element {
|
||||
if label == "" {
|
||||
panic("media: a picture needs a name")
|
||||
}
|
||||
return ui.Box(c).Size(k.D(w), k.D(h)).Label(label).Role(ui.RoleImage)
|
||||
}
|
||||
|
||||
// dark is the darkest slot and a line on it.
|
||||
func dark(k pixel.Kit) (body, line pixel.Slot) {
|
||||
if k.Palette().Night {
|
||||
return pixel.Well, pixel.Face
|
||||
}
|
||||
return pixel.Ink, pixel.Faint
|
||||
}
|
||||
|
||||
// sameSize panics unless every sprite matches the first.
|
||||
func sameSize(name string, sps []*pixel.Sprite) {
|
||||
if len(sps) == 0 {
|
||||
panic("media: " + name + " needs pictures")
|
||||
}
|
||||
for _, sp := range sps {
|
||||
if sp.W != sps[0].W || sp.H != sps[0].H {
|
||||
panic(fmt.Sprintf("media: %s mixes %d×%d and %d×%d pictures", name, sps[0].W, sps[0].H, sp.W, sp.H))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type turnKey struct{}
|
||||
|
||||
// turn is steps taken, their first frame, and play.
|
||||
type turn struct {
|
||||
held, from int
|
||||
playing bool
|
||||
}
|
||||
|
||||
// turned counts steps while playing; stopped, it holds still.
|
||||
func turned(c *ui.Context, k pixel.Kit, e *ui.Element, playing bool, fps int) int {
|
||||
s := ui.Local(e, turnKey{}, func() turn { return turn{} })
|
||||
on := playing && !k.Reduce()
|
||||
if on {
|
||||
n := k.Tick(c, fps)
|
||||
if !s.playing {
|
||||
s.from = n - s.held
|
||||
}
|
||||
s.held = n - s.from
|
||||
}
|
||||
s.playing = on
|
||||
return s.held
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
package media_test
|
||||
|
||||
import (
|
||||
"image"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/media"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
type clock struct{ t time.Time }
|
||||
|
||||
func (c *clock) now() time.Time { return c.t }
|
||||
|
||||
// world is the settings a test view runs under.
|
||||
type world struct {
|
||||
clk *clock
|
||||
reduce bool
|
||||
pal *pixel.Palette
|
||||
}
|
||||
|
||||
func newWorld() *world {
|
||||
return &world{clk: &clock{time.Date(2026, 9, 2, 10, 30, 0, 0, time.UTC)}, pal: pixel.SpringDay}
|
||||
}
|
||||
|
||||
// view builds on a fake clock, two pixels a dot.
|
||||
func view(w, h int, wd *world, build func(c *ui.Context)) *ui.Tester {
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
pixel.Use(c, pixel.Settings{Palette: wd.pal, Now: wd.clk.now})
|
||||
build(c)
|
||||
}, w, h)
|
||||
if wd.reduce {
|
||||
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
|
||||
tt.Frame()
|
||||
}
|
||||
return tt
|
||||
}
|
||||
|
||||
// later moves the clock on and builds a frame.
|
||||
func later(tt *ui.Tester, wd *world, d time.Duration) {
|
||||
wd.clk.t = wd.clk.t.Add(d)
|
||||
tt.Frame()
|
||||
}
|
||||
|
||||
func click(t *testing.T, tt *ui.Tester, label string) {
|
||||
t.Helper()
|
||||
if err := tt.Click(label); err != nil {
|
||||
t.Fatalf("%v in %q", err, tt.Texts())
|
||||
}
|
||||
}
|
||||
|
||||
func find(t *testing.T, tt *ui.Tester, label string) ui.Rect {
|
||||
t.Helper()
|
||||
r, ok := tt.Find(label)
|
||||
if !ok {
|
||||
t.Fatalf("no %q in %q", label, tt.Texts())
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// none is a color outside the palette.
|
||||
const none pixel.Slot = 255
|
||||
|
||||
// at is the slot at dot x, y, or none.
|
||||
func at(img *image.RGBA, pal *pixel.Palette, x, y int) pixel.Slot {
|
||||
c := img.RGBAAt(2*x, 2*y)
|
||||
for s, want := range pal.Colors {
|
||||
if c.R == want.R && c.G == want.G && c.B == want.B {
|
||||
return pixel.Slot(s)
|
||||
}
|
||||
}
|
||||
return none
|
||||
}
|
||||
|
||||
// count is how many dots in a box hold s.
|
||||
func count(img *image.RGBA, pal *pixel.Palette, x, y, w, h int, s pixel.Slot) int {
|
||||
n := 0
|
||||
for j := y; j < y+h; j++ {
|
||||
for i := x; i < x+w; i++ {
|
||||
if at(img, pal, i, j) == s {
|
||||
n++
|
||||
}
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// dots is a found box in dots.
|
||||
func dots(r ui.Rect) (x, y, w, h int) {
|
||||
return int(r.X / 2), int(r.Y / 2), int(r.W / 2), int(r.H / 2)
|
||||
}
|
||||
|
||||
func mustPanic(t *testing.T, name string, fn func()) {
|
||||
t.Helper()
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Errorf("%s did not panic", name)
|
||||
}
|
||||
}()
|
||||
fn()
|
||||
}
|
||||
|
||||
func TestTransportPlaysStopsAndSkips(t *testing.T) {
|
||||
wd := newWorld()
|
||||
playing := false
|
||||
var got media.TransportResult
|
||||
tt := view(400, 100, wd, func(c *ui.Context) {
|
||||
if r := media.Transport(c, "Deck", &playing, media.TransportOptions{}); r.Changed || r.Stopped || r.Previous || r.Next {
|
||||
got = r
|
||||
}
|
||||
})
|
||||
// ▶ has 50 ink dots; ❚❚ has 60.
|
||||
ink := func() int {
|
||||
x, y, w, h := dots(find(t, tt, "PLAY"))
|
||||
return count(tt.Image(), wd.pal, x, y, w, h, pixel.Ink)
|
||||
}
|
||||
if n := ink(); n != 50 {
|
||||
t.Fatalf("stopped, PLAY shows %d ink dots, want ▶'s 50", n)
|
||||
}
|
||||
click(t, tt, "PLAY")
|
||||
if !playing || !got.Changed || ink() != 60 {
|
||||
t.Fatalf("PLAY: playing %v, %+v, %d ink dots", playing, got, ink())
|
||||
}
|
||||
got = media.TransportResult{}
|
||||
click(t, tt, "STOP")
|
||||
if playing || !got.Stopped || !got.Changed {
|
||||
t.Fatalf("STOP: playing %v, %+v", playing, got)
|
||||
}
|
||||
got = media.TransportResult{}
|
||||
click(t, tt, "STOP")
|
||||
if !got.Stopped || got.Changed {
|
||||
t.Fatalf("STOP while stopped: %+v; nothing changed", got)
|
||||
}
|
||||
for _, b := range []string{"PREVIOUS", "NEXT"} {
|
||||
got = media.TransportResult{}
|
||||
click(t, tt, b)
|
||||
if got.Previous != (b == "PREVIOUS") || got.Next != (b == "NEXT") || got.Changed {
|
||||
t.Fatalf("%s: %+v", b, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScrubberSeeksByPressAndKeys(t *testing.T) {
|
||||
wd := newWorld()
|
||||
pos, total := 72*time.Second, 192*time.Second
|
||||
changed := false
|
||||
tt := view(400, 100, wd, func(c *ui.Context) {
|
||||
changed = media.Scrubber(c, "Seek", &pos, total, media.ScrubberOptions{Width: 96}).Changed || changed
|
||||
})
|
||||
find(t, tt, "1:12")
|
||||
find(t, tt, "3:12")
|
||||
r := find(t, tt, "Seek")
|
||||
tt.Press(r.X+r.W/2, r.Y+r.H/2)
|
||||
tt.Release(r.X+r.W/2, r.Y+r.H/2)
|
||||
if !changed || pos != total/2 || !tt.HasText("1:36") {
|
||||
t.Fatalf("pressing the middle: changed %v, at %v; want %v", changed, pos, total/2)
|
||||
}
|
||||
tt.Key(0, ui.KeyHome)
|
||||
if pos != 0 || !tt.HasText("0:00") {
|
||||
t.Fatalf("Home: at %v, %q", pos, tt.Texts())
|
||||
}
|
||||
tt.Key(0, ui.KeyRight)
|
||||
if pos != 9600*time.Millisecond {
|
||||
t.Fatalf("Right steps a twentieth of 3:12, 9.6 s: %v", pos)
|
||||
}
|
||||
mustPanic(t, "a place past the end", func() {
|
||||
view(400, 100, newWorld(), func(c *ui.Context) {
|
||||
p := total + time.Second
|
||||
media.Scrubber(c, "Seek", &p, total, media.ScrubberOptions{})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestVolumeStepsMutesAndLights(t *testing.T) {
|
||||
wd := newWorld()
|
||||
level, muted := 5, false
|
||||
changed := false
|
||||
tt := view(400, 100, wd, func(c *ui.Context) {
|
||||
changed = media.Volume(c, "Volume", &level, &muted, media.VolumeOptions{}).Changed || changed
|
||||
})
|
||||
bars := func() []pixel.Slot {
|
||||
img := tt.Image()
|
||||
x, y, _, h := dots(find(t, tt, "Volume"))
|
||||
var out []pixel.Slot
|
||||
for i := range 8 {
|
||||
out = append(out, at(img, wd.pal, x+4*i+1, y+h-2))
|
||||
}
|
||||
return out
|
||||
}
|
||||
want := []pixel.Slot{pixel.Good, pixel.Good, pixel.Good, pixel.Good, pixel.Good, pixel.Well, pixel.Well, pixel.Well}
|
||||
if got := bars(); !slices.Equal(got, want) {
|
||||
t.Fatalf("level 5 lights %v, want %v", got, want)
|
||||
}
|
||||
r := find(t, tt, "Volume")
|
||||
tt.Press(r.X+r.W-1, r.Y+r.H/2)
|
||||
tt.Release(r.X+r.W-1, r.Y+r.H/2)
|
||||
if level != 8 || !changed {
|
||||
t.Fatalf("pressing the top bar: level %d, changed %v", level, changed)
|
||||
}
|
||||
if got := bars(); got[5] != pixel.Good || got[6] != pixel.Warn || got[7] != pixel.Warn {
|
||||
t.Fatalf("the loud quarter is Warn: %v", got)
|
||||
}
|
||||
tt.Key(0, ui.KeyLeft)
|
||||
if level != 7 {
|
||||
t.Fatalf("Left: level %d, want 7", level)
|
||||
}
|
||||
changed = false
|
||||
click(t, tt, "MUTE")
|
||||
if !muted || !changed || bars()[0] != pixel.Faint {
|
||||
t.Fatalf("MUTE: muted %v, changed %v, bars %v", muted, changed, bars())
|
||||
}
|
||||
mustPanic(t, "level above the bars", func() {
|
||||
view(400, 100, newWorld(), func(c *ui.Context) {
|
||||
l, m := 9, false
|
||||
media.Volume(c, "Volume", &l, &m, media.VolumeOptions{})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestEqualizerReportsTheBand(t *testing.T) {
|
||||
wd := newWorld()
|
||||
gains := []float64{0, 0, 0}
|
||||
band := -1
|
||||
tt := view(400, 200, wd, func(c *ui.Context) {
|
||||
if b := media.Equalizer(c, "EQ", []string{"BASS", "MID", "HIGH"}, gains, media.EqualizerOptions{}).Band; b >= 0 {
|
||||
band = b
|
||||
}
|
||||
})
|
||||
click(t, tt, "MID")
|
||||
tt.Key(0, ui.KeyUp)
|
||||
if band != 1 || gains[1] <= 1 || gains[0] != 0 || gains[2] != 0 {
|
||||
t.Fatalf("Up on MID: band %d, gains %v", band, gains)
|
||||
}
|
||||
mustPanic(t, "gain past 12 dB", func() {
|
||||
view(400, 200, newWorld(), func(c *ui.Context) {
|
||||
media.Equalizer(c, "EQ", []string{"A"}, []float64{13}, media.EqualizerOptions{})
|
||||
})
|
||||
})
|
||||
mustPanic(t, "bands without gains", func() {
|
||||
view(400, 200, newWorld(), func(c *ui.Context) {
|
||||
media.Equalizer(c, "EQ", []string{"A", "B"}, []float64{0}, media.EqualizerOptions{})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
var songs = []media.Track{
|
||||
{Title: "One", Artist: "Ann", Length: 61 * time.Second},
|
||||
{Title: "Two", Artist: "Bob", Length: 3723 * time.Second},
|
||||
{Title: "Six", Artist: "Cy", Length: 9 * time.Second},
|
||||
}
|
||||
|
||||
func TestPlaylistMarksPlayingAndSubmits(t *testing.T) {
|
||||
wd := newWorld()
|
||||
sel := 0
|
||||
var got media.PlaylistResult
|
||||
tt := view(500, 300, wd, func(c *ui.Context) {
|
||||
if r := media.Playlist(c, "List", songs, 2, &sel, media.PlaylistOptions{}); r.Changed || r.Submitted {
|
||||
got = r
|
||||
}
|
||||
})
|
||||
find(t, tt, "Six, Cy, PLAYING")
|
||||
click(t, tt, "Two, Bob")
|
||||
if sel != 1 || !got.Changed {
|
||||
t.Fatalf("clicking Two: selected %d, %+v", sel, got)
|
||||
}
|
||||
got = media.PlaylistResult{}
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if !got.Submitted {
|
||||
t.Fatalf("Enter did not submit: %+v", got)
|
||||
}
|
||||
// Past the ♪, the playing row's title is Accent too.
|
||||
x, y, _, _ := dots(find(t, tt, "Six, Cy, PLAYING"))
|
||||
if count(tt.Image(), wd.pal, x+27, y, 30, 12, pixel.Accent) == 0 {
|
||||
t.Fatal("the playing title is not Accent")
|
||||
}
|
||||
mustPanic(t, "playing past the end", func() {
|
||||
view(500, 300, newWorld(), func(c *ui.Context) {
|
||||
s := 0
|
||||
media.Playlist(c, "List", songs, 3, &s, media.PlaylistOptions{})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestAudioPlayerStopRewinds(t *testing.T) {
|
||||
wd := newWorld()
|
||||
pl := media.Player{At: 30 * time.Second, Level: 4}
|
||||
var got media.AudioPlayerResult
|
||||
tt := view(600, 300, wd, func(c *ui.Context) {
|
||||
if r := media.AudioPlayer(c, "Deck", songs[1], &pl, media.AudioPlayerOptions{}); r.Changed || r.Previous || r.Next {
|
||||
got = r
|
||||
}
|
||||
})
|
||||
find(t, tt, "Two, Bob")
|
||||
find(t, tt, "1:02:03")
|
||||
click(t, tt, "PLAY")
|
||||
if !pl.Playing || !got.Changed {
|
||||
t.Fatalf("PLAY: %+v, %+v", pl, got)
|
||||
}
|
||||
got = media.AudioPlayerResult{}
|
||||
click(t, tt, "STOP")
|
||||
if pl.Playing || pl.At != 0 || !got.Stopped || !got.Changed {
|
||||
t.Fatalf("STOP did not end play and rewind: %+v, %+v", pl, got)
|
||||
}
|
||||
got = media.AudioPlayerResult{}
|
||||
click(t, tt, "NEXT")
|
||||
if !got.Next || got.Changed {
|
||||
t.Fatalf("NEXT: %+v", got)
|
||||
}
|
||||
got = media.AudioPlayerResult{}
|
||||
click(t, tt, "MUTE")
|
||||
if !pl.Muted || !got.Changed {
|
||||
t.Fatalf("MUTE: %+v, %+v", pl, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVideoFrameShowsTheScreenAndSeeks(t *testing.T) {
|
||||
wd := newWorld()
|
||||
playing, pos := false, time.Duration(0)
|
||||
changed := false
|
||||
tt := view(600, 400, wd, func(c *ui.Context) {
|
||||
screen := func() { ui.Box(c).Size(400, 400).Label("film").Role(ui.RoleImage) }
|
||||
changed = media.VideoFrame(c, "Clip", &playing, &pos, 20*time.Second, media.VideoFrameOptions{}, screen).Changed || changed
|
||||
})
|
||||
// The screen, 160 by 90 dots, clips the film.
|
||||
if f := find(t, tt, "film"); f.H != 180 {
|
||||
t.Fatalf("the screen shows %v of the film, want 180 pixels tall", f)
|
||||
}
|
||||
click(t, tt, "PLAY")
|
||||
if !playing || !changed {
|
||||
t.Fatalf("PLAY: playing %v, changed %v", playing, changed)
|
||||
}
|
||||
changed = false
|
||||
r := find(t, tt, "SEEK")
|
||||
tt.Press(r.X+r.W/2, r.Y+r.H/2)
|
||||
tt.Release(r.X+r.W/2, r.Y+r.H/2)
|
||||
if pos != 10*time.Second || !changed {
|
||||
t.Fatalf("seeking the middle: at %v, changed %v", pos, changed)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
// keyboard plays C4–C5: A–K white, WETYU black.
|
||||
var keyboard = [13]struct {
|
||||
key ui.Key
|
||||
letter string
|
||||
}{
|
||||
{ui.KeyA, "A"}, {ui.KeyW, "W"}, {ui.KeyS, "S"}, {ui.KeyE, "E"}, {ui.KeyD, "D"}, {ui.KeyF, "F"}, {ui.KeyT, "T"},
|
||||
{ui.KeyG, "G"}, {ui.KeyY, "Y"}, {ui.KeyH, "H"}, {ui.KeyU, "U"}, {ui.KeyJ, "J"}, {ui.KeyK, "K"},
|
||||
}
|
||||
|
||||
var names = [12]string{"C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"}
|
||||
|
||||
// note names the semitone above C4, like "C#4".
|
||||
func note(semi int) string {
|
||||
return fmt.Sprintf("%s%d", names[semi%12], 4+semi/12)
|
||||
}
|
||||
|
||||
// played are the semitones whose letters e heard this frame.
|
||||
func played(e *ui.Element) []int {
|
||||
var out []int
|
||||
for semi, kb := range keyboard {
|
||||
if e.Shortcut(0, kb.key) {
|
||||
out = append(out, semi)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
type PianoKeysOptions struct {
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
type PianoKeysResult struct {
|
||||
Element *ui.Element
|
||||
// Struck are notes begun this frame, like "C#4".
|
||||
Struck []string
|
||||
}
|
||||
|
||||
type pianoKey struct{}
|
||||
|
||||
// hand is the key under the pointer and last typed.
|
||||
type hand struct {
|
||||
held, typed int
|
||||
at time.Time
|
||||
}
|
||||
|
||||
// A black key sits right of the white it names.
|
||||
var (
|
||||
whites = [8]int{0, 2, 4, 5, 7, 9, 11, 12}
|
||||
blacks = [5][2]int{{1, 0}, {3, 1}, {6, 3}, {8, 4}, {10, 5}}
|
||||
)
|
||||
|
||||
// PianoKeys plays C4–C5 by pointer, A–K and WETYU.
|
||||
func PianoKeys(c *ui.Context, text string, o PianoKeysOptions) PianoKeysResult {
|
||||
k := pixel.K(c)
|
||||
shell, base, st := pixel.Shell(c, func() *ui.Element {
|
||||
return ui.Box(c).Focusable().Role(ui.RoleGroup).Label(text).Disabled(o.Disabled)
|
||||
})
|
||||
hd := ui.Local(base, pianoKey{}, func() hand { return hand{held: -1, typed: -1} })
|
||||
var struck []int
|
||||
held := -1
|
||||
if !base.IsDisabled() {
|
||||
if base.Pressed() {
|
||||
x, y, _ := base.PointerPosition()
|
||||
held = keyAt(int(x/k.Dot()), int(y/k.Dot()))
|
||||
}
|
||||
// Sliding along the keys strikes each new one.
|
||||
if held >= 0 && held != hd.held {
|
||||
struck = append(struck, held)
|
||||
}
|
||||
if typed := played(base); len(typed) > 0 {
|
||||
struck = append(struck, typed...)
|
||||
hd.typed, hd.at = typed[len(typed)-1], k.Now()
|
||||
}
|
||||
}
|
||||
hd.held = held
|
||||
lit := hd.held
|
||||
if k.Since(c, hd.at, 8, 2) < 2 {
|
||||
lit = hd.typed
|
||||
}
|
||||
res := PianoKeysResult{Element: shell}
|
||||
for _, s := range struck {
|
||||
res.Struck = append(res.Struck, note(s))
|
||||
}
|
||||
if len(res.Struck) > 0 {
|
||||
base.Description(res.Struck[len(res.Struck)-1])
|
||||
slog.Debug("8bit: piano keys", "label", text, "struck", res.Struck)
|
||||
}
|
||||
shell.Size(k.D(80), k.D(40)).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
g := k.Canvas(p, r)
|
||||
body, _ := dark(k)
|
||||
g.Rect(0, 0, 80, 40, pixel.Edge)
|
||||
for i, semi := range whites {
|
||||
fill := pixel.Lit
|
||||
if semi == lit {
|
||||
fill = pixel.AccentLit
|
||||
}
|
||||
g.Rect(i*10, 0, 9, 40, fill)
|
||||
g.HLine(i*10, 37, 9, pixel.Shade)
|
||||
g.Micro(i*10+3, 30, keyboard[semi].letter, pixel.Faint, 1)
|
||||
}
|
||||
for _, b := range blacks {
|
||||
x, fill := b[1]*10+7, body
|
||||
if b[0] == lit {
|
||||
fill = pixel.Accent
|
||||
}
|
||||
g.Rect(x, 0, 6, 24, fill)
|
||||
g.Micro(x+2, 17, keyboard[b[0]].letter, pixel.Faint, 1)
|
||||
}
|
||||
g.Finish(st)
|
||||
})
|
||||
return res
|
||||
}
|
||||
|
||||
// keyAt is the semitone at x, y; blacks on top.
|
||||
func keyAt(x, y int) int {
|
||||
if x < 0 || y < 0 || x >= 80 || y >= 40 {
|
||||
return -1
|
||||
}
|
||||
if y < 24 {
|
||||
for _, b := range blacks {
|
||||
if bx := b[1]*10 + 7; x >= bx && x < bx+6 {
|
||||
return b[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
return whites[x/10]
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package media_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/media"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
// step is one frame at 8 fps, plus a hair.
|
||||
const step = time.Second/8 + time.Microsecond
|
||||
|
||||
func TestCassetteReelsTurnOnlyWhilePlaying(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
playing, reduce bool
|
||||
turns bool
|
||||
}{
|
||||
{"stopped", false, false, false}, {"playing", true, false, true}, {"reduced", true, true, false},
|
||||
} {
|
||||
wd := newWorld()
|
||||
wd.reduce = tc.reduce
|
||||
tt := view(300, 200, wd, func(c *ui.Context) {
|
||||
media.Cassette(c, "Tape", media.CassetteOptions{Title: "Side A", Playing: tc.playing})
|
||||
})
|
||||
x, y, _, _ := dots(find(t, tt, "Tape"))
|
||||
// The left hub's spokes stand + then lean ×.
|
||||
plus := func() bool {
|
||||
img := tt.Image()
|
||||
return at(img, wd.pal, x+24, y+20) == pixel.Ink && at(img, wd.pal, x+24, y+21) == pixel.Lit
|
||||
}
|
||||
if !plus() {
|
||||
t.Fatalf("%s: the hub does not start upright", tc.name)
|
||||
}
|
||||
later(tt, wd, step)
|
||||
if plus() == tc.turns {
|
||||
t.Fatalf("%s: turned %v, want %v", tc.name, !plus(), tc.turns)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCassetteTapeCrossesOver(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
progress float64
|
||||
left, right pixel.Slot
|
||||
}{{0, pixel.Faint, pixel.Ink}, {1, pixel.Ink, pixel.Faint}} {
|
||||
wd := newWorld()
|
||||
tt := view(300, 200, wd, func(c *ui.Context) {
|
||||
media.Cassette(c, "Tape", media.CassetteOptions{Progress: tc.progress})
|
||||
})
|
||||
x, y, _, _ := dots(find(t, tt, "Tape"))
|
||||
img := tt.Image()
|
||||
// A full pack reaches five dots out; an empty, three.
|
||||
if l, r := at(img, wd.pal, x+18, y+20), at(img, wd.pal, x+45, y+20); l != tc.left || r != tc.right {
|
||||
t.Fatalf("progress %v: left %v, right %v; want %v, %v", tc.progress, l, r, tc.left, tc.right)
|
||||
}
|
||||
}
|
||||
for name, o := range map[string]media.CassetteOptions{
|
||||
"progress past the end": {Progress: 1.5},
|
||||
"a title in hanzi": {Title: "混音"},
|
||||
"a title past the label": {Title: "THE LONGEST MIXTAPE"},
|
||||
} {
|
||||
mustPanic(t, name, func() {
|
||||
view(300, 200, newWorld(), func(c *ui.Context) { media.Cassette(c, "Tape", o) })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCartridgeStickerAndCorner(t *testing.T) {
|
||||
wd := newWorld()
|
||||
art := pixel.SpriteOf(4, 4, func(x, y int) (pixel.Slot, bool) { return pixel.Good, true })
|
||||
tt := view(300, 300, wd, func(c *ui.Context) {
|
||||
media.Cartridge(c, "Cart", media.CartridgeOptions{Title: "Pixel Quest", Art: art})
|
||||
media.Cartridge(c, "Short", media.CartridgeOptions{Title: "Night Run"})
|
||||
})
|
||||
x, y, _, _ := dots(find(t, tt, "Cart"))
|
||||
img := tt.Image()
|
||||
if at(img, wd.pal, x+28, y+24) != pixel.Good {
|
||||
t.Fatal("the art does not sit in the sticker's middle")
|
||||
}
|
||||
// PIXEL and QUEST take a line each.
|
||||
if count(img, wd.pal, x+8, y+37, 40, 5, pixel.Ink) == 0 || count(img, wd.pal, x+8, y+43, 40, 5, pixel.Ink) == 0 {
|
||||
t.Fatal("the title does not fill two lines")
|
||||
}
|
||||
if at(img, wd.pal, x+53, y) != pixel.Back || at(img, wd.pal, x+40, y) != pixel.Edge {
|
||||
t.Fatal("the top right corner is not cut")
|
||||
}
|
||||
// One line sits in the middle of the band.
|
||||
x, y, _, _ = dots(find(t, tt, "Short"))
|
||||
if count(img, wd.pal, x+8, y+37, 40, 3, pixel.Ink) != 0 || count(img, wd.pal, x+8, y+40, 40, 5, pixel.Ink) == 0 {
|
||||
t.Fatal("NIGHT RUN is not one middle line")
|
||||
}
|
||||
for name, o := range map[string]media.CartridgeOptions{
|
||||
"art past the sticker": {Art: pixel.SpriteOf(40, 4, func(x, y int) (pixel.Slot, bool) { return pixel.Ink, true })},
|
||||
"three lines of title": {Title: "ONE TWO THREE FOUR FIVE"},
|
||||
"a word past a line": {Title: "SUPERCALIFRAGILISTIC"},
|
||||
} {
|
||||
mustPanic(t, name, func() {
|
||||
view(300, 300, newWorld(), func(c *ui.Context) { media.Cartridge(c, "Cart", o) })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVinylTurnsAndTheArmSwings(t *testing.T) {
|
||||
wd := newWorld()
|
||||
playing := false
|
||||
tt := view(300, 200, wd, func(c *ui.Context) { media.Vinyl(c, "Record", media.VinylOptions{Playing: playing}) })
|
||||
x, y, _, _ := dots(find(t, tt, "Record"))
|
||||
lit := func(dx, dy int) bool { return at(tt.Image(), wd.pal, x+dx, y+dy) == pixel.AccentLit }
|
||||
head := func(dx, dy int) bool { return at(tt.Image(), wd.pal, x+dx, y+dy) == pixel.Edge }
|
||||
if !lit(36, 31) || !head(66, 36) {
|
||||
t.Fatal("stopped, the mark is not at three o'clock or the arm not at rest")
|
||||
}
|
||||
later(tt, wd, step)
|
||||
if !lit(36, 31) {
|
||||
t.Fatal("a stopped record turned")
|
||||
}
|
||||
playing = true
|
||||
tt.Frame()
|
||||
later(tt, wd, step)
|
||||
if !lit(35, 35) || lit(36, 31) {
|
||||
t.Fatal("playing, the mark did not turn an eighth")
|
||||
}
|
||||
later(tt, wd, time.Second/2)
|
||||
if !head(52, 32) || head(66, 36) {
|
||||
t.Fatal("the arm did not swing onto the grooves")
|
||||
}
|
||||
// Five eighths round, it stops where it is.
|
||||
playing = false
|
||||
later(tt, wd, step)
|
||||
if !lit(27, 27) || lit(36, 31) {
|
||||
t.Fatal("a stopped record did not hold its turn")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilmstripHoldsEveryFrame(t *testing.T) {
|
||||
wd := newWorld()
|
||||
sp := pixel.MustSprite("g.\n.g\n\n.g\ng.\n\nr.\n.r")
|
||||
tt := view(300, 200, wd, func(c *ui.Context) {
|
||||
media.Filmstrip(c, "Film", sp, media.FilmstripOptions{Scale: 2})
|
||||
})
|
||||
x, y, w, h := dots(find(t, tt, "Film"))
|
||||
// Frames are 8 wide, film 4 more; 20 tall.
|
||||
if w != 3*8+4 || h != 6+14 {
|
||||
t.Fatalf("film is %d by %d dots", w, h)
|
||||
}
|
||||
img := tt.Image()
|
||||
if at(img, wd.pal, x+5, y+8) != pixel.Good || at(img, wd.pal, x+13, y+8) != pixel.Lit || at(img, wd.pal, x+21, y+8) != pixel.Bad {
|
||||
t.Fatal("the frames are not in order")
|
||||
}
|
||||
if at(img, wd.pal, x+15, y+10) != pixel.Lit {
|
||||
t.Fatal("a frame's backing is narrower than its sprite")
|
||||
}
|
||||
if at(img, wd.pal, x+2, y+2) != pixel.Faint || at(img, wd.pal, x+2, y+h-3) != pixel.Faint || at(img, wd.pal, x+5, y+2) != pixel.Ink {
|
||||
t.Fatal("no sprocket holes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaveformAndVisualizerFollowTheirData(t *testing.T) {
|
||||
wd := newWorld()
|
||||
tt := view(300, 200, wd, func(c *ui.Context) {
|
||||
media.Waveform(c, "Wave", []float64{1, -0.5, 0}, media.WaveformOptions{Height: 21})
|
||||
media.Visualizer(c, "Bars", []float64{1, 0.5, 0}, media.VisualizerOptions{Height: 20})
|
||||
})
|
||||
x, y, w, _ := dots(find(t, tt, "Wave"))
|
||||
img := tt.Image()
|
||||
// Up ten from the middle, down five, then the middle.
|
||||
if w != 5 || at(img, wd.pal, x, y) != pixel.Accent || at(img, wd.pal, x+2, y+15) != pixel.Accent ||
|
||||
at(img, wd.pal, x+2, y+16) == pixel.Accent || at(img, wd.pal, x+2, y+9) == pixel.Accent || at(img, wd.pal, x+4, y+10) != pixel.Accent {
|
||||
t.Fatal("waveform lines do not follow the samples")
|
||||
}
|
||||
x, y, _, _ = dots(find(t, tt, "Bars"))
|
||||
if at(img, wd.pal, x+1, y) != pixel.AccentLit || at(img, wd.pal, x+1, y+19) != pixel.AccentLit || at(img, wd.pal, x+5, y+5) != pixel.AccentLit ||
|
||||
at(img, wd.pal, x+5, y+4) == pixel.AccentLit || count(img, wd.pal, x+8, y, 3, 20, pixel.Magic)+count(img, wd.pal, x+8, y, 3, 20, pixel.AccentLit) != 0 {
|
||||
t.Fatal("visualizer bars do not reach their levels")
|
||||
}
|
||||
mustPanic(t, "a sample past 1", func() {
|
||||
view(300, 200, newWorld(), func(c *ui.Context) { media.Waveform(c, "W", []float64{1.5}, media.WaveformOptions{}) })
|
||||
})
|
||||
mustPanic(t, "a level below 0", func() {
|
||||
view(300, 200, newWorld(), func(c *ui.Context) { media.Visualizer(c, "V", []float64{-0.1}, media.VisualizerOptions{}) })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/data"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
type PlaylistOptions struct {
|
||||
// Width and Height are dots; zeros mean 200 by 120.
|
||||
Width, Height int
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
type PlaylistResult struct {
|
||||
Element *ui.Element
|
||||
// Submitted is Enter or a double click on a row.
|
||||
Changed, Submitted bool
|
||||
}
|
||||
|
||||
// Playlist lists tracks; ♪ marks the one playing.
|
||||
func Playlist(c *ui.Context, text string, tracks []Track, playing int, selected *int, o PlaylistOptions) PlaylistResult {
|
||||
k := pixel.K(c)
|
||||
if playing < -1 || playing >= len(tracks) || *selected < -1 || *selected >= len(tracks) {
|
||||
panic(fmt.Sprintf("media: Playlist plays %d and selects %d of %d", playing, *selected, len(tracks)))
|
||||
}
|
||||
for _, t := range tracks {
|
||||
if t.Length < 0 {
|
||||
panic(fmt.Sprintf("media: Playlist track %q lasts %v", t.Title, t.Length))
|
||||
}
|
||||
}
|
||||
name := func(i int) string {
|
||||
s := tracks[i].Title + ", " + tracks[i].Artist
|
||||
if i == playing {
|
||||
s += ", " + k.Msg("playing")
|
||||
}
|
||||
return s
|
||||
}
|
||||
var r data.ListResult
|
||||
e := ui.Box(c).Width(k.D(pixel.Or("Playlist Width", o.Width, 200))).Children(func() {
|
||||
r = data.List(c, text, len(tracks), selected, data.ListOptions{Height: o.Height, Disabled: o.Disabled}, name, func(i int) {
|
||||
t := tracks[i]
|
||||
ui.Box(c).FillWidth().Height(k.D(pixel.LineHeight)).Draw(func(p *ui.Painter, rc ui.Rect) {
|
||||
g := k.Canvas(p, rc)
|
||||
ink, length, x := pixel.Ink, mmss(t.Length), pixel.Measure("♪")+2
|
||||
if i == playing {
|
||||
g.Text(0, 0, "♪", pixel.Accent, 1)
|
||||
ink = pixel.Accent
|
||||
}
|
||||
lw := pixel.MicroWidth(length)
|
||||
g.Text(x, 0, pixel.Wrap(t.Title+" · "+t.Artist, g.W-x-6-lw, 1)[0], ink, 1)
|
||||
g.Micro(g.W-lw-2, 5, length, pixel.Faint, 1)
|
||||
})
|
||||
})
|
||||
})
|
||||
if r.Changed || r.Submitted {
|
||||
slog.Debug("8bit: playlist", "label", text, "selected", *selected, "submitted", r.Submitted)
|
||||
}
|
||||
return PlaylistResult{Element: e, Changed: r.Changed, Submitted: r.Submitted}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
type RecordButtonOptions struct {
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
type RecordButtonResult struct {
|
||||
Element *ui.Element
|
||||
Changed bool
|
||||
}
|
||||
|
||||
type takeKey struct{}
|
||||
|
||||
// take is when recording began and the last take's length.
|
||||
type take struct {
|
||||
since time.Time
|
||||
took time.Duration
|
||||
}
|
||||
|
||||
var (
|
||||
recDot = pixel.MustSprite(`
|
||||
..rrr..
|
||||
.rrrrr.
|
||||
rrrrrrr
|
||||
rrrrrrr
|
||||
rrrrrrr
|
||||
.rrrrr.
|
||||
..rrr..
|
||||
`)
|
||||
recOff = pixel.MustSprite(`
|
||||
..sss..
|
||||
.s...s.
|
||||
s.....s
|
||||
s.....s
|
||||
s.....s
|
||||
.s...s.
|
||||
..sss..
|
||||
`)
|
||||
)
|
||||
|
||||
// RecordButton toggles *rec; the dot blinks, time runs.
|
||||
func RecordButton(c *ui.Context, text string, rec *bool, o RecordButtonOptions) RecordButtonResult {
|
||||
k := pixel.K(c)
|
||||
now := k.Now()
|
||||
row := ui.Row(c)
|
||||
tk := ui.Local(row, takeKey{}, func() take { return take{} })
|
||||
// Follow *rec, whoever flips it; a click builds again.
|
||||
switch {
|
||||
case *rec && tk.since.IsZero():
|
||||
tk.since, tk.took = now, 0
|
||||
case !*rec && !tk.since.IsZero():
|
||||
tk.since, tk.took = time.Time{}, now.Sub(tk.since)
|
||||
}
|
||||
took, dot := tk.took, recDot
|
||||
if *rec {
|
||||
took = now.Sub(tk.since)
|
||||
k.At(c, tk.since.Add(took.Truncate(time.Second)+time.Second))
|
||||
if !k.Reduce() && k.Tick(c, 2)%2 == 1 {
|
||||
dot = recOff
|
||||
}
|
||||
}
|
||||
res := RecordButtonResult{Element: row}
|
||||
row.Children(func() {
|
||||
res.Changed = input.Toggle(c, k.Msg("rec")+" "+mmss(took), rec, input.ToggleOptions{Icon: dot, Label: text + ", " + mmss(took), Disabled: o.Disabled}).Changed
|
||||
})
|
||||
if res.Changed {
|
||||
slog.Debug("8bit: record button", "label", text, "rec", *rec, "time", mmss(took))
|
||||
}
|
||||
return res
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
type ScrubberOptions struct {
|
||||
// Width is the bar in dots; zero means 96.
|
||||
Width int
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
type ScrubberResult struct {
|
||||
Element *ui.Element
|
||||
Changed bool
|
||||
}
|
||||
|
||||
// Scrubber seeks *at within total, the time at either end.
|
||||
func Scrubber(c *ui.Context, text string, at *time.Duration, total time.Duration, o ScrubberOptions) ScrubberResult {
|
||||
k := pixel.K(c)
|
||||
if total <= 0 || *at < 0 || *at > total {
|
||||
panic(fmt.Sprintf("media: Scrubber at %v of %v", *at, total))
|
||||
}
|
||||
// Seconds, so readers hear the place, not a fraction.
|
||||
secs := at.Seconds()
|
||||
var res ScrubberResult
|
||||
res.Element = ui.Row(c).Gap(k.D(4)).AlignItems(ui.Center).Children(func() {
|
||||
times := func(d time.Duration) {
|
||||
s := mmss(d)
|
||||
ui.Box(c).Size(k.D(pixel.MicroWidth(s)), k.D(5)).Role(ui.RoleText).Label(s).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
k.Canvas(p, r).Micro(0, 0, s, pixel.Faint, 1)
|
||||
})
|
||||
}
|
||||
times(*at)
|
||||
if input.Slider(c, text, &secs, input.SliderOptions{Max: total.Seconds(), Width: o.Width, Disabled: o.Disabled}).Changed {
|
||||
// Never past the end, whatever the float says.
|
||||
*at, res.Changed = min(total, time.Duration(secs*float64(time.Second))), true
|
||||
slog.Debug("8bit: scrubber", "label", text, "at", *at)
|
||||
}
|
||||
times(total)
|
||||
})
|
||||
return res
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
type StepSequencerOptions struct {
|
||||
// Tempo is BPM, four steps a beat; zero means 120.
|
||||
Tempo int
|
||||
Playing bool
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
type StepSequencerResult struct {
|
||||
Element *ui.Element
|
||||
Changed bool
|
||||
// Step is the playhead, 0–15, or -1 when stopped.
|
||||
Step int
|
||||
}
|
||||
|
||||
type seqKey struct{}
|
||||
|
||||
// seq is when play began and the grid cursor.
|
||||
type seq struct {
|
||||
since time.Time
|
||||
cur int
|
||||
}
|
||||
|
||||
// groups light steps by beat, like the drum machines.
|
||||
var groups = [4]pixel.Slot{pixel.Bad, pixel.Warn, pixel.Good, pixel.Info}
|
||||
|
||||
// StepSequencer toggles 16 steps per track; a playhead runs along.
|
||||
func StepSequencer(c *ui.Context, text string, tracks []string, steps [][16]bool, o StepSequencerOptions) StepSequencerResult {
|
||||
k := pixel.K(c)
|
||||
if len(tracks) == 0 || len(tracks) != len(steps) {
|
||||
panic(fmt.Sprintf("media: StepSequencer has %d tracks and %d rows", len(tracks), len(steps)))
|
||||
}
|
||||
tempo := pixel.Or("StepSequencer Tempo", o.Tempo, 120)
|
||||
res := StepSequencerResult{Step: -1}
|
||||
labels := 0
|
||||
for _, t := range tracks {
|
||||
labels = max(labels, pixel.Measure(t)+4)
|
||||
}
|
||||
res.Element = ui.Column(c).Gap(k.D(2))
|
||||
sq := ui.Local(res.Element, seqKey{}, func() seq { return seq{} })
|
||||
sq.cur = min(sq.cur, 16*len(tracks)-1)
|
||||
switch {
|
||||
case !o.Playing:
|
||||
sq.since = time.Time{}
|
||||
case sq.since.IsZero():
|
||||
sq.since = k.Now()
|
||||
}
|
||||
// The playhead is the music; Reduce Motion keeps it.
|
||||
if o.Playing {
|
||||
per := time.Minute / time.Duration(4*tempo)
|
||||
n := int(k.Now().Sub(sq.since) / per)
|
||||
res.Step = n % 16
|
||||
k.At(c, sq.since.Add(time.Duration(n+1)*per))
|
||||
}
|
||||
res.Element.Children(func() {
|
||||
ui.Row(c).Children(func() {
|
||||
ui.Box(c).Size(k.D(labels), k.D(4))
|
||||
ui.Box(c).Size(k.D(16*12), k.D(4)).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
g := k.Canvas(p, r)
|
||||
for i := range 16 {
|
||||
led := pixel.Shade
|
||||
if i == res.Step {
|
||||
led = pixel.Bad
|
||||
}
|
||||
g.Rect(i*12+3, 0, 5, 3, led)
|
||||
}
|
||||
})
|
||||
})
|
||||
ui.Row(c).Children(func() {
|
||||
ui.Box(c).Size(k.D(labels), k.D(12*len(tracks))).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
g := k.Canvas(p, r)
|
||||
for i, t := range tracks {
|
||||
g.Text(0, i*12, t, pixel.Ink, 1)
|
||||
}
|
||||
})
|
||||
res.Changed = grid(c, k, text, tracks, steps, &sq.cur, res.Step, o.Disabled)
|
||||
})
|
||||
})
|
||||
return res
|
||||
}
|
||||
|
||||
// grid is the Pad of steps; picking one toggles it.
|
||||
func grid(c *ui.Context, k pixel.Kit, text string, tracks []string, steps [][16]bool, cur *int, step int, disabled bool) bool {
|
||||
shell, base, st, picked := pixel.Pad(c, text, 16, 16*len(tracks), 12, 12, cur, disabled)
|
||||
if picked >= 0 {
|
||||
on := &steps[picked/16][picked%16]
|
||||
*on = !*on
|
||||
slog.Debug("8bit: step sequencer", "label", text, "track", tracks[picked/16], "step", picked%16+1, "on", *on)
|
||||
}
|
||||
state := k.Msg("off")
|
||||
if steps[*cur/16][*cur%16] {
|
||||
state = k.Msg("on")
|
||||
}
|
||||
base.Description(fmt.Sprintf("%s, %d, %s", tracks[*cur/16], *cur%16+1, state))
|
||||
at := *cur
|
||||
shell.Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
g := k.Canvas(p, r)
|
||||
for i := range 16 * len(tracks) {
|
||||
x, y, col := i%16*12, i/16*12, i%16
|
||||
if i == at && st.Focus {
|
||||
g.Cursor(x, y, 12, 12)
|
||||
}
|
||||
if !steps[i/16][col] {
|
||||
g.Frame(x+1, y+1, 10, 10, pixel.Sunken, pixel.Well)
|
||||
continue
|
||||
}
|
||||
g.Frame(x+1, y+1, 10, 10, pixel.Raised, groups[col/4])
|
||||
if col == step {
|
||||
g.Rect(x+3, y+3, 6, 6, pixel.Lit)
|
||||
}
|
||||
}
|
||||
g.Finish(st)
|
||||
})
|
||||
return picked >= 0
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
// Row is one tracker line; no Note is a rest.
|
||||
type Row struct {
|
||||
// Note is like "C-4" or "C#4".
|
||||
Note string
|
||||
// Instrument is 1–99 and Volume 0–64 under a note.
|
||||
Instrument, Volume int
|
||||
}
|
||||
|
||||
type TrackerOptions struct {
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
type TrackerResult struct {
|
||||
Element *ui.Element
|
||||
Changed bool
|
||||
}
|
||||
|
||||
var noteForm = regexp.MustCompile(`^[A-G][-#][0-9]$`)
|
||||
|
||||
// Tracker edits rows: piano letters write notes, Delete rests.
|
||||
func Tracker(c *ui.Context, text string, rows []Row, cur *int, o TrackerOptions) TrackerResult {
|
||||
k := pixel.K(c)
|
||||
if *cur < 0 || *cur >= len(rows) {
|
||||
panic(fmt.Sprintf("media: Tracker cursor %d of %d rows", *cur, len(rows)))
|
||||
}
|
||||
for i, r := range rows {
|
||||
if r.Note != "" && (!noteForm.MatchString(r.Note) || r.Instrument < 1 || r.Instrument > 99 || r.Volume < 0 || r.Volume > 64) {
|
||||
panic(fmt.Sprintf("media: Tracker row %d is %+v", i, r))
|
||||
}
|
||||
}
|
||||
shell, base, st, _ := pixel.Pad(c, text, 1, len(rows), 52, 7, cur, o.Disabled)
|
||||
res := TrackerResult{Element: shell}
|
||||
if !base.IsDisabled() {
|
||||
for _, semi := range played(base) {
|
||||
write(rows, *cur, cell(semi))
|
||||
res.Changed = true
|
||||
slog.Debug("8bit: tracker", "label", text, "row", *cur, "note", rows[*cur].Note)
|
||||
*cur = min(len(rows)-1, *cur+1)
|
||||
}
|
||||
if base.Shortcut(0, ui.KeyDelete) || base.Shortcut(0, ui.KeyBackspace) {
|
||||
rows[*cur], res.Changed = Row{}, true
|
||||
slog.Debug("8bit: tracker", "label", text, "row", *cur, "note", "")
|
||||
}
|
||||
}
|
||||
base.Description(line(*cur, rows[*cur]))
|
||||
at := *cur
|
||||
shell.Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
g := k.Canvas(p, r)
|
||||
g.Rect(0, 0, g.W, g.H, pixel.Well)
|
||||
for i, row := range rows {
|
||||
y := i * 7
|
||||
num, ink := pixel.Faint, pixel.Ink
|
||||
switch {
|
||||
case i == at:
|
||||
g.Rect(0, y, g.W, 7, pixel.Accent)
|
||||
num, ink = pixel.AccentInk, pixel.AccentInk
|
||||
case i%4 == 0:
|
||||
g.Rect(0, y, g.W, 7, pixel.Face)
|
||||
}
|
||||
g.Micro(2, y+1, fmt.Sprintf("%02d", i), num, 1)
|
||||
if row.Note == "" {
|
||||
g.Micro(14, y+1, "--- .. ..", num, 1)
|
||||
continue
|
||||
}
|
||||
ins, vol := pixel.Info, pixel.Good
|
||||
if i == at {
|
||||
ins, vol = ink, ink
|
||||
}
|
||||
g.Micro(14, y+1, row.Note, ink, 1)
|
||||
g.Micro(30, y+1, fmt.Sprintf("%02d", row.Instrument), ins, 1)
|
||||
g.Micro(42, y+1, fmt.Sprintf("%02d", row.Volume), vol, 1)
|
||||
}
|
||||
g.Finish(st)
|
||||
})
|
||||
return res
|
||||
}
|
||||
|
||||
// cell is a semitone in tracker form, like "C-4".
|
||||
func cell(semi int) string {
|
||||
n := note(semi)
|
||||
if len(n) == 2 {
|
||||
return n[:1] + "-" + n[1:]
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// write notes row i; a rest borrows the voice above.
|
||||
func write(rows []Row, i int, n string) {
|
||||
if rows[i].Note == "" {
|
||||
rows[i] = Row{Instrument: 1, Volume: 64}
|
||||
for j := i - 1; j >= 0; j-- {
|
||||
if rows[j].Note != "" {
|
||||
rows[i] = rows[j]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
rows[i].Note = n
|
||||
}
|
||||
|
||||
// line reads a row aloud: number, note, instrument, volume.
|
||||
func line(i int, r Row) string {
|
||||
if r.Note == "" {
|
||||
return fmt.Sprintf("%02d ---", i)
|
||||
}
|
||||
return fmt.Sprintf("%02d %s %02d %02d", i, r.Note, r.Instrument, r.Volume)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
type TransportOptions struct {
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
type TransportResult struct {
|
||||
Element *ui.Element
|
||||
// Changed is play or pause; the rest are presses.
|
||||
Changed, Stopped, Previous, Next bool
|
||||
}
|
||||
|
||||
// Transport is ⏮ ▶/❚❚ ■ ⏭; play toggles *playing.
|
||||
func Transport(c *ui.Context, text string, playing *bool, o TransportOptions) TransportResult {
|
||||
k := pixel.K(c)
|
||||
var res TransportResult
|
||||
res.Element = ui.Row(c).Gap(k.D(2)).FocusGroup(ui.Horizontal).Role(ui.RoleToolbar).Label(text).Children(func() {
|
||||
press := func(sp *pixel.Sprite, key string) bool {
|
||||
return input.IconButton(c, sp, k.Msg(key), input.IconButtonOptions{Disabled: o.Disabled}).Clicked
|
||||
}
|
||||
res.Previous = press(prev, "prev_track")
|
||||
sp := icon.Play
|
||||
if *playing {
|
||||
sp = icon.Pause
|
||||
}
|
||||
res.Changed = input.Toggle(c, "", playing, input.ToggleOptions{Icon: sp, Label: k.Msg("play"), Disabled: o.Disabled}).Changed
|
||||
// Stop ends play too.
|
||||
if res.Stopped = press(icon.Stop, "stop"); res.Stopped && *playing {
|
||||
*playing, res.Changed = false, true
|
||||
}
|
||||
res.Next = press(icon.Next, "next_track")
|
||||
})
|
||||
if res.Changed || res.Stopped || res.Previous || res.Next {
|
||||
slog.Debug("8bit: transport", "label", text, "playing", *playing, "stopped", res.Stopped, "previous", res.Previous, "next", res.Next)
|
||||
}
|
||||
return res
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
type TunerOptions struct {
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
type TunerResult struct {
|
||||
Element *ui.Element
|
||||
Changed bool
|
||||
}
|
||||
|
||||
// Tuner sets *mhz on the FM dial by tenths.
|
||||
func Tuner(c *ui.Context, text string, mhz *float64, o TunerOptions) TunerResult {
|
||||
k := pixel.K(c)
|
||||
const lo, hi = 87.5, 108.0
|
||||
if !(*mhz >= lo && *mhz <= hi) {
|
||||
panic(fmt.Sprintf("media: Tuner %v MHz outside FM", *mhz))
|
||||
}
|
||||
var res TunerResult
|
||||
// The glass holds the scale; the scale is the slider.
|
||||
res.Element = ui.Box(c).Size(k.D(176), k.D(32)).PaddingX(k.D(8)).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
g := k.Canvas(p, r)
|
||||
g.Frame(0, 0, 176, 32, pixel.Sunken, pixel.Well)
|
||||
g.Dither(2, 2, 172, 28, pixel.Well, pixel.Warn, 3)
|
||||
g.HLine(8, 24, 162, pixel.Ink)
|
||||
g.Micro(150, 26, "MHZ", pixel.Faint, 1)
|
||||
}).Children(func() {
|
||||
shell, st, changed := input.SlideBase(c, text, mhz, input.RailOptions{Min: lo, Max: hi, Step: 0.1, Disabled: o.Disabled})
|
||||
res.Changed = changed
|
||||
at := *mhz
|
||||
shell.Size(k.D(160), k.D(32)).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
g := k.Canvas(p, r)
|
||||
// The dot under the pointer is the frequency it sets.
|
||||
x := func(f float64) int { return int((f - lo) / (hi - lo) * 160) }
|
||||
for f := 88; f <= 108; f++ {
|
||||
h := 3
|
||||
if f%4 == 0 {
|
||||
h = 6
|
||||
s := fmt.Sprint(f)
|
||||
g.Micro(x(float64(f))-pixel.MicroWidth(s)/2, 4, s, pixel.Ink, 1)
|
||||
}
|
||||
g.VLine(x(float64(f)), 24-h, h, pixel.Ink)
|
||||
}
|
||||
g.Rect(x(at), 3, 2, 26, pixel.Bad)
|
||||
g.Finish(st)
|
||||
})
|
||||
})
|
||||
if res.Changed {
|
||||
slog.Debug("8bit: tuner", "label", text, "mhz", *mhz)
|
||||
}
|
||||
return res
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
type VideoFrameOptions struct {
|
||||
// Width is the screen in dots, 16:9; zero means 160.
|
||||
Width int
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
type VideoFrameResult struct {
|
||||
Element *ui.Element
|
||||
// Changed is play, pause or a seek.
|
||||
Changed bool
|
||||
}
|
||||
|
||||
// VideoFrame puts screen in 16:9 over play and a scrubber.
|
||||
func VideoFrame(c *ui.Context, text string, playing *bool, at *time.Duration, total time.Duration, o VideoFrameOptions, screen func()) VideoFrameResult {
|
||||
k := pixel.K(c)
|
||||
w := pixel.Or("VideoFrame Width", o.Width, 160)
|
||||
// Play and the scrubber's two times need 128 dots.
|
||||
if w < 128 {
|
||||
panic(fmt.Sprintf("media: VideoFrame Width %d is under 128", w))
|
||||
}
|
||||
h := w * 9 / 16
|
||||
res := VideoFrameResult{}
|
||||
res.Element = ui.Column(c).Gap(k.D(3)).Role(ui.RoleGroup).Label(text).Children(func() {
|
||||
ui.Box(c).Size(k.D(w+4), k.D(h+4)).Padding(k.D(2)).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
g := k.Canvas(p, r)
|
||||
body, _ := dark(k)
|
||||
g.Frame(0, 0, g.W, g.H, pixel.Sunken, body)
|
||||
}).Children(func() {
|
||||
ui.Box(c).Size(k.D(w), k.D(h)).Clip().Children(screen)
|
||||
})
|
||||
ui.Row(c).Gap(k.D(3)).AlignItems(ui.Center).Children(func() {
|
||||
sp := icon.Play
|
||||
if *playing {
|
||||
sp = icon.Pause
|
||||
}
|
||||
play := input.Toggle(c, "", playing, input.ToggleOptions{Icon: sp, Label: k.Msg("play"), Disabled: o.Disabled}).Changed
|
||||
seek := Scrubber(c, k.Msg("seek"), at, total, ScrubberOptions{Width: w - 64, Disabled: o.Disabled}).Changed
|
||||
res.Changed = play || seek
|
||||
})
|
||||
})
|
||||
if res.Changed {
|
||||
slog.Debug("8bit: video frame", "label", text, "playing", *playing, "at", *at)
|
||||
}
|
||||
return res
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package media_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/media"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
// solid is a w by h picture in one slot.
|
||||
func solid(w, h int, s pixel.Slot) *pixel.Sprite {
|
||||
return pixel.SpriteOf(w, h, func(x, y int) (pixel.Slot, bool) { return s, true })
|
||||
}
|
||||
|
||||
func TestImageCompareSplitsWhereDragged(t *testing.T) {
|
||||
wd := newWorld()
|
||||
split, changed := 0.5, false
|
||||
tt := view(300, 200, wd, func(c *ui.Context) {
|
||||
changed = media.ImageCompare(c, "Compare", solid(10, 6, pixel.Good), solid(10, 6, pixel.Bad), &split, media.ImageCompareOptions{Scale: 2}).Changed || changed
|
||||
})
|
||||
r := find(t, tt, "Compare")
|
||||
x, y, w, h := dots(r)
|
||||
img := tt.Image()
|
||||
// Row 0 is above the grip.
|
||||
if w != 20 || h != 12 || at(img, wd.pal, x+2, y) != pixel.Good || at(img, wd.pal, x+10, y) != pixel.Edge || at(img, wd.pal, x+15, y) != pixel.Bad {
|
||||
t.Fatal("before is not left of the split, after right")
|
||||
}
|
||||
tt.Press(r.X+r.W/4, r.Y+2)
|
||||
tt.Release(r.X+r.W/4, r.Y+2)
|
||||
if split != 0.25 || !changed || at(tt.Image(), wd.pal, x+7, y) != pixel.Bad {
|
||||
t.Fatalf("pressing a quarter in split at %v", split)
|
||||
}
|
||||
tt.Key(0, ui.KeyHome)
|
||||
if split != 0 || at(tt.Image(), wd.pal, x+2, y) != pixel.Bad || at(tt.Image(), wd.pal, x+6, y+h/2) != pixel.Edge {
|
||||
t.Fatal("at 0, all is after and the grip stays whole")
|
||||
}
|
||||
tt.Key(0, ui.KeyEnd)
|
||||
if split != 1 || at(tt.Image(), wd.pal, x+17, y) != pixel.Good {
|
||||
t.Fatal("at 1, all is before")
|
||||
}
|
||||
mustPanic(t, "pictures of two sizes", func() {
|
||||
view(300, 200, newWorld(), func(c *ui.Context) {
|
||||
s := 0.5
|
||||
media.ImageCompare(c, "C", solid(2, 2, pixel.Ink), solid(3, 2, pixel.Ink), &s, media.ImageCompareOptions{})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestGalleryChoosesByArrowsAndClicks(t *testing.T) {
|
||||
wd := newWorld()
|
||||
pics := []*pixel.Sprite{solid(4, 3, pixel.Good), solid(4, 3, pixel.Bad), solid(4, 3, pixel.Info)}
|
||||
chosen, changed := 0, false
|
||||
tt := view(300, 200, wd, func(c *ui.Context) {
|
||||
changed = media.Gallery(c, "Views", pics, []string{"Green", "Red", "Blue"}, &chosen, media.GalleryOptions{Scale: 2}).Changed || changed
|
||||
})
|
||||
big := func(name string) pixel.Slot {
|
||||
x, y, _, _ := dots(find(t, tt, name))
|
||||
return at(tt.Image(), wd.pal, x+4, y+4)
|
||||
}
|
||||
if big("Green") != pixel.Good {
|
||||
t.Fatal("the big picture is not the chosen one")
|
||||
}
|
||||
// The middle of the strip is the second thumbnail.
|
||||
click(t, tt, "Views")
|
||||
if chosen != 1 || !changed || big("Red") != pixel.Bad {
|
||||
t.Fatalf("clicking the middle chose %d", chosen)
|
||||
}
|
||||
tt.Key(0, ui.KeyRight)
|
||||
if chosen != 2 || big("Blue") != pixel.Info {
|
||||
t.Fatalf("Right chose %d", chosen)
|
||||
}
|
||||
changed = false
|
||||
tt.Key(0, ui.KeyRight)
|
||||
if chosen != 2 || changed {
|
||||
t.Fatalf("Right at the end: %d, changed %v", chosen, changed)
|
||||
}
|
||||
// The chosen thumbnail wears the cursor ring.
|
||||
x, y, _, _ := dots(find(t, tt, "Views"))
|
||||
if at(tt.Image(), wd.pal, x+2*10, y) != pixel.Accent || at(tt.Image(), wd.pal, x, y) == pixel.Accent {
|
||||
t.Fatal("the ring is not on the chosen thumbnail")
|
||||
}
|
||||
for name, fn := range map[string]func(c *ui.Context){
|
||||
"names short of pictures": func(c *ui.Context) {
|
||||
n := 0
|
||||
media.Gallery(c, "V", pics, []string{"Green"}, &n, media.GalleryOptions{})
|
||||
},
|
||||
"a choice past the end": func(c *ui.Context) {
|
||||
n := 3
|
||||
media.Gallery(c, "V", pics, []string{"Green", "Red", "Blue"}, &n, media.GalleryOptions{})
|
||||
},
|
||||
} {
|
||||
mustPanic(t, name, func() { view(300, 200, newWorld(), fn) })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"math"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
type VinylOptions struct {
|
||||
Playing bool
|
||||
}
|
||||
|
||||
// Vinyl turns a record while playing; the arm swings on.
|
||||
func Vinyl(c *ui.Context, text string, o VinylOptions) *ui.Element {
|
||||
k := pixel.K(c)
|
||||
e := art(c, k, text, 72, 64)
|
||||
swing := float64(k.Toward(c, e, o.Playing, 12, 6)) / 6
|
||||
a := float64(turned(c, k, e, o.Playing, 8)%8) * math.Pi / 4
|
||||
return e.Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
g := k.Canvas(p, r)
|
||||
body, line := dark(k)
|
||||
g.Disc(32, 32, 29, body)
|
||||
for _, rr := range []int{25, 21, 17, 13} {
|
||||
g.Circle(32, 32, rr, line)
|
||||
}
|
||||
// Light stays put while the record turns under it.
|
||||
g.Line(14, 17, 18, 13, pixel.Lit)
|
||||
g.Line(46, 51, 50, 47, pixel.Lit)
|
||||
g.Disc(32, 32, 9, pixel.Accent)
|
||||
g.Rect(31+int(math.Round(5*math.Cos(a))), 31+int(math.Round(5*math.Sin(a))), 2, 2, pixel.AccentLit)
|
||||
g.Disc(32, 32, 1, pixel.Lit)
|
||||
// The arm swings from rest, 90°, onto grooves, 118°.
|
||||
arm := (90 + 28*swing) * math.Pi / 180
|
||||
hx, hy := 66+int(math.Round(30*math.Cos(arm))), 6+int(math.Round(30*math.Sin(arm)))
|
||||
g.Line(66, 6, hx, hy, pixel.Edge)
|
||||
g.Rect(hx-1, hy-1, 3, 3, pixel.Edge)
|
||||
g.Disc(66, 6, 3, pixel.Edge)
|
||||
g.Disc(66, 6, 2, pixel.Face)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
type VisualizerOptions struct {
|
||||
// Height is in dots; zero means 32.
|
||||
Height int
|
||||
}
|
||||
|
||||
// Visualizer mirrors each level, 0 to 1, about the middle.
|
||||
func Visualizer(c *ui.Context, text string, levels []float64, o VisualizerOptions) *ui.Element {
|
||||
k := pixel.K(c)
|
||||
if len(levels) == 0 {
|
||||
panic("media: Visualizer has no levels")
|
||||
}
|
||||
parts := make([]string, len(levels))
|
||||
for i, l := range levels {
|
||||
if !(l >= 0 && l <= 1) {
|
||||
panic(fmt.Sprintf("media: Visualizer level %v outside 0–1", l))
|
||||
}
|
||||
parts[i] = fmt.Sprintf("%.2f", l)
|
||||
}
|
||||
h := pixel.Or("Visualizer Height", o.Height, 32)
|
||||
mid := h / 2
|
||||
return art(c, k, text, 4*len(levels)-1, h).Description(strings.Join(parts, " ")).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
g := k.Canvas(p, r)
|
||||
for i, l := range levels {
|
||||
n := int(math.Round(l * float64(mid)))
|
||||
if n == 0 {
|
||||
continue
|
||||
}
|
||||
// Magic at the root, Accent above, an AccentLit tip.
|
||||
x, deep := 4*i, n/2
|
||||
g.Rect(x, mid-deep, 3, 2*deep, pixel.Magic)
|
||||
g.Rect(x, mid-n+1, 3, n-1-deep, pixel.Accent)
|
||||
g.Rect(x, mid+deep, 3, n-1-deep, pixel.Accent)
|
||||
g.Rect(x, mid-n, 3, 1, pixel.AccentLit)
|
||||
g.Rect(x, mid+n-1, 3, 1, pixel.AccentLit)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
type VolumeOptions struct {
|
||||
// Bars is how many steps; zero means 8.
|
||||
Bars int
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
type VolumeResult struct {
|
||||
Element *ui.Element
|
||||
Changed bool
|
||||
}
|
||||
|
||||
// Volume sets *level in stepped bars; the speaker mutes.
|
||||
func Volume(c *ui.Context, text string, level *int, muted *bool, o VolumeOptions) VolumeResult {
|
||||
k := pixel.K(c)
|
||||
n := pixel.Or("Volume Bars", o.Bars, 8)
|
||||
if n < 2 || *level < 0 || *level > n {
|
||||
panic(fmt.Sprintf("media: Volume level %d of %d bars", *level, n))
|
||||
}
|
||||
var res VolumeResult
|
||||
res.Element = ui.Row(c).Gap(k.D(3)).AlignItems(ui.Center).Children(func() {
|
||||
sp := icon.Volume
|
||||
if *muted {
|
||||
sp = icon.Mute
|
||||
}
|
||||
mute := input.Toggle(c, "", muted, input.ToggleOptions{Icon: sp, Label: k.Msg("mute"), Disabled: o.Disabled}).Changed
|
||||
v := float64(*level)
|
||||
shell, st, moved := input.SlideBase(c, text, &v, input.RailOptions{Max: float64(n), Step: 1, Disabled: o.Disabled})
|
||||
*level = int(v)
|
||||
lit, quiet := *level, *muted
|
||||
shell.Size(k.D(4*n), k.D(14)).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
g := k.Canvas(p, r)
|
||||
for i := range n {
|
||||
h := 4 + i*10/(n-1)
|
||||
s := pixel.Well
|
||||
switch {
|
||||
case i >= lit:
|
||||
case quiet:
|
||||
s = pixel.Faint
|
||||
case i >= n*3/4:
|
||||
s = pixel.Warn
|
||||
default:
|
||||
s = pixel.Good
|
||||
}
|
||||
g.Frame(4*i, 14-h, 3, h, pixel.Flat, s)
|
||||
}
|
||||
g.Finish(st)
|
||||
})
|
||||
res.Changed = mute || moved
|
||||
})
|
||||
if res.Changed {
|
||||
slog.Debug("8bit: volume", "label", text, "level", *level, "muted", *muted)
|
||||
}
|
||||
return res
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
type WaveformOptions struct {
|
||||
// Height is in dots; zero means 32.
|
||||
Height int
|
||||
}
|
||||
|
||||
// Waveform draws samples, ±1, as lines from the middle.
|
||||
func Waveform(c *ui.Context, text string, samples []float64, o WaveformOptions) *ui.Element {
|
||||
k := pixel.K(c)
|
||||
if len(samples) == 0 {
|
||||
panic("media: Waveform has no samples")
|
||||
}
|
||||
for _, s := range samples {
|
||||
if !(s >= -1 && s <= 1) {
|
||||
panic(fmt.Sprintf("media: Waveform sample %v outside ±1", s))
|
||||
}
|
||||
}
|
||||
h := pixel.Or("Waveform Height", o.Height, 32)
|
||||
half := (h - 1) / 2
|
||||
return art(c, k, text, 2*len(samples)-1, h).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
g := k.Canvas(p, r)
|
||||
for x := 1; x < g.W; x += 2 {
|
||||
g.Dot(x, half, pixel.Faint)
|
||||
}
|
||||
for i, s := range samples {
|
||||
n := int(math.Round(s * float64(half)))
|
||||
g.VLine(2*i, min(half, half-n), max(n, -n)+1, pixel.Accent)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user