diff --git a/AGENTS.md b/AGENTS.md index 42bd406..53d8b40 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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_.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_.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 diff --git a/data/data_test.go b/data/data_test.go index 9041345..39d453c 100644 --- a/data/data_test.go +++ b/data/data_test.go @@ -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) + } + } + } + } +} diff --git a/gallery/demos_media.go b/gallery/demos_media.go new file mode 100644 index 0000000..763d3cc --- /dev/null +++ b/gallery/demos_media.go @@ -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 + }) +} diff --git a/gallery/demos_studio.go b/gallery/demos_studio.go new file mode 100644 index 0000000..f6e87cc --- /dev/null +++ b/gallery/demos_studio.go @@ -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}) + }) +} diff --git a/gallery/media_demo_test.go b/gallery/media_demo_test.go new file mode 100644 index 0000000..5fb8e3f --- /dev/null +++ b/gallery/media_demo_test.go @@ -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]) + } + } + } +} diff --git a/gallery/registry.go b/gallery/registry.go index 286ed18..b76a9a4 100644 --- a/gallery/registry.go +++ b/gallery/registry.go @@ -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 } diff --git a/gallery/registry_test.go b/gallery/registry_test.go index 0f49506..ad4d1bf 100644 --- a/gallery/registry_test.go +++ b/gallery/registry_test.go @@ -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 { diff --git a/input/slider.go b/input/slider.go index 2371df3..f04acb4 100644 --- a/input/slider.go +++ b/input/slider.go @@ -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() diff --git a/media/audioplayer.go b/media/audioplayer.go new file mode 100644 index 0000000..0f5688d --- /dev/null +++ b/media/audioplayer.go @@ -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 +} diff --git a/media/cartridge.go b/media/cartridge.go new file mode 100644 index 0000000..c59efb4 --- /dev/null +++ b/media/cartridge.go @@ -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 +} diff --git a/media/cassette.go b/media/cassette.go new file mode 100644 index 0000000..35ceb9b --- /dev/null +++ b/media/cassette.go @@ -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) + } + }) +} diff --git a/media/equalizer.go b/media/equalizer.go new file mode 100644 index 0000000..4affb09 --- /dev/null +++ b/media/equalizer.go @@ -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 +} diff --git a/media/filmstrip.go b/media/filmstrip.go new file mode 100644 index 0000000..8b2c7a1 --- /dev/null +++ b/media/filmstrip.go @@ -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) + } + }) +} diff --git a/media/gallery.go b/media/gallery.go new file mode 100644 index 0000000..8867d0f --- /dev/null +++ b/media/gallery.go @@ -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 +} diff --git a/media/guards_test.go b/media/guards_test.go new file mode 100644 index 0000000..e39fdf1 --- /dev/null +++ b/media/guards_test.go @@ -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() {}) + }) + }) +} diff --git a/media/imagecompare.go b/media/imagecompare.go new file mode 100644 index 0000000..e50ca0e --- /dev/null +++ b/media/imagecompare.go @@ -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} +} diff --git a/media/instruments_test.go b/media/instruments_test.go new file mode 100644 index 0000000..90b27e2 --- /dev/null +++ b/media/instruments_test.go @@ -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") + } +} diff --git a/media/media.go b/media/media.go new file mode 100644 index 0000000..2c7ad18 --- /dev/null +++ b/media/media.go @@ -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 +} diff --git a/media/media_test.go b/media/media_test.go new file mode 100644 index 0000000..8b57c0a --- /dev/null +++ b/media/media_test.go @@ -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) + } +} diff --git a/media/notes.go b/media/notes.go new file mode 100644 index 0000000..bc15640 --- /dev/null +++ b/media/notes.go @@ -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 +} diff --git a/media/pianokeys.go b/media/pianokeys.go new file mode 100644 index 0000000..6e082e4 --- /dev/null +++ b/media/pianokeys.go @@ -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] +} diff --git a/media/pictures_test.go b/media/pictures_test.go new file mode 100644 index 0000000..a8fa5aa --- /dev/null +++ b/media/pictures_test.go @@ -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{}) }) + }) +} diff --git a/media/playlist.go b/media/playlist.go new file mode 100644 index 0000000..dfe492b --- /dev/null +++ b/media/playlist.go @@ -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} +} diff --git a/media/recordbutton.go b/media/recordbutton.go new file mode 100644 index 0000000..e5d51ac --- /dev/null +++ b/media/recordbutton.go @@ -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 +} diff --git a/media/scrubber.go b/media/scrubber.go new file mode 100644 index 0000000..307ff81 --- /dev/null +++ b/media/scrubber.go @@ -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 +} diff --git a/media/stepsequencer.go b/media/stepsequencer.go new file mode 100644 index 0000000..593f970 --- /dev/null +++ b/media/stepsequencer.go @@ -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 +} diff --git a/media/tracker.go b/media/tracker.go new file mode 100644 index 0000000..805ca5f --- /dev/null +++ b/media/tracker.go @@ -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) +} diff --git a/media/transport.go b/media/transport.go new file mode 100644 index 0000000..eb6a80a --- /dev/null +++ b/media/transport.go @@ -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 +} diff --git a/media/tuner.go b/media/tuner.go new file mode 100644 index 0000000..e24fbd8 --- /dev/null +++ b/media/tuner.go @@ -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 +} diff --git a/media/videoframe.go b/media/videoframe.go new file mode 100644 index 0000000..972135b --- /dev/null +++ b/media/videoframe.go @@ -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 +} diff --git a/media/views_test.go b/media/views_test.go new file mode 100644 index 0000000..588b983 --- /dev/null +++ b/media/views_test.go @@ -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) }) + } +} diff --git a/media/vinyl.go b/media/vinyl.go new file mode 100644 index 0000000..3d49784 --- /dev/null +++ b/media/vinyl.go @@ -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) + }) +} diff --git a/media/visualizer.go b/media/visualizer.go new file mode 100644 index 0000000..354f64a --- /dev/null +++ b/media/visualizer.go @@ -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) + } + }) +} diff --git a/media/volume.go b/media/volume.go new file mode 100644 index 0000000..cd1fe6f --- /dev/null +++ b/media/volume.go @@ -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 +} diff --git a/media/waveform.go b/media/waveform.go new file mode 100644 index 0000000..ff1d811 --- /dev/null +++ b/media/waveform.go @@ -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) + } + }) +} diff --git a/pixel/font_test.go b/pixel/font_test.go index f84148c..57f3805 100644 --- a/pixel/font_test.go +++ b/pixel/font_test.go @@ -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("@") } diff --git a/pixel/messages.go b/pixel/messages.go index ccd785a..2d1957b 100644 --- a/pixel/messages.go +++ b/pixel/messages.go @@ -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": "进度", }, } diff --git a/pixel/micro.go b/pixel/micro.go index de25bc8..1793fa1 100644 --- a/pixel/micro.go +++ b/pixel/micro.go @@ -52,6 +52,7 @@ var micro = microFont(map[rune]string{ 'Z': "###|..#|.#.|#..|###", '+': "...|.#.|###|.#.|...", '-': "...|...|###|...|...", + '#': "#.#|###|#.#|###|#.#", '.': "...|...|...|...|.#.", ':': "...|.#.|...|.#.|...", '/': "..#|..#|.#.|#..|#..", diff --git a/pixel/pad.go b/pixel/pad.go index 089b192..135d130 100644 --- a/pixel/pad.go +++ b/pixel/pad.go @@ -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