Media: components 341-360
No files matched your search
@@ -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()
|
||||
}
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{}) })
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) {}})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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{}) })
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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{})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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 以内才算抓住手柄。
|
||||
@@ -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, ¬es, 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 像素估算。
|
||||
@@ -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 | 激活 |
|
||||
|
||||
## 限制
|
||||
|
||||
- 选中状态由调用方保存;组件只报告点击。
|
||||
@@ -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。
|
||||
@@ -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()` 的频率。
|
||||
@@ -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, ¤t, 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 | 第一帧 / 最后一帧 |
|
||||
|
||||
## 限制
|
||||
|
||||
- 不提供横向滚动条,用两侧按钮或键盘移动。
|
||||
@@ -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 秒 |
|
||||
|
||||
## 限制
|
||||
|
||||
- 组件不播放声音。
|
||||
@@ -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 | 清除选区 |
|
||||
|
||||
## 限制
|
||||
|
||||
- 峰值按像素列计算,只在数据或宽度变化时重算,不会每帧计算。
|
||||
- 数据以首元素地址和长度识别;原地修改采样请换一个切片。
|
||||
@@ -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()`。
|
||||
@@ -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 | 关闭 |
|
||||
|
||||
## 限制
|
||||
|
||||
- 无。
|
||||
@@ -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 | 按下 |
|
||||
|
||||
## 限制
|
||||
|
||||
- 无。
|
||||
@@ -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 提供,拖动时显示插入线。
|
||||
@@ -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`。
|
||||
@@ -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。
|
||||
- 组件不打开摄像头。
|
||||
@@ -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 数值,不只靠颜色。
|
||||
|
||||
## 事件
|
||||
|
||||
无。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
| 按键 | 作用 |
|
||||
| --- | --- |
|
||||
| — | 不获取焦点 |
|
||||
|
||||
## 限制
|
||||
|
||||
- 保持结束时只请求一次重绘,空闲时不持续重绘。
|
||||
@@ -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 | 打开列表 |
|
||||
| ↑ / ↓ | 移动 |
|
||||
| 输入字母 | 跳到匹配的设备 |
|
||||
|
||||
## 限制
|
||||
|
||||
- 组件不枚举设备,也不监听设备插拔;调用方更新列表即可。
|
||||
@@ -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 铺满盒子,尺寸不同时会裁切。
|
||||
@@ -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, ¬es, 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, ¤t, 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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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] }})
|
||||
})
|
||||
}
|
||||
@@ -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, ¬es, 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, ¤t, 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)`
|
||||
)
|
||||
@@ -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))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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(¬es, 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(¬es, 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(¬es, 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, ¬es, ImageAnnotatorOptions{Height: 220})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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{})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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))) }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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", "之后"},
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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{})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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) + "×" }
|
||||
@@ -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{})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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:])
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 9.8 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 9.8 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1002 B |
|
After Width: | Height: | Size: 3.3 KiB |