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

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

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

No files matched your search

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