Merge branch 'worktree-agent-a085d9a07ef787c18'
No files matched your search
@@ -0,0 +1,112 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// AudioMessageOptions describe a voice message the caller plays.
|
||||
type AudioMessageOptions struct {
|
||||
// Duration is the length; required.
|
||||
Duration time.Duration
|
||||
// Position is how far playback has gone, 0 to Duration.
|
||||
Position time.Duration
|
||||
Playing bool
|
||||
// Waveform holds loudness samples from 0 to 1; empty draws a flat line.
|
||||
Waveform []float32
|
||||
}
|
||||
|
||||
// AudioMessageResult is an AudioMessage's element and the user's requests this frame.
|
||||
type AudioMessageResult struct {
|
||||
Element *ui.Element
|
||||
toggled bool
|
||||
seek time.Duration
|
||||
seeked bool
|
||||
}
|
||||
|
||||
// Toggled reports a press of play or pause.
|
||||
func (r AudioMessageResult) Toggled() bool { return r.toggled }
|
||||
|
||||
// Seek returns the position the user moved to by click, drag or keys.
|
||||
func (r AudioMessageResult) Seek() (time.Duration, bool) { return r.seek, r.seeked }
|
||||
|
||||
// audioBarWidth and audioBarGap space the waveform's bars.
|
||||
const audioBarWidth, audioBarGap float32 = 2, 2
|
||||
|
||||
// AudioMessage shows a voice bar: play button, waveform with progress, and times.
|
||||
func AudioMessage(c *ui.Context, opts AudioMessageOptions) AudioMessageResult {
|
||||
if opts.Duration <= 0 || opts.Position < 0 || opts.Position > opts.Duration {
|
||||
panic("mujica: AudioMessage needs 0 <= Position <= Duration and Duration > 0")
|
||||
}
|
||||
for _, v := range opts.Waveform {
|
||||
if !(v >= 0 && v <= 1) {
|
||||
panic("mujica: AudioMessage Waveform samples must be from 0 to 1")
|
||||
}
|
||||
}
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
var r AudioMessageResult
|
||||
r.Element = ui.Row(c).Gap(u*2).AlignItems(ui.Center).Padding(u, u*3, u, u).Width(300).
|
||||
Radius(theme.CardRadius).Background(k.Surface).Border(theme.BorderWidth, k.Border)
|
||||
disabled := r.Element.IsDisabled()
|
||||
r.Element.Children(func() {
|
||||
icon, name := "play", msg(c, "audio_message.play")
|
||||
if opts.Playing {
|
||||
icon, name = "pause", msg(c, "audio_message.pause")
|
||||
}
|
||||
if iconAction(c, icons.Must(icon), name).Clicked() && !disabled {
|
||||
r.toggled = true
|
||||
slog.Debug("mujica: audio_message toggle", "playing", !opts.Playing)
|
||||
}
|
||||
pos := opts.Position.Seconds()
|
||||
s := ui.SliderBase(c, &pos, 0, opts.Duration.Seconds()).Grow(1).Height(theme.MinHitSize).
|
||||
Label(msg(c, "audio_message.seek")).Description(audioClock(opts.Position) + " / " + audioClock(opts.Duration))
|
||||
internal.Ring(s, k.Focus, theme.ControlRadius)
|
||||
if s.Changed() && !disabled {
|
||||
r.seek, r.seeked = time.Duration(pos*float64(time.Second)), true
|
||||
slog.Debug("mujica: audio_message seek", "at", r.seek)
|
||||
}
|
||||
frac := float32(opts.Position.Seconds() / opts.Duration.Seconds())
|
||||
s.Draw(func(p *ui.Painter, b ui.Rect) { audioWave(p, b, opts.Waveform, frac, k) })
|
||||
ui.Text(c, audioClock(opts.Position)+" / "+audioClock(opts.Duration)).FontFeatures("tnum").
|
||||
FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).SingleLine().Shrink(0)
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
// audioWave draws bars, played ones in AccentText, with a position line so progress is not color alone.
|
||||
func audioWave(p *ui.Painter, b ui.Rect, wave []float32, frac float32, k theme.Tokens) {
|
||||
n := int((b.W + audioBarGap) / (audioBarWidth + audioBarGap))
|
||||
if n <= 0 {
|
||||
return
|
||||
}
|
||||
cy := b.Y + b.H/2
|
||||
for i := range n {
|
||||
v := float32(0.12)
|
||||
if len(wave) > 0 {
|
||||
v = max(v, wave[i*len(wave)/n])
|
||||
}
|
||||
h := max(2, v*(b.H-8))
|
||||
x := b.X + float32(i)*(audioBarWidth+audioBarGap)
|
||||
col := k.TextMuted.Alpha(0.55)
|
||||
if (x-b.X)/b.W < frac {
|
||||
col = k.AccentText
|
||||
}
|
||||
p.Fill(ui.Rect{X: x, Y: cy - h/2, W: audioBarWidth, H: h}, col, 1)
|
||||
}
|
||||
at := internal.Snap(p, b.X+b.W*frac)
|
||||
p.Fill(ui.Rect{X: at - 1, Y: b.Y + 2, W: 2, H: b.H - 4}, k.Text, 1)
|
||||
}
|
||||
|
||||
// audioClock formats d as m:ss.
|
||||
func audioClock(d time.Duration) string {
|
||||
s := int(d.Round(time.Second).Seconds())
|
||||
return fmt.Sprintf("%d:%02d", s/60, s%60)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
var audioMessageWave = []float32{0.2, 0.5, 0.9, 0.4, 0.7, 0.3, 0.8, 0.6, 0.2, 0.5}
|
||||
|
||||
func TestAudioMessagePlaySeekAndKeys(t *testing.T) {
|
||||
playing, pos := false, 10*time.Second
|
||||
var seeks []time.Duration
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
r := AudioMessage(c, AudioMessageOptions{Duration: time.Minute, Position: pos, Playing: playing, Waveform: audioMessageWave})
|
||||
if r.Toggled() {
|
||||
playing = !playing
|
||||
}
|
||||
if d, ok := r.Seek(); ok {
|
||||
pos = d
|
||||
seeks = append(seeks, d)
|
||||
}
|
||||
}, 400, 200)
|
||||
if !tt.HasText("0:10 / 1:00") {
|
||||
t.Fatalf("times %q", tt.Texts())
|
||||
}
|
||||
_ = tt.Click("Play")
|
||||
if !playing {
|
||||
t.Fatal("play not reported")
|
||||
}
|
||||
if _, ok := tt.Find("Pause"); !ok {
|
||||
t.Fatal("button did not turn to pause")
|
||||
}
|
||||
r, _ := tt.Find("Playback position")
|
||||
tt.ClickAt(r.X+r.W*0.75, r.Y+r.H/2)
|
||||
if len(seeks) == 0 || pos < 40*time.Second || pos > 50*time.Second {
|
||||
t.Fatalf("click at 3/4 seeked to %v", pos)
|
||||
}
|
||||
tt.Key(0, ui.KeyHome)
|
||||
if pos != 0 {
|
||||
t.Fatalf("Home gave %v", pos)
|
||||
}
|
||||
tt.Key(0, ui.KeyEnd)
|
||||
if pos != time.Minute {
|
||||
t.Fatalf("End gave %v", pos)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAudioMessageDisabled(t *testing.T) {
|
||||
events := 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Column(c).Disabled(true).Children(func() {
|
||||
r := AudioMessage(c, AudioMessageOptions{Duration: time.Minute})
|
||||
if _, ok := r.Seek(); ok || r.Toggled() {
|
||||
events++
|
||||
}
|
||||
})
|
||||
}, 400, 200)
|
||||
_ = tt.Click("Play")
|
||||
r, _ := tt.Find("Playback position")
|
||||
tt.ClickAt(r.X+r.W/2, r.Y+r.H/2)
|
||||
if events != 0 {
|
||||
t.Fatalf("disabled audio reported %d events", events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAudioMessageBadOptions(t *testing.T) {
|
||||
for _, o := range []AudioMessageOptions{{}, {Duration: time.Second, Position: 2 * time.Second}, {Duration: time.Second, Waveform: []float32{2}}} {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Errorf("%+v: no panic", o)
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
AudioMessage(c, o)
|
||||
}, 100, 100)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func TestAudioMessageSnapshot(t *testing.T) {
|
||||
snapshot(t, "audio_message", 340, 80, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
AudioMessage(c, AudioMessageOptions{Duration: 47 * time.Second, Position: 18 * time.Second, Playing: true, Waveform: audioMessageWave})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// BranchNavigator switches among count branches of a message, showing "< 2 / 3 >"; Left and Right step.
|
||||
func BranchNavigator(c *ui.Context, index *int, count int) ChoiceResult {
|
||||
if count < 1 || *index < 0 || *index >= count {
|
||||
panic("mujica: BranchNavigator needs 0 <= index < count")
|
||||
}
|
||||
k := Tokens(c)
|
||||
label := fmt.Sprintf(msg(c, "branch_navigator.label"), *index+1, count)
|
||||
var r ChoiceResult
|
||||
r.Element = ui.Row(c).Focusable().AlignItems(ui.Center).Radius(theme.ControlRadius).Role(ui.RoleGroup).Label(label)
|
||||
internal.Ring(r.Element, k.Focus, theme.ControlRadius)
|
||||
disabled := r.Element.IsDisabled()
|
||||
step := func(d int) {
|
||||
if i := *index + d; i >= 0 && i < count && !disabled {
|
||||
*index, r.changed = i, true
|
||||
c.Announce(fmt.Sprintf(msg(c, "branch_navigator.label"), i+1, count))
|
||||
slog.Debug("mujica: branch_navigator change", "index", i)
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case r.Element.Shortcut(0, ui.KeyLeft):
|
||||
step(-1)
|
||||
case r.Element.Shortcut(0, ui.KeyRight):
|
||||
step(1)
|
||||
case r.Element.Shortcut(0, ui.KeyHome):
|
||||
step(-*index)
|
||||
case r.Element.Shortcut(0, ui.KeyEnd):
|
||||
step(count - 1 - *index)
|
||||
}
|
||||
r.Element.Children(func() {
|
||||
if numberInputButton(c, "chevron-left", msg(c, "branch_navigator.previous"), *index > 0) {
|
||||
step(-1)
|
||||
}
|
||||
ui.Text(c, fmt.Sprintf("%d / %d", *index+1, count)).FontFeatures("tnum").FontSize(FontSize(c, theme.CaptionSize)).
|
||||
TextColor(k.TextMuted).SingleLine()
|
||||
if numberInputButton(c, "chevron-right", msg(c, "branch_navigator.next"), *index < count-1) {
|
||||
step(1)
|
||||
}
|
||||
})
|
||||
return r
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func TestBranchNavigatorButtonsAndKeys(t *testing.T) {
|
||||
i, changes, off := 1, 0, false
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Column(c).Disabled(off).Children(func() {
|
||||
if BranchNavigator(c, &i, 3).Changed() {
|
||||
changes++
|
||||
}
|
||||
})
|
||||
}, 300, 100)
|
||||
if !tt.HasText("2 / 3") {
|
||||
t.Fatalf("texts %q", tt.Texts())
|
||||
}
|
||||
_ = tt.Click("Next branch")
|
||||
_ = tt.Click("Next branch")
|
||||
if i != 2 || changes != 1 {
|
||||
t.Fatalf("index %d changes %d at the end", i, changes)
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyLeft)
|
||||
tt.Key(0, ui.KeyHome)
|
||||
tt.Key(0, ui.KeyLeft)
|
||||
if i != 0 || changes != 3 {
|
||||
t.Fatalf("keys: index %d changes %d", i, changes)
|
||||
}
|
||||
tt.Key(0, ui.KeyEnd)
|
||||
if i != 2 {
|
||||
t.Fatalf("End gave %d", i)
|
||||
}
|
||||
off = true
|
||||
tt.Key(0, ui.KeyLeft)
|
||||
_ = tt.Click("Previous branch")
|
||||
if i != 2 {
|
||||
t.Fatalf("disabled navigator moved to %d", i)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBranchNavigatorBounds(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic for an index past count")
|
||||
}
|
||||
}()
|
||||
i := 3
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
BranchNavigator(c, &i, 3)
|
||||
}, 100, 100)
|
||||
}
|
||||
|
||||
func TestBranchNavigatorSnapshot(t *testing.T) {
|
||||
i := 1
|
||||
snapshot(t, "branch_navigator", 200, 60, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(12).Children(func() { BranchNavigator(c, &i, 3) })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// ChatContainerOptions configure a ChatContainer.
|
||||
type ChatContainerOptions struct {
|
||||
// List configures the message area; its ID is required.
|
||||
List MessageListOptions
|
||||
// Input builds the input area under the messages.
|
||||
Input func()
|
||||
// Sidebar builds a side column, as a session list; nil shows none.
|
||||
Sidebar func()
|
||||
// SidebarWidth is the side column's width in DIPs; zero means 260.
|
||||
SidebarWidth float32
|
||||
}
|
||||
|
||||
// ChatContainer lays out a chat: sidebar, messages that follow the newest, and the input.
|
||||
func ChatContainer(c *ui.Context, s *MessageListState, n int, opts ChatContainerOptions, row func(i int)) MessageListResult {
|
||||
if opts.SidebarWidth < 0 {
|
||||
panic("mujica: ChatContainer SidebarWidth must not be negative")
|
||||
}
|
||||
w := opts.SidebarWidth
|
||||
if w == 0 {
|
||||
w = 260
|
||||
}
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
var r MessageListResult
|
||||
shell := ui.Row(c).Fill().AlignItems(ui.Stretch).Background(k.Background)
|
||||
shell.Children(func() {
|
||||
if opts.Sidebar != nil {
|
||||
ui.Column(c).Width(w).Shrink(0).MinHeight(0).Background(k.Surface).
|
||||
BorderWidth(0, theme.BorderWidth, 0, 0).BorderColor(k.Border).Children(opts.Sidebar)
|
||||
}
|
||||
ui.Column(c).Grow(1).MinWidth(0).MinHeight(0).Children(func() {
|
||||
r = MessageList(c, s, n, opts.List, row)
|
||||
if opts.Input != nil {
|
||||
ui.Column(c).Shrink(0).Padding(u*3, u*4).BorderWidth(theme.BorderWidth, 0, 0, 0).BorderColor(k.Border).Children(opts.Input)
|
||||
}
|
||||
})
|
||||
})
|
||||
r.Element = shell
|
||||
return r
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
// chatContainerApp is a chat with a sidebar and an input.
|
||||
type chatContainerApp struct {
|
||||
msgs []string
|
||||
state MessageListState
|
||||
draft string
|
||||
}
|
||||
|
||||
func (a *chatContainerApp) view(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ChatContainer(c, &a.state, len(a.msgs), ChatContainerOptions{
|
||||
List: MessageListOptions{ID: func(i int) string { return a.msgs[i] }},
|
||||
Sidebar: func() { ui.Text(c, "Sessions").Padding(12) },
|
||||
Input: func() { TextInput(c, &a.draft, TextInputOptions{Label: "Prompt"}) },
|
||||
}, func(i int) {
|
||||
MessageBubble(c, MessageAssistant, MessageBubbleOptions{}, func() { ui.Text(c, a.msgs[i]) })
|
||||
})
|
||||
}
|
||||
|
||||
func TestChatContainerSlotsAndFollow(t *testing.T) {
|
||||
a := &chatContainerApp{}
|
||||
for i := range 40 {
|
||||
a.msgs = append(a.msgs, "reply "+string(rune('A'+i%26))+string(rune('a'+i/26)))
|
||||
}
|
||||
tt := ui.NewTester(a.view, 600, 400)
|
||||
tt.Frame()
|
||||
if !tt.HasText("Sessions") || !tt.HasText("reply Nb") {
|
||||
t.Fatalf("slots or newest message missing: %q", tt.Texts())
|
||||
}
|
||||
_ = tt.Click("Prompt")
|
||||
tt.Type("hi")
|
||||
if a.draft != "hi" {
|
||||
t.Fatalf("input slot draft %q", a.draft)
|
||||
}
|
||||
a.msgs = append(a.msgs, "fresh")
|
||||
tt.Frame()
|
||||
tt.Frame()
|
||||
if !tt.HasText("fresh") {
|
||||
t.Fatal("new message not followed")
|
||||
}
|
||||
tt.Scroll(400, 150, 0, -3000)
|
||||
tt.Frame()
|
||||
a.msgs = append(a.msgs, "later")
|
||||
tt.Frame()
|
||||
if tt.HasText("later") {
|
||||
t.Fatal("followed after the user scrolled up")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatContainerSnapshot(t *testing.T) {
|
||||
snapshot(t, "chat_container", 560, 300, func(c *ui.Context) {
|
||||
a := ui.Local(c.Root(), "app", func() *chatContainerApp {
|
||||
return &chatContainerApp{msgs: []string{"Bonjour, Madame.", "How may I serve the court today?"}}
|
||||
})
|
||||
(*a).view(c)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// CopyResult is a block's element and whether its copy action ran this frame.
|
||||
type CopyResult struct {
|
||||
Element *ui.Element
|
||||
copied bool
|
||||
}
|
||||
|
||||
// Copied reports that the block wrote its content to the clipboard this frame.
|
||||
func (r CopyResult) Copied() bool { return r.copied }
|
||||
|
||||
// CodeBlockOptions configure a CodeBlock.
|
||||
type CodeBlockOptions struct {
|
||||
// Language picks the highlighting: go, javascript, typescript, python, json, shell, sql.
|
||||
Language string
|
||||
LineNumbers bool
|
||||
// Wrap and Collapsed set the toggles' first state.
|
||||
Wrap, Collapsed bool
|
||||
}
|
||||
|
||||
// codeBlockState holds the user's toggles and the copy confirmation.
|
||||
type codeBlockState struct {
|
||||
wrap, collapsed bool
|
||||
copiedAt time.Time
|
||||
}
|
||||
|
||||
// codeBlockCopiedFor is how long the copy button shows its check.
|
||||
const codeBlockCopiedFor = 2 * time.Second
|
||||
|
||||
// CodeBlock shows code with a language label, highlighting, copy, collapse and wrap toggles.
|
||||
func CodeBlock(c *ui.Context, code string, opts CodeBlockOptions) CopyResult {
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
var r CopyResult
|
||||
r.Element = ui.Column(c).Radius(theme.CardRadius).Border(theme.BorderWidth, k.Border).Background(k.Surface).Clip().MinWidth(0)
|
||||
st := ui.Local(r.Element, "code", func() codeBlockState { return codeBlockState{wrap: opts.Wrap, collapsed: opts.Collapsed} })
|
||||
disabled := r.Element.IsDisabled()
|
||||
lines := strings.Split(strings.TrimSuffix(code, "\n"), "\n")
|
||||
r.Element.Children(func() {
|
||||
ui.Row(c).Padding(0, u, 0, u*3).Gap(u).AlignItems(ui.Center).Background(k.SurfaceHover).Children(func() {
|
||||
lang := opts.Language
|
||||
if lang == "" {
|
||||
lang = msg(c, "code_block.plain")
|
||||
}
|
||||
ui.Text(c, lang).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).SingleLine().Grow(1)
|
||||
if codeBlockToggle(c, "wrap-text", msg(c, "code_block.wrap"), &st.wrap) {
|
||||
slog.Debug("mujica: code_block wrap", "on", st.wrap)
|
||||
}
|
||||
icon, name := "copy", msg(c, "code_block.copy")
|
||||
if left := codeBlockCopiedFor - c.Now().Sub(st.copiedAt); left > 0 {
|
||||
icon, name = "check", msg(c, "code_block.copied")
|
||||
c.After(left)
|
||||
}
|
||||
if iconAction(c, icons.Must(icon), name).Clicked() && !disabled {
|
||||
c.WriteClipboard(code)
|
||||
st.copiedAt, r.copied = c.Now(), true
|
||||
c.Announce(msg(c, "code_block.copied"))
|
||||
slog.Debug("mujica: code_block copy", "lines", len(lines))
|
||||
}
|
||||
fold, foldName := "chevron-up", msg(c, "code_block.collapse")
|
||||
if st.collapsed {
|
||||
fold, foldName = "chevron-down", fmt.Sprintf(msg(c, "code_block.expand"), len(lines))
|
||||
}
|
||||
if iconAction(c, icons.Must(fold), foldName).Clicked() && !disabled {
|
||||
st.collapsed = !st.collapsed
|
||||
slog.Debug("mujica: code_block collapse", "on", st.collapsed)
|
||||
}
|
||||
})
|
||||
if st.collapsed {
|
||||
return
|
||||
}
|
||||
var body *ui.Element
|
||||
if st.wrap {
|
||||
body = ui.Column(c).Padding(u*2, u*3).MinWidth(0)
|
||||
} else {
|
||||
body = ui.ScrollHorizontal(c).Padding(u*2, u*3).AlignItems(ui.Start)
|
||||
}
|
||||
body.Font(theme.MonoFont).FontSize(FontSize(c, 13)).Children(func() {
|
||||
ui.Column(c).MinWidth(0).Grow(1).Children(func() { codeBlockLines(c, lines, opts, st.wrap) })
|
||||
})
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
// codeBlockLines builds numbered, highlighted lines.
|
||||
func codeBlockLines(c *ui.Context, lines []string, opts CodeBlockOptions, wrap bool) {
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
digits := len(fmt.Sprint(len(lines)))
|
||||
inBlock := false
|
||||
for i, line := range lines {
|
||||
var toks []internal.Token
|
||||
toks, inBlock = internal.Highlight(opts.Language, line, inBlock)
|
||||
ui.Row(c).Gap(u * 3).AlignItems(ui.Start).Children(func() {
|
||||
if opts.LineNumbers {
|
||||
ui.Text(c, fmt.Sprintf("%*d", digits, i+1)).TextColor(k.TextMuted).Shrink(0).NoWrap()
|
||||
}
|
||||
spans := make([]ui.Span, len(toks))
|
||||
for j, t := range toks {
|
||||
spans[j] = ui.Span{Text: t.Text, Color: codeBlockColor(k, t.Kind), Italic: t.Kind == internal.TokComment}
|
||||
}
|
||||
if len(spans) == 0 {
|
||||
spans = []ui.Span{{Text: " "}}
|
||||
}
|
||||
t := ui.RichText(c, spans...).MinWidth(0)
|
||||
if wrap {
|
||||
t.Grow(1)
|
||||
} else {
|
||||
t.NoWrap()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// codeBlockColor colors a token from the theme; comments are also italic.
|
||||
func codeBlockColor(k theme.Tokens, kind internal.TokenKind) ui.Color {
|
||||
switch kind {
|
||||
case internal.TokKeyword:
|
||||
return k.AccentText
|
||||
case internal.TokString:
|
||||
return k.Success
|
||||
case internal.TokNumber:
|
||||
return k.Warning
|
||||
case internal.TokComment, internal.TokPunct:
|
||||
return k.TextMuted
|
||||
case internal.TokType:
|
||||
return k.Info
|
||||
}
|
||||
return k.Text
|
||||
}
|
||||
|
||||
// codeBlockToggle is a pressed-state icon button; on shows a Selection face and frame.
|
||||
func codeBlockToggle(c *ui.Context, icon, label string, on *bool) bool {
|
||||
k := Tokens(c)
|
||||
b := ui.ToggleBase(c, on).Size(theme.MinHitSize, theme.MinHitSize).Radius(theme.ControlRadius).Label(label).TextColor(k.TextMuted)
|
||||
internal.Ring(b, k.Focus, theme.ControlRadius)
|
||||
switch {
|
||||
case *on:
|
||||
b.Background(k.Selection).Border(theme.BorderWidth, k.AccentText).TextColor(k.AccentText)
|
||||
case b.Hovered():
|
||||
b.Background(k.SurfaceHover).TextColor(k.Text)
|
||||
}
|
||||
b.Children(func() { ui.Icon(c, icons.Must(icon)).Size(16, 16) })
|
||||
return b.Changed()
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
const codeBlockSample = "// greet the court\nfunc greet(name string) int {\n\treturn len(\"hi \" + name) + 42\n}"
|
||||
|
||||
func codeBlockTester(opts CodeBlockOptions, copies *int, disabled *bool) *ui.Tester {
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Column(c).Width(360).Disabled(*disabled).Children(func() {
|
||||
if CodeBlock(c, codeBlockSample, opts).Copied() {
|
||||
*copies++
|
||||
}
|
||||
})
|
||||
}, 400, 300)
|
||||
}
|
||||
|
||||
func TestCodeBlockLabelNumbersAndCopy(t *testing.T) {
|
||||
copies, off := 0, false
|
||||
tt := codeBlockTester(CodeBlockOptions{Language: "go", LineNumbers: true}, &copies, &off)
|
||||
for _, s := range []string{"go", "1", "4", "func", "greet", "42"} {
|
||||
if !tt.HasText(s) {
|
||||
t.Errorf("missing %q in %q", s, tt.Texts())
|
||||
}
|
||||
}
|
||||
if err := tt.Click("Copy code"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tt.Clipboard() != codeBlockSample || copies != 1 {
|
||||
t.Fatalf("copied %d, clipboard matches %v", copies, tt.Clipboard() == codeBlockSample)
|
||||
}
|
||||
if _, ok := tt.Find("Copied"); !ok {
|
||||
t.Fatal("no copied confirmation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodeBlockCollapseAndWrapByKeyboard(t *testing.T) {
|
||||
copies, off := 0, false
|
||||
tt := codeBlockTester(CodeBlockOptions{Language: "go"}, &copies, &off)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeySpace)
|
||||
if !tt.Focused("Wrap lines") {
|
||||
t.Fatal("Tab did not reach the wrap toggle")
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if tt.HasText("greet") {
|
||||
t.Fatal("collapse left the code built")
|
||||
}
|
||||
if err := tt.Click("Show 4 lines"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !tt.HasText("greet") {
|
||||
t.Fatal("expand did not show the code")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodeBlockDisabledBlocksActions(t *testing.T) {
|
||||
copies, off := 0, true
|
||||
tt := codeBlockTester(CodeBlockOptions{Collapsed: true}, &copies, &off)
|
||||
_ = tt.Click("Copy code")
|
||||
_ = tt.Click("Show 4 lines")
|
||||
if copies != 0 || tt.Clipboard() != "" || tt.HasText("greet") {
|
||||
t.Fatalf("disabled block acted: copies %d", copies)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodeBlockSnapshot(t *testing.T) {
|
||||
snapshot(t, "code_block", 440, 200, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
CodeBlock(c, codeBlockSample, CodeBlockOptions{Language: "go", LineNumbers: true})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
# 201 ChatContainer 对话容器
|
||||
|
||||
## 用途
|
||||
|
||||
整页对话布局:左侧可选侧栏插槽,右侧为消息区与底部输入区。消息区就是 MessageList:新消息到来时自动滚到底部,用户向上翻看时停止跟随,并在底部显示回到最新按钮。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
mujica.ChatContainer(c, &chat, len(msgs), mujica.ChatContainerOptions{
|
||||
List: mujica.MessageListOptions{ID: func(i int) string { return msgs[i] }},
|
||||
Input: func() { ui.Text(c, "Composer goes here") },
|
||||
}, func(i int) { ui.Text(c, msgs[i]) })
|
||||
```
|
||||
|
||||
签名:`func ChatContainer(c *ui.Context, s *MessageListState, n int, opts ChatContainerOptions, row func(i int)) MessageListResult`
|
||||
|
||||
## 参数
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `s` | `*MessageListState` | 必填 | 消息区的位置与已读状态,由调用方持有。 |
|
||||
| `n / row` | `int / func(i int)` | 必填 | 消息数量与第 i 条消息的构建函数;只构建可见消息。 |
|
||||
| `List` | `MessageListOptions` | `ID` 必填 | 传给消息列表的选项,见 202。 |
|
||||
| `Input` | `func()` | nil | 输入区插槽,位于消息区下方,带上边线。 |
|
||||
| `Sidebar` | `func()` | nil | 侧栏插槽;nil 时不显示侧栏。 |
|
||||
| `SidebarWidth` | `float32` | 260 | 侧栏宽度 DIP;负数 panic。 |
|
||||
|
||||
## 状态
|
||||
|
||||
消息区跟随底部 / 用户上翻后停止跟随;离开底部时出现带未读数的回到最新按钮。
|
||||
|
||||
## 事件
|
||||
|
||||
返回的 `MessageListResult.Unread()` 给出离开底部后新到的消息数。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
Tab 在侧栏、消息与输入区之间移动;消息区获得焦点后滚轮与 PageUp/PageDown(Linux、Windows)滚动。
|
||||
|
||||
## 限制
|
||||
|
||||
- 侧栏宽度固定,不提供拖动调整;需要时用 SplitPane(008)包裹。
|
||||
@@ -0,0 +1,42 @@
|
||||
# 202 MessageList 消息列表
|
||||
|
||||
## 用途
|
||||
|
||||
虚拟化的消息列表,基于 MyGo `ui.List`:只构建可见消息,以稳定消息 ID 保持位置;从底部开始并跟随新消息,用户上翻后停止跟随,并显示新消息提示;可按日期插入分隔。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
r := mujica.MessageList(c, &list, len(ids), mujica.MessageListOptions{
|
||||
ID: func(i int) string { return ids[i] },
|
||||
}, func(i int) { ui.Text(c, "Message "+ids[i]) })
|
||||
fmt.Println("unread", r.Unread())
|
||||
```
|
||||
|
||||
签名:`func MessageList(c *ui.Context, s *MessageListState, n int, opts MessageListOptions, row func(i int)) MessageListResult`
|
||||
|
||||
## 参数
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `ID` | `func(i int) string` | 必填 | 消息的稳定 ID;在上方插入旧消息时视野中的消息保持不动。ID 须唯一。 |
|
||||
| `Date` | `func(i int) time.Time` | nil | 设置后在每个新日期的第一条消息前显示“今天 / 昨天 / 日期”分隔。 |
|
||||
| `Label` | `func(i int) string` | nil | 消息的无障碍名称,也用于键入定位。 |
|
||||
| `n` | `int` | 必填 | 消息数;负数 panic。 |
|
||||
|
||||
## 状态
|
||||
|
||||
跟随底部;离开底部时底部居中出现 ScrollToBottomButton(含未读数);点击后回到底部并清零。
|
||||
|
||||
## 事件
|
||||
|
||||
`Unread()`:离开底部后新增的消息数。`MessageListState.ScrollToEnd()` 与 `AtEnd()` 供调用方控制。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
列表获得焦点后,滚轮、方向键与 PageUp/PageDown 滚动;回到最新按钮可用 Tab 聚焦,Enter 或 Space 触发。
|
||||
|
||||
## 限制
|
||||
|
||||
- 不检查 ID 是否重复(逐帧检查 10 万条代价过高);重复 ID 会让两条消息共享状态。
|
||||
- 消息高度由 MyGo 在显示时测量,未显示消息按已测高度估算,长距离跳转时滚动条可能微调。
|
||||
@@ -0,0 +1,41 @@
|
||||
# 203 MessageBubble 消息气泡
|
||||
|
||||
## 用途
|
||||
|
||||
按角色框住一条消息:用户靠右、Selection 底色;助手靠左、Surface 底与细边;系统居中、灰色小字;错误带 Danger 边框与警示图标。头像与名称为插槽,宽度可限制。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
mujica.MessageBubble(c, mujica.MessageAssistant, mujica.MessageBubbleOptions{Name: "Herald"}, func() {
|
||||
ui.Text(c, "The ball begins at nine.")
|
||||
})
|
||||
```
|
||||
|
||||
签名:`func MessageBubble(c *ui.Context, role MessageRole, opts MessageBubbleOptions, content func()) *ui.Element`
|
||||
|
||||
## 参数
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `role` | `MessageRole` | 必填 | `MessageUser`、`MessageAssistant`、`MessageSystem`、`MessageError`。 |
|
||||
| `Name` | `string` | "" | 显示在气泡上方的名称。 |
|
||||
| `Avatar` | `func()` | nil | 头像插槽,用户在右侧、其他角色在左侧;系统消息不显示。 |
|
||||
| `MaxWidth` | `float32` | 560 | 气泡最大宽度 DIP;负数 panic。 |
|
||||
| `content` | `func()` | 必填 | 气泡内容,如 MarkdownView 或 StreamingText。 |
|
||||
|
||||
## 状态
|
||||
|
||||
四种角色外观;错误角色同时使用图标、边框与文字,不只依赖颜色。
|
||||
|
||||
## 事件
|
||||
|
||||
无;交互由内容与 MessageActions 提供。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
气泡本身不获取焦点;内容中的链接、按钮按 Tab 顺序访问。辅助技术读到“角色,名称”分组。
|
||||
|
||||
## 限制
|
||||
|
||||
- 气泡不自带操作栏;把 MessageActions 放在 content 中或气泡下方。
|
||||
@@ -0,0 +1,37 @@
|
||||
# 204 MessageHeader 消息头
|
||||
|
||||
## 用途
|
||||
|
||||
在消息上方显示模型名、时间与状态(发送中、生成中、已发送、失败),状态同时用图标和文字表达。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
mujica.MessageHeader(c, mujica.MessageHeaderOptions{Model: "herald-2", Time: time.Now(), Status: mujica.MessageSent})
|
||||
```
|
||||
|
||||
签名:`func MessageHeader(c *ui.Context, opts MessageHeaderOptions) *ui.Element`
|
||||
|
||||
## 参数
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Model` | `string` | "" | 模型名,加粗;空则不显示。 |
|
||||
| `Time` | `time.Time` | 零值 | 时间,按 `15:04` 显示;零值不显示。 |
|
||||
| `Status` | `MessageStatus` | `MessageNoStatus` | `MessageSending`、`MessageStreaming`、`MessageSent`、`MessageFailed`;未知值 panic。 |
|
||||
|
||||
## 状态
|
||||
|
||||
生成中显示菱形加载标记(减少动画时静止);失败用 Danger 色并带警示图标。
|
||||
|
||||
## 事件
|
||||
|
||||
无。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
不获取焦点。
|
||||
|
||||
## 限制
|
||||
|
||||
- 时间格式固定为 24 小时制时分,日期由 MessageList 的日期分隔显示。
|
||||
@@ -0,0 +1,38 @@
|
||||
# 205 StreamingText 流式文本
|
||||
|
||||
## 用途
|
||||
|
||||
显示随流式输出不断增长的文本。可平滑出字(按速率逐字显示),可显示流式光标;减少动画时直接显示全部文本。文本被替换为不以旧文本开头的内容时,从共同前缀继续。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
mujica.StreamingText(c, reply, mujica.StreamingTextOptions{Streaming: true, Smooth: true})
|
||||
```
|
||||
|
||||
签名:`func StreamingText(c *ui.Context, text string, opts StreamingTextOptions) *ui.Element`
|
||||
|
||||
## 参数
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `text` | `string` | 必填 | 当前收到的全部文本。 |
|
||||
| `Streaming` | `bool` | false | 在文本末尾显示 AccentText 色的静态光标 ▍。 |
|
||||
| `Smooth` | `bool` | false | 新到的文本按 Rate 逐字显示;关闭时立即显示。 |
|
||||
| `Rate` | `float64` | 80 | 平滑出字速度,字符/秒;负数 panic。 |
|
||||
|
||||
## 状态
|
||||
|
||||
出字中 / 完成;减少动画时始终为完成。光标不闪烁,避免装饰性循环。
|
||||
|
||||
## 事件
|
||||
|
||||
无。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
不获取焦点。辅助技术读到的名称始终是完整文本。
|
||||
|
||||
## 限制
|
||||
|
||||
- 只渲染纯文本;Markdown 内容请交给 MarkdownView(206),它的增量解析同样适合流式输出。
|
||||
@@ -0,0 +1,39 @@
|
||||
# 206 MarkdownView Markdown 视图
|
||||
|
||||
## 用途
|
||||
|
||||
渲染 Markdown 子集:标题、段落、粗体、斜体、删除线、行内代码、有序与无序列表(含缩进层级)、引用、链接、围栏代码块(交给 CodeBlock)、表格(交给 TableBlock)与分隔线(菱形饰线)。解析器在 `internal/markdown.go`,按块增量解析:新文本只是在旧文本后追加时,只重新解析最后一块,已完成的块保持不变。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
if url, ok := mujica.MarkdownView(c, "# Decree\n\nSee **the guide** at [the court](https://example.com).").Link(); ok {
|
||||
fmt.Println("open", url)
|
||||
}
|
||||
```
|
||||
|
||||
签名:`func MarkdownView(c *ui.Context, src string) MarkdownResult`
|
||||
|
||||
## 参数
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `src` | `string` | 必填 | Markdown 源文本;流式输出时每帧传入增长后的全文即可。 |
|
||||
|
||||
## 状态
|
||||
|
||||
围栏未闭合的代码块按已收到的内容显示;未闭合的强调标记按字面显示,闭合后再加样式。
|
||||
|
||||
## 事件
|
||||
|
||||
`Link()` 返回本帧被点击或用 Enter 激活的链接地址;打开链接由调用方决定。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
链接可用 Tab 聚焦,Enter 激活;代码块与表格的按钮按 Tab 顺序访问。
|
||||
|
||||
## 限制
|
||||
|
||||
- 只支持上述子集:不支持 HTML、图片、脚注、任务列表、Setext 标题、链接引用定义,列表项内不支持多段落。
|
||||
- 表格单元格按纯文本显示,不解析行内样式。
|
||||
- 源文本被整体替换(不是在末尾追加)时重新解析全部块。
|
||||
@@ -0,0 +1,39 @@
|
||||
# 207 CodeBlock 代码块
|
||||
|
||||
## 用途
|
||||
|
||||
显示代码:语言标签、可选行号、基础语法着色(`internal.Highlight`,支持 go、javascript、typescript、python、json、shell、sql),复制、折叠与自动换行切换。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
mujica.CodeBlock(c, "func bow() {}", mujica.CodeBlockOptions{Language: "go", LineNumbers: true})
|
||||
```
|
||||
|
||||
签名:`func CodeBlock(c *ui.Context, code string, opts CodeBlockOptions) CopyResult`
|
||||
|
||||
## 参数
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Language` | `string` | ""(显示“文本”) | 着色语言;未知语言按纯文本显示。 |
|
||||
| `LineNumbers` | `bool` | false | 显示右对齐的行号。 |
|
||||
| `Wrap` | `bool` | false | 自动换行的初始状态;关闭时横向滚动。 |
|
||||
| `Collapsed` | `bool` | false | 折叠的初始状态。 |
|
||||
|
||||
## 状态
|
||||
|
||||
换行开关以 Selection 底色加边框表示开启;复制后按钮在 2 秒内显示对勾“已复制”;折叠时只显示标题栏,展开按钮写明行数。关键字、字符串、数字、类型使用不同颜色,注释同时使用斜体。
|
||||
|
||||
## 事件
|
||||
|
||||
`Copied()`:本帧把代码写入了剪贴板。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
Tab 依次到换行开关、复制、折叠按钮;Space 或 Enter 触发。
|
||||
|
||||
## 限制
|
||||
|
||||
- 着色是逐行的词法着色,不做语法分析;块注释可跨行。
|
||||
- 不做虚拟化,适合消息中的代码片段,不适合上万行文件。
|
||||
@@ -0,0 +1,39 @@
|
||||
# 208 TableBlock 表格块
|
||||
|
||||
## 用途
|
||||
|
||||
在消息中显示小型表格:表头加粗,列按内容宽度排列并铺满消息宽度,过宽时横向滚动;可复制为 TSV。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
if mujica.TableBlock(c, []string{"Guest", "Seat"}, [][]string{{"Marie", "1"}}, mujica.TableBlockOptions{}).Copied() {
|
||||
fmt.Println("copied as TSV")
|
||||
}
|
||||
```
|
||||
|
||||
签名:`func TableBlock(c *ui.Context, header []string, rows [][]string, opts TableBlockOptions) CopyResult`
|
||||
|
||||
## 参数
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `header` | `[]string` | 必填 | 表头;为空 panic。 |
|
||||
| `rows` | `[][]string` | nil | 数据行;每行列数必须与表头相同,否则 panic。 |
|
||||
| `Align` | `[]ui.Align` | 全部 `ui.Start` | 各列对齐:`ui.Start`、`ui.Center`、`ui.End`;多于列数 panic。 |
|
||||
|
||||
## 状态
|
||||
|
||||
复制后按钮 2 秒内显示对勾。
|
||||
|
||||
## 事件
|
||||
|
||||
`Copied()`:本帧写入了 TSV(制表符分列、换行分行;单元格内的制表符与换行替换为空格)。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
Tab 到复制按钮,Enter 或 Space 复制。
|
||||
|
||||
## 限制
|
||||
|
||||
- 不排序、不选择;需要这些功能请使用 DataTable(069 起)。
|
||||
@@ -0,0 +1,40 @@
|
||||
# 209 ImageGrid 图片网格
|
||||
|
||||
## 用途
|
||||
|
||||
在消息中排列 1 至 9 张图片:1 张大图,2 与 4 张两列,其余三列。点击已加载的图片打开 ImageViewer;加载中与失败状态复用 Image 组件,失败时可重试。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
if i, ok := mujica.ImageGrid(c, []mujica.GridImage{{Source: pic, Alt: "Portrait"}}).Retried(); ok {
|
||||
fmt.Println("reload", i)
|
||||
}
|
||||
```
|
||||
|
||||
签名:`func ImageGrid(c *ui.Context, images []GridImage) ImageGridResult`
|
||||
|
||||
## 参数
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `images` | `[]GridImage` | 必填 | 1 至 9 张,否则 panic。 |
|
||||
| `Source` | `ui.ImageSource` | nil | 已加载的位图或 SVG。 |
|
||||
| `Alt` | `string` | "" | 替代说明,也是按钮名称与查看器标题。 |
|
||||
| `Loading / Err` | `bool / error` | false / nil | 调用方的加载中与失败状态。 |
|
||||
|
||||
## 状态
|
||||
|
||||
已加载、加载中(菱形加载标记)、失败(重试按钮);只有已加载的图片可点击。
|
||||
|
||||
## 事件
|
||||
|
||||
`Retried()` 返回本帧点击了重试的图片下标。查看器在组件内部打开与关闭。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
Tab 聚焦图片,Enter 或 Space 打开查看器;查看器内的键盘操作见 ImageViewer。
|
||||
|
||||
## 限制
|
||||
|
||||
- 查看器只包含已加载的图片,序号按已加载图片计算。
|
||||
@@ -0,0 +1,40 @@
|
||||
# 210 FileMessage 文件消息
|
||||
|
||||
## 用途
|
||||
|
||||
显示消息中的文件:按类型的图标、文件名、大小,以及打开与下载回调。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
r := mujica.FileMessage(c, mujica.FileMessageOptions{Name: "treaty.pdf", Size: 2621440})
|
||||
if r.Downloaded() {
|
||||
fmt.Println("save treaty.pdf")
|
||||
}
|
||||
```
|
||||
|
||||
签名:`func FileMessage(c *ui.Context, opts FileMessageOptions) FileMessageResult`
|
||||
|
||||
## 参数
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Name` | `string` | 必填 | 文件名;为空 panic。 |
|
||||
| `Size` | `int64` | 0 | 字节数,按 B、KB、MB、GB 显示(1024 进制);负数 panic。 |
|
||||
| `Type` | `string` | "" | MIME 类型;为空时按扩展名选择图标。 |
|
||||
|
||||
## 状态
|
||||
|
||||
悬停时文件区域加深;禁用时两个操作都不响应。
|
||||
|
||||
## 事件
|
||||
|
||||
`Opened()`:点击或在文件区按 Enter。`Downloaded()`:点击下载按钮。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
Tab 依次到文件区与下载按钮,Enter 或 Space 触发。
|
||||
|
||||
## 限制
|
||||
|
||||
- 组件不读取文件、不下载:打开与下载由调用方完成。
|
||||
@@ -0,0 +1,41 @@
|
||||
# 211 AudioMessage 语音消息
|
||||
|
||||
## 用途
|
||||
|
||||
语音条:播放/暂停按钮、波形、播放进度与时长。播放由调用方驱动:组件只报告播放切换与跳转请求,调用方更新 Position 与 Playing。波形基于 MyGo `ui.SliderBase`,可拖动与键盘跳转。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
r := mujica.AudioMessage(c, mujica.AudioMessageOptions{Duration: time.Minute, Position: pos})
|
||||
if d, ok := r.Seek(); ok {
|
||||
pos = d
|
||||
}
|
||||
```
|
||||
|
||||
签名:`func AudioMessage(c *ui.Context, opts AudioMessageOptions) AudioMessageResult`
|
||||
|
||||
## 参数
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Duration` | `time.Duration` | 必填 | 时长,必须大于 0。 |
|
||||
| `Position` | `time.Duration` | 0 | 当前进度,0 到 Duration 之间,否则 panic。 |
|
||||
| `Playing` | `bool` | false | 按钮显示暂停(播放中)或播放。 |
|
||||
| `Waveform` | `[]float32` | nil | 0 到 1 的响度采样,按宽度重采样为竖条;为空画平线;越界 panic。 |
|
||||
|
||||
## 状态
|
||||
|
||||
已播放的竖条为 AccentText,未播放为半透明 TextMuted,当前位置另有一条竖线,进度不只靠颜色表达;右侧显示 `m:ss / m:ss`。
|
||||
|
||||
## 事件
|
||||
|
||||
`Toggled()`:按下播放或暂停。`Seek()`:点击、拖动或按键请求的新位置。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
Tab 到播放按钮(Enter 或 Space)与波形;波形上 Left/Right(Up/Down)按时长的 1% 移动,Home、End 到开头与结尾。
|
||||
|
||||
## 限制
|
||||
|
||||
- 不解码音频、不生成波形:采样由调用方提供。
|
||||
@@ -0,0 +1,42 @@
|
||||
# 212 LinkPreviewCard 链接预览卡片
|
||||
|
||||
## 用途
|
||||
|
||||
显示链接的站点、标题、描述与缩略图;元数据由调用方获取。整张卡片是一个按钮,悬停显示完整地址。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
p := mujica.LinkPreview{URL: "https://example.com", Title: "The Palace", Site: "example.com"}
|
||||
if mujica.LinkPreviewCard(c, p).Clicked() {
|
||||
fmt.Println("open", p.URL)
|
||||
}
|
||||
```
|
||||
|
||||
签名:`func LinkPreviewCard(c *ui.Context, p LinkPreview) *ui.Element`
|
||||
|
||||
## 参数
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `URL` | `string` | 必填 | 链接地址,作为悬停提示。 |
|
||||
| `Title` | `string` | 必填 | 标题,最多两行,AccentText 色。 |
|
||||
| `Description` | `string` | "" | 描述,最多两行。 |
|
||||
| `Site` | `string` | URL | 站点名;为空时显示 URL。 |
|
||||
| `Thumbnail` | `ui.ImageSource` | nil | 缩略图,64×64 裁切显示。 |
|
||||
|
||||
## 状态
|
||||
|
||||
悬停时底色加深;聚焦时显示焦点环。
|
||||
|
||||
## 事件
|
||||
|
||||
`Clicked()`:点击或 Enter、Space;打开链接由调用方完成。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
Tab 聚焦,Enter 或 Space 触发。
|
||||
|
||||
## 限制
|
||||
|
||||
- 组件不联网:标题、描述与缩略图都由调用方提供。
|
||||
@@ -0,0 +1,38 @@
|
||||
# 213 QuoteReply 引用回复
|
||||
|
||||
## 用途
|
||||
|
||||
显示被引用消息的作者与单行摘要(多余空白折叠,过长省略);点击跳转到原消息。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
if mujica.QuoteReply(c, "Marie", "Let them eat cake.").Clicked() {
|
||||
fmt.Println("jump to the quoted message")
|
||||
}
|
||||
```
|
||||
|
||||
签名:`func QuoteReply(c *ui.Context, author, excerpt string) *ui.Element`
|
||||
|
||||
## 参数
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `author` | `string` | 必填 | 原消息作者;为空 panic。 |
|
||||
| `excerpt` | `string` | "" | 原消息摘要;换行与连续空白合并为一个空格。 |
|
||||
|
||||
## 状态
|
||||
|
||||
左侧 3 DIP AccentText 竖线标明引用;悬停时底色变为 Selection。
|
||||
|
||||
## 事件
|
||||
|
||||
`Clicked()`:点击或 Enter、Space;跳转由调用方完成(如 MessageList 的 ScrollTo)。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
Tab 聚焦,Enter 或 Space 触发。辅助技术读到“回复 作者:摘要”。
|
||||
|
||||
## 限制
|
||||
|
||||
- 只显示一行摘要,不渲染原消息的格式。
|
||||
@@ -0,0 +1,38 @@
|
||||
# 214 MessageActions 消息操作
|
||||
|
||||
## 用途
|
||||
|
||||
消息下方的操作工具栏:复制、重新生成、编辑、赞、踩、朗读、分享,按角色显示:用户消息为复制、编辑;助手消息为复制、重新生成、赞、踩、朗读、分享;系统消息为复制;错误消息为复制、重新生成。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
if a, ok := mujica.MessageActions(c, mujica.MessageAssistant, mujica.MessageActionsOptions{Feedback: &rating}).Action(); ok {
|
||||
fmt.Println("action", a)
|
||||
}
|
||||
```
|
||||
|
||||
签名:`func MessageActions(c *ui.Context, role MessageRole, opts MessageActionsOptions) MessageActionsResult`
|
||||
|
||||
## 参数
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `role` | `MessageRole` | 必填 | 决定显示哪些操作。 |
|
||||
| `Feedback` | `*MessageFeedback` | nil | 赞踩状态;nil 时不显示赞与踩。再次点击已选的赞或踩会取消。 |
|
||||
|
||||
## 状态
|
||||
|
||||
赞或踩选中时为按下状态:Selection 底色、AccentText 边框与图标;辅助技术读到切换按钮的按下状态。
|
||||
|
||||
## 事件
|
||||
|
||||
`Action()` 返回本帧的操作:`ActionCopy`、`ActionRegenerate`、`ActionEdit`、`ActionLike`、`ActionDislike`、`ActionSpeak`、`ActionShare`。复制、朗读与分享由调用方执行。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
整个工具栏是一个 Tab 停靠点,Left/Right 在操作间移动,Home、End 到首末;Enter 或 Space 触发。
|
||||
|
||||
## 限制
|
||||
|
||||
- 不内置复制内容与朗读:调用方收到操作后自行处理。
|
||||
@@ -0,0 +1,38 @@
|
||||
# 215 MessageEditor 消息编辑器
|
||||
|
||||
## 用途
|
||||
|
||||
编辑已发送的消息:多行文本框加取消与保存。保存时调用方据草稿产生新分支(配合 BranchNavigator)。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
if mujica.MessageEditor(c, &draft, mujica.MessageEditorOptions{Original: "Hello"}).Saved() {
|
||||
fmt.Println("new branch from", draft)
|
||||
}
|
||||
```
|
||||
|
||||
签名:`func MessageEditor(c *ui.Context, draft *string, opts MessageEditorOptions) MessageEditorResult`
|
||||
|
||||
## 参数
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `draft` | `*string` | 必填 | 正在编辑的文本。 |
|
||||
| `Original` | `string` | "" | 原文;草稿与原文相同或只有空白时不能保存。 |
|
||||
|
||||
## 状态
|
||||
|
||||
保存按钮在内容无效时禁用;文本框保留输入法、撤销与剪贴板行为。
|
||||
|
||||
## 事件
|
||||
|
||||
`Saved()`:点击保存或 Cmd/Ctrl+Enter。`Canceled()`:点击取消或在文本框中按 Escape。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
Cmd/Ctrl+Enter 保存,Escape 取消;Tab 依次到文本框、取消、保存。
|
||||
|
||||
## 限制
|
||||
|
||||
- 不保存历史:分支与原文由调用方保存。
|
||||
@@ -0,0 +1,38 @@
|
||||
# 216 BranchNavigator 分支切换
|
||||
|
||||
## 用途
|
||||
|
||||
在一条消息的多个分支间切换,显示“< 2 / 3 >”。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
if mujica.BranchNavigator(c, &branch, 3).Changed() {
|
||||
fmt.Println("show branch", branch)
|
||||
}
|
||||
```
|
||||
|
||||
签名:`func BranchNavigator(c *ui.Context, index *int, count int) ChoiceResult`
|
||||
|
||||
## 参数
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `index` | `*int` | 必填 | 当前分支,0 起;不在 0 到 count−1 之间时 panic。 |
|
||||
| `count` | `int` | 必填 | 分支数,至少 1。 |
|
||||
|
||||
## 状态
|
||||
|
||||
首末分支时对应箭头禁用;切换时向辅助技术播报“第 n 个分支,共 m 个”。
|
||||
|
||||
## 事件
|
||||
|
||||
`Changed()`:分支改变。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
整个控件是一个 Tab 停靠点:Left/Right 切换,Home、End 到第一个与最后一个;箭头按钮只响应指针。
|
||||
|
||||
## 限制
|
||||
|
||||
- 箭头按钮不进入 Tab 序列,避免到达首末分支时焦点停在禁用按钮上。
|
||||
@@ -0,0 +1,40 @@
|
||||
# 217 ErrorMessage 错误消息
|
||||
|
||||
## 用途
|
||||
|
||||
显示失败的回复:标题、原始错误文本(等宽、可选择复制,不改写、不截断)与重试按钮;限流时显示倒计时,倒计时结束前不能重试。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
opts := mujica.ErrorMessageOptions{RetryAt: time.Now().Add(30 * time.Second)}
|
||||
if mujica.ErrorMessage(c, "429 Too Many Requests", opts).Retried() {
|
||||
fmt.Println("send again")
|
||||
}
|
||||
```
|
||||
|
||||
签名:`func ErrorMessage(c *ui.Context, text string, opts ErrorMessageOptions) ErrorMessageResult`
|
||||
|
||||
## 参数
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `text` | `string` | 必填 | 原始错误文本,原样显示;为空 panic。 |
|
||||
| `Title` | `string` | “出错了” | 概括失败原因。 |
|
||||
| `RetryAt` | `time.Time` | 零值 | 限流解除时间;之前重试按钮禁用并显示“n 秒后重试”与“已限流”。 |
|
||||
|
||||
## 状态
|
||||
|
||||
可重试 / 限流倒计时;Danger 边框、图标与标题文字共同表示错误。倒计时只在秒数变化时重绘。
|
||||
|
||||
## 事件
|
||||
|
||||
`Retried()`:按下重试。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
Tab 到错误文本(可用 Shift+方向键选择、Cmd/Ctrl+C 复制)与重试按钮。
|
||||
|
||||
## 限制
|
||||
|
||||
- 倒计时依据窗口时钟;调用方的 RetryAt 应来自服务端的 Retry-After。
|
||||
@@ -0,0 +1,40 @@
|
||||
# 218 ThinkingBlock 思考过程
|
||||
|
||||
## 用途
|
||||
|
||||
可折叠的思考过程:思考中显示加载标记与实时秒数,结束后显示“思考了 n 秒”。默认折叠或展开由调用方传入的 `*open` 初值决定。基于 Collapsible。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
mujica.ThinkingBlock(c, &open, mujica.ThinkingBlockOptions{Elapsed: 12 * time.Second}, func() {
|
||||
ui.Text(c, "Weigh the guest list.")
|
||||
})
|
||||
```
|
||||
|
||||
签名:`func ThinkingBlock(c *ui.Context, open *bool, opts ThinkingBlockOptions, content func()) ChoiceResult`
|
||||
|
||||
## 参数
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `open` | `*bool` | 必填 | 是否展开;其初值即默认状态。 |
|
||||
| `Thinking` | `bool` | false | 思考中:显示加载标记与从 Started 起的秒数。 |
|
||||
| `Started` | `time.Time` | 零值 | 思考开始时间;Thinking 时必填,否则 panic。 |
|
||||
| `Elapsed` | `time.Duration` | 0 | 思考结束后的总耗时;负数 panic。 |
|
||||
|
||||
## 状态
|
||||
|
||||
思考中 / 已完成;展开时内容带左侧细线、TextMuted 色。减少动画时加载标记静止,秒数仍每秒更新。
|
||||
|
||||
## 事件
|
||||
|
||||
`Changed()`:展开状态改变。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
Tab 聚焦标题,Enter 或 Space 展开与折叠。
|
||||
|
||||
## 限制
|
||||
|
||||
- 思考中每秒重绘一次以更新秒数;折叠且完成后不再重绘。
|
||||
@@ -0,0 +1,40 @@
|
||||
# 219 ScrollToBottomButton 回到底部按钮
|
||||
|
||||
## 用途
|
||||
|
||||
离开底部时出现在容器底部居中的圆角按钮,带未读计数;点击回到底部。MessageList 已内置,也可单独用于其他滚动区域。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
ui.Box(c).Fill().Children(func() {
|
||||
if mujica.ScrollToBottomButton(c, !list.AtEnd(), 3).Clicked() {
|
||||
list.ScrollToEnd()
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
签名:`func ScrollToBottomButton(c *ui.Context, away bool, unread int) *ui.Element`
|
||||
|
||||
## 参数
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `away` | `bool` | 必填 | 是否已离开底部;false 时返回不可见的空元素,其 Clicked 为 false。 |
|
||||
| `unread` | `int` | 0 | 未读数,大于 0 时显示;负数 panic。 |
|
||||
|
||||
## 状态
|
||||
|
||||
出现时 160 毫秒淡入(减少动画时直接显示);悬停时底色加深。
|
||||
|
||||
## 事件
|
||||
|
||||
`Clicked()`:点击或 Enter、Space;滚动由调用方完成。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
Tab 聚焦,Enter 或 Space 触发。辅助技术读到“回到最新,n 条新消息”。
|
||||
|
||||
## 限制
|
||||
|
||||
- 按钮以绝对定位贴在父元素底部,父元素需要占满滚动区域。
|
||||
@@ -0,0 +1,35 @@
|
||||
# 220 TypingIndicator 正在输入
|
||||
|
||||
## 用途
|
||||
|
||||
对方正在输入的提示:三个依次明暗变化的圆点;减少动画时改为静态文字“某某 正在输入…”。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
mujica.TypingIndicator(c, "Herald")
|
||||
```
|
||||
|
||||
签名:`func TypingIndicator(c *ui.Context, name string) *ui.Element`
|
||||
|
||||
## 参数
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `name` | `string` | 必填 | 正在输入的一方;为空 panic。 |
|
||||
|
||||
## 状态
|
||||
|
||||
动画 / 静态文字;状态角色,辅助技术读到“某某 正在输入…”。
|
||||
|
||||
## 事件
|
||||
|
||||
无。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
不获取焦点。
|
||||
|
||||
## 限制
|
||||
|
||||
- 动画只在显示时请求下一帧,移出界面后停止重绘。
|
||||
@@ -0,0 +1,72 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// ErrorMessageOptions configure an ErrorMessage.
|
||||
type ErrorMessageOptions struct {
|
||||
// Title sums the failure up; empty uses the built-in "Something went wrong".
|
||||
Title string
|
||||
// RetryAt is when a rate limit lifts; until then retry waits with a countdown.
|
||||
RetryAt time.Time
|
||||
}
|
||||
|
||||
// ErrorMessageResult is the error's element and a retry the user asked for.
|
||||
type ErrorMessageResult struct {
|
||||
Element *ui.Element
|
||||
retried bool
|
||||
}
|
||||
|
||||
// Retried reports a press of retry.
|
||||
func (r ErrorMessageResult) Retried() bool { return r.retried }
|
||||
|
||||
// ErrorMessage shows a failed reply with the original error text, selectable, and retry.
|
||||
func ErrorMessage(c *ui.Context, text string, opts ErrorMessageOptions) ErrorMessageResult {
|
||||
if text == "" {
|
||||
panic("mujica: ErrorMessage needs the error text")
|
||||
}
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
title := opts.Title
|
||||
if title == "" {
|
||||
title = msg(c, "error_message.title")
|
||||
}
|
||||
wait := opts.RetryAt.Sub(c.Now())
|
||||
var r ErrorMessageResult
|
||||
r.Element = ui.Column(c).Gap(u*2).Padding(u*3, u*4).MaxWidth(560).Radius(theme.CardRadius).
|
||||
Background(k.Surface).Border(theme.BorderWidth, k.Danger).Role(ui.RoleGroup).Label(title)
|
||||
disabled := r.Element.IsDisabled()
|
||||
r.Element.Children(func() {
|
||||
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
|
||||
ui.Icon(c, icons.Must("circle-alert")).Size(16, 16).Shrink(0).TextColor(k.Danger)
|
||||
ui.Text(c, title).FontWeight(600).TextColor(k.Danger)
|
||||
})
|
||||
ui.Text(c, text).Selectable().Font(theme.MonoFont).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.Text)
|
||||
ui.Row(c).Gap(u * 3).AlignItems(ui.Center).Children(func() {
|
||||
label := msg(c, "error_message.retry")
|
||||
if wait > 0 {
|
||||
secs := int(math.Ceil(wait.Seconds()))
|
||||
label = fmt.Sprintf(msg(c, "error_message.wait"), secs)
|
||||
// Wake when the shown second changes.
|
||||
c.After(wait - time.Duration(secs-1)*time.Second)
|
||||
}
|
||||
if Button(c, label, ButtonOptions{Variant: Secondary, Icon: icons.Must("refresh-cw"), Disabled: wait > 0}).Clicked() && !disabled {
|
||||
r.retried = true
|
||||
slog.Debug("mujica: error_message retry")
|
||||
}
|
||||
if wait > 0 {
|
||||
ui.Text(c, msg(c, "error_message.limited")).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
|
||||
}
|
||||
})
|
||||
})
|
||||
return r
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
const errorMessageText = `429 Too Many Requests: {"error":"rate_limit","retry_after":30}`
|
||||
|
||||
func TestErrorMessageKeepsTextAndRetries(t *testing.T) {
|
||||
retries := 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
if ErrorMessage(c, errorMessageText, ErrorMessageOptions{}).Retried() {
|
||||
retries++
|
||||
}
|
||||
}, 500, 200)
|
||||
if !tt.HasText(errorMessageText) || !tt.HasText("Something went wrong") {
|
||||
t.Fatalf("texts %q", tt.Texts())
|
||||
}
|
||||
_ = tt.Click("Retry")
|
||||
if retries != 1 {
|
||||
t.Fatalf("retries %d", retries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorMessageRateLimitCountdown(t *testing.T) {
|
||||
retries := 0
|
||||
until := time.Now().Add(2500 * time.Millisecond)
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
if ErrorMessage(c, errorMessageText, ErrorMessageOptions{Title: "Rate limited by the API", RetryAt: until}).Retried() {
|
||||
retries++
|
||||
}
|
||||
}, 500, 200)
|
||||
if !tt.HasText("Retry in 3 s") {
|
||||
t.Fatalf("countdown missing: %q", tt.Texts())
|
||||
}
|
||||
_ = tt.Click("Retry in 3 s")
|
||||
if retries != 0 {
|
||||
t.Fatal("retried during the rate limit")
|
||||
}
|
||||
until = time.Now().Add(-time.Second)
|
||||
tt.Frame()
|
||||
_ = tt.Click("Retry")
|
||||
if retries != 1 {
|
||||
t.Fatalf("retries %d after the limit lifted", retries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorMessageNeedsText(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic without error text")
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ErrorMessage(c, "", ErrorMessageOptions{})
|
||||
}, 100, 100)
|
||||
}
|
||||
|
||||
func TestErrorMessageSnapshot(t *testing.T) {
|
||||
snapshot(t, "error_message", 520, 150, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Children(func() { ErrorMessage(c, errorMessageText, ErrorMessageOptions{}) })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package mujica_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
mujica "mujicaui"
|
||||
)
|
||||
|
||||
func ExampleChatContainer() {
|
||||
var chat mujica.MessageListState
|
||||
msgs := []string{"Hello", "Bonjour"}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.ChatContainer(c, &chat, len(msgs), mujica.ChatContainerOptions{
|
||||
List: mujica.MessageListOptions{ID: func(i int) string { return msgs[i] }},
|
||||
Input: func() { ui.Text(c, "Composer goes here") },
|
||||
}, func(i int) { ui.Text(c, msgs[i]) })
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleMessageList() {
|
||||
var list mujica.MessageListState
|
||||
ids := []string{"m1", "m2", "m3"}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
r := mujica.MessageList(c, &list, len(ids), mujica.MessageListOptions{
|
||||
ID: func(i int) string { return ids[i] },
|
||||
}, func(i int) { ui.Text(c, "Message "+ids[i]) })
|
||||
fmt.Println("unread", r.Unread())
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleMessageBubble() {
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.MessageBubble(c, mujica.MessageAssistant, mujica.MessageBubbleOptions{Name: "Herald"}, func() {
|
||||
ui.Text(c, "The ball begins at nine.")
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleMessageHeader() {
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.MessageHeader(c, mujica.MessageHeaderOptions{Model: "herald-2", Time: time.Now(), Status: mujica.MessageSent})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleStreamingText() {
|
||||
reply := "The decree reads"
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.StreamingText(c, reply, mujica.StreamingTextOptions{Streaming: true, Smooth: true})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleMarkdownView() {
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if url, ok := mujica.MarkdownView(c, "# Decree\n\nSee **the guide** at [the court](https://example.com).").Link(); ok {
|
||||
fmt.Println("open", url)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleCodeBlock() {
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.CodeBlock(c, "func bow() {}", mujica.CodeBlockOptions{Language: "go", LineNumbers: true})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleTableBlock() {
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if mujica.TableBlock(c, []string{"Guest", "Seat"}, [][]string{{"Marie", "1"}}, mujica.TableBlockOptions{}).Copied() {
|
||||
fmt.Println("copied as TSV")
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleImageGrid() {
|
||||
pic := ui.MustParseSVG([]byte(`<svg xmlns="http://www.w3.org/2000/svg" width="4" height="3"/>`))
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if i, ok := mujica.ImageGrid(c, []mujica.GridImage{{Source: pic, Alt: "Portrait"}}).Retried(); ok {
|
||||
fmt.Println("reload", i)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleFileMessage() {
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
r := mujica.FileMessage(c, mujica.FileMessageOptions{Name: "treaty.pdf", Size: 2621440})
|
||||
if r.Downloaded() {
|
||||
fmt.Println("save treaty.pdf")
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleAudioMessage() {
|
||||
pos := 5 * time.Second
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
r := mujica.AudioMessage(c, mujica.AudioMessageOptions{Duration: time.Minute, Position: pos})
|
||||
if d, ok := r.Seek(); ok {
|
||||
pos = d
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleLinkPreviewCard() {
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
p := mujica.LinkPreview{URL: "https://example.com", Title: "The Palace", Site: "example.com"}
|
||||
if mujica.LinkPreviewCard(c, p).Clicked() {
|
||||
fmt.Println("open", p.URL)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleQuoteReply() {
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if mujica.QuoteReply(c, "Marie", "Let them eat cake.").Clicked() {
|
||||
fmt.Println("jump to the quoted message")
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleMessageActions() {
|
||||
rating := mujica.FeedbackNone
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if a, ok := mujica.MessageActions(c, mujica.MessageAssistant, mujica.MessageActionsOptions{Feedback: &rating}).Action(); ok {
|
||||
fmt.Println("action", a)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleMessageEditor() {
|
||||
draft := "Hello"
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if mujica.MessageEditor(c, &draft, mujica.MessageEditorOptions{Original: "Hello"}).Saved() {
|
||||
fmt.Println("new branch from", draft)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleBranchNavigator() {
|
||||
branch := 1
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if mujica.BranchNavigator(c, &branch, 3).Changed() {
|
||||
fmt.Println("show branch", branch)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleErrorMessage() {
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
opts := mujica.ErrorMessageOptions{RetryAt: time.Now().Add(30 * time.Second)}
|
||||
if mujica.ErrorMessage(c, "429 Too Many Requests", opts).Retried() {
|
||||
fmt.Println("send again")
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleThinkingBlock() {
|
||||
open := false
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.ThinkingBlock(c, &open, mujica.ThinkingBlockOptions{Elapsed: 12 * time.Second}, func() {
|
||||
ui.Text(c, "Weigh the guest list.")
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleScrollToBottomButton() {
|
||||
var list mujica.MessageListState
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
ui.Box(c).Fill().Children(func() {
|
||||
if mujica.ScrollToBottomButton(c, !list.AtEnd(), 3).Clicked() {
|
||||
list.ScrollToEnd()
|
||||
}
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleTypingIndicator() {
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.TypingIndicator(c, "Herald")
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
@@ -1,3 +1,261 @@
|
||||
package main
|
||||
|
||||
var chatmsgPages = []page{}
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
mujica "mujicaui"
|
||||
)
|
||||
|
||||
const chatmsgCategory = "Chat Messages"
|
||||
|
||||
var chatmsgPages = append(append(chatmsgListPages, chatmsgBlockPages...), chatmsgActionPages...)
|
||||
|
||||
// chatmsgReplies are the lines the demo assistant sends.
|
||||
var chatmsgReplies = []string{
|
||||
"The ball begins at nine, in the Hall of Mirrors.",
|
||||
"Guests arrive by the **east door**; carriages wait in the *lower court*.",
|
||||
"Seating follows rank: dukes nearest the throne, then the envoys.",
|
||||
"Shall I send the invitations tonight?",
|
||||
}
|
||||
|
||||
// chatmsgDemo is a chat whose messages the demo adds.
|
||||
type chatmsgDemo struct {
|
||||
ids []string
|
||||
texts []string
|
||||
list mujica.MessageListState
|
||||
next int
|
||||
sidebar bool
|
||||
dated bool
|
||||
}
|
||||
|
||||
func newChatmsgDemo(n int) *chatmsgDemo {
|
||||
d := &chatmsgDemo{sidebar: true, dated: true}
|
||||
for range n {
|
||||
d.add()
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func (d *chatmsgDemo) add() {
|
||||
d.ids = append(d.ids, fmt.Sprintf("msg-%d", d.next))
|
||||
d.texts = append(d.texts, chatmsgReplies[d.next%len(chatmsgReplies)])
|
||||
d.next++
|
||||
}
|
||||
|
||||
// day puts three messages on each day, ending today.
|
||||
func (d *chatmsgDemo) day(i int) time.Time {
|
||||
last := (len(d.ids) - 1) / 3
|
||||
return time.Now().AddDate(0, 0, i/3-last)
|
||||
}
|
||||
|
||||
// row shows message i as a bubble, users and the assistant taking turns.
|
||||
func (d *chatmsgDemo) row(c *ui.Context, i int) {
|
||||
role := mujica.MessageAssistant
|
||||
if i%2 == 0 {
|
||||
role = mujica.MessageUser
|
||||
}
|
||||
mujica.MessageBubble(c, role, mujica.MessageBubbleOptions{}, func() { mujica.MarkdownView(c, d.texts[i]) })
|
||||
}
|
||||
|
||||
// chatmsgControls adds messages to a demo chat and logs it.
|
||||
func chatmsgControls(c *ui.Context, log func(string), d *chatmsgDemo) {
|
||||
ui.Row(c).Gap(8).Children(func() {
|
||||
if mujica.Button(c, "Add message", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() {
|
||||
d.add()
|
||||
log(fmt.Sprintf("message %s added", d.ids[len(d.ids)-1]))
|
||||
}
|
||||
if mujica.Button(c, "Add 10", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() {
|
||||
for range 10 {
|
||||
d.add()
|
||||
}
|
||||
log(fmt.Sprintf("10 messages added, %d in all", len(d.ids)))
|
||||
}
|
||||
if mujica.Button(c, "Jump to latest", mujica.ButtonOptions{Variant: mujica.Ghost}).Clicked() {
|
||||
d.list.ScrollToEnd()
|
||||
log("scrolled to end")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
var chatmsgListPages = []page{
|
||||
{ID: "201", Name: "ChatContainer", ZhName: "对话容器", Category: chatmsgCategory,
|
||||
Keys: "Tab 在侧栏、消息与输入区之间移动;消息区滚轮与 PageUp/PageDown 滚动;上翻后出现回到最新按钮。",
|
||||
Code: `mujica.ChatContainer(c, &chat, len(msgs), mujica.ChatContainerOptions{
|
||||
List: mujica.MessageListOptions{ID: func(i int) string { return msgs[i] }},
|
||||
Input: func() { ui.Text(c, "Composer goes here") },
|
||||
}, func(i int) { ui.Text(c, msgs[i]) })`,
|
||||
Demo: func(c *ui.Context, log func(string)) {
|
||||
d := *ui.Local(c.Root(), "201", func() *chatmsgDemo { return newChatmsgDemo(6) })
|
||||
draft := ui.Local(c.Root(), "201.draft", func() string { return "" })
|
||||
inputSection(c, "Parameters", func() {
|
||||
inputFlags(c, log, map[string]*bool{"Sidebar": &d.sidebar}, "Sidebar")
|
||||
chatmsgControls(c, log, d)
|
||||
})
|
||||
inputSection(c, "Live", func() {
|
||||
ui.Box(c).Height(420).Border(1, mujica.Tokens(c).Border).Clip().Children(func() {
|
||||
opts := mujica.ChatContainerOptions{
|
||||
List: mujica.MessageListOptions{ID: func(i int) string { return d.ids[i] }},
|
||||
Input: func() {
|
||||
r := mujica.TextInput(c, draft, mujica.TextInputOptions{Label: "Message", Placeholder: "Write to the herald"})
|
||||
if r.Submitted() && strings.TrimSpace(*draft) != "" {
|
||||
d.add()
|
||||
d.texts[len(d.texts)-1] = *draft
|
||||
log("sent a message")
|
||||
*draft = ""
|
||||
}
|
||||
},
|
||||
}
|
||||
if d.sidebar {
|
||||
opts.Sidebar = func() {
|
||||
ui.Column(c).Padding(12).Gap(8).Children(func() {
|
||||
for _, s := range []string{"The ball", "Seating", "Envoys"} {
|
||||
ui.Text(c, s)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
r := mujica.ChatContainer(c, &d.list, len(d.ids), opts, func(i int) { d.row(c, i) })
|
||||
if r.Unread() > 0 {
|
||||
ui.Text(c, fmt.Sprintf("%d unread", r.Unread())).Absolute().Top(8).Right(12)
|
||||
}
|
||||
})
|
||||
})
|
||||
}},
|
||||
{ID: "202", Name: "MessageList", ZhName: "消息列表", Category: chatmsgCategory,
|
||||
Keys: "滚轮与方向键滚动;上翻后停止跟随;Tab 到回到最新按钮,Enter 回到底部。",
|
||||
Code: `r := mujica.MessageList(c, &list, len(ids), mujica.MessageListOptions{
|
||||
ID: func(i int) string { return ids[i] },
|
||||
}, func(i int) { ui.Text(c, "Message "+ids[i]) })
|
||||
fmt.Println("unread", r.Unread())`,
|
||||
Demo: func(c *ui.Context, log func(string)) {
|
||||
d := *ui.Local(c.Root(), "202", func() *chatmsgDemo { return newChatmsgDemo(30) })
|
||||
inputSection(c, "Parameters", func() {
|
||||
inputFlags(c, log, map[string]*bool{"Date separators": &d.dated}, "Date separators")
|
||||
chatmsgControls(c, log, d)
|
||||
ui.Row(c).Children(func() {
|
||||
if mujica.Button(c, "Load 100,000", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() {
|
||||
for len(d.ids) < 100000 {
|
||||
d.add()
|
||||
}
|
||||
log("100,000 messages loaded")
|
||||
}
|
||||
})
|
||||
})
|
||||
inputSection(c, "Live", func() {
|
||||
opts := mujica.MessageListOptions{ID: func(i int) string { return d.ids[i] }, Label: func(i int) string { return d.texts[i] }}
|
||||
if d.dated {
|
||||
opts.Date = d.day
|
||||
}
|
||||
ui.Column(c).Height(360).Border(1, mujica.Tokens(c).Border).Children(func() {
|
||||
r := mujica.MessageList(c, &d.list, len(d.ids), opts, func(i int) { d.row(c, i) })
|
||||
ui.Text(c, fmt.Sprintf("%d messages, %d unread, at end %v", len(d.ids), r.Unread(), d.list.AtEnd())).Padding(8)
|
||||
})
|
||||
})
|
||||
}},
|
||||
{ID: "203", Name: "MessageBubble", ZhName: "消息气泡", Category: chatmsgCategory,
|
||||
Keys: "气泡不获取焦点;内容中的链接与按钮按 Tab 顺序访问。",
|
||||
Code: `mujica.MessageBubble(c, mujica.MessageAssistant, mujica.MessageBubbleOptions{Name: "Herald"}, func() {
|
||||
ui.Text(c, "The ball begins at nine.")
|
||||
})`,
|
||||
Demo: func(c *ui.Context, log func(string)) {
|
||||
type state struct {
|
||||
role mujica.MessageRole
|
||||
avatar, named bool
|
||||
width float64
|
||||
}
|
||||
s := ui.Local(c.Root(), "203", func() state { return state{role: mujica.MessageAssistant, avatar: true, named: true, width: 360} })
|
||||
roles := []mujica.RadioItem[mujica.MessageRole]{{Value: mujica.MessageUser, Label: "User"}, {Value: mujica.MessageAssistant, Label: "Assistant"},
|
||||
{Value: mujica.MessageSystem, Label: "System"}, {Value: mujica.MessageError, Label: "Error"}}
|
||||
inputSection(c, "Parameters", func() {
|
||||
if mujica.RadioGroup(c, &s.role, roles, mujica.RadioGroupOptions{Horizontal: true, Label: "Role"}).Changed() {
|
||||
log(fmt.Sprintf("role %d", s.role))
|
||||
}
|
||||
inputFlags(c, log, map[string]*bool{"Avatar": &s.avatar, "Name": &s.named}, "Avatar", "Name")
|
||||
if mujica.NumberInput(c, &s.width, mujica.NumberInputOptions{Min: 160, Max: 640, Step: 40, Label: "MaxWidth"}).Changed() {
|
||||
log(fmt.Sprintf("MaxWidth %v", s.width))
|
||||
}
|
||||
})
|
||||
inputSection(c, "Live", func() {
|
||||
opts := mujica.MessageBubbleOptions{MaxWidth: float32(s.width)}
|
||||
if s.named {
|
||||
opts.Name = "Herald"
|
||||
}
|
||||
if s.avatar {
|
||||
opts.Avatar = func() { mujica.Avatar(c, "Herald", mujica.AvatarOptions{}) }
|
||||
}
|
||||
mujica.MessageBubble(c, s.role, opts, func() { ui.Text(c, chatmsgReplies[0]+" "+chatmsgReplies[2]) })
|
||||
})
|
||||
inputSection(c, "States", func() {
|
||||
for _, r := range roles {
|
||||
mujica.MessageBubble(c, r.Value, mujica.MessageBubbleOptions{Name: r.Label}, func() { ui.Text(c, r.Label+" message") })
|
||||
}
|
||||
})
|
||||
}},
|
||||
{ID: "204", Name: "MessageHeader", ZhName: "消息头", Category: chatmsgCategory, Keys: "不获取焦点。",
|
||||
Code: `mujica.MessageHeader(c, mujica.MessageHeaderOptions{Model: "herald-2", Time: time.Now(), Status: mujica.MessageSent})`,
|
||||
Demo: func(c *ui.Context, log func(string)) {
|
||||
status := ui.Local(c.Root(), "204", func() mujica.MessageStatus { return mujica.MessageSent })
|
||||
items := []mujica.RadioItem[mujica.MessageStatus]{{Value: mujica.MessageNoStatus, Label: "None"}, {Value: mujica.MessageSending, Label: "Sending"},
|
||||
{Value: mujica.MessageStreaming, Label: "Streaming"}, {Value: mujica.MessageSent, Label: "Sent"}, {Value: mujica.MessageFailed, Label: "Failed"}}
|
||||
inputSection(c, "Parameters", func() {
|
||||
if mujica.RadioGroup(c, status, items, mujica.RadioGroupOptions{Horizontal: true, Label: "Status"}).Changed() {
|
||||
log(fmt.Sprintf("status %d", *status))
|
||||
}
|
||||
})
|
||||
inputSection(c, "Live", func() {
|
||||
mujica.MessageHeader(c, mujica.MessageHeaderOptions{Model: "herald-2", Time: time.Now(), Status: *status})
|
||||
})
|
||||
inputSection(c, "States", func() {
|
||||
for _, it := range items[1:] {
|
||||
mujica.MessageHeader(c, mujica.MessageHeaderOptions{Model: "herald-2", Time: time.Now(), Status: it.Value})
|
||||
}
|
||||
})
|
||||
}},
|
||||
{ID: "205", Name: "StreamingText", ZhName: "流式文本", Category: chatmsgCategory, Keys: "不获取焦点;减少动画时直接显示全部文本。",
|
||||
Code: `mujica.StreamingText(c, reply, mujica.StreamingTextOptions{Streaming: true, Smooth: true})`,
|
||||
Demo: func(c *ui.Context, log func(string)) {
|
||||
type state struct {
|
||||
text string
|
||||
smooth, streaming, auto bool
|
||||
rate float64
|
||||
at time.Time
|
||||
}
|
||||
s := ui.Local(c.Root(), "205", func() state { return state{smooth: true, streaming: true, rate: 80} })
|
||||
const full = "By order of the Queen, the ball begins at nine. Guests arrive by the east door, and carriages wait in the lower court until midnight."
|
||||
inputSection(c, "Parameters", func() {
|
||||
inputFlags(c, log, map[string]*bool{"Smooth": &s.smooth, "Streaming cursor": &s.streaming, "Stream": &s.auto}, "Smooth", "Streaming cursor", "Stream")
|
||||
if mujica.NumberInput(c, &s.rate, mujica.NumberInputOptions{Min: 10, Max: 400, Step: 10, Label: "Rate"}).Changed() {
|
||||
log(fmt.Sprintf("Rate %v", s.rate))
|
||||
}
|
||||
ui.Row(c).Children(func() {
|
||||
if mujica.Button(c, "Restart", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() {
|
||||
s.text = ""
|
||||
log("stream restarted")
|
||||
}
|
||||
})
|
||||
})
|
||||
// The demo's own source appends a word every 150 ms while streaming.
|
||||
if s.auto && len(s.text) < len(full) && c.Now().Sub(s.at) > 150*time.Millisecond {
|
||||
rest := full[len(s.text):]
|
||||
n := strings.IndexByte(rest[1:], ' ') + 2
|
||||
if n <= 1 {
|
||||
n = len(rest)
|
||||
}
|
||||
s.text += rest[:n]
|
||||
s.at = c.Now()
|
||||
if len(s.text) == len(full) {
|
||||
log("stream finished")
|
||||
}
|
||||
}
|
||||
if s.auto && len(s.text) < len(full) {
|
||||
c.After(150 * time.Millisecond)
|
||||
}
|
||||
inputSection(c, "Live", func() {
|
||||
mujica.StreamingText(c, s.text, mujica.StreamingTextOptions{Streaming: s.streaming && len(s.text) < len(full), Smooth: s.smooth, Rate: s.rate})
|
||||
})
|
||||
}},
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
mujica "mujicaui"
|
||||
)
|
||||
|
||||
var chatmsgActionPages = []page{
|
||||
{ID: "211", Name: "AudioMessage", ZhName: "语音消息", Category: chatmsgCategory, Keys: "Tab 到播放按钮与波形;波形上 Left/Right 跳转,Home、End 到首尾。",
|
||||
Code: `r := mujica.AudioMessage(c, mujica.AudioMessageOptions{Duration: time.Minute, Position: pos})
|
||||
if d, ok := r.Seek(); ok {
|
||||
pos = d
|
||||
}`,
|
||||
Demo: func(c *ui.Context, log func(string)) {
|
||||
type state struct {
|
||||
pos time.Duration
|
||||
playing bool
|
||||
at time.Time
|
||||
}
|
||||
s := ui.Local(c.Root(), "211", func() state { return state{pos: 12 * time.Second} })
|
||||
const length = 47 * time.Second
|
||||
// The demo plays: it moves the position while playing.
|
||||
if s.playing {
|
||||
s.pos += c.Now().Sub(s.at)
|
||||
if s.pos >= length {
|
||||
s.pos, s.playing = length, false
|
||||
log("playback ended")
|
||||
}
|
||||
c.After(100 * time.Millisecond)
|
||||
}
|
||||
s.at = c.Now()
|
||||
inputSection(c, "Live", func() {
|
||||
r := mujica.AudioMessage(c, mujica.AudioMessageOptions{Duration: length, Position: s.pos, Playing: s.playing, Waveform: chatmsgWave})
|
||||
if r.Toggled() {
|
||||
s.playing = !s.playing
|
||||
if s.pos >= length {
|
||||
s.pos = 0
|
||||
}
|
||||
log(fmt.Sprintf("playing %v", s.playing))
|
||||
}
|
||||
if d, ok := r.Seek(); ok {
|
||||
s.pos = d
|
||||
log(fmt.Sprintf("seek to %v", d.Round(time.Second)))
|
||||
}
|
||||
})
|
||||
inputSection(c, "States", func() {
|
||||
ui.Column(c).Disabled(true).Children(func() {
|
||||
mujica.AudioMessage(c, mujica.AudioMessageOptions{Duration: length, Waveform: chatmsgWave})
|
||||
})
|
||||
})
|
||||
}},
|
||||
{ID: "212", Name: "LinkPreviewCard", ZhName: "链接预览卡片", Category: chatmsgCategory, Keys: "Tab 聚焦卡片,Enter 或 Space 打开。",
|
||||
Code: `p := mujica.LinkPreview{URL: "https://example.com", Title: "The Palace", Site: "example.com"}
|
||||
if mujica.LinkPreviewCard(c, p).Clicked() {
|
||||
fmt.Println("open", p.URL)
|
||||
}`,
|
||||
Demo: func(c *ui.Context, log func(string)) {
|
||||
thumb := ui.Local(c.Root(), "212", func() bool { return true })
|
||||
inputSection(c, "Parameters", func() {
|
||||
inputFlags(c, log, map[string]*bool{"Thumbnail": thumb}, "Thumbnail")
|
||||
})
|
||||
p := mujica.LinkPreview{URL: "https://example.com/versailles", Title: "The Palace of Versailles", Site: "example.com",
|
||||
Description: "A royal château in the Île-de-France region, seat of the French court from 1682 until the Revolution."}
|
||||
if *thumb {
|
||||
p.Thumbnail = chatmsgPicture("#AD9164")
|
||||
}
|
||||
inputSection(c, "Live", func() {
|
||||
if mujica.LinkPreviewCard(c, p).Clicked() {
|
||||
log("open " + p.URL)
|
||||
}
|
||||
if mujica.LinkPreviewCard(c, mujica.LinkPreview{URL: "https://example.com/hall", Title: "Hall of Mirrors"}).Clicked() {
|
||||
log("open https://example.com/hall")
|
||||
}
|
||||
})
|
||||
}},
|
||||
{ID: "213", Name: "QuoteReply", ZhName: "引用回复", Category: chatmsgCategory, Keys: "Tab 聚焦,Enter 或 Space 跳转。",
|
||||
Code: `if mujica.QuoteReply(c, "Marie", "Let them eat cake.").Clicked() {
|
||||
fmt.Println("jump to the quoted message")
|
||||
}`,
|
||||
Demo: func(c *ui.Context, log func(string)) {
|
||||
inputSection(c, "Live", func() {
|
||||
for _, q := range [][2]string{{"Marie", "Let them eat cake."}, {"Herald", chatmsgMarkdown}} {
|
||||
if mujica.QuoteReply(c, q[0], q[1]).Clicked() {
|
||||
log("jump to " + q[0] + ": " + chatmsgSnippet(q[1], 24))
|
||||
}
|
||||
}
|
||||
})
|
||||
}},
|
||||
{ID: "214", Name: "MessageActions", ZhName: "消息操作", Category: chatmsgCategory, Keys: "Tab 进入工具栏,Left/Right 移动,Enter 或 Space 触发。",
|
||||
Code: `if a, ok := mujica.MessageActions(c, mujica.MessageAssistant, mujica.MessageActionsOptions{Feedback: &rating}).Action(); ok {
|
||||
fmt.Println("action", a)
|
||||
}`,
|
||||
Demo: func(c *ui.Context, log func(string)) {
|
||||
type state struct {
|
||||
role mujica.MessageRole
|
||||
rating mujica.MessageFeedback
|
||||
rated bool
|
||||
}
|
||||
s := ui.Local(c.Root(), "214", func() state { return state{role: mujica.MessageAssistant, rated: true} })
|
||||
names := map[mujica.MessageAction]string{mujica.ActionCopy: "copy", mujica.ActionRegenerate: "regenerate", mujica.ActionEdit: "edit",
|
||||
mujica.ActionLike: "like", mujica.ActionDislike: "dislike", mujica.ActionSpeak: "speak", mujica.ActionShare: "share"}
|
||||
inputSection(c, "Parameters", func() {
|
||||
roles := []mujica.RadioItem[mujica.MessageRole]{{Value: mujica.MessageUser, Label: "User"}, {Value: mujica.MessageAssistant, Label: "Assistant"},
|
||||
{Value: mujica.MessageSystem, Label: "System"}, {Value: mujica.MessageError, Label: "Error"}}
|
||||
if mujica.RadioGroup(c, &s.role, roles, mujica.RadioGroupOptions{Horizontal: true, Label: "Role"}).Changed() {
|
||||
log(fmt.Sprintf("role %d", s.role))
|
||||
}
|
||||
inputFlags(c, log, map[string]*bool{"Rating": &s.rated}, "Rating")
|
||||
})
|
||||
inputSection(c, "Live", func() {
|
||||
opts := mujica.MessageActionsOptions{}
|
||||
if s.rated {
|
||||
opts.Feedback = &s.rating
|
||||
}
|
||||
if a, ok := mujica.MessageActions(c, s.role, opts).Action(); ok {
|
||||
log(fmt.Sprintf("action %s, rating %d", names[a], s.rating))
|
||||
}
|
||||
})
|
||||
}},
|
||||
{ID: "215", Name: "MessageEditor", ZhName: "消息编辑器", Category: chatmsgCategory, Keys: "Cmd/Ctrl+Enter 保存,Escape 取消。",
|
||||
Code: `if mujica.MessageEditor(c, &draft, mujica.MessageEditorOptions{Original: "Hello"}).Saved() {
|
||||
fmt.Println("new branch from", draft)
|
||||
}`,
|
||||
Demo: func(c *ui.Context, log func(string)) {
|
||||
type state struct {
|
||||
original, draft string
|
||||
editing bool
|
||||
branches []string
|
||||
index int
|
||||
}
|
||||
s := ui.Local(c.Root(), "215", func() state {
|
||||
return state{original: "Seat the ambassador.", branches: []string{"Seat the ambassador."}}
|
||||
})
|
||||
inputSection(c, "Live", func() {
|
||||
if !s.editing {
|
||||
mujica.MessageBubble(c, mujica.MessageUser, mujica.MessageBubbleOptions{}, func() { ui.Text(c, s.branches[s.index]) })
|
||||
ui.Row(c).Gap(8).Justify(ui.End).Children(func() {
|
||||
if mujica.BranchNavigator(c, &s.index, len(s.branches)).Changed() {
|
||||
log(fmt.Sprintf("branch %d", s.index+1))
|
||||
}
|
||||
if mujica.Button(c, "Edit", mujica.ButtonOptions{Variant: mujica.Ghost}).Clicked() {
|
||||
s.editing, s.draft = true, s.branches[s.index]
|
||||
log("editing")
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
r := mujica.MessageEditor(c, &s.draft, mujica.MessageEditorOptions{Original: s.branches[s.index]})
|
||||
if r.Saved() {
|
||||
s.branches = append(s.branches, s.draft)
|
||||
s.index, s.editing = len(s.branches)-1, false
|
||||
log(fmt.Sprintf("saved as branch %d", len(s.branches)))
|
||||
}
|
||||
if r.Canceled() {
|
||||
s.editing = false
|
||||
log("edit canceled")
|
||||
}
|
||||
})
|
||||
}},
|
||||
{ID: "216", Name: "BranchNavigator", ZhName: "分支切换", Category: chatmsgCategory, Keys: "Tab 聚焦,Left/Right 切换,Home、End 到首末。",
|
||||
Code: `if mujica.BranchNavigator(c, &branch, 3).Changed() {
|
||||
fmt.Println("show branch", branch)
|
||||
}`,
|
||||
Demo: func(c *ui.Context, log func(string)) {
|
||||
type state struct {
|
||||
index int
|
||||
count float64
|
||||
disabled bool
|
||||
}
|
||||
s := ui.Local(c.Root(), "216", func() state { return state{index: 1, count: 3} })
|
||||
inputSection(c, "Parameters", func() {
|
||||
if mujica.NumberInput(c, &s.count, mujica.NumberInputOptions{Min: 1, Max: 9, StepperOnly: true, Label: "Branches"}).Changed() {
|
||||
s.index = min(s.index, int(s.count)-1)
|
||||
log(fmt.Sprintf("%v branches", s.count))
|
||||
}
|
||||
inputFlags(c, log, map[string]*bool{"Disabled": &s.disabled}, "Disabled")
|
||||
})
|
||||
inputSection(c, "Live", func() {
|
||||
ui.Row(c).Disabled(s.disabled).Children(func() {
|
||||
if mujica.BranchNavigator(c, &s.index, int(s.count)).Changed() {
|
||||
log(fmt.Sprintf("branch %d of %v", s.index+1, s.count))
|
||||
}
|
||||
})
|
||||
})
|
||||
}},
|
||||
{ID: "217", Name: "ErrorMessage", ZhName: "错误消息", Category: chatmsgCategory, Keys: "Tab 到错误文本(可选择复制)与重试按钮。",
|
||||
Code: `opts := mujica.ErrorMessageOptions{RetryAt: time.Now().Add(30 * time.Second)}
|
||||
if mujica.ErrorMessage(c, "429 Too Many Requests", opts).Retried() {
|
||||
fmt.Println("send again")
|
||||
}`,
|
||||
Demo: func(c *ui.Context, log func(string)) {
|
||||
until := ui.Local(c.Root(), "217", func() time.Time { return time.Time{} })
|
||||
inputSection(c, "Parameters", func() {
|
||||
ui.Row(c).Children(func() {
|
||||
if mujica.Button(c, "Rate limit for 10 s", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() {
|
||||
*until = c.Now().Add(10 * time.Second)
|
||||
log("rate limited for 10 s")
|
||||
}
|
||||
})
|
||||
})
|
||||
inputSection(c, "Live", func() {
|
||||
opts := mujica.ErrorMessageOptions{RetryAt: *until}
|
||||
if mujica.ErrorMessage(c, `429 Too Many Requests: {"type":"rate_limit_error","message":"Number of requests exceeded"}`, opts).Retried() {
|
||||
log("retry")
|
||||
}
|
||||
if mujica.ErrorMessage(c, "dial tcp 10.0.0.7:443: connection refused", mujica.ErrorMessageOptions{Title: "Could not reach the server"}).Retried() {
|
||||
log("retry connection")
|
||||
}
|
||||
})
|
||||
}},
|
||||
{ID: "218", Name: "ThinkingBlock", ZhName: "思考过程", Category: chatmsgCategory, Keys: "Tab 聚焦标题,Enter 或 Space 展开与折叠。",
|
||||
Code: `mujica.ThinkingBlock(c, &open, mujica.ThinkingBlockOptions{Elapsed: 12 * time.Second}, func() {
|
||||
ui.Text(c, "Weigh the guest list.")
|
||||
})`,
|
||||
Demo: func(c *ui.Context, log func(string)) {
|
||||
type state struct {
|
||||
open, thinking bool
|
||||
started time.Time
|
||||
elapsed time.Duration
|
||||
}
|
||||
s := ui.Local(c.Root(), "218", func() state { return state{elapsed: 14 * time.Second} })
|
||||
inputSection(c, "Parameters", func() {
|
||||
label := "Start thinking"
|
||||
if s.thinking {
|
||||
label = "Finish thinking"
|
||||
}
|
||||
ui.Row(c).Children(func() {
|
||||
if mujica.Button(c, label, mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() {
|
||||
if s.thinking {
|
||||
s.elapsed = c.Now().Sub(s.started)
|
||||
} else {
|
||||
s.started = c.Now()
|
||||
}
|
||||
s.thinking = !s.thinking
|
||||
log(fmt.Sprintf("thinking %v", s.thinking))
|
||||
}
|
||||
})
|
||||
})
|
||||
inputSection(c, "Live", func() {
|
||||
opts := mujica.ThinkingBlockOptions{Thinking: s.thinking, Started: s.started, Elapsed: s.elapsed}
|
||||
if mujica.ThinkingBlock(c, &s.open, opts, func() {
|
||||
ui.Text(c, "Forty guests, thirty-six seats. The envoys outrank the counts; four counts dine in the antechamber.")
|
||||
}).Changed() {
|
||||
log(fmt.Sprintf("open %v", s.open))
|
||||
}
|
||||
})
|
||||
}},
|
||||
{ID: "219", Name: "ScrollToBottomButton", ZhName: "回到底部按钮", Category: chatmsgCategory, Keys: "Tab 聚焦,Enter 或 Space 回到底部。",
|
||||
Code: `ui.Box(c).Fill().Children(func() {
|
||||
if mujica.ScrollToBottomButton(c, !list.AtEnd(), 3).Clicked() {
|
||||
list.ScrollToEnd()
|
||||
}
|
||||
})`,
|
||||
Demo: func(c *ui.Context, log func(string)) {
|
||||
type state struct {
|
||||
away bool
|
||||
unread float64
|
||||
}
|
||||
s := ui.Local(c.Root(), "219", func() state { return state{away: true, unread: 3} })
|
||||
inputSection(c, "Parameters", func() {
|
||||
inputFlags(c, log, map[string]*bool{"Away from bottom": &s.away}, "Away from bottom")
|
||||
if mujica.NumberInput(c, &s.unread, mujica.NumberInputOptions{Min: 0, Max: 99, Label: "Unread"}).Changed() {
|
||||
log(fmt.Sprintf("unread %v", s.unread))
|
||||
}
|
||||
})
|
||||
inputSection(c, "Live", func() {
|
||||
ui.Box(c).Height(120).Background(mujica.Tokens(c).Surface).Border(1, mujica.Tokens(c).Border).Children(func() {
|
||||
if mujica.ScrollToBottomButton(c, s.away, int(s.unread)).Clicked() {
|
||||
s.away, s.unread = false, 0
|
||||
log("back to the bottom")
|
||||
}
|
||||
})
|
||||
})
|
||||
}},
|
||||
{ID: "220", Name: "TypingIndicator", ZhName: "正在输入", Category: chatmsgCategory, Keys: "不获取焦点;减少动画时显示静态文字。",
|
||||
Code: `mujica.TypingIndicator(c, "Herald")`,
|
||||
Demo: func(c *ui.Context, log func(string)) {
|
||||
typing := ui.Local(c.Root(), "220", func() bool { return true })
|
||||
inputSection(c, "Parameters", func() {
|
||||
inputFlags(c, log, map[string]*bool{"Typing": typing}, "Typing")
|
||||
})
|
||||
inputSection(c, "Live", func() {
|
||||
if *typing {
|
||||
mujica.TypingIndicator(c, "Herald")
|
||||
}
|
||||
})
|
||||
}},
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
mujica "mujicaui"
|
||||
)
|
||||
|
||||
const chatmsgMarkdown = "## The Decree\n\nBy order of **the Queen**, *all* guests use `the east door`. See [the protocol](https://example.com/protocol).\n\n" +
|
||||
"1. Arrive at nine\n2. Bow to the throne\n - dukes first\n\n> Silence in the Hall of Mirrors.\n\n" +
|
||||
"```python\ndef bow(guest):\n return f\"{guest} bows\" # deeply\n```\n\n| Guest | Seat |\n|---|--:|\n| Marie | 1 |\n| Axel | 14 |\n\n---\n\nVive la reine."
|
||||
|
||||
// chatmsgPicture is a flat colored picture for image demos.
|
||||
func chatmsgPicture(color string) ui.ImageSource {
|
||||
return ui.MustParseSVG([]byte(fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" width="240" height="180"><rect width="240" height="180" fill="%s"/><circle cx="120" cy="90" r="40" fill="#FBF7EF" opacity="0.5"/></svg>`, color)))
|
||||
}
|
||||
|
||||
var chatmsgColors = []string{"#7D2034", "#AD9164", "#355E80", "#326247", "#805714", "#6A5B55", "#9C3C53", "#D2C3AF", "#211C1D"}
|
||||
|
||||
var chatmsgBlockPages = []page{
|
||||
{ID: "206", Name: "MarkdownView", ZhName: "Markdown 视图", Category: chatmsgCategory, Keys: "Tab 到链接,Enter 激活;代码块与表格按钮按 Tab 顺序访问。",
|
||||
Code: `if url, ok := mujica.MarkdownView(c, "# Decree\n\nSee **the guide** at [the court](https://example.com).").Link(); ok {
|
||||
fmt.Println("open", url)
|
||||
}`,
|
||||
Demo: func(c *ui.Context, log func(string)) {
|
||||
type state struct {
|
||||
src string
|
||||
stream bool
|
||||
at time.Time
|
||||
}
|
||||
s := ui.Local(c.Root(), "206", func() state { return state{src: chatmsgMarkdown} })
|
||||
inputSection(c, "Parameters", func() {
|
||||
ui.Row(c).Children(func() {
|
||||
if mujica.Button(c, "Stream it again", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() {
|
||||
s.src, s.stream = "", true
|
||||
log("streaming started")
|
||||
}
|
||||
})
|
||||
mujica.TextArea(c, &s.src, mujica.TextAreaOptions{Label: "Markdown source", MaxHeight: 160})
|
||||
})
|
||||
if s.stream && len(s.src) < len(chatmsgMarkdown) && c.Now().Sub(s.at) > 40*time.Millisecond {
|
||||
s.src = chatmsgMarkdown[:min(len(chatmsgMarkdown), len(s.src)+6)]
|
||||
s.at = c.Now()
|
||||
if len(s.src) == len(chatmsgMarkdown) {
|
||||
s.stream = false
|
||||
log("streaming finished; finished blocks were never parsed again")
|
||||
}
|
||||
}
|
||||
if s.stream {
|
||||
c.After(40 * time.Millisecond)
|
||||
}
|
||||
inputSection(c, "Live", func() {
|
||||
if url, ok := mujica.MarkdownView(c, s.src).Link(); ok {
|
||||
log("link " + url)
|
||||
}
|
||||
})
|
||||
}},
|
||||
{ID: "207", Name: "CodeBlock", ZhName: "代码块", Category: chatmsgCategory, Keys: "Tab 到换行、复制、折叠按钮;Space 或 Enter 触发。",
|
||||
Code: `mujica.CodeBlock(c, "func bow() {}", mujica.CodeBlockOptions{Language: "go", LineNumbers: true})`,
|
||||
Demo: func(c *ui.Context, log func(string)) {
|
||||
type state struct {
|
||||
lang string
|
||||
numbers, wrap bool
|
||||
}
|
||||
s := ui.Local(c.Root(), "207", func() state { return state{lang: "go", numbers: true} })
|
||||
samples := map[string]string{
|
||||
"go": "// greet the court\nfunc greet(name string) int {\n\treturn len(\"Your Majesty, \" + name + \", the ball awaits you in the Hall of Mirrors\") + 42\n}",
|
||||
"python": "def bow(guest):\n # deeply, always\n return f\"{guest} bows\" * 3",
|
||||
"sql": "SELECT name, seat FROM guests WHERE rank > 2 ORDER BY seat LIMIT 10; -- the envoys",
|
||||
}
|
||||
inputSection(c, "Parameters", func() {
|
||||
langs := []mujica.RadioItem[string]{{Value: "go", Label: "Go"}, {Value: "python", Label: "Python"}, {Value: "sql", Label: "SQL"}}
|
||||
if mujica.RadioGroup(c, &s.lang, langs, mujica.RadioGroupOptions{Horizontal: true, Label: "Language"}).Changed() {
|
||||
log("language " + s.lang)
|
||||
}
|
||||
inputFlags(c, log, map[string]*bool{"LineNumbers": &s.numbers, "Wrap at first": &s.wrap}, "LineNumbers", "Wrap at first")
|
||||
})
|
||||
inputSection(c, "Live", func() {
|
||||
ui.Column(c).Key(s.lang + fmt.Sprint(s.wrap)).Children(func() {
|
||||
if mujica.CodeBlock(c, samples[s.lang], mujica.CodeBlockOptions{Language: s.lang, LineNumbers: s.numbers, Wrap: s.wrap}).Copied() {
|
||||
log("code copied")
|
||||
}
|
||||
})
|
||||
})
|
||||
inputSection(c, "States", func() {
|
||||
mujica.CodeBlock(c, samples["go"], mujica.CodeBlockOptions{Language: "go", Collapsed: true})
|
||||
})
|
||||
}},
|
||||
{ID: "208", Name: "TableBlock", ZhName: "表格块", Category: chatmsgCategory, Keys: "Tab 到复制按钮,Enter 或 Space 复制为 TSV。",
|
||||
Code: `if mujica.TableBlock(c, []string{"Guest", "Seat"}, [][]string{{"Marie", "1"}}, mujica.TableBlockOptions{}).Copied() {
|
||||
fmt.Println("copied as TSV")
|
||||
}`,
|
||||
Demo: func(c *ui.Context, log func(string)) {
|
||||
wide := ui.Local(c.Root(), "208", func() bool { return false })
|
||||
inputSection(c, "Parameters", func() {
|
||||
inputFlags(c, log, map[string]*bool{"Wide table": wide}, "Wide table")
|
||||
})
|
||||
header := []string{"Guest", "Title", "Seat"}
|
||||
rows := [][]string{{"Marie", "Queen of France", "1"}, {"Axel", "Count of Fersen", "14"}}
|
||||
if *wide {
|
||||
header = append(header, "Arrival", "Carriage", "Retinue", "Notes")
|
||||
for i := range rows {
|
||||
rows[i] = append(rows[i], "21:00", "Gilded coach", "Twelve", "Seated near the orchestra, away from the draughts")
|
||||
}
|
||||
}
|
||||
inputSection(c, "Live", func() {
|
||||
ui.Column(c).MaxWidth(520).Children(func() {
|
||||
if mujica.TableBlock(c, header, rows, mujica.TableBlockOptions{Align: []ui.Align{ui.Start, ui.Start, ui.End}}).Copied() {
|
||||
log("table copied as TSV")
|
||||
}
|
||||
})
|
||||
})
|
||||
}},
|
||||
{ID: "209", Name: "ImageGrid", ZhName: "图片网格", Category: chatmsgCategory, Keys: "Tab 聚焦图片,Enter 打开查看器;查看器内 Left/Right 切换,Escape 关闭。",
|
||||
Code: `if i, ok := mujica.ImageGrid(c, []mujica.GridImage{{Source: pic, Alt: "Portrait"}}).Retried(); ok {
|
||||
fmt.Println("reload", i)
|
||||
}`,
|
||||
Demo: func(c *ui.Context, log func(string)) {
|
||||
type state struct {
|
||||
n float64
|
||||
loading, fail bool
|
||||
}
|
||||
s := ui.Local(c.Root(), "209", func() state { return state{n: 5} })
|
||||
inputSection(c, "Parameters", func() {
|
||||
if mujica.NumberInput(c, &s.n, mujica.NumberInputOptions{Min: 1, Max: 9, Label: "Images", StepperOnly: true}).Changed() {
|
||||
log(fmt.Sprintf("%v images", s.n))
|
||||
}
|
||||
inputFlags(c, log, map[string]*bool{"Last loading": &s.loading, "Second failed": &s.fail}, "Last loading", "Second failed")
|
||||
})
|
||||
imgs := make([]mujica.GridImage, int(s.n))
|
||||
for i := range imgs {
|
||||
imgs[i] = mujica.GridImage{Source: chatmsgPicture(chatmsgColors[i]), Alt: fmt.Sprintf("Portrait %d", i+1)}
|
||||
}
|
||||
if s.loading {
|
||||
imgs[len(imgs)-1] = mujica.GridImage{Alt: "Loading portrait", Loading: true}
|
||||
}
|
||||
if s.fail && len(imgs) > 1 {
|
||||
imgs[1] = mujica.GridImage{Alt: "Lost portrait", Err: errors.New("404")}
|
||||
}
|
||||
inputSection(c, "Live", func() {
|
||||
if i, ok := mujica.ImageGrid(c, imgs).Retried(); ok {
|
||||
s.fail = false
|
||||
log(fmt.Sprintf("retry image %d", i))
|
||||
}
|
||||
})
|
||||
}},
|
||||
{ID: "210", Name: "FileMessage", ZhName: "文件消息", Category: chatmsgCategory, Keys: "Tab 到文件与下载按钮,Enter 或 Space 触发。",
|
||||
Code: `r := mujica.FileMessage(c, mujica.FileMessageOptions{Name: "treaty.pdf", Size: 2621440})
|
||||
if r.Downloaded() {
|
||||
fmt.Println("save treaty.pdf")
|
||||
}`,
|
||||
Demo: func(c *ui.Context, log func(string)) {
|
||||
disabled := ui.Local(c.Root(), "210", func() bool { return false })
|
||||
inputSection(c, "Parameters", func() {
|
||||
inputFlags(c, log, map[string]*bool{"Disabled": disabled}, "Disabled")
|
||||
})
|
||||
inputSection(c, "Live", func() {
|
||||
ui.Column(c).Gap(8).Disabled(*disabled).Children(func() {
|
||||
for _, f := range []mujica.FileMessageOptions{{Name: "treaty.pdf", Size: 2621440}, {Name: "portrait.png", Size: 812000},
|
||||
{Name: "ledger.zip", Size: 73400320}, {Name: "decree.go", Size: 1840}, {Name: "notes", Size: 12}} {
|
||||
r := mujica.FileMessage(c, f)
|
||||
if r.Opened() {
|
||||
log("open " + f.Name)
|
||||
}
|
||||
if r.Downloaded() {
|
||||
log("download " + f.Name)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}},
|
||||
}
|
||||
|
||||
// chatmsgWave is a fixed waveform for the voice demo.
|
||||
var chatmsgWave = func() []float32 {
|
||||
w := make([]float32, 48)
|
||||
for i := range w {
|
||||
w[i] = float32(0.25 + 0.7*float64((i*37)%11)/10)
|
||||
if w[i] > 1 {
|
||||
w[i] = 1
|
||||
}
|
||||
}
|
||||
return w
|
||||
}()
|
||||
|
||||
// chatmsgSnippet trims s to n runes for logs.
|
||||
func chatmsgSnippet(s string, n int) string {
|
||||
r := []rune(strings.TrimSpace(s))
|
||||
if len(r) > n {
|
||||
return string(r[:n]) + "…"
|
||||
}
|
||||
return string(r)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"image/png"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
mujica "mujicaui"
|
||||
)
|
||||
|
||||
// chatmsgDemoTester runs the demo of page id and returns its tester and event log.
|
||||
func chatmsgDemoTester(t *testing.T, id string, mode mujica.Mode) (*ui.Tester, *[]string) {
|
||||
t.Helper()
|
||||
i := slices.IndexFunc(chatmsgPages, func(p page) bool { return p.ID == id })
|
||||
if i < 0 {
|
||||
t.Fatalf("no page %s", id)
|
||||
}
|
||||
events := &[]string{}
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{Mode: mode})
|
||||
ui.Scroll(c).Fill().Padding(24).Gap(16).Children(func() {
|
||||
chatmsgPages[i].Demo(c, func(s string) { *events = append(*events, s) })
|
||||
})
|
||||
}, 900, 800)
|
||||
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
|
||||
tt.Frame()
|
||||
return tt, events
|
||||
}
|
||||
|
||||
func TestChatmsgDemosRender(t *testing.T) {
|
||||
if len(chatmsgPages) != 20 {
|
||||
t.Fatalf("%d chat message pages, want 20", len(chatmsgPages))
|
||||
}
|
||||
dir := os.Getenv("CHATMSG_DEMO_DIR")
|
||||
for _, p := range chatmsgPages {
|
||||
for _, m := range []mujica.Mode{mujica.Light, mujica.Dark} {
|
||||
tt, _ := chatmsgDemoTester(t, p.ID, m)
|
||||
if len(tt.Texts()) == 0 {
|
||||
t.Errorf("%s: demo shows nothing", p.ID)
|
||||
}
|
||||
if dir == "" {
|
||||
continue
|
||||
}
|
||||
f, err := os.Create(filepath.Join(dir, p.ID+"-"+m.String()+".png"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := png.Encode(f, tt.Image()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatmsgDemosLogEvents(t *testing.T) {
|
||||
for id, click := range map[string]string{
|
||||
"202": "Add message", "207": "Copy code", "214": "Regenerate", "216": "Next branch", "217": "Retry",
|
||||
} {
|
||||
tt, events := chatmsgDemoTester(t, id, mujica.Light)
|
||||
if err := tt.Click(click); err != nil {
|
||||
t.Fatalf("%s: %v", id, err)
|
||||
}
|
||||
if len(*events) == 0 {
|
||||
t.Errorf("%s: clicking %q logged nothing", id, click)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// FileMessageOptions describe a file sent in a chat.
|
||||
type FileMessageOptions struct {
|
||||
// Name is the file name; required.
|
||||
Name string
|
||||
// Size is the length in bytes.
|
||||
Size int64
|
||||
// Type is the MIME type; empty guesses from the name's extension.
|
||||
Type string
|
||||
}
|
||||
|
||||
// FileMessageResult is a FileMessage's element and this frame's actions.
|
||||
type FileMessageResult struct {
|
||||
Element *ui.Element
|
||||
opened, downloaded bool
|
||||
}
|
||||
|
||||
// Opened reports a click or Enter on the file.
|
||||
func (r FileMessageResult) Opened() bool { return r.opened }
|
||||
|
||||
// Downloaded reports a click on the download button.
|
||||
func (r FileMessageResult) Downloaded() bool { return r.downloaded }
|
||||
|
||||
// FileMessage shows a file's type icon, name and size, with open and download actions.
|
||||
func FileMessage(c *ui.Context, opts FileMessageOptions) FileMessageResult {
|
||||
if opts.Name == "" {
|
||||
panic("mujica: FileMessage needs a Name")
|
||||
}
|
||||
if opts.Size < 0 {
|
||||
panic("mujica: FileMessage Size must not be negative")
|
||||
}
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
size := fileMessageSize(c, opts.Size)
|
||||
var r FileMessageResult
|
||||
r.Element = ui.Row(c).Gap(u).AlignItems(ui.Center).Padding(u, u, u, u*2).MaxWidth(360).
|
||||
Radius(theme.CardRadius).Background(k.Surface).Border(theme.BorderWidth, k.Border)
|
||||
disabled := r.Element.IsDisabled()
|
||||
r.Element.Children(func() {
|
||||
open := ui.ButtonBase(c).Grow(1).MinWidth(0).Gap(u * 3).Padding(u).Justify(ui.Start).Radius(theme.ControlRadius).
|
||||
Label(fmt.Sprintf(msg(c, "file_message.open"), opts.Name, size))
|
||||
internal.Ring(open, k.Focus, theme.ControlRadius)
|
||||
if open.Hovered() {
|
||||
open.Background(k.SurfaceHover)
|
||||
}
|
||||
open.Children(func() {
|
||||
ui.Icon(c, icons.Must(fileMessageIcon(opts.Name, opts.Type))).Size(24, 24).Shrink(0).TextColor(k.AccentText)
|
||||
ui.Column(c).MinWidth(0).Grow(1).Children(func() {
|
||||
ui.Text(c, opts.Name).SingleLine().FontWeight(600)
|
||||
ui.Text(c, size).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).SingleLine()
|
||||
})
|
||||
})
|
||||
if open.Clicked() && !disabled {
|
||||
r.opened = true
|
||||
slog.Debug("mujica: file_message open")
|
||||
}
|
||||
if iconAction(c, icons.Must("download"), fmt.Sprintf(msg(c, "file_message.download"), opts.Name)).Clicked() && !disabled {
|
||||
r.downloaded = true
|
||||
slog.Debug("mujica: file_message download")
|
||||
}
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
// fileMessageSize formats n bytes in binary units.
|
||||
func fileMessageSize(c *ui.Context, n int64) string {
|
||||
units := []string{"B", "KB", "MB", "GB", "TB"}
|
||||
v, i := float64(n), 0
|
||||
for v >= 1024 && i < len(units)-1 {
|
||||
v /= 1024
|
||||
i++
|
||||
}
|
||||
if i == 0 {
|
||||
return fmt.Sprintf(msg(c, "file_message.bytes"), n)
|
||||
}
|
||||
return fmt.Sprintf("%.1f %s", v, units[i])
|
||||
}
|
||||
|
||||
// fileMessageKinds maps extensions to type icons.
|
||||
var fileMessageKinds = func() map[string]string {
|
||||
m := map[string]string{}
|
||||
for icon, exts := range map[string]string{
|
||||
"image": ".png .jpg .jpeg .gif .webp .svg",
|
||||
"file-archive": ".zip .tar .gz .tgz .7z .rar",
|
||||
"file-code": ".go .js .ts .py .json .sh .sql .html .css .rs .java .c .h",
|
||||
"file-text": ".txt .md .pdf .doc .docx .rtf",
|
||||
} {
|
||||
for _, e := range strings.Fields(exts) {
|
||||
m[e] = icon
|
||||
}
|
||||
}
|
||||
return m
|
||||
}()
|
||||
|
||||
// fileMessageIcon picks a type icon from the MIME type, else the extension.
|
||||
func fileMessageIcon(name, mime string) string {
|
||||
switch {
|
||||
case strings.HasPrefix(mime, "image/"):
|
||||
return "image"
|
||||
case strings.Contains(mime, "zip"):
|
||||
return "file-archive"
|
||||
case strings.HasPrefix(mime, "text/"):
|
||||
return "file-text"
|
||||
}
|
||||
if icon, ok := fileMessageKinds[strings.ToLower(path.Ext(name))]; ok {
|
||||
return icon
|
||||
}
|
||||
return "file"
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func TestFileMessageOpenDownloadAndSize(t *testing.T) {
|
||||
opened, downloaded, off := 0, 0, false
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Column(c).Disabled(off).Children(func() {
|
||||
r := FileMessage(c, FileMessageOptions{Name: "treaty.pdf", Size: 2_621_440})
|
||||
if r.Opened() {
|
||||
opened++
|
||||
}
|
||||
if r.Downloaded() {
|
||||
downloaded++
|
||||
}
|
||||
})
|
||||
}, 400, 200)
|
||||
if !tt.HasText("2.5 MB") || !tt.HasText("treaty.pdf") {
|
||||
t.Fatalf("texts %q", tt.Texts())
|
||||
}
|
||||
_ = tt.Click("Open treaty.pdf, 2.5 MB")
|
||||
_ = tt.Click("Download treaty.pdf")
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if opened != 2 || downloaded != 1 {
|
||||
t.Fatalf("opened %d downloaded %d", opened, downloaded)
|
||||
}
|
||||
off = true
|
||||
_ = tt.Click("Open treaty.pdf, 2.5 MB")
|
||||
_ = tt.Click("Download treaty.pdf")
|
||||
if opened != 2 || downloaded != 1 {
|
||||
t.Fatalf("disabled file acted: opened %d downloaded %d", opened, downloaded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileMessageIconsAndSizes(t *testing.T) {
|
||||
for name, want := range map[string]string{"a.png": "image", "b.zip": "file-archive", "c.go": "file-code", "d.md": "file-text", "e.bin": "file", "f.c": "file-code", "g.s": "file"} {
|
||||
if got := fileMessageIcon(name, ""); got != want {
|
||||
t.Errorf("%s: icon %s, want %s", name, got, want)
|
||||
}
|
||||
}
|
||||
if fileMessageIcon("x", "image/heic") != "image" {
|
||||
t.Error("MIME type ignored")
|
||||
}
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
for _, n := range []int64{0, 1023, 1024, 5 << 30} {
|
||||
ui.Text(c, fileMessageSize(c, n))
|
||||
}
|
||||
}, 200, 200)
|
||||
for _, s := range []string{"0 B", "1023 B", "1.0 KB", "5.0 GB"} {
|
||||
if !tt.HasText(s) {
|
||||
t.Errorf("missing size %q in %q", s, tt.Texts())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileMessageNeedsName(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic without a name")
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
FileMessage(c, FileMessageOptions{})
|
||||
}, 100, 100)
|
||||
}
|
||||
|
||||
func TestFileMessageSnapshot(t *testing.T) {
|
||||
snapshot(t, "file_message", 400, 160, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Gap(8).Children(func() {
|
||||
FileMessage(c, FileMessageOptions{Name: "seating-plan.xlsx", Size: 48_213})
|
||||
FileMessage(c, FileMessageOptions{Name: "portrait.png", Size: 3_400_000})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<svg
|
||||
class="lucide lucide-brain"
|
||||
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"
|
||||
>
|
||||
<path d="M12 18V5" />
|
||||
<path d="M15 13a4.17 4.17 0 0 1-3-4 4.17 4.17 0 0 1-3 4" />
|
||||
<path d="M17.598 6.5A3 3 0 1 0 12 5a3 3 0 1 0-5.598 1.5" />
|
||||
<path d="M17.997 5.125a4 4 0 0 1 2.526 5.77" />
|
||||
<path d="M18 18a4 4 0 0 0 2-7.464" />
|
||||
<path d="M19.967 17.483A4 4 0 1 1 12 18a4 4 0 1 1-7.967-.517" />
|
||||
<path d="M6 18a4 4 0 0 1-2-7.464" />
|
||||
<path d="M6.003 5.125a4 4 0 0 0-2.526 5.77" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 631 B |
@@ -0,0 +1,16 @@
|
||||
<svg
|
||||
class="lucide lucide-download"
|
||||
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"
|
||||
>
|
||||
<path d="M12 15V3" />
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<path d="m7 10 5 5 5-5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 351 B |
@@ -0,0 +1,19 @@
|
||||
<svg
|
||||
class="lucide lucide-file-archive"
|
||||
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"
|
||||
>
|
||||
<path d="M13.659 22H18a2 2 0 0 0 2-2V8a2.4 2.4 0 0 0-.706-1.706l-3.588-3.588A2.4 2.4 0 0 0 14 2H6a2 2 0 0 0-2 2v11.5" />
|
||||
<path d="M14 2v5a1 1 0 0 0 1 1h5" />
|
||||
<path d="M8 12v-1" />
|
||||
<path d="M8 18v-2" />
|
||||
<path d="M8 7V6" />
|
||||
<circle cx="8" cy="20" r="2" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 511 B |
@@ -0,0 +1,17 @@
|
||||
<svg
|
||||
class="lucide lucide-file-code"
|
||||
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"
|
||||
>
|
||||
<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z" />
|
||||
<path d="M14 2v5a1 1 0 0 0 1 1h5" />
|
||||
<path d="M10 12.5 8 15l2 2.5" />
|
||||
<path d="m14 12.5 2 2.5-2 2.5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 478 B |
@@ -0,0 +1,15 @@
|
||||
<svg
|
||||
class="lucide lucide-pause"
|
||||
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"
|
||||
>
|
||||
<rect x="14" y="3" width="5" height="18" rx="1" />
|
||||
<rect x="5" y="3" width="5" height="18" rx="1" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 343 B |
@@ -0,0 +1,15 @@
|
||||
<svg
|
||||
class="lucide lucide-pencil"
|
||||
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"
|
||||
>
|
||||
<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" />
|
||||
<path d="m15 5 4 4" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 408 B |
@@ -0,0 +1,14 @@
|
||||
<svg
|
||||
class="lucide lucide-play"
|
||||
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"
|
||||
>
|
||||
<path d="M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 335 B |
@@ -0,0 +1,18 @@
|
||||
<svg
|
||||
class="lucide lucide-share-2"
|
||||
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"
|
||||
>
|
||||
<circle cx="18" cy="5" r="3" />
|
||||
<circle cx="6" cy="12" r="3" />
|
||||
<circle cx="18" cy="19" r="3" />
|
||||
<line x1="8.59" x2="15.42" y1="13.51" y2="17.49" />
|
||||
<line x1="15.41" x2="8.59" y1="6.51" y2="10.49" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 450 B |
@@ -0,0 +1,15 @@
|
||||
<svg
|
||||
class="lucide lucide-thumbs-down"
|
||||
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"
|
||||
>
|
||||
<path d="M9 18.12 10 14H4.17a2 2 0 0 1-1.92-2.56l2.33-8A2 2 0 0 1 6.5 2H20a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-2.76a2 2 0 0 0-1.79 1.11L12 22a3.13 3.13 0 0 1-3-3.88Z" />
|
||||
<path d="M17 14V2" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 435 B |
@@ -0,0 +1,15 @@
|
||||
<svg
|
||||
class="lucide lucide-thumbs-up"
|
||||
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"
|
||||
>
|
||||
<path d="M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88Z" />
|
||||
<path d="M7 10v12" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 434 B |
@@ -0,0 +1,16 @@
|
||||
<svg
|
||||
class="lucide lucide-volume-2"
|
||||
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"
|
||||
>
|
||||
<path d="M11 4.702a.705.705 0 0 0-1.203-.498L6.413 7.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.705.705 0 0 0 11 19.298z" />
|
||||
<path d="M16 9a5 5 0 0 1 0 6" />
|
||||
<path d="M19.364 18.364a9 9 0 0 0 0-12.728" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 509 B |
@@ -0,0 +1,17 @@
|
||||
<svg
|
||||
class="lucide lucide-wrap-text"
|
||||
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"
|
||||
>
|
||||
<path d="m16 16-3 3 3 3" />
|
||||
<path d="M3 12h14.5a1 1 0 0 1 0 7H13" />
|
||||
<path d="M3 19h6" />
|
||||
<path d="M3 5h18" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 361 B |
@@ -0,0 +1,103 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// GridImage is one picture of an ImageGrid; the caller loads it.
|
||||
type GridImage struct {
|
||||
Source ui.ImageSource
|
||||
Alt string
|
||||
// Loading and Err show the caller's load in progress or failure.
|
||||
Loading bool
|
||||
Err error
|
||||
}
|
||||
|
||||
// ImageGridResult is an ImageGrid's element and a retry the user asked for.
|
||||
type ImageGridResult struct {
|
||||
Element *ui.Element
|
||||
retry int
|
||||
}
|
||||
|
||||
// Retried returns the index of the failed picture whose retry was pressed this frame.
|
||||
func (r ImageGridResult) Retried() (int, bool) { return r.retry, r.retry >= 0 }
|
||||
|
||||
// imageGridState is the viewer opened from the grid.
|
||||
type imageGridState struct {
|
||||
open bool
|
||||
index int
|
||||
}
|
||||
|
||||
// ImageGrid lays out 1 to 9 pictures; a click on a loaded one opens the ImageViewer.
|
||||
func ImageGrid(c *ui.Context, images []GridImage) ImageGridResult {
|
||||
n := len(images)
|
||||
if n < 1 || n > 9 {
|
||||
panic("mujica: ImageGrid shows 1 to 9 images")
|
||||
}
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
cols := 3
|
||||
switch n {
|
||||
case 1:
|
||||
cols = 1
|
||||
case 2, 4:
|
||||
cols = 2
|
||||
}
|
||||
cell := float32(120)
|
||||
if n == 1 {
|
||||
cell = 280
|
||||
}
|
||||
r := ImageGridResult{retry: -1}
|
||||
r.Element = ui.Grid(c).Columns(cols).Gap(u).AlignSelf(ui.Start)
|
||||
st := ui.Local(r.Element, "viewer", func() imageGridState { return imageGridState{} })
|
||||
var viewable []ViewerImage
|
||||
var at []int
|
||||
for i, im := range images {
|
||||
if im.Source != nil && !im.Loading && im.Err == nil {
|
||||
at = append(at, i)
|
||||
viewable = append(viewable, ViewerImage{Source: im.Source, Alt: im.Alt})
|
||||
}
|
||||
}
|
||||
disabled := r.Element.IsDisabled()
|
||||
r.Element.Children(func() {
|
||||
for i, im := range images {
|
||||
loaded := im.Source != nil && !im.Loading && im.Err == nil
|
||||
// Only a loaded picture is a button; a failed one keeps its retry.
|
||||
var b *ui.Element
|
||||
if loaded {
|
||||
b = ui.ButtonBase(c).Label(im.Alt)
|
||||
internal.Ring(b, k.Focus, theme.ControlRadius)
|
||||
} else {
|
||||
b = ui.Box(c)
|
||||
}
|
||||
b.Size(cell, cell).Radius(theme.ControlRadius).Clip().Children(func() {
|
||||
v := Image(c, im.Source, ImageOptions{Width: cell, Height: cell, Crop: true, Alt: im.Alt, Loading: im.Loading, Err: im.Err})
|
||||
if v.Retry && !disabled {
|
||||
r.retry = i
|
||||
slog.Debug("mujica: image_grid retry", "index", i)
|
||||
}
|
||||
})
|
||||
if loaded && b.Clicked() && !disabled {
|
||||
for vi, ii := range at {
|
||||
if ii == i {
|
||||
st.open, st.index = true, vi
|
||||
}
|
||||
}
|
||||
slog.Debug("mujica: image_grid open", "index", i)
|
||||
}
|
||||
}
|
||||
})
|
||||
if disabled {
|
||||
st.open = false
|
||||
}
|
||||
if st.open && len(viewable) > 0 {
|
||||
st.index = min(st.index, len(viewable)-1)
|
||||
ImageViewer(c, &st.open, &st.index, viewable)
|
||||
}
|
||||
return r
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
// imageGridPicture is a plain colored picture standing in for a photo.
|
||||
func imageGridPicture(color string) ui.ImageSource {
|
||||
return ui.MustParseSVG([]byte(fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" width="200" height="150"><rect width="200" height="150" fill="%s"/></svg>`, color)))
|
||||
}
|
||||
|
||||
func imageGridSet(n int) []GridImage {
|
||||
colors := []string{"#7D2034", "#AD9164", "#355E80", "#326247", "#805714", "#6A5B55", "#9C3C53", "#D2C3AF", "#211C1D"}
|
||||
out := make([]GridImage, n)
|
||||
for i := range out {
|
||||
out[i] = GridImage{Source: imageGridPicture(colors[i]), Alt: fmt.Sprintf("Photo %d", i+1)}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestImageGridLayoutsByCount(t *testing.T) {
|
||||
for n, cols := range map[int]int{1: 1, 2: 2, 3: 3, 4: 2, 5: 3, 9: 3} {
|
||||
var xs map[float32]bool
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ImageGrid(c, imageGridSet(n))
|
||||
}, 900, 900)
|
||||
tt.Frame()
|
||||
xs = map[float32]bool{}
|
||||
for i := range n {
|
||||
r, ok := tt.Find(fmt.Sprintf("Photo %d", i+1))
|
||||
if !ok {
|
||||
t.Fatalf("%d images: Photo %d missing", n, i+1)
|
||||
}
|
||||
xs[r.X] = true
|
||||
}
|
||||
if len(xs) != cols {
|
||||
t.Errorf("%d images in %d columns, want %d", n, len(xs), cols)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageGridOpensViewerAndRetries(t *testing.T) {
|
||||
imgs := imageGridSet(3)
|
||||
imgs[1] = GridImage{Alt: "Lost", Err: errors.New("404")}
|
||||
retried := -1
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
if i, ok := ImageGrid(c, imgs).Retried(); ok {
|
||||
retried = i
|
||||
}
|
||||
}, 800, 600)
|
||||
if err := tt.Click("Photo 3"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !tt.HasText("Image 2 of 2") {
|
||||
t.Fatalf("viewer not at the third picture (second viewable): %q", tt.Texts())
|
||||
}
|
||||
tt.Key(0, ui.KeyEscape)
|
||||
if err := tt.Click("Retry"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if retried != 1 {
|
||||
t.Fatalf("retried %d, want 1", retried)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageGridCountLimits(t *testing.T) {
|
||||
for _, n := range []int{0, 10} {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Errorf("%d images: no panic", n)
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ImageGrid(c, make([]GridImage, n))
|
||||
}, 100, 100)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageGridSnapshot(t *testing.T) {
|
||||
imgs := imageGridSet(5)
|
||||
imgs[3] = GridImage{Alt: "Loading", Loading: true}
|
||||
imgs[4] = GridImage{Alt: "Lost", Err: errors.New("404")}
|
||||
snapshot(t, "image_grid", 420, 300, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Children(func() { ImageGrid(c, imgs) })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// BlockKind is the kind of a Markdown block.
|
||||
type BlockKind uint8
|
||||
|
||||
const (
|
||||
BlockParagraph BlockKind = iota
|
||||
BlockHeading
|
||||
BlockList
|
||||
BlockQuote
|
||||
BlockCode
|
||||
BlockTable
|
||||
BlockRule
|
||||
)
|
||||
|
||||
// MdSpan is a run of inline text with one style.
|
||||
type MdSpan struct {
|
||||
Text string
|
||||
// URL is set for a link.
|
||||
URL string
|
||||
Strong, Em, Code, Strike bool
|
||||
}
|
||||
|
||||
// MdItem is one list item.
|
||||
type MdItem struct {
|
||||
// Number is the ordinal of an ordered item, 0 for a bullet.
|
||||
Number int
|
||||
Depth int
|
||||
Spans []MdSpan
|
||||
}
|
||||
|
||||
// TableAlign is a table column's alignment.
|
||||
type TableAlign uint8
|
||||
|
||||
const (
|
||||
AlignLeft TableAlign = iota
|
||||
AlignCenter
|
||||
AlignRight
|
||||
)
|
||||
|
||||
// MdBlock is one parsed block.
|
||||
type MdBlock struct {
|
||||
Kind BlockKind
|
||||
// Level is a heading's level, 1 to 6.
|
||||
Level int
|
||||
// Spans hold a paragraph's, heading's or quote's text.
|
||||
Spans []MdSpan
|
||||
Items []MdItem
|
||||
// Lang and Code hold a fenced code block; Open is true until its fence closes.
|
||||
Lang, Code string
|
||||
Open bool
|
||||
Header []string
|
||||
Align []TableAlign
|
||||
Rows [][]string
|
||||
}
|
||||
|
||||
// Markdown is a document parsed incrementally as its source grows.
|
||||
type Markdown struct {
|
||||
src string
|
||||
Blocks []MdBlock
|
||||
starts []int
|
||||
}
|
||||
|
||||
// Update parses src and returns the index of the first block parsed anew.
|
||||
// When src extends the last source, blocks before the last stay as they are.
|
||||
func (m *Markdown) Update(src string) int {
|
||||
if src == m.src && m.starts != nil {
|
||||
return len(m.Blocks)
|
||||
}
|
||||
from := 0
|
||||
if len(m.Blocks) > 0 && strings.HasPrefix(src, m.src) {
|
||||
from = len(m.Blocks) - 1
|
||||
}
|
||||
off := 0
|
||||
if from > 0 {
|
||||
off = m.starts[from]
|
||||
}
|
||||
m.Blocks, m.starts = m.Blocks[:from], m.starts[:from]
|
||||
m.src = src
|
||||
parseBlocks(m, src, off)
|
||||
return from
|
||||
}
|
||||
|
||||
// mdLine is one source line and its byte offset.
|
||||
type mdLine struct {
|
||||
text string
|
||||
off int
|
||||
}
|
||||
|
||||
func parseBlocks(m *Markdown, src string, off int) {
|
||||
var lines []mdLine
|
||||
for off < len(src) {
|
||||
end := strings.IndexByte(src[off:], '\n')
|
||||
if end < 0 {
|
||||
lines = append(lines, mdLine{src[off:], off})
|
||||
break
|
||||
}
|
||||
lines = append(lines, mdLine{strings.TrimRight(src[off:off+end], "\r"), off})
|
||||
off += end + 1
|
||||
}
|
||||
for i := 0; i < len(lines); {
|
||||
l := lines[i].text
|
||||
if strings.TrimSpace(l) == "" {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
start := lines[i].off
|
||||
var b MdBlock
|
||||
switch {
|
||||
case mdFence(l) != "":
|
||||
b, i = parseFenced(lines, i)
|
||||
case mdHeadingLevel(l) > 0:
|
||||
lv := mdHeadingLevel(l)
|
||||
b = MdBlock{Kind: BlockHeading, Level: lv, Spans: ParseInline(strings.TrimSpace(strings.TrimRight(l[lv:], "# ")))}
|
||||
i++
|
||||
case mdIsRule(l):
|
||||
b = MdBlock{Kind: BlockRule}
|
||||
i++
|
||||
case strings.HasPrefix(strings.TrimLeft(l, " "), ">"):
|
||||
b, i = parseQuote(lines, i)
|
||||
case mdListMarker(l) != nil:
|
||||
b, i = parseList(lines, i)
|
||||
case i+1 < len(lines) && strings.Contains(l, "|") && mdDelimiter(lines[i+1].text) != nil:
|
||||
b, i = parseTable(lines, i)
|
||||
default:
|
||||
b, i = parseParagraph(lines, i)
|
||||
}
|
||||
m.Blocks = append(m.Blocks, b)
|
||||
m.starts = append(m.starts, start)
|
||||
}
|
||||
}
|
||||
|
||||
// mdStarts reports whether line l begins a block other than a paragraph.
|
||||
func mdStarts(lines []mdLine, i int) bool {
|
||||
l := lines[i].text
|
||||
return strings.TrimSpace(l) == "" || mdFence(l) != "" || mdHeadingLevel(l) > 0 || mdIsRule(l) ||
|
||||
strings.HasPrefix(strings.TrimLeft(l, " "), ">") || mdListMarker(l) != nil ||
|
||||
i+1 < len(lines) && strings.Contains(l, "|") && mdDelimiter(lines[i+1].text) != nil
|
||||
}
|
||||
|
||||
func parseParagraph(lines []mdLine, i int) (MdBlock, int) {
|
||||
var parts []string
|
||||
for ; i < len(lines) && (len(parts) == 0 || !mdStarts(lines, i)); i++ {
|
||||
parts = append(parts, strings.TrimSpace(lines[i].text))
|
||||
}
|
||||
return MdBlock{Kind: BlockParagraph, Spans: ParseInline(strings.Join(parts, " "))}, i
|
||||
}
|
||||
|
||||
// mdFence returns the fence a line opens, "```" or "~~~", or "".
|
||||
func mdFence(l string) string {
|
||||
t := strings.TrimLeft(l, " ")
|
||||
for _, f := range []string{"```", "~~~"} {
|
||||
if strings.HasPrefix(t, f) {
|
||||
return f
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func parseFenced(lines []mdLine, i int) (MdBlock, int) {
|
||||
fence := mdFence(lines[i].text)
|
||||
b := MdBlock{Kind: BlockCode, Open: true, Lang: strings.ToLower(strings.TrimSpace(strings.TrimLeft(strings.TrimSpace(lines[i].text), fence[:1])))}
|
||||
var code []string
|
||||
for i++; i < len(lines); i++ {
|
||||
if strings.HasPrefix(strings.TrimSpace(lines[i].text), fence) && strings.Trim(strings.TrimSpace(lines[i].text), fence[:1]) == "" {
|
||||
b.Open = false
|
||||
i++
|
||||
break
|
||||
}
|
||||
code = append(code, lines[i].text)
|
||||
}
|
||||
b.Code = strings.Join(code, "\n")
|
||||
return b, i
|
||||
}
|
||||
|
||||
func mdHeadingLevel(l string) int {
|
||||
n := 0
|
||||
for n < len(l) && l[n] == '#' {
|
||||
n++
|
||||
}
|
||||
if n == 0 || n > 6 || n < len(l) && l[n] != ' ' {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func mdIsRule(l string) bool {
|
||||
t := strings.ReplaceAll(strings.TrimSpace(l), " ", "")
|
||||
if len(t) < 3 {
|
||||
return false
|
||||
}
|
||||
return strings.Count(t, t[:1]) == len(t) && strings.ContainsAny(t[:1], "-*_")
|
||||
}
|
||||
|
||||
func parseQuote(lines []mdLine, i int) (MdBlock, int) {
|
||||
var parts []string
|
||||
for ; i < len(lines); i++ {
|
||||
t := strings.TrimLeft(lines[i].text, " ")
|
||||
if !strings.HasPrefix(t, ">") {
|
||||
break
|
||||
}
|
||||
parts = append(parts, strings.TrimSpace(t[1:]))
|
||||
}
|
||||
return MdBlock{Kind: BlockQuote, Spans: ParseInline(strings.Join(parts, " "))}, i
|
||||
}
|
||||
|
||||
// mdMarker is a list item's marker.
|
||||
type mdMarker struct {
|
||||
number, depth, width int
|
||||
}
|
||||
|
||||
func mdListMarker(l string) *mdMarker {
|
||||
indent := len(l) - len(strings.TrimLeft(l, " "))
|
||||
t := l[indent:]
|
||||
if len(t) >= 2 && strings.ContainsRune("-*+", rune(t[0])) && t[1] == ' ' {
|
||||
return &mdMarker{depth: indent / 2, width: indent + 2}
|
||||
}
|
||||
d := 0
|
||||
for d < len(t) && t[d] >= '0' && t[d] <= '9' {
|
||||
d++
|
||||
}
|
||||
if d == 0 || d > 9 || d+1 >= len(t) || !strings.ContainsRune(".)", rune(t[d])) || t[d+1] != ' ' {
|
||||
return nil
|
||||
}
|
||||
n, _ := strconv.Atoi(t[:d])
|
||||
return &mdMarker{number: max(n, 1), depth: indent / 2, width: indent + d + 2}
|
||||
}
|
||||
|
||||
func parseList(lines []mdLine, i int) (MdBlock, int) {
|
||||
b := MdBlock{Kind: BlockList}
|
||||
var texts []string
|
||||
for ; i < len(lines); i++ {
|
||||
l := lines[i].text
|
||||
if mk := mdListMarker(l); mk != nil {
|
||||
b.Items = append(b.Items, MdItem{Number: mk.number, Depth: mk.depth})
|
||||
texts = append(texts, strings.TrimSpace(l[mk.width:]))
|
||||
continue
|
||||
}
|
||||
if mdStarts(lines, i) {
|
||||
break
|
||||
}
|
||||
// A lazy line continues the item above.
|
||||
texts[len(texts)-1] += " " + strings.TrimSpace(l)
|
||||
}
|
||||
for j := range b.Items {
|
||||
b.Items[j].Spans = ParseInline(texts[j])
|
||||
}
|
||||
return b, i
|
||||
}
|
||||
|
||||
// mdCells splits a table row into trimmed cells.
|
||||
func mdCells(l string) []string {
|
||||
t := strings.TrimSpace(l)
|
||||
t = strings.TrimSuffix(strings.TrimPrefix(t, "|"), "|")
|
||||
cells := strings.Split(t, "|")
|
||||
for i := range cells {
|
||||
cells[i] = strings.TrimSpace(cells[i])
|
||||
}
|
||||
return cells
|
||||
}
|
||||
|
||||
// mdDelimiter parses a table's delimiter row, or returns nil.
|
||||
func mdDelimiter(l string) []TableAlign {
|
||||
if !strings.Contains(l, "-") {
|
||||
return nil
|
||||
}
|
||||
var out []TableAlign
|
||||
for _, c := range mdCells(l) {
|
||||
left, right := strings.HasPrefix(c, ":"), strings.HasSuffix(c, ":")
|
||||
if strings.Trim(c, ":") == "" || strings.Trim(c, ":-") != "" {
|
||||
return nil
|
||||
}
|
||||
switch {
|
||||
case left && right:
|
||||
out = append(out, AlignCenter)
|
||||
case right:
|
||||
out = append(out, AlignRight)
|
||||
default:
|
||||
out = append(out, AlignLeft)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parseTable(lines []mdLine, i int) (MdBlock, int) {
|
||||
b := MdBlock{Kind: BlockTable, Header: mdCells(lines[i].text), Align: mdDelimiter(lines[i+1].text)}
|
||||
n := len(b.Header)
|
||||
for len(b.Align) < n {
|
||||
b.Align = append(b.Align, AlignLeft)
|
||||
}
|
||||
b.Align = b.Align[:n]
|
||||
for i += 2; i < len(lines) && strings.Contains(lines[i].text, "|") && strings.TrimSpace(lines[i].text) != ""; i++ {
|
||||
row := mdCells(lines[i].text)
|
||||
for len(row) < n {
|
||||
row = append(row, "")
|
||||
}
|
||||
b.Rows = append(b.Rows, row[:n])
|
||||
}
|
||||
return b, i
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package internal
|
||||
|
||||
import "strings"
|
||||
|
||||
// mdStyle is the inline style in effect.
|
||||
type mdStyle struct{ strong, em, strike bool }
|
||||
|
||||
// ParseInline splits text into styled spans: emphasis, code, strikethrough and links.
|
||||
// An opener without its closer stays literal, as while a message streams in.
|
||||
func ParseInline(s string) []MdSpan {
|
||||
var out []MdSpan
|
||||
parseInline(s, mdStyle{}, &out)
|
||||
return out
|
||||
}
|
||||
|
||||
func mdEmit(out *[]MdSpan, sp MdSpan) {
|
||||
if sp.Text == "" {
|
||||
return
|
||||
}
|
||||
if n := len(*out); n > 0 {
|
||||
last := &(*out)[n-1]
|
||||
if sp.URL == "" && last.URL == "" && last.Strong == sp.Strong && last.Em == sp.Em && last.Code == sp.Code && last.Strike == sp.Strike {
|
||||
last.Text += sp.Text
|
||||
return
|
||||
}
|
||||
}
|
||||
*out = append(*out, sp)
|
||||
}
|
||||
|
||||
func parseInline(s string, st mdStyle, out *[]MdSpan) {
|
||||
plain := func(t string) {
|
||||
mdEmit(out, MdSpan{Text: t, Strong: st.strong, Em: st.em, Strike: st.strike})
|
||||
}
|
||||
for i := 0; i < len(s); {
|
||||
switch c := s[i]; {
|
||||
case c == '\\' && i+1 < len(s) && strings.IndexByte("\\`*_~[]()#!>-|", s[i+1]) >= 0:
|
||||
plain(s[i+1 : i+2])
|
||||
i += 2
|
||||
continue
|
||||
case c == '`':
|
||||
if end := strings.IndexByte(s[i+1:], '`'); end >= 0 {
|
||||
mdEmit(out, MdSpan{Text: s[i+1 : i+1+end], Code: true, Strong: st.strong, Em: st.em, Strike: st.strike})
|
||||
i += end + 2
|
||||
continue
|
||||
}
|
||||
case c == '[':
|
||||
if text, url, n := mdLink(s[i:]); n > 0 {
|
||||
mdEmit(out, MdSpan{Text: text, URL: url, Strong: st.strong, Em: st.em, Strike: st.strike})
|
||||
i += n
|
||||
continue
|
||||
}
|
||||
case c == '*' || c == '_' || c == '~':
|
||||
if n := mdDelimited(s, i, st, out); n > 0 {
|
||||
i += n
|
||||
continue
|
||||
}
|
||||
}
|
||||
j := i + 1
|
||||
for j < len(s) && strings.IndexByte("\\`[*_~", s[j]) < 0 {
|
||||
j++
|
||||
}
|
||||
plain(s[i:j])
|
||||
i = j
|
||||
}
|
||||
}
|
||||
|
||||
// mdDelimited parses emphasis or strikethrough opening at i and returns its length, or 0.
|
||||
func mdDelimited(s string, i int, st mdStyle, out *[]MdSpan) int {
|
||||
c := s[i]
|
||||
n := 1
|
||||
if i+1 < len(s) && s[i+1] == c {
|
||||
n = 2
|
||||
}
|
||||
if c == '~' && n != 2 {
|
||||
return 0
|
||||
}
|
||||
// Intraword underscores stay literal, as in snake_case.
|
||||
if c == '_' && i > 0 && mdWord(s[i-1]) {
|
||||
return 0
|
||||
}
|
||||
marker := s[i : i+n]
|
||||
body := i + n
|
||||
if body >= len(s) || s[body] == ' ' {
|
||||
return 0
|
||||
}
|
||||
end := -1
|
||||
for j := body + 1; j+n <= len(s); j++ {
|
||||
if s[j:j+n] == marker && s[j-1] != ' ' && (n == 2 || j+1 >= len(s) || s[j+1] != c) {
|
||||
end = j
|
||||
break
|
||||
}
|
||||
}
|
||||
if end < 0 {
|
||||
return 0
|
||||
}
|
||||
inner := st
|
||||
switch {
|
||||
case c == '~':
|
||||
inner.strike = true
|
||||
case n == 2:
|
||||
inner.strong = true
|
||||
default:
|
||||
inner.em = true
|
||||
}
|
||||
parseInline(s[body:end], inner, out)
|
||||
return end + n - i
|
||||
}
|
||||
|
||||
func mdWord(b byte) bool {
|
||||
return b >= 'a' && b <= 'z' || b >= 'A' && b <= 'Z' || b >= '0' && b <= '9'
|
||||
}
|
||||
|
||||
// mdLink parses [text](url) at the start of s and returns its length, or 0.
|
||||
func mdLink(s string) (text, url string, n int) {
|
||||
close := strings.Index(s, "](")
|
||||
if close < 1 || strings.IndexByte(s[1:close], '[') >= 0 {
|
||||
return "", "", 0
|
||||
}
|
||||
end := strings.IndexByte(s[close+2:], ')')
|
||||
if end < 0 {
|
||||
return "", "", 0
|
||||
}
|
||||
url = strings.TrimSpace(s[close+2 : close+2+end])
|
||||
if url == "" || strings.ContainsAny(url, " \n") {
|
||||
return "", "", 0
|
||||
}
|
||||
return s[1:close], url, close + 3 + end
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const mdSample = "# Title\n\nSome **bold** and *em* text with `code`\nand a [link](https://x.io).\n\n" +
|
||||
"- one\n- two\n - nested\n3. three\n\n> quoted *line*\n\n```go\nfunc f() {}\n```\n\n" +
|
||||
"| a | b |\n|:--|--:|\n| 1 | 2 |\n| 3 |\n\n---\n\nend ~~old~~ new"
|
||||
|
||||
func TestMarkdownBlocks(t *testing.T) {
|
||||
var m Markdown
|
||||
m.Update(mdSample)
|
||||
var kinds []BlockKind
|
||||
for _, b := range m.Blocks {
|
||||
kinds = append(kinds, b.Kind)
|
||||
}
|
||||
want := []BlockKind{BlockHeading, BlockParagraph, BlockList, BlockQuote, BlockCode, BlockTable, BlockRule, BlockParagraph}
|
||||
if !reflect.DeepEqual(kinds, want) {
|
||||
t.Fatalf("kinds %v, want %v", kinds, want)
|
||||
}
|
||||
b := m.Blocks
|
||||
if b[0].Level != 1 || b[0].Spans[0].Text != "Title" {
|
||||
t.Errorf("heading %+v", b[0])
|
||||
}
|
||||
p := b[1].Spans
|
||||
if p[1] != (MdSpan{Text: "bold", Strong: true}) || p[3] != (MdSpan{Text: "em", Em: true}) || p[5] != (MdSpan{Text: "code", Code: true}) {
|
||||
t.Errorf("paragraph spans %+v", p)
|
||||
}
|
||||
if p[7] != (MdSpan{Text: "link", URL: "https://x.io"}) {
|
||||
t.Errorf("link %+v", p[7])
|
||||
}
|
||||
it := b[2].Items
|
||||
if len(it) != 4 || it[2].Depth != 1 || it[2].Spans[0].Text != "nested" || it[3].Number != 3 {
|
||||
t.Errorf("list items %+v", it)
|
||||
}
|
||||
if b[3].Spans[1] != (MdSpan{Text: "line", Em: true}) {
|
||||
t.Errorf("quote %+v", b[3].Spans)
|
||||
}
|
||||
if b[4].Lang != "go" || b[4].Code != "func f() {}" || b[4].Open {
|
||||
t.Errorf("code %+v", b[4])
|
||||
}
|
||||
tb := b[5]
|
||||
if !reflect.DeepEqual(tb.Header, []string{"a", "b"}) || !reflect.DeepEqual(tb.Align, []TableAlign{AlignLeft, AlignRight}) ||
|
||||
!reflect.DeepEqual(tb.Rows, [][]string{{"1", "2"}, {"3", ""}}) {
|
||||
t.Errorf("table %+v", tb)
|
||||
}
|
||||
if b[7].Spans[1] != (MdSpan{Text: "old", Strike: true}) {
|
||||
t.Errorf("strike %+v", b[7].Spans)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownInlineLiterals(t *testing.T) {
|
||||
for in, want := range map[string]string{
|
||||
"2 * 3 * 4": "2 * 3 * 4",
|
||||
"snake_case_name": "snake_case_name",
|
||||
"**unclosed": "**unclosed",
|
||||
`\*not em\*`: "*not em*",
|
||||
"[no link]": "[no link]",
|
||||
} {
|
||||
sp := ParseInline(in)
|
||||
if len(sp) != 1 || sp[0].Text != want || sp[0].Em || sp[0].Strong {
|
||||
t.Errorf("%q gave %+v", in, sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMarkdownIncremental streams the sample byte by byte and checks finished blocks are kept.
|
||||
func TestMarkdownIncremental(t *testing.T) {
|
||||
var m Markdown
|
||||
for i := 1; i <= len(mdSample); i++ {
|
||||
before := append([]MdBlock(nil), m.Blocks...)
|
||||
from := m.Update(mdSample[:i])
|
||||
if from < len(before)-1 {
|
||||
t.Fatalf("at %d reparsed from %d of %d blocks", i, from, len(before))
|
||||
}
|
||||
for j := 0; j < from; j++ {
|
||||
if !reflect.DeepEqual(m.Blocks[j], before[j]) {
|
||||
t.Fatalf("at %d finished block %d changed", i, j)
|
||||
}
|
||||
}
|
||||
}
|
||||
var whole Markdown
|
||||
whole.Update(mdSample)
|
||||
if !reflect.DeepEqual(m.Blocks, whole.Blocks) {
|
||||
t.Fatal("streamed parse differs from a whole parse")
|
||||
}
|
||||
if m.Update(mdSample) != len(m.Blocks) {
|
||||
t.Fatal("an unchanged source parsed again")
|
||||
}
|
||||
if m.Update("# Other") != 0 || len(m.Blocks) != 1 {
|
||||
t.Fatal("a replaced source kept old blocks")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownOpenFence(t *testing.T) {
|
||||
var m Markdown
|
||||
m.Update("```py\nprint(1)\n")
|
||||
if len(m.Blocks) != 1 || !m.Blocks[0].Open || m.Blocks[0].Code != "print(1)" {
|
||||
t.Fatalf("open fence %+v", m.Blocks)
|
||||
}
|
||||
m.Update("```py\nprint(1)\n```\nafter")
|
||||
if len(m.Blocks) != 2 || m.Blocks[0].Open || !strings.Contains(m.Blocks[1].Spans[0].Text, "after") {
|
||||
t.Fatalf("closed fence %+v", m.Blocks)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// LinkPreview is a link's metadata, fetched by the caller.
|
||||
type LinkPreview struct {
|
||||
// URL is the link; required.
|
||||
URL string
|
||||
// Title is required; Description, Site and Thumbnail may be empty.
|
||||
Title, Description, Site string
|
||||
Thumbnail ui.ImageSource
|
||||
}
|
||||
|
||||
// LinkPreviewCard shows a link's site, title, description and thumbnail; Clicked opens it.
|
||||
func LinkPreviewCard(c *ui.Context, p LinkPreview) *ui.Element {
|
||||
if p.URL == "" || p.Title == "" {
|
||||
panic("mujica: LinkPreviewCard needs URL and Title")
|
||||
}
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
b := ui.ButtonBase(c).MaxWidth(420).Gap(u*3).Padding(u*2).AlignItems(ui.Start).Justify(ui.Start).
|
||||
Radius(theme.CardRadius).Background(k.Surface).Border(theme.BorderWidth, k.Border).Label(p.Title).Tooltip(p.URL).Transition(colorFade)
|
||||
internal.Ring(b, k.Focus, theme.CardRadius)
|
||||
if b.Hovered() {
|
||||
b.Background(k.SurfaceHover)
|
||||
}
|
||||
b.Children(func() {
|
||||
ui.Column(c).Grow(1).MinWidth(0).Gap(u).Children(func() {
|
||||
site := p.Site
|
||||
if site == "" {
|
||||
site = p.URL
|
||||
}
|
||||
ui.Text(c, site).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).SingleLine()
|
||||
ui.Text(c, p.Title).FontWeight(600).TextColor(k.AccentText).MaxLines(2)
|
||||
if p.Description != "" {
|
||||
ui.Text(c, p.Description).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).MaxLines(2)
|
||||
}
|
||||
})
|
||||
if p.Thumbnail != nil {
|
||||
ui.Image(c, p.Thumbnail).Fit(ui.Cover).Size(64, 64).Radius(theme.ControlRadius).Shrink(0)
|
||||
}
|
||||
})
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
var linkPreviewSample = LinkPreview{
|
||||
URL: "https://example.com/versailles", Title: "The Palace of Versailles",
|
||||
Description: "A royal château in the Île-de-France region, seat of the court from 1682.", Site: "example.com",
|
||||
}
|
||||
|
||||
func TestLinkPreviewCardShowsMetadataAndOpens(t *testing.T) {
|
||||
opened := 0
|
||||
p := linkPreviewSample
|
||||
p.Thumbnail = imageGridPicture("#7D2034")
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
if LinkPreviewCard(c, p).Clicked() {
|
||||
opened++
|
||||
}
|
||||
}, 500, 200)
|
||||
for _, s := range []string{"example.com", "The Palace of Versailles", "A royal château"} {
|
||||
if !tt.HasText(s) {
|
||||
t.Errorf("missing %q in %q", s, tt.Texts())
|
||||
}
|
||||
}
|
||||
_ = tt.Click("The Palace of Versailles")
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if opened != 2 {
|
||||
t.Fatalf("opened %d", opened)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLinkPreviewCardSiteFallsBackToURL(t *testing.T) {
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
LinkPreviewCard(c, LinkPreview{URL: "https://a.io/x", Title: "X"})
|
||||
}, 400, 200)
|
||||
if !tt.HasText("https://a.io/x") {
|
||||
t.Fatalf("texts %q", tt.Texts())
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic without a title")
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
LinkPreviewCard(c, LinkPreview{URL: "https://a.io"})
|
||||
}, 100, 100)
|
||||
}
|
||||
|
||||
func TestLinkPreviewCardSnapshot(t *testing.T) {
|
||||
p := linkPreviewSample
|
||||
p.Thumbnail = imageGridPicture("#AD9164")
|
||||
snapshot(t, "link_preview_card", 460, 130, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Children(func() { LinkPreviewCard(c, p) })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// MarkdownResult is a MarkdownView's element and the link the user opened this frame.
|
||||
type MarkdownResult struct {
|
||||
Element *ui.Element
|
||||
link string
|
||||
}
|
||||
|
||||
// Link returns the URL of the link clicked or activated this frame.
|
||||
func (r MarkdownResult) Link() (string, bool) { return r.link, r.link != "" }
|
||||
|
||||
// MarkdownView renders a Markdown subset; a growing src parses only its last block again.
|
||||
func MarkdownView(c *ui.Context, src string) MarkdownResult {
|
||||
u := Density(c).Unit()
|
||||
var r MarkdownResult
|
||||
r.Element = ui.Column(c).Gap(u * 3).MinWidth(0)
|
||||
doc := ui.Local(r.Element, "doc", func() *internal.Markdown { return &internal.Markdown{} })
|
||||
if from := (*doc).Update(src); from < len((*doc).Blocks) {
|
||||
slog.Debug("mujica: markdown_view parse", "from", from, "blocks", len((*doc).Blocks))
|
||||
}
|
||||
r.Element.Children(func() {
|
||||
for i := range (*doc).Blocks {
|
||||
b := &(*doc).Blocks[i]
|
||||
ui.Column(c).Key(i).MinWidth(0).Children(func() {
|
||||
if url := markdownBlock(c, b); url != "" {
|
||||
r.link = url
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
if r.link != "" {
|
||||
slog.Debug("mujica: markdown_view link")
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// markdownBlock builds one block and returns a link opened in it.
|
||||
func markdownBlock(c *ui.Context, b *internal.MdBlock) string {
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
switch b.Kind {
|
||||
case internal.BlockHeading:
|
||||
size := []float32{theme.H1Size, theme.H2Size, theme.H3Size}[min(b.Level, 3)-1]
|
||||
t := ui.RichText(c).Font(theme.SerifFont).FontSize(FontSize(c, size)).FontWeight(600).Role(ui.RoleText)
|
||||
return markdownSpans(c, t, b.Spans)
|
||||
case internal.BlockParagraph:
|
||||
return markdownSpans(c, ui.RichText(c), b.Spans)
|
||||
case internal.BlockQuote:
|
||||
var link string
|
||||
ui.Row(c).Padding(0, 0, 0, u*3).BorderWidth(0, 0, 0, 3).BorderColor(k.Border).Children(func() {
|
||||
link = markdownSpans(c, ui.RichText(c).TextColor(k.TextMuted).Grow(1), b.Spans)
|
||||
})
|
||||
return link
|
||||
case internal.BlockList:
|
||||
var link string
|
||||
ui.Column(c).Gap(u).Role(ui.RoleList).Children(func() {
|
||||
for _, it := range b.Items {
|
||||
ui.Row(c).Gap(u*2).AlignItems(ui.Start).Padding(0, 0, 0, float32(it.Depth)*u*5).Role(ui.RoleListItem).Children(func() {
|
||||
mark := "•"
|
||||
if it.Number > 0 {
|
||||
mark = fmt.Sprintf("%d.", it.Number)
|
||||
}
|
||||
ui.Text(c, mark).TextColor(k.TextMuted).FontFeatures("tnum").MinWidth(u * 4).Shrink(0)
|
||||
if l := markdownSpans(c, ui.RichText(c).Grow(1).MinWidth(0), it.Spans); l != "" {
|
||||
link = l
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
return link
|
||||
case internal.BlockCode:
|
||||
CodeBlock(c, b.Code, CodeBlockOptions{Language: b.Lang})
|
||||
case internal.BlockTable:
|
||||
align := make([]ui.Align, len(b.Align))
|
||||
for i, a := range b.Align {
|
||||
align[i] = []ui.Align{ui.Start, ui.Center, ui.End}[a]
|
||||
}
|
||||
TableBlock(c, b.Header, b.Rows, TableBlockOptions{Align: align})
|
||||
case internal.BlockRule:
|
||||
ui.Box(c).Height(u * 4).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
internal.RuleDiamond(p, r, u*2, 5, k.Ornament)
|
||||
})
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// markdownSpans fills paragraph t with styled spans; links are focusable and return their URL when used.
|
||||
func markdownSpans(c *ui.Context, t *ui.Element, spans []internal.MdSpan) string {
|
||||
k := Tokens(c)
|
||||
var link string
|
||||
t.MinWidth(0).Children(func() {
|
||||
for _, sp := range spans {
|
||||
e := ui.Text(c, sp.Text)
|
||||
if sp.Strong {
|
||||
e.FontWeight(700)
|
||||
}
|
||||
if sp.Em {
|
||||
e.Italic()
|
||||
}
|
||||
if sp.Strike {
|
||||
e.Strikethrough()
|
||||
}
|
||||
if sp.Code {
|
||||
e.Font(theme.MonoFont).TextBackground(k.SurfaceHover)
|
||||
}
|
||||
if sp.URL == "" {
|
||||
continue
|
||||
}
|
||||
e.TextColor(k.AccentText).Underline().Focusable().Role(ui.RoleLink).Cursor(ui.CursorPointer).Tooltip(sp.URL)
|
||||
if e.Clicked() && !e.IsDisabled() {
|
||||
link = sp.URL
|
||||
}
|
||||
}
|
||||
})
|
||||
return link
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
const markdownViewSample = "# Decree\n\nBy order of **the Queen**, *all* guests use `the east door`.\n\n" +
|
||||
"- Arrive at nine\n- Wear [court dress](https://example.com/dress)\n\n> Silence in the hall.\n\n" +
|
||||
"```go\nfunc bow() {}\n```\n\n| Room | Floor |\n|---|--:|\n| Hall | 1 |\n\n---\n\nEnd."
|
||||
|
||||
func TestMarkdownViewRendersSubset(t *testing.T) {
|
||||
src := markdownViewSample
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Scroll(c).Fill().Children(func() { MarkdownView(c, src) })
|
||||
}, 600, 900)
|
||||
for _, s := range []string{"Decree", "the Queen", "all", "the east door", "Arrive at nine", "court dress",
|
||||
"Silence in the hall.", "func", "bow", "Room", "Hall", "End."} {
|
||||
if !tt.HasText(s) {
|
||||
t.Errorf("missing %q in %q", s, tt.Texts())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownViewLinkByClickAndKeyboard(t *testing.T) {
|
||||
var got []string
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
if url, ok := MarkdownView(c, "See [the guide](https://x.io/guide).").Link(); ok {
|
||||
got = append(got, url)
|
||||
}
|
||||
}, 400, 200)
|
||||
if err := tt.Click("the guide"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if len(got) != 2 || got[0] != "https://x.io/guide" || got[1] != got[0] {
|
||||
t.Fatalf("links %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownViewStreamsWithoutReparsingFinishedBlocks(t *testing.T) {
|
||||
src := ""
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Scroll(c).Fill().Children(func() { MarkdownView(c, src) })
|
||||
}, 600, 900)
|
||||
for _, chunk := range strings.SplitAfter(markdownViewSample, " ") {
|
||||
src += chunk
|
||||
tt.Frame()
|
||||
}
|
||||
if !tt.HasText("End.") || !tt.HasText("Decree") {
|
||||
t.Fatalf("streamed render incomplete: %q", tt.Texts())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownViewSnapshot(t *testing.T) {
|
||||
snapshot(t, "markdown_view", 560, 720, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Children(func() { MarkdownView(c, markdownViewSample) })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
)
|
||||
|
||||
// MessageAction is one action on a chat message.
|
||||
type MessageAction int
|
||||
|
||||
const (
|
||||
ActionCopy MessageAction = iota + 1
|
||||
ActionRegenerate
|
||||
ActionEdit
|
||||
ActionLike
|
||||
ActionDislike
|
||||
ActionSpeak
|
||||
ActionShare
|
||||
)
|
||||
|
||||
// MessageFeedback is the user's rating of an assistant message.
|
||||
type MessageFeedback int
|
||||
|
||||
const (
|
||||
FeedbackNone MessageFeedback = iota
|
||||
FeedbackLiked
|
||||
FeedbackDisliked
|
||||
)
|
||||
|
||||
// MessageActionsOptions configure MessageActions.
|
||||
type MessageActionsOptions struct {
|
||||
// Feedback holds the rating; nil hides like and dislike.
|
||||
Feedback *MessageFeedback
|
||||
}
|
||||
|
||||
// MessageActionsResult is the toolbar's element and the action chosen this frame.
|
||||
type MessageActionsResult struct {
|
||||
Element *ui.Element
|
||||
action MessageAction
|
||||
}
|
||||
|
||||
// Action returns the action the user chose this frame.
|
||||
func (r MessageActionsResult) Action() (MessageAction, bool) { return r.action, r.action != 0 }
|
||||
|
||||
// messageActionIcons names each action's icon and copy key.
|
||||
var messageActionIcons = map[MessageAction][2]string{
|
||||
ActionCopy: {"copy", "message_actions.copy"},
|
||||
ActionRegenerate: {"refresh-cw", "message_actions.regenerate"},
|
||||
ActionEdit: {"pencil", "message_actions.edit"},
|
||||
ActionLike: {"thumbs-up", "message_actions.like"},
|
||||
ActionDislike: {"thumbs-down", "message_actions.dislike"},
|
||||
ActionSpeak: {"volume-2", "message_actions.speak"},
|
||||
ActionShare: {"share-2", "message_actions.share"},
|
||||
}
|
||||
|
||||
// messageActionsFor lists the actions a role's messages offer.
|
||||
func messageActionsFor(role MessageRole, rated bool) []MessageAction {
|
||||
switch role {
|
||||
case MessageUser:
|
||||
return []MessageAction{ActionCopy, ActionEdit}
|
||||
case MessageAssistant:
|
||||
if rated {
|
||||
return []MessageAction{ActionCopy, ActionRegenerate, ActionLike, ActionDislike, ActionSpeak, ActionShare}
|
||||
}
|
||||
return []MessageAction{ActionCopy, ActionRegenerate, ActionSpeak, ActionShare}
|
||||
case MessageSystem:
|
||||
return []MessageAction{ActionCopy}
|
||||
case MessageError:
|
||||
return []MessageAction{ActionCopy, ActionRegenerate}
|
||||
}
|
||||
panic("mujica: unknown MessageRole")
|
||||
}
|
||||
|
||||
// MessageActions is a toolbar of the actions a role's message offers; arrows move between them.
|
||||
func MessageActions(c *ui.Context, role MessageRole, opts MessageActionsOptions) MessageActionsResult {
|
||||
var r MessageActionsResult
|
||||
r.Element = ui.Row(c).Gap(Density(c).Unit()).AlignItems(ui.Center).FocusGroup(ui.Horizontal).
|
||||
Role(ui.RoleToolbar).Label(msg(c, "message_actions.label"))
|
||||
disabled := r.Element.IsDisabled()
|
||||
r.Element.Children(func() {
|
||||
for _, a := range messageActionsFor(role, opts.Feedback != nil) {
|
||||
ic := messageActionIcons[a]
|
||||
hit := false
|
||||
if a == ActionLike || a == ActionDislike {
|
||||
want := FeedbackLiked
|
||||
if a == ActionDislike {
|
||||
want = FeedbackDisliked
|
||||
}
|
||||
on := *opts.Feedback == want
|
||||
if codeBlockToggle(c, ic[0], msg(c, ic[1]), &on) && !disabled {
|
||||
hit = true
|
||||
if on {
|
||||
*opts.Feedback = want
|
||||
} else {
|
||||
*opts.Feedback = FeedbackNone
|
||||
}
|
||||
}
|
||||
} else {
|
||||
hit = iconAction(c, icons.Must(ic[0]), msg(c, ic[1])).Tooltip(msg(c, ic[1])).Clicked() && !disabled
|
||||
}
|
||||
if hit {
|
||||
r.action = a
|
||||
slog.Debug("mujica: message_actions action", "action", a)
|
||||
}
|
||||
}
|
||||
})
|
||||
return r
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func TestMessageActionsByRole(t *testing.T) {
|
||||
for role, want := range map[MessageRole][]string{
|
||||
MessageUser: {"Copy", "Edit"},
|
||||
MessageAssistant: {"Copy", "Regenerate", "Good response", "Bad response", "Read aloud", "Share"},
|
||||
MessageSystem: {"Copy"},
|
||||
MessageError: {"Copy", "Regenerate"},
|
||||
} {
|
||||
fb := FeedbackNone
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
MessageActions(c, role, MessageActionsOptions{Feedback: &fb})
|
||||
}, 400, 100)
|
||||
for _, name := range want {
|
||||
if _, ok := tt.Find(name); !ok {
|
||||
t.Errorf("role %v: missing %q", role, name)
|
||||
}
|
||||
}
|
||||
if _, ok := tt.Find("Edit"); ok != (role == MessageUser) {
|
||||
t.Errorf("role %v: edit shown %v", role, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageActionsChooseAndRate(t *testing.T) {
|
||||
fb := FeedbackNone
|
||||
var got []MessageAction
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
if a, ok := MessageActions(c, MessageAssistant, MessageActionsOptions{Feedback: &fb}).Action(); ok {
|
||||
got = append(got, a)
|
||||
}
|
||||
}, 400, 100)
|
||||
_ = tt.Click("Regenerate")
|
||||
_ = tt.Click("Good response")
|
||||
if fb != FeedbackLiked {
|
||||
t.Fatalf("feedback %v after like", fb)
|
||||
}
|
||||
_ = tt.Click("Bad response")
|
||||
if fb != FeedbackDisliked {
|
||||
t.Fatalf("feedback %v after dislike", fb)
|
||||
}
|
||||
_ = tt.Click("Bad response")
|
||||
if fb != FeedbackNone {
|
||||
t.Fatalf("feedback %v after undoing dislike", fb)
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyRight)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
// Tab returns to the last focused action, Dislike; Right moves to Read aloud.
|
||||
want := []MessageAction{ActionRegenerate, ActionLike, ActionDislike, ActionDislike, ActionSpeak}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("actions %v, want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("actions %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageActionsHideRatingAndDisable(t *testing.T) {
|
||||
n := 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Column(c).Disabled(true).Children(func() {
|
||||
if _, ok := MessageActions(c, MessageAssistant, MessageActionsOptions{}).Action(); ok {
|
||||
n++
|
||||
}
|
||||
})
|
||||
}, 400, 100)
|
||||
if _, ok := tt.Find("Good response"); ok {
|
||||
t.Fatal("rating shown without Feedback")
|
||||
}
|
||||
_ = tt.Click("Copy")
|
||||
if n != 0 {
|
||||
t.Fatal("disabled actions fired")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageActionsSnapshot(t *testing.T) {
|
||||
fb := FeedbackLiked
|
||||
snapshot(t, "message_actions", 300, 70, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Children(func() { MessageActions(c, MessageAssistant, MessageActionsOptions{Feedback: &fb}) })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// MessageRole is who a chat message comes from.
|
||||
type MessageRole int
|
||||
|
||||
const (
|
||||
MessageUser MessageRole = iota
|
||||
MessageAssistant
|
||||
MessageSystem
|
||||
MessageError
|
||||
)
|
||||
|
||||
func (r MessageRole) key() string {
|
||||
switch r {
|
||||
case MessageUser:
|
||||
return "message_bubble.user"
|
||||
case MessageAssistant:
|
||||
return "message_bubble.assistant"
|
||||
case MessageSystem:
|
||||
return "message_bubble.system"
|
||||
case MessageError:
|
||||
return "message_bubble.error"
|
||||
}
|
||||
panic("mujica: unknown MessageRole")
|
||||
}
|
||||
|
||||
// MessageBubbleOptions configure a MessageBubble.
|
||||
type MessageBubbleOptions struct {
|
||||
// Name shows above the bubble; empty shows none.
|
||||
Name string
|
||||
// Avatar builds the picture beside the bubble.
|
||||
Avatar func()
|
||||
// MaxWidth caps the bubble in DIPs; zero means 560.
|
||||
MaxWidth float32
|
||||
}
|
||||
|
||||
// MessageBubble frames one chat message by role: users right, others left, system centered.
|
||||
func MessageBubble(c *ui.Context, role MessageRole, opts MessageBubbleOptions, content func()) *ui.Element {
|
||||
if opts.MaxWidth < 0 {
|
||||
panic("mujica: MessageBubble MaxWidth must not be negative")
|
||||
}
|
||||
if content == nil {
|
||||
panic("mujica: MessageBubble needs content")
|
||||
}
|
||||
maxW := opts.MaxWidth
|
||||
if maxW == 0 {
|
||||
maxW = 560
|
||||
}
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
label := msg(c, role.key())
|
||||
if opts.Name != "" {
|
||||
label += ", " + opts.Name
|
||||
}
|
||||
row := ui.Row(c).Gap(u * 2).AlignItems(ui.Start).Role(ui.RoleGroup).Label(label)
|
||||
switch role {
|
||||
case MessageUser:
|
||||
row.Justify(ui.End)
|
||||
case MessageSystem:
|
||||
row.Justify(ui.Center)
|
||||
}
|
||||
row.Children(func() {
|
||||
if role == MessageSystem {
|
||||
ui.Row(c).MaxWidth(maxW).Padding(u, u*3).Gap(u * 2).Radius(theme.ControlRadius).
|
||||
Background(k.SurfaceHover).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize)).
|
||||
Children(content)
|
||||
return
|
||||
}
|
||||
if opts.Avatar != nil && role != MessageUser {
|
||||
opts.Avatar()
|
||||
}
|
||||
ui.Column(c).Gap(u).MaxWidth(maxW).Shrink(1).MinWidth(0).AlignItems(messageBubbleAlign(role)).Children(func() {
|
||||
if opts.Name != "" {
|
||||
ui.Text(c, opts.Name).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).SingleLine()
|
||||
}
|
||||
b := ui.Column(c).Padding(u*2, u*3).Gap(u * 2).Radius(theme.CardRadius).MinWidth(0)
|
||||
switch role {
|
||||
case MessageUser:
|
||||
b.Background(k.Selection)
|
||||
case MessageError:
|
||||
b.Background(k.Surface).Border(theme.BorderWidth, k.Danger)
|
||||
default:
|
||||
b.Background(k.Surface).Border(theme.BorderWidth, k.Border)
|
||||
}
|
||||
b.Children(func() {
|
||||
if role != MessageError {
|
||||
content()
|
||||
return
|
||||
}
|
||||
ui.Row(c).Gap(u * 2).AlignItems(ui.Start).Children(func() {
|
||||
ui.Icon(c, icons.Must("circle-alert")).Size(16, 16).Shrink(0).TextColor(k.Danger)
|
||||
ui.Column(c).Gap(u * 2).Grow(1).MinWidth(0).Children(content)
|
||||
})
|
||||
})
|
||||
})
|
||||
if opts.Avatar != nil && role == MessageUser {
|
||||
opts.Avatar()
|
||||
}
|
||||
})
|
||||
return row
|
||||
}
|
||||
|
||||
func messageBubbleAlign(r MessageRole) ui.Align {
|
||||
if r == MessageUser {
|
||||
return ui.End
|
||||
}
|
||||
return ui.Start
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func TestMessageBubbleRolesAlignAndLimit(t *testing.T) {
|
||||
var user, bot, sys ui.Rect
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Column(c).Width(600).Children(func() {
|
||||
MessageBubble(c, MessageUser, MessageBubbleOptions{Name: "Ada", MaxWidth: 200}, func() {
|
||||
user = ui.Text(c, "A long user message that must wrap inside the bubble's width limit.").Bounds()
|
||||
})
|
||||
MessageBubble(c, MessageAssistant, MessageBubbleOptions{Avatar: func() { Avatar(c, "Bot", AvatarOptions{}) }}, func() {
|
||||
bot = ui.Text(c, "Hello").Bounds()
|
||||
})
|
||||
MessageBubble(c, MessageSystem, MessageBubbleOptions{}, func() { sys = ui.Text(c, "Joined").Bounds() })
|
||||
MessageBubble(c, MessageError, MessageBubbleOptions{}, func() { ui.Text(c, "Timeout") })
|
||||
})
|
||||
}, 600, 400)
|
||||
tt.Frame()
|
||||
if user.W > 200 || user.X < 300 {
|
||||
t.Errorf("user bubble %+v not right-aligned within 200", user)
|
||||
}
|
||||
if bot.X > 100 || sys.X < 200 || sys.X > 300 {
|
||||
t.Errorf("assistant at %v, system at %v", bot.X, sys.X)
|
||||
}
|
||||
for _, s := range []string{"Ada", "Bot", "Error"} {
|
||||
if !tt.HasText(s) {
|
||||
t.Errorf("missing %q", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageBubbleBadOptions(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic for a negative MaxWidth")
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
MessageBubble(c, MessageUser, MessageBubbleOptions{MaxWidth: -1}, func() {})
|
||||
}, 100, 100)
|
||||
}
|
||||
|
||||
func TestMessageBubbleSnapshot(t *testing.T) {
|
||||
snapshot(t, "message_bubble", 520, 300, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Gap(12).Children(func() {
|
||||
MessageBubble(c, MessageUser, MessageBubbleOptions{Name: "Marie"}, func() { ui.Text(c, "Is the ball tonight?") })
|
||||
MessageBubble(c, MessageAssistant, MessageBubbleOptions{Name: "Herald", Avatar: func() { Avatar(c, "Herald", AvatarOptions{}) }},
|
||||
func() { ui.Text(c, "Yes, at nine, in the Hall of Mirrors.") })
|
||||
MessageBubble(c, MessageSystem, MessageBubbleOptions{}, func() { ui.Text(c, "Herald joined the chat") })
|
||||
MessageBubble(c, MessageError, MessageBubbleOptions{}, func() { ui.Text(c, "The messenger did not return.") })
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
// MessageEditorOptions configure a MessageEditor.
|
||||
type MessageEditorOptions struct {
|
||||
// Original is the sent text; saving it unchanged is not allowed.
|
||||
Original string
|
||||
}
|
||||
|
||||
// MessageEditorResult is the editor's element and its outcome this frame.
|
||||
type MessageEditorResult struct {
|
||||
Element *ui.Element
|
||||
saved, canceled bool
|
||||
}
|
||||
|
||||
// Saved reports a save; the caller makes a new branch from the draft.
|
||||
func (r MessageEditorResult) Saved() bool { return r.saved }
|
||||
|
||||
// Canceled reports Cancel or Escape.
|
||||
func (r MessageEditorResult) Canceled() bool { return r.canceled }
|
||||
|
||||
// MessageEditor edits a sent message in *draft; Cmd+Enter saves, Escape cancels.
|
||||
func MessageEditor(c *ui.Context, draft *string, opts MessageEditorOptions) MessageEditorResult {
|
||||
if draft == nil {
|
||||
panic("mujica: MessageEditor needs a draft")
|
||||
}
|
||||
u := Density(c).Unit()
|
||||
var r MessageEditorResult
|
||||
r.Element = ui.Column(c).Gap(u * 2).MinWidth(0)
|
||||
disabled := r.Element.IsDisabled()
|
||||
r.Element.Children(func() {
|
||||
area := TextArea(c, draft, TextAreaOptions{Label: msg(c, "message_editor.label"), MaxHeight: 240})
|
||||
ok := strings.TrimSpace(*draft) != "" && *draft != opts.Original
|
||||
escape := area.Input.Shortcut(0, ui.KeyEscape)
|
||||
ui.Row(c).Gap(u * 2).Justify(ui.End).Children(func() {
|
||||
if Button(c, msg(c, "message_editor.cancel"), ButtonOptions{Variant: Secondary}).Clicked() || escape {
|
||||
r.canceled = !disabled
|
||||
}
|
||||
if Button(c, msg(c, "message_editor.save"), ButtonOptions{Disabled: !ok}).Clicked() || area.Submitted() && ok {
|
||||
r.saved = !disabled
|
||||
}
|
||||
})
|
||||
})
|
||||
if r.saved {
|
||||
slog.Debug("mujica: message_editor save", "chars", len(*draft))
|
||||
}
|
||||
if r.canceled {
|
||||
slog.Debug("mujica: message_editor cancel")
|
||||
}
|
||||
return r
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func messageEditorTester(draft *string, saves, cancels *int) *ui.Tester {
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
r := MessageEditor(c, draft, MessageEditorOptions{Original: "Hello"})
|
||||
if r.Saved() {
|
||||
*saves++
|
||||
}
|
||||
if r.Canceled() {
|
||||
*cancels++
|
||||
}
|
||||
}, 400, 300)
|
||||
}
|
||||
|
||||
func TestMessageEditorSaveRules(t *testing.T) {
|
||||
draft, saves, cancels := "Hello", 0, 0
|
||||
tt := messageEditorTester(&draft, &saves, &cancels)
|
||||
_ = tt.Click("Save")
|
||||
_ = tt.Click("Edit message")
|
||||
tt.Key(ui.Cmd, ui.KeyEnter)
|
||||
if saves != 0 {
|
||||
t.Fatal("saved unchanged text")
|
||||
}
|
||||
tt.Type(" there")
|
||||
tt.Key(ui.Cmd, ui.KeyEnter)
|
||||
_ = tt.Click("Save")
|
||||
if saves != 2 || draft != "Hello there" {
|
||||
t.Fatalf("saves %d draft %q", saves, draft)
|
||||
}
|
||||
_ = tt.Click("Edit message")
|
||||
tt.Command("selectAll")
|
||||
tt.Type(" ")
|
||||
_ = tt.Click("Save")
|
||||
if saves != 2 {
|
||||
t.Fatal("saved blank text")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageEditorCancel(t *testing.T) {
|
||||
draft, saves, cancels := "Hello", 0, 0
|
||||
tt := messageEditorTester(&draft, &saves, &cancels)
|
||||
_ = tt.Click("Cancel")
|
||||
_ = tt.Click("Edit message")
|
||||
tt.Key(0, ui.KeyEscape)
|
||||
if cancels != 2 || saves != 0 {
|
||||
t.Fatalf("cancels %d saves %d", cancels, saves)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageEditorSnapshot(t *testing.T) {
|
||||
draft := "Could you seat the ambassador nearer the throne?"
|
||||
snapshot(t, "message_editor", 420, 200, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Children(func() { MessageEditor(c, &draft, MessageEditorOptions{Original: "Seat the ambassador."}) })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// MessageStatus is where a chat message is in its delivery.
|
||||
type MessageStatus int
|
||||
|
||||
const (
|
||||
// MessageNoStatus shows no status.
|
||||
MessageNoStatus MessageStatus = iota
|
||||
MessageSending
|
||||
MessageStreaming
|
||||
MessageSent
|
||||
MessageFailed
|
||||
)
|
||||
|
||||
// MessageHeaderOptions configure a MessageHeader; zero fields are hidden.
|
||||
type MessageHeaderOptions struct {
|
||||
Model string
|
||||
Time time.Time
|
||||
Status MessageStatus
|
||||
}
|
||||
|
||||
// MessageHeader shows a message's model, time and status as icon and text.
|
||||
func MessageHeader(c *ui.Context, opts MessageHeaderOptions) *ui.Element {
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
small := FontSize(c, theme.CaptionSize)
|
||||
row := ui.Row(c).Gap(u * 2).AlignItems(ui.Center).FontSize(small).TextColor(k.TextMuted)
|
||||
row.Children(func() {
|
||||
if opts.Model != "" {
|
||||
ui.Text(c, opts.Model).FontWeight(600).TextColor(k.Text).SingleLine()
|
||||
}
|
||||
if !opts.Time.IsZero() {
|
||||
ui.Text(c, opts.Time.Format(msg(c, "message_header.time"))).FontFeatures("tnum").SingleLine()
|
||||
}
|
||||
if opts.Status == MessageNoStatus {
|
||||
return
|
||||
}
|
||||
key, icon, color := "", "", k.TextMuted
|
||||
switch opts.Status {
|
||||
case MessageSending:
|
||||
key, icon = "message_header.sending", "clock"
|
||||
case MessageStreaming:
|
||||
key = "message_header.streaming"
|
||||
case MessageSent:
|
||||
key, icon = "message_header.sent", "check"
|
||||
case MessageFailed:
|
||||
key, icon, color = "message_header.failed", "circle-alert", k.Danger
|
||||
default:
|
||||
panic("mujica: unknown MessageStatus")
|
||||
}
|
||||
ui.Row(c).Gap(u).AlignItems(ui.Center).TextColor(color).Children(func() {
|
||||
if icon == "" {
|
||||
Spinner(c, SpinnerOptions{Size: 12, Label: msg(c, key)})
|
||||
} else {
|
||||
ui.Icon(c, icons.Must(icon)).Size(12, 12)
|
||||
}
|
||||
ui.Text(c, msg(c, key)).SingleLine()
|
||||
})
|
||||
})
|
||||
return row
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func TestMessageHeaderFieldsAndStatus(t *testing.T) {
|
||||
at := time.Date(2026, 3, 1, 14, 5, 0, 0, time.UTC)
|
||||
for status, want := range map[MessageStatus]string{
|
||||
MessageSending: "Sending", MessageStreaming: "Generating", MessageSent: "Sent", MessageFailed: "Failed",
|
||||
} {
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{Locale: EnUS})
|
||||
MessageHeader(c, MessageHeaderOptions{Model: "claude", Time: at, Status: status})
|
||||
}, 400, 100)
|
||||
for _, s := range []string{"claude", "14:05", want} {
|
||||
if !tt.HasText(s) {
|
||||
t.Errorf("status %v: missing %q in %q", status, s, tt.Texts())
|
||||
}
|
||||
}
|
||||
}
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
MessageHeader(c, MessageHeaderOptions{})
|
||||
}, 400, 100)
|
||||
if len(tt.Texts()) != 0 {
|
||||
t.Fatalf("zero header shows %q", tt.Texts())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageHeaderUnknownStatus(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic for an unknown status")
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
MessageHeader(c, MessageHeaderOptions{Status: 99})
|
||||
}, 100, 100)
|
||||
}
|
||||
|
||||
func TestMessageHeaderSnapshot(t *testing.T) {
|
||||
at := time.Date(2026, 3, 1, 21, 30, 0, 0, time.UTC)
|
||||
snapshot(t, "message_header", 360, 150, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Gap(12).Children(func() {
|
||||
for _, s := range []MessageStatus{MessageSending, MessageStreaming, MessageSent, MessageFailed} {
|
||||
MessageHeader(c, MessageHeaderOptions{Model: "herald-2", Time: at, Status: s})
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// MessageListState keeps a MessageList's place and what the user has seen.
|
||||
type MessageListState struct {
|
||||
list ui.ListState
|
||||
seen int
|
||||
started bool
|
||||
}
|
||||
|
||||
// ScrollToEnd shows the newest message and follows new ones again.
|
||||
func (s *MessageListState) ScrollToEnd() { s.list.ScrollToEnd() }
|
||||
|
||||
// AtEnd reports whether the newest message is in view.
|
||||
func (s *MessageListState) AtEnd() bool { return s.list.AtEnd() }
|
||||
|
||||
// MessageListOptions configure a MessageList.
|
||||
type MessageListOptions struct {
|
||||
// ID returns message i's stable identity; required.
|
||||
ID func(i int) string
|
||||
// Date returns message i's time; set, a separator heads each new day.
|
||||
Date func(i int) time.Time
|
||||
// Label returns message i's text for assistive technology and typing to find.
|
||||
Label func(i int) string
|
||||
}
|
||||
|
||||
// MessageListResult is a MessageList's element and its unread count.
|
||||
type MessageListResult struct {
|
||||
Element *ui.Element
|
||||
unread int
|
||||
}
|
||||
|
||||
// Unread returns how many messages arrived since the user scrolled away from the end.
|
||||
func (r MessageListResult) Unread() int { return r.unread }
|
||||
|
||||
// MessageList shows n messages, building only those in view, and follows the end until the user scrolls up.
|
||||
func MessageList(c *ui.Context, s *MessageListState, n int, opts MessageListOptions, row func(i int)) MessageListResult {
|
||||
if opts.ID == nil || row == nil {
|
||||
panic("mujica: MessageList needs ID and row")
|
||||
}
|
||||
if n < 0 {
|
||||
panic("mujica: MessageList with negative message count")
|
||||
}
|
||||
u := Density(c).Unit()
|
||||
s.list.Key = func(i int) any { return opts.ID(i) }
|
||||
s.list.Label = opts.Label
|
||||
s.list.FollowEnd = true
|
||||
at := !s.started || s.list.AtEnd()
|
||||
if at || n < s.seen {
|
||||
s.seen = n
|
||||
}
|
||||
s.started = true
|
||||
r := MessageListResult{unread: n - s.seen}
|
||||
r.Element = ui.Box(c).Grow(1).MinHeight(0)
|
||||
r.Element.Children(func() {
|
||||
ui.List(c, &s.list, n, func(i int) {
|
||||
ui.Column(c).Padding(u*2, u*4).Gap(u * 2).Children(func() {
|
||||
if opts.Date != nil && (i == 0 || !messageListSameDay(opts.Date(i-1), opts.Date(i))) {
|
||||
messageListDay(c, opts.Date(i))
|
||||
}
|
||||
row(i)
|
||||
})
|
||||
}).Fill().Justify(ui.End).Label(msg(c, "message_list.label"))
|
||||
jump := ScrollToBottomButton(c, !at, r.unread)
|
||||
if jump.Clicked() && !r.Element.IsDisabled() {
|
||||
s.list.ScrollToEnd()
|
||||
s.seen = n
|
||||
slog.Debug("mujica: message_list jump", "unread", r.unread)
|
||||
}
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
func messageListSameDay(a, b time.Time) bool {
|
||||
ay, am, ad := a.Date()
|
||||
by, bm, bd := b.Date()
|
||||
return ay == by && am == bm && ad == bd
|
||||
}
|
||||
|
||||
// messageListDay is a separator naming the day: today, yesterday or the date.
|
||||
func messageListDay(c *ui.Context, t time.Time) {
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
label := t.Format(msg(c, "message_list.date"))
|
||||
now := c.Now().In(t.Location())
|
||||
switch {
|
||||
case messageListSameDay(t, now):
|
||||
label = msg(c, "message_list.today")
|
||||
case messageListSameDay(t, now.AddDate(0, 0, -1)):
|
||||
label = msg(c, "message_list.yesterday")
|
||||
}
|
||||
ui.Row(c).Gap(u*3).AlignItems(ui.Center).Padding(u*2, 0).Role(ui.RoleText).Label(label).Children(func() {
|
||||
rule := func() {
|
||||
ui.Box(c).Grow(1).Height(theme.BorderWidth).Background(k.Border)
|
||||
}
|
||||
rule()
|
||||
ui.Text(c, label).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).SingleLine()
|
||||
rule()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
// messageListApp is a chat of n messages with stable IDs.
|
||||
type messageListApp struct {
|
||||
ids []string
|
||||
state MessageListState
|
||||
built int
|
||||
res MessageListResult
|
||||
}
|
||||
|
||||
func newMessageListApp(n int) *messageListApp {
|
||||
a := &messageListApp{}
|
||||
for i := range n {
|
||||
a.ids = append(a.ids, fmt.Sprintf("m%d", i))
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func (a *messageListApp) view(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
a.built = 0
|
||||
day := time.Date(2026, 3, 1, 9, 0, 0, 0, time.UTC)
|
||||
a.res = MessageList(c, &a.state, len(a.ids), MessageListOptions{
|
||||
ID: func(i int) string { return a.ids[i] },
|
||||
Date: func(i int) time.Time { return day.Add(time.Duration(i/3) * 24 * time.Hour) },
|
||||
}, func(i int) {
|
||||
a.built++
|
||||
ui.Text(c, "Message "+a.ids[i]).Padding(8)
|
||||
})
|
||||
}
|
||||
|
||||
func TestMessageListVirtualizedAndFollowsEnd(t *testing.T) {
|
||||
a := newMessageListApp(100000)
|
||||
tt := ui.NewTester(a.view, 400, 300)
|
||||
tt.Frame()
|
||||
if a.built == 0 || a.built > 60 {
|
||||
t.Fatalf("built %d rows of 100000", a.built)
|
||||
}
|
||||
if !tt.HasText("Message m99999") || !a.state.AtEnd() {
|
||||
t.Fatal("list did not start at the newest message")
|
||||
}
|
||||
a.ids = append(a.ids, "m100000")
|
||||
tt.Frame()
|
||||
tt.Frame()
|
||||
if !tt.HasText("Message m100000") || a.res.Unread() != 0 {
|
||||
t.Fatalf("new message not followed, unread %d", a.res.Unread())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageListStopsFollowingAndCountsUnread(t *testing.T) {
|
||||
a := newMessageListApp(50)
|
||||
tt := ui.NewTester(a.view, 400, 300)
|
||||
tt.Frame()
|
||||
tt.Scroll(200, 150, 0, -2000)
|
||||
tt.Frame()
|
||||
if a.state.AtEnd() {
|
||||
t.Fatal("still at end after scrolling up")
|
||||
}
|
||||
a.ids = append(a.ids, "n1", "n2")
|
||||
tt.Frame()
|
||||
tt.Frame()
|
||||
if tt.HasText("Message n2") || a.res.Unread() != 2 {
|
||||
t.Fatalf("followed while scrolled up, unread %d", a.res.Unread())
|
||||
}
|
||||
if err := tt.Click("Scroll to latest, 2 new"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tt.Frame()
|
||||
tt.Frame()
|
||||
if !tt.HasText("Message n2") || a.res.Unread() != 0 {
|
||||
t.Fatalf("jump did not reach the end, unread %d", a.res.Unread())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageListKeepsPlaceByID(t *testing.T) {
|
||||
a := newMessageListApp(50)
|
||||
tt := ui.NewTester(a.view, 400, 300)
|
||||
tt.Frame()
|
||||
tt.Scroll(200, 150, 0, -600)
|
||||
tt.Frame()
|
||||
r, _ := tt.Find("Message m40")
|
||||
// Older messages load above; the one in view stays put.
|
||||
a.ids = append([]string{"old1", "old2", "old3"}, a.ids...)
|
||||
tt.Frame()
|
||||
r2, ok := tt.Find("Message m40")
|
||||
if !ok || r2.Y != r.Y {
|
||||
t.Fatalf("m40 moved from %v to %v (found %v)", r.Y, r2.Y, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageListDaySeparators(t *testing.T) {
|
||||
a := newMessageListApp(6)
|
||||
tt := ui.NewTester(a.view, 400, 600)
|
||||
tt.Frame()
|
||||
for _, d := range []string{"Mar 1, 2026", "Mar 2, 2026"} {
|
||||
if !tt.HasText(d) {
|
||||
t.Errorf("missing separator %q in %q", d, tt.Texts())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageListNeedsID(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic without ID")
|
||||
}
|
||||
}()
|
||||
var s MessageListState
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
MessageList(c, &s, 1, MessageListOptions{}, func(int) {})
|
||||
}, 200, 100)
|
||||
}
|
||||
|
||||
func TestMessageListSnapshot(t *testing.T) {
|
||||
snapshot(t, "message_list", 420, 320, func(c *ui.Context) {
|
||||
(*ui.Local(c.Root(), "app", func() *messageListApp { return newMessageListApp(6) })).view(c)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package mujica
|
||||
|
||||
func init() {
|
||||
addMessages(map[string][2]string{
|
||||
"message_bubble.user": {"You", "你"},
|
||||
"message_bubble.assistant": {"Assistant", "助手"},
|
||||
"message_bubble.system": {"System", "系统"},
|
||||
"message_bubble.error": {"Error", "错误"},
|
||||
"message_header.time": {"15:04", "15:04"},
|
||||
"message_header.sending": {"Sending", "发送中"},
|
||||
"message_header.streaming": {"Generating", "生成中"},
|
||||
"message_header.sent": {"Sent", "已发送"},
|
||||
"message_header.failed": {"Failed", "失败"},
|
||||
"message_list.label": {"Messages", "消息"},
|
||||
"message_list.date": {"Jan 2, 2006", "2006年1月2日"},
|
||||
"message_list.today": {"Today", "今天"},
|
||||
"message_list.yesterday": {"Yesterday", "昨天"},
|
||||
"scroll_to_bottom_button.label": {"Scroll to latest", "回到最新"},
|
||||
"scroll_to_bottom_button.unread": {"Scroll to latest, %d new", "回到最新,%d 条新消息"},
|
||||
"code_block.plain": {"text", "文本"},
|
||||
"code_block.wrap": {"Wrap lines", "自动换行"},
|
||||
"code_block.copy": {"Copy code", "复制代码"},
|
||||
"code_block.copied": {"Copied", "已复制"},
|
||||
"code_block.collapse": {"Collapse", "折叠"},
|
||||
"code_block.expand": {"Show %d lines", "展开 %d 行"},
|
||||
"table_block.title": {"Table", "表格"},
|
||||
"table_block.copy": {"Copy as TSV", "复制为 TSV"},
|
||||
"file_message.open": {"Open %s, %s", "打开 %s,%s"},
|
||||
"file_message.download": {"Download %s", "下载 %s"},
|
||||
"file_message.bytes": {"%d B", "%d 字节"},
|
||||
"audio_message.play": {"Play", "播放"},
|
||||
"audio_message.pause": {"Pause", "暂停"},
|
||||
"audio_message.seek": {"Playback position", "播放位置"},
|
||||
"quote_reply.label": {"Reply to %s: %s", "回复 %s:%s"},
|
||||
"message_actions.label": {"Message actions", "消息操作"},
|
||||
"message_actions.copy": {"Copy", "复制"},
|
||||
"message_actions.regenerate": {"Regenerate", "重新生成"},
|
||||
"message_actions.edit": {"Edit", "编辑"},
|
||||
"message_actions.like": {"Good response", "赞"},
|
||||
"message_actions.dislike": {"Bad response", "踩"},
|
||||
"message_actions.speak": {"Read aloud", "朗读"},
|
||||
"message_actions.share": {"Share", "分享"},
|
||||
"message_editor.label": {"Edit message", "编辑消息"},
|
||||
"message_editor.cancel": {"Cancel", "取消"},
|
||||
"message_editor.save": {"Save", "保存"},
|
||||
"branch_navigator.label": {"Branch %d of %d", "第 %d 个分支,共 %d 个"},
|
||||
"branch_navigator.previous": {"Previous branch", "上一个分支"},
|
||||
"branch_navigator.next": {"Next branch", "下一个分支"},
|
||||
"error_message.title": {"Something went wrong", "出错了"},
|
||||
"error_message.retry": {"Retry", "重试"},
|
||||
"error_message.wait": {"Retry in %d s", "%d 秒后重试"},
|
||||
"error_message.limited": {"Rate limited", "已限流"},
|
||||
"thinking_block.done": {"Thought for %d s", "思考了 %d 秒"},
|
||||
"thinking_block.thinking": {"Thinking… %d s", "思考中… %d 秒"},
|
||||
"typing_indicator.label": {"%s is typing…", "%s 正在输入…"},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// QuoteReply shows the author and a one-line excerpt of a quoted message; Clicked jumps to it.
|
||||
func QuoteReply(c *ui.Context, author, excerpt string) *ui.Element {
|
||||
if author == "" {
|
||||
panic("mujica: QuoteReply needs an author")
|
||||
}
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
line := strings.Join(strings.Fields(excerpt), " ")
|
||||
b := ui.ButtonBase(c).MinHeight(theme.MinHitSize).MaxWidth(420).Gap(u*2).Padding(u, u*3, u, u*2).Justify(ui.Start).
|
||||
Radius(theme.ControlRadius).Background(k.SurfaceHover).BorderWidth(0, 0, 0, 3).BorderColor(k.AccentText).
|
||||
Label(fmt.Sprintf(msg(c, "quote_reply.label"), author, line)).Transition(colorFade)
|
||||
internal.Ring(b, k.Focus, theme.ControlRadius)
|
||||
if b.Hovered() {
|
||||
b.Background(k.Selection)
|
||||
}
|
||||
b.Children(func() {
|
||||
ui.Text(c, author).FontWeight(600).FontSize(FontSize(c, theme.CaptionSize)).SingleLine().Shrink(0)
|
||||
ui.Text(c, line).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).SingleLine().MinWidth(0).Shrink(1)
|
||||
})
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func TestQuoteReplyJumpsAndFlattensExcerpt(t *testing.T) {
|
||||
jumps, off := 0, false
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Column(c).Disabled(off).Children(func() {
|
||||
if QuoteReply(c, "Marie", "Let them\n eat cake.").Clicked() {
|
||||
jumps++
|
||||
}
|
||||
})
|
||||
}, 400, 200)
|
||||
if !tt.HasText("Let them eat cake.") {
|
||||
t.Fatalf("excerpt %q", tt.Texts())
|
||||
}
|
||||
_ = tt.Click("Reply to Marie: Let them eat cake.")
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeySpace)
|
||||
if jumps != 2 {
|
||||
t.Fatalf("jumps %d", jumps)
|
||||
}
|
||||
off = true
|
||||
_ = tt.Click("Reply to Marie: Let them eat cake.")
|
||||
if jumps != 2 {
|
||||
t.Fatal("disabled quote jumped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuoteReplyNeedsAuthor(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic without an author")
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
QuoteReply(c, "", "x")
|
||||
}, 100, 100)
|
||||
}
|
||||
|
||||
func TestQuoteReplySnapshot(t *testing.T) {
|
||||
snapshot(t, "quote_reply", 420, 70, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
QuoteReply(c, "Herald", "The ball begins at nine in the Hall of Mirrors, and every guest must arrive by the east door.")
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// ScrollToBottomButton shows while away is true, with the unread count; click it to return to the end.
|
||||
// While hidden it returns an empty element whose Clicked is false.
|
||||
func ScrollToBottomButton(c *ui.Context, away bool, unread int) *ui.Element {
|
||||
if unread < 0 {
|
||||
panic("mujica: ScrollToBottomButton unread must not be negative")
|
||||
}
|
||||
if !away {
|
||||
return ui.Box(c).Absolute().Invisible()
|
||||
}
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
label := msg(c, "scroll_to_bottom_button.label")
|
||||
if unread > 0 {
|
||||
label = fmt.Sprintf(msg(c, "scroll_to_bottom_button.unread"), unread)
|
||||
}
|
||||
wrap := ui.Row(c).Absolute().Left(0).Right(0).Bottom(u * 3).Justify(ui.Center).PassThrough()
|
||||
var b *ui.Element
|
||||
wrap.Children(func() {
|
||||
b = ui.ButtonBase(c).MinHeight(theme.MinHitSize).MinWidth(theme.MinHitSize).Padding(0, u*3).Gap(u*2).
|
||||
Radius(theme.MinHitSize/2).Label(label).Background(k.Surface).Border(theme.BorderWidth, k.ControlBorder).
|
||||
TextColor(k.Text).Shadow(0, 2, 8, 0, ui.RGBA(0, 0, 0, 0.14)).Transition(colorFade)
|
||||
b.Opacity(overlayEnter(c, b))
|
||||
internal.Ring(b, k.Focus, theme.MinHitSize/2)
|
||||
if b.Hovered() {
|
||||
b.Background(k.SurfaceHover)
|
||||
}
|
||||
b.Children(func() {
|
||||
ui.Icon(c, icons.Must("arrow-down")).Size(16, 16)
|
||||
if unread > 0 {
|
||||
ui.Text(c, fmt.Sprint(unread)).FontFeatures("tnum").FontWeight(600).TextColor(k.AccentText).SingleLine()
|
||||
}
|
||||
})
|
||||
})
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func TestScrollToBottomButtonShowsWhenAway(t *testing.T) {
|
||||
away, unread, clicks := false, 0, 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Box(c).Fill().Children(func() {
|
||||
if ScrollToBottomButton(c, away, unread).Clicked() {
|
||||
clicks++
|
||||
}
|
||||
})
|
||||
}, 300, 200)
|
||||
if _, ok := tt.Find("Scroll to latest"); ok {
|
||||
t.Fatal("shown while at the bottom")
|
||||
}
|
||||
away, unread = true, 4
|
||||
tt.Frame()
|
||||
if !tt.HasText("4") {
|
||||
t.Fatalf("unread count missing: %q", tt.Texts())
|
||||
}
|
||||
_ = tt.Click("Scroll to latest, 4 new")
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if clicks != 2 {
|
||||
t.Fatalf("clicks %d", clicks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScrollToBottomButtonNegativeUnread(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic for negative unread")
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ScrollToBottomButton(c, true, -1)
|
||||
}, 100, 100)
|
||||
}
|
||||
|
||||
func TestScrollToBottomButtonSnapshot(t *testing.T) {
|
||||
snapshot(t, "scroll_to_bottom_button", 240, 80, func(c *ui.Context) {
|
||||
ui.Box(c).Fill().Children(func() { ScrollToBottomButton(c, true, 12) })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
// StreamingTextOptions configure a StreamingText.
|
||||
type StreamingTextOptions struct {
|
||||
// Streaming shows the cursor after the text while more is coming.
|
||||
Streaming bool
|
||||
// Smooth reveals new text gradually instead of at once.
|
||||
Smooth bool
|
||||
// Rate is the smooth reveal speed in characters per second; zero means 80.
|
||||
Rate float64
|
||||
}
|
||||
|
||||
// streamingState is how much of the text shows, and since when.
|
||||
type streamingState struct {
|
||||
text string
|
||||
shown float64
|
||||
at time.Time
|
||||
}
|
||||
|
||||
// StreamingText shows text that grows as it streams; reduced motion shows it at once.
|
||||
func StreamingText(c *ui.Context, text string, opts StreamingTextOptions) *ui.Element {
|
||||
if opts.Rate < 0 {
|
||||
panic("mujica: StreamingText Rate must not be negative")
|
||||
}
|
||||
rate := opts.Rate
|
||||
if rate == 0 {
|
||||
rate = 80
|
||||
}
|
||||
k := Tokens(c)
|
||||
e := ui.Box(c).Label(text)
|
||||
st := ui.Local(e, "stream", func() streamingState {
|
||||
return streamingState{text: text, shown: float64(utf8.RuneCountInString(text)), at: c.Now()}
|
||||
})
|
||||
total := float64(utf8.RuneCountInString(text))
|
||||
if !strings.HasPrefix(text, st.text) {
|
||||
// Replaced text starts over from what it shares.
|
||||
st.shown = min(st.shown, float64(utf8.RuneCountInString(streamingCommon(st.text, text))))
|
||||
}
|
||||
st.text = text
|
||||
now := c.Now()
|
||||
if !opts.Smooth || Motion(c, time.Second) == 0 {
|
||||
st.shown = total
|
||||
} else if st.shown < total {
|
||||
st.shown = min(total, st.shown+now.Sub(st.at).Seconds()*rate)
|
||||
c.AnimationFrame()
|
||||
}
|
||||
st.at = now
|
||||
shown := text
|
||||
if n := int(st.shown); n < int(total) {
|
||||
shown = string([]rune(text)[:n])
|
||||
}
|
||||
spans := []ui.Span{{Text: shown}}
|
||||
if opts.Streaming {
|
||||
spans = append(spans, ui.Span{Text: "▍", Color: k.AccentText})
|
||||
}
|
||||
return e.Children(func() { ui.RichText(c, spans...) })
|
||||
}
|
||||
|
||||
// streamingCommon returns the longest common prefix of a and b, on rune boundaries.
|
||||
func streamingCommon(a, b string) string {
|
||||
n := 0
|
||||
for n < len(a) && n < len(b) && a[n] == b[n] {
|
||||
n++
|
||||
}
|
||||
for n > 0 && n < len(a) && !utf8.RuneStart(a[n]) {
|
||||
n--
|
||||
}
|
||||
return a[:n]
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func streamingTester(text *string, opts StreamingTextOptions, reduce bool) *ui.Tester {
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
StreamingText(c, *text, opts)
|
||||
}, 400, 200)
|
||||
tt.SetPreferences(ui.Preferences{ReduceMotion: reduce, TextScale: 1})
|
||||
tt.Frame()
|
||||
return tt
|
||||
}
|
||||
|
||||
// streamingShown returns the visible text of the stream.
|
||||
func streamingShown(tt *ui.Tester) string {
|
||||
texts := tt.Texts()
|
||||
return texts[len(texts)-1]
|
||||
}
|
||||
|
||||
func TestStreamingTextSmoothReveal(t *testing.T) {
|
||||
text := "Hello"
|
||||
tt := streamingTester(&text, StreamingTextOptions{Smooth: true, Streaming: true, Rate: 1}, false)
|
||||
if !strings.HasPrefix(streamingShown(tt), "Hello") || !strings.HasSuffix(streamingShown(tt), "▍") {
|
||||
t.Fatalf("first text %q", streamingShown(tt))
|
||||
}
|
||||
text += ", court"
|
||||
tt.Frame()
|
||||
if s := streamingShown(tt); strings.Contains(s, "court") {
|
||||
t.Fatalf("1 char/s revealed %q at once", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamingTextInstantAndReducedMotion(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
opts StreamingTextOptions
|
||||
reduce bool
|
||||
}{{StreamingTextOptions{}, false}, {StreamingTextOptions{Smooth: true, Rate: 1}, true}} {
|
||||
text := "Hello"
|
||||
tt := streamingTester(&text, tc.opts, tc.reduce)
|
||||
text += ", court"
|
||||
tt.Frame()
|
||||
if s := streamingShown(tt); s != "Hello, court" {
|
||||
t.Errorf("%+v reduce %v: shows %q", tc.opts, tc.reduce, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamingTextReplacedRestarts(t *testing.T) {
|
||||
text := "abcdef"
|
||||
tt := streamingTester(&text, StreamingTextOptions{Smooth: true, Rate: 1}, false)
|
||||
text = "abXYZ"
|
||||
tt.Frame()
|
||||
if s := streamingShown(tt); s != "ab" {
|
||||
t.Fatalf("replaced text shows %q, want the shared prefix", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamingTextNegativeRate(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic for a negative rate")
|
||||
}
|
||||
}()
|
||||
text := ""
|
||||
streamingTester(&text, StreamingTextOptions{Rate: -1}, false)
|
||||
}
|
||||
|
||||
func TestStreamingTextSnapshot(t *testing.T) {
|
||||
snapshot(t, "streaming_text", 360, 90, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
StreamingText(c, "The herald reads the decree, word by word", StreamingTextOptions{Streaming: true, Smooth: true})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// TableBlockOptions configure a TableBlock.
|
||||
type TableBlockOptions struct {
|
||||
// Align sets each column's alignment: ui.Start, ui.Center or ui.End; missing columns start.
|
||||
Align []ui.Align
|
||||
}
|
||||
|
||||
// TableBlock shows a small table inside a message; it scrolls sideways and copies as TSV.
|
||||
func TableBlock(c *ui.Context, header []string, rows [][]string, opts TableBlockOptions) CopyResult {
|
||||
n := len(header)
|
||||
if n == 0 {
|
||||
panic("mujica: TableBlock needs a header")
|
||||
}
|
||||
if len(opts.Align) > n {
|
||||
panic("mujica: TableBlock has more alignments than columns")
|
||||
}
|
||||
for _, row := range rows {
|
||||
if len(row) != n {
|
||||
panic("mujica: TableBlock row width differs from the header")
|
||||
}
|
||||
}
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
var r CopyResult
|
||||
r.Element = ui.Column(c).Radius(theme.CardRadius).Border(theme.BorderWidth, k.Border).Background(k.Surface).Clip().MinWidth(0)
|
||||
copiedAt := ui.Local(r.Element, "copied", func() time.Time { return time.Time{} })
|
||||
r.Element.Children(func() {
|
||||
ui.Row(c).Padding(0, u, 0, u*3).AlignItems(ui.Center).Background(k.SurfaceHover).Children(func() {
|
||||
ui.Text(c, msg(c, "table_block.title")).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).Grow(1)
|
||||
icon, name := "copy", msg(c, "table_block.copy")
|
||||
if left := codeBlockCopiedFor - c.Now().Sub(*copiedAt); left > 0 {
|
||||
icon, name = "check", msg(c, "code_block.copied")
|
||||
c.After(left)
|
||||
}
|
||||
if iconAction(c, icons.Must(icon), name).Clicked() && !r.Element.IsDisabled() {
|
||||
c.WriteClipboard(tableBlockTSV(header, rows))
|
||||
*copiedAt, r.copied = c.Now(), true
|
||||
c.Announce(msg(c, "code_block.copied"))
|
||||
slog.Debug("mujica: table_block copy", "rows", len(rows))
|
||||
}
|
||||
})
|
||||
tracks := make([]ui.Track, n)
|
||||
for i := range tracks {
|
||||
tracks[i] = ui.FitContent()
|
||||
}
|
||||
sc := ui.ScrollHorizontal(c).AlignItems(ui.Start)
|
||||
// Rows span the block: the grid is at least last frame's scroll width.
|
||||
w := sc.Bounds().W
|
||||
internal.Remeasure(c, sc, w, sc.Bounds().H)
|
||||
sc.Children(func() {
|
||||
ui.Grid(c).ColumnTracks(tracks...).MinWidth(w).Children(func() {
|
||||
for i, h := range header {
|
||||
tableBlockCell(c, h, tableBlockAlign(opts.Align, i), true, false)
|
||||
}
|
||||
for ri, row := range rows {
|
||||
for i, cell := range row {
|
||||
tableBlockCell(c, cell, tableBlockAlign(opts.Align, i), false, ri == len(rows)-1)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
func tableBlockAlign(a []ui.Align, i int) ui.Align {
|
||||
if i < len(a) {
|
||||
return a[i]
|
||||
}
|
||||
return ui.Start
|
||||
}
|
||||
|
||||
func tableBlockCell(c *ui.Context, s string, align ui.Align, head, last bool) {
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
cell := ui.Box(c).Padding(u*2, u*3).Role(ui.RoleCell)
|
||||
if !last {
|
||||
cell.BorderWidth(0, 0, theme.BorderWidth, 0).BorderColor(k.Border)
|
||||
}
|
||||
if head {
|
||||
cell.Role(ui.RoleColumnHeader)
|
||||
}
|
||||
cell.Children(func() {
|
||||
t := ui.Text(c, tableBlockClean.Replace(s)).NoWrap().TextAlign(align)
|
||||
if head {
|
||||
t.FontWeight(600)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// tableBlockClean turns a cell's tabs and newlines into spaces.
|
||||
var tableBlockClean = strings.NewReplacer("\t", " ", "\r\n", " ", "\n", " ")
|
||||
|
||||
// tableBlockTSV joins cells with tabs and rows with newlines.
|
||||
func tableBlockTSV(header []string, rows [][]string) string {
|
||||
clean := tableBlockClean
|
||||
var b strings.Builder
|
||||
line := func(cells []string) {
|
||||
for i, s := range cells {
|
||||
if i > 0 {
|
||||
b.WriteByte('\t')
|
||||
}
|
||||
b.WriteString(clean.Replace(s))
|
||||
}
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
line(header)
|
||||
for _, row := range rows {
|
||||
line(row)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
var (
|
||||
tableBlockHeader = []string{"Guest", "Title", "Seat"}
|
||||
tableBlockRows = [][]string{{"Marie", "Queen", "1"}, {"Louis", "King", "2"}, {"Axel", "Count\tof Fersen", "14"}}
|
||||
)
|
||||
|
||||
func TestTableBlockCopiesTSVAndScrolls(t *testing.T) {
|
||||
copies := 0
|
||||
var w float32
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Column(c).Width(160).Children(func() {
|
||||
r := TableBlock(c, tableBlockHeader, tableBlockRows, TableBlockOptions{Align: []ui.Align{ui.Start, ui.Start, ui.End}})
|
||||
w = r.Element.Bounds().W
|
||||
if r.Copied() {
|
||||
copies++
|
||||
}
|
||||
})
|
||||
}, 400, 300)
|
||||
tt.Frame()
|
||||
if w > 160 {
|
||||
t.Fatalf("table widened its message to %v", w)
|
||||
}
|
||||
if err := tt.Click("Copy as TSV"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := "Guest\tTitle\tSeat\nMarie\tQueen\t1\nLouis\tKing\t2\nAxel\tCount of Fersen\t14\n"
|
||||
if tt.Clipboard() != want || copies != 1 {
|
||||
t.Fatalf("copies %d clipboard %q", copies, tt.Clipboard())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTableBlockBadRows(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic for a short row")
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
TableBlock(c, []string{"a", "b"}, [][]string{{"1"}}, TableBlockOptions{})
|
||||
}, 100, 100)
|
||||
}
|
||||
|
||||
func TestTableBlockSnapshot(t *testing.T) {
|
||||
snapshot(t, "table_block", 400, 220, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
TableBlock(c, tableBlockHeader, tableBlockRows, TableBlockOptions{Align: []ui.Align{ui.Start, ui.Start, ui.End}})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 8.2 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 8.4 KiB |