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
@@ -55,12 +55,14 @@ Imports point down only. Same-tier imports are forbidden. `internal/archtest` en
|
||||
|
||||
- Signatures and results: PRD §5.1. Zero options give the default look. Sizes in options are dots.
|
||||
- `k := pixel.K(c)` once per component; layout sizes through `k.D(n)`. Integer options default through `pixel.Or`.
|
||||
- Custom-drawn controls end their Draw with `g.Finish(st)`. Grids of cells moved by arrows use `pixel.Pad`; one-shot timing steps with `k.Since`; a decorative one-shot on a cue steps with `k.Shot`, which Reduce Motion spends whole; a view that changes at a known moment asks `k.At`; a two-way slide (bars, fades, wipes) steps with `k.Toward`, which turns from where it is.
|
||||
- Custom-drawn controls end their Draw with `g.Finish(st)`. Grids of cells moved by arrows use `pixel.Pad`; custom-drawn sliders use `input.SlideBase`; one-shot timing steps with `k.Since`; a decorative one-shot on a cue steps with `k.Shot`, which Reduce Motion spends whole; a view that changes at a known moment asks `k.At`; a two-way slide (bars, fades, wipes) steps with `k.Toward`, which turns from where it is.
|
||||
- A one-shot effect inside a lasting view starts when its cue changes (a `Cue` option read through `k.Cue`, or a value that rises), never on its first frame. A state screen (`ErrorState`, `SuccessState`) starts when it appears. Decorative motion jumps to its end under Reduce Motion; countdowns and loaders keep time.
|
||||
- Containers take content as a final `func()`; interactive results are `NameResult`.
|
||||
- A Clicked, Shortcut, Changed or drag during the build makes MyGo build the frame again, so what they change shows at once. State changed any other way after its element was built (`Pressed`, `Edit.Changed`, a drag's release) is read at paint time, or set on the element after its children.
|
||||
- Never call `Selected`. The pixel theme keeps MyGo's row highlights clear, since scrolled rows sit between dots: draw chosen rows yourself.
|
||||
- A disabled or inert scroll view must not move: cover it, so the wheel stops above it.
|
||||
- Read pointers and keys only while `base.IsDisabled()` is false, never by the `Disabled` option alone: `pixel.Shell` disables the base under Inert too, and a drag begun before stays pressed.
|
||||
- MyGo paints a disabled element and all inside it at half opacity, and only a disabled element reads as disabled to assistive technology. Disable only drawless bases, as `pixel.Shell` does. A container with drawn content stays enabled, parks its focus, ignores input and says `DISABLED` in its description.
|
||||
- A press focuses the nearest focusable element under it, or nothing, and a focusable element keeps the click: clicks never reach its ancestors. A control that acts on a press is that focusable element. A `DialogBase` moves the focus inside as it opens and keeps Tab there, but only onto something focusable: make its panel focusable, ring off, and build its children in `pixel.Harbor(c, panel, …)` so controls turning off park their focus there and get it back. A closing overlay hands the focus back to its opener. While a press is on, `Hovered` is false for every other element: ask `Pressed`.
|
||||
- Enter and Space click a focused clickable element unless a `Shortcut` takes them. Take them wherever a click reads the pointer's position.
|
||||
- Under `pixel.Settings.Inert` (a busy veil) `pixel.Shell` disables every base and gives the focus back after. A popup sits above any veil: a control closes its popup when disabled or inert.
|
||||
@@ -78,7 +80,7 @@ Imports point down only. Same-tier imports are forbidden. `internal/archtest` en
|
||||
|
||||
## Gallery entry
|
||||
|
||||
Each component adds one `Entry` in `gallery/demos_<category>.go`: ID, Name, Zh, Category, Summary, Demo. The demo reports every event through `log`. The code panel shows the demo function's own source.
|
||||
Each component adds one `Entry` in `gallery/demos_<category>.go`: ID, Name, Zh, Category, Summary, Demo. The demo reports every event through `log`. The code panel shows the demo function's own source. An exported `…Base` (like `input.SlideBase`) is an undrawn behavior other packages draw on; it needs no demo of its own.
|
||||
|
||||
## Tests
|
||||
|
||||
|
||||
@@ -394,3 +394,27 @@ func TestListNamesRowsForReadersAndTyping(t *testing.T) {
|
||||
t.Fatalf("typing ch chose row %d", sel)
|
||||
}
|
||||
}
|
||||
|
||||
// MyGo would halve a disabled list's colors off the palette.
|
||||
func TestOffListsKeepThePalette(t *testing.T) {
|
||||
fruit := []string{"Apple", "Banana"}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
disabled, inert bool
|
||||
}{{"disabled", true, false}, {"inert", false, true}} {
|
||||
sel := 0
|
||||
tt := view(300, 200, newClock(), tc.inert, func(c *ui.Context) {
|
||||
data.List(c, "Fruit", len(fruit), &sel, data.ListOptions{Disabled: tc.disabled}, func(i int) string { return fruit[i] }, func(i int) {
|
||||
pixel.Text(c, fruit[i], pixel.TextOptions{})
|
||||
})
|
||||
})
|
||||
r, img := find(t, tt, "Fruit"), tt.Image()
|
||||
for y := int(r.Y); y < int(r.Y+r.H); y++ {
|
||||
for x := int(r.X); x < int(r.X+r.W); x++ {
|
||||
if c := img.RGBAAt(x, y); !slices.ContainsFunc(pixel.SpringDay.Colors[:], func(p ui.Color) bool { return p.R == c.R && p.G == c.G && p.B == c.B }) {
|
||||
t.Fatalf("%s list shows %v at %d, %d, off the palette", tc.name, c, x, y)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package gallery
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/media"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
var mediaEntries = []Entry{
|
||||
{346, "AudioPlayer", "音乐播放器", "media", "A whole deck in one box. Stop rewinds to the top.", demoAudioPlayer},
|
||||
{347, "Transport", "播放控制", "media", "Back, play or pause, stop, skip: the four buttons every deck agrees on.", demoTransport},
|
||||
{348, "Scrubber", "进度拖动", "media", "Drag to seek, or nudge with arrows. Time at both ends.", demoScrubber},
|
||||
{349, "Volume", "音量", "media", "Eight bars; click one or use arrows. The speaker mutes.", demoVolume},
|
||||
{350, "Equalizer", "均衡器", "media", "Five faders, ±12 dB. Bass boost at your own risk.", demoEqualizer},
|
||||
{351, "Visualizer", "频谱", "media", "Bars bounce out from the middle, hottest at the tips.", demoVisualizer},
|
||||
{352, "Playlist", "播放列表", "media", "♪ marks the track that plays. Enter plays the one you pick.", demoPlaylist},
|
||||
{353, "Cassette", "磁带", "media", "Reels turn while it plays; watch the tape cross over.", demoCassette},
|
||||
{354, "Cartridge", "卡带", "media", "A cart with a sticker. Blow on the contacts before use.", demoCartridge},
|
||||
{355, "Vinyl", "黑胶", "media", "Turns in eight frames; the arm swings onto the groove.", demoVinyl},
|
||||
}
|
||||
|
||||
type mediaDemo struct {
|
||||
player media.Player
|
||||
track, pick int
|
||||
deck string
|
||||
seek time.Duration
|
||||
level int
|
||||
muted bool
|
||||
gains []float64
|
||||
tape, spin, video bool
|
||||
tapeAt, videoAt time.Duration
|
||||
notes []string
|
||||
beat bool
|
||||
pattern [][16]bool
|
||||
rows []media.Row
|
||||
row, chosen int
|
||||
split, mhz float64
|
||||
rec bool
|
||||
ran, taped, filmed time.Time
|
||||
}
|
||||
|
||||
var tracks = []media.Track{
|
||||
{Title: "Overworld Theme", Artist: "The Square Waves", Length: 2*time.Minute + 47*time.Second},
|
||||
{Title: "Boss Battle", Artist: "Chip Wizards", Length: 3*time.Minute + 12*time.Second},
|
||||
{Title: "Save Point Lullaby", Artist: "Triangle Wave", Length: 1*time.Minute + 58*time.Second},
|
||||
{Title: "Credits Roll", Artist: "Pulse & Noise", Length: 4*time.Minute + 5*time.Second},
|
||||
}
|
||||
|
||||
func mediaState(c *ui.Context) *mediaDemo {
|
||||
return ui.Local(c.Root(), demoKey("media"), func() mediaDemo {
|
||||
return mediaDemo{
|
||||
player: media.Player{Level: 6}, pick: 1, deck: "STOPPED", seek: 72 * time.Second, level: 5,
|
||||
gains: []float64{6, 2, 0, 3, 5},
|
||||
pattern: [][16]bool{beat("x...x...x...x..."), beat("....x.......x..."), beat("x.x.x.x.x.x.x.xx"), beat("............x...")},
|
||||
rows: melody(), split: 0.5, mhz: 98.3,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// beat reads a pattern: x hits, . rests.
|
||||
func beat(s string) [16]bool {
|
||||
var b [16]bool
|
||||
for i, r := range s {
|
||||
b[i] = r == 'x'
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// spin moves *at while playing, as an audio engine would.
|
||||
func spin(c *ui.Context, last *time.Time, playing bool, at *time.Duration, length time.Duration) {
|
||||
k := pixel.K(c)
|
||||
if playing && !last.IsZero() {
|
||||
*at = (*at + k.Now().Sub(*last)) % length
|
||||
}
|
||||
*last = time.Time{}
|
||||
if playing {
|
||||
*last = k.Now()
|
||||
k.Tick(c, 4)
|
||||
}
|
||||
}
|
||||
|
||||
func demoAudioPlayer(c *ui.Context, log func(string)) {
|
||||
k := pixel.K(c)
|
||||
s := mediaState(c)
|
||||
t := tracks[s.track]
|
||||
spin(c, &s.ran, s.player.Playing, &s.player.At, t.Length)
|
||||
ui.Box(c).Width(k.D(240)).Children(func() {
|
||||
res := media.AudioPlayer(c, "Now playing", t, &s.player, media.AudioPlayerOptions{})
|
||||
switch {
|
||||
case res.Previous:
|
||||
s.track = (s.track + len(tracks) - 1) % len(tracks)
|
||||
case res.Next:
|
||||
s.track = (s.track + 1) % len(tracks)
|
||||
}
|
||||
if res.Previous || res.Next {
|
||||
s.player.At = 0
|
||||
log("♪ " + tracks[s.track].Title)
|
||||
}
|
||||
if res.Changed {
|
||||
log(fmt.Sprintf("playing %v at %s, volume %d, muted %v", s.player.Playing, s.player.At.Truncate(time.Second), s.player.Level, s.player.Muted))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func demoTransport(c *ui.Context, log func(string)) {
|
||||
k := pixel.K(c)
|
||||
s := mediaState(c)
|
||||
ui.Column(c).Gap(k.D(6)).Children(func() {
|
||||
playing := s.deck == "PLAYING"
|
||||
res := media.Transport(c, "Deck", &playing, media.TransportOptions{})
|
||||
switch {
|
||||
case res.Stopped:
|
||||
s.deck = "STOPPED"
|
||||
case res.Changed && playing:
|
||||
s.deck = "PLAYING"
|
||||
case res.Changed:
|
||||
s.deck = "PAUSED"
|
||||
case res.Previous:
|
||||
log("PREVIOUS")
|
||||
case res.Next:
|
||||
log("NEXT")
|
||||
}
|
||||
if res.Changed || res.Stopped {
|
||||
log(s.deck)
|
||||
}
|
||||
pixel.Text(c, s.deck, pixel.TextOptions{Slot: pixel.Faint})
|
||||
})
|
||||
}
|
||||
|
||||
func demoScrubber(c *ui.Context, log func(string)) {
|
||||
s := mediaState(c)
|
||||
if media.Scrubber(c, "Seek", &s.seek, tracks[1].Length, media.ScrubberOptions{Width: 120}).Changed {
|
||||
log("seek " + s.seek.String())
|
||||
}
|
||||
}
|
||||
|
||||
func demoVolume(c *ui.Context, log func(string)) {
|
||||
s := mediaState(c)
|
||||
if media.Volume(c, "Volume", &s.level, &s.muted, media.VolumeOptions{}).Changed {
|
||||
log(fmt.Sprintf("volume %d of 8, muted %v", s.level, s.muted))
|
||||
}
|
||||
}
|
||||
|
||||
func demoEqualizer(c *ui.Context, log func(string)) {
|
||||
s := mediaState(c)
|
||||
bands := []string{"60", "250", "1K", "4K", "12K"}
|
||||
if b := media.Equalizer(c, "Equalizer", bands, s.gains, media.EqualizerOptions{}).Band; b >= 0 {
|
||||
log(fmt.Sprintf("%s Hz %+.1f dB", bands[b], s.gains[b]))
|
||||
}
|
||||
}
|
||||
|
||||
func demoVisualizer(c *ui.Context, log func(string)) {
|
||||
k := pixel.K(c)
|
||||
// A made-up song: each bar sways at its own pace.
|
||||
t := 0.0
|
||||
if !k.Reduce() {
|
||||
t = float64(k.Tick(c, 12)) / 12
|
||||
}
|
||||
levels := make([]float64, 24)
|
||||
for i := range levels {
|
||||
f := float64(i)
|
||||
levels[i] = (0.55 + 0.45*math.Sin(t*(2+f/5)+f)) * (1 - f/32)
|
||||
}
|
||||
media.Visualizer(c, "Visualizer", levels, media.VisualizerOptions{Height: 40})
|
||||
}
|
||||
|
||||
func demoPlaylist(c *ui.Context, log func(string)) {
|
||||
s := mediaState(c)
|
||||
res := media.Playlist(c, "Playlist", tracks, s.track, &s.pick, media.PlaylistOptions{Height: 80})
|
||||
if res.Submitted {
|
||||
s.track, s.player.At = s.pick, 0
|
||||
log("♪ " + tracks[s.track].Title)
|
||||
} else if res.Changed {
|
||||
log("picked " + tracks[s.pick].Title)
|
||||
}
|
||||
}
|
||||
|
||||
func demoCassette(c *ui.Context, log func(string)) {
|
||||
k := pixel.K(c)
|
||||
s := mediaState(c)
|
||||
const side = 45 * time.Second
|
||||
spin(c, &s.taped, s.tape, &s.tapeAt, side)
|
||||
ui.Row(c).Gap(k.D(8)).AlignItems(ui.Center).Children(func() {
|
||||
media.Cassette(c, "Mixtape, side A", media.CassetteOptions{Title: "Mixtape 86", Playing: s.tape, Progress: float64(s.tapeAt) / float64(side)})
|
||||
if input.Toggle(c, "PLAY", &s.tape, input.ToggleOptions{}).Changed {
|
||||
log(fmt.Sprintf("tape playing %v", s.tape))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func demoCartridge(c *ui.Context, log func(string)) {
|
||||
k := pixel.K(c)
|
||||
ui.Row(c).Gap(k.D(8)).Children(func() {
|
||||
media.Cartridge(c, "Pixel Quest cartridge", media.CartridgeOptions{Title: "Pixel Quest", Art: scene(pixel.Warn, pixel.Info, pixel.Good, 8, 4)})
|
||||
media.Cartridge(c, "Night Run cartridge", media.CartridgeOptions{Title: "Night Run 2", Art: scene(pixel.Lit, pixel.Magic, pixel.Shade, 26, 3)})
|
||||
})
|
||||
}
|
||||
|
||||
func demoVinyl(c *ui.Context, log func(string)) {
|
||||
k := pixel.K(c)
|
||||
s := mediaState(c)
|
||||
ui.Row(c).Gap(k.D(8)).AlignItems(ui.Center).Children(func() {
|
||||
media.Vinyl(c, "Record", media.VinylOptions{Playing: s.spin})
|
||||
if input.Toggle(c, "PLAY", &s.spin, input.ToggleOptions{}).Changed {
|
||||
log(fmt.Sprintf("record playing %v", s.spin))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// scene is hills under a sky, sun at x, y.
|
||||
func scene(sun, sky, hill pixel.Slot, x, y int) *pixel.Sprite {
|
||||
return pixel.SpriteOf(32, 18, func(i, j int) (pixel.Slot, bool) {
|
||||
switch {
|
||||
case (i-x)*(i-x)+(j-y)*(j-y) <= 5:
|
||||
return sun, true
|
||||
case float64(j) > 12+2*math.Sin(float64(i)/4):
|
||||
return hill, true
|
||||
}
|
||||
return sky, true
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package gallery
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/media"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
var studioEntries = []Entry{
|
||||
{356, "PianoKeys", "钢琴键", "media", "Click, or type A–K. W E T Y U play the black keys.", demoPianoKeys},
|
||||
{357, "StepSequencer", "步进音序器", "media", "Four tracks, sixteen steps. Press play and nod along.", demoStepSequencer},
|
||||
{358, "Tracker", "音轨编辑", "media", "Type A–K to write a note, Delete for a rest. Beats every four rows.", demoTracker},
|
||||
{359, "Waveform", "波形", "media", "Every sample a line, up or down from zero.", demoWaveform},
|
||||
{360, "VideoFrame", "视频框", "media", "A 16:9 screen with play and a scrubber. The slime does the rest.", demoVideoFrame},
|
||||
{361, "ImageCompare", "对比滑块", "media", "Drag the grip: sketch on the left, paint on the right.", demoImageCompare},
|
||||
{362, "Gallery", "画廊", "media", "Arrows walk the thumbnails; the big picture follows.", demoGallery},
|
||||
{363, "Filmstrip", "胶片条", "media", "A sprite's frames on film, sprocket holes and all.", demoFilmstrip},
|
||||
{364, "RecordButton", "录制按钮", "media", "The dot blinks while it records, and the time runs.", demoRecordButton},
|
||||
{365, "Tuner", "调频器", "media", "Drag the needle across FM. A few stations are on the air.", demoTuner},
|
||||
}
|
||||
|
||||
func demoPianoKeys(c *ui.Context, log func(string)) {
|
||||
k := pixel.K(c)
|
||||
s := mediaState(c)
|
||||
ui.Column(c).Gap(k.D(6)).Children(func() {
|
||||
for _, n := range media.PianoKeys(c, "Piano", media.PianoKeysOptions{}).Struck {
|
||||
s.notes = append(s.notes, n)
|
||||
log("♪ " + n)
|
||||
}
|
||||
played := s.notes[max(0, len(s.notes)-8):]
|
||||
pixel.Text(c, "♪ "+strings.Join(played, " "), pixel.TextOptions{Slot: pixel.Faint})
|
||||
})
|
||||
}
|
||||
|
||||
func demoStepSequencer(c *ui.Context, log func(string)) {
|
||||
k := pixel.K(c)
|
||||
s := mediaState(c)
|
||||
tracks := []string{"KICK", "SNARE", "HAT", "CLAP"}
|
||||
ui.Column(c).Gap(k.D(6)).Children(func() {
|
||||
if media.StepSequencer(c, "Beat", tracks, s.pattern, media.StepSequencerOptions{Playing: s.beat}).Changed {
|
||||
log("pattern changed")
|
||||
}
|
||||
if input.Toggle(c, "PLAY", &s.beat, input.ToggleOptions{}).Changed {
|
||||
log(fmt.Sprintf("beat playing %v", s.beat))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// melody is a pattern with a tune in it.
|
||||
func melody() []media.Row {
|
||||
rows := make([]media.Row, 16)
|
||||
for i, n := range map[int]string{0: "C-4", 2: "E-4", 4: "G-4", 6: "E-4", 8: "F-4", 10: "A-4", 12: "G-4", 14: "C-5"} {
|
||||
rows[i] = media.Row{Note: n, Instrument: 1 + i/8, Volume: 64 - i*2}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func demoTracker(c *ui.Context, log func(string)) {
|
||||
s := mediaState(c)
|
||||
if media.Tracker(c, "Pattern", s.rows, &s.row, media.TrackerOptions{}).Changed {
|
||||
log(fmt.Sprintf("pattern edited at row %02d", s.row))
|
||||
}
|
||||
}
|
||||
|
||||
func demoWaveform(c *ui.Context, log func(string)) {
|
||||
// A plucked string: a fast wiggle fading out.
|
||||
samples := make([]float64, 72)
|
||||
for i := range samples {
|
||||
f := float64(i)
|
||||
samples[i] = math.Sin(f*0.7) * math.Exp(-f/24)
|
||||
}
|
||||
media.Waveform(c, "Pluck", samples, media.WaveformOptions{Height: 40})
|
||||
}
|
||||
|
||||
func demoVideoFrame(c *ui.Context, log func(string)) {
|
||||
k := pixel.K(c)
|
||||
s := mediaState(c)
|
||||
const length = 20 * time.Second
|
||||
spin(c, &s.filmed, s.video, &s.videoAt, length)
|
||||
at := s.videoAt
|
||||
screen := func() {
|
||||
ui.Box(c).Size(k.D(160), k.D(90)).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
g := k.Canvas(p, r)
|
||||
g.Rect(0, 0, g.W, g.H, pixel.Info)
|
||||
g.Rect(0, 72, g.W, 18, pixel.Good)
|
||||
g.Dither(0, 72, g.W, 18, pixel.Good, pixel.Shade, 4)
|
||||
// The slime hops right with the film's own clock.
|
||||
hop := int(at / (time.Second / 4))
|
||||
x := int(float64(at) / float64(length) * 128)
|
||||
g.Sprite(x, 48-hop%2*4, slime, hop%2, pixel.Ink, 2)
|
||||
})
|
||||
}
|
||||
res := media.VideoFrame(c, "Slime documentary", &s.video, &s.videoAt, length, media.VideoFrameOptions{}, screen)
|
||||
if res.Changed {
|
||||
log(fmt.Sprintf("video playing %v at %s", s.video, s.videoAt.Truncate(time.Second)))
|
||||
}
|
||||
}
|
||||
|
||||
func demoImageCompare(c *ui.Context, log func(string)) {
|
||||
s := mediaState(c)
|
||||
after := scene(pixel.Warn, pixel.Info, pixel.Good, 24, 5)
|
||||
// The sketch is the painting in one faint ink.
|
||||
before := pixel.SpriteOf(after.W, after.H, func(x, y int) (pixel.Slot, bool) {
|
||||
if sl, _ := after.At(0, x, y, pixel.Ink); sl == pixel.Info {
|
||||
return pixel.Lit, true
|
||||
}
|
||||
return pixel.Faint, true
|
||||
})
|
||||
if media.ImageCompare(c, "Sketch and painting", before, after, &s.split, media.ImageCompareOptions{Scale: 4}).Changed {
|
||||
log(fmt.Sprintf("split at %.0f%%", s.split*100))
|
||||
}
|
||||
}
|
||||
|
||||
var views = []struct {
|
||||
name string
|
||||
pic *pixel.Sprite
|
||||
}{
|
||||
{"Morning", scene(pixel.Warn, pixel.AccentLit, pixel.Good, 4, 10)},
|
||||
{"Noon", scene(pixel.Warn, pixel.Info, pixel.Good, 16, 3)},
|
||||
{"Dusk", scene(pixel.Bad, pixel.Accent, pixel.Magic, 27, 11)},
|
||||
{"Night", scene(pixel.Lit, pixel.Well, pixel.Shade, 22, 4)},
|
||||
}
|
||||
|
||||
func demoGallery(c *ui.Context, log func(string)) {
|
||||
s := mediaState(c)
|
||||
pics, names := make([]*pixel.Sprite, len(views)), make([]string, len(views))
|
||||
for i, v := range views {
|
||||
pics[i], names[i] = v.pic, v.name
|
||||
}
|
||||
if media.Gallery(c, "Views", pics, names, &s.chosen, media.GalleryOptions{}).Changed {
|
||||
log("showing " + names[s.chosen])
|
||||
}
|
||||
}
|
||||
|
||||
var coin = pixel.MustSprite(`
|
||||
..eeee..
|
||||
.eyyyye.
|
||||
eyylyyye
|
||||
eyylyyye
|
||||
eyylyyye
|
||||
eyylyyye
|
||||
.eyyyye.
|
||||
..eeee..
|
||||
|
||||
...ee...
|
||||
..eyye..
|
||||
..eyle..
|
||||
..eyle..
|
||||
..eyle..
|
||||
..eyle..
|
||||
..eyye..
|
||||
...ee...
|
||||
|
||||
...ee...
|
||||
...ee...
|
||||
...ee...
|
||||
...ee...
|
||||
...ee...
|
||||
...ee...
|
||||
...ee...
|
||||
...ee...
|
||||
|
||||
...ee...
|
||||
..eyye..
|
||||
..elye..
|
||||
..elye..
|
||||
..elye..
|
||||
..elye..
|
||||
..eyye..
|
||||
...ee...
|
||||
`)
|
||||
|
||||
func demoFilmstrip(c *ui.Context, log func(string)) {
|
||||
media.Filmstrip(c, "Coin spin", coin, media.FilmstripOptions{Scale: 3})
|
||||
}
|
||||
|
||||
func demoRecordButton(c *ui.Context, log func(string)) {
|
||||
s := mediaState(c)
|
||||
if media.RecordButton(c, "Record", &s.rec, media.RecordButtonOptions{}).Changed {
|
||||
log(fmt.Sprintf("recording %v", s.rec))
|
||||
}
|
||||
}
|
||||
|
||||
var stations = map[float64]string{88.1: "PIXEL FM", 95.5: "CHIPTUNE RADIO", 101.1: "8-BIT HITS", 104.3: "BOSS BATTLE FM"}
|
||||
|
||||
func demoTuner(c *ui.Context, log func(string)) {
|
||||
k := pixel.K(c)
|
||||
s := mediaState(c)
|
||||
ui.Column(c).Gap(k.D(6)).Children(func() {
|
||||
if media.Tuner(c, "Radio", &s.mhz, media.TunerOptions{}).Changed {
|
||||
log(fmt.Sprintf("%.1f MHz", s.mhz))
|
||||
}
|
||||
on := "· · · static · · ·"
|
||||
if name, ok := stations[s.mhz]; ok {
|
||||
on = "♪ " + name
|
||||
}
|
||||
pixel.Text(c, on, pixel.TextOptions{Slot: pixel.Faint})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package gallery
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
var letters = map[string]ui.Key{"A": ui.KeyA, "Right": ui.KeyRight, "Up": ui.KeyUp, "Enter": ui.KeyEnter}
|
||||
|
||||
func TestMediaDemosLogTheirActions(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
path string
|
||||
steps []string
|
||||
}{
|
||||
{"/c/346-audioplayer", []string{"PLAY", "NEXT"}}, {"/c/347-transport", []string{"PLAY", "STOP", "PREVIOUS"}},
|
||||
{"/c/348-scrubber", []string{"Seek"}}, {"/c/349-volume", []string{"MUTE"}}, {"/c/350-equalizer", []string{"1K", "key:Up"}},
|
||||
{"/c/352-playlist", []string{"Save Point Lullaby, Triangle Wave", "key:Enter"}}, {"/c/353-cassette", []string{"PLAY"}},
|
||||
{"/c/355-vinyl", []string{"PLAY"}}, {"/c/356-pianokeys", []string{"Piano", "key:A"}}, {"/c/357-stepsequencer", []string{"Beat", "PLAY"}},
|
||||
{"/c/358-tracker", []string{"Pattern", "key:A"}}, {"/c/360-videoframe", []string{"PLAY"}},
|
||||
{"/c/361-imagecompare", []string{"Sketch and painting", "key:Right"}}, {"/c/362-gallery", []string{"Views"}},
|
||||
{"/c/364-recordbutton", []string{"Record, 0:00"}}, {"/c/365-tuner", []string{"Radio"}},
|
||||
} {
|
||||
a, tt := open(tc.path)
|
||||
slug := tc.path[3:]
|
||||
for _, step := range tc.steps {
|
||||
before := len(a.logs[slug])
|
||||
if k, ok := strings.CutPrefix(step, "key:"); ok {
|
||||
tt.Key(0, letters[k])
|
||||
} else {
|
||||
click(t, tt, step)
|
||||
}
|
||||
// Clicking a field only to focus it logs nothing.
|
||||
if len(a.logs[slug]) <= before && step != "1K" && step != "Pattern" && step != "Sketch and painting" {
|
||||
t.Errorf("%s: %s logged nothing: %q", slug, step, a.logs[slug])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -56,7 +56,7 @@ var Categories = []Category{
|
||||
{"tabletop", "Tabletop", "桌游", icon.Crown},
|
||||
}
|
||||
|
||||
var entries = slices.Concat(foundationEntries, layoutEntries, textEntries, displayEntries, inputEntries, controlEntries, feedbackEntries, navigationEntries, dataEntries, recordEntries, overlayEntries, popupEntries, datetimeEntries, timeEntries, chartEntries, plotEntries, gameEntries, rpgEntries, screenEntries, lootEntries, fxEntries, screenFxEntries, chatEntries, mailEntries)
|
||||
var entries = slices.Concat(foundationEntries, layoutEntries, textEntries, displayEntries, inputEntries, controlEntries, feedbackEntries, navigationEntries, dataEntries, recordEntries, overlayEntries, popupEntries, datetimeEntries, timeEntries, chartEntries, plotEntries, gameEntries, rpgEntries, screenEntries, lootEntries, fxEntries, screenFxEntries, chatEntries, mailEntries, mediaEntries, studioEntries)
|
||||
|
||||
func Entries() []Entry { return entries }
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ func TestRegistry(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Every exported component function appears in a demo.
|
||||
// Every exported component has a demo; Bases are not components.
|
||||
func TestEveryComponentHasADemo(t *testing.T) {
|
||||
var demos strings.Builder
|
||||
for _, e := range Entries() {
|
||||
@@ -58,7 +58,7 @@ func TestEveryComponentHasADemo(t *testing.T) {
|
||||
}
|
||||
for _, d := range file.Decls {
|
||||
fn, ok := d.(*ast.FuncDecl)
|
||||
if !ok || fn.Recv != nil || !fn.Name.IsExported() || fn.Type.Params.NumFields() == 0 {
|
||||
if !ok || fn.Recv != nil || !fn.Name.IsExported() || strings.HasSuffix(fn.Name.Name, "Base") || fn.Type.Params.NumFields() == 0 {
|
||||
continue
|
||||
}
|
||||
if p, ok := fn.Type.Params.List[0].Type.(*ast.StarExpr); ok {
|
||||
|
||||
+16
-1
@@ -39,6 +39,20 @@ func Slider(c *ui.Context, text string, value *float64, o SliderOptions) SliderR
|
||||
return report(shell, changed, "slider", text, *value)
|
||||
}
|
||||
|
||||
// RailOptions shape a SlideBase.
|
||||
type RailOptions struct {
|
||||
// Zeros mean 0 to 1; a zero Step glides.
|
||||
Min, Max, Step float64
|
||||
Vertical bool
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
// SlideBase is an undrawn slider: pointer and keys set *value.
|
||||
func SlideBase(c *ui.Context, text string, value *float64, o RailOptions) (shell *ui.Element, st pixel.State, changed bool) {
|
||||
lo, hi := bounds(o.Min, o.Max)
|
||||
return slide(c, value, rail{lo: lo, hi: hi, step: o.Step, vertical: o.Vertical, label: text, disabled: o.Disabled})
|
||||
}
|
||||
|
||||
// rail maps a slide's pointer and keys to a value.
|
||||
type rail struct {
|
||||
lo, hi, step float64
|
||||
@@ -54,7 +68,8 @@ func slide(c *ui.Context, v *float64, r rail) (*ui.Element, pixel.State, bool) {
|
||||
})
|
||||
old := *v
|
||||
set := r.snap(v)
|
||||
if !r.disabled {
|
||||
// The base, not the option: Inert disables it too.
|
||||
if !base.IsDisabled() {
|
||||
if _, _, held := base.Dragged(); held {
|
||||
px, py, _ := base.PointerPosition()
|
||||
b := base.Bounds()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
+3
-3
@@ -193,7 +193,7 @@ func hanging(punct func(rune) bool) func(rune) bool {
|
||||
}
|
||||
|
||||
func TestMicroFont(t *testing.T) {
|
||||
set := "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ+-.:/%! "
|
||||
set := "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ+-#.:/%! "
|
||||
seen := map[[MicroHeight]uint8]rune{}
|
||||
for _, r := range set {
|
||||
g := microGlyph(r)
|
||||
@@ -213,8 +213,8 @@ func TestMicroFont(t *testing.T) {
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("micro '#' did not panic")
|
||||
t.Fatal("micro '@' did not panic")
|
||||
}
|
||||
}()
|
||||
MicroWidth("#")
|
||||
MicroWidth("@")
|
||||
}
|
||||
@@ -34,6 +34,7 @@ var messages = map[Locale]map[string]string{
|
||||
"from": "FROM", "subject": "SUBJECT", "date": "DATE", "faces": "FACES", "nature": "NATURE", "items": "ITEMS",
|
||||
"mute": "MUTE", "camera": "CAMERA", "hang_up": "HANG UP", "new_count": "%d NEW", "votes": "%d VOTES", "unread": "%d UNREAD",
|
||||
"day_date": "%[1]s %[2]d %[3]s", "category": "CATEGORY", "days": "SUN MON TUE WED THU FRI SAT",
|
||||
"prev_track": "PREVIOUS", "next_track": "NEXT", "play": "PLAY", "volume": "VOLUME", "rec": "REC", "playing": "PLAYING", "seek": "SEEK",
|
||||
},
|
||||
Zh: {
|
||||
"ok": "确定", "cancel": "取消", "yes": "是", "no": "否", "close": "关闭",
|
||||
@@ -66,6 +67,7 @@ var messages = map[Locale]map[string]string{
|
||||
"from": "发件人", "subject": "主题", "date": "日期", "faces": "表情", "nature": "万物", "items": "道具",
|
||||
"mute": "静音", "camera": "摄像头", "hang_up": "挂断", "new_count": "%d 条新", "votes": "%d 票", "unread": "%d 未读",
|
||||
"day_date": "%[4]d月%[2]d日 周%[1]s", "category": "类别", "days": "日 一 二 三 四 五 六",
|
||||
"prev_track": "上一首", "next_track": "下一首", "play": "播放", "volume": "音量", "rec": "录制", "playing": "正在播放", "seek": "进度",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ var micro = microFont(map[rune]string{
|
||||
'Z': "###|..#|.#.|#..|###",
|
||||
'+': "...|.#.|###|.#.|...",
|
||||
'-': "...|...|###|...|...",
|
||||
'#': "#.#|###|#.#|###|#.#",
|
||||
'.': "...|...|...|...|.#.",
|
||||
':': "...|.#.|...|.#.|...",
|
||||
'/': "..#|..#|.#.|#..|#..",
|
||||
|
||||
+5
-1
@@ -9,6 +9,11 @@ func Pad(c *ui.Context, text string, cols, n, cw, ch int, cur *int, disabled boo
|
||||
return ui.Box(c).Focusable().Role(ui.RoleGroup).Label(text).Disabled(disabled)
|
||||
})
|
||||
shell.Size(k.D(cols*cw), k.D((n+cols-1)/cols*ch))
|
||||
picked = -1
|
||||
// Disabled or inert, even a key already sent waits.
|
||||
if base.IsDisabled() {
|
||||
return shell, base, st, picked
|
||||
}
|
||||
for _, m := range []struct {
|
||||
key ui.Key
|
||||
to func() int
|
||||
@@ -21,7 +26,6 @@ func Pad(c *ui.Context, text string, cols, n, cw, ch int, cur *int, disabled boo
|
||||
*cur = to
|
||||
}
|
||||
}
|
||||
picked = -1
|
||||
enter, space := base.Shortcut(0, ui.KeyEnter), base.Shortcut(0, ui.KeySpace)
|
||||
if enter || space {
|
||||
picked = *cur
|
||||
|
||||
Reference in new issue
Block a user