Media: components 341-360

This commit is contained in:
ZacharyZhang-NY committed 2026-10-05 14:33:40 -04:00
1 parent 6f0c02d339
commit 85575ea2e2
111 files changed
+6831 -1

No files matched your search

+84
View File
@@ -0,0 +1,84 @@
package mujica
import (
"log/slog"
"time"
"github.com/egoist/mygo/ui"
"mujicaui/theme"
)
// AudioState is what the caller's playback reports.
type AudioState struct {
Title string
Position time.Duration
Duration time.Duration
Playing bool
}
// AudioPlayerOptions are the callbacks a player shell reports to.
type AudioPlayerOptions struct {
OnPlay, OnPause func()
OnSeek func(time.Duration)
Disabled bool
}
// AudioPlayer is a play, pause and seek shell.
func AudioPlayer(c *ui.Context, st AudioState, opts AudioPlayerOptions) *ui.Element {
if opts.OnPlay == nil || opts.OnPause == nil || opts.OnSeek == nil {
panic("mujica: AudioPlayer needs OnPlay, OnPause and OnSeek")
}
k := Tokens(c)
u := Density(c).Unit()
row := ui.Row(c).Gap(u*3).AlignItems(ui.Center).MinHeight(Density(c).ControlHeight()).Padding(u*2, u*3).
Radius(theme.CardRadius).Background(k.Surface).Border(theme.BorderWidth, k.Border).Role(ui.RoleGroup).
Disabled(opts.Disabled)
if st.Title != "" {
row.Label(st.Title)
}
row.Children(func() {
mediaPlayToggle(c, st.Playing, opts.OnPlay, opts.OnPause)
ui.Column(c).Grow(1).MinWidth(0).Gap(2).Children(func() {
if st.Title != "" {
ui.Text(c, st.Title).SingleLine()
}
if st.Duration > 0 {
pos := min(max(0, st.Position), st.Duration)
if VideoScrubber(c, &pos, VideoScrubberOptions{Duration: st.Duration, Label: st.Title}).Changed() {
opts.OnSeek(pos)
}
}
})
ui.Text(c, mediaClock(st.Position)+" / "+mediaClock(st.Duration)).FontSize(FontSize(c, theme.CaptionSize)).
TextColor(k.TextMuted).FontFeatures("tnum").SingleLine()
})
// Space plays or pauses wherever the focus is inside.
if !row.IsDisabled() && row.Shortcut(0, ui.KeySpace) {
mediaToggle(st.Playing, opts.OnPlay, opts.OnPause)
}
return row
}
// mediaPlayToggle is the play/pause button; it calls the matching callback.
func mediaPlayToggle(c *ui.Context, playing bool, onPlay, onPause func()) *ui.Element {
icon, label := mediaPlay, msg(c, "media_controls.play")
if playing {
icon, label = mediaPause, msg(c, "media_controls.pause")
}
b := IconButton(c, icon, label, IconButtonOptions{})
if b.Clicked() && !b.IsDisabled() {
mediaToggle(playing, onPlay, onPause)
}
return b
}
func mediaToggle(playing bool, onPlay, onPause func()) {
if playing {
slog.Debug("mujica: media pause")
onPause()
return
}
slog.Debug("mujica: media play")
onPlay()
}
+89
View File
@@ -0,0 +1,89 @@
package mujica
import (
"testing"
"time"
"github.com/egoist/mygo/ui"
)
type mediaCalls struct {
play, pause int
seeks []time.Duration
}
func (m *mediaCalls) opts() AudioPlayerOptions {
return AudioPlayerOptions{OnPlay: func() { m.play++ }, OnPause: func() { m.pause++ }, OnSeek: func(d time.Duration) { m.seeks = append(m.seeks, d) }}
}
func audioTester(st *AudioState, m *mediaCalls, disabled bool) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
o := m.opts()
o.Disabled = disabled
ui.Column(c).Padding(20).Children(func() { AudioPlayer(c, *st, o) })
}, 600, 200)
}
func TestAudioPlayerPlayPause(t *testing.T) {
st := AudioState{Title: "Ave Mujica", Position: 30 * time.Second, Duration: 3 * time.Minute}
var m mediaCalls
tt := audioTester(&st, &m, false)
if err := tt.Click("Play"); err != nil {
t.Fatal(err)
}
st.Playing = true
tt.Frame()
if err := tt.Click("Pause"); err != nil {
t.Fatal(err)
}
if m.play != 1 || m.pause != 1 {
t.Fatalf("play %d pause %d", m.play, m.pause)
}
if !tt.HasText("0:30 / 3:00") {
t.Fatalf("texts %v", tt.Texts())
}
}
func TestAudioPlayerKeyboard(t *testing.T) {
st := AudioState{Title: "Ave Mujica", Position: 30 * time.Second, Duration: 3 * time.Minute}
var m mediaCalls
tt := audioTester(&st, &m, false)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyRight)
if len(m.seeks) != 1 || m.seeks[0] != 35*time.Second {
t.Fatalf("seeks %v", m.seeks)
}
tt.Key(0, ui.KeySpace)
if m.play != 1 {
t.Fatalf("Space on the timeline: play %d", m.play)
}
}
func TestAudioPlayerDisabled(t *testing.T) {
st := AudioState{Title: "Ave Mujica", Duration: time.Minute}
var m mediaCalls
tt := audioTester(&st, &m, true)
_ = tt.Click("Play")
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeySpace)
tt.Key(0, ui.KeyRight)
if m.play != 0 || len(m.seeks) != 0 {
t.Fatalf("disabled player reported %+v", m)
}
}
func TestAudioPlayerNeedsCallbacks(t *testing.T) {
mediaPanics(t, "no callbacks", func(c *ui.Context) { AudioPlayer(c, AudioState{}, AudioPlayerOptions{}) })
}
func TestAudioPlayerSnapshot(t *testing.T) {
var m mediaCalls
snapshot(t, "audio_player", 560, 200, func(c *ui.Context) {
ui.Column(c).Padding(20).Gap(12).Children(func() {
AudioPlayer(c, AudioState{Title: "Ave Mujica — Overture", Position: 72 * time.Second, Duration: 4 * time.Minute, Playing: true}, m.opts())
AudioPlayer(c, AudioState{Title: "Imprisoned XII", Duration: 5 * time.Minute}, m.opts())
})
})
}
+146
View File
@@ -0,0 +1,146 @@
package mujica
import (
"fmt"
"math"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
)
// AudioSpectrumOptions configure an AudioSpectrum.
type AudioSpectrumOptions struct {
// Height is the chart's height; zero means 120.
Height float32
// BandLabel names band i in the readout; nil numbers them.
BandLabel func(i int) string
// Status and Error show loading and failure at one size.
Status DataStatus
Error string
// Label names the chart for assistive technology.
Label string
}
type spectrumKey struct{}
// AudioSpectrum draws caller bands as bars; it never animates itself.
func AudioSpectrum(c *ui.Context, bands []float32, opts AudioSpectrumOptions) *ui.Element {
for _, b := range bands {
if b < 0 || b > 1 || math.IsNaN(float64(b)) {
panic("mujica: AudioSpectrum band outside 0..1")
}
}
h := opts.Height
if h < 0 {
panic("mujica: AudioSpectrum Height must not be negative")
}
if h == 0 {
h = 120
}
k := Tokens(c)
label := opts.Label
if label == "" {
label = msg(c, "audio_spectrum.label")
}
e := ui.Box(c).Height(h).MinWidth(120).Radius(theme.ControlRadius).Background(k.Surface).
Border(theme.BorderWidth, k.Border).Focusable().Label(label)
internal.Ring(e, k.Focus, theme.ControlRadius)
if mediaChartState(c, e, opts.Status, opts.Error, len(bands) == 0, "audio_spectrum.empty") {
return e
}
cur := ui.Local(e, spectrumKey{}, func() int { return -1 })
*cur = min(*cur, len(bands)-1)
name := func(i int) string {
if opts.BandLabel != nil {
return opts.BandLabel(i)
}
return fmt.Sprintf(msg(c, "audio_spectrum.band"), i+1)
}
if moved, ok := chartCursor(e, cur, len(bands)); ok && moved {
c.Announce(spectrumReadout(name(*cur), bands[*cur]))
}
readout := ""
if *cur >= 0 {
readout = spectrumReadout(name(*cur), bands[*cur])
e.Description(readout)
}
sel, size := *cur, FontSize(c, theme.CaptionSize)
e.Draw(func(p *ui.Painter, r ui.Rect) {
pad := float32(8)
n := float32(len(bands))
w := (r.W - 2*pad) / n
base := r.Y + r.H - pad
for i, b := range bands {
x := internal.Snap(p, r.X+pad+float32(i)*w)
bh := internal.Snap(p, (r.H-2*pad-16)*b)
col := k.AccentText
if i == sel {
col = k.Text
}
p.Fill(ui.Rect{X: x + 1, Y: base - bh, W: max(1, w-2), H: bh}, col, 0)
}
p.Fill(ui.Rect{X: r.X + pad, Y: base, W: r.W - 2*pad, H: 1}, k.Border, 0)
if sel >= 0 {
x := r.X + pad + float32(sel)*w
p.StrokeDashed(ui.Rect{X: x, Y: r.Y + pad, W: w, H: base - r.Y - pad}, k.TextMuted, 0, 1)
p.Text(r.X+pad, r.Y+4, readout, size, k.Text)
}
})
return e
}
func spectrumReadout(name string, v float32) string {
return fmt.Sprintf("%s: %d%%", name, int(math.Round(float64(v*100))))
}
// chartCursor moves *cur by arrows, Home and End.
func chartCursor(e *ui.Element, cur *int, n int) (moved, ok bool) {
if e.IsDisabled() || n == 0 {
return false, false
}
old := *cur
switch {
case e.Shortcut(0, ui.KeyRight):
*cur = min(n-1, *cur+1)
case e.Shortcut(0, ui.KeyLeft):
*cur = max(0, *cur-1)
case e.Shortcut(0, ui.KeyHome):
*cur = 0
case e.Shortcut(0, ui.KeyEnd):
*cur = n - 1
default:
return false, false
}
return *cur != old, true
}
// mediaChartState fills e with a loading, error or empty state.
func mediaChartState(c *ui.Context, e *ui.Element, status DataStatus, errText string, empty bool, emptyKey string) bool {
k := Tokens(c)
switch {
case status == DataLoading:
e.Center().Children(func() { Spinner(c, SpinnerOptions{ShowLabel: true}) })
case status == DataFailed:
if errText == "" {
panic("mujica: a failed media chart needs Error")
}
e.Center().Description(errText).Children(func() {
ui.Row(c).Gap(6).AlignItems(ui.Center).Children(func() {
ui.Icon(c, icons.Must("circle-alert")).Size(16, 16).TextColor(k.Danger)
ui.Text(c, errText).TextColor(k.Danger).MaxLines(2)
})
})
case status != DataReady:
panic(fmt.Sprintf("mujica: media chart Status %d is not Ready, Loading or Failed", status))
case empty:
e.Center().Description(msg(c, emptyKey)).Children(func() {
ui.Text(c, msg(c, emptyKey)).TextColor(k.TextMuted)
})
default:
return false
}
return true
}
+74
View File
@@ -0,0 +1,74 @@
package mujica
import (
"slices"
"testing"
"github.com/egoist/mygo/ui"
)
func TestAudioSpectrumKeyboardReadout(t *testing.T) {
bands := []float32{0.2, 0.9, 0.5}
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Padding(20).Width(300).Children(func() {
AudioSpectrum(c, bands, AudioSpectrumOptions{BandLabel: func(i int) string { return []string{"63 Hz", "1 kHz", "8 kHz"}[i] }})
})
}, 400, 200)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeyEnd)
if got := tt.Announcements(); !slices.Equal(got, []string{"63 Hz: 20%", "1 kHz: 90%", "8 kHz: 50%"}) {
t.Fatalf("announced %v", got)
}
}
func TestAudioSpectrumNeverAnimates(t *testing.T) {
builds := 0
bands := []float32{0.3, 0.6}
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
builds++
AudioSpectrum(c, bands, AudioSpectrumOptions{})
}, 300, 200)
start := builds
tt.Frame()
if builds-start != 1 {
t.Fatalf("one frame built %d times: the spectrum asked for more", builds-start)
}
}
func TestAudioSpectrumStates(t *testing.T) {
var got []ui.Rect
for _, o := range []AudioSpectrumOptions{{}, {Status: DataLoading}, {Status: DataFailed, Error: "Device lost"}} {
var r ui.Rect
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Width(300).Children(func() { r = AudioSpectrum(c, nil, o).Bounds() })
}, 400, 200)
tt.Frame()
got = append(got, r)
if o.Status == DataFailed && !tt.HasText("Device lost") {
t.Fatal("error not shown")
}
if o.Status == DataReady && !tt.HasText("No signal") {
t.Fatal("empty state not shown")
}
}
if got[1] != got[0] || got[2] != got[0] {
t.Fatalf("state sizes %v", got)
}
mediaPanics(t, "band over 1", func(c *ui.Context) { AudioSpectrum(c, []float32{2}, AudioSpectrumOptions{}) })
mediaPanics(t, "failed without error", func(c *ui.Context) { AudioSpectrum(c, nil, AudioSpectrumOptions{Status: DataFailed}) })
}
func TestAudioSpectrumSnapshot(t *testing.T) {
bands := make([]float32, 32)
for i := range bands {
bands[i] = float32(0.15 + 0.8*float64((i*37)%29)/29)
}
snapshot(t, "audio_spectrum", 420, 160, func(c *ui.Context) {
ui.Column(c).Padding(20).Width(380).Children(func() { AudioSpectrum(c, bands, AudioSpectrumOptions{}) })
})
}
+187
View File
@@ -0,0 +1,187 @@
package mujica
import (
"log/slog"
"math"
"time"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
// AudioWaveformOptions configure an AudioWaveform.
type AudioWaveformOptions struct {
// Duration is the audio's length; required when data is ready.
Duration time.Duration
// Position draws the played part; OnSeek reports clicks and arrows.
Position time.Duration
OnSeek func(time.Duration)
// Selection, when set, is chosen by dragging or Shift+arrows.
Selection *MediaRange
// Height is the chart's height; zero means 96.
Height float32
Status DataStatus
Error string
Label string
}
// waveformPeaks caches column peaks until data or width change.
type waveformPeaks struct {
first *float32
n int
cols int
peaks []float32
}
type waveformKey struct{}
type waveformPress struct {
on, dragged bool
x float32
}
// waveformBuckets counts peak rebuilds, for tests.
var waveformBuckets int
// AudioWaveform draws samples with played part and selectable range.
func AudioWaveform(c *ui.Context, samples []float32, opts AudioWaveformOptions) ChoiceResult {
h := opts.Height
if h < 0 || opts.Position < 0 {
panic("mujica: AudioWaveform Height and Position must not be negative")
}
if h == 0 {
h = 96
}
k := Tokens(c)
label := opts.Label
if label == "" {
label = msg(c, "audio_waveform.label")
}
e := ui.Box(c).Height(h).MinWidth(120).Radius(theme.ControlRadius).Background(k.Surface).
Border(theme.BorderWidth, k.Border).Focusable().Label(label).Cursor(ui.CursorText)
internal.Ring(e, k.Focus, theme.ControlRadius)
v := ChoiceResult{Element: e}
if mediaChartState(c, e, opts.Status, opts.Error, len(samples) == 0, "audio_waveform.empty") {
return v
}
if opts.Duration <= 0 || opts.OnSeek == nil || opts.Position > opts.Duration {
panic("mujica: AudioWaveform needs Duration > 0, OnSeek and Position <= Duration")
}
if s := opts.Selection; s != nil && (s.Start < 0 || s.End < s.Start || s.End > opts.Duration) {
panic("mujica: AudioWaveform Selection outside 0..Duration")
}
r := e.Bounds()
cols := int(r.W)
cache := ui.Local(e, waveformKey{}, func() waveformPeaks { return waveformPeaks{} })
if cols <= 0 {
// Peaks need the width: lay out first, then build again.
c.AnimationFrame()
} else if cache.first != &samples[0] || cache.n != len(samples) || cache.cols != cols {
*cache = waveformPeaks{&samples[0], len(samples), cols, waveformBucket(samples, cols)}
}
if !e.IsDisabled() {
v.changed = waveformInput(c, e, r, opts)
}
e.Description(mediaClock(opts.Position) + " / " + mediaClock(opts.Duration))
peaks, pos, sel := cache.peaks, float32(opts.Position)/float32(opts.Duration), opts.Selection
dur := float32(opts.Duration)
e.Draw(func(p *ui.Painter, r ui.Rect) {
mid := r.Y + r.H/2
amp := r.H/2 - 6
if sel != nil && sel.End > sel.Start {
x0, x1 := r.X+r.W*float32(sel.Start)/dur, r.X+r.W*float32(sel.End)/dur
p.Fill(ui.Rect{X: x0, Y: r.Y, W: x1 - x0, H: r.H}, k.Selection, 0)
p.Fill(ui.Rect{X: x0, Y: r.Y, W: 1, H: r.H}, k.AccentText, 0)
p.Fill(ui.Rect{X: x1 - 1, Y: r.Y, W: 1, H: r.H}, k.AccentText, 0)
}
played := r.X + r.W*pos
for i, pk := range peaks {
x := r.X + r.W*float32(i)/float32(len(peaks))
col := k.TextMuted
if x < played {
col = k.AccentText
}
ph := max(1, amp*pk)
p.Fill(ui.Rect{X: x, Y: mid - ph, W: max(1, r.W/float32(len(peaks))-0.5), H: 2 * ph}, col, 0)
}
px := internal.Snap(p, played)
p.Fill(ui.Rect{X: px - 1, Y: r.Y, W: 2, H: r.H}, k.Text, 0)
p.FillPath(internal.Diamond(px, internal.Snap(p, r.Y+5), 8), k.Text)
})
return v
}
// waveformBucket returns the largest magnitude in each of cols spans.
func waveformBucket(samples []float32, cols int) []float32 {
waveformBuckets++
peaks := make([]float32, cols)
for i, s := range samples {
col := i * cols / len(samples)
peaks[col] = max(peaks[col], float32(math.Abs(float64(s))))
}
return peaks
}
// waveformInput seeks and selects; true means the selection changed.
func waveformInput(c *ui.Context, e *ui.Element, r ui.Rect, opts AudioWaveformOptions) bool {
at := func(x float32) time.Duration {
f := max(0, min(1, (x-r.X)/max(1, r.W)))
return time.Duration(float64(f) * float64(opts.Duration)).Round(time.Millisecond)
}
seek := func(t time.Duration) {
t = max(0, min(opts.Duration, t))
slog.Debug("mujica: audio_waveform seek", "pos", t)
opts.OnSeek(t)
}
sel := opts.Selection
old := MediaRange{}
if sel != nil {
old = *sel
}
px, _, _ := e.PointerPosition()
px += r.X
st := ui.Local(e, waveformPress{}, func() waveformPress { return waveformPress{} })
_, _, dragging := e.Dragged()
switch {
case e.Pressed() && !st.on:
*st = waveformPress{on: true, x: px}
case e.Pressed() && sel != nil && (dragging || st.dragged) && math.Abs(float64(px-st.x)) > 3:
st.dragged = true
a, b := at(st.x), at(px)
*sel = MediaRange{min(a, b), max(a, b)}
case !e.Pressed() && st.on:
if !st.dragged {
seek(at(st.x))
}
*st = waveformPress{}
}
const step = 5 * time.Second
switch {
case e.Shortcut(0, ui.KeyLeft):
seek(opts.Position - step)
case e.Shortcut(0, ui.KeyRight):
seek(opts.Position + step)
case sel != nil && e.Shortcut(ui.Shift, ui.KeyRight):
*sel = waveformExtend(*sel, opts.Position, step, opts.Duration)
case sel != nil && e.Shortcut(ui.Shift, ui.KeyLeft):
*sel = waveformExtend(*sel, opts.Position, -step, opts.Duration)
case sel != nil && e.Shortcut(0, ui.KeyEscape):
*sel = MediaRange{}
}
changed := sel != nil && *sel != old
if changed {
slog.Debug("mujica: audio_waveform select", "start", sel.Start, "end", sel.End)
}
return changed
}
// waveformExtend starts a selection at pos or moves its end.
func waveformExtend(s MediaRange, pos, d, dur time.Duration) MediaRange {
if s.End <= s.Start {
s = MediaRange{pos, pos}
}
s.End = max(s.Start, min(dur, s.End+d))
return s
}
+131
View File
@@ -0,0 +1,131 @@
package mujica
import (
"math"
"testing"
"time"
"github.com/egoist/mygo/ui"
)
func waveSamples(n int) []float32 {
s := make([]float32, n)
for i := range s {
s[i] = float32(math.Sin(float64(i)/40) * (0.3 + 0.6*math.Abs(math.Sin(float64(i)/3000))))
}
return s
}
func waveTester(samples []float32, opts *AudioWaveformOptions, n *int) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Padding(20).Width(400).Children(func() {
if AudioWaveform(c, samples, *opts).Changed() {
*n++
}
})
}, 500, 200)
}
func TestAudioWaveformSeekAndSelect(t *testing.T) {
var seeks []time.Duration
sel := MediaRange{}
opts := AudioWaveformOptions{Duration: 100 * time.Second, Position: 10 * time.Second, Selection: &sel,
OnSeek: func(d time.Duration) { seeks = append(seeks, d) }}
n := 0
tt := waveTester(waveSamples(5000), &opts, &n)
r, ok := tt.Find("Waveform")
if !ok {
t.Fatal("not named")
}
tt.ClickAt(r.X+r.W/2, r.Y+r.H/2)
if len(seeks) != 1 || seeks[0] < 48*time.Second || seeks[0] > 52*time.Second {
t.Fatalf("click seeks %v", seeks)
}
tt.Press(r.X+r.W*0.2, r.Y+r.H/2)
tt.Move(r.X+r.W*0.3, r.Y+r.H/2)
tt.Move(r.X+r.W*0.4, r.Y+r.H/2)
tt.Release(r.X+r.W*0.4, r.Y+r.H/2)
if sel.Start < 18*time.Second || sel.Start > 22*time.Second || sel.End < 38*time.Second || sel.End > 42*time.Second || len(seeks) != 1 {
t.Fatalf("drag selection %+v, seeks %v", sel, seeks)
}
tt.Key(0, ui.KeyEscape)
tt.Key(ui.Shift, ui.KeyRight)
if sel != (MediaRange{10 * time.Second, 15 * time.Second}) || n < 3 {
t.Fatalf("Shift+Right selection %+v, changes %d", sel, n)
}
tt.Key(0, ui.KeyRight)
if seeks[len(seeks)-1] != 15*time.Second {
t.Fatalf("Right seek %v", seeks)
}
}
func TestAudioWaveformComputesPeaksOnce(t *testing.T) {
samples := waveSamples(100000)
opts := AudioWaveformOptions{Duration: time.Minute, OnSeek: func(time.Duration) {}}
n := 0
tt := waveTester(samples, &opts, &n)
before := waveformBuckets
for range 5 {
opts.Position += time.Second
tt.Frame()
}
if got := waveformBuckets - before; got != 0 {
t.Fatalf("peaks rebuilt %d times without new data", got)
}
samples = waveSamples(100000)
tt = waveTester(samples, &opts, &n)
tt.Frame()
if waveformBuckets-before == 0 {
t.Fatal("new data did not rebuild peaks")
}
}
func TestAudioWaveformStatesKeepSize(t *testing.T) {
var got []ui.Rect
seek := func(time.Duration) {}
for _, o := range []AudioWaveformOptions{{Duration: time.Second, OnSeek: seek}, {Status: DataLoading}, {Status: DataFailed, Error: "Decode failed"}} {
var r ui.Rect
data := waveSamples(100)
if o.Status != DataReady {
data = nil
}
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Width(300).Children(func() { r = AudioWaveform(c, data, o).Element.Bounds() })
}, 400, 200)
tt.Frame()
got = append(got, r)
}
if got[1] != got[0] || got[2] != got[0] {
t.Fatalf("state sizes %v", got)
}
}
func TestAudioWaveformDisabled(t *testing.T) {
var seeks []time.Duration
opts := AudioWaveformOptions{Duration: time.Minute, OnSeek: func(d time.Duration) { seeks = append(seeks, d) }}
n := 0
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Disabled(true).Width(300).Children(func() { AudioWaveform(c, waveSamples(500), opts) })
}, 400, 200)
r, _ := tt.Find("Waveform")
tt.ClickAt(r.X+10, r.Y+10)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyRight)
if len(seeks) != 0 || n != 0 {
t.Fatalf("disabled waveform seeked %v", seeks)
}
}
func TestAudioWaveformSnapshot(t *testing.T) {
samples := waveSamples(20000)
sel := MediaRange{50 * time.Second, 70 * time.Second}
snapshot(t, "audio_waveform", 460, 150, func(c *ui.Context) {
ui.Column(c).Padding(20).Width(420).Children(func() {
AudioWaveform(c, samples, AudioWaveformOptions{Duration: 2 * time.Minute, Position: 40 * time.Second,
Selection: &sel, OnSeek: func(time.Duration) {}})
})
})
}
+93
View File
@@ -0,0 +1,93 @@
package mujica
import (
"image"
"log/slog"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
// CameraFrame is the caller's latest camera picture.
type CameraFrame struct {
// Image is the frame; nil shows "No signal".
Image image.Image
// Seq numbers frames; a new number redraws a reused Image.
Seq uint64
}
// CameraPreviewOptions configure a CameraPreview.
type CameraPreviewOptions struct {
// Label names the camera for assistive technology.
Label string
Disabled bool
}
type cameraCache struct {
img image.Image
seq uint64
mirror bool
bmp *ui.Bitmap
}
type cameraKey struct{}
// CameraPreview shows frame at 16:9, mirrored while *mirror.
func CameraPreview(c *ui.Context, frame CameraFrame, mirror *bool, opts CameraPreviewOptions) ChoiceResult {
k := Tokens(c)
old := *mirror
label := opts.Label
if label == "" {
label = msg(c, "camera_preview.label")
}
box := ui.Box(c).FillWidth().AspectRatio(16.0 / 9).Background(videoBlack).Radius(theme.CardRadius).Clip().
Center().Focusable().Role(ui.RoleImage).Label(label).Disabled(opts.Disabled)
internal.Ring(box, k.Focus, theme.CardRadius)
cache := ui.Local(box, cameraKey{}, func() cameraCache { return cameraCache{} })
box.Children(func() {
if frame.Image == nil {
box.Description(msg(c, "camera_preview.noSignal"))
ui.Column(c).Gap(8).AlignItems(ui.Center).Children(func() {
ui.Icon(c, mediaCamera).Size(28, 28).TextColor(ui.Hex("#BCAEAA"))
ui.Text(c, msg(c, "camera_preview.noSignal")).TextColor(ui.Hex("#BCAEAA"))
})
} else {
// Copies are made once per frame number, not per paint.
if cache.img != frame.Image || cache.seq != frame.Seq || cache.mirror != *mirror || cache.bmp == nil {
*cache = cameraCache{frame.Image, frame.Seq, *mirror, cameraBitmap(frame.Image, *mirror)}
}
ui.Image(c, cache.bmp).Fit(ui.Contain).Fill()
}
ui.Box(c).Absolute().Top(8).Right(8).Background(ui.RGBA(0, 0, 0, 0.5)).Radius(theme.ControlRadius).Children(func() {
b := mediaToggleAction(c, mediaMirror, msg(c, "camera_preview.mirror"), *mirror)
if b.Clicked() && !b.IsDisabled() {
*mirror = !*mirror
}
})
})
if !box.IsDisabled() && box.Shortcut(0, ui.KeyM) {
*mirror = !*mirror
}
changed := *mirror != old
if changed {
slog.Debug("mujica: camera_preview mirror", "on", *mirror)
}
return ChoiceResult{Element: box, changed: changed}
}
// cameraBitmap copies img into a bitmap, flipped when mirror.
func cameraBitmap(img image.Image, mirror bool) *ui.Bitmap {
if !mirror {
return ui.NewBitmap(img)
}
b := img.Bounds()
out := image.NewRGBA(image.Rect(0, 0, b.Dx(), b.Dy()))
for y := b.Min.Y; y < b.Max.Y; y++ {
for x := b.Min.X; x < b.Max.X; x++ {
out.Set(b.Max.X-1-x, y-b.Min.Y, img.At(x, y))
}
}
return ui.NewBitmap(out)
}
+86
View File
@@ -0,0 +1,86 @@
package mujica
import (
"image"
"testing"
"github.com/egoist/mygo/ui"
)
func cameraTester(frame *CameraFrame, mirror *bool, disabled bool, n *int) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Padding(20).Width(320).Children(func() {
if CameraPreview(c, *frame, mirror, CameraPreviewOptions{Disabled: disabled}).Changed() {
*n++
}
})
}, 400, 260)
}
func TestCameraPreviewNoSignalAndMirror(t *testing.T) {
frame, mirror, n := CameraFrame{}, false, 0
tt := cameraTester(&frame, &mirror, false, &n)
if !tt.HasText("No signal") {
t.Fatalf("texts %v", tt.Texts())
}
if err := tt.Click("Mirror"); err != nil {
t.Fatal(err)
}
tt.Key(0, ui.KeyTab)
if !tt.Focused("Camera") {
t.Fatal("preview not focusable")
}
tt.Key(0, ui.KeyM)
if mirror || n != 2 {
t.Fatalf("mirror %v changes %d", mirror, n)
}
frame = CameraFrame{Image: mediaTestImage(32, 18), Seq: 1}
tt.Frame()
if tt.HasText("No signal") {
t.Fatal("frame did not replace No signal")
}
}
func TestCameraPreviewDisabled(t *testing.T) {
frame, mirror, n := CameraFrame{}, false, 0
tt := cameraTester(&frame, &mirror, true, &n)
_ = tt.Click("Mirror")
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyM)
if mirror || n != 0 {
t.Fatal("disabled preview flipped")
}
}
func TestCameraBitmapMirrors(t *testing.T) {
img := mediaTestImage(8, 8)
flipped := image.NewRGBA(image.Rect(0, 0, 8, 8))
for y := range 8 {
for x := range 8 {
flipped.Set(7-x, y, img.At(x, y))
}
}
got := ui.Render(func(c *ui.Context) {
ui.Image(c, cameraBitmap(img, true)).Size(8, 8)
}, 8, 8, 1)
want := ui.Render(func(c *ui.Context) {
ui.Image(c, ui.NewBitmap(flipped)).Size(8, 8)
}, 8, 8, 1)
if got.RGBAAt(1, 6) != want.RGBAAt(1, 6) || got.RGBAAt(1, 6) == ui.Render(func(c *ui.Context) {
ui.Image(c, ui.NewBitmap(img)).Size(8, 8)
}, 8, 8, 1).RGBAAt(1, 6) {
t.Fatal("mirrored bitmap is not flipped")
}
}
func TestCameraPreviewSnapshot(t *testing.T) {
img := mediaTestImage(160, 90)
m1, m2 := true, false
snapshot(t, "camera_preview", 560, 180, func(c *ui.Context) {
ui.Row(c).Padding(20).Gap(12).Children(func() {
ui.Column(c).Grow(1).Children(func() { CameraPreview(c, CameraFrame{Image: img}, &m1, CameraPreviewOptions{}) })
ui.Column(c).Grow(1).Children(func() { CameraPreview(c, CameraFrame{}, &m2, CameraPreviewOptions{}) })
})
})
}
+78
View File
@@ -0,0 +1,78 @@
package mujica
import (
"log/slog"
"github.com/egoist/mygo/ui"
"mujicaui/theme"
)
// MediaDevice is one device; IDs are unique within a kind.
type MediaDevice struct {
ID, Name string
}
// MediaDevices are the caller's lists; nil hides that kind.
type MediaDevices struct {
Microphones, Cameras, Speakers []MediaDevice
}
// DeviceSelection holds each kind's chosen ID; empty is none.
type DeviceSelection struct {
Microphone, Camera, Speaker string
}
// DeviceSelectorOptions configure a DeviceSelector.
type DeviceSelectorOptions struct {
Disabled bool
}
// DeviceSelector chooses a microphone, camera and speaker.
func DeviceSelector(c *ui.Context, sel *DeviceSelection, devices MediaDevices, opts DeviceSelectorOptions) ChoiceResult {
old := *sel
k := Tokens(c)
u := Density(c).Unit()
col := ui.Column(c).Gap(u * 3).Role(ui.RoleGroup).Label(msg(c, "device_selector.label")).Disabled(opts.Disabled)
col.Children(func() {
deviceRow(c, k, mediaMic, "device_selector.microphone", &sel.Microphone, devices.Microphones, opts.Disabled)
deviceRow(c, k, mediaCamera, "device_selector.camera", &sel.Camera, devices.Cameras, opts.Disabled)
deviceRow(c, k, mediaSpeaker, "device_selector.speaker", &sel.Speaker, devices.Speakers, opts.Disabled)
})
changed := *sel != old
if changed {
slog.Debug("mujica: device_selector changed", "selection", *sel)
}
return ChoiceResult{Element: col, changed: changed}
}
func deviceRow(c *ui.Context, k theme.Tokens, icon *ui.SVG, key string, id *string, list []MediaDevice, disabled bool) {
if list == nil {
return
}
options := make([]SelectOption[string], len(list))
for i, d := range list {
if d.ID == "" {
panic("mujica: DeviceSelector device without an ID")
}
for _, o := range options[:i] {
if o.Value == d.ID {
panic("mujica: DeviceSelector duplicate device ID " + d.ID)
}
}
options[i] = SelectOption[string]{Value: d.ID, Label: d.Name}
}
if _, ok := selectLabel(options, *id); *id != "" && !ok {
panic("mujica: DeviceSelector selection " + *id + " is not in its list")
}
ui.Row(c).Gap(Density(c).Unit() * 2).AlignItems(ui.Center).Children(func() {
ui.Icon(c, icon).Size(16, 16).TextColor(k.TextMuted)
ui.Text(c, msg(c, key)).Width(96).SingleLine()
hint := msg(c, "device_selector.choose")
if len(list) == 0 {
hint = msg(c, "device_selector.none")
}
Select(c, id, options, SelectOptions{Disabled: disabled || len(list) == 0, Label: msg(c, key),
Placeholder: hint, Empty: msg(c, "device_selector.none")}).Element.Grow(1)
})
}
+74
View File
@@ -0,0 +1,74 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
var deviceTestList = MediaDevices{
Microphones: []MediaDevice{{"m1", "Built-in Microphone"}, {"m2", "Studio Mic"}},
Cameras: []MediaDevice{},
Speakers: []MediaDevice{{"s1", "Headphones"}},
}
func deviceTester(sel *DeviceSelection, devices MediaDevices, disabled bool, n *int) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Padding(20).Width(420).Children(func() {
if DeviceSelector(c, sel, devices, DeviceSelectorOptions{Disabled: disabled}).Changed() {
*n++
}
})
}, 500, 400)
}
func TestDeviceSelectorChoose(t *testing.T) {
sel, n := DeviceSelection{Microphone: "m1"}, 0
tt := deviceTester(&sel, deviceTestList, false, &n)
if !tt.HasText("Built-in Microphone") || !tt.HasText("No devices found") {
t.Fatalf("texts %v", tt.Texts())
}
if err := tt.Click("Built-in Microphone"); err != nil {
t.Fatal(err)
}
if err := tt.Click("Studio Mic"); err != nil {
t.Fatal(err)
}
if sel.Microphone != "m2" || n != 1 {
t.Fatalf("selection %+v changes %d", sel, n)
}
}
func TestDeviceSelectorHidesNilListsAndDisabled(t *testing.T) {
sel, n := DeviceSelection{}, 0
tt := deviceTester(&sel, MediaDevices{Speakers: deviceTestList.Speakers}, true, &n)
if tt.HasText("Camera") || tt.HasText("Microphone") {
t.Fatalf("nil lists shown: %v", tt.Texts())
}
_ = tt.Click("Choose a device")
_ = tt.Click("Headphones")
if sel.Speaker != "" || n != 0 {
t.Fatal("disabled selector chose")
}
}
func TestDeviceSelectorBadLists(t *testing.T) {
sel := DeviceSelection{}
mediaPanics(t, "duplicate", func(c *ui.Context) {
DeviceSelector(c, &sel, MediaDevices{Cameras: []MediaDevice{{"a", "A"}, {"a", "B"}}}, DeviceSelectorOptions{})
})
gone := DeviceSelection{Camera: "x"}
mediaPanics(t, "unknown selection", func(c *ui.Context) {
DeviceSelector(c, &gone, MediaDevices{Cameras: []MediaDevice{{"a", "A"}}}, DeviceSelectorOptions{})
})
}
func TestDeviceSelectorSnapshot(t *testing.T) {
sel := DeviceSelection{Microphone: "m2", Speaker: "s1"}
snapshot(t, "device_selector", 460, 190, func(c *ui.Context) {
ui.Column(c).Padding(20).Width(420).Children(func() {
DeviceSelector(c, &sel, deviceTestList, DeviceSelectorOptions{})
})
})
}
+55
View File
@@ -0,0 +1,55 @@
# 341 ImageCropper 图片裁剪
## 用途
在图片上拖出裁剪框,可锁定宽高比、按 90 度旋转,输出裁剪矩形。
## 最小示例
```go
crop := mujica.ImageCrop{} // empty: starts fitted to the image
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.ImageCropper(c, examplePhoto, &crop, mujica.ImageCropperOptions{Aspect: 1}).Changed() {
_ = crop.Rect // in pixels of the photo turned by crop.Rotation
}
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func ImageCropper(c *ui.Context, src image.Image, crop *ImageCrop, opts ImageCropperOptions) ChoiceResult`
`src` 必填,是标准库 `image.Image`(组件要自己转动像素)。`ImageCrop.Rect` 是旋转 `Rotation` 度之后那张图上的像素矩形;`Rotation` 只能是 0、90、180、270。`Rect` 为空时自动设为最大的居中框。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Aspect` | `float64` | 0 | 锁定宽除以高;0 为自由比例。`Rect` 不符合比例时 panic。 |
| `Height` | `float32` | 0(即 320) | 舞台高度。 |
| `Disabled` | `bool` | false | 禁用拖动、按键与旋转。 |
| `Label` | `string` | "Crop area" | 舞台的无障碍名称。 |
## 状态
- 默认:框外区域压暗,框线与三分线为象牙色,四角有菱形手柄。
- 聚焦:舞台显示 2 DIP 焦点环;下方读数同时作为无障碍描述。
- 禁用:MyGo 统一调暗,不响应输入。
## 事件
`Changed()` 在框移动、缩放或旋转后为 true。
## 键盘操作
| 按键 | 作用 |
| --- | --- |
| 方向键 | 移动裁剪框,每次图片宽度的 1% |
| Shift+方向键 | 右、下放大,左、上缩小;锁定比例时保持比例 |
| R / Shift+R | 向右 / 向左旋转 90 度 |
## 限制
- MyGo 的 Painter 不能旋转位图(只有单色图标能转,ui/svg.go:98),所以组件在图片或角度变化时复制一份转好的像素;超大图片会在那一帧花时间。
- 锁定比例时旋转会重新取最大的居中框,原选区不保留。
- 拖动角点时,按下点距角 14 DIP 以内才算抓住手柄。
+59
View File
@@ -0,0 +1,59 @@
# 342 ImageAnnotator 图片标注
## 用途
在图片上画矩形、箭头和文字标注,可选择、移动和删除,并导出标注数据。
## 最小示例
```go
photo := ui.NewBitmap(examplePhoto)
var notes []mujica.Annotation
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.ImageAnnotator(c, photo, &notes, mujica.ImageAnnotatorOptions{}).Changed() {
data, _ := mujica.ExportAnnotations(notes)
_ = data // save the JSON
}
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func ImageAnnotator(c *ui.Context, src ui.ImageSource, notes *[]Annotation, opts ImageAnnotatorOptions) ChoiceResult`
`func ExportAnnotations(notes []Annotation) ([]byte, error)` 把标注写成 JSON(kind、x0、y0、x1、y1、text)。
`Annotation` 的坐标是图片像素;文字标注以 `From` 为左上角,`Text` 不能为空。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Height` | `float32` | 0(即 320) | 舞台高度。 |
| `Disabled` | `bool` | false | 禁用绘制、选择和删除。 |
| `Label` | `string` | "Annotations" | 舞台的无障碍名称。 |
## 状态
- 工具:选择、矩形、箭头、文字,用分段控件切换。
- 选中:虚线框加菱形标记,不只靠颜色。
- 标注用暗红墨线加浅色描边,在任何图片上都看得清。
## 事件
`Changed()` 在新增、移动、改字或删除标注后为 true。
## 键盘操作
| 按键 | 作用 |
| --- | --- |
| [ / ] | 选中上一个 / 下一个标注 |
| 方向键 | 移动选中的标注,每次图片宽度的 1% |
| Delete / Backspace | 删除选中的标注 |
| Escape | 取消选择 |
## 限制
- 文字工具:先在工具栏输入文字,再点击放置;选中文字标注时同一输入框可改字。
- 标注不能缩放,只能移动;要改大小请删除后重画。
- 文字的命中区域按每字 8 像素估算。
+54
View File
@@ -0,0 +1,54 @@
# 343 ImageThumbnail 缩略图
## 用途
固定尺寸、按比例裁切的可点击缩略图,带选中、加载中和加载失败状态。
## 最小示例
```go
photo := ui.NewBitmap(examplePhoto)
selected := false
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.ImageThumbnail(c, photo, mujica.ImageThumbnailOptions{Alt: "Grand hall", Aspect: 4.0 / 3, Selected: selected}).Changed() {
selected = !selected
}
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func ImageThumbnail(c *ui.Context, src ui.ImageSource, opts ImageThumbnailOptions) ChoiceResult`
`src` 为 nil 时显示占位图标。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Width` | `float32` | 0(即 96) | 宽度。 |
| `Aspect` | `float32` | 0(即 1) | 宽除以高,图片按 Cover 裁切填满。 |
| `Selected` | `bool` | false | 选中:2 DIP 强调边框加右上角菱形。 |
| `Loading` | `bool` | false | 显示加载标记,尺寸不变。 |
| `Err` | `error` | nil | 显示失败图标;错误文字作为提示与描述。 |
| `Alt` | `string` | 必填 | 替代说明,为空时 panic。 |
| `Disabled` | `bool` | false | 禁用点击。 |
## 状态
默认、悬停(边框加深)、聚焦、选中、加载、失败、禁用,所有状态尺寸相同。
## 事件
`Changed()` 表示本帧被点击,或聚焦时按了 Enter / Space。
## 键盘操作
| 按键 | 作用 |
| --- | --- |
| Tab | 聚焦缩略图 |
| Enter / Space | 激活 |
## 限制
- 选中状态由调用方保存;组件只报告点击。
+53
View File
@@ -0,0 +1,53 @@
# 344 Lightbox 灯箱
## 用途
全窗口浏览图片:左右切换、显示说明文字,Escape 关闭后焦点回到触发控件。
## 最小示例
```go
images := []mujica.ViewerImage{{Source: ui.NewBitmap(examplePhoto), Alt: "The grand hall"}}
open, index := false, 0
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.Button(c, "View photos", mujica.ButtonOptions{}).Clicked() {
open = true
}
mujica.Lightbox(c, &open, &index, images)
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func Lightbox(c *ui.Context, open *bool, index *int, images []ViewerImage) ChoiceResult`
与 ImageViewer 共用 `ViewerImage`(`Source`、`Alt`)。`Alt` 同时是说明文字。关闭时 `Element` 为 nil。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `open` | `*bool` | — | 是否显示;Escape、点击关闭或背景时设为 false。 |
| `index` | `*int` | — | 当前图片;越界 panic。 |
| `images` | `[]ViewerImage` | — | 至少一张,`Source` 须为 `*ui.Bitmap` 或 `*ui.SVG`。 |
## 状态
- 黑色遮罩铺满窗口,图片按 Contain 居中;首尾时对应切换按钮禁用。
- 打开时按弹层时长淡入,减少动画时直接出现。
## 事件
`Changed()` 在切换图片后为 true,并朗读“第 n 张,共 m 张”。
## 键盘操作
| 按键 | 作用 |
| --- | --- |
| ← / → | 上一张 / 下一张 |
| Home / End | 第一张 / 最后一张 |
| Escape | 关闭并把焦点还给打开它的控件 |
## 限制
- 不提供缩放与平移;需要时请用 ImageViewer。
+59
View File
@@ -0,0 +1,59 @@
# 345 VideoPlayer 视频播放器
## 用途
播放器外壳:画面由调用方提供,组件绘制状态并通过回调报告播放、暂停、跳转、倍速、字幕、全屏和画中画。
## 最小示例
```go
st := mujica.VideoState{Duration: 4 * time.Minute, Rate: 1}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.VideoPlayer(c, st, mujica.VideoPlayerOptions{
OnPlay: func() { st.Playing = true }, // start your decoder
OnPause: func() { st.Playing = false }, // pause it
OnSeek: func(d time.Duration) { st.Position = d },
OnRate: func(r float64) { st.Rate = r },
})
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func VideoPlayer(c *ui.Context, st VideoState, opts VideoPlayerOptions) *ui.Element`
`VideoState`:`Frame`(当前画面,nil 显示“暂无视频”)、`Position`、`Duration`、`Buffered`、`Playing`、`Rate`、`Subtitle`、`SubtitlesOn`、`Fullscreen`、`PiP`。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `OnPlay / OnPause / OnSeek` | `func` | 必填 | 播放、暂停、跳转。 |
| `OnRate` | `func(float64)` | nil | 倍速;nil 时不显示倍速选择。 |
| `OnSubtitles / OnFullscreen / OnPiP` | `func(bool)` | nil | 开关回调;nil 时隐藏对应按钮。 |
| `Disabled` | `bool` | false | 禁用全部控件。 |
| `Label` | `string` | "Video" | 无障碍名称。 |
## 状态
- 画面区 16:9,黑底;字幕开启且有字幕时在底部显示。
- 字幕、画中画、全屏按钮开启时有选中底色和下划线。
## 事件
全部通过回调报告;组件不改动 `st`。
## 键盘操作
| 按键 | 作用 |
| --- | --- |
| Tab | 先聚焦画面,再到进度条和各按钮 |
| Space / K | 画面聚焦时播放或暂停 |
| ← / → | 画面聚焦时后退 / 前进 5 秒 |
| F | 切换全屏 |
| C | 切换字幕 |
## 限制
- 组件不解码视频,也不实现全屏或画中画窗口,只报告用户的请求。
- 帧率取决于调用方更新 `Frame` 并调用 `c.Invalidate()` 的频率。
+56
View File
@@ -0,0 +1,56 @@
# 346 VideoScrubber 视频时间轴
## 用途
可拖动的时间轴:显示已缓冲区间,悬停显示时间和缩略图插槽。
## 最小示例
```go
pos := 30 * time.Second
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.VideoScrubber(c, &pos, mujica.VideoScrubberOptions{
Duration: 2 * time.Minute,
Buffered: []mujica.MediaRange{{Start: 0, End: time.Minute}},
})
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func VideoScrubber(c *ui.Context, pos *time.Duration, opts VideoScrubberOptions) ChoiceResult`
`*pos` 必须在 0 到 `Duration` 之间,否则 panic。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Duration` | `time.Duration` | 必填 | 时间轴长度,须大于 0。 |
| `Buffered` | `[]MediaRange` | nil | 已缓冲区间,以浅色填充。 |
| `Preview` | `func(at time.Duration)` | nil | 悬停时在插槽里构建该时间的缩略图。 |
| `PreviewWidth / PreviewHeight` | `float32` | 160×90 | 插槽尺寸。 |
| `Step` | `time.Duration` | 5 秒 | 方向键每次移动的时长。 |
| `Disabled` | `bool` | false | 禁用。 |
| `Label` | `string` | "Timeline" | 无障碍名称;以滑块角色报告范围与数值。 |
## 状态
默认、悬停(显示时间气泡)、拖动、聚焦(菱形手柄外圈)、禁用。
## 事件
`Changed()` 在拖动、点击或按键改变 `*pos` 后为 true。
## 键盘操作
| 按键 | 作用 |
| --- | --- |
| ← / ↓ | 后退一个 Step |
| → / ↑ | 前进一个 Step |
| Home / End | 开头 / 结尾 |
## 限制
- 位置按毫秒取整。
- 悬停插槽只在指针位于时间轴上时显示,键盘操作时不显示。
@@ -0,0 +1,51 @@
# 347 VideoThumbnailStrip 缩略帧条
## 用途
横向缩略帧条:标出当前帧,点击或按键跳转。
## 最小示例
```go
frames := []mujica.StripFrame{{At: 0}, {At: 10 * time.Second}, {At: 20 * time.Second}}
current := 0
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.VideoThumbnailStrip(c, &current, frames, mujica.VideoThumbnailStripOptions{}).Changed() {
_ = frames[current].At // seek the player there
}
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func VideoThumbnailStrip(c *ui.Context, current *int, frames []StripFrame, opts VideoThumbnailStripOptions) ChoiceResult`
`StripFrame` 有 `Image`(可为 nil)和 `At`。`*current` 越界 panic。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `FrameWidth` | `float32` | 0(即 96) | 每格宽度,高度按 16:9。 |
| `Disabled` | `bool` | false | 禁用。 |
## 状态
- 只构建放得下的那几格,窗口跟随当前帧移动;10 万帧也只建十几个元素。
- 当前帧:强调边框加菱形;每格左下角显示时间。
## 事件
`Changed()` 在当前帧变化后为 true;调用方据此跳转播放器。
## 键盘操作
| 按键 | 作用 |
| --- | --- |
| Tab | 聚焦帧条 |
| ← / → | 上一帧 / 下一帧 |
| Home / End | 第一帧 / 最后一帧 |
## 限制
- 不提供横向滚动条,用两侧按钮或键盘移动。
+51
View File
@@ -0,0 +1,51 @@
# 348 AudioPlayer 音频播放器
## 用途
音频播放外壳:播放、暂停、进度与时长,播放本身由调用方驱动。
## 最小示例
```go
st := mujica.AudioState{Title: "Overture", Duration: 3 * time.Minute}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.AudioPlayer(c, st, mujica.AudioPlayerOptions{
OnPlay: func() { st.Playing = true },
OnPause: func() { st.Playing = false },
OnSeek: func(d time.Duration) { st.Position = d },
})
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func AudioPlayer(c *ui.Context, st AudioState, opts AudioPlayerOptions) *ui.Element`
`AudioState`:`Title`、`Position`、`Duration`、`Playing`。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `OnPlay / OnPause / OnSeek` | `func` | 必填 | 缺少任一回调时 panic。 |
| `Disabled` | `bool` | false | 禁用。 |
## 状态
播放中显示暂停按钮,暂停时显示播放按钮;`Duration` 为 0 时不显示进度条。
## 事件
通过回调报告,组件不改动 `st`。
## 键盘操作
| 按键 | 作用 |
| --- | --- |
| Tab | 播放按钮、进度条 |
| Space | 组件内任意位置播放或暂停(按钮聚焦时由按钮处理) |
| ← / → | 进度条聚焦时跳转 5 秒 |
## 限制
- 组件不播放声音。
+58
View File
@@ -0,0 +1,58 @@
# 349 AudioWaveform 音频波形
## 用途
绘制调用方提供的采样波形,覆盖播放进度,可拖动选择区间。
## 最小示例
```go
samples := make([]float32, 48000) // from your decoder, -1 to 1
pos, sel := time.Duration(0), mujica.MediaRange{}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.AudioWaveform(c, samples, mujica.AudioWaveformOptions{
Duration: time.Second, Position: pos, Selection: &sel,
OnSeek: func(d time.Duration) { pos = d },
})
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func AudioWaveform(c *ui.Context, samples []float32, opts AudioWaveformOptions) ChoiceResult`
`samples` 取值 -1 到 1。数据就绪时 `Duration` 须大于 0,`OnSeek` 必填。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Duration` | `time.Duration` | — | 音频长度。 |
| `Position` | `time.Duration` | 0 | 已播放部分。 |
| `OnSeek` | `func(time.Duration)` | — | 点击或方向键跳转。 |
| `Selection` | `*MediaRange` | nil | 非 nil 时可拖动或 Shift+方向键选择区间。 |
| `Height` | `float32` | 0(即 96) | 高度。 |
| `Status / Error` | `DataStatus / string` | DataReady | 加载中、失败(须给 Error)与无数据时显示同尺寸占位。 |
| `Label` | `string` | "Waveform" | 无障碍名称。 |
## 状态
- 已播放部分用强调色,其余用说明色,并有播放线和菱形标记,不只靠颜色区分。
- 选区用 Selection 底色加两条边线。
## 事件
`Changed()` 在选区变化后为 true;跳转通过 `OnSeek` 报告。
## 键盘操作
| 按键 | 作用 |
| --- | --- |
| ← / → | 跳转 ±5 秒 |
| Shift+← / → | 从播放位置开始或调整选区终点 |
| Escape | 清除选区 |
## 限制
- 峰值按像素列计算,只在数据或宽度变化时重算,不会每帧计算。
- 数据以首元素地址和长度识别;原地修改采样请换一个切片。
+49
View File
@@ -0,0 +1,49 @@
# 350 AudioSpectrum 音频频谱
## 用途
把调用方提供的频带数值画成柱状频谱;组件自己不做动画,隐藏时不重绘。
## 最小示例
```go
bands := []float32{0.2, 0.6, 0.9, 0.4, 0.1} // set by your analyser, then c.Invalidate()
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.AudioSpectrum(c, bands, mujica.AudioSpectrumOptions{})
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func AudioSpectrum(c *ui.Context, bands []float32, opts AudioSpectrumOptions) *ui.Element`
每个频带取 0 到 1,超出范围 panic。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Height` | `float32` | 0(即 120) | 高度。 |
| `BandLabel` | `func(i int) string` | nil | 读数中频带的名称,如“1 kHz”;nil 时编号。 |
| `Status / Error` | `DataStatus / string` | DataReady | 加载、失败与无数据占位,尺寸相同。 |
| `Label` | `string` | "Spectrum" | 无障碍名称。 |
## 状态
聚焦后用方向键选中的频带以正文色显示,带虚线框和读数,不只靠颜色。
## 事件
无事件;方向键读数会通过 `Announce` 朗读。
## 键盘操作
| 按键 | 作用 |
| --- | --- |
| Tab | 聚焦频谱 |
| ← / → | 上一个 / 下一个频带 |
| Home / End | 第一个 / 最后一个频带 |
## 限制
- 刷新节奏由调用方决定:更新数据后调用 `c.Invalidate()`。
+46
View File
@@ -0,0 +1,46 @@
# 351 VolumeControl 音量控制
## 用途
音量滑块与静音按钮。
## 最小示例
```go
volume, muted := 0.8, false
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.VolumeControl(c, &volume, &muted, mujica.VolumeControlOptions{})
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func VolumeControl(c *ui.Context, volume *float64, muted *bool, opts VolumeControlOptions) ChoiceResult`
`*volume` 取 0 到 1,超出 panic;步长 5%。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Disabled` | `bool` | false | 禁用。 |
## 状态
静音或音量为 0 时显示静音图标;静音按钮开启时有选中底色和下划线。静音时调高音量会自动取消静音。
## 事件
`Changed()` 在音量或静音变化后为 true。
## 键盘操作
| 按键 | 作用 |
| --- | --- |
| ← / → | 音量 ±5% |
| Home / End | 0% / 100% |
| M | 组件内切换静音 |
## 限制
- 组件不调节系统音量,只改调用方的值。
@@ -0,0 +1,48 @@
# 352 PlaybackSpeedControl 倍速选择
## 用途
在 0.5× 到 2× 之间选择播放速度。
## 最小示例
```go
rate := 1.0
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.PlaybackSpeedControl(c, &rate, mujica.PlaybackSpeedControlOptions{})
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func PlaybackSpeedControl(c *ui.Context, rate *float64, opts PlaybackSpeedControlOptions) ChoiceResult`
`*rate` 必须是 `Rates` 中的一项,否则 panic。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Rates` | `[]float64` | 0.5, 0.75, 1, 1.25, 1.5, 2 | 升序的正数。 |
| `Disabled` | `bool` | false | 禁用。 |
## 状态
基于 Select,状态与键盘同 Select。
## 事件
`Changed()` 在选择新倍速后为 true。
## 键盘操作
| 按键 | 作用 |
| --- | --- |
| Enter / Space | 打开列表 |
| ↑ / ↓ | 移动 |
| Enter | 确认 |
| Escape | 关闭 |
## 限制
- 无。
+53
View File
@@ -0,0 +1,53 @@
# 353 MediaControls 媒体控制
## 用途
上一首、播放/暂停、下一首、循环、随机,一组可用方向键切换焦点的按钮。
## 最小示例
```go
st := mujica.MediaControlsState{}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.MediaControls(c, st, mujica.MediaControlsOptions{
OnPrevious: func() {}, OnNext: func() {},
OnPlay: func() { st.Playing = true }, OnPause: func() { st.Playing = false },
OnLoop: func(m mujica.LoopMode) { st.Loop = m }, OnShuffle: func(on bool) { st.Shuffle = on },
})
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func MediaControls(c *ui.Context, st MediaControlsState, opts MediaControlsOptions) *ui.Element`
`MediaControlsState`:`Playing`、`Loop`(`LoopOff`、`LoopAll`、`LoopOne`)、`Shuffle`、`NoPrevious`、`NoNext`。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `OnPrevious / OnPlay / OnPause / OnNext` | `func()` | 必填 | 按钮回调。 |
| `OnLoop` | `func(LoopMode)` | 必填 | 收到下一个循环模式:不循环 → 列表 → 单曲。 |
| `OnShuffle` | `func(bool)` | 必填 | 随机开关。 |
| `Disabled` | `bool` | false | 禁用。 |
## 状态
循环与随机开启时有选中底色和下划线,单曲循环换用带“1”的图标;循环按钮的名称说明当前模式。
## 事件
全部通过回调报告。
## 键盘操作
| 按键 | 作用 |
| --- | --- |
| Tab | 进入按钮组 |
| ← / → | 在按钮间移动焦点 |
| Enter / Space | 按下 |
## 限制
- 无。
+52
View File
@@ -0,0 +1,52 @@
# 354 Playlist 播放列表
## 用途
播放列表:标出当前项,可拖动排序、移除,只构建可见行。
## 最小示例
```go
items := []mujica.PlaylistItem{{ID: "a", Title: "Overture"}, {ID: "b", Title: "Masquerade"}}
current := "a"
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if r := mujica.Playlist(c, &items, mujica.PlaylistOptions{Current: current}); r.Played != "" {
current = r.Played
}
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func Playlist(c *ui.Context, items *[]PlaylistItem, opts PlaylistOptions) PlaylistResult`
`PlaylistItem`:`ID`(唯一且非空,否则 panic)、`Title`、`Subtitle`、`Duration`。组件直接修改 `*items` 完成排序和移除。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Current` | `string` | "" | 正在播放项的 ID,显示播放标记和强调文字。 |
| `Height` | `float32` | 0(即 320) | 列表高度。 |
| `Disabled` | `bool` | false | 禁用选择、排序和移除。 |
## 状态
基于 List:选中行使用 Selection 底色加勾选标记;空列表显示提示。
## 事件
`PlaylistResult`:`Played` 为 Enter 或双击选择的 ID;`Removed` 为被移除的 ID;`Moved` 表示顺序变化。
## 键盘操作
| 按键 | 作用 |
| --- | --- |
| ↑ / ↓ | 移动选择 |
| Enter | 播放选中项 |
| Alt+↑ / Alt+↓ | 上移 / 下移选中项 |
| Delete / Backspace | 移除选中项 |
## 限制
- 拖动排序由 MyGo List 的 Reorder 提供,拖动时显示插入线。
+52
View File
@@ -0,0 +1,52 @@
# 355 SubtitleEditor 字幕编辑器
## 用途
逐条编辑字幕的开始、结束时间和文本,校验时间先后与重叠。
## 最小示例
```go
cues := []mujica.SubtitleCue{{Start: 0, End: 2 * time.Second, Text: "The curtain rises."}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.SubtitleEditor(c, &cues, mujica.SubtitleEditorOptions{})
if len(mujica.CheckSubtitles(cues)) > 0 {
_ = cues // block saving until the issues are fixed
}
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func SubtitleEditor(c *ui.Context, cues *[]SubtitleCue, opts SubtitleEditorOptions) ChoiceResult`
`func CheckSubtitles(cues []SubtitleCue) []SubtitleIssue` 返回结束早于开始(`SubtitleEndBeforeStart`)和与上一条重叠(`SubtitleOverlap`)的条目,供调用方阻止保存。时间格式为 `m:ss.mmm` 或 `h:mm:ss.mmm`。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Height` | `float32` | 0(即 360) | 列表高度。 |
| `Disabled` | `bool` | false | 禁用编辑、添加和删除。 |
## 状态
- 无法解析的时间保留在输入框中,并显示格式提示;有效后才写回。
- 时间问题在该行下方用错误色说明,并报告给辅助技术。
- 只构建可见行,10 万条也保持流畅。
## 事件
`Changed()` 在任一字段、添加或删除后为 true。
## 键盘操作
| 按键 | 作用 |
| --- | --- |
| Tab | 在一行的开始、结束、文本、删除之间移动 |
| Enter | 提交输入 |
## 限制
- 虚拟化只构建可见行,Tab 不会跳到未显示的行;请先滚动。
- 组件不排序字幕;顺序由调用方决定。
@@ -0,0 +1,51 @@
# 356 ScreenRecorderControls 录屏控制
## 用途
开始、暂停、继续、停止录制并显示计时;录制本身由调用方实现。
## 最小示例
```go
st := mujica.RecorderState{}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.ScreenRecorderControls(c, st, mujica.ScreenRecorderControlsOptions{
OnStart: func() { st.Status = mujica.RecorderRecording },
OnPause: func() { st.Status = mujica.RecorderPaused },
OnResume: func() { st.Status = mujica.RecorderRecording },
OnStop: func() { st = mujica.RecorderState{} },
})
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func ScreenRecorderControls(c *ui.Context, st RecorderState, opts ScreenRecorderControlsOptions) *ui.Element`
`RecorderState`:`Status`(`RecorderIdle`、`RecorderRecording`、`RecorderPaused`)、`Elapsed`。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `OnStart / OnPause / OnResume / OnStop` | `func()` | 必填 | 缺少任一回调时 panic。 |
| `Disabled` | `bool` | false | 禁用。 |
## 状态
就绪、录制中(红点)、已暂停;状态同时用文字表示,不只靠圆点颜色。录制中的圆点不闪烁。
## 事件
全部通过回调报告。
## 键盘操作
| 按键 | 作用 |
| --- | --- |
| Tab | 在按钮间移动 |
| Enter / Space | 按下 |
## 限制
- 计时由调用方更新 `Elapsed`。
+48
View File
@@ -0,0 +1,48 @@
# 357 CameraPreview 摄像头预览
## 用途
显示调用方提供的摄像头帧,无帧时显示“无信号”,可切换镜像。
## 最小示例
```go
frame := mujica.CameraFrame{Image: examplePhoto, Seq: 1} // a new Seq for each captured frame
mirror := true
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.CameraPreview(c, frame, &mirror, mujica.CameraPreviewOptions{})
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func CameraPreview(c *ui.Context, frame CameraFrame, mirror *bool, opts CameraPreviewOptions) ChoiceResult`
`CameraFrame`:`Image`(标准库 `image.Image`,nil 为无信号)、`Seq`(帧序号,复用同一图像缓冲时用新序号触发重绘)。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Label` | `string` | "Camera" | 无障碍名称。 |
| `Disabled` | `bool` | false | 禁用镜像切换。 |
## 状态
16:9 黑底;镜像按钮开启时有选中底色和下划线。
## 事件
`Changed()` 在镜像切换后为 true。
## 键盘操作
| 按键 | 作用 |
| --- | --- |
| Tab | 聚焦预览 |
| M | 切换镜像 |
## 限制
- MyGo 的 Painter 不能水平翻转位图(ui/paint.go:834),所以镜像时组件每个新帧复制一份翻转后的像素;高分辨率帧会占用 CPU。
- 组件不打开摄像头。
+45
View File
@@ -0,0 +1,45 @@
# 358 MicLevelMeter 麦克风电平
## 用途
分段电平条,带峰值保持与 dB 读数。
## 最小示例
```go
level := float32(0.4) // the input's peak, 0 to 1 of full scale
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.MicLevelMeter(c, level, mujica.MicLevelMeterOptions{})
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func MicLevelMeter(c *ui.Context, level float32, opts MicLevelMeterOptions) *ui.Element`
`level` 为满刻度的 0 到 1,超出 panic。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Hold` | `time.Duration` | 1.5 秒 | 峰值保持时长。 |
| `Label` | `string` | "Microphone level" | 无障碍名称;描述中读出当前与峰值 dB。 |
## 状态
24 段:前 70% 为成功色,接着警告色,最后 10% 为危险色;峰值段为空心标记;右侧显示 dB 数值,不只靠颜色。
## 事件
无。
## 键盘操作
| 按键 | 作用 |
| --- | --- |
| — | 不获取焦点 |
## 限制
- 保持结束时只请求一次重绘,空闲时不持续重绘。
+51
View File
@@ -0,0 +1,51 @@
# 359 DeviceSelector 设备选择
## 用途
选择麦克风、摄像头、扬声器,设备列表由调用方提供。
## 最小示例
```go
devices := mujica.MediaDevices{
Microphones: []mujica.MediaDevice{{ID: "mic-1", Name: "Built-in Microphone"}},
Speakers: []mujica.MediaDevice{{ID: "out-1", Name: "Headphones"}},
}
sel := mujica.DeviceSelection{Microphone: "mic-1"}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.DeviceSelector(c, &sel, devices, mujica.DeviceSelectorOptions{})
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func DeviceSelector(c *ui.Context, sel *DeviceSelection, devices MediaDevices, opts DeviceSelectorOptions) ChoiceResult`
`MediaDevices` 的某类列表为 nil 时不显示该类;为空切片时显示禁用的“未找到设备”。设备 ID 重复,或已选 ID 不在列表中时 panic。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Disabled` | `bool` | false | 禁用全部选择。 |
## 状态
每类一行:图标、名称和 Select。
## 事件
`Changed()` 在任一类选择变化后为 true。
## 键盘操作
| 按键 | 作用 |
| --- | --- |
| Tab | 在各选择框间移动 |
| Enter / Space | 打开列表 |
| ↑ / ↓ | 移动 |
| 输入字母 | 跳到匹配的设备 |
## 限制
- 组件不枚举设备,也不监听设备插拔;调用方更新列表即可。
+48
View File
@@ -0,0 +1,48 @@
# 360 ImageCompare 前后对比
## 用途
前后两张图片叠放,用滑块分隔对比。
## 最小示例
```go
before, after := ui.NewBitmap(examplePhoto), ui.NewBitmap(examplePhoto)
split := 0.5
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.ImageCompare(c, before, after, &split, mujica.ImageCompareOptions{})
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func ImageCompare(c *ui.Context, before, after ui.ImageSource, split *float64, opts ImageCompareOptions) ChoiceResult`
`*split` 取 0 到 1,表示分隔线位置;超出 panic。两张图都须为 `*ui.Bitmap` 或 `*ui.SVG`,盒子按 `before` 的宽高比。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `BeforeLabel / AfterLabel` | `string` | "Before" / "After" | 两侧标签,也组成无障碍名称。 |
| `Disabled` | `bool` | false | 禁用。 |
## 状态
分隔线与菱形手柄;两侧标签始终显示,不只靠画面区分。
## 事件
`Changed()` 在分隔位置变化后为 true。
## 键盘操作
| 按键 | 作用 |
| --- | --- |
| ← / → | 移动 1% |
| Home / End | 最左 / 最右 |
| 拖动 | 任意位置拖动分隔线 |
## 限制
- 两张图都按 Cover 铺满盒子,尺寸不同时会裁切。
+246
View File
@@ -0,0 +1,246 @@
package mujica_test
import (
"image"
"time"
"github.com/egoist/mygo/ui"
mujica "mujicaui"
)
// examplePhoto stands in for a decoded picture.
var examplePhoto = image.NewRGBA(image.Rect(0, 0, 320, 180))
func ExampleImageCropper() {
crop := mujica.ImageCrop{} // empty: starts fitted to the image
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.ImageCropper(c, examplePhoto, &crop, mujica.ImageCropperOptions{Aspect: 1}).Changed() {
_ = crop.Rect // in pixels of the photo turned by crop.Rotation
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleImageAnnotator() {
photo := ui.NewBitmap(examplePhoto)
var notes []mujica.Annotation
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.ImageAnnotator(c, photo, &notes, mujica.ImageAnnotatorOptions{}).Changed() {
data, _ := mujica.ExportAnnotations(notes)
_ = data // save the JSON
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleImageThumbnail() {
photo := ui.NewBitmap(examplePhoto)
selected := false
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.ImageThumbnail(c, photo, mujica.ImageThumbnailOptions{Alt: "Grand hall", Aspect: 4.0 / 3, Selected: selected}).Changed() {
selected = !selected
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleLightbox() {
images := []mujica.ViewerImage{{Source: ui.NewBitmap(examplePhoto), Alt: "The grand hall"}}
open, index := false, 0
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.Button(c, "View photos", mujica.ButtonOptions{}).Clicked() {
open = true
}
mujica.Lightbox(c, &open, &index, images)
}
ui.Render(view, 400, 300, 1)
}
func ExampleVideoPlayer() {
st := mujica.VideoState{Duration: 4 * time.Minute, Rate: 1}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.VideoPlayer(c, st, mujica.VideoPlayerOptions{
OnPlay: func() { st.Playing = true }, // start your decoder
OnPause: func() { st.Playing = false }, // pause it
OnSeek: func(d time.Duration) { st.Position = d },
OnRate: func(r float64) { st.Rate = r },
})
}
ui.Render(view, 400, 300, 1)
}
func ExampleVideoScrubber() {
pos := 30 * time.Second
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.VideoScrubber(c, &pos, mujica.VideoScrubberOptions{
Duration: 2 * time.Minute,
Buffered: []mujica.MediaRange{{Start: 0, End: time.Minute}},
})
}
ui.Render(view, 400, 300, 1)
}
func ExampleVideoThumbnailStrip() {
frames := []mujica.StripFrame{{At: 0}, {At: 10 * time.Second}, {At: 20 * time.Second}}
current := 0
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.VideoThumbnailStrip(c, &current, frames, mujica.VideoThumbnailStripOptions{}).Changed() {
_ = frames[current].At // seek the player there
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleAudioPlayer() {
st := mujica.AudioState{Title: "Overture", Duration: 3 * time.Minute}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.AudioPlayer(c, st, mujica.AudioPlayerOptions{
OnPlay: func() { st.Playing = true },
OnPause: func() { st.Playing = false },
OnSeek: func(d time.Duration) { st.Position = d },
})
}
ui.Render(view, 400, 300, 1)
}
func ExampleAudioWaveform() {
samples := make([]float32, 48000) // from your decoder, -1 to 1
pos, sel := time.Duration(0), mujica.MediaRange{}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.AudioWaveform(c, samples, mujica.AudioWaveformOptions{
Duration: time.Second, Position: pos, Selection: &sel,
OnSeek: func(d time.Duration) { pos = d },
})
}
ui.Render(view, 400, 300, 1)
}
func ExampleAudioSpectrum() {
bands := []float32{0.2, 0.6, 0.9, 0.4, 0.1} // set by your analyser, then c.Invalidate()
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.AudioSpectrum(c, bands, mujica.AudioSpectrumOptions{})
}
ui.Render(view, 400, 300, 1)
}
func ExampleVolumeControl() {
volume, muted := 0.8, false
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.VolumeControl(c, &volume, &muted, mujica.VolumeControlOptions{})
}
ui.Render(view, 400, 300, 1)
}
func ExamplePlaybackSpeedControl() {
rate := 1.0
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.PlaybackSpeedControl(c, &rate, mujica.PlaybackSpeedControlOptions{})
}
ui.Render(view, 400, 300, 1)
}
func ExampleMediaControls() {
st := mujica.MediaControlsState{}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.MediaControls(c, st, mujica.MediaControlsOptions{
OnPrevious: func() {}, OnNext: func() {},
OnPlay: func() { st.Playing = true }, OnPause: func() { st.Playing = false },
OnLoop: func(m mujica.LoopMode) { st.Loop = m }, OnShuffle: func(on bool) { st.Shuffle = on },
})
}
ui.Render(view, 400, 300, 1)
}
func ExamplePlaylist() {
items := []mujica.PlaylistItem{{ID: "a", Title: "Overture"}, {ID: "b", Title: "Masquerade"}}
current := "a"
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if r := mujica.Playlist(c, &items, mujica.PlaylistOptions{Current: current}); r.Played != "" {
current = r.Played
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleSubtitleEditor() {
cues := []mujica.SubtitleCue{{Start: 0, End: 2 * time.Second, Text: "The curtain rises."}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.SubtitleEditor(c, &cues, mujica.SubtitleEditorOptions{})
if len(mujica.CheckSubtitles(cues)) > 0 {
_ = cues // block saving until the issues are fixed
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleScreenRecorderControls() {
st := mujica.RecorderState{}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.ScreenRecorderControls(c, st, mujica.ScreenRecorderControlsOptions{
OnStart: func() { st.Status = mujica.RecorderRecording },
OnPause: func() { st.Status = mujica.RecorderPaused },
OnResume: func() { st.Status = mujica.RecorderRecording },
OnStop: func() { st = mujica.RecorderState{} },
})
}
ui.Render(view, 400, 300, 1)
}
func ExampleCameraPreview() {
frame := mujica.CameraFrame{Image: examplePhoto, Seq: 1} // a new Seq for each captured frame
mirror := true
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.CameraPreview(c, frame, &mirror, mujica.CameraPreviewOptions{})
}
ui.Render(view, 400, 300, 1)
}
func ExampleMicLevelMeter() {
level := float32(0.4) // the input's peak, 0 to 1 of full scale
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.MicLevelMeter(c, level, mujica.MicLevelMeterOptions{})
}
ui.Render(view, 400, 300, 1)
}
func ExampleDeviceSelector() {
devices := mujica.MediaDevices{
Microphones: []mujica.MediaDevice{{ID: "mic-1", Name: "Built-in Microphone"}},
Speakers: []mujica.MediaDevice{{ID: "out-1", Name: "Headphones"}},
}
sel := mujica.DeviceSelection{Microphone: "mic-1"}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.DeviceSelector(c, &sel, devices, mujica.DeviceSelectorOptions{})
}
ui.Render(view, 400, 300, 1)
}
func ExampleImageCompare() {
before, after := ui.NewBitmap(examplePhoto), ui.NewBitmap(examplePhoto)
split := 0.5
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.ImageCompare(c, before, after, &split, mujica.ImageCompareOptions{})
}
ui.Render(view, 400, 300, 1)
}
+116 -1
View File
@@ -1,3 +1,118 @@
package main
var mediaPages = []page{}
import (
"fmt"
"image"
"image/color"
"math"
"time"
"github.com/egoist/mygo/ui"
mujica "mujicaui"
"mujicaui/theme"
)
const mediaCategory = "Media"
var mediaPages = []page{
{ID: "341", Name: "ImageCropper", ZhName: "图片裁剪", Category: mediaCategory, Demo: imageCropperDemo, Code: imageCropperCode,
Keys: "方向键移动裁剪框;Shift+方向键缩放;R / Shift+R 向右 / 向左旋转。"},
{ID: "342", Name: "ImageAnnotator", ZhName: "图片标注", Category: mediaCategory, Demo: imageAnnotatorDemo, Code: imageAnnotatorCode,
Keys: "[ / ] 切换选中标注;方向键移动;Delete 删除;Escape 取消选择。"},
{ID: "343", Name: "ImageThumbnail", ZhName: "缩略图", Category: mediaCategory, Demo: imageThumbnailDemo, Code: imageThumbnailCode,
Keys: "Tab 聚焦;Enter / Space 激活。"},
{ID: "344", Name: "Lightbox", ZhName: "灯箱", Category: mediaCategory, Demo: lightboxDemo, Code: lightboxCode,
Keys: "← / → 切换;Home / End 首尾;Escape 关闭并恢复焦点。"},
{ID: "345", Name: "VideoPlayer", ZhName: "视频播放器", Category: mediaCategory, Demo: videoPlayerDemo, Code: videoPlayerCode,
Keys: "画面聚焦时 Space / K 播放暂停,← / → 跳转 5 秒,F 全屏,C 字幕。"},
{ID: "346", Name: "VideoScrubber", ZhName: "视频时间轴", Category: mediaCategory, Demo: videoScrubberDemo, Code: videoScrubberCode,
Keys: "← / → 跳转一个步长;Home / End 开头结尾。"},
{ID: "347", Name: "VideoThumbnailStrip", ZhName: "缩略帧条", Category: mediaCategory, Demo: videoStripDemo, Code: videoStripCode,
Keys: "Tab 聚焦帧条;← / → 上下帧;Home / End 首尾帧。"},
{ID: "348", Name: "AudioPlayer", ZhName: "音频播放器", Category: mediaCategory, Demo: audioPlayerDemo, Code: audioPlayerCode,
Keys: "Space 播放或暂停;进度条聚焦时 ← / → 跳转 5 秒。"},
{ID: "349", Name: "AudioWaveform", ZhName: "音频波形", Category: mediaCategory, Demo: audioWaveformDemo, Code: audioWaveformCode,
Keys: "← / → 跳转 5 秒;Shift+← / → 调整选区;Escape 清除选区。"},
{ID: "350", Name: "AudioSpectrum", ZhName: "音频频谱", Category: mediaCategory, Demo: audioSpectrumDemo, Code: audioSpectrumCode,
Keys: "Tab 聚焦;← / → 逐个读出频带;Home / End 首尾。"},
{ID: "351", Name: "VolumeControl", ZhName: "音量控制", Category: mediaCategory, Demo: volumeDemo, Code: volumeCode,
Keys: "← / → 音量 ±5%;Home / End;M 静音。"},
{ID: "352", Name: "PlaybackSpeedControl", ZhName: "倍速选择", Category: mediaCategory, Demo: speedDemo, Code: speedCode,
Keys: "Enter / Space 打开;↑ / ↓ 移动;Enter 确认;Escape 关闭。"},
{ID: "353", Name: "MediaControls", ZhName: "媒体控制", Category: mediaCategory, Demo: mediaControlsDemo, Code: mediaControlsCode,
Keys: "Tab 进入;← / → 在按钮间移动;Enter / Space 按下。"},
{ID: "354", Name: "Playlist", ZhName: "播放列表", Category: mediaCategory, Demo: playlistDemo, Code: playlistCode,
Keys: "↑ / ↓ 选择;Enter 播放;Alt+↑ / ↓ 移动;Delete 移除;可拖动排序。"},
{ID: "355", Name: "SubtitleEditor", ZhName: "字幕编辑器", Category: mediaCategory, Demo: subtitleDemo, Code: subtitleCode,
Keys: "Tab 在一行的开始、结束、文本、删除间移动;时间格式 m:ss.mmm。"},
{ID: "356", Name: "ScreenRecorderControls", ZhName: "录屏控制", Category: mediaCategory, Demo: recorderDemo, Code: recorderCode,
Keys: "Tab 在按钮间移动;Enter / Space 按下。"},
{ID: "357", Name: "CameraPreview", ZhName: "摄像头预览", Category: mediaCategory, Demo: cameraDemo, Code: cameraCode,
Keys: "Tab 聚焦预览;M 切换镜像。"},
{ID: "358", Name: "MicLevelMeter", ZhName: "麦克风电平", Category: mediaCategory, Demo: micDemo, Code: micCode,
Keys: "不获取焦点;读数随描述朗读。"},
{ID: "359", Name: "DeviceSelector", ZhName: "设备选择", Category: mediaCategory, Demo: deviceDemo, Code: deviceCode,
Keys: "Tab 在选择框间移动;Enter 打开;输入字母跳转。"},
{ID: "360", Name: "ImageCompare", ZhName: "前后对比", Category: mediaCategory, Demo: compareDemo, Code: compareCode,
Keys: "← / → 移动 1%;Home / End;可拖动。"},
}
// mediaSection titles a part of a demo.
func mediaSection(c *ui.Context, title string, fn func()) {
ui.Column(c).Gap(8).FillWidth().Children(func() {
ui.Text(c, title).FontSize(mujica.FontSize(c, theme.CaptionSize)).TextColor(mujica.Tokens(c).TextMuted)
fn()
})
}
// mediaCheck is a parameter checkbox that logs its changes.
func mediaCheck(c *ui.Context, log func(string), v *bool, label string) {
before := *v
mujica.Checkbox(c, v, label, mujica.CheckboxOptions{})
if *v != before {
log(fmt.Sprintf("%s = %v", label, *v))
}
}
// mediaPicture draws a w×h court scene in variant hues.
func mediaPicture(w, h, variant int) *image.RGBA {
walls := []color.RGBA{{36, 28, 32, 255}, {242, 235, 221, 255}, {73, 42, 54, 255}}
arch := []color.RGBA{{125, 32, 52, 255}, {173, 145, 100, 255}, {53, 94, 128, 255}}
img := image.NewRGBA(image.Rect(0, 0, w, h))
cx := float64(w) / 2
for y := range h {
for x := range w {
c := walls[variant%3]
dx, dy := (float64(x)-cx)/(float64(w)*0.28), (float64(y)-float64(h)*0.55)/(float64(h)*0.5)
if dy > 0 && math.Abs(dx) < 1 || dx*dx+dy*dy < 1 {
c = arch[variant%3]
}
if x%(w/6) < w/40 || y > h*7/8 {
c = color.RGBA{189, 161, 114, 255}
}
if math.Abs(float64(x)-cx)/float64(w)+math.Abs(float64(y)-float64(h)*0.45)/float64(h) < 0.06 {
c = color.RGBA{251, 247, 239, 255}
}
img.Set(x, y, c)
}
}
return img
}
var (
mediaPhotos = []*image.RGBA{mediaPicture(480, 270, 0), mediaPicture(480, 270, 1), mediaPicture(360, 360, 2)}
mediaBitmaps = func() []*ui.Bitmap {
out := make([]*ui.Bitmap, len(mediaPhotos))
for i, p := range mediaPhotos {
out[i] = ui.NewBitmap(p)
}
return out
}()
)
// mediaClockLabel writes d as m:ss for event logs.
func mediaClockLabel(d time.Duration) string {
s := int(d / time.Second)
return fmt.Sprintf("%d:%02d", s/60, s%60)
}
+191
View File
@@ -0,0 +1,191 @@
package main
import (
"fmt"
"math"
"time"
"github.com/egoist/mygo/ui"
mujica "mujicaui"
)
// mediaClockState simulates the caller's playback: position advances while playing.
type mediaClockState struct {
pos, dur time.Duration
playing bool
rate float64
last time.Time
}
// tick advances the position; frames are asked only while playing.
func (m *mediaClockState) tick(c *ui.Context) {
now := c.Now()
if m.playing {
if !m.last.IsZero() {
m.pos += time.Duration(float64(now.Sub(m.last)) * m.rate)
}
if m.pos >= m.dur {
m.pos, m.playing = m.dur, false
} else {
c.AnimationFrame()
}
}
m.last = now
}
func videoPlayerDemo(c *ui.Context, log func(string)) {
type state struct {
clock mediaClockState
subs, full, pip, noFrame bool
disabled bool
}
s := ui.Local(c.Root(), "345", func() state {
return state{clock: mediaClockState{dur: 3 * time.Minute, rate: 1}, subs: true}
})
s.clock.tick(c)
mediaSection(c, "Parameters", func() {
ui.Row(c).Gap(16).Children(func() {
mediaCheck(c, log, &s.noFrame, "No frame")
mediaCheck(c, log, &s.disabled, "Disabled")
})
})
cues := []string{"The curtain rises.", "Welcome to the masquerade.", "Ave Mujica."}
var frame ui.ImageSource = mediaBitmaps[int(s.clock.pos/(20*time.Second))%3]
if s.noFrame {
frame = nil
}
st := mujica.VideoState{Frame: frame, Position: s.clock.pos, Duration: s.clock.dur, Playing: s.clock.playing,
Rate: s.clock.rate, Buffered: []mujica.MediaRange{{Start: 0, End: s.clock.pos + 40*time.Second}},
Subtitle: cues[int(s.clock.pos/(10*time.Second))%3], SubtitlesOn: s.subs, Fullscreen: s.full, PiP: s.pip}
mediaSection(c, "Live", func() {
ui.Column(c).MaxWidth(640).Children(func() {
mujica.VideoPlayer(c, st, mujica.VideoPlayerOptions{
OnPlay: func() { s.clock.playing = true; log("play") },
OnPause: func() { s.clock.playing = false; log("pause") },
OnSeek: func(d time.Duration) { s.clock.pos = d; log("seek " + mediaClockLabel(d)) },
OnRate: func(r float64) { s.clock.rate = r; log(fmt.Sprintf("rate %v×", r)) },
OnSubtitles: func(on bool) { s.subs = on; log(fmt.Sprintf("subtitles %v", on)) },
OnFullscreen: func(on bool) { s.full = on; log(fmt.Sprintf("full screen requested %v", on)) },
OnPiP: func(on bool) { s.pip = on; log(fmt.Sprintf("picture in picture requested %v", on)) },
Disabled: s.disabled,
})
})
})
}
func videoScrubberDemo(c *ui.Context, log func(string)) {
type state struct {
pos time.Duration
preview, disabled bool
}
s := ui.Local(c.Root(), "346", func() state { return state{pos: 40 * time.Second, preview: true} })
mediaSection(c, "Parameters", func() {
ui.Row(c).Gap(16).Children(func() {
mediaCheck(c, log, &s.preview, "Preview slot")
mediaCheck(c, log, &s.disabled, "Disabled")
})
})
opts := mujica.VideoScrubberOptions{Duration: 3 * time.Minute, Disabled: s.disabled,
Buffered: []mujica.MediaRange{{Start: 0, End: 90 * time.Second}, {Start: 120 * time.Second, End: 140 * time.Second}}}
if s.preview {
opts.Preview = func(at time.Duration) {
ui.Image(c, mediaBitmaps[int(at/(time.Minute))%3]).Fit(ui.Cover).Fill()
}
}
mediaSection(c, "Live", func() {
ui.Column(c).MaxWidth(560).Padding(110, 0, 0, 0).Children(func() {
if mujica.VideoScrubber(c, &s.pos, opts).Changed() {
log("seek " + mediaClockLabel(s.pos))
}
})
})
}
func audioPlayerDemo(c *ui.Context, log func(string)) {
type state struct {
clock mediaClockState
disabled bool
}
s := ui.Local(c.Root(), "348", func() state { return state{clock: mediaClockState{dur: 4*time.Minute + 5*time.Second, rate: 1}} })
s.clock.tick(c)
mediaSection(c, "Parameters", func() { mediaCheck(c, log, &s.disabled, "Disabled") })
mediaSection(c, "Live", func() {
ui.Column(c).MaxWidth(560).Children(func() {
mujica.AudioPlayer(c, mujica.AudioState{Title: "Ave Mujica — Overture", Position: s.clock.pos, Duration: s.clock.dur, Playing: s.clock.playing},
mujica.AudioPlayerOptions{
OnPlay: func() { s.clock.playing = true; log("play") },
OnPause: func() { s.clock.playing = false; log("pause") },
OnSeek: func(d time.Duration) { s.clock.pos = d; log("seek " + mediaClockLabel(d)) },
Disabled: s.disabled,
})
})
})
}
// mediaSamples is a synthetic voice-like recording, made once.
var mediaSamples = func() []float32 {
s := make([]float32, 120000)
for i := range s {
env := 0.25 + 0.7*math.Abs(math.Sin(float64(i)/9000))*math.Abs(math.Sin(float64(i)/2100))
s[i] = float32(env * math.Sin(float64(i)/7))
}
return s
}()
func audioWaveformDemo(c *ui.Context, log func(string)) {
type state struct {
pos time.Duration
sel mujica.MediaRange
status int
selecting bool
}
s := ui.Local(c.Root(), "349", func() state { return state{pos: 20 * time.Second, selecting: true} })
mediaSection(c, "Parameters", func() {
ui.Row(c).Gap(16).AlignItems(ui.Center).Children(func() {
before := s.status
mujica.SegmentedControl(c, &s.status, []mujica.Segment{{Label: "Ready"}, {Label: "Loading"}, {Label: "Failed"}, {Label: "Empty"}}, mujica.SegmentedControlOptions{Label: "Status"})
if s.status != before {
log(fmt.Sprintf("status %d", s.status))
}
mediaCheck(c, log, &s.selecting, "Selectable")
})
})
opts := mujica.AudioWaveformOptions{Duration: time.Minute, Position: s.pos, OnSeek: func(d time.Duration) { s.pos = d; log("seek " + mediaClockLabel(d)) }}
if s.selecting {
opts.Selection = &s.sel
}
data := mediaSamples
switch s.status {
case 1:
opts.Status, data = mujica.DataLoading, nil
case 2:
opts.Status, opts.Error, data = mujica.DataFailed, "The recording could not be decoded.", nil
case 3:
data = nil
}
mediaSection(c, "Live", func() {
if mujica.AudioWaveform(c, data, opts).Changed() {
log(fmt.Sprintf("selection %s – %s", mediaClockLabel(s.sel.Start), mediaClockLabel(s.sel.End)))
}
})
}
func audioSpectrumDemo(c *ui.Context, log func(string)) {
type state struct{ live bool }
s := ui.Local(c.Root(), "350", func() state { return state{live: true} })
mediaSection(c, "Parameters", func() { mediaCheck(c, log, &s.live, "Live input") })
freqs := []string{"32 Hz", "63 Hz", "125 Hz", "250 Hz", "500 Hz", "1 kHz", "2 kHz", "4 kHz", "8 kHz", "16 kHz"}
bands := make([]float32, len(freqs))
t := float64(c.Now().UnixMilli()) / 1000
for i := range bands {
bands[i] = float32(0.2 + 0.6*math.Abs(math.Sin(float64(i)*0.9+t*(1+float64(i)*0.2))))
}
if s.live {
// The demo is its own analyser; the component only draws.
c.AnimationFrame()
}
mediaSection(c, "Live", func() {
mujica.AudioSpectrum(c, bands, mujica.AudioSpectrumOptions{BandLabel: func(i int) string { return freqs[i] }})
})
}
+178
View File
@@ -0,0 +1,178 @@
package main
// Media page snippets, each its compiled Example's body.
const (
imageCropperCode = `crop := mujica.ImageCrop{} // empty: starts fitted to the image
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.ImageCropper(c, examplePhoto, &crop, mujica.ImageCropperOptions{Aspect: 1}).Changed() {
_ = crop.Rect // in pixels of the photo turned by crop.Rotation
}
}
ui.Render(view, 400, 300, 1)`
imageAnnotatorCode = `photo := ui.NewBitmap(examplePhoto)
var notes []mujica.Annotation
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.ImageAnnotator(c, photo, &notes, mujica.ImageAnnotatorOptions{}).Changed() {
data, _ := mujica.ExportAnnotations(notes)
_ = data // save the JSON
}
}
ui.Render(view, 400, 300, 1)`
imageThumbnailCode = `photo := ui.NewBitmap(examplePhoto)
selected := false
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.ImageThumbnail(c, photo, mujica.ImageThumbnailOptions{Alt: "Grand hall", Aspect: 4.0 / 3, Selected: selected}).Changed() {
selected = !selected
}
}
ui.Render(view, 400, 300, 1)`
lightboxCode = `images := []mujica.ViewerImage{{Source: ui.NewBitmap(examplePhoto), Alt: "The grand hall"}}
open, index := false, 0
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.Button(c, "View photos", mujica.ButtonOptions{}).Clicked() {
open = true
}
mujica.Lightbox(c, &open, &index, images)
}
ui.Render(view, 400, 300, 1)`
videoPlayerCode = `st := mujica.VideoState{Duration: 4 * time.Minute, Rate: 1}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.VideoPlayer(c, st, mujica.VideoPlayerOptions{
OnPlay: func() { st.Playing = true }, // start your decoder
OnPause: func() { st.Playing = false }, // pause it
OnSeek: func(d time.Duration) { st.Position = d },
OnRate: func(r float64) { st.Rate = r },
})
}
ui.Render(view, 400, 300, 1)`
videoScrubberCode = `pos := 30 * time.Second
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.VideoScrubber(c, &pos, mujica.VideoScrubberOptions{
Duration: 2 * time.Minute,
Buffered: []mujica.MediaRange{{Start: 0, End: time.Minute}},
})
}
ui.Render(view, 400, 300, 1)`
videoStripCode = `frames := []mujica.StripFrame{{At: 0}, {At: 10 * time.Second}, {At: 20 * time.Second}}
current := 0
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.VideoThumbnailStrip(c, &current, frames, mujica.VideoThumbnailStripOptions{}).Changed() {
_ = frames[current].At // seek the player there
}
}
ui.Render(view, 400, 300, 1)`
audioPlayerCode = `st := mujica.AudioState{Title: "Overture", Duration: 3 * time.Minute}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.AudioPlayer(c, st, mujica.AudioPlayerOptions{
OnPlay: func() { st.Playing = true },
OnPause: func() { st.Playing = false },
OnSeek: func(d time.Duration) { st.Position = d },
})
}
ui.Render(view, 400, 300, 1)`
audioWaveformCode = `samples := make([]float32, 48000) // from your decoder, -1 to 1
pos, sel := time.Duration(0), mujica.MediaRange{}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.AudioWaveform(c, samples, mujica.AudioWaveformOptions{
Duration: time.Second, Position: pos, Selection: &sel,
OnSeek: func(d time.Duration) { pos = d },
})
}
ui.Render(view, 400, 300, 1)`
audioSpectrumCode = `bands := []float32{0.2, 0.6, 0.9, 0.4, 0.1} // set by your analyser, then c.Invalidate()
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.AudioSpectrum(c, bands, mujica.AudioSpectrumOptions{})
}
ui.Render(view, 400, 300, 1)`
volumeCode = `volume, muted := 0.8, false
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.VolumeControl(c, &volume, &muted, mujica.VolumeControlOptions{})
}
ui.Render(view, 400, 300, 1)`
speedCode = `rate := 1.0
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.PlaybackSpeedControl(c, &rate, mujica.PlaybackSpeedControlOptions{})
}
ui.Render(view, 400, 300, 1)`
mediaControlsCode = `st := mujica.MediaControlsState{}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.MediaControls(c, st, mujica.MediaControlsOptions{
OnPrevious: func() {}, OnNext: func() {},
OnPlay: func() { st.Playing = true }, OnPause: func() { st.Playing = false },
OnLoop: func(m mujica.LoopMode) { st.Loop = m }, OnShuffle: func(on bool) { st.Shuffle = on },
})
}
ui.Render(view, 400, 300, 1)`
playlistCode = `items := []mujica.PlaylistItem{{ID: "a", Title: "Overture"}, {ID: "b", Title: "Masquerade"}}
current := "a"
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if r := mujica.Playlist(c, &items, mujica.PlaylistOptions{Current: current}); r.Played != "" {
current = r.Played
}
}
ui.Render(view, 400, 300, 1)`
subtitleCode = `cues := []mujica.SubtitleCue{{Start: 0, End: 2 * time.Second, Text: "The curtain rises."}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.SubtitleEditor(c, &cues, mujica.SubtitleEditorOptions{})
if len(mujica.CheckSubtitles(cues)) > 0 {
_ = cues // block saving until the issues are fixed
}
}
ui.Render(view, 400, 300, 1)`
recorderCode = `st := mujica.RecorderState{}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.ScreenRecorderControls(c, st, mujica.ScreenRecorderControlsOptions{
OnStart: func() { st.Status = mujica.RecorderRecording },
OnPause: func() { st.Status = mujica.RecorderPaused },
OnResume: func() { st.Status = mujica.RecorderRecording },
OnStop: func() { st = mujica.RecorderState{} },
})
}
ui.Render(view, 400, 300, 1)`
cameraCode = `frame := mujica.CameraFrame{Image: examplePhoto, Seq: 1} // a new Seq for each captured frame
mirror := true
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.CameraPreview(c, frame, &mirror, mujica.CameraPreviewOptions{})
}
ui.Render(view, 400, 300, 1)`
micCode = `level := float32(0.4) // the input's peak, 0 to 1 of full scale
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.MicLevelMeter(c, level, mujica.MicLevelMeterOptions{})
}
ui.Render(view, 400, 300, 1)`
deviceCode = `devices := mujica.MediaDevices{
Microphones: []mujica.MediaDevice{{ID: "mic-1", Name: "Built-in Microphone"}},
Speakers: []mujica.MediaDevice{{ID: "out-1", Name: "Headphones"}},
}
sel := mujica.DeviceSelection{Microphone: "mic-1"}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.DeviceSelector(c, &sel, devices, mujica.DeviceSelectorOptions{})
}
ui.Render(view, 400, 300, 1)`
compareCode = `before, after := ui.NewBitmap(examplePhoto), ui.NewBitmap(examplePhoto)
split := 0.5
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.ImageCompare(c, before, after, &split, mujica.ImageCompareOptions{})
}
ui.Render(view, 400, 300, 1)`
)
+225
View File
@@ -0,0 +1,225 @@
package main
import (
"fmt"
"math"
"time"
"github.com/egoist/mygo/ui"
mujica "mujicaui"
)
func volumeDemo(c *ui.Context, log func(string)) {
type state struct {
volume float64
muted, disabled bool
}
s := ui.Local(c.Root(), "351", func() state { return state{volume: 0.7} })
mediaSection(c, "Parameters", func() { mediaCheck(c, log, &s.disabled, "Disabled") })
mediaSection(c, "Live", func() {
if mujica.VolumeControl(c, &s.volume, &s.muted, mujica.VolumeControlOptions{Disabled: s.disabled}).Changed() {
log(fmt.Sprintf("volume %.0f%%, muted %v", s.volume*100, s.muted))
}
})
}
func speedDemo(c *ui.Context, log func(string)) {
type state struct {
rate float64
disabled bool
}
s := ui.Local(c.Root(), "352", func() state { return state{rate: 1} })
mediaSection(c, "Parameters", func() { mediaCheck(c, log, &s.disabled, "Disabled") })
mediaSection(c, "Live", func() {
if mujica.PlaybackSpeedControl(c, &s.rate, mujica.PlaybackSpeedControlOptions{Disabled: s.disabled}).Changed() {
log(fmt.Sprintf("rate %v×", s.rate))
}
})
}
func mediaControlsDemo(c *ui.Context, log func(string)) {
type state struct {
st mujica.MediaControlsState
track int
disabled bool
}
s := ui.Local(c.Root(), "353", func() state { return state{} })
tracks := []string{"Ave Mujica", "Imprisoned XII", "KiLLKiSS", "Symbol I: △"}
s.st.NoPrevious, s.st.NoNext = s.track == 0, s.track == len(tracks)-1
mediaSection(c, "Parameters", func() { mediaCheck(c, log, &s.disabled, "Disabled") })
mediaSection(c, "Live", func() {
ui.Text(c, fmt.Sprintf("Now: %s (%d of %d)", tracks[s.track], s.track+1, len(tracks)))
mujica.MediaControls(c, s.st, mujica.MediaControlsOptions{
OnPrevious: func() { s.track--; log("previous") },
OnNext: func() { s.track++; log("next") },
OnPlay: func() { s.st.Playing = true; log("play") },
OnPause: func() { s.st.Playing = false; log("pause") },
OnLoop: func(m mujica.LoopMode) { s.st.Loop = m; log(fmt.Sprintf("loop mode %d", m)) },
OnShuffle: func(on bool) { s.st.Shuffle = on; log(fmt.Sprintf("shuffle %v", on)) },
Disabled: s.disabled,
})
})
}
func playlistDemo(c *ui.Context, log func(string)) {
type state struct {
items []mujica.PlaylistItem
current string
disabled bool
}
s := ui.Local(c.Root(), "354", func() state {
titles := []string{"Ave Mujica", "Imprisoned XII", "KiLLKiSS", "Symbol I: △", "Choir 'S' Choir", "Georgette Me, Georgette You"}
items := make([]mujica.PlaylistItem, len(titles))
for i, t := range titles {
items[i] = mujica.PlaylistItem{ID: fmt.Sprint("t", i), Title: t, Subtitle: fmt.Sprintf("Act %d", i+1), Duration: time.Duration(200+i*17) * time.Second}
}
return state{items: items, current: "t1"}
})
mediaSection(c, "Parameters", func() {
ui.Row(c).Gap(16).AlignItems(ui.Center).Children(func() {
mediaCheck(c, log, &s.disabled, "Disabled")
if mujica.Button(c, "Add track", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() {
id := fmt.Sprint("t", len(s.items)+int(time.Now().UnixNano()%1000000))
s.items = append(s.items, mujica.PlaylistItem{ID: id, Title: "Encore " + id, Duration: 3 * time.Minute})
log("added " + id)
}
})
})
mediaSection(c, "Live", func() {
ui.Column(c).MaxWidth(520).Children(func() {
r := mujica.Playlist(c, &s.items, mujica.PlaylistOptions{Current: s.current, Disabled: s.disabled})
switch {
case r.Played != "":
s.current = r.Played
log("play " + r.Played)
case r.Removed != "":
log("removed " + r.Removed)
case r.Moved:
log("reordered")
}
})
})
}
func subtitleDemo(c *ui.Context, log func(string)) {
type state struct {
cues []mujica.SubtitleCue
disabled bool
}
s := ui.Local(c.Root(), "355", func() state {
return state{cues: []mujica.SubtitleCue{
{Start: 0, End: 2500 * time.Millisecond, Text: "The curtain rises."},
{Start: 2 * time.Second, End: 5 * time.Second, Text: "Welcome to the masquerade."},
{Start: 6 * time.Second, End: 9 * time.Second, Text: "Ave Mujica."},
}}
})
mediaSection(c, "Parameters", func() { mediaCheck(c, log, &s.disabled, "Disabled") })
mediaSection(c, "Live", func() {
ui.Column(c).MaxWidth(720).Children(func() {
if mujica.SubtitleEditor(c, &s.cues, mujica.SubtitleEditorOptions{Height: 260, Disabled: s.disabled}).Changed() {
log(fmt.Sprintf("%d cues, %d issues", len(s.cues), len(mujica.CheckSubtitles(s.cues))))
}
})
})
}
func recorderDemo(c *ui.Context, log func(string)) {
type state struct {
st mujica.RecorderState
since time.Time
disabled bool
accumulated time.Duration
}
s := ui.Local(c.Root(), "356", func() state { return state{} })
if s.st.Status == mujica.RecorderRecording {
s.st.Elapsed = s.accumulated + c.Now().Sub(s.since)
// The clock changes each second; ask for that frame only.
c.After(time.Second - s.st.Elapsed%time.Second)
}
mediaSection(c, "Parameters", func() { mediaCheck(c, log, &s.disabled, "Disabled") })
mediaSection(c, "Live", func() {
mujica.ScreenRecorderControls(c, s.st, mujica.ScreenRecorderControlsOptions{
OnStart: func() {
s.st, s.since, s.accumulated = mujica.RecorderState{Status: mujica.RecorderRecording}, c.Now(), 0
log("start")
},
OnPause: func() { s.st.Status, s.accumulated = mujica.RecorderPaused, s.st.Elapsed; log("pause") },
OnResume: func() { s.st.Status, s.since = mujica.RecorderRecording, c.Now(); log("resume") },
OnStop: func() { log("stop at " + mediaClockLabel(s.st.Elapsed)); s.st = mujica.RecorderState{} },
Disabled: s.disabled,
})
})
}
func cameraDemo(c *ui.Context, log func(string)) {
type state struct {
mirror, signal, disabled bool
}
s := ui.Local(c.Root(), "357", func() state { return state{mirror: true, signal: true} })
mediaSection(c, "Parameters", func() {
ui.Row(c).Gap(16).Children(func() {
mediaCheck(c, log, &s.signal, "Signal")
mediaCheck(c, log, &s.disabled, "Disabled")
})
})
frame := mujica.CameraFrame{}
if s.signal {
frame = mujica.CameraFrame{Image: mediaPhotos[2], Seq: 1}
}
mediaSection(c, "Live", func() {
ui.Column(c).MaxWidth(480).Children(func() {
if mujica.CameraPreview(c, frame, &s.mirror, mujica.CameraPreviewOptions{Disabled: s.disabled}).Changed() {
log(fmt.Sprintf("mirror %v", s.mirror))
}
})
})
}
func micDemo(c *ui.Context, log func(string)) {
type state struct{ live bool }
s := ui.Local(c.Root(), "358", func() state { return state{live: true} })
mediaSection(c, "Parameters", func() { mediaCheck(c, log, &s.live, "Live input") })
level := float32(0.3)
if s.live {
t := float64(c.Now().UnixMilli()) / 1000
level = float32(math.Min(1, 0.15+0.8*math.Abs(math.Sin(t*1.3)*math.Sin(t*0.37))))
c.AnimationFrame()
}
mediaSection(c, "Live", func() {
ui.Column(c).MaxWidth(420).Children(func() { mujica.MicLevelMeter(c, level, mujica.MicLevelMeterOptions{}) })
})
}
func deviceDemo(c *ui.Context, log func(string)) {
type state struct {
sel mujica.DeviceSelection
noCamera, disabled bool
}
s := ui.Local(c.Root(), "359", func() state { return state{sel: mujica.DeviceSelection{Microphone: "m1", Speaker: "s2"}} })
devices := mujica.MediaDevices{
Microphones: []mujica.MediaDevice{{ID: "m1", Name: "Built-in Microphone"}, {ID: "m2", Name: "Studio Condenser"}},
Cameras: []mujica.MediaDevice{{ID: "c1", Name: "FaceTime HD Camera"}},
Speakers: []mujica.MediaDevice{{ID: "s1", Name: "Built-in Speakers"}, {ID: "s2", Name: "Headphones"}},
}
mediaSection(c, "Parameters", func() {
ui.Row(c).Gap(16).Children(func() {
before := s.noCamera
mediaCheck(c, log, &s.noCamera, "No cameras")
if s.noCamera != before {
s.sel.Camera = ""
}
mediaCheck(c, log, &s.disabled, "Disabled")
})
})
if s.noCamera {
devices.Cameras = []mujica.MediaDevice{}
}
mediaSection(c, "Live", func() {
ui.Column(c).MaxWidth(480).Children(func() {
if mujica.DeviceSelector(c, &s.sel, devices, mujica.DeviceSelectorOptions{Disabled: s.disabled}).Changed() {
log(fmt.Sprintf("microphone %q, camera %q, speaker %q", s.sel.Microphone, s.sel.Camera, s.sel.Speaker))
}
})
})
}
+162
View File
@@ -0,0 +1,162 @@
package main
import (
"errors"
"fmt"
"image"
"time"
"github.com/egoist/mygo/ui"
mujica "mujicaui"
)
func imageCropperDemo(c *ui.Context, log func(string)) {
type state struct {
crop mujica.ImageCrop
aspect int
disabled bool
}
s := ui.Local(c.Root(), "341", func() state { return state{} })
aspects := []float64{0, 1, 16.0 / 9, 4.0 / 3}
mediaSection(c, "Parameters", func() {
ui.Row(c).Gap(16).AlignItems(ui.Center).Children(func() {
before := s.aspect
mujica.SegmentedControl(c, &s.aspect, []mujica.Segment{{Label: "Free"}, {Label: "1:1"}, {Label: "16:9"}, {Label: "4:3"}}, mujica.SegmentedControlOptions{Label: "Aspect"})
if s.aspect != before {
s.crop.Rect = image.Rectangle{} // refit to the new ratio
log(fmt.Sprintf("aspect = %v", aspects[s.aspect]))
}
mediaCheck(c, log, &s.disabled, "Disabled")
})
})
mediaSection(c, "Live", func() {
if mujica.ImageCropper(c, mediaPhotos[0], &s.crop, mujica.ImageCropperOptions{Aspect: aspects[s.aspect], Disabled: s.disabled}).Changed() {
log(fmt.Sprintf("crop %v rotated %d°", s.crop.Rect, s.crop.Rotation))
}
})
}
func imageAnnotatorDemo(c *ui.Context, log func(string)) {
type state struct {
notes []mujica.Annotation
disabled bool
}
s := ui.Local(c.Root(), "342", func() state {
return state{notes: []mujica.Annotation{{Kind: mujica.AnnotateRect, From: image.Pt(150, 60), To: image.Pt(330, 230)}}}
})
mediaSection(c, "Parameters", func() {
ui.Row(c).Gap(16).AlignItems(ui.Center).Children(func() {
mediaCheck(c, log, &s.disabled, "Disabled")
if mujica.Button(c, "Export JSON", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() {
data, err := mujica.ExportAnnotations(s.notes)
if err != nil {
log("export failed: " + err.Error())
} else {
c.WriteClipboard(string(data))
log(fmt.Sprintf("exported %d bytes to the clipboard", len(data)))
}
}
})
})
mediaSection(c, "Live", func() {
if mujica.ImageAnnotator(c, mediaBitmaps[1], &s.notes, mujica.ImageAnnotatorOptions{Disabled: s.disabled}).Changed() {
log(fmt.Sprintf("annotations: %d", len(s.notes)))
}
})
}
func imageThumbnailDemo(c *ui.Context, log func(string)) {
type state struct {
selected int
loading, failed, disabled bool
}
s := ui.Local(c.Root(), "343", func() state { return state{} })
mediaSection(c, "Parameters", func() {
ui.Row(c).Gap(16).Children(func() {
mediaCheck(c, log, &s.loading, "Loading")
mediaCheck(c, log, &s.failed, "Failed")
mediaCheck(c, log, &s.disabled, "Disabled")
})
})
mediaSection(c, "Live", func() {
ui.Row(c).Gap(12).Wrap().Children(func() {
for i, b := range mediaBitmaps {
var err error
if s.failed && i == 2 {
err = errors.New("the file could not be read")
}
o := mujica.ImageThumbnailOptions{Alt: fmt.Sprintf("Scene %d", i+1), Aspect: 4.0 / 3, Width: 120,
Selected: s.selected == i, Loading: s.loading && i == 1, Err: err, Disabled: s.disabled}
if mujica.ImageThumbnail(c, b, o).Changed() {
s.selected = i
log(fmt.Sprintf("selected scene %d", i+1))
}
}
})
})
}
func lightboxDemo(c *ui.Context, log func(string)) {
type state struct {
open bool
index int
}
s := ui.Local(c.Root(), "344", func() state { return state{} })
images := []mujica.ViewerImage{
{Source: mediaBitmaps[0], Alt: "The grand hall at dusk"},
{Source: mediaBitmaps[1], Alt: "The ivory gallery"},
{Source: mediaBitmaps[2], Alt: "The blue chapel"},
}
mediaSection(c, "Live", func() {
ui.Row(c).Gap(12).Children(func() {
for i, im := range images {
if mujica.ImageThumbnail(c, im.Source, mujica.ImageThumbnailOptions{Alt: im.Alt, Aspect: 4.0 / 3}).Changed() {
s.open, s.index = true, i
log(fmt.Sprintf("open at %d", i+1))
}
}
})
was := s.open
if mujica.Lightbox(c, &s.open, &s.index, images).Changed() {
log(fmt.Sprintf("image %d", s.index+1))
}
if was && !s.open {
log("closed")
}
})
}
func videoStripDemo(c *ui.Context, log func(string)) {
type state struct {
current int
disabled bool
}
s := ui.Local(c.Root(), "347", func() state { return state{} })
frames := make([]mujica.StripFrame, 240)
for i := range frames {
frames[i] = mujica.StripFrame{Image: mediaBitmaps[i%3], At: time.Duration(i) * 5 * time.Second}
}
mediaSection(c, "Parameters", func() { mediaCheck(c, log, &s.disabled, "Disabled") })
mediaSection(c, "Live", func() {
if mujica.VideoThumbnailStrip(c, &s.current, frames, mujica.VideoThumbnailStripOptions{Disabled: s.disabled}).Changed() {
log("jump to " + mediaClockLabel(frames[s.current].At))
}
})
}
func compareDemo(c *ui.Context, log func(string)) {
type state struct {
split float64
disabled bool
}
s := ui.Local(c.Root(), "360", func() state { return state{split: 0.5} })
mediaSection(c, "Parameters", func() { mediaCheck(c, log, &s.disabled, "Disabled") })
mediaSection(c, "Live", func() {
ui.Column(c).MaxWidth(560).Children(func() {
if mujica.ImageCompare(c, mediaBitmaps[0], mediaBitmaps[1], &s.split, mujica.ImageCompareOptions{Disabled: s.disabled}).Changed() {
log(fmt.Sprintf("split %.0f%%", s.split*100))
}
})
})
}
+292
View File
@@ -0,0 +1,292 @@
package mujica
import (
"encoding/json"
"fmt"
"image"
"log/slog"
"math"
"slices"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
)
// AnnotationKind is the shape of an Annotation.
type AnnotationKind int
const (
AnnotateRect AnnotationKind = iota
AnnotateArrow
AnnotateText
)
// Annotation is a mark in image pixels; text at From.
type Annotation struct {
Kind AnnotationKind
From, To image.Point
Text string
}
// annotationJSON is the exported form of an Annotation.
type annotationJSON struct {
Kind string `json:"kind"`
X0 int `json:"x0"`
Y0 int `json:"y0"`
X1 int `json:"x1"`
Y1 int `json:"y1"`
Text string `json:"text,omitempty"`
}
// ExportAnnotations writes notes as JSON: kind, corners and text.
func ExportAnnotations(notes []Annotation) ([]byte, error) {
out := make([]annotationJSON, len(notes))
for i, n := range notes {
out[i] = annotationJSON{[]string{"rect", "arrow", "text"}[n.Kind], n.From.X, n.From.Y, n.To.X, n.To.Y, n.Text}
}
return json.Marshal(out)
}
// ImageAnnotatorOptions configure an ImageAnnotator.
type ImageAnnotatorOptions struct {
// Height is the stage's height; zero means 320.
Height float32
Disabled bool
Label string
}
type annotatorState struct {
tool int // 0 select, 1 rect, 2 arrow, 3 text
selected int
draft string
pressing bool
start, now image.Point
moved image.Point
}
type annotatorKey struct{}
// ImageAnnotator draws, selects, moves and deletes marks over src.
func ImageAnnotator(c *ui.Context, src ui.ImageSource, notes *[]Annotation, opts ImageAnnotatorOptions) ChoiceResult {
iw, ih := viewerSize(src)
size := image.Pt(int(iw), int(ih))
for _, n := range *notes {
if n.Kind < AnnotateRect || n.Kind > AnnotateText || (n.Kind == AnnotateText && n.Text == "") {
panic(fmt.Sprintf("mujica: ImageAnnotator bad annotation %+v", n))
}
}
if opts.Height < 0 {
panic("mujica: ImageAnnotator Height must not be negative")
}
h := opts.Height
if h == 0 {
h = 320
}
k := Tokens(c)
u := Density(c).Unit()
before := slices.Clone(*notes)
col := ui.Column(c).Gap(u * 2).Disabled(opts.Disabled)
st := ui.Local(col, annotatorKey{}, func() *annotatorState { return &annotatorState{selected: -1, draft: msg(c, "image_annotator.note")} })
s := *st
if s.selected >= len(*notes) {
s.selected = -1
}
col.Children(func() {
annotatorToolbar(c, k, s, notes)
stage := ui.Box(c).FillWidth().Height(h).Focusable().Clip().Radius(theme.ControlRadius).
Background(videoBlack).Label(annotatorLabel(c, opts.Label)).Cursor(ui.CursorCrosshair)
internal.Ring(stage, k.Focus, theme.ControlRadius)
r := stage.Bounds()
scale, ox, oy := cropperPlace(r, size)
if !stage.IsDisabled() && scale > 0 {
annotatorPointer(stage, s, notes, r, size, scale, ox, oy)
annotatorKeys(stage, s, notes, size)
}
if s.selected >= 0 {
n := (*notes)[s.selected]
stage.Description(fmt.Sprintf(msg(c, "image_annotator.selected"), s.selected+1, len(*notes), annotatorKind(c, n.Kind)))
}
list, sel, preview := slices.Clone(*notes), s.selected, annotatorPreview(s)
size := FontSize(c, theme.BodySize)
stage.Draw(func(p *ui.Painter, r ui.Rect) {
scale, ox, oy := cropperPlace(r, image.Pt(int(iw), int(ih)))
p.Image(src, ui.Rect{X: ox, Y: oy, W: iw * scale, H: ih * scale}, ui.FillBox)
at := func(pt image.Point) (float32, float32) {
return internal.Snap(p, ox+float32(pt.X)*scale), internal.Snap(p, oy+float32(pt.Y)*scale)
}
for i, n := range append(list, preview...) {
annotatorPaint(p, k, n, at, size, i == sel)
}
})
})
changed := !slices.Equal(before, *notes)
if changed {
slog.Debug("mujica: image_annotator changed", "count", len(*notes))
}
return ChoiceResult{Element: col, changed: changed}
}
func annotatorLabel(c *ui.Context, l string) string {
if l != "" {
return l
}
return msg(c, "image_annotator.label")
}
func annotatorKind(c *ui.Context, k AnnotationKind) string {
return msg(c, []string{"image_annotator.rect", "image_annotator.arrow", "image_annotator.text"}[k])
}
// annotatorToolbar picks tools, edits text and deletes the selection.
func annotatorToolbar(c *ui.Context, k theme.Tokens, s *annotatorState, notes *[]Annotation) {
u := Density(c).Unit()
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Wrap().Children(func() {
SegmentedControl(c, &s.tool, []Segment{
{Label: msg(c, "image_annotator.select"), Icon: mediaPointer, HideLabel: true},
{Label: msg(c, "image_annotator.rect"), Icon: mediaRect, HideLabel: true},
{Label: msg(c, "image_annotator.arrow"), Icon: mediaArrow, HideLabel: true},
{Label: msg(c, "image_annotator.text"), Icon: mediaText, HideLabel: true},
}, SegmentedControlOptions{Label: msg(c, "image_annotator.tool")})
editing := s.selected >= 0 && (*notes)[s.selected].Kind == AnnotateText
if editing {
s.draft = (*notes)[s.selected].Text
}
if s.tool == 3 || editing {
in := TextInput(c, &s.draft, TextInputOptions{Label: msg(c, "image_annotator.textLabel")})
in.Element.Width(180)
if editing && in.Changed() && s.draft != "" {
(*notes)[s.selected].Text = s.draft
}
}
del := iconAction(c, icons.Must("trash-2"), msg(c, "image_annotator.delete")).Disabled(s.selected < 0)
if del.Clicked() && !del.IsDisabled() {
*notes = slices.Delete(*notes, s.selected, s.selected+1)
s.selected = -1
}
ui.Textf(c, msg(c, "image_annotator.count"), len(*notes)).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
})
}
// annotatorPreview is the shape being drawn, if any.
func annotatorPreview(s *annotatorState) []Annotation {
if !s.pressing || s.tool == 0 || s.tool == 3 {
return nil
}
return []Annotation{{Kind: AnnotationKind(s.tool - 1), From: s.start, To: s.now}}
}
// annotatorPointer draws shapes, selects by click, drags the selection.
func annotatorPointer(stage *ui.Element, s *annotatorState, notes *[]Annotation, r ui.Rect, size image.Point, scale, ox, oy float32) {
_, _, _ = stage.Dragged()
px, py, _ := stage.PointerPosition()
pt := image.Pt(int(math.Round(float64((px+r.X-ox)/scale))), int(math.Round(float64((py+r.Y-oy)/scale))))
pt = image.Pt(max(0, min(size.X, pt.X)), max(0, min(size.Y, pt.Y)))
switch {
case stage.Pressed() && !s.pressing:
s.pressing, s.start, s.now, s.moved = true, pt, pt, image.Point{}
if s.tool == 0 {
s.selected = annotatorHit(*notes, pt, int(8/scale)+1)
}
case stage.Pressed():
if s.tool == 0 && s.selected >= 0 {
d := pt.Sub(s.now)
n := &(*notes)[s.selected]
n.From, n.To = n.From.Add(d), n.To.Add(d)
}
s.now = pt
case s.pressing:
s.pressing = false
big := absInt(s.now.X-s.start.X) >= 4 || absInt(s.now.Y-s.start.Y) >= 4
switch {
case s.tool == 3 && s.draft != "":
*notes = append(*notes, Annotation{Kind: AnnotateText, From: s.start, To: s.start, Text: s.draft})
s.selected = len(*notes) - 1
case (s.tool == 1 || s.tool == 2) && big:
*notes = append(*notes, Annotation{Kind: AnnotationKind(s.tool - 1), From: s.start, To: s.now})
s.selected = len(*notes) - 1
}
}
}
// annotatorKeys: [ ] select, arrows move, Delete removes, Escape clears.
func annotatorKeys(stage *ui.Element, s *annotatorState, notes *[]Annotation, size image.Point) {
n := len(*notes)
step := max(1, size.X/100)
move := func(dx, dy int) {
if s.selected >= 0 {
a := &(*notes)[s.selected]
a.From, a.To = a.From.Add(image.Pt(dx, dy)), a.To.Add(image.Pt(dx, dy))
}
}
switch {
case n > 0 && stage.Shortcut(0, ui.KeyBracketRight):
s.selected = (s.selected + 1) % n
case n > 0 && stage.Shortcut(0, ui.KeyBracketLeft):
s.selected = (s.selected - 1 + n) % n
case s.selected >= 0 && (stage.Shortcut(0, ui.KeyDelete) || stage.Shortcut(0, ui.KeyBackspace)):
*notes = slices.Delete(*notes, s.selected, s.selected+1)
s.selected = -1
case stage.Shortcut(0, ui.KeyEscape):
s.selected = -1
case stage.Shortcut(0, ui.KeyLeft):
move(-step, 0)
case stage.Shortcut(0, ui.KeyRight):
move(step, 0)
case stage.Shortcut(0, ui.KeyUp):
move(0, -step)
case stage.Shortcut(0, ui.KeyDown):
move(0, step)
}
}
// annotatorHit is the topmost mark near pt, or -1.
func annotatorHit(notes []Annotation, pt image.Point, slop int) int {
for i := len(notes) - 1; i >= 0; i-- {
n := notes[i]
b := image.Rectangle{Min: n.From, Max: n.To}.Canon()
if n.Kind == AnnotateText {
b = image.Rectangle{Min: n.From, Max: n.From.Add(image.Pt(len(n.Text)*8, 18))}
}
if pt.In(b.Inset(-slop)) {
return i
}
}
return -1
}
func absInt(v int) int { return max(v, -v) }
// annotatorPaint draws a mark with a halo for any picture.
func annotatorPaint(p *ui.Painter, k theme.Tokens, n Annotation, at func(image.Point) (float32, float32), size float32, selected bool) {
x0, y0 := at(n.From)
x1, y1 := at(n.To)
halo, ink := ui.Hex("#FBF7EF"), ui.Hex("#A12635")
switch n.Kind {
case AnnotateRect:
b := ui.Rect{X: min(x0, x1), Y: min(y0, y1), W: abs32(x1 - x0), H: abs32(y1 - y0)}
p.Stroke(b, halo, 0, 4)
p.Stroke(b, ink, 0, 2)
case AnnotateArrow:
var path ui.Path
path.MoveTo(x0, y0).LineTo(x1, y1)
a := math.Atan2(float64(y1-y0), float64(x1-x0))
for _, d := range []float64{2.6, -2.6} {
path.MoveTo(x1, y1).LineTo(x1+12*float32(math.Cos(a+d)), y1+12*float32(math.Sin(a+d)))
}
p.StrokePath(&path, 4, halo)
p.StrokePath(&path, 2, ink)
case AnnotateText:
w, h := p.MeasureText(1000, ui.Span{Text: n.Text, Size: size})
p.Fill(ui.Rect{X: x0, Y: y0, W: w + 8, H: h + 4}, halo, theme.ControlRadius)
p.Text(x0+4, y0+2, n.Text, size, ink)
x1, y1 = x0+w+8, y0+h+4
}
if selected {
b := ui.Rect{X: min(x0, x1) - 4, Y: min(y0, y1) - 4, W: abs32(x1-x0) + 8, H: abs32(y1-y0) + 8}
p.StrokeDashed(b, k.Focus, 0, 1)
p.FillPath(internal.Diamond(internal.Snap(p, b.X), internal.Snap(p, b.Y), 8), k.Focus)
}
}
+121
View File
@@ -0,0 +1,121 @@
package mujica
import (
"image"
"testing"
"github.com/egoist/mygo/ui"
)
func annotatorTester(notes *[]Annotation, disabled bool, n *int) *ui.Tester {
src := mediaTestBitmap(400, 200)
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Padding(20).Width(440).Children(func() {
if ImageAnnotator(c, src, notes, ImageAnnotatorOptions{Height: 200, Disabled: disabled}).Changed() {
*n++
}
})
}, 520, 360)
}
// annotatorAt maps image pixel (x, y) to window DIPs on the stage.
func annotatorAt(tt *ui.Tester, x, y float32) (float32, float32) {
r, _ := tt.Find("Annotations")
scale := min(r.W/400, r.H/200)
return r.X + (r.W-400*scale)/2 + x*scale, r.Y + (r.H-200*scale)/2 + y*scale
}
func annotatorDrag(tt *ui.Tester, x0, y0, x1, y1 float32) {
ax, ay := annotatorAt(tt, x0, y0)
bx, by := annotatorAt(tt, x1, y1)
tt.Press(ax, ay)
tt.Move((ax+bx)/2, (ay+by)/2)
tt.Move(bx, by)
tt.Release(bx, by)
}
func TestImageAnnotatorDrawShapes(t *testing.T) {
var notes []Annotation
n := 0
tt := annotatorTester(&notes, false, &n)
if err := tt.Click("Rectangle"); err != nil {
t.Fatal(err)
}
annotatorDrag(tt, 40, 40, 140, 100)
if err := tt.Click("Arrow"); err != nil {
t.Fatal(err)
}
annotatorDrag(tt, 200, 150, 300, 60)
if err := tt.Click("Text"); err != nil {
t.Fatal(err)
}
x, y := annotatorAt(tt, 250, 20)
tt.ClickAt(x, y)
if len(notes) != 3 || n != 3 {
t.Fatalf("notes %+v, changes %d", notes, n)
}
near := func(p, q image.Point) bool { return absInt(p.X-q.X) <= 2 && absInt(p.Y-q.Y) <= 2 }
if notes[0].Kind != AnnotateRect || !near(notes[0].From, image.Pt(40, 40)) || !near(notes[0].To, image.Pt(140, 100)) {
t.Fatalf("rect %+v", notes[0])
}
if notes[1].Kind != AnnotateArrow || !near(notes[1].To, image.Pt(300, 60)) {
t.Fatalf("arrow %+v", notes[1])
}
if notes[2].Kind != AnnotateText || notes[2].Text != "Note" {
t.Fatalf("text %+v", notes[2])
}
}
func TestImageAnnotatorSelectMoveDelete(t *testing.T) {
notes := []Annotation{{Kind: AnnotateRect, From: image.Pt(40, 40), To: image.Pt(140, 100)}, {Kind: AnnotateArrow, From: image.Pt(200, 150), To: image.Pt(300, 60)}}
n := 0
tt := annotatorTester(&notes, false, &n)
x, y := annotatorAt(tt, 90, 70)
tt.ClickAt(x, y)
if !tt.Focused("Annotations") {
t.Fatal("stage not focused")
}
tt.Key(0, ui.KeyRight)
if notes[0].From != image.Pt(44, 40) {
t.Fatalf("Right moved %+v", notes[0])
}
tt.Key(0, ui.KeyBracketRight)
tt.Key(0, ui.KeyDelete)
if len(notes) != 1 || notes[0].Kind != AnnotateRect {
t.Fatalf("delete left %+v", notes)
}
data, err := ExportAnnotations(notes)
if err != nil || string(data) != `[{"kind":"rect","x0":44,"y0":40,"x1":144,"y1":100}]` {
t.Fatalf("export %s %v", data, err)
}
}
func TestImageAnnotatorDisabledAndBad(t *testing.T) {
var notes []Annotation
n := 0
tt := annotatorTester(&notes, true, &n)
_ = tt.Click("Rectangle")
annotatorDrag(tt, 40, 40, 140, 100)
if len(notes) != 0 || n != 0 {
t.Fatalf("disabled annotator drew %+v", notes)
}
bad := []Annotation{{Kind: AnnotateText}}
mediaPanics(t, "empty text", func(c *ui.Context) {
ImageAnnotator(c, mediaTestBitmap(10, 10), &bad, ImageAnnotatorOptions{})
})
}
func TestImageAnnotatorSnapshot(t *testing.T) {
src := mediaTestBitmap(400, 200)
notes := []Annotation{
{Kind: AnnotateRect, From: image.Pt(30, 40), To: image.Pt(150, 120)},
{Kind: AnnotateArrow, From: image.Pt(330, 160), To: image.Pt(220, 80)},
{Kind: AnnotateText, From: image.Pt(230, 30), To: image.Pt(230, 30), Text: "Chandelier"},
}
snapshot(t, "image_annotator", 460, 320, func(c *ui.Context) {
ui.Column(c).Padding(20).Width(420).Children(func() {
ImageAnnotator(c, src, &notes, ImageAnnotatorOptions{Height: 220})
})
})
}
+74
View File
@@ -0,0 +1,74 @@
package mujica
import (
"log/slog"
"math"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
// ImageCompareOptions configure an ImageCompare.
type ImageCompareOptions struct {
// BeforeLabel and AfterLabel caption the sides; empty uses defaults.
BeforeLabel, AfterLabel string
Disabled bool
}
// ImageCompare shows before left of *split, after right.
func ImageCompare(c *ui.Context, before, after ui.ImageSource, split *float64, opts ImageCompareOptions) ChoiceResult {
if *split < 0 || *split > 1 || math.IsNaN(*split) {
panic("mujica: ImageCompare split outside 0..1")
}
bw, bh := viewerSize(before)
viewerSize(after)
k := Tokens(c)
bl, al := opts.BeforeLabel, opts.AfterLabel
if bl == "" {
bl = msg(c, "image_compare.before")
}
if al == "" {
al = msg(c, "image_compare.after")
}
g, _ := sliderSetup(0, 1, 0.01, nil, false, c)
old := *split
var s *ui.Element
var next float64
ui.Box(c).Disabled(opts.Disabled).Children(func() {
s, next, _ = sliderControl(c, old, g, 0, 1)
})
s.FillWidth().AspectRatio(bw / bh).FocusRing(false).Cursor(ui.CursorResizeEW).Clip().Radius(theme.ControlRadius).
Label(bl + " / " + al)
internal.Ring(s, k.Focus, theme.ControlRadius)
if !s.IsDisabled() {
*split = next
}
changed := *split != old
if changed {
slog.Debug("mujica: image_compare split", "split", *split)
}
v, size := float32(*split), FontSize(c, theme.CaptionSize)
s.Draw(func(p *ui.Painter, r ui.Rect) {
x := internal.Snap(p, r.X+r.W*v)
p.Image(after, r, ui.Cover)
p.Clip(ui.Rect{X: r.X, Y: r.Y, W: x - r.X, H: r.H}, 0, func() { p.Image(before, r, ui.Cover) })
compareTag(p, k, r.X+8, r.Y+8, bl, size)
w, _ := p.MeasureText(1000, ui.Span{Text: al, Size: size})
compareTag(p, k, r.X+r.W-w-20, r.Y+8, al, size)
p.Fill(ui.Rect{X: x - 1, Y: r.Y, W: 2, H: r.H}, k.Surface, 0)
cy := internal.Snap(p, r.Y+r.H/2)
p.FillPath(internal.Diamond(x, cy, 22), k.Surface)
p.FillPath(internal.Diamond(x, cy, 16), k.AccentText)
})
return ChoiceResult{Element: s, changed: changed}
}
// compareTag draws a caption chip at (x, y).
func compareTag(p *ui.Painter, k theme.Tokens, x, y float32, s string, size float32) {
w, h := p.MeasureText(1000, ui.Span{Text: s, Size: size})
b := ui.Rect{X: x, Y: y, W: w + 12, H: h + 4}
p.Fill(b, k.Surface.Alpha(0.88), theme.ControlRadius)
p.Text(b.X+6, b.Y+2, s, size, k.Text)
}
+73
View File
@@ -0,0 +1,73 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
func compareTester(split *float64, disabled bool, n *int) *ui.Tester {
before, after := mediaTestBitmap(160, 90), mediaTestBitmap(160, 90)
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Padding(20).Width(320).Children(func() {
if ImageCompare(c, before, after, split, ImageCompareOptions{Disabled: disabled}).Changed() {
*n++
}
})
}, 400, 300)
}
func TestImageCompareKeysAndDrag(t *testing.T) {
split, n := 0.5, 0
tt := compareTester(&split, false, &n)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyRight)
if split != 0.51 {
t.Fatalf("Right: %v", split)
}
tt.Key(0, ui.KeyHome)
if split != 0 {
t.Fatalf("Home: %v", split)
}
r, ok := tt.Find("Before / After")
if !ok {
t.Fatal("not named")
}
tt.Press(r.X+r.W/2, r.Y+r.H/2)
tt.Move(r.X+r.W*0.75, r.Y+r.H/2)
tt.Release(r.X+r.W*0.75, r.Y+r.H/2)
if split < 0.7 || split > 0.8 {
t.Fatalf("drag: %v", split)
}
if n < 3 {
t.Fatalf("changes %d", n)
}
}
func TestImageCompareDisabled(t *testing.T) {
split, n := 0.5, 0
tt := compareTester(&split, true, &n)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyRight)
r, _ := tt.Find("Before / After")
tt.ClickAt(r.X+10, r.Y+10)
if split != 0.5 || n != 0 {
t.Fatalf("disabled moved to %v", split)
}
bad := 2.0
mediaPanics(t, "split over 1", func(c *ui.Context) {
ImageCompare(c, mediaTestBitmap(4, 4), mediaTestBitmap(4, 4), &bad, ImageCompareOptions{})
})
}
func TestImageCompareSnapshot(t *testing.T) {
split := 0.4
before := mediaTestBitmap(160, 90)
after := ui.NewBitmap(mediaTestImage(90, 160))
snapshot(t, "image_compare", 360, 220, func(c *ui.Context) {
ui.Column(c).Padding(20).Width(320).Children(func() {
ImageCompare(c, before, after, &split, ImageCompareOptions{})
})
})
}
+245
View File
@@ -0,0 +1,245 @@
package mujica
import (
"fmt"
"image"
"log/slog"
"math"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
// ImageCrop is Rect in pixels of the turned image.
type ImageCrop struct {
Rect image.Rectangle
// Rotation is 0, 90, 180 or 270.
Rotation int
}
// ImageCropperOptions configure an ImageCropper.
type ImageCropperOptions struct {
// Aspect locks width over height; zero is free.
Aspect float64
// Height is the stage's height; zero means 320.
Height float32
Disabled bool
Label string
}
type cropperCache struct {
src image.Image
rot int
bmp *ui.Bitmap
}
type cropperDrag struct {
mode int // 0 none, 1 move, 2-5 corners
start image.Rectangle
dx, dy float32
}
type cropperKey struct{}
// ImageCropper edits *crop over src; empty Rect starts fitted.
func ImageCropper(c *ui.Context, src image.Image, crop *ImageCrop, opts ImageCropperOptions) ChoiceResult {
if src == nil || src.Bounds().Empty() || opts.Aspect < 0 || opts.Height < 0 {
panic("mujica: ImageCropper needs an image, Aspect >= 0 and Height >= 0")
}
if crop.Rotation%90 != 0 || crop.Rotation < 0 || crop.Rotation >= 360 {
panic(fmt.Sprintf("mujica: ImageCropper Rotation %d is not 0, 90, 180 or 270", crop.Rotation))
}
size := cropperSize(src, crop.Rotation)
if crop.Rect.Empty() {
crop.Rect = cropperFit(size, opts.Aspect)
}
if !crop.Rect.In(image.Rectangle{Max: size}) {
panic(fmt.Sprintf("mujica: ImageCropper Rect %v outside the %v image", crop.Rect, size))
}
if opts.Aspect > 0 && math.Abs(float64(crop.Rect.Dx())-float64(crop.Rect.Dy())*opts.Aspect) > 1+opts.Aspect {
panic(fmt.Sprintf("mujica: ImageCropper Rect %v does not keep Aspect %v", crop.Rect, opts.Aspect))
}
h := opts.Height
if h == 0 {
h = 320
}
k := Tokens(c)
u := Density(c).Unit()
old := *crop
label := opts.Label
if label == "" {
label = msg(c, "image_cropper.label")
}
col := ui.Column(c).Gap(u * 2).Disabled(opts.Disabled)
col.Children(func() {
stage := ui.Box(c).FillWidth().Height(h).Focusable().Clip().Radius(theme.ControlRadius).
Background(videoBlack).Label(label).Cursor(ui.CursorMove)
internal.Ring(stage, k.Focus, theme.ControlRadius)
r := stage.Bounds()
scale, ox, oy := cropperPlace(r, size)
live := !stage.IsDisabled()
if live && scale > 0 {
cropperPointer(stage, crop, r, size, scale, ox, oy, opts.Aspect)
cropperKeys(stage, crop, size, opts.Aspect)
}
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
left := iconAction(c, mediaRotateLeft, msg(c, "image_cropper.rotateLeft"))
right := iconAction(c, mediaRotate, msg(c, "image_cropper.rotateRight"))
switch {
case live && (right.Clicked() || stage.Shortcut(0, ui.KeyR)):
cropperRotate(crop, size, 90, opts.Aspect)
case live && (left.Clicked() || stage.Shortcut(ui.Shift, ui.KeyR)):
cropperRotate(crop, size, 270, opts.Aspect)
}
ui.Text(c, cropperReadout(*crop)).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).FontFeatures("tnum")
})
stage.Description(cropperReadout(*crop))
// Turned pixels are copied only when image or rotation change.
cache := ui.Local(stage, cropperKey{}, func() cropperCache { return cropperCache{} })
if cache.src != src || cache.rot != crop.Rotation || cache.bmp == nil {
*cache = cropperCache{src, crop.Rotation, ui.NewBitmap(cropperTurn(src, crop.Rotation))}
}
size := cropperSize(src, crop.Rotation)
bmp, rect := cache.bmp, crop.Rect
stage.Draw(func(p *ui.Painter, r ui.Rect) {
scale, ox, oy := cropperPlace(r, size)
img := ui.Rect{X: ox, Y: oy, W: float32(size.X) * scale, H: float32(size.Y) * scale}
p.Image(bmp, img, ui.FillBox)
f := ui.Rect{X: internal.Snap(p, ox+float32(rect.Min.X)*scale), Y: internal.Snap(p, oy+float32(rect.Min.Y)*scale),
W: internal.Snap(p, float32(rect.Dx())*scale), H: internal.Snap(p, float32(rect.Dy())*scale)}
shade := ui.RGBA(0, 0, 0, 0.55)
p.Fill(ui.Rect{X: img.X, Y: img.Y, W: img.W, H: f.Y - img.Y}, shade, 0)
p.Fill(ui.Rect{X: img.X, Y: f.Y + f.H, W: img.W, H: img.Y + img.H - f.Y - f.H}, shade, 0)
p.Fill(ui.Rect{X: img.X, Y: f.Y, W: f.X - img.X, H: f.H}, shade, 0)
p.Fill(ui.Rect{X: f.X + f.W, Y: f.Y, W: img.X + img.W - f.X - f.W, H: f.H}, shade, 0)
light := ui.Hex("#F2EBDD")
p.Stroke(f, light, 0, 1)
for i := 1; i < 3; i++ {
p.Fill(ui.Rect{X: f.X + f.W*float32(i)/3, Y: f.Y, W: 1, H: f.H}, light.Alpha(0.4), 0)
p.Fill(ui.Rect{X: f.X, Y: f.Y + f.H*float32(i)/3, W: f.W, H: 1}, light.Alpha(0.4), 0)
}
for _, pt := range [4][2]float32{{f.X, f.Y}, {f.X + f.W, f.Y}, {f.X, f.Y + f.H}, {f.X + f.W, f.Y + f.H}} {
p.FillPath(internal.Diamond(internal.Snap(p, pt[0]), internal.Snap(p, pt[1]), 12), light)
}
})
})
changed := *crop != old
if changed {
slog.Debug("mujica: image_cropper changed", "rect", crop.Rect, "rotation", crop.Rotation)
}
return ChoiceResult{Element: col, changed: changed}
}
func cropperReadout(cr ImageCrop) string {
r := cr.Rect
return fmt.Sprintf("%d, %d · %d × %d · %d°", r.Min.X, r.Min.Y, r.Dx(), r.Dy(), cr.Rotation)
}
// cropperSize is src's size after turning it rot degrees.
func cropperSize(src image.Image, rot int) image.Point {
s := src.Bounds().Size()
if rot == 90 || rot == 270 {
return image.Pt(s.Y, s.X)
}
return s
}
// cropperFit is the largest centered aspect rectangle inside size.
func cropperFit(size image.Point, aspect float64) image.Rectangle {
w, h := size.X, size.Y
if aspect > 0 {
if float64(w) > float64(h)*aspect {
w = int(math.Round(float64(h) * aspect))
} else {
h = int(math.Round(float64(w) / aspect))
}
}
x, y := (size.X-w)/2, (size.Y-h)/2
return image.Rect(x, y, x+w, y+h)
}
// cropperPlace fits size in r: scale and top-left corner.
func cropperPlace(r ui.Rect, size image.Point) (scale, x, y float32) {
if r.W <= 0 {
return 0, 0, 0
}
scale = min(r.W/float32(size.X), r.H/float32(size.Y))
return scale, r.X + (r.W-float32(size.X)*scale)/2, r.Y + (r.H-float32(size.Y)*scale)/2
}
// cropperTurn copies src turned rot degrees clockwise.
func cropperTurn(src image.Image, rot int) image.Image {
if rot == 0 {
return src
}
b := src.Bounds()
size := cropperSize(src, rot)
out := image.NewRGBA(image.Rectangle{Max: size})
for y := 0; y < b.Dy(); y++ {
for x := 0; x < b.Dx(); x++ {
var nx, ny int
switch rot {
case 90:
nx, ny = b.Dy()-1-y, x
case 180:
nx, ny = b.Dx()-1-x, b.Dy()-1-y
case 270:
nx, ny = y, b.Dx()-1-x
}
out.Set(nx, ny, src.At(b.Min.X+x, b.Min.Y+y))
}
}
return out
}
// cropperRotate turns the crop d degrees; locked aspects refit.
func cropperRotate(cr *ImageCrop, size image.Point, d int, aspect float64) {
r := cr.Rect
for range d / 90 {
r = image.Rect(size.Y-r.Max.Y, r.Min.X, size.Y-r.Min.Y, r.Max.X)
size = image.Pt(size.Y, size.X)
}
cr.Rotation = (cr.Rotation + d) % 360
cr.Rect = r
if aspect > 0 {
cr.Rect = cropperFit(size, aspect)
}
}
// cropperPointer moves the frame, or resizes it by a corner.
func cropperPointer(stage *ui.Element, cr *ImageCrop, r ui.Rect, size image.Point, scale, ox, oy float32, aspect float64) {
d := ui.Local(stage, cropperDrag{}, func() cropperDrag { return cropperDrag{} })
dx, dy, ok := stage.Dragged()
if !stage.Pressed() {
d.mode = 0
return
}
if d.mode == 0 {
px, py, _ := stage.PointerPosition()
px, py = (px+r.X-ox)/scale, (py+r.Y-oy)/scale
d.start, d.dx, d.dy, d.mode = cr.Rect, 0, 0, cropperHit(cr.Rect, px, py, 14/scale)
}
if !ok || d.mode == 0 {
return
}
d.dx, d.dy = d.dx+dx/scale, d.dy+dy/scale
cr.Rect = cropperDragRect(d.start, d.mode, int(math.Round(float64(d.dx))), int(math.Round(float64(d.dy))), size, aspect)
}
// cropperHit says what a press grabs: corner, inside or nothing.
func cropperHit(r image.Rectangle, x, y, slop float32) int {
corners := [4][2]int{{r.Min.X, r.Min.Y}, {r.Max.X, r.Min.Y}, {r.Min.X, r.Max.Y}, {r.Max.X, r.Max.Y}}
for i, c := range corners {
if abs32(x-float32(c[0])) <= slop && abs32(y-float32(c[1])) <= slop {
return 2 + i
}
}
if x >= float32(r.Min.X) && x <= float32(r.Max.X) && y >= float32(r.Min.Y) && y <= float32(r.Max.Y) {
return 1
}
return 0
}
func abs32(v float32) float32 { return float32(math.Abs(float64(v))) }
+81
View File
@@ -0,0 +1,81 @@
package mujica
import (
"image"
"math"
"github.com/egoist/mygo/ui"
)
// cropperMin is the smallest crop side in pixels.
const cropperMin = 8
// cropperDragRect moves (mode 1) or corner-resizes (2-5) start.
func cropperDragRect(start image.Rectangle, mode, dx, dy int, size image.Point, aspect float64) image.Rectangle {
if mode == 1 {
dx = max(-start.Min.X, min(size.X-start.Max.X, dx))
dy = max(-start.Min.Y, min(size.Y-start.Max.Y, dy))
return start.Add(image.Pt(dx, dy))
}
corners := [4]image.Point{start.Min, {start.Max.X, start.Min.Y}, {start.Min.X, start.Max.Y}, start.Max}
moving, anchor := corners[mode-2], corners[5-mode]
x := max(0, min(size.X, moving.X+dx))
y := max(0, min(size.Y, moving.Y+dy))
sx, sy := 1, 1
room := image.Pt(size.X-anchor.X, size.Y-anchor.Y)
if x < anchor.X {
sx, room.X = -1, anchor.X
}
if y < anchor.Y {
sy, room.Y = -1, anchor.Y
}
w, h := cropperClampSize(max(cropperMin, (x-anchor.X)*sx), max(cropperMin, (y-anchor.Y)*sy), room, aspect)
return image.Rectangle{Min: anchor, Max: anchor.Add(image.Pt(w*sx, h*sy))}.Canon()
}
// cropperClampSize fits w×h inside room, keeping aspect when locked.
func cropperClampSize(w, h int, room image.Point, aspect float64) (int, int) {
w, h = min(w, room.X), min(h, room.Y)
if aspect <= 0 {
return w, h
}
h = int(math.Round(float64(w) / aspect))
if h > room.Y {
h = room.Y
w = int(math.Round(float64(h) * aspect))
}
return w, h
}
// cropperKeys: arrows move, Shift+arrows resize, 1% per press.
func cropperKeys(stage *ui.Element, cr *ImageCrop, size image.Point, aspect float64) {
step := max(1, size.X/100)
r := cr.Rect
move := func(dx, dy int) { cr.Rect = cropperDragRect(r, 1, dx, dy, size, aspect) }
grow := func(dw, dh int) {
w, h := max(cropperMin, r.Dx()+dw), max(cropperMin, r.Dy()+dh)
if aspect > 0 && dw == 0 {
w = int(math.Round(float64(h) * aspect))
}
w, h = cropperClampSize(w, h, image.Pt(size.X-r.Min.X, size.Y-r.Min.Y), aspect)
cr.Rect = image.Rectangle{Min: r.Min, Max: r.Min.Add(image.Pt(w, h))}
}
switch {
case stage.Shortcut(0, ui.KeyLeft):
move(-step, 0)
case stage.Shortcut(0, ui.KeyRight):
move(step, 0)
case stage.Shortcut(0, ui.KeyUp):
move(0, -step)
case stage.Shortcut(0, ui.KeyDown):
move(0, step)
case stage.Shortcut(ui.Shift, ui.KeyRight):
grow(step, 0)
case stage.Shortcut(ui.Shift, ui.KeyLeft):
grow(-step, 0)
case stage.Shortcut(ui.Shift, ui.KeyDown):
grow(0, step)
case stage.Shortcut(ui.Shift, ui.KeyUp):
grow(0, -step)
}
}
+101
View File
@@ -0,0 +1,101 @@
package mujica
import (
"image"
"testing"
"github.com/egoist/mygo/ui"
)
func cropperTester(src image.Image, crop *ImageCrop, opts ImageCropperOptions, n *int) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Padding(20).Width(440).Children(func() {
if ImageCropper(c, src, crop, opts).Changed() {
*n++
}
})
}, 500, 420)
}
func TestImageCropperDefaultAndKeys(t *testing.T) {
crop, n := ImageCrop{}, 0
tt := cropperTester(mediaTestImage(400, 200), &crop, ImageCropperOptions{Aspect: 1, Height: 200}, &n)
if crop.Rect != image.Rect(100, 0, 300, 200) {
t.Fatalf("default %v", crop.Rect)
}
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyRight)
if crop.Rect != image.Rect(104, 0, 304, 200) {
t.Fatalf("Right: %v", crop.Rect)
}
tt.Key(ui.Shift, ui.KeyLeft)
if crop.Rect.Dx() != crop.Rect.Dy() || crop.Rect.Dx() != 196 {
t.Fatalf("Shift+Left keeps 1:1: %v", crop.Rect)
}
if n != 2 || !tt.HasText("104, 0 · 196 × 196 · 0°") {
t.Fatalf("changes %d, texts %v", n, tt.Texts())
}
}
func TestImageCropperRotate(t *testing.T) {
crop, n := ImageCrop{Rect: image.Rect(10, 20, 110, 70)}, 0
tt := cropperTester(mediaTestImage(400, 200), &crop, ImageCropperOptions{Height: 200}, &n)
if err := tt.Click("Rotate right"); err != nil {
t.Fatal(err)
}
if crop.Rotation != 90 || crop.Rect != image.Rect(130, 10, 180, 110) {
t.Fatalf("rotated %+v", crop)
}
tt.Key(0, ui.KeyTab)
tt.Key(ui.Shift, ui.KeyR)
if crop.Rotation != 0 || crop.Rect != image.Rect(10, 20, 110, 70) {
t.Fatalf("rotated back %+v", crop)
}
}
func TestImageCropperDragCorner(t *testing.T) {
crop, n := ImageCrop{Rect: image.Rect(0, 0, 200, 100)}, 0
tt := cropperTester(mediaTestImage(400, 200), &crop, ImageCropperOptions{Aspect: 2, Height: 200}, &n)
r, _ := tt.Find("Crop area")
scale := min(r.W/400, r.H/200)
ox, oy := r.X+(r.W-400*scale)/2, r.Y+(r.H-200*scale)/2
cx, cy := ox+200*scale, oy+100*scale
tt.Press(cx, cy)
tt.Move(cx+50*scale, cy+5*scale)
tt.Move(cx+100*scale, cy+10*scale)
tt.Release(cx+100*scale, cy+10*scale)
if crop.Rect.Min != (image.Point{}) || crop.Rect.Dx() < 290 || crop.Rect.Dx() > 310 || crop.Rect.Dx() != 2*crop.Rect.Dy() {
t.Fatalf("corner drag %v", crop.Rect)
}
}
func TestImageCropperDisabledAndBad(t *testing.T) {
crop, n := ImageCrop{}, 0
tt := cropperTester(mediaTestImage(100, 100), &crop, ImageCropperOptions{Disabled: true}, &n)
start := crop
_ = tt.Click("Rotate right")
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyRight)
if crop != start || n != 0 {
t.Fatalf("disabled cropper changed %+v", crop)
}
img := mediaTestImage(100, 100)
mediaPanics(t, "outside", func(c *ui.Context) {
ImageCropper(c, img, &ImageCrop{Rect: image.Rect(0, 0, 200, 10)}, ImageCropperOptions{})
})
mediaPanics(t, "rotation", func(c *ui.Context) { ImageCropper(c, img, &ImageCrop{Rotation: 45}, ImageCropperOptions{}) })
mediaPanics(t, "aspect", func(c *ui.Context) {
ImageCropper(c, img, &ImageCrop{Rect: image.Rect(0, 0, 50, 10)}, ImageCropperOptions{Aspect: 1})
})
}
func TestImageCropperSnapshot(t *testing.T) {
img := mediaTestImage(320, 180)
crop := ImageCrop{Rect: image.Rect(60, 30, 220, 150)}
snapshot(t, "image_cropper", 440, 300, func(c *ui.Context) {
ui.Column(c).Padding(20).Width(400).Children(func() {
ImageCropper(c, img, &crop, ImageCropperOptions{Height: 220})
})
})
}
+87
View File
@@ -0,0 +1,87 @@
package mujica
import (
"log/slog"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
)
// ImageThumbnailOptions configure an ImageThumbnail.
type ImageThumbnailOptions struct {
// Width is the thumbnail's width; zero means 96.
Width float32
// Aspect is width over height, cropped; zero is 1.
Aspect float32
Selected bool
// Loading and Err show the load state at one size.
Loading bool
Err error
// Alt names the picture; required.
Alt string
Disabled bool
}
// ImageThumbnail is a cropped picture; Changed reports activation.
func ImageThumbnail(c *ui.Context, src ui.ImageSource, opts ImageThumbnailOptions) ChoiceResult {
if opts.Width < 0 || opts.Aspect < 0 {
panic("mujica: ImageThumbnail needs Width and Aspect >= 0")
}
if opts.Alt == "" {
panic("mujica: ImageThumbnail needs Alt")
}
w, aspect := opts.Width, opts.Aspect
if w == 0 {
w = 96
}
if aspect == 0 {
aspect = 1
}
k := Tokens(c)
h := w / aspect
b := ui.ButtonBase(c).Size(w, h).Radius(theme.ControlRadius).Clip().
Disabled(opts.Disabled).Label(opts.Alt).Selected(opts.Selected).Background(k.SurfaceHover)
internal.Ring(b, k.Focus, theme.ControlRadius)
edge, width := k.Border, theme.BorderWidth
switch {
case opts.Selected:
edge, width = k.AccentText, 2
case b.Hovered():
edge = k.ControlBorder
}
b.Border(width, edge).Transition(colorFade)
b.Children(func() {
switch {
case opts.Err != nil:
b.Description(opts.Err.Error()).Tooltip(opts.Err.Error())
ui.Column(c).Gap(4).AlignItems(ui.Center).Children(func() {
ui.Icon(c, icons.Must("circle-alert")).Size(20, 20).TextColor(k.Danger)
if h >= 64 {
ui.Text(c, msg(c, "image_thumbnail.failed")).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.Danger).SingleLine()
}
})
case opts.Loading:
Spinner(c, SpinnerOptions{Size: min(20, h/2), Label: msg(c, "image_thumbnail.loading")})
case src == nil:
ui.Icon(c, icons.Must("image")).Size(20, 20).TextColor(k.TextMuted)
default:
ui.Image(c, src).Fit(ui.Cover).Size(w, h)
}
if opts.Selected {
// A diamond mark, so selection never rests on color alone.
ui.Box(c).Absolute().Top(4).Right(4).Size(14, 14).PassThrough().Draw(func(p *ui.Painter, r ui.Rect) {
cx, cy := internal.Snap(p, r.X+r.W/2), internal.Snap(p, r.Y+r.H/2)
p.FillPath(internal.Diamond(cx, cy, 14), k.Surface)
p.FillPath(internal.Diamond(cx, cy, 10), k.AccentText)
})
}
})
clicked := b.Clicked() && !b.IsDisabled()
if clicked {
slog.Debug("mujica: image_thumbnail click", "alt", opts.Alt)
}
return ChoiceResult{Element: b, changed: clicked}
}
+82
View File
@@ -0,0 +1,82 @@
package mujica
import (
"errors"
"testing"
"github.com/egoist/mygo/ui"
)
func TestImageThumbnailClickAndKeys(t *testing.T) {
n := 0
bmp := mediaTestBitmap(120, 80)
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Row(c).Padding(20).AlignItems(ui.Start).Children(func() {
if ImageThumbnail(c, bmp, ImageThumbnailOptions{Alt: "Hall", Aspect: 4.0 / 3}).Changed() {
n++
}
})
}, 300, 200)
r, ok := tt.Find("Hall")
if !ok || r.W != 96 || r.H != 72 {
t.Fatalf("bounds %v", r)
}
tt.ClickAt(r.X+r.W/2, r.Y+r.H/2)
tt.Key(0, ui.KeyEnter)
tt.Key(0, ui.KeySpace)
if n != 3 {
t.Fatalf("activations %d", n)
}
}
func TestImageThumbnailStatesKeepSize(t *testing.T) {
var got []ui.Rect
for _, o := range []ImageThumbnailOptions{{}, {Loading: true}, {Err: errors.New("404")}, {Selected: true}} {
o.Alt, o.Width = "Hall", 120
var r ui.Rect
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Row(c).AlignItems(ui.Start).Children(func() {
r = ImageThumbnail(c, mediaTestBitmap(40, 40), o).Element.Bounds()
})
}, 300, 200)
tt.Frame()
got = append(got, r)
}
for _, r := range got {
if r != got[0] || r.W != 120 || r.H != 120 {
t.Fatalf("sizes %v", got)
}
}
}
func TestImageThumbnailDisabledAndBad(t *testing.T) {
n := 0
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
if ImageThumbnail(c, nil, ImageThumbnailOptions{Alt: "Hall", Disabled: true}).Changed() {
n++
}
}, 300, 200)
_ = tt.Click("Hall")
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
if n != 0 {
t.Fatal("disabled thumbnail activated")
}
mediaPanics(t, "no alt", func(c *ui.Context) { ImageThumbnail(c, nil, ImageThumbnailOptions{}) })
}
func TestImageThumbnailSnapshot(t *testing.T) {
bmp := mediaTestBitmap(160, 90)
snapshot(t, "image_thumbnail", 520, 120, func(c *ui.Context) {
ui.Row(c).Padding(20).Gap(12).AlignItems(ui.Start).Children(func() {
ImageThumbnail(c, bmp, ImageThumbnailOptions{Alt: "Hall", Width: 80})
ImageThumbnail(c, bmp, ImageThumbnailOptions{Alt: "Hall", Width: 80, Selected: true})
ImageThumbnail(c, bmp, ImageThumbnailOptions{Alt: "Hall", Width: 80, Loading: true})
ImageThumbnail(c, bmp, ImageThumbnailOptions{Alt: "Hall", Width: 80, Err: errors.New("404")})
ImageThumbnail(c, bmp, ImageThumbnailOptions{Alt: "Hall", Width: 80, Disabled: true})
})
})
}
+89
View File
@@ -0,0 +1,89 @@
package mujica
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
)
// Lightbox fills the window with images[*index] while *open.
func Lightbox(c *ui.Context, open *bool, index *int, images []ViewerImage) ChoiceResult {
var v ChoiceResult
if !*open {
return v
}
if len(images) == 0 || *index < 0 || *index >= len(images) {
panic("mujica: Lightbox index out of range")
}
for _, im := range images {
viewerSize(im.Source)
}
u := Density(c).Unit()
w, h := c.Size()
light := ui.Hex("#F2EBDD")
v.Element = ui.DialogBase(c, open, func(back, panel *ui.Element) {
back.Background(ui.RGBA(0, 0, 0, 0.92))
panel.Size(w, h).Padding(u * 4).Gap(u * 3).TextColor(light).Label(msg(c, "lightbox.title")).
Opacity(overlayEnter(c, panel))
goTo := func(i int) {
if i < 0 || i >= len(images) || i == *index {
return
}
*index, v.changed = i, true
c.Announce(fmt.Sprintf(msg(c, "lightbox.position"), i+1, len(images)))
slog.Debug("mujica: lightbox image", "index", i)
}
switch {
case panel.Shortcut(0, ui.KeyLeft):
goTo(*index - 1)
case panel.Shortcut(0, ui.KeyRight):
goTo(*index + 1)
case panel.Shortcut(0, ui.KeyHome):
goTo(0)
case panel.Shortcut(0, ui.KeyEnd):
goTo(len(images) - 1)
}
img := images[*index]
ui.Row(c).AlignItems(ui.Center).Children(func() {
ui.Text(c, fmt.Sprintf(msg(c, "lightbox.position"), *index+1, len(images))).FontFeatures("tnum").Grow(1)
if lightboxButton(c, "x", "lightbox.close", false).Clicked() {
*open = false
}
})
ui.Row(c).Grow(1).MinHeight(0).Gap(u * 3).AlignItems(ui.Center).Children(func() {
if b := lightboxButton(c, "chevron-left", "lightbox.previous", *index == 0); b.Clicked() && !b.IsDisabled() {
goTo(*index - 1)
}
ui.Box(c).Grow(1).FillHeight().MinWidth(0).Center().Children(func() {
ui.Image(c, img.Source).Fit(ui.Contain).Fill().Label(img.Alt)
})
if b := lightboxButton(c, "chevron-right", "lightbox.next", *index == len(images)-1); b.Clicked() && !b.IsDisabled() {
goTo(*index + 1)
}
})
if img.Alt != "" {
ui.Text(c, img.Alt).TextAlign(ui.Center).FillWidth().MaxLines(3)
}
})
if !*open {
slog.Debug("mujica: lightbox close")
}
return v
}
// lightboxButton is a 40 DIP icon button on the backdrop.
func lightboxButton(c *ui.Context, icon, key string, disabled bool) *ui.Element {
light := ui.Hex("#F2EBDD")
b := iconAction(c, icons.Must(icon), msg(c, key)).Size(40, 40).TextColor(light).Disabled(disabled)
// The theme's focus color is too dark on the backdrop.
internal.Ring(b, light, theme.ControlRadius)
if b.Hovered() {
b.Background(ui.RGBA(255, 255, 255, 0.12)).TextColor(ui.Hex("#FFFFFF"))
}
return b
}
+66
View File
@@ -0,0 +1,66 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
func lightboxImages() []ViewerImage {
return []ViewerImage{
{Source: mediaTestBitmap(160, 90), Alt: "The grand hall"},
{Source: mediaTestBitmap(90, 160), Alt: "The east tower"},
{Source: mediaTestBitmap(120, 120), Alt: "The garden"},
}
}
func TestLightboxBrowseAndClose(t *testing.T) {
open, index, n := false, 0, 0
images := lightboxImages()
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
if Button(c, "Open gallery", ButtonOptions{}).Clicked() {
open = true
}
if Lightbox(c, &open, &index, images).Changed() {
n++
}
}, 640, 480)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
if !open || !tt.HasText("The grand hall") || !tt.HasText("1 of 3") {
t.Fatalf("not open: %v", tt.Texts())
}
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeyRight)
if index != 2 || n != 2 || !tt.HasText("The garden") {
t.Fatalf("index %d changes %d", index, n)
}
if err := tt.Click("Previous image"); err != nil {
t.Fatal(err)
}
if index != 1 {
t.Fatalf("previous: %d", index)
}
tt.Key(0, ui.KeyEscape)
if open {
t.Fatal("Escape did not close")
}
if !tt.Focused("Open gallery") {
t.Fatal("focus not restored to the opener")
}
}
func TestLightboxBadIndex(t *testing.T) {
open, index := true, 5
mediaPanics(t, "index", func(c *ui.Context) { Lightbox(c, &open, &index, lightboxImages()) })
}
func TestLightboxSnapshot(t *testing.T) {
open, index := true, 0
images := lightboxImages()
snapshot(t, "lightbox", 520, 360, func(c *ui.Context) {
Lightbox(c, &open, &index, images)
})
}
+77
View File
@@ -0,0 +1,77 @@
package mujica
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
)
// LoopMode is how playback repeats. The zero value repeats nothing.
type LoopMode int
const (
LoopOff LoopMode = iota
LoopAll
LoopOne
)
// next cycles off, all, one.
func (m LoopMode) next() LoopMode { return (m + 1) % 3 }
// MediaControlsState is what the caller's playback reports.
type MediaControlsState struct {
Playing bool
Loop LoopMode
Shuffle bool
// NoPrevious and NoNext disable those buttons at the ends.
NoPrevious, NoNext bool
}
// MediaControlsOptions are the callbacks; every one is required.
type MediaControlsOptions struct {
OnPrevious, OnPlay, OnPause, OnNext func()
OnLoop func(LoopMode)
OnShuffle func(bool)
Disabled bool
}
// MediaControls are previous, play, next, loop and shuffle buttons.
func MediaControls(c *ui.Context, st MediaControlsState, opts MediaControlsOptions) *ui.Element {
if opts.OnPrevious == nil || opts.OnPlay == nil || opts.OnPause == nil || opts.OnNext == nil || opts.OnLoop == nil || opts.OnShuffle == nil {
panic("mujica: MediaControls needs every callback")
}
if st.Loop < LoopOff || st.Loop > LoopOne {
panic(fmt.Sprintf("mujica: unknown LoopMode %d", st.Loop))
}
row := ui.Row(c).Gap(Density(c).Unit()).AlignItems(ui.Center).FocusGroup(ui.Horizontal).Role(ui.RoleToolbar).
Label(msg(c, "media_controls.label")).Disabled(opts.Disabled)
row.Children(func() {
shuffle := mediaToggleAction(c, mediaShuffle, msg(c, "media_controls.shuffle"), st.Shuffle)
if shuffle.Clicked() && !shuffle.IsDisabled() {
slog.Debug("mujica: media_controls shuffle", "on", !st.Shuffle)
opts.OnShuffle(!st.Shuffle)
}
prev := iconAction(c, mediaPrev, msg(c, "media_controls.previous")).Disabled(st.NoPrevious)
if prev.Clicked() && !prev.IsDisabled() {
slog.Debug("mujica: media_controls previous")
opts.OnPrevious()
}
mediaPlayToggle(c, st.Playing, opts.OnPlay, opts.OnPause)
next := iconAction(c, mediaNext, msg(c, "media_controls.next")).Disabled(st.NoNext)
if next.Clicked() && !next.IsDisabled() {
slog.Debug("mujica: media_controls next")
opts.OnNext()
}
icon := mediaRepeat
if st.Loop == LoopOne {
icon = mediaRepeatOne
}
loop := mediaToggleAction(c, icon, msg(c, []string{"media_controls.loopOff", "media_controls.loopAll", "media_controls.loopOne"}[st.Loop]), st.Loop != LoopOff)
if loop.Clicked() && !loop.IsDisabled() {
slog.Debug("mujica: media_controls loop", "mode", st.Loop.next())
opts.OnLoop(st.Loop.next())
}
})
return row
}
+91
View File
@@ -0,0 +1,91 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
type controlsCalls struct {
prev, play, pause, next int
loops []LoopMode
shuffles []bool
}
func (m *controlsCalls) opts() MediaControlsOptions {
return MediaControlsOptions{OnPrevious: func() { m.prev++ }, OnPlay: func() { m.play++ }, OnPause: func() { m.pause++ },
OnNext: func() { m.next++ }, OnLoop: func(l LoopMode) { m.loops = append(m.loops, l) },
OnShuffle: func(on bool) { m.shuffles = append(m.shuffles, on) }}
}
func controlsTester(st *MediaControlsState, o MediaControlsOptions) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Padding(20).AlignItems(ui.Start).Children(func() { MediaControls(c, *st, o) })
}, 400, 120)
}
func TestMediaControlsClicks(t *testing.T) {
st := MediaControlsState{Loop: LoopAll}
var m controlsCalls
tt := controlsTester(&st, m.opts())
for _, name := range []string{"Previous", "Play", "Next", "Repeat all", "Shuffle"} {
if err := tt.Click(name); err != nil {
t.Fatal(err)
}
}
if m.prev != 1 || m.play != 1 || m.next != 1 || len(m.loops) != 1 || m.loops[0] != LoopOne || len(m.shuffles) != 1 || !m.shuffles[0] {
t.Fatalf("calls %+v", m)
}
}
func TestMediaControlsArrowsMoveFocus(t *testing.T) {
st := MediaControlsState{}
var m controlsCalls
tt := controlsTester(&st, m.opts())
tt.Key(0, ui.KeyTab)
if !tt.Focused("Shuffle") {
t.Fatal("Tab did not enter at Shuffle")
}
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeyRight)
if !tt.Focused("Play") {
t.Fatal("arrows did not reach Play")
}
tt.Key(0, ui.KeyEnter)
if m.play != 1 {
t.Fatalf("Enter: play %d", m.play)
}
}
func TestMediaControlsEndsAndDisabled(t *testing.T) {
st := MediaControlsState{NoPrevious: true, NoNext: true}
var m controlsCalls
tt := controlsTester(&st, m.opts())
_ = tt.Click("Previous")
_ = tt.Click("Next")
o := m.opts()
o.Disabled = true
off := controlsTester(&MediaControlsState{}, o)
_ = off.Click("Play")
_ = off.Click("Shuffle")
if m.prev != 0 || m.next != 0 || m.play != 0 || len(m.shuffles) != 0 {
t.Fatalf("blocked buttons reported %+v", m)
}
}
func TestMediaControlsBadOptions(t *testing.T) {
mediaPanics(t, "no callbacks", func(c *ui.Context) { MediaControls(c, MediaControlsState{}, MediaControlsOptions{}) })
var m controlsCalls
mediaPanics(t, "bad loop", func(c *ui.Context) { MediaControls(c, MediaControlsState{Loop: 7}, m.opts()) })
}
func TestMediaControlsSnapshot(t *testing.T) {
var m controlsCalls
snapshot(t, "media_controls", 300, 120, func(c *ui.Context) {
ui.Column(c).Padding(20).Gap(12).AlignItems(ui.Start).Children(func() {
MediaControls(c, MediaControlsState{Playing: true, Loop: LoopOne, Shuffle: true}, m.opts())
MediaControls(c, MediaControlsState{NoPrevious: true}, m.opts())
})
})
}
+44
View File
@@ -0,0 +1,44 @@
package mujica
import (
"image"
"image/color"
"testing"
"github.com/egoist/mygo/ui"
)
// mediaTestImage is a w×h picture whose left half is wine and right half ivory.
func mediaTestImage(w, h int) *image.RGBA {
img := image.NewRGBA(image.Rect(0, 0, w, h))
for y := range h {
for x := range w {
c := color.RGBA{125, 32, 52, 255}
if x >= w/2 {
c = color.RGBA{242, 235, 221, 255}
}
if y < h/8 {
c = color.RGBA{173, 145, 100, 255}
}
img.Set(x, y, c)
}
}
return img
}
func mediaTestBitmap(w, h int) *ui.Bitmap { return ui.NewBitmap(mediaTestImage(w, h)) }
// mediaPanics fails t unless fn panics.
func mediaPanics(t *testing.T, name string, fn func(c *ui.Context)) {
t.Helper()
defer func() {
if recover() == nil {
t.Errorf("%s: no panic", name)
}
}()
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
fn(c)
}, 400, 300)
tt.Frame()
}
+53
View File
@@ -0,0 +1,53 @@
package mujica
import (
"fmt"
"time"
"github.com/egoist/mygo/ui"
)
// mediaSVG wraps stroked 24×24 path data as an icon.
func mediaSVG(body string) *ui.SVG {
return ui.MustParseSVG([]byte(`<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">` + body + `</svg>`))
}
// Media glyphs drawn for this group.
var (
mediaPlay = mediaSVG(`<path d="M7 4.5v15l12-7.5z"/>`)
mediaPause = mediaSVG(`<path d="M8 5v14M16 5v14"/>`)
mediaPrev = mediaSVG(`<path d="M18 5v14L8 12zM6 5v14"/>`)
mediaNext = mediaSVG(`<path d="M6 5v14l10-7zM18 5v14"/>`)
mediaRepeat = mediaSVG(`<path d="M17 3l3 3-3 3M4 11V9a3 3 0 0 1 3-3h13M7 21l-3-3 3-3M20 13v2a3 3 0 0 1-3 3H4"/>`)
mediaRepeatOne = mediaSVG(`<path d="M17 3l3 3-3 3M4 11V9a3 3 0 0 1 3-3h13M7 21l-3-3 3-3M20 13v2a3 3 0 0 1-3 3H4M11 10h1v4"/>`)
mediaShuffle = mediaSVG(`<path d="M16 3h5v5M4 20 21 3M21 16v5h-5M15 15l6 6M4 4l5 5"/>`)
mediaVolume = mediaSVG(`<path d="M11 5 6 9H3v6h3l5 4zM15.5 8.5a5 5 0 0 1 0 7M18.5 5.5a9 9 0 0 1 0 13"/>`)
mediaMute = mediaSVG(`<path d="M11 5 6 9H3v6h3l5 4zM16 9l6 6M22 9l-6 6"/>`)
mediaRotate = mediaSVG(`<path d="M21 12a9 9 0 1 1-3-6.7L21 8M21 3v5h-5"/>`)
mediaRotateLeft = mediaSVG(`<path d="M3 12a9 9 0 1 0 3-6.7L3 8M3 3v5h5"/>`)
mediaMirror = mediaSVG(`<path d="M12 3v18M8 7 3 12l5 5zM16 7l5 5-5 5z"/>`)
mediaPiP = mediaSVG(`<rect x="2" y="4" width="20" height="16" rx="2"/><rect x="12" y="12" width="7" height="5" rx="1"/>`)
mediaFullscreen = mediaSVG(`<path d="M3 8V3h5M16 3h5v5M21 16v5h-5M8 21H3v-5"/>`)
mediaCaptions = mediaSVG(`<rect x="2" y="5" width="20" height="14" rx="2"/><path d="M10 10.5a2 2 0 1 0 0 3M17 10.5a2 2 0 1 0 0 3"/>`)
mediaRecord = mediaSVG(`<circle cx="12" cy="12" r="6" fill="currentColor"/>`)
mediaStop = mediaSVG(`<rect x="6" y="6" width="12" height="12" rx="1" fill="currentColor"/>`)
mediaMic = mediaSVG(`<rect x="9" y="2" width="6" height="12" rx="3"/><path d="M5 10a7 7 0 0 0 14 0M12 17v5M8 22h8"/>`)
mediaCamera = mediaSVG(`<rect x="2" y="6" width="14" height="12" rx="2"/><path d="m16 10 6-4v12l-6-4z"/>`)
mediaSpeaker = mediaSVG(`<rect x="5" y="2" width="14" height="20" rx="2"/><circle cx="12" cy="14" r="4"/><path d="M12 6h.01"/>`)
mediaRect = mediaSVG(`<rect x="4" y="6" width="16" height="12" rx="1"/>`)
mediaArrow = mediaSVG(`<path d="M5 19 19 5M10 5h9v9"/>`)
mediaText = mediaSVG(`<path d="M5 6V4h14v2M12 4v16M9 20h6"/>`)
mediaPointer = mediaSVG(`<path d="M5 3l14 7-6 2-2 6z"/>`)
)
// mediaClock writes d as m:ss, or h:mm:ss past an hour.
func mediaClock(d time.Duration) string {
if d < 0 {
d = 0
}
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)
}
+24
View File
@@ -0,0 +1,24 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
"mujicaui/theme"
)
// TestMediaChartInksContrast keeps every chart ink at 3:1 against Surface in both themes.
func TestMediaChartInksContrast(t *testing.T) {
for _, k := range []theme.Tokens{theme.Light(), theme.Dark()} {
inks := map[string]ui.Color{
"played (AccentText)": k.AccentText, "unplayed (TextMuted)": k.TextMuted, "cursor (Text)": k.Text,
"mic safe": micZone(k, 0), "mic loud": micZone(k, micSegments*7/10), "mic clip": micZone(k, micSegments-1),
}
for name, c := range inks {
if r := theme.Contrast(c, k.Surface); r < 3 {
t.Errorf("dark=%v %s: %.2f:1 against Surface", k.Dark, name, r)
}
}
}
}
+106
View File
@@ -0,0 +1,106 @@
package mujica
func init() {
addMessages(map[string][2]string{
"image_cropper.label": {"Crop area", "裁剪区域"},
"image_cropper.rotateLeft": {"Rotate left", "向左旋转"},
"image_cropper.rotateRight": {"Rotate right", "向右旋转"},
"image_annotator.label": {"Annotations", "标注"},
"image_annotator.tool": {"Tool", "工具"},
"image_annotator.select": {"Select", "选择"},
"image_annotator.rect": {"Rectangle", "矩形"},
"image_annotator.arrow": {"Arrow", "箭头"},
"image_annotator.text": {"Text", "文字"},
"image_annotator.textLabel": {"Annotation text", "标注文字"},
"image_annotator.note": {"Note", "注释"},
"image_annotator.delete": {"Delete annotation", "删除标注"},
"image_annotator.count": {"%d annotations", "%d 个标注"},
"image_annotator.selected": {"Annotation %d of %d selected: %s", "已选中第 %d 个标注(共 %d 个):%s"},
"image_thumbnail.failed": {"Failed to load", "加载失败"},
"image_thumbnail.loading": {"Loading", "加载中"},
"lightbox.title": {"Image", "图片"},
"lightbox.close": {"Close", "关闭"},
"lightbox.position": {"%d of %d", "第 %d 张,共 %d 张"},
"lightbox.previous": {"Previous image", "上一张"},
"lightbox.next": {"Next image", "下一张"},
"video_player.label": {"Video", "视频"},
"video_player.empty": {"No video", "暂无视频"},
"video_player.subtitles": {"Subtitles", "字幕"},
"video_player.pip": {"Picture in picture", "画中画"},
"video_player.fullscreen": {"Full screen", "全屏"},
"video_scrubber.label": {"Timeline", "时间轴"},
"video_thumbnail_strip.label": {"Frames", "缩略帧"},
"video_thumbnail_strip.position": {"Frame %d of %d, %s", "第 %d 帧,共 %d 帧,%s"},
"video_thumbnail_strip.previous": {"Previous frame", "上一帧"},
"video_thumbnail_strip.next": {"Next frame", "下一帧"},
"audio_waveform.label": {"Waveform", "波形"},
"audio_waveform.empty": {"No audio", "暂无音频"},
"audio_spectrum.label": {"Spectrum", "频谱"},
"audio_spectrum.empty": {"No signal", "无信号"},
"audio_spectrum.band": {"Band %d", "第 %d 段"},
"volume_control.label": {"Volume", "音量"},
"volume_control.mute": {"Mute", "静音"},
"volume_control.unmute": {"Unmute", "取消静音"},
"playback_speed_control.label": {"Playback speed", "播放速度"},
"media_controls.label": {"Playback", "播放控制"},
"media_controls.play": {"Play", "播放"},
"media_controls.pause": {"Pause", "暂停"},
"media_controls.previous": {"Previous", "上一首"},
"media_controls.next": {"Next", "下一首"},
"media_controls.shuffle": {"Shuffle", "随机播放"},
"media_controls.loopOff": {"Repeat off", "不循环"},
"media_controls.loopAll": {"Repeat all", "列表循环"},
"media_controls.loopOne": {"Repeat one", "单曲循环"},
"playlist.empty": {"The playlist is empty", "播放列表为空"},
"playlist.playing": {"Playing", "正在播放"},
"playlist.remove": {"Remove", "移除"},
"subtitle_editor.start": {"Start", "开始"},
"subtitle_editor.end": {"End", "结束"},
"subtitle_editor.text": {"Text", "文本"},
"subtitle_editor.add": {"Add cue", "添加字幕"},
"subtitle_editor.remove": {"Remove cue", "删除字幕"},
"subtitle_editor.empty": {"No cues yet", "还没有字幕"},
"subtitle_editor.badTime": {"Use m:ss.mmm, as 1:05.250", "请使用 m:ss.mmm 格式,例如 1:05.250"},
"subtitle_editor.order": {"End must be after start", "结束时间须晚于开始时间"},
"subtitle_editor.overlap": {"Overlaps cue %d", "与第 %d 条重叠"},
"screen_recorder_controls.label": {"Screen recording", "屏幕录制"},
"screen_recorder_controls.idle": {"Ready", "就绪"},
"screen_recorder_controls.recording": {"Recording", "录制中"},
"screen_recorder_controls.paused": {"Paused", "已暂停"},
"screen_recorder_controls.start": {"Record", "开始录制"},
"screen_recorder_controls.pause": {"Pause", "暂停"},
"screen_recorder_controls.resume": {"Resume", "继续"},
"screen_recorder_controls.stop": {"Stop", "停止"},
"camera_preview.label": {"Camera", "摄像头"},
"camera_preview.noSignal": {"No signal", "无信号"},
"camera_preview.mirror": {"Mirror", "镜像"},
"mic_level_meter.label": {"Microphone level", "麦克风电平"},
"mic_level_meter.peak": {"peak", "峰值"},
"device_selector.label": {"Devices", "设备"},
"device_selector.microphone": {"Microphone", "麦克风"},
"device_selector.camera": {"Camera", "摄像头"},
"device_selector.speaker": {"Speaker", "扬声器"},
"device_selector.choose": {"Choose a device", "选择设备"},
"device_selector.none": {"No devices found", "未找到设备"},
"image_compare.before": {"Before", "之前"},
"image_compare.after": {"After", "之后"},
})
}
+101
View File
@@ -0,0 +1,101 @@
package mujica
import (
"fmt"
"math"
"time"
"github.com/egoist/mygo/ui"
"mujicaui/theme"
)
// MicLevelMeterOptions configure a MicLevelMeter.
type MicLevelMeterOptions struct {
// Hold keeps the peak mark this long; zero is 1.5s.
Hold time.Duration
// Label names the meter for assistive technology.
Label string
}
const micSegments = 24
type micPeak struct {
level float32
at time.Time
}
// MicLevelMeter shows level as segments, held peak and dB.
func MicLevelMeter(c *ui.Context, level float32, opts MicLevelMeterOptions) *ui.Element {
if level < 0 || level > 1 || math.IsNaN(float64(level)) || opts.Hold < 0 {
panic("mujica: MicLevelMeter needs level in 0..1 and Hold >= 0")
}
hold := opts.Hold
if hold == 0 {
hold = 1500 * time.Millisecond
}
k := Tokens(c)
label := opts.Label
if label == "" {
label = msg(c, "mic_level_meter.label")
}
row := ui.Row(c).Gap(Density(c).Unit() * 2).AlignItems(ui.Center).MinHeight(theme.MinHitSize)
pk := ui.Local(row, micPeak{}, func() micPeak { return micPeak{} })
now := c.Now()
switch {
case level >= pk.level:
*pk = micPeak{level, now}
case now.Sub(pk.at) >= hold:
*pk = micPeak{level, now}
default:
// Repaint once when the hold ends; nothing runs while idle.
c.After(hold - now.Sub(pk.at))
}
peak := pk.level
row.Role(ui.RoleMeter).Label(label).Description(micDB(level) + ", " + msg(c, "mic_level_meter.peak") + " " + micDB(peak))
row.Children(func() {
ui.Box(c).Height(12).MinWidth(120).Grow(1).Draw(func(p *ui.Painter, r ui.Rect) {
gap := float32(2)
w := (r.W - gap*(micSegments-1)) / micSegments
lit := int(math.Round(float64(level * micSegments)))
mark := int(math.Round(float64(peak*micSegments))) - 1
for i := range micSegments {
x := r.X + float32(i)*(w+gap)
seg := ui.Rect{X: x, Y: r.Y, W: w, H: r.H}
col := micZone(k, i)
switch {
case i < lit:
p.Fill(seg, col, 1)
case i == mark:
p.Fill(seg, col, 1)
p.Fill(ui.Rect{X: x + 1, Y: r.Y + 2, W: w - 2, H: r.H - 4}, k.Surface, 0)
default:
p.Stroke(seg, k.Border, 1, 1)
}
}
})
ui.Text(c, micDB(level)).FontSize(FontSize(c, theme.CaptionSize)).FontFeatures("tnum").
TextColor(k.TextMuted).MinWidth(56).TextAlign(ui.End).SingleLine()
})
return row
}
// micZone colors the safe, loud and clipping zones.
func micZone(k theme.Tokens, i int) ui.Color {
switch {
case i >= micSegments*9/10:
return k.Danger
case i >= micSegments*7/10:
return k.Warning
}
return k.Success
}
// micDB writes a full-scale level in decibels.
func micDB(v float32) string {
if v <= 0 {
return "-∞ dB"
}
// Adding 0 turns a rounded -0 into 0.
return fmt.Sprintf("%.0f dB", math.Round(20*math.Log10(float64(v)))+0)
}
+62
View File
@@ -0,0 +1,62 @@
package mujica
import (
"strings"
"testing"
"time"
"github.com/egoist/mygo/ui"
)
func TestMicLevelMeterPeakHold(t *testing.T) {
level := float32(1)
var e *ui.Element
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
e = MicLevelMeter(c, level, MicLevelMeterOptions{Hold: 40 * time.Millisecond})
}, 400, 100)
if !tt.HasText("0 dB") {
t.Fatalf("texts %v", tt.Texts())
}
level = 0.1
tt.Frame()
if !tt.HasText("-20 dB") {
t.Fatalf("level readout %v", tt.Texts())
}
if r, ok := tt.Find("Microphone level"); !ok || r.W == 0 {
t.Fatal("meter not named")
}
if !strings.Contains(micTestDescription(e), "peak 0 dB") {
t.Fatal("peak not held")
}
time.Sleep(60 * time.Millisecond)
tt.Frame()
if !strings.Contains(micTestDescription(e), "peak -20 dB") {
t.Fatal("peak did not fall after the hold")
}
}
// micTestDescription rebuilds the meter's spoken value from its state.
func micTestDescription(e *ui.Element) string {
pk := *ui.Local(e, micPeak{}, func() micPeak { return micPeak{} })
return "peak " + micDB(pk.level)
}
func TestMicLevelMeterDB(t *testing.T) {
for v, want := range map[float32]string{0: "-∞ dB", 1: "0 dB", 0.5: "-6 dB"} {
if got := micDB(v); got != want {
t.Errorf("%v: %s, want %s", v, got, want)
}
}
mediaPanics(t, "over full scale", func(c *ui.Context) { MicLevelMeter(c, 1.2, MicLevelMeterOptions{}) })
}
func TestMicLevelMeterSnapshot(t *testing.T) {
snapshot(t, "mic_level_meter", 360, 150, func(c *ui.Context) {
ui.Column(c).Padding(20).Gap(8).Children(func() {
MicLevelMeter(c, 0.35, MicLevelMeterOptions{})
MicLevelMeter(c, 0.8, MicLevelMeterOptions{})
MicLevelMeter(c, 0.97, MicLevelMeterOptions{})
})
})
}
+46
View File
@@ -0,0 +1,46 @@
package mujica
import (
"log/slog"
"slices"
"strconv"
"github.com/egoist/mygo/ui"
)
// playbackRates are the default choices, 0.5× to 2×.
var playbackRates = []float64{0.5, 0.75, 1, 1.25, 1.5, 2}
// PlaybackSpeedControlOptions configure a PlaybackSpeedControl.
type PlaybackSpeedControlOptions struct {
// Rates are the ascending choices; nil means 0.5× to 2×.
Rates []float64
Disabled bool
}
// PlaybackSpeedControl chooses *rate from Rates in a drop-down.
func PlaybackSpeedControl(c *ui.Context, rate *float64, opts PlaybackSpeedControlOptions) ChoiceResult {
rates := opts.Rates
if rates == nil {
rates = playbackRates
}
if len(rates) == 0 || !slices.IsSorted(rates) || rates[0] <= 0 {
panic("mujica: PlaybackSpeedControl needs positive Rates in ascending order")
}
if !slices.Contains(rates, *rate) {
panic("mujica: PlaybackSpeedControl rate is not one of Rates")
}
options := make([]SelectOption[float64], len(rates))
for i, r := range rates {
options[i] = SelectOption[float64]{Value: r, Label: playbackLabel(r)}
}
res := Select(c, rate, options, SelectOptions{Disabled: opts.Disabled, Label: msg(c, "playback_speed_control.label")})
res.Element.MinWidth(88).Width(96)
if res.Changed() {
slog.Debug("mujica: playback_speed changed", "rate", *rate)
}
return res
}
// playbackLabel writes a rate as "1.25×".
func playbackLabel(r float64) string { return strconv.FormatFloat(r, 'f', -1, 64) + "×" }
+77
View File
@@ -0,0 +1,77 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
func speedTester(rate *float64, opts PlaybackSpeedControlOptions, n *int) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Padding(20).AlignItems(ui.Start).Children(func() {
if PlaybackSpeedControl(c, rate, opts).Changed() {
*n++
}
})
}, 300, 400)
}
func TestPlaybackSpeedChoose(t *testing.T) {
rate, n := 1.0, 0
tt := speedTester(&rate, PlaybackSpeedControlOptions{}, &n)
if !tt.HasText("1×") {
t.Fatalf("texts %v", tt.Texts())
}
if err := tt.Click("Playback speed"); err != nil {
t.Fatal(err)
}
for _, want := range []string{"0.5×", "0.75×", "1.25×", "1.5×", "2×"} {
if !tt.HasText(want) {
t.Fatalf("no %s in %v", want, tt.Texts())
}
}
if err := tt.Click("2×"); err != nil {
t.Fatal(err)
}
if rate != 2 || n != 1 {
t.Fatalf("rate %v changes %d", rate, n)
}
}
func TestPlaybackSpeedKeyboardAndDisabled(t *testing.T) {
rate, n := 1.0, 0
tt := speedTester(&rate, PlaybackSpeedControlOptions{}, &n)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyEnter)
if rate != 1.25 {
t.Fatalf("keyboard chose %v", rate)
}
rate2, n2 := 1.0, 0
off := speedTester(&rate2, PlaybackSpeedControlOptions{Disabled: true}, &n2)
_ = off.Click("Playback speed")
_ = off.Click("2×")
if rate2 != 1 || n2 != 0 {
t.Fatalf("disabled chose %v", rate2)
}
}
func TestPlaybackSpeedBadOptions(t *testing.T) {
odd := 3.0
mediaPanics(t, "rate not offered", func(c *ui.Context) { PlaybackSpeedControl(c, &odd, PlaybackSpeedControlOptions{}) })
one := 1.0
mediaPanics(t, "unsorted", func(c *ui.Context) {
PlaybackSpeedControl(c, &one, PlaybackSpeedControlOptions{Rates: []float64{2, 1}})
})
}
func TestPlaybackSpeedSnapshot(t *testing.T) {
rate := 1.25
snapshot(t, "playback_speed_control", 200, 80, func(c *ui.Context) {
ui.Column(c).Padding(20).AlignItems(ui.Start).Children(func() {
PlaybackSpeedControl(c, &rate, PlaybackSpeedControlOptions{})
})
})
}
+159
View File
@@ -0,0 +1,159 @@
package mujica
import (
"log/slog"
"slices"
"time"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/theme"
)
// PlaylistItem is one track; ID must be unique and stable.
type PlaylistItem struct {
ID, Title, Subtitle string
Duration time.Duration
}
// PlaylistOptions configure a Playlist.
type PlaylistOptions struct {
// Current is the playing item's ID; empty is none.
Current string
Disabled bool
// Height caps the list; zero means 320.
Height float32
}
// PlaylistResult is the playlist's element and this frame's events.
type PlaylistResult struct {
Element *ui.Element
// Played is the ID chosen by Enter or double click.
Played string
// Removed is the ID taken out of *items.
Removed string
// Moved reports a reorder of *items.
Moved bool
}
type playlistKey struct{}
// Playlist lists *items, building only rows in view.
func Playlist(c *ui.Context, items *[]PlaylistItem, opts PlaylistOptions) PlaylistResult {
if opts.Height < 0 {
panic("mujica: Playlist Height must not be negative")
}
h := opts.Height
if h == 0 {
h = 320
}
seen := make(map[string]bool, len(*items))
for _, it := range *items {
if it.ID == "" || seen[it.ID] {
panic("mujica: Playlist item IDs must be unique and non-empty: " + it.ID)
}
seen[it.ID] = true
}
k := Tokens(c)
var r PlaylistResult
box := ui.Column(c).Height(h).Disabled(opts.Disabled)
r.Element = box
st := ui.Local(box, playlistKey{}, func() *ListState[string] { return &ListState[string]{} })
s := *st
list := *items
live := !box.IsDisabled()
s.rows.Reorder = nil
if live {
s.rows.Reorder = func(rows []int, to int) {
*items = playlistMove(*items, rows, to)
r.Moved = true
}
}
removeAt := -1
box.Children(func() {
v := List(c, s, len(list), ListOptions[string]{
Key: func(i int) string { return list[i].ID },
Label: func(i int) string { return list[i].Title },
Empty: msg(c, "playlist.empty"),
}, func(i int) {
playlistRow(c, k, list[i], i, list[i].ID == opts.Current)
if iconAction(c, icons.Must("x"), msg(c, "playlist.remove")+" "+list[i].Title).Clicked() && live {
removeAt = i
}
})
v.Element.Grow(1)
if removeAt >= 0 {
r.Removed = list[removeAt].ID
*items = slices.Delete(*items, removeAt, removeAt+1)
return
}
sel, ok := s.Selected()
i := slices.IndexFunc(*items, func(it PlaylistItem) bool { return it.ID == sel })
if !live || !ok || i < 0 {
return
}
switch {
case v.Submitted():
r.Played = sel
case v.Element.Shortcut(0, ui.KeyDelete), v.Element.Shortcut(0, ui.KeyBackspace):
r.Removed = sel
*items = slices.Delete(*items, i, i+1)
case v.Element.Shortcut(ui.Alt, ui.KeyUp) && i > 0:
*items, r.Moved = playlistMove(*items, []int{i}, i-1), true
case v.Element.Shortcut(ui.Alt, ui.KeyDown) && i < len(*items)-1:
*items, r.Moved = playlistMove(*items, []int{i}, i+2), true
}
})
switch {
case r.Played != "":
slog.Debug("mujica: playlist play", "id", r.Played)
case r.Removed != "":
slog.Debug("mujica: playlist remove", "id", r.Removed)
case r.Moved:
slog.Debug("mujica: playlist move")
}
return r
}
// playlistRow shows a track's mark, title, subtitle and length.
func playlistRow(c *ui.Context, k theme.Tokens, it PlaylistItem, i int, playing bool) {
ui.Icon(c, icons.Must("grip-vertical")).Size(14, 14).TextColor(k.TextMuted).Shrink(0)
ui.Box(c).Width(20).Center().Shrink(0).Children(func() {
if playing {
ui.Icon(c, mediaPlay).Size(14, 14).TextColor(k.AccentText).Label(msg(c, "playlist.playing"))
} else {
ui.Textf(c, "%d", i+1).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).FontFeatures("tnum")
}
})
ui.Column(c).Grow(1).MinWidth(0).Children(func() {
t := ui.Text(c, it.Title).SingleLine()
if playing {
t.TextColor(k.AccentText).FontWeight(600)
}
if it.Subtitle != "" {
ui.Text(c, it.Subtitle).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).SingleLine()
}
})
if it.Duration > 0 {
ui.Text(c, mediaClock(it.Duration)).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).FontFeatures("tnum")
}
}
// playlistMove puts rows before index to; len means the end.
func playlistMove(items []PlaylistItem, rows []int, to int) []PlaylistItem {
moved := make([]PlaylistItem, 0, len(rows))
rest := make([]PlaylistItem, 0, len(items))
at := to
for i, it := range items {
if slices.Contains(rows, i) {
moved = append(moved, it)
if i < to {
at--
}
continue
}
rest = append(rest, it)
}
return slices.Concat(rest[:at], moved, rest[at:])
}
+123
View File
@@ -0,0 +1,123 @@
package mujica
import (
"fmt"
"slices"
"testing"
"time"
"github.com/egoist/mygo/ui"
)
func playlistItems(n int) []PlaylistItem {
items := make([]PlaylistItem, n)
for i := range items {
items[i] = PlaylistItem{ID: fmt.Sprint("t", i), Title: fmt.Sprint("Track ", i), Duration: time.Duration(180+i) * time.Second}
}
return items
}
func playlistIDs(items []PlaylistItem) []string {
ids := make([]string, len(items))
for i, it := range items {
ids[i] = it.ID
}
return ids
}
func playlistTester(items *[]PlaylistItem, disabled bool, got *[]PlaylistResult) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Padding(20).Width(420).Children(func() {
r := Playlist(c, items, PlaylistOptions{Current: "t1", Disabled: disabled})
if r.Played != "" || r.Removed != "" || r.Moved {
*got = append(*got, r)
}
})
}, 500, 420)
}
func TestPlaylistKeyboard(t *testing.T) {
items := playlistItems(4)
var got []PlaylistResult
tt := playlistTester(&items, false, &got)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyEnter)
if len(got) != 1 || got[0].Played != "t0" {
t.Fatalf("Enter played %+v", got)
}
tt.Key(ui.Alt, ui.KeyDown)
if !slices.Equal(playlistIDs(items), []string{"t1", "t0", "t2", "t3"}) {
t.Fatalf("Alt+Down order %v", playlistIDs(items))
}
tt.Key(0, ui.KeyDelete)
if !slices.Equal(playlistIDs(items), []string{"t1", "t2", "t3"}) || got[len(got)-1].Removed != "t0" {
t.Fatalf("Delete left %v, %+v", playlistIDs(items), got)
}
}
func TestPlaylistRemoveButtonAndDrag(t *testing.T) {
items := playlistItems(4)
var got []PlaylistResult
tt := playlistTester(&items, false, &got)
if err := tt.Click("Remove Track 3"); err != nil {
t.Fatal(err)
}
if len(items) != 3 || got[0].Removed != "t3" {
t.Fatalf("remove button: %v", playlistIDs(items))
}
a, _ := tt.Find("Track 0")
b, _ := tt.Find("Track 2")
tt.Press(a.X+4, a.Y+a.H/2)
tt.Move(a.X+4, a.Y+a.H/2+10)
tt.Move(b.X+4, b.Y+b.H-2)
tt.Release(b.X+4, b.Y+b.H-2)
if !slices.Equal(playlistIDs(items), []string{"t1", "t2", "t0"}) || !got[len(got)-1].Moved {
t.Fatalf("drag order %v", playlistIDs(items))
}
}
func TestPlaylistBuildsOnlyVisible(t *testing.T) {
items := playlistItems(100000)
var got []PlaylistResult
tt := playlistTester(&items, false, &got)
tt.Frame()
built := 0
for _, s := range tt.Texts() {
if len(s) > 6 && s[:6] == "Track " {
built++
}
}
if built == 0 || built > 40 {
t.Fatalf("built %d rows of 100000", built)
}
}
func TestPlaylistDisabledAndBad(t *testing.T) {
items := playlistItems(3)
var got []PlaylistResult
tt := playlistTester(&items, true, &got)
_ = tt.Click("Remove Track 0")
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyDelete)
tt.Key(0, ui.KeyEnter)
if len(items) != 3 || len(got) != 0 {
t.Fatalf("disabled playlist changed: %v %+v", playlistIDs(items), got)
}
dup := []PlaylistItem{{ID: "a"}, {ID: "a"}}
mediaPanics(t, "duplicate", func(c *ui.Context) { Playlist(c, &dup, PlaylistOptions{}) })
}
func TestPlaylistSnapshot(t *testing.T) {
items := []PlaylistItem{
{ID: "a", Title: "Ave Mujica", Subtitle: "Overture", Duration: 245 * time.Second},
{ID: "b", Title: "Imprisoned XII", Subtitle: "Act I", Duration: 302 * time.Second},
{ID: "c", Title: "KiLLKiSS", Subtitle: "Act II", Duration: 268 * time.Second},
}
snapshot(t, "playlist", 440, 240, func(c *ui.Context) {
ui.Column(c).Padding(20).Width(400).Children(func() {
Playlist(c, &items, PlaylistOptions{Current: "b", Height: 200})
})
})
}
+76
View File
@@ -0,0 +1,76 @@
package mujica
import (
"fmt"
"log/slog"
"time"
"github.com/egoist/mygo/ui"
"mujicaui/theme"
)
// RecorderStatus is where a recording is; zero is idle.
type RecorderStatus int
const (
RecorderIdle RecorderStatus = iota
RecorderRecording
RecorderPaused
)
// RecorderState is what the caller's recorder reports.
type RecorderState struct {
Status RecorderStatus
Elapsed time.Duration
}
// ScreenRecorderControlsOptions are the callbacks; every one is required.
type ScreenRecorderControlsOptions struct {
OnStart, OnPause, OnResume, OnStop func()
Disabled bool
}
// ScreenRecorderControls start, pause, resume and stop a recording.
func ScreenRecorderControls(c *ui.Context, st RecorderState, opts ScreenRecorderControlsOptions) *ui.Element {
if opts.OnStart == nil || opts.OnPause == nil || opts.OnResume == nil || opts.OnStop == nil {
panic("mujica: ScreenRecorderControls needs every callback")
}
if st.Status < RecorderIdle || st.Status > RecorderPaused || st.Elapsed < 0 {
panic(fmt.Sprintf("mujica: bad RecorderState %+v", st))
}
k := Tokens(c)
u := Density(c).Unit()
row := ui.Row(c).Gap(u*2).Padding(u, u*2).AlignItems(ui.Center).Radius(theme.CardRadius).
Background(k.Surface).Border(theme.BorderWidth, k.Border).Role(ui.RoleToolbar).
Label(msg(c, "screen_recorder_controls.label")).Disabled(opts.Disabled)
act := func(icon *ui.SVG, key string, variant ButtonVariant, fn func()) {
if Button(c, msg(c, key), ButtonOptions{Variant: variant, Icon: icon}).Clicked() && !row.IsDisabled() {
slog.Debug("mujica: recorder " + key)
fn()
}
}
row.Children(func() {
status := []string{"screen_recorder_controls.idle", "screen_recorder_controls.recording", "screen_recorder_controls.paused"}[st.Status]
ui.Row(c).Gap(u * 1.5).AlignItems(ui.Center).Role(ui.RoleStatus).Label(msg(c, status) + " " + mediaClock(st.Elapsed)).Children(func() {
dot := k.TextMuted
if st.Status == RecorderRecording {
dot = k.Danger
}
ui.Box(c).Size(10, 10).Radius(5).Background(dot)
ui.Text(c, msg(c, status)).TextColor(k.TextMuted).SingleLine()
ui.Text(c, mediaClock(st.Elapsed)).FontFeatures("tnum").SingleLine().MinWidth(48)
})
switch st.Status {
case RecorderIdle:
act(mediaRecord, "screen_recorder_controls.start", Danger, opts.OnStart)
case RecorderRecording:
act(mediaPause, "screen_recorder_controls.pause", Secondary, opts.OnPause)
act(mediaStop, "screen_recorder_controls.stop", Danger, opts.OnStop)
case RecorderPaused:
act(mediaRecord, "screen_recorder_controls.resume", Secondary, opts.OnResume)
act(mediaStop, "screen_recorder_controls.stop", Danger, opts.OnStop)
}
})
return row
}
+75
View File
@@ -0,0 +1,75 @@
package mujica
import (
"testing"
"time"
"github.com/egoist/mygo/ui"
)
type recorderCalls struct{ start, pause, resume, stop int }
func (r *recorderCalls) opts() ScreenRecorderControlsOptions {
return ScreenRecorderControlsOptions{OnStart: func() { r.start++ }, OnPause: func() { r.pause++ },
OnResume: func() { r.resume++ }, OnStop: func() { r.stop++ }}
}
func TestScreenRecorderControlsFlow(t *testing.T) {
st := RecorderState{}
var r recorderCalls
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Padding(20).AlignItems(ui.Start).Children(func() { ScreenRecorderControls(c, st, r.opts()) })
}, 500, 120)
if !tt.HasText("Ready") || tt.HasText("Stop") {
t.Fatalf("idle shows %v", tt.Texts())
}
if err := tt.Click("Record"); err != nil {
t.Fatal(err)
}
st = RecorderState{Status: RecorderRecording, Elapsed: 65 * time.Second}
tt.Frame()
if !tt.HasText("1:05") || !tt.HasText("Recording") {
t.Fatalf("recording shows %v", tt.Texts())
}
if err := tt.Click("Pause"); err != nil {
t.Fatal(err)
}
st.Status = RecorderPaused
tt.Frame()
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
if r != (recorderCalls{1, 1, 1, 1}) {
t.Fatalf("calls %+v", r)
}
}
func TestScreenRecorderControlsDisabledAndBad(t *testing.T) {
var r recorderCalls
o := r.opts()
o.Disabled = true
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ScreenRecorderControls(c, RecorderState{}, o)
}, 500, 120)
_ = tt.Click("Record")
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
if r.start != 0 {
t.Fatal("disabled recorder started")
}
mediaPanics(t, "no callbacks", func(c *ui.Context) { ScreenRecorderControls(c, RecorderState{}, ScreenRecorderControlsOptions{}) })
mediaPanics(t, "bad status", func(c *ui.Context) { ScreenRecorderControls(c, RecorderState{Status: 9}, r.opts()) })
}
func TestScreenRecorderControlsSnapshot(t *testing.T) {
var r recorderCalls
snapshot(t, "screen_recorder_controls", 420, 140, func(c *ui.Context) {
ui.Column(c).Padding(20).Gap(12).AlignItems(ui.Start).Children(func() {
ScreenRecorderControls(c, RecorderState{}, r.opts())
ScreenRecorderControls(c, RecorderState{Status: RecorderRecording, Elapsed: 3*time.Minute + 7*time.Second}, r.opts())
})
})
}
+244
View File
@@ -0,0 +1,244 @@
package mujica
import (
"fmt"
"log/slog"
"slices"
"strconv"
"strings"
"time"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/theme"
)
// SubtitleCue is one subtitle: shown from Start until End.
type SubtitleCue struct {
Start, End time.Duration
Text string
}
// SubtitleIssueKind is what is wrong with a cue.
type SubtitleIssueKind int
const (
// SubtitleEndBeforeStart: End is not after Start.
SubtitleEndBeforeStart SubtitleIssueKind = iota
// SubtitleOverlap: Start is before the previous cue's End.
SubtitleOverlap
)
// SubtitleIssue is a problem with cue Index.
type SubtitleIssue struct {
Index int
Kind SubtitleIssueKind
}
// CheckSubtitles lists misordered cues and cues overlapping the previous.
func CheckSubtitles(cues []SubtitleCue) []SubtitleIssue {
var out []SubtitleIssue
for i, q := range cues {
if q.End <= q.Start {
out = append(out, SubtitleIssue{i, SubtitleEndBeforeStart})
}
if i > 0 && q.Start < cues[i-1].End {
out = append(out, SubtitleIssue{i, SubtitleOverlap})
}
}
return out
}
// SubtitleEditorOptions configure a SubtitleEditor.
type SubtitleEditorOptions struct {
// Height is the visible list's height; zero means 360.
Height float32
Disabled bool
}
type subtitleState struct {
scroll ui.ScrollState
drafts map[subtitleField]string
}
type subtitleField struct{ row, part int }
type subtitleKey struct{}
// SubtitleEditor edits cue times and text, building visible rows.
func SubtitleEditor(c *ui.Context, cues *[]SubtitleCue, opts SubtitleEditorOptions) ChoiceResult {
if opts.Height < 0 {
panic("mujica: SubtitleEditor Height must not be negative")
}
h := opts.Height
if h == 0 {
h = 360
}
k := Tokens(c)
u := Density(c).Unit()
rowH := Density(c).ControlHeight() + FontSize(c, theme.CaptionSize)*theme.LineHeight + u*3
col := ui.Column(c).Gap(u * 2).Disabled(opts.Disabled)
st := ui.Local(col, subtitleKey{}, func() *subtitleState { return &subtitleState{drafts: map[subtitleField]string{}} })
s := *st
issues := map[int]string{}
for _, is := range CheckSubtitles(*cues) {
if is.Kind == SubtitleOverlap {
issues[is.Index] = fmt.Sprintf(msg(c, "subtitle_editor.overlap"), is.Index)
} else {
issues[is.Index] = msg(c, "subtitle_editor.order")
}
}
changed := false
col.Children(func() {
list := ui.Scroll(c).Height(h).TrackScroll(&s.scroll).Background(k.Surface).
Border(theme.BorderWidth, k.Border).Radius(theme.ControlRadius)
list.Children(func() {
n := len(*cues)
// The container keeps Y inside the content; match it.
y := max(0, min(s.scroll.Y, float32(n)*rowH-h))
first := max(0, int(y/rowH)-1)
last := min(n, int((y+h)/rowH)+2)
ui.Box(c).Height(float32(n) * rowH).Children(func() {
for i := first; i < last; i++ {
before := len(*cues)
if subtitleRow(c, k, cues, s, i, rowH, issues[i]) {
changed = true
}
if len(*cues) != before {
// A removal shifts rows below; the next frame builds them.
c.AnimationFrame()
return
}
}
})
if n == 0 {
ui.Text(c, msg(c, "subtitle_editor.empty")).TextColor(k.TextMuted).Padding(u * 3)
}
})
if Button(c, msg(c, "subtitle_editor.add"), ButtonOptions{Variant: Secondary, Icon: icons.Must("plus")}).Clicked() && !col.IsDisabled() {
var at time.Duration
if n := len(*cues); n > 0 {
at = (*cues)[n-1].End
}
*cues = append(*cues, SubtitleCue{Start: at, End: at + 2*time.Second})
s.scroll.Y = float32(len(*cues)) * rowH
changed = true
}
})
if changed {
slog.Debug("mujica: subtitle_editor changed", "cues", len(*cues))
}
return ChoiceResult{Element: col, changed: changed}
}
// subtitleRow edits cue i; true means changed or removed.
func subtitleRow(c *ui.Context, k theme.Tokens, cues *[]SubtitleCue, s *subtitleState, i int, rowH float32, issue string) bool {
u := Density(c).Unit()
q := &(*cues)[i]
changed, removed := false, false
row := ui.Column(c).Key(i).Absolute().Left(0).Right(0).Top(float32(i)*rowH).Height(rowH).Padding(u*1.5, u*2).Gap(2).
BorderWidth(0, 0, 1, 0).BorderColor(k.Border)
row.Children(func() {
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
ui.Textf(c, "%d", i+1).Width(28).TextColor(k.TextMuted).FontFeatures("tnum")
for part, t := range []*time.Duration{&q.Start, &q.End} {
f := subtitleField{i, part}
text, editing := s.drafts[f]
if !editing {
text = subtitleClock(*t)
}
name := msg(c, []string{"subtitle_editor.start", "subtitle_editor.end"}[part])
in := TextInput(c, &text, TextInputOptions{Label: fmt.Sprintf("%s %d", name, i+1)})
in.Element.Width(112)
bad := false
if in.Changed() {
if v, ok := subtitleParse(text); ok {
*t, changed = v, true
delete(s.drafts, f)
} else {
s.drafts[f], bad = text, true
}
} else if editing {
_, ok := subtitleParse(text)
bad = !ok
}
if bad {
in.Input.Error(msg(c, "subtitle_editor.badTime"))
} else if issue != "" {
in.Input.Error(issue)
}
}
txt := TextInput(c, &q.Text, TextInputOptions{Label: fmt.Sprintf("%s %d", msg(c, "subtitle_editor.text"), i+1)})
txt.Element.Grow(1)
if txt.Changed() {
changed = true
}
if b := iconAction(c, icons.Must("trash-2"), fmt.Sprintf("%s %d", msg(c, "subtitle_editor.remove"), i+1)); b.Clicked() && !b.IsDisabled() {
removed = true
}
})
ui.Row(c).Gap(u).PaddingX(28 + u*2).Children(func() {
note := issue
for part := range 2 {
if d, ok := s.drafts[subtitleField{i, part}]; ok {
if _, valid := subtitleParse(d); !valid {
note = msg(c, "subtitle_editor.badTime")
}
}
}
if note != "" {
ui.Text(c, note).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.Danger).SingleLine()
}
})
})
if removed {
*cues = slices.Delete(*cues, i, i+1)
clear(s.drafts)
return true
}
return changed
}
// subtitleClock writes d as m:ss.mmm.
func subtitleClock(d time.Duration) string {
ms := d.Milliseconds()
return fmt.Sprintf("%d:%02d.%03d", ms/60000, ms/1000%60, ms%1000)
}
// subtitleParse reads m:ss.mmm or h:mm:ss.mmm; the fraction is optional.
func subtitleParse(s string) (time.Duration, bool) {
parts := strings.Split(strings.TrimSpace(s), ":")
if len(parts) < 2 || len(parts) > 3 {
return 0, false
}
acc := 0
for i, p := range parts[:len(parts)-1] {
n, err := strconv.Atoi(p)
if err != nil || !subtitleDigits(p) || (i > 0 && n > 59) {
return 0, false
}
acc = acc*60 + n
}
sec := parts[len(parts)-1]
whole, frac, _ := strings.Cut(sec, ".")
s0, err := strconv.Atoi(whole)
if err != nil || len(whole) != 2 || s0 < 0 || s0 > 59 || len(frac) > 3 || !subtitleDigits(whole+frac) {
return 0, false
}
ms := 0
if frac != "" {
ms, _ = strconv.Atoi(frac + strings.Repeat("0", 3-len(frac)))
}
return time.Duration(acc*60+s0)*time.Second + time.Duration(ms)*time.Millisecond, true
}
// subtitleDigits reports whether s is ASCII digits only.
func subtitleDigits(s string) bool {
for _, r := range s {
if r < '0' || r > '9' {
return false
}
}
return true
}
+131
View File
@@ -0,0 +1,131 @@
package mujica
import (
"slices"
"strings"
"testing"
"time"
"github.com/egoist/mygo/ui"
)
func subtitleTester(cues *[]SubtitleCue, disabled bool, n *int) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Padding(20).Width(620).Children(func() {
if SubtitleEditor(c, cues, SubtitleEditorOptions{Height: 260, Disabled: disabled}).Changed() {
*n++
}
})
}, 700, 400)
}
func TestCheckSubtitles(t *testing.T) {
cues := []SubtitleCue{{0, 2 * time.Second, "a"}, {time.Second, 3 * time.Second, "b"}, {4 * time.Second, 4 * time.Second, "c"}}
want := []SubtitleIssue{{1, SubtitleOverlap}, {2, SubtitleEndBeforeStart}}
if got := CheckSubtitles(cues); !slices.Equal(got, want) {
t.Fatalf("issues %v", got)
}
}
func TestSubtitleParse(t *testing.T) {
for s, want := range map[string]time.Duration{"0:01.5": 1500 * time.Millisecond, "1:05.250": 65250 * time.Millisecond, "1:00:00": time.Hour, "2:03": 123 * time.Second} {
if got, ok := subtitleParse(s); !ok || got != want {
t.Errorf("%q: %v %v", s, got, ok)
}
}
for _, s := range []string{"", "5", "1:60", "1:5", "a:00", "1:00.1234", "1:-1", "+1:00"} {
if _, ok := subtitleParse(s); ok {
t.Errorf("%q parsed", s)
}
}
}
func TestSubtitleEditorEditTimesAndText(t *testing.T) {
cues := []SubtitleCue{{0, 2 * time.Second, "Welcome"}, {3 * time.Second, 5 * time.Second, "to the court"}}
n := 0
tt := subtitleTester(&cues, false, &n)
if err := tt.Click("End 1"); err != nil {
t.Fatal(err)
}
tt.Command("selectAll")
tt.Type("0:04")
if cues[0].End != 4*time.Second || !tt.HasText("Overlaps cue 1") {
t.Fatalf("end edit %+v, texts %v", cues[0], tt.Texts())
}
tt.Command("selectAll")
tt.Type("zz")
if cues[0].End != 4*time.Second || !tt.HasText("Use m:ss.mmm, as 1:05.250") {
t.Fatalf("bad time kept? %+v", cues[0])
}
if err := tt.Click("Text 2"); err != nil {
t.Fatal(err)
}
tt.Type("!")
if cues[1].Text != "to the court!" || n < 2 {
t.Fatalf("text %q changes %d", cues[1].Text, n)
}
}
func TestSubtitleEditorAddRemove(t *testing.T) {
cues := []SubtitleCue{{0, 2 * time.Second, "a"}}
n := 0
tt := subtitleTester(&cues, false, &n)
if err := tt.Click("Add cue"); err != nil {
t.Fatal(err)
}
if len(cues) != 2 || cues[1].Start != 2*time.Second || cues[1].End != 4*time.Second {
t.Fatalf("added %+v", cues)
}
if err := tt.Click("Remove cue 1"); err != nil {
t.Fatal(err)
}
if len(cues) != 1 || cues[0].Start != 2*time.Second || n != 2 {
t.Fatalf("removed %+v, changes %d", cues, n)
}
}
func TestSubtitleEditorBuildsOnlyVisible(t *testing.T) {
cues := make([]SubtitleCue, 100000)
for i := range cues {
cues[i] = SubtitleCue{time.Duration(i) * time.Second, time.Duration(i)*time.Second + 900*time.Millisecond, "line"}
}
n := 0
tt := subtitleTester(&cues, false, &n)
tt.Frame()
built := 0
for _, x := range tt.Texts() {
if strings.HasPrefix(x, "Remove cue ") {
built++
}
}
if built == 0 || built > 20 {
t.Fatalf("built %d rows of 100000", built)
}
}
func TestSubtitleEditorDisabled(t *testing.T) {
cues := []SubtitleCue{{0, 2 * time.Second, "a"}}
n := 0
tt := subtitleTester(&cues, true, &n)
_ = tt.Click("Add cue")
_ = tt.Click("Remove cue 1")
_ = tt.Click("Text 1")
tt.Type("x")
if len(cues) != 1 || cues[0].Text != "a" || n != 0 {
t.Fatalf("disabled editor changed %+v", cues)
}
}
func TestSubtitleEditorSnapshot(t *testing.T) {
cues := []SubtitleCue{
{0, 2500 * time.Millisecond, "The curtain rises."},
{2 * time.Second, 5 * time.Second, "Welcome to the masquerade."},
{6 * time.Second, 9 * time.Second, "Ave Mujica."},
}
snapshot(t, "subtitle_editor", 640, 300, func(c *ui.Context) {
ui.Column(c).Padding(20).Width(600).Children(func() {
SubtitleEditor(c, &cues, SubtitleEditorOptions{Height: 200})
})
})
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1002 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Loaded 100 of 111 files, more files were not shown because too many files have changed in this diff. Show more